diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-03 05:20:15 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-04 03:58:21 +0300 |
| commit | b12da23918ff94099e7fcedd7db79f3d2fa8fa6f (patch) | |
| tree | f58d7e0c1058414c7292d56257aace56eb4e079a /workers/plugin-marketplace/src/index.test.ts | |
| parent | e7c96206f716e5772971af2861c04168af2bb396 (diff) | |
feat(plugins): index marketplace manifests
Diffstat (limited to 'workers/plugin-marketplace/src/index.test.ts')
| -rw-r--r-- | workers/plugin-marketplace/src/index.test.ts | 717 |
1 files changed, 532 insertions, 185 deletions
diff --git a/workers/plugin-marketplace/src/index.test.ts b/workers/plugin-marketplace/src/index.test.ts index b84ed14d..9e3df19b 100644 --- a/workers/plugin-marketplace/src/index.test.ts +++ b/workers/plugin-marketplace/src/index.test.ts @@ -1,20 +1,44 @@ import { describe, expect, test } from "bun:test"; -import worker, { normalizeRepositories, refreshPlugins, type Env } from "./index"; +import worker, { + normalizeRepositories, + parseManifestSummary, + refreshPlugins, + type Env, +} from "./index"; + +const HEAD_COMMIT = "a".repeat(40); +const SECOND_COMMIT = "b".repeat(40); + +type TreeFixture = { + path: string; + content?: string; + mode?: string; + type?: string; + size?: number; +}; class MemoryR2 { objects = new Map<string, { value: string; options: unknown }>(); - async put(key: string, value: string, options?: unknown): Promise<void> { this.objects.set(key, { value, options }); } + + async get(key: string): Promise<{ text(): Promise<string> } | null> { + const object = this.objects.get(key); + return object + ? { + async text() { + return object.value; + }, + } + : null; + } } class MemoryKV { constructor(private readonly keyNames: string[]) {} - async list(options?: { prefix?: string }): Promise<{ - keys: Array<{ name: string }>; - }> { + async list(options?: { prefix?: string }): Promise<{ keys: Array<{ name: string }> }> { return { keys: this.keyNames .filter((name) => !options?.prefix || name.startsWith(options.prefix)) @@ -29,8 +53,9 @@ function repo(overrides: Record<string, unknown> = {}): Record<string, unknown> full_name: "ogulcancelik/herdr-plugin-example", owner: { login: "ogulcancelik" }, name: "herdr-plugin-example", - description: "Example plugin", + description: "Example plugin repository", html_url: "https://github.com/ogulcancelik/herdr-plugin-example", + default_branch: "main", stargazers_count: 5, forks_count: 1, open_issues_count: 0, @@ -48,6 +73,17 @@ function repo(overrides: Record<string, unknown> = {}): Record<string, unknown> }; } +function manifest(overrides = ""): string { + return ` +id = "example.plugin" +name = "Example Plugin" +version = "0.2.0" +min_herdr_version = "0.7.0" +description = "Example manifest" +platforms = ["linux", "macos"] +${overrides}`; +} + function env(bucket = new MemoryR2(), blacklist?: MemoryKV): Env { return { PLUGIN_MARKETPLACE_BUCKET: bucket, @@ -56,62 +92,137 @@ function env(bucket = new MemoryR2(), blacklist?: MemoryKV): Env { }; } +function repositoryFetch(options: { + repositories: Record<string, unknown>[]; + trees?: Record<string, TreeFixture[]>; + commits?: Record<string, string>; + totalCount?: number; + incompleteResults?: boolean; + searchStatus?: number; + treeStatus?: Record<string, number>; + truncatedTrees?: Set<string>; + onRequest?: (kind: "search" | "head" | "tree" | "manifest", detail: string) => void; +}): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const url = new URL(input.toString()); + if (url.pathname === "/search/repositories") { + options.onRequest?.("search", url.toString()); + if (options.searchStatus) return new Response("search failed", { status: options.searchStatus }); + return Response.json({ + total_count: options.totalCount ?? options.repositories.length, + incomplete_results: options.incompleteResults ?? false, + items: options.repositories, + }); + } + + const treeMatch = url.pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/git\/trees\/([^/]+)$/); + if (treeMatch) { + const fullName = `${decodeURIComponent(treeMatch[1])}/${decodeURIComponent(treeMatch[2])}`; + options.onRequest?.("tree", fullName); + const status = options.treeStatus?.[fullName]; + if (status) return new Response("tree failed", { status }); + const fixtures = options.trees?.[fullName] ?? [ + { path: "herdr-plugin.toml", content: manifest() }, + ]; + return Response.json({ + sha: "tree-sha", + truncated: options.truncatedTrees?.has(fullName) ?? false, + tree: fixtures.map((fixture) => ({ + path: fixture.path, + mode: fixture.mode ?? "100644", + type: fixture.type ?? "blob", + size: + fixture.size ?? + new TextEncoder().encode(fixture.content ?? "").byteLength, + })), + }); + } + + if (url.pathname === "/graphql") { + const request = JSON.parse(String(init?.body ?? "{}")); + const query = String(request.query ?? ""); + const data: Record<string, unknown> = {}; + if (query.includes("PluginMarketplaceHeads")) { + options.onRequest?.("head", query); + for (const match of query.matchAll( + /repo(\d+): repository\(owner: "([^"]+)", name: "([^"]+)"\)/g, + )) { + const [, alias, owner, name] = match; + const fullName = `${owner}/${name}`; + const repository = options.repositories.find( + (candidate) => candidate.full_name === fullName, + ); + data[`repo${alias}`] = repository + ? { + defaultBranchRef: { + name: repository.default_branch ?? "main", + target: { oid: options.commits?.[fullName] ?? HEAD_COMMIT }, + }, + } + : null; + } + } else if (query.includes("PluginMarketplaceManifests")) { + options.onRequest?.("manifest", query); + for (const match of query.matchAll( + /item(\d+): repository\(owner: "([^"]+)", name: "([^"]+)"\) \{\s+manifest: object\(expression: "[a-f0-9]+:([^"]+)"\)/g, + )) { + const [, alias, owner, name, path] = match; + const fullName = `${owner}/${name}`; + const fixture = (options.trees?.[fullName] ?? [ + { path: "herdr-plugin.toml", content: manifest() }, + ]).find((entry) => entry.path === path); + data[`item${alias}`] = fixture?.content === undefined + ? { manifest: null } + : { manifest: { text: fixture.content } }; + } + } else { + throw new Error(`unexpected GraphQL query: ${query}`); + } + return Response.json({ data }); + } + + throw new Error(`unexpected request: ${url}`); + }) as typeof fetch; +} + describe("normalizeRepositories", () => { - test("normalizes repository fields into the public snapshot schema", () => { - const [plugin] = normalizeRepositories([repo()]); + test("normalizes fields while preserving repository-card ordering", () => { + const plugins = normalizeRepositories([ + repo({ + id: 2, + full_name: "other/newer", + owner: { login: "other" }, + name: "newer", + html_url: "https://github.com/other/newer", + stargazers_count: 5, + pushed_at: "2026-06-04T00:00:00Z", + }), + repo(), + ]); - expect(plugin).toEqual({ + expect(plugins.map((plugin) => plugin.fullName)).toEqual([ + "other/newer", + "ogulcancelik/herdr-plugin-example", + ]); + expect(plugins[1]).toMatchObject({ id: 1, - fullName: "ogulcancelik/herdr-plugin-example", owner: "ogulcancelik", name: "herdr-plugin-example", - description: "Example plugin", - url: "https://github.com/ogulcancelik/herdr-plugin-example", + defaultBranch: "main", stars: 5, - forks: 1, - openIssues: 0, - language: "TypeScript", - topics: ["herdr-plugin"], - createdAt: "2026-06-01T00:00:00Z", - updatedAt: "2026-06-02T00:00:00Z", - pushedAt: "2026-06-03T00:00:00Z", }); }); - test("sorts by stars, pushed date, and full name", () => { + test("deduplicates repositories by immutable GitHub id", () => { const plugins = normalizeRepositories([ - repo({ - id: 1, - full_name: "z/z", - owner: { login: "z" }, - name: "z", - html_url: "https://github.com/z/z", - stargazers_count: 3, - pushed_at: "2026-06-01T00:00:00Z", - }), - repo({ - id: 2, - full_name: "a/a", - owner: { login: "a" }, - name: "a", - html_url: "https://github.com/a/a", - stargazers_count: 3, - pushed_at: "2026-06-02T00:00:00Z", - }), - repo({ - id: 3, - full_name: "m/m", - owner: { login: "m" }, - name: "m", - html_url: "https://github.com/m/m", - stargazers_count: 10, - }), + repo(), + repo({ full_name: "duplicate/name", owner: { login: "duplicate" }, name: "name", html_url: "https://github.com/duplicate/name" }), ]); - - expect(plugins.map((plugin) => plugin.fullName)).toEqual(["m/m", "a/a", "z/z"]); + expect(plugins).toHaveLength(1); + expect(plugins[0].fullName).toBe("ogulcancelik/herdr-plugin-example"); }); - test("drops unsafe urls, archived repositories, forks, disabled repositories, and private repositories", () => { + test("drops unsafe, unavailable, and default-branch-less repositories", () => { const plugins = normalizeRepositories([ repo({ html_url: "https://example.com/ogulcancelik/herdr-plugin-example" }), repo({ archived: true }), @@ -119,62 +230,52 @@ describe("normalizeRepositories", () => { repo({ disabled: true }), repo({ private: true }), repo({ visibility: "private" }), + repo({ default_branch: undefined }), repo({ id: 5 }), ]); - expect(plugins.map((plugin) => plugin.id)).toEqual([5]); }); +}); - test("uses safe defaults for missing nullable fields", () => { - const [plugin] = normalizeRepositories([ - repo({ - id: undefined, - description: undefined, - stargazers_count: undefined, - forks_count: undefined, - open_issues_count: undefined, - language: undefined, - topics: undefined, - created_at: "not a date", - updated_at: undefined, - pushed_at: undefined, - }), - ]); +describe("parseManifestSummary", () => { + test("extracts metadata and accepts the UTF-8 BOM accepted by Herdr", () => { + expect(parseManifestSummary(`\uFEFF${manifest()}`)).toEqual({ + id: "example.plugin", + name: "Example Plugin", + version: "0.2.0", + minHerdrVersion: "0.7.0", + description: "Example manifest", + platforms: ["linux", "macos"], + }); + }); - expect(plugin.id).toBe(0); - expect(plugin.description).toBeNull(); - expect(plugin.stars).toBe(0); - expect(plugin.forks).toBe(0); - expect(plugin.openIssues).toBe(0); - expect(plugin.language).toBeNull(); - expect(plugin.topics).toEqual([]); - expect(plugin.createdAt).toBeNull(); - expect(plugin.updatedAt).toBeNull(); - expect(plugin.pushedAt).toBeNull(); + test("rejects malformed TOML and invalid required metadata", () => { + expect(parseManifestSummary("not = [valid")).toBeNull(); + expect(parseManifestSummary(manifest().replace("example.plugin", "bad/plugin"))).toBeNull(); + expect(parseManifestSummary(manifest().replace("0.7.0", "next"))).toBeNull(); + expect(parseManifestSummary(manifest().replace('["linux", "macos"]', "[]"))).toBeNull(); + expect( + parseManifestSummary(manifest().replace("Example Plugin", "n".repeat(121))), + ).toBeNull(); }); }); describe("refreshPlugins", () => { - test("fetches pages and writes a sorted snapshot to R2 with cache metadata", async () => { - const calls: string[] = []; - const fetch = async (input: RequestInfo | URL): Promise<Response> => { - const url = new URL(input.toString()); - calls.push(url.searchParams.get("page") ?? ""); - const page = url.searchParams.get("page"); - const item = - page === "1" - ? repo({ id: 1, full_name: "b/b", owner: { login: "b" }, name: "b", html_url: "https://github.com/b/b" }) - : repo({ - id: 2, - full_name: "a/a", - owner: { login: "a" }, - name: "a", - html_url: "https://github.com/a/a", - stargazers_count: 9, - }); - return Response.json({ total_count: 2, items: [item] }); - }; + test("publishes one backward-compatible repository card with multiple manifests", async () => { const bucket = new MemoryR2(); + const fullName = "ogulcancelik/herdr-plugin-example"; + const fetch = repositoryFetch({ + repositories: [repo()], + trees: { + [fullName]: [ + { path: "herdr-plugin.toml", content: manifest() }, + { + path: "plugins/second/herdr-plugin.toml", + content: manifest().replace("example.plugin", "example.second").replace("Example Plugin", "Second Plugin"), + }, + ], + }, + }); const result = await refreshPlugins(env(bucket), { fetch, @@ -183,142 +284,389 @@ describe("refreshPlugins", () => { }); expect(result.ok).toBe(true); - expect(calls).toEqual(["1", "2"]); - const object = bucket.objects.get("plugins/index.json"); - expect(object?.options).toEqual({ + const snapshotObject = bucket.objects.get("plugins/index.json"); + const snapshot = JSON.parse(snapshotObject?.value ?? ""); + expect(snapshotObject?.options).toEqual({ httpMetadata: { contentType: "application/json; charset=utf-8", cacheControl: "public, max-age=300, s-maxage=1800, stale-while-revalidate=3600", }, }); - const snapshot = JSON.parse(object?.value ?? ""); - expect(snapshot.generatedAt).toBe("2026-06-20T12:00:00.000Z"); - expect(snapshot.source).toMatchObject({ - provider: "github", - query: "topic:herdr-plugin is:public", - totalCount: 2, - collectedCount: 2, - truncated: false, + expect(snapshot).toMatchObject({ + schemaVersion: 1, + generatedAt: "2026-06-20T12:00:00.000Z", + pluginCount: 2, + repositoryCount: 1, + source: { + missingManifestCount: 0, + invalidManifestCount: 0, + }, }); - expect(snapshot.plugins.map((plugin: { fullName: string }) => plugin.fullName)).toEqual([ - "a/a", - "b/b", + expect(snapshot.plugins[0]).toMatchObject({ + id: 1, + fullName, + name: "herdr-plugin-example", + headCommit: HEAD_COMMIT, + manifests: [ + { path: "herdr-plugin.toml", id: "example.plugin" }, + { path: "plugins/second/herdr-plugin.toml", id: "example.second" }, + ], + }); + expect(snapshot.plugins[0]).not.toHaveProperty("defaultBranch"); + expect(bucket.objects.has("plugins/scan-cache.json")).toBe(true); + expect( + [...bucket.objects.keys()].some((key) => key.includes("history")), + ).toBe(false); + }); + + test("reuses cached manifests without resolving or rescanning an unchanged repository", async () => { + const bucket = new MemoryR2(); + const requests: string[] = []; + const fetch = repositoryFetch({ + repositories: [repo()], + onRequest(kind) { + requests.push(kind); + }, + }); + + expect((await refreshPlugins(env(bucket), { fetch, logger: { error() {} } })).ok).toBe(true); + requests.length = 0; + expect((await refreshPlugins(env(bucket), { fetch, logger: { error() {} } })).ok).toBe(true); + + expect(requests).toEqual(["search"]); + }); + + test("rescans a repository when its pushed timestamp and head change", async () => { + const bucket = new MemoryR2(); + const repository = repo(); + const fullName = String(repository.full_name); + const commits = { [fullName]: HEAD_COMMIT }; + const trees = { [fullName]: [{ path: "herdr-plugin.toml", content: manifest() }] }; + const fetch = repositoryFetch({ repositories: [repository], commits, trees }); + + expect((await refreshPlugins(env(bucket), { fetch, logger: { error() {} } })).ok).toBe(true); + commits[fullName] = SECOND_COMMIT; + repository.pushed_at = "2026-06-04T00:00:00Z"; + trees[fullName][0].content = manifest().replace("0.2.0", "0.3.0"); + + const result = await refreshPlugins(env(bucket), { fetch, logger: { error() {} } }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.snapshot.plugins[0].headCommit).toBe(SECOND_COMMIT); + expect(result.snapshot.plugins[0].manifests[0].version).toBe("0.3.0"); + }); + + test("counts manifests separately from repository cards and omits empty cards", async () => { + const repositories = [ + repo(), + repo({ + id: 2, + full_name: "example/empty", + owner: { login: "example" }, + name: "empty", + html_url: "https://github.com/example/empty", + }), + repo({ + id: 3, + full_name: "example/invalid", + owner: { login: "example" }, + name: "invalid", + html_url: "https://github.com/example/invalid", + }), + ]; + const result = await refreshPlugins(env(), { + fetch: repositoryFetch({ + repositories, + trees: { + "ogulcancelik/herdr-plugin-example": [ + { path: "one/herdr-plugin.toml", content: manifest() }, + { path: "two/herdr-plugin.toml", content: manifest().replace("example.plugin", "example.two") }, + ], + "example/empty": [{ path: "README.md", content: "empty" }], + "example/invalid": [{ path: "herdr-plugin.toml", content: "id = [broken" }], + }, + }), + logger: { error() {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.snapshot.pluginCount).toBe(2); + expect(result.snapshot.repositoryCount).toBe(1); + expect(result.snapshot.source.missingManifestCount).toBe(1); + expect(result.snapshot.source.invalidManifestCount).toBe(1); + }); + + test("ignores manifest symlinks and indexes their regular target", async () => { + const fullName = "ogulcancelik/herdr-plugin-example"; + const result = await refreshPlugins(env(), { + fetch: repositoryFetch({ + repositories: [repo()], + trees: { + [fullName]: [ + { path: "herdr-plugin.toml", content: "plugin/herdr-plugin.toml", mode: "120000" }, + { path: "plugin/herdr-plugin.toml", content: manifest() }, + ], + }, + }), + logger: { error() {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.snapshot.plugins[0].manifests.map((item) => item.path)).toEqual([ + "plugin/herdr-plugin.toml", ]); + expect(result.snapshot.source.invalidManifestCount).toBe(0); }); - test("excludes repositories listed in the KV blacklist", async () => { - const fetch = async (): Promise<Response> => - Response.json({ - total_count: 2, - items: [ - repo({ - id: 1, - full_name: "example/not-a-plugin", - owner: { login: "example" }, - name: "not-a-plugin", - html_url: "https://github.com/example/not-a-plugin", - }), - repo({ - id: 2, - full_name: "ogulcancelik/herdr-plugin-example", - owner: { login: "ogulcancelik" }, - name: "herdr-plugin-example", - html_url: "https://github.com/ogulcancelik/herdr-plugin-example", - }), - ], - }); + test("ignores test fixtures and deduplicates repeated plugin ids", async () => { + const fullName = "ogulcancelik/herdr-plugin-example"; + const result = await refreshPlugins(env(), { + fetch: repositoryFetch({ + repositories: [repo()], + trees: { + [fullName]: [ + { path: "herdr-plugin.toml", content: manifest() }, + { path: "platform/herdr-plugin.toml", content: manifest() }, + { + path: "tests/fixtures/helper/herdr-plugin.toml", + content: manifest().replace("example.plugin", "example.fixture"), + }, + ], + }, + }), + logger: { error() {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.snapshot.plugins[0].manifests.map((item) => item.path)).toEqual([ + "herdr-plugin.toml", + ]); + expect(result.snapshot.source.duplicateManifestCount).toBe(1); + }); + + test("skips a truncated tree without blocking the marketplace", async () => { + const fullName = "ogulcancelik/herdr-plugin-example"; + const result = await refreshPlugins(env(), { + fetch: repositoryFetch({ + repositories: [repo()], + truncatedTrees: new Set([fullName]), + }), + logger: { error() {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.snapshot.plugins).toEqual([]); + expect(result.snapshot.source.skippedRepositoryCount).toBe(1); + expect(result.snapshot.source.warnings?.[0]).toContain(fullName); + }); + + test("writes an empty snapshot when every repository is blacklisted", async () => { const bucket = new MemoryR2(); + const result = await refreshPlugins( + env(bucket, new MemoryKV(["repo:ogulcancelik/herdr-plugin-example"])), + { fetch: repositoryFetch({ repositories: [repo()] }), logger: { error() {} } }, + ); - const result = await refreshPlugins(env(bucket, new MemoryKV(["repo:example/not-a-plugin"])), { - fetch, + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.snapshot.plugins).toEqual([]); + expect(result.snapshot.source.blacklistedCount).toBe(1); + }); + + test("pins tree and manifest reads to the resolved commit", async () => { + const requests: Array<[string, string]> = []; + const result = await refreshPlugins(env(), { + fetch: repositoryFetch({ + repositories: [repo()], + onRequest(kind, detail) { + requests.push([kind, detail]); + }, + }), logger: { error() {} }, }); expect(result.ok).toBe(true); - const snapshot = JSON.parse(bucket.objects.get("plugins/index.json")?.value ?? ""); - expect(snapshot.plugins.map((plugin: { fullName: string }) => plugin.fullName)).toEqual([ + expect(requests.find(([kind]) => kind === "tree")?.[1]).toBe( "ogulcancelik/herdr-plugin-example", - ]); + ); + const manifestQuery = requests.find(([kind]) => kind === "manifest")?.[1] ?? ""; + expect(manifestQuery).toContain(`${HEAD_COMMIT}:herdr-plugin.toml`); + expect(manifestQuery).not.toContain("main:herdr-plugin.toml"); }); - test("writes an empty snapshot when every listable repository is blacklisted", async () => { - const fetch = async (): Promise<Response> => - Response.json({ - total_count: 1, - items: [ - repo({ - id: 1, - full_name: "example/not-a-plugin", - owner: { login: "example" }, - name: "not-a-plugin", - html_url: "https://github.com/example/not-a-plugin", - }), - ], - }); + test("does not overwrite the public snapshot when scanning fails", async () => { const bucket = new MemoryR2(); await bucket.put("plugins/index.json", '{"schemaVersion":1,"plugins":[{"id":1}]}'); + const result = await refreshPlugins(env(bucket), { + fetch: repositoryFetch({ + repositories: [repo()], + treeStatus: { "ogulcancelik/herdr-plugin-example": 429 }, + }), + logger: { error() {} }, + }); - const result = await refreshPlugins(env(bucket, new MemoryKV(["repo:example/not-a-plugin"])), { - fetch, + expect(result.ok).toBe(false); + expect(bucket.objects.get("plugins/index.json")?.value).toBe( + '{"schemaVersion":1,"plugins":[{"id":1}]}', + ); + }); + + test("discards malformed cache state and rebuilds it", async () => { + const bucket = new MemoryR2(); + await bucket.put("plugins/index.json", '{"schemaVersion":1,"plugins":[{"id":1}]}'); + await bucket.put("plugins/scan-cache.json", "broken"); + const errors: string[] = []; + + const result = await refreshPlugins(env(bucket), { + fetch: repositoryFetch({ repositories: [repo()] }), + logger: { error(message) { errors.push(String(message)); } }, + }); + + expect(result.ok).toBe(true); + expect(errors[0]).toContain("discarding invalid plugin marketplace scan cache"); + expect(JSON.parse(bucket.objects.get("plugins/scan-cache.json")?.value ?? "").entries).toHaveLength(1); + }); + + test("rejects an implausible empty search when a healthy cache exists", async () => { + const bucket = new MemoryR2(); + const normalFetch = repositoryFetch({ repositories: [repo()] }); + expect((await refreshPlugins(env(bucket), { fetch: normalFetch, logger: { error() {} } })).ok).toBe(true); + const previous = bucket.objects.get("plugins/index.json")?.value; + + const result = await refreshPlugins(env(bucket), { + fetch: repositoryFetch({ repositories: [] }), + logger: { error() {} }, + }); + + expect(result.ok).toBe(false); + expect(bucket.objects.get("plugins/index.json")?.value).toBe(previous); + }); + + test("publishes an empty snapshot when the initial complete search is empty", async () => { + const bucket = new MemoryR2(); + const result = await refreshPlugins(env(bucket), { + fetch: repositoryFetch({ repositories: [] }), logger: { error() {} }, }); expect(result.ok).toBe(true); - const snapshot = JSON.parse(bucket.objects.get("plugins/index.json")?.value ?? ""); - expect(snapshot.plugins).toEqual([]); + if (!result.ok) return; + expect(result.snapshot.plugins).toEqual([]); + expect(result.snapshot.pluginCount).toBe(0); }); - test("marks snapshots truncated at the GitHub search cap", async () => { - const fetch = async (): Promise<Response> => { - const items = Array.from({ length: 100 }, (_, index) => - repo({ - id: index, - full_name: `owner/repo-${index}`, - owner: { login: "owner" }, - name: `repo-${index}`, - html_url: `https://github.com/owner/repo-${index}`, - }), - ); - return Response.json({ total_count: 1200, items }); + test("preserves the snapshot when head resolution omits a cached repository", async () => { + const bucket = new MemoryR2(); + const repository = repo(); + const baseFetch = repositoryFetch({ repositories: [repository] }); + expect((await refreshPlugins(env(bucket), { fetch: baseFetch, logger: { error() {} } })).ok).toBe(true); + const previous = bucket.objects.get("plugins/index.json")?.value; + + repository.pushed_at = "2026-06-04T00:00:00Z"; + const missingHeadFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const request = String(init?.body ?? ""); + if (new URL(input.toString()).pathname === "/graphql" && request.includes("PluginMarketplaceHeads")) { + return Response.json({ + data: { repo0: null }, + errors: [{ type: "NOT_FOUND", path: ["repo0"], message: "not found" }], + }); + } + return baseFetch(input, init); }; + const result = await refreshPlugins(env(bucket), { + fetch: missingHeadFetch as typeof fetch, + logger: { error() {} }, + }); - const result = await refreshPlugins(env(), { - fetch, - now: new Date("2026-06-20T12:00:00.000Z"), + expect(result.ok).toBe(false); + expect(bucket.objects.get("plugins/index.json")?.value).toBe(previous); + }); + + test("does not cache a transiently missing manifest response", async () => { + const bucket = new MemoryR2(); + const repository = repo(); + const fullName = String(repository.full_name); + const commits = { [fullName]: HEAD_COMMIT }; + const baseFetch = repositoryFetch({ repositories: [repository], commits }); + expect((await refreshPlugins(env(bucket), { fetch: baseFetch, logger: { error() {} } })).ok).toBe(true); + const previous = bucket.objects.get("plugins/index.json")?.value; + + repository.pushed_at = "2026-06-04T00:00:00Z"; + commits[fullName] = SECOND_COMMIT; + const missingFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const request = String(init?.body ?? ""); + if (new URL(input.toString()).pathname === "/graphql" && request.includes("PluginMarketplaceManifests")) { + return Response.json({ + data: { item0: { manifest: null } }, + errors: [{ type: "NOT_FOUND", path: ["item0"], message: "not found" }], + }); + } + return baseFetch(input, init); + }; + const result = await refreshPlugins(env(bucket), { + fetch: missingFetch as typeof fetch, logger: { error() {} }, }); + expect(result.ok).toBe(false); + expect(bucket.objects.get("plugins/index.json")?.value).toBe(previous); + }); + + test("retries budget-skipped repositories when capacity becomes available", async () => { + const repositories = Array.from({ length: 51 }, (_, index) => + repo({ + id: index + 1, + full_name: `owner/repo-${index}`, + owner: { login: "owner" }, + name: `repo-${index}`, + html_url: `https://github.com/owner/repo-${index}`, + }), + ); + const trees = Object.fromEntries( + repositories.map((repository) => [ + repository.full_name, + Array.from({ length: 100 }, (_, index) => ({ + path: `plugins/${index}/herdr-plugin.toml`, + content: manifest(), + })), + ]), + ); + + const bucket = new MemoryR2(); + const fetch = repositoryFetch({ repositories, trees }); + const result = await refreshPlugins(env(bucket), { fetch, logger: { error() {} } }); + expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.snapshot.source.collectedCount).toBe(1000); - expect(result.snapshot.source.truncated).toBe(true); - expect(result.snapshot.source.warnings?.[0]).toContain("1200"); + expect(result.snapshot.source.skippedRepositoryCount).toBe(1); + expect(result.snapshot.source.warnings?.[0]).toContain("scan budget was exhausted"); + + repositories.shift(); + const recovered = await refreshPlugins(env(bucket), { fetch, logger: { error() {} } }); + expect(recovered.ok).toBe(true); + if (!recovered.ok) return; + expect(recovered.snapshot.source.skippedRepositoryCount).toBe(0); + expect(recovered.snapshot.repositoryCount).toBe(50); }); for (const { name, fetch } of [ { name: "GitHub failure", - fetch: async (): Promise<Response> => new Response("rate limited", { status: 429 }), - }, - { - name: "no listable repositories", - fetch: async (): Promise<Response> => Response.json({ total_count: 0, items: [] }), + fetch: repositoryFetch({ repositories: [], searchStatus: 429 }), }, { name: "incomplete search results", - fetch: async (): Promise<Response> => - Response.json({ total_count: 1, incomplete_results: true, items: [repo()] }), + fetch: repositoryFetch({ repositories: [repo()], incompleteResults: true }), }, ]) { - test(`does not overwrite the R2 snapshot on ${name}`, async () => { + test(`preserves the current snapshot on ${name}`, async () => { const bucket = new MemoryR2(); await bucket.put("plugins/index.json", '{"schemaVersion":1,"plugins":[{"id":1}]}'); - - const result = await refreshPlugins(env(bucket), { - fetch, - logger: { error() {} }, - }); - + const result = await refreshPlugins(env(bucket), { fetch, logger: { error() {} } }); expect(result.ok).toBe(false); expect(bucket.objects.get("plugins/index.json")?.value).toBe( '{"schemaVersion":1,"plugins":[{"id":1}]}', @@ -330,7 +678,6 @@ describe("refreshPlugins", () => { describe("fetch handler", () => { test("does not expose a public Worker API", async () => { const response = await worker.fetch(new Request("https://herdr.dev/api/plugins"), env()); - expect(response.status).toBe(404); expect(response.headers.get("Cache-Control")).toBe("no-store"); }); |
