8d48a038c7
Three defects that all showed up as "the dashboard's numbers are wrong", plus the notification and brand work asked for alongside them. Tokens: Claude Code writes ONE JSONL line per content block of an assistant message, and every one of those lines repeats the same message-level `usage`. Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran 1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId` counts a message once, carried across the transcript cache's incremental chunk boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser cannot correct what is already stored, so `setTokenUsage` (exact, baselines cleared) is added for the deliberate re-parse path and `reconcileTokens` now covers every session with a transcript on disk rather than imported ones only. Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on the transcript's timestamp, so each hook re-inserted the session's whole turn history — 587k rows, 75% of a real events table, one session holding 68,747 copies of the same turns. The row now carries the transcript timestamp, which fixes the guard and the feed's ordering together. Same class of bug in the session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms` are assigned from the full parse instead of `+=`, since `extract()` returns whole-transcript totals on every call. Events: leftover checkout hook entries in `~/.claude/settings.json` double every event, every subagent row and every token figure. `stripLegacyHooks` has always removed them — but only inside `bootstrap()`, which `fastPathOk` skips on exactly the healthy installs where nothing else forces a full pass, so they survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path. A one-time repair at the first start after upgrading (`user_version` 3) deletes the duplicated `TurnDuration` rows and re-parses every transcript with exact writes. Marker 3 and not 2: an install that ran the earlier pass had its corrected rows re-inflated by whatever old build was still ingesting hooks. Router: switching a lane's AI Router while a pane is live only half-applies — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails with "model not found". The apply route reports `restart_required` / `run_id`, and the picker offers a Restart that respawns the pane from the lane id alone (replaying the old `--model` would re-create the mismatch). Notifications (#29): the two moments Claude is BLOCKED on the user now have their own switches and default to on — `onAwaitingInput` (a permission prompt or a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable and the second hung off a preference that defaults to off, so the one thing a user walks away from went unannounced. Prefs shape/defaults/storage move to `client/src/lib/notifPrefs.ts`; Settings and the hook had private copies. Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under it, the collapsed rail shows the mark, and the favicon is vendored to match.
654 lines
24 KiB
JavaScript
654 lines
24 KiB
JavaScript
/**
|
|
* @file Tests scripts/plugin-bootstrap.js — the SessionStart bootstrap that
|
|
* makes a plugin install self-sufficient. Covers the fast path, the Node
|
|
* version gate, stale-lock reclaim, legacy hook removal (the duplicate-hook
|
|
* double-counting bug) and the CLI launcher's refusal to clobber a foreign
|
|
* `agenthub`. Every path is exercised against injected temp directories — nothing
|
|
* here touches the real $HOME.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, beforeEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-bootstrap-"));
|
|
process.env.CLAUDE_HOME = TMP_HOME;
|
|
delete process.env.DASHBOARD_DATA_DIR;
|
|
|
|
const boot = require("../../scripts/plugin-bootstrap");
|
|
|
|
const RT = path.join(TMP_HOME, "agenthub", "runtime");
|
|
|
|
function resetRuntime() {
|
|
fs.rmSync(RT, { recursive: true, force: true });
|
|
fs.mkdirSync(RT, { recursive: true });
|
|
}
|
|
|
|
after(() => {
|
|
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("serverIsLive", () => {
|
|
const infoPath = path.join(TMP_HOME, ".agenthub.json");
|
|
|
|
beforeEach(() => fs.rmSync(infoPath, { force: true }));
|
|
after(() => fs.rmSync(infoPath, { force: true }));
|
|
|
|
it("is false with no discovery file at all", () => {
|
|
// Regression: server-info's resolveAllDashboardPorts() falls back to
|
|
// [DEFAULT_PORT] when nothing is live (a reasonable guess for the CLI),
|
|
// which made the bootstrap wrongly believe a server was already running
|
|
// and skip starting its own.
|
|
assert.equal(boot.serverIsLive(), false);
|
|
});
|
|
|
|
it("is false when every recorded pid is dead", () => {
|
|
fs.writeFileSync(
|
|
infoPath,
|
|
JSON.stringify({ servers: [{ port: 4820, pid: 999999, startedAt: "x" }] })
|
|
);
|
|
assert.equal(boot.serverIsLive(), false);
|
|
});
|
|
|
|
it("is true when a recorded pid is actually alive", () => {
|
|
fs.writeFileSync(
|
|
infoPath,
|
|
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: "x" }] })
|
|
);
|
|
assert.equal(boot.serverIsLive(), true);
|
|
assert.deepEqual(boot.liveServers(), [{ port: 4820, pid: process.pid, startedAt: "x" }]);
|
|
});
|
|
});
|
|
|
|
describe("runtime location", () => {
|
|
it("lives under the shared data dir, not the plugin cache", () => {
|
|
assert.equal(boot.runtimeDir(), RT);
|
|
});
|
|
});
|
|
|
|
describe("node version gate", () => {
|
|
it("refuses anything below 22.5 (node:sqlite is the only driver available)", () => {
|
|
assert.equal(boot.nodeVersionOk("20.11.0"), false);
|
|
assert.equal(boot.nodeVersionOk("22.4.1"), false);
|
|
});
|
|
|
|
it("accepts 22.5 and newer", () => {
|
|
assert.equal(boot.nodeVersionOk("22.5.0"), true);
|
|
assert.equal(boot.nodeVersionOk("24.0.0"), true);
|
|
});
|
|
});
|
|
|
|
describe("fast path", () => {
|
|
beforeEach(resetRuntime);
|
|
|
|
it("is false with no recorded state", () => {
|
|
assert.equal(boot.fastPathOk(RT), false);
|
|
});
|
|
|
|
it("is false when the recorded plugin version is stale", () => {
|
|
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
|
boot.writeState({ pluginVersion: "0.0.0-old", depsHash: boot.depsHash() }, RT);
|
|
assert.equal(boot.fastPathOk(RT), false);
|
|
});
|
|
|
|
it("is false after a plugin update moved the install to a new cache dir", () => {
|
|
const pkg = require("../../package.json");
|
|
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
|
boot.writeState(
|
|
{
|
|
pluginVersion: pkg.version,
|
|
depsHash: boot.depsHash(),
|
|
pluginRoot: "/old/plugin/cache/dir",
|
|
},
|
|
RT
|
|
);
|
|
assert.equal(boot.fastPathOk(RT), false);
|
|
});
|
|
|
|
it("is false when node_modules is missing even if state matches", () => {
|
|
const pkg = require("../../package.json");
|
|
boot.writeState({ pluginVersion: pkg.version, depsHash: boot.depsHash() }, RT);
|
|
assert.equal(boot.fastPathOk(RT), false);
|
|
});
|
|
|
|
it("is false while the skill-plugin check has never settled", () => {
|
|
// A machine can be fully installed and still lack the plugins the
|
|
// pipelines drive; the worker has to run once to find that out.
|
|
const pkg = require("../../package.json");
|
|
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
|
boot.writeState(
|
|
{
|
|
pluginVersion: pkg.version,
|
|
depsHash: boot.depsHash(),
|
|
pluginRoot: path.resolve(__dirname, "..", ".."),
|
|
},
|
|
RT
|
|
);
|
|
assert.equal(boot.fastPathOk(RT), false);
|
|
});
|
|
|
|
it("is false when the systemd unit still names a retired plugin root", () => {
|
|
// The one failure a live server hides completely: everything is healthy
|
|
// until the next reboot, and then the dashboard never comes back because
|
|
// its unit points into a cache directory Claude Code has deleted. The fast
|
|
// path has to reject it, or no SessionStart ever repairs it.
|
|
//
|
|
// Everything else here is deliberately HEALTHY — including a live server,
|
|
// without which `fastPathOk` is false anyway and this test would pass
|
|
// against the unfixed code.
|
|
const ROOT = path.resolve(__dirname, "..", "..");
|
|
const pkg = require("../../package.json");
|
|
const infoPath = path.join(TMP_HOME, ".agenthub.json");
|
|
const prevHome = process.env.HOME;
|
|
// HOME first: `unitPath()` resolves against it, and computing the path
|
|
// before this line points every write in this test at the REAL
|
|
// ~/.config/systemd/user/agenthub.service — which the cleanup below then
|
|
// deletes. (Asked how I know.)
|
|
process.env.HOME = TMP_HOME;
|
|
const unit = require("../../scripts/systemd-service").unitPath("agenthub");
|
|
assert.ok(
|
|
unit.startsWith(TMP_HOME + path.sep),
|
|
`refusing to touch a unit outside the temp home: ${unit}`
|
|
);
|
|
|
|
const writeUnit = (workingDir) => {
|
|
fs.mkdirSync(path.dirname(unit), { recursive: true });
|
|
fs.writeFileSync(
|
|
unit,
|
|
"[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" +
|
|
`[Service]\nWorkingDirectory=${workingDir}\n`,
|
|
"utf8"
|
|
);
|
|
};
|
|
|
|
try {
|
|
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
|
fs.writeFileSync(
|
|
infoPath,
|
|
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: "x" }] })
|
|
);
|
|
boot.writeState(
|
|
{
|
|
pluginVersion: pkg.version,
|
|
depsHash: boot.depsHash(),
|
|
pluginRoot: ROOT,
|
|
skillPluginsSettled: "nothing-missing",
|
|
},
|
|
RT
|
|
);
|
|
|
|
// Control: a unit naming THIS root is the steady state — fast path taken.
|
|
writeUnit(ROOT);
|
|
assert.equal(boot.fastPathOk(RT), true, "a current unit must not block the fast path");
|
|
|
|
// The regression: same healthy install, unit left on a retired root.
|
|
writeUnit("/plugins/cache/agenthub/deadbeef");
|
|
assert.equal(boot.fastPathOk(RT), false);
|
|
} finally {
|
|
fs.rmSync(infoPath, { force: true });
|
|
fs.rmSync(unit, { force: true });
|
|
if (prevHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("skill-plugin auto-install gate", () => {
|
|
// Both cases here must return WITHOUT reaching server/lib/skill-plugins.js —
|
|
// that module shells out to `claude plugin install`, and no test may.
|
|
beforeEach(resetRuntime);
|
|
after(() => delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS);
|
|
|
|
for (const flag of ["0", "false", "off"]) {
|
|
it(`is off when AGENTHUB_AUTO_INSTALL_PLUGINS=${flag}`, () => {
|
|
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = flag;
|
|
assert.equal(boot.autoInstallSkillPlugins(RT), "off");
|
|
});
|
|
}
|
|
|
|
it("runs once: a settled state is returned verbatim, not re-attempted", () => {
|
|
delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS;
|
|
boot.writeState({ skillPluginsSettled: "installed" }, RT);
|
|
|
|
// Verbatim matters: writeState records whatever comes back, so a marker
|
|
// here would erase the only record of what actually happened.
|
|
assert.equal(boot.autoInstallSkillPlugins(RT), "installed");
|
|
});
|
|
|
|
it("is off under the flag even when forced, so /agenthub-update honours the opt-out", () => {
|
|
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = "0";
|
|
boot.writeState({ skillPluginsSettled: "failed" }, RT);
|
|
assert.equal(boot.autoInstallSkillPlugins(RT, { force: true }), "off");
|
|
});
|
|
});
|
|
|
|
describe("bootstrap lock", () => {
|
|
beforeEach(resetRuntime);
|
|
|
|
it("is exclusive while held", () => {
|
|
assert.equal(boot.acquireLock(RT), true);
|
|
assert.equal(boot.acquireLock(RT), false, "a live lock must not be reclaimed");
|
|
boot.releaseLock(RT);
|
|
assert.equal(fs.existsSync(boot.lockPath(RT)), false);
|
|
});
|
|
|
|
it("reclaims a lock whose owner is dead", () => {
|
|
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
|
|
// PID 1 is alive but not ours; use an unused high pid instead.
|
|
fs.writeFileSync(path.join(boot.lockPath(RT), "pid"), "999999", "utf8");
|
|
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
|
|
assert.equal(boot.acquireLock(RT), true);
|
|
boot.releaseLock(RT);
|
|
});
|
|
|
|
it("reclaims a lock older than the stale timeout even if its pid is alive", () => {
|
|
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
|
|
fs.writeFileSync(path.join(boot.lockPath(RT), "pid"), String(process.pid), "utf8");
|
|
const old = Date.now() - boot.LOCK_STALE_MS - 1000;
|
|
fs.utimesSync(boot.lockPath(RT), old / 1000, old / 1000);
|
|
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
|
|
assert.equal(boot.acquireLock(RT), true);
|
|
boot.releaseLock(RT);
|
|
});
|
|
|
|
it("treats a lock directory with no pid file as debris", () => {
|
|
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
|
|
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
|
|
});
|
|
});
|
|
|
|
describe("legacy hook cleanup", () => {
|
|
const settings = path.join(TMP_HOME, "settings-legacy.json");
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(settings, { force: true });
|
|
fs.rmSync(`${settings}.agenthub-bak`, { force: true });
|
|
});
|
|
|
|
it("reports a settings file that still carries checkout hooks", () => {
|
|
// What fastPathOk consults on every SessionStart. The strip itself only
|
|
// runs in the worker, so without this the one defect that DOUBLES every
|
|
// event survives on an otherwise-healthy install forever.
|
|
fs.writeFileSync(settings, JSON.stringify({ hooks: {} }));
|
|
assert.equal(boot.hasLegacyHooks(settings), false);
|
|
|
|
fs.writeFileSync(
|
|
settings,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{ matcher: "*", hooks: [{ type: "command", command: "node /other/tool.js" }] },
|
|
],
|
|
},
|
|
})
|
|
);
|
|
assert.equal(boot.hasLegacyHooks(settings), false, "an unrelated hook is not ours");
|
|
|
|
fs.writeFileSync(
|
|
settings,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: "*",
|
|
hooks: [
|
|
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
);
|
|
assert.equal(boot.hasLegacyHooks(settings), true);
|
|
// Pure read: the caller is a hot path, and the file is the user's.
|
|
assert.equal(fs.existsSync(`${settings}.agenthub-bak`), false);
|
|
});
|
|
|
|
it("removes checkout-installed hook entries and backs the file up first", () => {
|
|
fs.writeFileSync(
|
|
settings,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: "*",
|
|
hooks: [
|
|
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
|
|
],
|
|
},
|
|
{ matcher: "*", hooks: [{ type: "command", command: "node /other/tool.js" }] },
|
|
],
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{ type: "command", command: "node /repo/scripts/hook-handler.js SessionStart" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
);
|
|
const removed = boot.stripLegacyHooks(settings);
|
|
assert.equal(removed, 2);
|
|
|
|
const after = JSON.parse(fs.readFileSync(settings, "utf8"));
|
|
assert.equal(after.hooks.PreToolUse.length, 1, "unrelated hooks must survive");
|
|
assert.match(JSON.stringify(after.hooks.PreToolUse), /other\/tool\.js/);
|
|
assert.equal(after.hooks.SessionStart, undefined, "an emptied list is dropped");
|
|
assert.ok(fs.existsSync(`${settings}.agenthub-bak`), "must back up before writing");
|
|
});
|
|
|
|
it("never removes the plugin's own cache-resolved hook entries", () => {
|
|
// Regression: `claude plugin install` materializes the plugin's inline
|
|
// hooks into settings.json itself, with ${CLAUDE_PLUGIN_ROOT} resolved to
|
|
// the cache path — confirmed against a real install. Those entries also
|
|
// contain "hook-handler.js", so a naive substring check would strip them
|
|
// right along with genuine checkout leftovers, killing the plugin's own
|
|
// hooks on every session start.
|
|
const cachePath = path.join(
|
|
TMP_HOME,
|
|
"plugins",
|
|
"cache",
|
|
"agenthub",
|
|
"agenthub",
|
|
"abc123",
|
|
"scripts",
|
|
"hook-handler.js"
|
|
);
|
|
fs.writeFileSync(
|
|
settings,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: "*",
|
|
hooks: [{ type: "command", command: `node "${cachePath}" PreToolUse` }],
|
|
},
|
|
{
|
|
matcher: "*",
|
|
hooks: [
|
|
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
);
|
|
const removed = boot.stripLegacyHooks(settings);
|
|
assert.equal(removed, 1);
|
|
const after = JSON.parse(fs.readFileSync(settings, "utf8"));
|
|
assert.equal(after.hooks.PreToolUse.length, 1);
|
|
assert.match(JSON.stringify(after.hooks.PreToolUse), /plugins.*cache/);
|
|
});
|
|
|
|
it("leaves a file with no AgentHub hooks untouched and writes no backup", () => {
|
|
fs.writeFileSync(settings, JSON.stringify({ hooks: { Stop: [{ hooks: [] }] } }));
|
|
assert.equal(boot.stripLegacyHooks(settings), 0);
|
|
assert.equal(fs.existsSync(`${settings}.agenthub-bak`), false);
|
|
});
|
|
|
|
it("is a no-op when no settings file exists", () => {
|
|
assert.equal(boot.stripLegacyHooks(path.join(TMP_HOME, "nope.json")), 0);
|
|
});
|
|
});
|
|
|
|
describe("agenthub CLI launcher", () => {
|
|
const bin = path.join(TMP_HOME, "bin");
|
|
const target = path.join(bin, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(bin, { recursive: true, force: true });
|
|
});
|
|
|
|
it("writes a launcher that points at the plugin's bin/agenthub.js", () => {
|
|
assert.equal(boot.linkCli(bin), "written");
|
|
assert.match(fs.readFileSync(target, "utf8"), /bin[/\\]agenthub\.js/);
|
|
});
|
|
|
|
it("is idempotent", () => {
|
|
boot.linkCli(bin);
|
|
assert.equal(boot.linkCli(bin), "exists");
|
|
});
|
|
|
|
it("never clobbers a agenthub it did not write (a linked checkout keeps winning)", () => {
|
|
fs.mkdirSync(bin, { recursive: true });
|
|
fs.writeFileSync(target, '#!/bin/sh\nexec node /my/checkout/bin/agenthub.js "$@"\n');
|
|
assert.equal(boot.linkCli(bin), "foreign");
|
|
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/);
|
|
});
|
|
});
|
|
|
|
describe("plugin-open / plugin-bootstrap require cycle", () => {
|
|
// bootstrap() lazily requires plugin-open for buildClient, and plugin-open
|
|
// requires bootstrap at load. When plugin-open is the entry point (/agenthub-open)
|
|
// that lazy require resolves to whatever sits on its module.exports at that
|
|
// instant — so the assignment must come before the entry block that calls
|
|
// bootstrap(), or /agenthub-open dies with "buildClient is not a function".
|
|
const src = fs.readFileSync(
|
|
path.join(__dirname, "..", "..", "scripts", "plugin-open.js"),
|
|
"utf8"
|
|
);
|
|
|
|
it("assigns module.exports before running the entry block", () => {
|
|
const exportsAt = src.indexOf("module.exports =");
|
|
const entryAt = src.indexOf("require.main === module");
|
|
assert.ok(exportsAt !== -1 && entryAt !== -1);
|
|
assert.ok(exportsAt < entryAt, "module.exports must be assigned before the entry block");
|
|
});
|
|
|
|
it("exposes buildClient to a bootstrap that requires it back", () => {
|
|
assert.equal(typeof require("../../scripts/plugin-open").buildClient, "function");
|
|
});
|
|
});
|
|
|
|
describe("buildClient dependency install", () => {
|
|
const { buildClient } = require("../../scripts/plugin-open");
|
|
|
|
// A fake plugin root holding just enough of a client/ to copy.
|
|
function fakeRoot() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-root-"));
|
|
fs.mkdirSync(path.join(root, "client"));
|
|
fs.writeFileSync(path.join(root, "client", "package.json"), "{}");
|
|
return root;
|
|
}
|
|
|
|
// Records every npm invocation; `fails` names the subcommands that exit non-zero.
|
|
// A successful "run build" writes the dist/ the real Vite build would produce.
|
|
function fakeExec(calls, fails = []) {
|
|
return (_cmd, args, cwd) => {
|
|
calls.push(args.join(" "));
|
|
if (fails.includes(args[0])) return false;
|
|
if (args[0] === "run") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
|
|
return true;
|
|
};
|
|
}
|
|
|
|
it("installs with `npm ci`, which cannot reuse a half-populated node_modules", () => {
|
|
// Regression: `npm install` reported success against a truncated cache
|
|
// entry, and the build then died with TS2318 — typescript's lib/*.d.ts
|
|
// missing, so every global type (Array, Boolean, …) was undefined.
|
|
resetRuntime();
|
|
const calls = [];
|
|
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
|
|
|
|
assert.equal(result, "built");
|
|
assert.equal(calls[0].startsWith("ci"), true, `expected npm ci first, got ${calls[0]}`);
|
|
assert.equal(
|
|
calls.some((c) => c.startsWith("install")),
|
|
false,
|
|
"npm install must not run once ci succeeded"
|
|
);
|
|
});
|
|
|
|
it("falls back to `npm install` when ci refuses the lockfile", () => {
|
|
resetRuntime();
|
|
const calls = [];
|
|
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls, ["ci"]) });
|
|
|
|
assert.equal(result, "built");
|
|
assert.deepEqual(
|
|
calls.map((c) => c.split(" ")[0]),
|
|
["ci", "install", "run"]
|
|
);
|
|
});
|
|
|
|
it("installs devDependencies explicitly, because NODE_ENV=production omits them", () => {
|
|
// The whole client build toolchain (vite, typescript, @types/*) is a
|
|
// devDependency, and npm turns NODE_ENV=production into --omit=dev. Every
|
|
// bootstrap descended from the running server inherits that env, so the
|
|
// install dropped vite, `npm run build` failed, and the dashboard kept
|
|
// serving the PREVIOUS version's bundle after a self-update.
|
|
resetRuntime();
|
|
const calls = [];
|
|
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
|
|
|
|
for (const call of calls.filter((c) => c.startsWith("ci") || c.startsWith("install"))) {
|
|
assert.match(call, /--include=dev/, `dependency install must include dev deps: ${call}`);
|
|
}
|
|
});
|
|
|
|
it("fails without building when neither install path works", () => {
|
|
resetRuntime();
|
|
const calls = [];
|
|
const result = buildClient({
|
|
rt: RT,
|
|
root: fakeRoot(),
|
|
exec: fakeExec(calls, ["ci", "install"]),
|
|
});
|
|
|
|
assert.equal(result, "failed");
|
|
assert.equal(
|
|
calls.some((c) => c.startsWith("run")),
|
|
false,
|
|
"the build must not run on a broken dependency tree"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("supersededBy — an old cache directory must not act", () => {
|
|
const pluginsDir = path.join(TMP_HOME, "plugins");
|
|
const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub");
|
|
const oldRoot = path.join(cacheDir, "aaaaaaaaaaaa");
|
|
const newRoot = path.join(cacheDir, "bbbbbbbbbbbb");
|
|
|
|
function writeInstalled(installPath) {
|
|
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginsDir, "installed_plugins.json"),
|
|
JSON.stringify({
|
|
version: 2,
|
|
plugins: { "agenthub@sg": [{ scope: "user", installPath }] },
|
|
})
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(pluginsDir, { recursive: true, force: true });
|
|
fs.mkdirSync(oldRoot, { recursive: true });
|
|
fs.mkdirSync(newRoot, { recursive: true });
|
|
});
|
|
|
|
it("names the installed build when this one is not it", () => {
|
|
writeInstalled(newRoot);
|
|
assert.equal(boot.supersededBy(oldRoot), newRoot);
|
|
});
|
|
|
|
it("clears the build that IS installed", () => {
|
|
writeInstalled(newRoot);
|
|
assert.equal(boot.supersededBy(newRoot), null);
|
|
});
|
|
|
|
it("ignores an installed path that no longer exists", () => {
|
|
// A phantom entry must never wedge a bootstrap shut.
|
|
writeInstalled(path.join(cacheDir, "cccccccccccc"));
|
|
assert.equal(boot.supersededBy(oldRoot), null);
|
|
});
|
|
|
|
it("ignores a plugin that is not a sibling of this one", () => {
|
|
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
const other = path.join(pluginsDir, "cache", "sg", "sg-all", "0.1.0");
|
|
fs.mkdirSync(other, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginsDir, "installed_plugins.json"),
|
|
JSON.stringify({ plugins: { "sg-all@sg": [{ installPath: other }] } })
|
|
);
|
|
assert.equal(boot.supersededBy(oldRoot), null);
|
|
});
|
|
|
|
it("says nothing when there is no record to read (a git checkout)", () => {
|
|
assert.equal(boot.supersededBy(oldRoot), null);
|
|
});
|
|
});
|
|
|
|
describe("pruneRetiredPluginRoots", () => {
|
|
const pluginsDir = path.join(TMP_HOME, "plugins");
|
|
const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub");
|
|
const settingsPath = path.join(TMP_HOME, "settings.json");
|
|
|
|
/** Six builds, oldest first, so mtime order is deterministic. */
|
|
function seed(names) {
|
|
fs.rmSync(pluginsDir, { recursive: true, force: true });
|
|
let t = Date.now() - names.length * 60_000;
|
|
for (const n of names) {
|
|
const dir = path.join(cacheDir, n);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.utimesSync(dir, new Date(t), new Date(t));
|
|
t += 60_000;
|
|
}
|
|
}
|
|
|
|
const NAMES = ["v1", "v2", "v3", "v4", "v5", "current"];
|
|
const dir = (n) => path.join(cacheDir, n);
|
|
|
|
beforeEach(() => {
|
|
resetRuntime();
|
|
seed(NAMES);
|
|
fs.rmSync(settingsPath, { force: true });
|
|
fs.writeFileSync(path.join(pluginsDir, "installed_plugins.json"), "{}");
|
|
});
|
|
|
|
it("keeps the current build and the single newest retired one", () => {
|
|
const removed = boot.pruneRetiredPluginRoots(RT, dir("current"));
|
|
assert.equal(removed, 4); // v1..v4 go; v5 is the whole cushion
|
|
assert.ok(fs.existsSync(dir("current")));
|
|
assert.ok(fs.existsSync(dir("v5")));
|
|
assert.ok(!fs.existsSync(dir("v4")));
|
|
assert.ok(!fs.existsSync(dir("v1")));
|
|
});
|
|
|
|
it("never deletes a build Claude Code records as installed", () => {
|
|
fs.writeFileSync(
|
|
path.join(pluginsDir, "installed_plugins.json"),
|
|
JSON.stringify({ plugins: { "agenthub@sg": [{ installPath: dir("v1") }] } })
|
|
);
|
|
boot.pruneRetiredPluginRoots(RT, dir("current"));
|
|
assert.ok(fs.existsSync(dir("v1")));
|
|
});
|
|
|
|
it("never deletes a build the user's hooks still point at", () => {
|
|
// A Claude Code process that has not been restarted is still executing
|
|
// from there — deleting it breaks that session's hooks mid-flight.
|
|
fs.writeFileSync(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
hooks: { SessionStart: [{ hooks: [{ command: `node "${dir("v2")}/scripts/x.js"` }] }] },
|
|
})
|
|
);
|
|
boot.pruneRetiredPluginRoots(RT, dir("current"));
|
|
assert.ok(fs.existsSync(dir("v2")));
|
|
assert.ok(!fs.existsSync(dir("v1")));
|
|
});
|
|
|
|
it("refuses to touch anything outside the plugin cache", () => {
|
|
const checkout = path.join(TMP_HOME, "checkout", "agenthub");
|
|
const sibling = path.join(TMP_HOME, "checkout", "something-precious");
|
|
fs.mkdirSync(checkout, { recursive: true });
|
|
fs.mkdirSync(sibling, { recursive: true });
|
|
assert.equal(boot.pruneRetiredPluginRoots(RT, checkout), 0);
|
|
assert.ok(fs.existsSync(sibling));
|
|
});
|
|
});
|