Files
paseo/packages/website/src/plugins/registry.test.ts
T
Mohamed Boudraandpaseo-bot[bot] 8bc5f47b1c Add a Report a bug button to plugin detail pages (#6721)
Links to a new issue on the plugin's GitHub repository, from the registry's repository URL. Monorepo folder URLs resolve to the repository; non-GitHub sources show no button.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 20:51:22 +07:00

353 lines
14 KiB
TypeScript

import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import {
addedAgo,
featuredPlugins,
formatInstalls,
installCommand,
mostInstalled,
newestFirst,
type Plugin,
readmeBody,
recentlyUpdated,
reportBugUrl,
searchPlugins,
updatedAgo,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
const plugin: Plugin = {
id: "acme/example",
name: "Example",
description: "An example",
categories: ["themes"],
author: { github: "acme" },
repository: { url: "https://github.com/acme/example" },
artifact: {
kind: "npm",
package: "paseo-example",
version: "1.2.3",
resolved: "https://registry.npmjs.org/example.tgz",
integrity: "sha512-YWJj",
},
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
installs: 42,
};
describe("plugin registry", () => {
it("loads a directory and a detail from a static HTTP registry", async () => {
const index = {
schemaVersion: 1,
registry: { name: "Internal", url: "https://example.test" },
categories: [],
plugins: [plugin],
featured: [plugin.id],
generatedAt: "2026-10-03",
};
const detail = { ...plugin, readme: "# Example" };
const server = createServer((request, response) => {
const value = request.url === "/index.json" ? index : detail;
response.end(JSON.stringify(value));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing address");
const base = `http://127.0.0.1:${address.port}`;
const context = { cache: null, waitUntil: () => undefined };
try {
expect(await loadRegistryIndex(base, context)).toEqual(index);
expect(await loadRegistryPlugin(base, plugin.id, context)).toEqual(detail);
await expect(loadRegistryPlugin(base, "acme/wrong", context)).rejects.toThrow("different ID");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("features listed plugins once each, in the registry's order, dropping IDs that are not listed", () => {
const listed = [
{ ...plugin, id: "acme/first" },
{ ...plugin, id: "acme/second" },
{ ...plugin, id: "acme/third" },
];
const featured = ["acme/third", "acme/not-published", "acme/first", "acme/third"];
expect(featuredPlugins(listed, featured).map((p) => p.id)).toEqual([
"acme/third",
"acme/first",
]);
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
});
it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => {
const listedFirst = {
...plugin,
id: "acme/listed-first",
submittedAt: "2026-09-30T00:00:00.000Z",
publishedAt: "2026-09-30T06:00:00.000Z",
reviewedAt: "2026-10-02T00:00:00.000Z",
updatedAt: "2026-10-02T00:00:00.000Z",
};
const listedLater = {
...plugin,
id: "acme/listed-later",
submittedAt: "2026-09-02T00:00:00.000Z",
publishedAt: "2026-09-30T08:00:00.000Z",
reviewedAt: "2026-09-30T08:00:00.000Z",
updatedAt: "2026-09-30T08:00:00.000Z",
};
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
"acme/listed-later",
"acme/listed-first",
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(updatedAgo(listedFirst, now)).toBe("yesterday");
});
it("lists only plugins updated after their first listing as recently updated, latest update first", () => {
const bumped = {
...plugin,
id: "acme/bumped",
publishedAt: "2026-09-10T00:00:00.000Z",
updatedAt: "2026-10-01T09:00:00.000Z",
};
const bumpedLater = {
...plugin,
id: "acme/bumped-later",
publishedAt: "2026-09-20T00:00:00.000Z",
updatedAt: "2026-10-01T17:00:00.000Z",
};
const neverUpdated = {
...plugin,
id: "acme/never-updated",
publishedAt: "2026-10-02T00:00:00.000Z",
updatedAt: "2026-10-02T00:00:00.000Z",
};
expect(recentlyUpdated([bumped, neverUpdated, bumpedLater]).map((p) => p.id)).toEqual([
"acme/bumped-later",
"acme/bumped",
]);
});
it("orders by first listing time and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" };
const twin = { ...plugin, id: "acme/twin" };
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
"acme/example",
"acme/twin",
"acme/older",
]);
const installs = {
"acme/example": { week: 1, month: 5, all: 9 },
"acme/older": { week: 3, month: 3, all: 3 },
"acme/twin": { week: 1, month: 1, all: 1 },
};
expect(mostInstalled([plugin, older, twin], installs, "week").map((p) => p.id)).toEqual([
"acme/older",
"acme/example",
"acme/twin",
]);
expect(mostInstalled([plugin, older, twin], installs, "month")[0].id).toBe("acme/example");
});
it("counts installs this week, this month, and all time from the daily record", async () => {
const cache = memoryKv();
const day = (date: string) => new Date(`${date}T12:00:00.000Z`);
await cache.put("plugin-installs:acme/example", "40");
await recordInstall(cache, "acme/example", day("2026-07-01"));
await recordInstall(cache, "acme/example", day("2026-09-10"));
await recordInstall(cache, "acme/example", day("2026-09-30"));
await recordInstall(cache, "acme/example", day("2026-10-04"));
expect(await readInstallCounts(cache, ["acme/example"], day("2026-10-04"))).toEqual({
"acme/example": { week: 2, month: 3, all: 44 },
});
expect(
Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")),
).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]);
});
it("counts repeated install reports from the same IP once per hour", async () => {
let now = Date.now();
const cache = memoryKv(() => now);
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, headers);
now += 3_599_000;
await resolvePlugin(cache, headers);
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 1, month: 1, all: 1 },
});
now += 1_000;
await resolvePlugin(cache, headers);
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 2, month: 2, all: 2 },
});
});
it("counts different IPs and plugins independently, including query install intent", async () => {
const cache = memoryKv();
const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, first);
await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" });
await resolvePlugin(cache, first, { id: "acme/other" });
await resolvePlugin(cache, first, { query: "?intent=install" });
await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true });
expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({
[plugin.id]: { week: 2, month: 2, all: 2 },
"acme/other": { week: 1, month: 1, all: 1 },
});
const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex");
expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1");
expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1");
expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1");
});
it("does not count without a Cloudflare IP or install intent", async () => {
const cache = memoryKv();
await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" });
await resolvePlugin(cache, {}, { query: "?intent=install" });
await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" });
await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" });
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 0, month: 0, all: 0 },
});
expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]);
});
it("preserves historical totals and the installs endpoint response", async () => {
const cache = memoryKv();
await cache.put(`plugin-installs:${plugin.id}`, "40");
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, headers);
await resolvePlugin(cache, headers);
const base = "https://registry.example.test";
await cache.put(
`plugins:index:v1:${base}`,
JSON.stringify({
fetchedAt: Date.now(),
value: {
schemaVersion: 1,
registry: { name: "Internal", url: base },
categories: [],
plugins: [plugin],
featured: [],
generatedAt: "2026-10-03",
},
}),
);
const response = await handlePluginRegistryRequest(
new Request("https://paseo.sh/api/plugins/installs"),
{ PLUGINS_REGISTRY_URL: base },
{ cache, waitUntil: () => undefined },
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({ [plugin.id]: 41 });
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 1, month: 1, all: 41 },
});
});
it("searches name, description, ID, and author, ignoring case", () => {
const other = {
...plugin,
id: "zed/other",
name: "Other",
description: "Unrelated",
author: { github: "zed" },
};
const plugins = [plugin, other];
expect(searchPlugins(plugins, "EXAMPLE").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, "unrelated").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "zed/").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "acme").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, " ")).toEqual(plugins);
expect(searchPlugins(plugins, "nothing")).toEqual([]);
});
it("ranks name, then author, then description matches, keeping the given order on ties", () => {
const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({
...plugin,
id,
name,
description,
author: { github },
});
// Given in install order: description-only matches first, the exact name last.
const plugins = [
named("acme/editor", "Remote Editor", "Composer pill to open a workspace"),
named("acme/kit", "PromptKit", "Rewrite prompts"),
named("omp/tools", "Tools", "Helpers", "omp"),
named("acme/history", "History", "Inspect raw composer records"),
named("acme/omp", "OMP", "Paseo integration for OMP"),
];
expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([
"acme/omp",
"acme/kit",
"omp/tools",
"acme/editor",
"acme/history",
]);
expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]);
expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]);
});
it("strips the README title and quoted description that the page already shows", () => {
expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n");
expect(readmeBody("## Usage\n\n> Note\n")).toBe("## Usage\n\n> Note\n");
expect(readmeBody("# Example\n\n> [!WARNING]\n> Needs Docker\n\n## Usage\n")).toBe(
"> [!WARNING]\n> Needs Docker\n\n## Usage\n",
);
});
it("offers a registry install command", () => {
expect(installCommand(plugin)).toBe("paseo plugin add acme/example");
expect(formatInstalls(1250)).toBe("1.3k");
});
it("reports bugs on the source repository's GitHub issue tracker", () => {
const at = (url: string) => reportBugUrl({ ...plugin, repository: { url } });
expect(at("https://github.com/acme/example")).toBe(
"https://github.com/acme/example/issues/new",
);
expect(at("https://github.com/acme/plugins.git")).toBe(
"https://github.com/acme/plugins/issues/new",
);
expect(at("https://github.com/acme/plugins/tree/HEAD/packages/example")).toBe(
"https://github.com/acme/plugins/issues/new",
);
expect(at("https://gitlab.com/acme/example")).toBeUndefined();
expect(at("javascript:alert(1)")).toBeUndefined();
});
});
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } {
const values = new Map<string, string>();
const expiresAt = new Map<string, number>();
const kv = {
values,
get: async (key: string, options?: { type?: string }) => {
if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key);
const value = values.get(key) ?? null;
return options?.type === "json" && value !== null ? JSON.parse(value) : value;
},
put: async (key: string, value: string, options?: { expirationTtl?: number }) => {
values.set(key, value);
expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity);
},
};
return kv as unknown as KVNamespace & { values: Map<string, string> };
}
async function resolvePlugin(
cache: KVNamespace,
headers: HeadersInit,
{ id = plugin.id, query = "", staticDetail = false } = {},
): Promise<void> {
const base = "https://registry.example.test";
const detail = { ...plugin, id, readme: "# Example" };
await cache.put(
`plugins:detail:v1:${base}:${id}`,
JSON.stringify({ fetchedAt: Date.now(), value: detail }),
);
const pending: Promise<unknown>[] = [];
const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`;
const response = await handlePluginRegistryRequest(
new Request(`https://paseo.sh${path}${query}`, { headers }),
{ PLUGINS_REGISTRY_URL: base },
{ cache, waitUntil: (promise) => pending.push(promise) },
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual(detail);
await Promise.all(pending);
}