/** * @file Tests for the plugin-cache update chain: the verdict file the detached * helper leaves behind (the only channel through which a failed chain becomes * visible, since the server that would report it is the one being replaced), * and the helper's own name guard. * * The guard test runs the REAL helper, deliberately, with a name that must be * rejected — that is the one execution path safe to exercise, because it must * bail out before invoking the `claude` CLI. No test here ever runs the chain * with a valid name: that would pull a plugin version onto the machine running * the suite and stop the developer's own dashboard. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("fs"); const os = require("os"); const path = require("path"); const { execFileSync } = require("child_process"); const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update"); const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js"); let tmpDir; before(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pluginupd-")); }); after(() => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // ignore } }); /** Wait for `file` to exist, polling — the helper is a separate process. */ function waitForFile(file, timeoutMs = 20_000) { const deadline = Date.now() + timeoutMs; const sleeper = new Int32Array(new SharedArrayBuffer(4)); while (!fs.existsSync(file) && Date.now() < deadline) { Atomics.wait(sleeper, 0, 0, 100); } return fs.existsSync(file); } describe("readLastPluginUpdate", () => { it("returns null when the verdict file has never been written", () => { const rt = path.join(tmpDir, "empty"); fs.mkdirSync(rt, { recursive: true }); assert.equal(readLastPluginUpdate(rt), null); }); it("returns null for malformed JSON rather than throwing on a status poll", () => { // This is read on EVERY status request for a plugin install, so a corrupt // file must degrade to "nothing to report", never break the endpoint. const rt = path.join(tmpDir, "malformed"); fs.mkdirSync(rt, { recursive: true }); fs.writeFileSync(path.join(rt, "plugin-update.json"), "{not json"); assert.equal(readLastPluginUpdate(rt), null); }); it("returns null for valid JSON that isn't an object", () => { const rt = path.join(tmpDir, "notobj"); fs.mkdirSync(rt, { recursive: true }); fs.writeFileSync(path.join(rt, "plugin-update.json"), "42"); assert.equal(readLastPluginUpdate(rt), null); }); it("reads back a recorded failure, including the step it died at", () => { const rt = path.join(tmpDir, "failed"); fs.mkdirSync(rt, { recursive: true }); fs.writeFileSync( path.join(rt, "plugin-update.json"), JSON.stringify({ ok: false, step: "bootstrap", error: "npm ci exploded" }) ); const v = readLastPluginUpdate(rt); assert.equal(v.ok, false); assert.equal(v.step, "bootstrap"); assert.match(v.error, /npm ci exploded/); }); }); describe("schedulePluginUpdate", () => { it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => { // Redirect the runtime dir into the temp tree — this must not touch the // real ~/.claude/agenthub/runtime of whoever runs the suite. const dataDir = path.join(tmpDir, "datadir"); const prev = process.env.DASHBOARD_DATA_DIR; process.env.DASHBOARD_DATA_DIR = dataDir; // Point it at a root with no helper script: the spawn fails harmlessly // (detached, stdio ignored) and nothing can rewrite the verdict, which is // exactly what makes the clearing observable. const fakeRoot = path.join(tmpDir, "noscripts"); fs.mkdirSync(fakeRoot, { recursive: true }); try { const rt = runtimeDir(); fs.mkdirSync(rt, { recursive: true }); const verdict = path.join(rt, "plugin-update.json"); fs.writeFileSync(verdict, JSON.stringify({ ok: true, step: "done" })); const res = schedulePluginUpdate({ marketplace: "sg", plugin: "agenthub", root: fakeRoot }); assert.equal(res.spawned, true); assert.equal(res.runtime_dir, rt); assert.equal(fs.existsSync(verdict), false, "stale verdict must be removed"); assert.equal(readLastPluginUpdate(rt), null); } finally { if (prev === undefined) delete process.env.DASHBOARD_DATA_DIR; else process.env.DASHBOARD_DATA_DIR = prev; } }); }); describe("plugin-update-helper name guard", () => { it("refuses a path-traversal marketplace name without invoking the claude CLI", () => { const rt = path.join(tmpDir, "guard"); fs.mkdirSync(rt, { recursive: true }); // Run synchronously and let it record its own verdict. A name like this can // only arrive from a bug or tampering; either way the helper must stop // before it builds an argv for `claude`. execFileSync(process.execPath, [HELPER, "../../../evil", "agenthub", "/nonexistent/root", rt], { stdio: "ignore", timeout: 30_000, }); assert.ok(waitForFile(path.join(rt, "plugin-update.json")), "helper must record a verdict"); const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8")); assert.equal(v.ok, false); assert.equal(v.step, "validate"); // And it must not have gotten as far as running a command. const logPath = path.join(rt, "plugin-update.log"); const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""; assert.doesNotMatch(log, /claude plugin/, "no claude invocation may be logged"); }); it("refuses an empty plugin name the same way", () => { const rt = path.join(tmpDir, "guard2"); fs.mkdirSync(rt, { recursive: true }); execFileSync(process.execPath, [HELPER, "sg", "", "/nonexistent/root", rt], { stdio: "ignore", timeout: 30_000, }); const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8")); assert.equal(v.ok, false); assert.equal(v.step, "validate"); }); it("stamps every verdict with a finish time", () => { // The failure mode this file exists to prevent is a helper that dies // silently: the tab would poll a server that never returns and say nothing. // Only the rejected-name path is exercised — running the helper with a // valid name would really update this machine's plugin and stop its server. const rt = path.join(tmpDir, "guard3"); fs.mkdirSync(rt, { recursive: true }); execFileSync(process.execPath, [HELPER, "sg", "agent hub", "/nonexistent/root", rt], { stdio: "ignore", timeout: 30_000, }); const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8")); assert.equal(v.step, "validate"); assert.ok(v.finished_at, "every verdict is timestamped"); assert.ok(!Number.isNaN(Date.parse(v.finished_at))); }); });