aca35dd4cd
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect. Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement. readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race. Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: a one-shot claude -p "/reload-plugins" would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command. Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action. Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted. Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
166 lines
6.0 KiB
JavaScript
166 lines
6.0 KiB
JavaScript
/**
|
|
* @file Unit tests for the "Update all" batch journal — the only channel the UI
|
|
* has for following a batch that deliberately outlives the server process.
|
|
*
|
|
* The batch worker itself is never run to completion here: with a valid plan it
|
|
* would really update this machine's plugins and stop its dashboard. Only the
|
|
* paths that must terminate WITHOUT touching anything are exercised against the
|
|
* real script, because a worker that dies without a terminal journal entry
|
|
* leaves the UI polling "running" forever — the exact failure this journal
|
|
* exists to prevent.
|
|
* @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("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { execFileSync } = require("node:child_process");
|
|
|
|
const HELPER = path.join(__dirname, "..", "..", "scripts", "cc-update-all-helper.js");
|
|
|
|
let tmpDir;
|
|
let prevDataDir;
|
|
|
|
before(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-update-all-"));
|
|
prevDataDir = process.env.DASHBOARD_DATA_DIR;
|
|
// Redirect the runtime dir: these tests must never read or write the real
|
|
// ~/.claude/agenthub/runtime of whoever runs the suite.
|
|
process.env.DASHBOARD_DATA_DIR = path.join(tmpDir, "data");
|
|
});
|
|
|
|
after(() => {
|
|
if (prevDataDir === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
|
else process.env.DASHBOARD_DATA_DIR = prevDataDir;
|
|
try {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
});
|
|
|
|
const {
|
|
readUpdateAll,
|
|
isRunning,
|
|
journalPath,
|
|
runtimeDir,
|
|
STALE_MS,
|
|
} = require("../lib/cc-update-all");
|
|
|
|
/** Write a journal into a fresh runtime dir and return that dir. */
|
|
function withJournal(name, body) {
|
|
const rt = path.join(tmpDir, name);
|
|
fs.mkdirSync(rt, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(rt, "cc-update-all.json"),
|
|
typeof body === "string" ? body : JSON.stringify(body)
|
|
);
|
|
return rt;
|
|
}
|
|
|
|
describe("readUpdateAll", () => {
|
|
it("returns null when no batch has ever run", () => {
|
|
const rt = path.join(tmpDir, "never");
|
|
fs.mkdirSync(rt, { recursive: true });
|
|
assert.equal(readUpdateAll(rt), null);
|
|
});
|
|
|
|
it("returns null for malformed JSON rather than throwing on a poll", () => {
|
|
assert.equal(readUpdateAll(withJournal("bad", "{nope")), null);
|
|
});
|
|
|
|
it("returns null for JSON without a steps array", () => {
|
|
// Guards against reporting a half-written file as a batch.
|
|
assert.equal(readUpdateAll(withJournal("nosteps", { status: "running" })), null);
|
|
});
|
|
|
|
it("reads back a finished batch with its per-step outcomes", () => {
|
|
const rt = withJournal("done", {
|
|
status: "partial",
|
|
started_at: new Date().toISOString(),
|
|
finished_at: new Date().toISOString(),
|
|
steps: [
|
|
{ key: "marketplaces", label: "Update all marketplaces", status: "ok" },
|
|
{
|
|
key: "plugin:ponytail@ponytail",
|
|
label: "ponytail@ponytail",
|
|
status: "failed",
|
|
error: "boom",
|
|
},
|
|
],
|
|
});
|
|
const batch = readUpdateAll(rt);
|
|
assert.equal(batch.status, "partial");
|
|
assert.equal(batch.steps.length, 2);
|
|
assert.equal(batch.steps[1].status, "failed");
|
|
assert.match(batch.steps[1].error, /boom/);
|
|
});
|
|
|
|
it("reports a fresh running batch as running", () => {
|
|
const rt = withJournal("fresh", {
|
|
status: "running",
|
|
started_at: new Date().toISOString(),
|
|
steps: [{ key: "marketplaces", status: "running" }],
|
|
});
|
|
assert.equal(readUpdateAll(rt).status, "running");
|
|
assert.equal(isRunning(rt), true);
|
|
});
|
|
|
|
it("reports a long-stale running batch as abandoned, so the button unwedges", () => {
|
|
// The worker is detached: a reboot leaves nobody to write the terminal
|
|
// entry, and the UI would otherwise spin forever.
|
|
const rt = withJournal("stale", {
|
|
status: "running",
|
|
started_at: new Date(Date.now() - STALE_MS - 60_000).toISOString(),
|
|
steps: [{ key: "marketplaces", status: "running" }],
|
|
});
|
|
assert.equal(readUpdateAll(rt).status, "abandoned");
|
|
assert.equal(isRunning(rt), false, "an abandoned batch must not block a new one");
|
|
});
|
|
|
|
it("treats an unparseable started_at as running rather than abandoned", () => {
|
|
// Better to show a spinner than to start a second concurrent batch against
|
|
// the same config dir on the strength of a garbled timestamp.
|
|
const rt = withJournal("nodate", {
|
|
status: "running",
|
|
started_at: "not a date",
|
|
steps: [{ key: "marketplaces", status: "running" }],
|
|
});
|
|
assert.equal(readUpdateAll(rt).status, "running");
|
|
});
|
|
});
|
|
|
|
describe("runtimeDir / journalPath", () => {
|
|
it("puts the journal under the data dir's runtime folder, outside any plugin cache", () => {
|
|
// The batch replaces the plugin cache directory it may be running from, so
|
|
// a journal stored in there would vanish exactly when it is needed.
|
|
const p = journalPath();
|
|
assert.equal(p, path.join(runtimeDir(), "cc-update-all.json"));
|
|
assert.ok(!p.includes(`${path.sep}cache${path.sep}`), p);
|
|
});
|
|
});
|
|
|
|
describe("cc-update-all-helper", () => {
|
|
it("records a terminal failure for an unreadable plan without running anything", () => {
|
|
const rt = path.join(tmpDir, "badplan");
|
|
fs.mkdirSync(rt, { recursive: true });
|
|
|
|
execFileSync(process.execPath, [HELPER, rt, "{not json"], {
|
|
stdio: "ignore",
|
|
timeout: 30_000,
|
|
});
|
|
|
|
const batch = JSON.parse(fs.readFileSync(path.join(rt, "cc-update-all.json"), "utf8"));
|
|
assert.equal(batch.status, "failed");
|
|
assert.match(batch.error, /unreadable plan/);
|
|
assert.ok(batch.finished_at, "a terminal entry is always timestamped");
|
|
// Nothing was attempted, so there is no step log and no claude invocation.
|
|
assert.deepEqual(batch.steps, []);
|
|
const logPath = path.join(rt, "cc-update-all.log");
|
|
const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "";
|
|
assert.doesNotMatch(log, /claude plugin/, "no claude command may be logged");
|
|
});
|
|
});
|