952e8584f0
- 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.
177 lines
7.0 KiB
JavaScript
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 & 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");
|
|
});
|
|
});
|