diff --git a/README.md b/README.md index 44d680f..a1a091e 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ node artifact/scripts/plugin-library.mjs stop The bundled skill also tells agents how to answer skill questions from the read-only API without opening a window, and forbids them from calling `POST /api/apply` — applying to a bot stays a user click. -`npm test` runs the `node --test` suite (front matter, cache dedupe, path containment, apply-status mapping, and an HTTP smoke test against a throwaway cache). +`npm test` builds the generated artifact, then runs the `node --test` suite +(installer lifecycle, front matter, cache dedupe, path containment, +apply-status mapping, and an HTTP smoke test against a throwaway cache). ## Browse API (read-only) diff --git a/package.json b/package.json index 462e19c..66720e2 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "type": "module", "scripts": { "build": "agent-bundle build", - "check": "npm run validate && npm run build && npm run validate:artifact && npm run typecheck && npm test && npm run test:packed", + "check": "npm run validate && npm run typecheck && npm test && npm run validate:artifact && npm run test:packed", "start": "node server.js", - "test": "node --test \"test/**/*.test.js\"", + "test": "npm run build && node --test \"test/**/*.test.js\"", "test:packed": "node scripts/packed-smoke.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "validate": "agent-bundle validate", diff --git a/scripts/packed-smoke.mjs b/scripts/packed-smoke.mjs index 079c0cd..dce3c07 100644 --- a/scripts/packed-smoke.mjs +++ b/scripts/packed-smoke.mjs @@ -32,7 +32,12 @@ try { const tarball = path.join(packages, packed[0].filename); runNpm(["install", "--global", "--prefix", prefix, "--ignore-scripts", tarball]); - const env = { ...process.env, HOME: home }; + const env = { + ...process.env, + HOME: home, + XDG_STATE_HOME: path.join(home, ".local", "state"), + }; + delete env.AGENT_BUNDLE_STATE_ROOT; const installBin = path.join(prefix, "bin", "plugin-library-install"); const libraryBin = path.join(prefix, "bin", "plugin-library"); execFileSync(installBin, ["install", "cursor"], { env, stdio: "inherit" }); diff --git a/test/install.test.js b/test/install.test.js new file mode 100644 index 0000000..3512ac6 --- /dev/null +++ b/test/install.test.js @@ -0,0 +1,93 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const artifactRoot = path.join(__dirname, "..", "artifact"); +const installer = path.join(artifactRoot, "install.mjs"); + +function makeHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-library-install-")); + fs.mkdirSync(path.join(home, ".cursor")); + return home; +} + +function runInstaller(home, args = []) { + const env = { + ...process.env, + HOME: home, + XDG_STATE_HOME: path.join(home, ".local", "state"), + }; + delete env.AGENT_BUNDLE_STATE_ROOT; + return spawnSync(process.execPath, [installer, ...args], { + encoding: "utf8", + env, + }); +} + +test("generated installer copies only the artifact and writes a lifecycle receipt", (t) => { + const home = makeHome(); + t.after(() => fs.rmSync(home, { force: true, recursive: true })); + + const result = runInstaller(home); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Installed plugin-library@0\.3\.0/); + + const installed = path.join(home, ".cursor", "plugins", "local", "plugin-library"); + assert.equal(fs.lstatSync(installed).isSymbolicLink(), false); + assert.equal(fs.existsSync(path.join(installed, ".cursor-plugin", "plugin.json")), true); + assert.equal(fs.existsSync(path.join(installed, "commands", "plugin-library.md")), true); + assert.equal(fs.existsSync(path.join(installed, "skills", "plugin-library", "SKILL.md")), true); + assert.equal(fs.existsSync(path.join(installed, "assets", "data", "unified-catalog.json")), true); + assert.equal(fs.existsSync(path.join(installed, "scripts", "plugin-library.mjs")), true); + + const receipt = JSON.parse( + fs.readFileSync(path.join(installed, ".agent-bundle-install.json"), "utf8"), + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(artifactRoot, "agent-bundle.manifest.json"), "utf8"), + ); + assert.equal(receipt.format, "agent-bundle-install-receipt/2"); + assert.equal(receipt.plugin, "plugin-library"); + assert.equal(receipt.version, "0.3.0"); + assert.equal(receipt.host, "cursor"); + assert.equal(receipt.mode, "local"); + assert.equal(receipt.scope, "user"); + assert.deepEqual(receipt.registrations, [{ kind: "cursor-local-plugin" }]); + assert.deepEqual( + [...receipt.files].sort(), + ["agent-bundle.manifest.json", ...manifest.files.map((file) => file.path)].sort(), + ); + assert.match(receipt.contentHash, /^[a-f0-9]{64}$/); +}); + +test("generated installer is idempotent for identical content", (t) => { + const home = makeHome(); + t.after(() => fs.rmSync(home, { force: true, recursive: true })); + + assert.equal(runInstaller(home).status, 0); + const second = runInstaller(home); + assert.equal(second.status, 0, second.stderr); + assert.match(second.stdout, /Already installed plugin-library@0\.3\.0/); +}); + +test("generated installer plans and performs receipt-owned uninstall", (t) => { + const home = makeHome(); + t.after(() => fs.rmSync(home, { force: true, recursive: true })); + const installed = path.join(home, ".cursor", "plugins", "local", "plugin-library"); + + assert.equal(runInstaller(home).status, 0); + const plan = runInstaller(home, ["--uninstall", "--plan"]); + assert.equal(plan.status, 0, plan.stderr); + assert.match(plan.stdout, /Would uninstall plugin-library@0\.3\.0/); + assert.equal(fs.existsSync(installed), true); + + const uninstall = runInstaller(home, ["--uninstall"]); + assert.equal(uninstall.status, 0, uninstall.stderr); + assert.match(uninstall.stdout, /Uninstalled plugin-library@0\.3\.0/); + assert.equal(fs.existsSync(installed), false); +});