Files
AgentHub/server/__tests__/plugin-update.test.js
T
nntrivi2001 837da6e5de fix: v2.3.1 — correct version, say why a plugin update failed, gitignored files in Files
- v2.3.0's tree reported 2.2.1, so every install saw an update forever;
  package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the
  snapshot now read 2.3.1
- A failed plugin-update step records the tail of the command's stdout and
  stderr plus its exit code or signal (failureText); plugin-bootstrap.js
  prints its refusal on stdout, which the verdict used to drop. Update all
  uses the same text
- updates.test.js never applies an update: it skips when the checkout is
  behind a release (apply ran npm run setup and pruned dev dependencies)
- Files tree lists gitignored files and folders, named in orange
  (addIgnored in git-diff.js); change-tree rows indent in whole steps
2026-10-06 16:22:53 +07:00

205 lines
8.1 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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)));
});
});
describe("plugin-update-helper failureText", () => {
// Required, not spawned: the helper only runs main() as a script.
const { failureText } = require("../../scripts/plugin-update-helper");
it("names why the bootstrap refused, which it prints on stdout", () => {
const err = Object.assign(new Error("Command failed: node plugin-bootstrap.js --force"), {
status: 1,
stdout: "installing…\nbootstrap: locked\n",
stderr: "",
});
assert.equal(failureText(err), "installing…\nbootstrap: locked\n(exit 1)");
});
it("keeps only the last lines, stdout then stderr", () => {
const err = Object.assign(new Error("x"), {
status: 2,
stdout: ["1", "2", "3", "4", "5", "6", "7"].join("\n"),
stderr: "boom",
});
assert.equal(failureText(err), "3\n4\n5\n6\n7\nboom\n(exit 2)");
});
it("says a killed command was killed, and falls back to the message when nothing was printed", () => {
const err = Object.assign(new Error("Command failed: node b.js --force\n"), {
status: null,
signal: "SIGTERM",
stdout: "",
stderr: "",
});
assert.equal(failureText(err), "Command failed: node b.js --force (killed by SIGTERM)");
});
});