Files
AgentHub/server/__tests__/plugin-doctor-systemd.test.js
T
nntrivi2001 c90dd7b147 fix(bootstrap): self-heal a systemd unit left on a retired plugin root
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.

- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
  instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
  path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
  server keeps running and the corrected unit takes effect at the next
  start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
  detached-spawn fallback is guarded by needsServer so a stale unit alone
  cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
  stopped owns nothing: skipping the stop there and asking systemd to start
  would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
  come back after a reboot") and the fix. WARN, never FAIL — the dashboard
  in front of you works, and having no unit at all is a normal setup.

The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
2026-09-11 16:45:47 +07:00

122 lines
4.2 KiB
JavaScript

/**
* @file Tests the `systemd` row of scripts/plugin-doctor.js.
*
* The row exists for a failure nothing else in the report can see: a unit still
* naming a retired plugin cache directory leaves a running, healthy-looking
* dashboard that simply does not come back after the next reboot — it starts a
* path Claude Code deleted during the update. Every other row would read OK.
*
* WARN and never FAIL: the dashboard in front of the user works, and plenty of
* machines legitimately have no unit at all.
* @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("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-systemd-"));
const prevHome = process.env.HOME;
// Before anything resolves a unit path: `unitPath()` reads HOME, and a path
// computed against the real one makes these tests rewrite the developer's own
// ~/.config/systemd/user/agenthub.service.
process.env.HOME = TMP_HOME;
process.env.CLAUDE_HOME = path.join(TMP_HOME, ".claude");
const svc = require("../../scripts/systemd-service");
const { diagnose } = require("../../scripts/plugin-doctor");
const UNIT = svc.unitPath("agenthub");
const PLUGIN_ROOT = path.resolve(__dirname, "..", "..");
assert.ok(UNIT.startsWith(TMP_HOME + path.sep), `refusing to touch ${UNIT}`);
beforeEach(() => {
fs.rmSync(UNIT, { force: true });
// Claim systemd exists and the unit is running, so each test varies exactly
// one thing: the unit file on disk.
svc.__setExecImpl((args) => ({
status: 0,
stdout: args[0] === "is-active" ? "active\n" : "",
}));
});
after(() => {
svc.__reset();
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
delete process.env.CLAUDE_HOME;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
function row() {
const found = diagnose().find((r) => r.label === "systemd");
assert.ok(found, "the doctor must always emit a systemd row");
return found;
}
function 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"
);
}
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
describe("plugin-doctor: systemd row", linuxOnly, () => {
it("warns, never fails, when the unit names a retired plugin root", () => {
writeUnit("/plugins/cache/sg/agenthub/deadbeef");
const r = row();
assert.equal(r.level, "warn", "a working dashboard must not fail the doctor run");
assert.match(r.detail, /previous plugin version/i);
// The consequence is the whole point — a reader who does not know it will
// ignore the row.
assert.match(r.detail, /reboot/i);
assert.match(r.detail, /plugin-bootstrap\.js --force|new Claude Code session/);
});
it("passes when the unit is current and running", () => {
writeUnit(PLUGIN_ROOT);
const r = row();
assert.equal(r.level, "ok", r.detail);
assert.match(r.detail, /current and running/i);
});
it("warns when the unit is current but stopped", () => {
writeUnit(PLUGIN_ROOT);
svc.__setExecImpl((args) => ({
status: 3,
stdout: args[0] === "is-active" ? "inactive\n" : "",
}));
const r = row();
assert.equal(r.level, "warn");
assert.match(r.detail, /not running/i);
});
it("reports no unit as fine, not as a problem", () => {
// A plugin install without `agenthub service install` is a normal setup:
// the server is started per session.
const r = row();
assert.equal(r.level, "ok", r.detail);
assert.match(r.detail, /no unit/i);
});
it("warns about a unit it did not write, and never claims it is stale", () => {
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
fs.writeFileSync(UNIT, "[Service]\nExecStart=/opt/someone-elses-agenthub\n", "utf8");
const r = row();
assert.equal(r.level, "warn");
assert.match(r.detail, /not written by AgentHub|left untouched/i);
});
});