/** * @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ĩ */ 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"); }); });