| 1 | #!/usr/bin/env node |
| 2 | // Unit tests for the ACP registry catalog parser. |
| 3 | // |
| 4 | // Bundles the pure `src/catalog.ts` module (no `vscode` dependency) and |
| 5 | // exercises parseCatalog/validateEntry against the official registry schema |
| 6 | // (https://github.com/agentclientprotocol/registry), with a fixed platform so |
| 7 | // binary-distribution resolution is deterministic. |
| 8 | // |
| 9 | // Run: node test/registry.mjs |
| 10 | |
| 11 | import { fileURLToPath } from "node:url"; |
| 12 | import { dirname, join } from "node:path"; |
| 13 | import { build } from "esbuild"; |
| 14 | import { createRequire } from "node:module"; |
| 15 | |
| 16 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 17 | const repoRoot = join(__dirname, ".."); |
| 18 | |
| 19 | const bundle = await build({ |
| 20 | entryPoints: [join(repoRoot, "src", "catalog.ts")], |
| 21 | bundle: true, |
| 22 | write: false, |
| 23 | format: "cjs", |
| 24 | platform: "node", |
| 25 | target: "node18", |
| 26 | logLevel: "silent" |
| 27 | }); |
| 28 | |
| 29 | const requireFromTest = createRequire(import.meta.url); |
| 30 | const Module = requireFromTest("node:module"); |
| 31 | const compiled = eval(Module.wrap(bundle.outputFiles[0].text)); |
| 32 | const moduleObj = { exports: {} }; |
| 33 | compiled(moduleObj.exports, requireFromTest, moduleObj, "catalog.bundle.js", repoRoot); |
| 34 | const { parseCatalog, validateEntry } = moduleObj.exports; |
| 35 | |
| 36 | const MAC = "darwin-aarch64"; |
| 37 | |
| 38 | let failures = 0; |
| 39 | function check(cond, msg) { |
| 40 | if (cond) { |
| 41 | console.log(`OK ${msg}`); |
| 42 | } else { |
| 43 | console.error(`FAIL ${msg}`); |
| 44 | failures++; |
| 45 | } |
| 46 | } |
| 47 | function throws(fn, msg) { |
| 48 | try { |
| 49 | fn(); |
| 50 | console.error(`FAIL ${msg} (expected throw)`); |
| 51 | failures++; |
| 52 | } catch { |
| 53 | console.log(`OK ${msg}`); |
| 54 | } |
| 55 | } |
| 56 | const parse = (doc, platform = MAC) => parseCatalog(JSON.stringify(doc), platform); |
| 57 | |
| 58 | // npx distribution → `npx -y <package> [args]`. |
| 59 | const npx = parse({ |
| 60 | version: "1.0.0", |
| 61 | agents: [ |
| 62 | { |
| 63 | id: "claude-acp", |
| 64 | name: "Claude Agent", |
| 65 | version: "0.51.0", |
| 66 | description: "ACP wrapper for Anthropic's Claude", |
| 67 | distribution: { npx: { package: "@agentclientprotocol/claude-agent-acp@0.51.0" } } |
| 68 | } |
| 69 | ] |
| 70 | }).agents[0]; |
| 71 | check(npx.command === "npx", "npx → command is npx"); |
| 72 | check( |
| 73 | JSON.stringify(npx.args) === |
| 74 | JSON.stringify(["-y", "@agentclientprotocol/claude-agent-acp@0.51.0"]), |
| 75 | "npx → args use -y + package" |
| 76 | ); |
| 77 | check(npx.distribution === "npx" && npx.manualInstall === false, "npx → not manual install"); |
| 78 | check(npx.key === "claude-acp" && npx.version === "0.51.0", "carries id and version"); |
| 79 | |
| 80 | // npx with extra args. |
| 81 | const npxArgs = parse({ |
| 82 | agents: [ |
| 83 | { id: "a", name: "A", distribution: { npx: { package: "pkg@1", args: ["--acp"] } } } |
| 84 | ] |
| 85 | }).agents[0]; |
| 86 | check( |
| 87 | JSON.stringify(npxArgs.args) === JSON.stringify(["-y", "pkg@1", "--acp"]), |
| 88 | "npx → appends distribution args" |
| 89 | ); |
| 90 | |
| 91 | // uvx distribution → `uvx <package> [args]`. |
| 92 | const uvx = parse({ |
| 93 | agents: [ |
| 94 | { id: "fast", name: "Fast", distribution: { uvx: { package: "fast-agent-acp==0.7.22", args: ["-x"] } } } |
| 95 | ] |
| 96 | }).agents[0]; |
| 97 | check( |
| 98 | uvx.command === "uvx" && |
| 99 | JSON.stringify(uvx.args) === JSON.stringify(["fast-agent-acp==0.7.22", "-x"]), |
| 100 | "uvx → command + package + args" |
| 101 | ); |
| 102 | |
| 103 | // binary distribution for the current platform → basename cmd + manual install. |
| 104 | const binDoc = { |
| 105 | agents: [ |
| 106 | { |
| 107 | id: "amp-acp", |
| 108 | name: "Amp", |
| 109 | distribution: { |
| 110 | binary: { |
| 111 | "darwin-aarch64": { |
| 112 | archive: "https://example.com/amp-darwin-aarch64.tar.gz", |
| 113 | cmd: "./amp-acp" |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | ] |
| 119 | }; |
| 120 | const bin = parse(binDoc).agents[0]; |
| 121 | check(bin.command === "amp-acp", "binary → command is cmd basename (./amp-acp → amp-acp)"); |
| 122 | check(bin.distribution === "binary" && bin.manualInstall === true, "binary → manual install"); |
| 123 | check(typeof bin.install === "string" && bin.install.includes("example.com"), "binary → install hint with archive URL"); |
| 124 | |
| 125 | // binary with no target for this platform → dropped (unrunnable here). |
| 126 | check(parse(binDoc, "linux-x86_64").agents.length === 0, "binary → dropped when no platform target"); |
| 127 | |
| 128 | // binary + npx prefers npx (zero manual steps). |
| 129 | const both = parse({ |
| 130 | agents: [ |
| 131 | { |
| 132 | id: "dual", |
| 133 | name: "Dual", |
| 134 | distribution: { |
| 135 | npx: { package: "dual@1" }, |
| 136 | binary: { "darwin-aarch64": { archive: "https://x/y.tgz", cmd: "./dual" } } |
| 137 | } |
| 138 | } |
| 139 | ] |
| 140 | }).agents[0]; |
| 141 | check(both.command === "npx" && both.distribution === "npx", "binary+npx → prefers npx"); |
| 142 | |
| 143 | // Invalid / unusable entries are dropped, not fatal. |
| 144 | const filtered = parse({ |
| 145 | agents: [ |
| 146 | { name: "no id", distribution: { npx: { package: "x" } } }, |
| 147 | { id: "Bad-Id", name: "bad", distribution: { npx: { package: "x" } } }, |
| 148 | { id: "nodist", name: "No dist" }, |
| 149 | { id: "emptydist", name: "Empty", distribution: {} }, |
| 150 | { id: "good", name: "Good", distribution: { npx: { package: "good@1" } } } |
| 151 | ] |
| 152 | }); |
| 153 | check( |
| 154 | filtered.agents.length === 1 && filtered.agents[0].key === "good", |
| 155 | "drops entries missing id/name/usable distribution" |
| 156 | ); |
| 157 | |
| 158 | // Duplicate ids: first wins. |
| 159 | const deduped = parse({ |
| 160 | agents: [ |
| 161 | { id: "dup", name: "First", distribution: { npx: { package: "first@1" } } }, |
| 162 | { id: "dup", name: "Second", distribution: { npx: { package: "second@1" } } } |
| 163 | ] |
| 164 | }); |
| 165 | check(deduped.agents.length === 1 && deduped.agents[0].name === "First", "dedupes by id (first wins)"); |
| 166 | |
| 167 | // validateEntry on its own. |
| 168 | check( |
| 169 | validateEntry({ id: "k", name: "K", distribution: { npx: { package: "p" } } }, MAC) !== null, |
| 170 | "validateEntry accepts a valid npx entry" |
| 171 | ); |
| 172 | check(validateEntry({ id: "k", name: "K", distribution: {} }, MAC) === null, "validateEntry rejects empty distribution"); |
| 173 | check(validateEntry(null, MAC) === null, "validateEntry rejects null"); |
| 174 | |
| 175 | // Structural failures throw. |
| 176 | throws(() => parseCatalog("not json", MAC), "throws on invalid JSON"); |
| 177 | throws(() => parseCatalog("[]", MAC), "throws on non-object top level"); |
| 178 | throws(() => parseCatalog(JSON.stringify({ version: "1.0.0" }), MAC), "throws when agents array missing"); |
| 179 | throws(() => parseCatalog(JSON.stringify({ version: "2.0.0", agents: [] }), MAC), "throws on unsupported major version"); |
| 180 | |
| 181 | if (failures > 0) { |
| 182 | console.error(`\n${failures} CHECK(S) FAILED`); |
| 183 | process.exit(1); |
| 184 | } |
| 185 | console.log("\nALL REGISTRY CHECKS PASSED"); |