Files
AgentHub/server/__tests__/launchd-service.test.js
nntrivi2001 952e8584f0 feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
2026-09-25 17:30:28 +07:00

177 lines
7.0 KiB
JavaScript

/**
* @file Tests scripts/launchd-service.js, the macOS LaunchAgent twin of the
* systemd unit: the plist it writes (KeepAlive, AbandonProcessGroup so lane
* panes survive a restart, the plugin root as WorkingDirectory), that writing
* starts nothing, that a restart re-reads the plist (bootout + bootstrap, not
* kickstart), and that a foreign plist is never touched. `launchctl` is
* stubbed and the platform faked, so this runs on any OS.
* @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-launchd-"));
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME;
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin" });
const svc = require("../../scripts/launchd-service");
const DOMAIN = `gui/${process.getuid()}`;
const JOB = `${DOMAIN}/io.smartgift.agenthub`;
let calls = [];
let loaded = false;
let running = false;
let failBootstrap = false;
let noGui = false;
beforeEach(() => {
calls = [];
loaded = running = failBootstrap = noGui = false;
fs.rmSync(svc.unitPath("agenthub"), { force: true });
svc.__setExecImpl((args) => {
if (args[0] === "print" && args[1] === DOMAIN) return { status: noGui ? 113 : 0 };
calls.push(args.join(" "));
if (args[0] === "print") {
return {
status: loaded ? 0 : 113,
stdout: running ? "\tstate = running\n" : "\tstate = not running\n",
};
}
if (args[0] === "bootstrap") return { status: failBootstrap ? 5 : 0 };
return { status: 0 };
});
});
after(() => {
svc.__reset();
Object.defineProperty(process, "platform", realPlatform);
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
const UNIT = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: "/plugin/root/new & improved",
execStart: "/usr/local/bin/node /plugin/root/new/server/index.js",
argv: ["/usr/local/bin/node", "/plugin/root/new & improved/server/index.js"],
env: { NODE_ENV: "production", PATH: "/usr/bin:/bin" },
};
const plist = () => fs.readFileSync(svc.unitPath("agenthub"), "utf8");
describe("launchd-service — writeUserService", () => {
it("writes a LaunchAgent that restarts forever and spares the lane panes, and starts nothing", () => {
assert.equal(svc.writeUserService(UNIT), "written");
const body = plist();
assert.ok(svc.unitPath("agenthub").startsWith(path.join(TMP_HOME, "Library", "LaunchAgents")));
assert.match(body, /<key>Label<\/key>\s*<string>io\.smartgift\.agenthub<\/string>/);
assert.match(body, /<key>KeepAlive<\/key>\s*<true\/>/);
assert.match(body, /<key>RunAtLoad<\/key>\s*<true\/>/);
assert.match(body, /<key>AbandonProcessGroup<\/key>\s*<true\/>/);
// argv stays split, so a path with a space is one argument; XML escaped.
assert.match(body, /<string>\/plugin\/root\/new &amp; improved\/server\/index\.js<\/string>/);
assert.match(body, /<key>PATH<\/key>\s*<string>\/usr\/bin:\/bin<\/string>/);
assert.deepEqual(calls, [`enable ${JOB}`], "writing must not load or start the job");
});
it("leaves an identical plist alone", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.writeUserService(UNIT), "unchanged");
assert.deepEqual(calls, []);
});
it("never overwrites a plist it did not write", () => {
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "<plist>theirs</plist>");
assert.equal(svc.writeUserService(UNIT), "foreign");
assert.equal(plist(), "<plist>theirs</plist>");
assert.equal(svc.uninstallUserService("agenthub"), "foreign");
assert.ok(fs.existsSync(svc.unitPath("agenthub")));
});
it("is unavailable without a GUI login, and writes nothing", () => {
noGui = true;
assert.equal(svc.systemctlAvailable(), false);
assert.equal(svc.writeUserService(UNIT), "unavailable");
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
});
});
describe("launchd-service — stale and root", () => {
it("reads the working directory back, escaped path included", () => {
svc.writeUserService(UNIT);
assert.equal(svc.unitRoot("agenthub"), UNIT.workingDir);
assert.equal(svc.unitIsStale("agenthub", UNIT.workingDir), false);
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/newer"), true);
});
it("calls neither a missing nor a foreign plist stale", () => {
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(
svc.unitPath("agenthub"),
"<key>WorkingDirectory</key><string>/elsewhere</string>"
);
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
assert.equal(svc.unitRoot("agenthub"), "/elsewhere", "the pruner still must not delete it");
});
});
describe("launchd-service — restart, install, uninstall", () => {
it("restarts by bootout + bootstrap, so the new plist is what runs", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.restartUserService("agenthub"), "restarted");
assert.deepEqual(calls, [`bootout ${JOB}`, `bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`]);
});
it("reports a failed bootstrap, so the caller can fall back", () => {
failBootstrap = true;
assert.equal(svc.restartUserService("agenthub"), "failed");
});
it("install loads a new plist, and only kickstarts one already loaded", () => {
assert.equal(svc.installUserService(UNIT), "written");
assert.ok(calls.includes(`bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`));
calls = [];
loaded = true;
assert.equal(svc.installUserService(UNIT), "unchanged");
assert.ok(calls.includes(`kickstart ${JOB}`));
assert.ok(!calls.some((c) => c.startsWith("bootout")), "a running dashboard is not bounced");
});
it("uninstall unloads and deletes our plist", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.uninstallUserService("agenthub"), "removed");
assert.deepEqual(calls, [`bootout ${JOB}`]);
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
assert.equal(svc.uninstallUserService("agenthub"), "absent");
});
it("is active only when launchd says the job is running", () => {
assert.equal(svc.unitIsActive("agenthub"), false);
loaded = true;
assert.equal(svc.unitIsActive("agenthub"), false);
running = true;
assert.equal(svc.unitIsActive("agenthub"), true);
});
});
describe("service-manager", () => {
it("hands out the LaunchAgent on macOS", () => {
delete require.cache[require.resolve("../../scripts/service-manager")];
assert.equal(require("../../scripts/service-manager").kind, "launchd");
});
});