837da6e5de
- 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
205 lines
8.1 KiB
JavaScript
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)");
|
|
});
|
|
});
|