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.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Unit tests for Codex stage ingestion: the payload→hook-event mapping,
|
||||
* and one full pass over a fixture rollout journal (detection + status mirror +
|
||||
* broadcast + the no-new-bytes no-op).
|
||||
* @file Unit tests for the Codex lane status mirror: one pass over fixture
|
||||
* rollout journals flips a codex lane idle↔running, broadcasts it, and never
|
||||
* touches a lane's stage.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,87 +26,11 @@ function line(payload, type = "response_item") {
|
||||
return JSON.stringify({ timestamp: new Date().toISOString(), ordinal: 1, type, payload });
|
||||
}
|
||||
|
||||
describe("codex payload mapping", () => {
|
||||
it("maps a function_call exec_command to a Bash event", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: "/repo" }),
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps a custom_tool_call exec to a Bash event carrying the raw script", () => {
|
||||
const [event] = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input: 'const r = await tools.exec_command({cmd:"git push origin feat/x"}); text(r.output);',
|
||||
});
|
||||
assert.equal(event.tool_name, "Bash");
|
||||
assert.match(event.tool_input.command, /git push origin feat\/x/);
|
||||
});
|
||||
|
||||
it("maps an apply_patch script to one event per touched file", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input:
|
||||
'const patch = "*** Begin Patch\n*** Update File: server/lib/lanes.js\n+x\n' +
|
||||
'*** Add File: docs/NEW.md\n+y\n*** End Patch";',
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Edit", tool_input: { file_path: "server/lib/lanes.js" } },
|
||||
{ tool_name: "Write", tool_input: { file_path: "docs/NEW.md" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps an apply_patch script whose newlines are still escaped", () => {
|
||||
// Verbatim from a real rollout: the patch heredoc lives inside a JS string
|
||||
// literal, so after JSON.parse the line breaks are a literal backslash-n,
|
||||
// not newlines. Matching only real newlines silently sees no files at all.
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input:
|
||||
'const patch = "*** Begin Patch\\n*** Add File: config/demo.yml\\n+# demo\\n*** End Patch";',
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Write", tool_input: { file_path: "config/demo.yml" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps update_plan to TaskCreate and spawn_agent to Agent", () => {
|
||||
assert.deepEqual(
|
||||
codex.eventsFromPayload({ type: "function_call", name: "update_plan", arguments: "{}" }),
|
||||
[{ tool_name: "TaskCreate", tool_input: {} }]
|
||||
);
|
||||
assert.deepEqual(
|
||||
codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: "spawn_agent",
|
||||
arguments: JSON.stringify({ task_name: "code_reviewer", message: "…" }),
|
||||
}),
|
||||
[{ tool_name: "Agent", tool_input: { subagent_type: "code_reviewer" } }]
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores anything that is not a tool call", () => {
|
||||
assert.deepEqual(codex.eventsFromPayload({ type: "reasoning" }), []);
|
||||
assert.deepEqual(
|
||||
codex.eventsFromPayload({ type: "custom_tool_call_output", name: "exec" }),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(codex.eventsFromPayload({ type: "function_call", name: "web_search" }), []);
|
||||
assert.deepEqual(codex.eventsFromPayload(null), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex ingest pass", () => {
|
||||
describe("codex status mirror", () => {
|
||||
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-"));
|
||||
const DIR = path.join(HOME, "sessions", "2026", "08", "27");
|
||||
let prevHome;
|
||||
let seq = 0;
|
||||
|
||||
before(() => {
|
||||
prevHome = process.env.CODEX_HOME;
|
||||
@@ -117,21 +41,18 @@ describe("codex ingest pass", () => {
|
||||
after(() => {
|
||||
if (prevHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevHome;
|
||||
codex.__resetOffsets();
|
||||
try {
|
||||
fs.rmSync(HOME, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
codex.__resetCache();
|
||||
fs.rmSync(HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A rollout journal for `cwd` with one `npm run test:server` shell call. */
|
||||
function writeRollout(name, cwd) {
|
||||
const file = path.join(DIR, name);
|
||||
function writeRollout(cwd, { mtime } = {}) {
|
||||
seq += 1;
|
||||
const file = path.join(DIR, `rollout-2026-08-27T10-00-00-${seq}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd, session_id: "abc" }, "session_meta"),
|
||||
line({ cwd, session_id: `s${seq}` }, "session_meta"),
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
@@ -139,256 +60,97 @@ describe("codex ingest pass", () => {
|
||||
}),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
if (mtime !== undefined) fs.utimesSync(file, mtime / 1000, mtime / 1000);
|
||||
codex.__resetCache();
|
||||
return file;
|
||||
}
|
||||
|
||||
it("detects a stage for a codex lane and mirrors its status", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-00-aaa.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
const codexLane = (prefix = "codex-lane-") =>
|
||||
lanes.createLane({
|
||||
title: "cx",
|
||||
cwd: fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||
tool: "codex",
|
||||
});
|
||||
|
||||
it("marks a codex lane running while its journal is fresh, and broadcasts it", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.detected_stage, "tests"); // `npm run test:server` → default template's tests node
|
||||
assert.match(after.detected_signal, /test:server/);
|
||||
assert.equal(after.status, "running"); // a journal written just now means work is happening
|
||||
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||
assert.deepEqual(seen, [lane.id]);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("advances the declared stage from the detection, without evidence", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-10-adv.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
it("never writes a stage or a detection, whatever the journal ran", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
// The ring moves — a codex session has nothing else that would move it…
|
||||
assert.equal(after.stage, "tests");
|
||||
// …but the node stays amber: an inference sees the tool call, not its result.
|
||||
assert.equal(after.stages.tests.evidence, null);
|
||||
// …and the badge survives, so the card still says WHY it moved.
|
||||
assert.equal(after.detected_stage, "tests");
|
||||
assert.match(after.detected_signal, /test:server/);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never drags an auto-advanced stage backwards", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
lanes.setStage(lane.id, { stage: "review", evidence: "declared by the session" });
|
||||
writeRollout("rollout-2026-08-27T10-00-11-back.jsonl", cwd); // detects `tests`, behind `review`
|
||||
codex.__resetOffsets();
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.stage, "review"); // declared-wins: recordDetection refused, so no advance
|
||||
assert.equal(after.stage, "idle");
|
||||
assert.equal(after.detected_stage, null);
|
||||
assert.equal(after.stages.review.evidence, "declared by the session");
|
||||
assert.equal(after.detected_signal, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("a second pass with no appended bytes changes nothing", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-01-bbb.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
it("a second pass with nothing new changes nothing", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const first = lanes.getLane(lane.id).detected_at;
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_at, first);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("puts a running lane back to idle once its journal goes quiet", () => {
|
||||
const lane = codexLane();
|
||||
lanes.updateLane(lane.id, { status: "running" });
|
||||
writeRollout(lane.cwd, { mtime: Date.now() - 6 * 60 * 60 * 1000 });
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("treats a journal whose mtime is in the FUTURE as idle", () => {
|
||||
// A clock stepped back (or a restore that preserved timestamps) leaves a
|
||||
// finished thread's mtime ahead of now; raw `now - mtime` would read as live.
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd, { mtime: Date.now() + 6 * 60 * 60 * 1000 });
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never touches a claude lane, even at the journal's own cwd", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "claude-lane-"));
|
||||
const lane = lanes.createLane({ title: "cc", cwd, tool: "claude" });
|
||||
writeRollout("rollout-2026-08-27T10-00-02-ccc.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(cwd);
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("ignores a journal whose cwd belongs to no lane", () => {
|
||||
const orphan = fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-03-ddd.jsonl", orphan);
|
||||
codex.__resetOffsets();
|
||||
const lane = codexLane();
|
||||
writeRollout(fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-")));
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("keeps byte offsets exact when a tail read starts mid multi-byte character", () => {
|
||||
// The first sight of a >64 KiB journal reads only its tail, and that offset
|
||||
// can land inside a 3-byte UTF-8 sequence. Splitting the decoded STRING and
|
||||
// measuring it back with Buffer.byteLength then counts the decoder's U+FFFD
|
||||
// replacement instead of the bytes actually consumed, so the next pass
|
||||
// starts 1-2 bytes off and every later record is unparseable.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-2026-08-27T10-00-06-utf8.jsonl");
|
||||
const record = (cmd) =>
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd, workdir: cwd }),
|
||||
});
|
||||
|
||||
// Grow an all-multi-byte filler line until the tail boundary (size - 64 KiB)
|
||||
// falls on a UTF-8 continuation byte.
|
||||
const head = line({ cwd, session_id: "utf8" }, "session_meta") + "\n";
|
||||
const tailRecord = record("npm run test:server") + "\n";
|
||||
let filler = "→".repeat(30_000);
|
||||
for (let pad = 0; pad < 8; pad += 1) {
|
||||
const body = head + filler + "\n" + tailRecord;
|
||||
const buf = Buffer.from(body, "utf8");
|
||||
const boundary = buf.length - 64 * 1024;
|
||||
if (boundary > 0 && (buf[boundary] & 0xc0) === 0x80) break;
|
||||
filler += "x";
|
||||
}
|
||||
fs.writeFileSync(file, head + filler + "\n" + tailRecord);
|
||||
const buf = Buffer.from(fs.readFileSync(file));
|
||||
assert.equal(buf[buf.length - 64 * 1024] & 0xc0, 0x80, "fixture must straddle a UTF-8 char");
|
||||
|
||||
codex.__resetOffsets();
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
|
||||
|
||||
// The real proof: the next append must still parse, which it only does if
|
||||
// the stored offset is exactly on the previous line boundary.
|
||||
fs.appendFileSync(file, record("git push origin feat/x") + "\n");
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "ship");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("ingests a line appended exactly at the previous read's boundary", () => {
|
||||
// The offset after a pass lands on a line boundary, so the next chunk's
|
||||
// FIRST line is a whole record — dropping it as torn loses every append
|
||||
// that arrives one line at a time, which is how a journal actually grows.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-2026-08-27T10-00-04-fff.jsonl");
|
||||
fs.writeFileSync(file, line({ cwd, session_id: "boundary" }, "session_meta") + "\n");
|
||||
codex.__resetOffsets();
|
||||
codex.ingestCodexOnce({}); // consumes the header, offset now at EOF
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
|
||||
fs.appendFileSync(
|
||||
file,
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
}) + "\n"
|
||||
);
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("re-reads a torn line whole on the next pass", () => {
|
||||
// A pass that catches the writer mid-line must not consume that fragment:
|
||||
// the record it belongs to would be lost forever.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-2026-08-27T10-00-05-ggg.jsonl");
|
||||
const record = line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
});
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
line({ cwd, session_id: "torn" }, "session_meta") + "\n" + record.slice(0, 40)
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
codex.ingestCodexOnce({});
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
|
||||
fs.appendFileSync(file, record.slice(40) + "\n"); // the writer finishes the line
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("does not replay a journal it sees for the first time already idle", () => {
|
||||
// A thread that finished days ago must not paint a stage now — the first
|
||||
// sight of a stale file only records where it ends.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = writeRollout("rollout-2026-08-20T10-00-00-eee.jsonl", cwd);
|
||||
const old = Date.now() - 6 * 60 * 60 * 1000;
|
||||
fs.utimesSync(file, old / 1000, old / 1000);
|
||||
codex.__resetOffsets();
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("does not replay a journal whose mtime is in the FUTURE", () => {
|
||||
// A clock stepped back (or a restore that preserved timestamps) leaves a
|
||||
// finished thread's mtime ahead of now. Raw `now - mtime` is then negative,
|
||||
// which reads as "written milliseconds ago" and would replay it as live work.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = writeRollout("rollout-2026-08-27T10-00-00-fut.jsonl", cwd);
|
||||
const future = Date.now() + 6 * 60 * 60 * 1000;
|
||||
fs.utimesSync(file, future / 1000, future / 1000);
|
||||
codex.__resetOffsets();
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("ignores a torn/partial JSONL line at the end of a file", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-torn.jsonl");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd, session_id: "torn" }, "session_meta"),
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
}),
|
||||
// A torn last line: not valid JSON, so it will be skipped on next pass
|
||||
'{"timestamp":"2026-08-27T10:00:00.000Z","type":"response_item","paylo',
|
||||
].join("\n")
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.detected_stage, "tests"); // the valid line was processed
|
||||
assert.deepEqual(seen, [lane.id]);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("handles a journal whose session_meta is unparseable gracefully", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-bad-meta-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-bad-meta.jsonl");
|
||||
// Write an invalid JSON line as the first line
|
||||
fs.writeFileSync(file, "not valid json at all\n");
|
||||
codex.__resetOffsets();
|
||||
const lane = codexLane();
|
||||
fs.writeFileSync(path.join(DIR, "rollout-bad-meta.jsonl"), "not valid json at all\n");
|
||||
codex.__resetCache();
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
@@ -397,50 +159,31 @@ describe("codex ingest pass", () => {
|
||||
const inner = fs.mkdtempSync(path.join(outer, "inner"));
|
||||
const outerLane = lanes.createLane({ title: "outer", cwd: outer, tool: "codex" });
|
||||
const innerLane = lanes.createLane({ title: "inner", cwd: inner, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-nested.jsonl");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd: inner, session_id: "nested" }, "session_meta"),
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: inner }),
|
||||
}),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(inner);
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
// Inner lane should be the one detected, not outer
|
||||
assert.equal(lanes.getLane(innerLane.id).detected_stage, "tests");
|
||||
assert.equal(lanes.getLane(outerLane.id).detected_stage, null);
|
||||
assert.equal(lanes.getLane(innerLane.id).status, "running");
|
||||
assert.equal(lanes.getLane(outerLane.id).status, "idle");
|
||||
assert.deepEqual(seen, [innerLane.id]);
|
||||
lanes.deleteLane(innerLane.id);
|
||||
lanes.deleteLane(outerLane.id);
|
||||
});
|
||||
|
||||
it("does not touch status of a lane with an active run_id", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-run-id-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const lane = codexLane();
|
||||
lanes.updateLane(lane.id, { run_id: "active-run" });
|
||||
writeRollout("rollout-2026-08-27T10-00-10-rrr.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
// Status should not change because run_id is present
|
||||
assert.equal(after.status, "idle");
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("does not change status from provisioning or failed", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-status-protected-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const lane = codexLane();
|
||||
lanes.updateLane(lane.id, { status: "provisioning" });
|
||||
writeRollout("rollout-2026-08-27T10-00-11-ppp.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
assert.equal(lanes.getLane(lane.id).status, "provisioning");
|
||||
@@ -455,119 +198,13 @@ describe("codex ingest pass", () => {
|
||||
const oldMs = process.env.DASHBOARD_CODEX_POLL_MS;
|
||||
try {
|
||||
process.env.DASHBOARD_CODEX_POLL_MS = "0";
|
||||
// startCodexPoll should return early and not set up an interval
|
||||
const timer = codex.startCodexPoll(() => {});
|
||||
assert.equal(timer, undefined);
|
||||
assert.equal(
|
||||
codex.startCodexPoll(() => {}),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
if (oldMs === undefined) delete process.env.DASHBOARD_CODEX_POLL_MS;
|
||||
else process.env.DASHBOARD_CODEX_POLL_MS = oldMs;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles a shell call with command as an array in arguments", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ command: ["npm", "run", "test:server"], workdir: "/repo" }),
|
||||
});
|
||||
// Should join array elements
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a payload with a non-string name", () => {
|
||||
// A payload with name as null or number should not match any tool
|
||||
const events1 = codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: null,
|
||||
arguments: "{}",
|
||||
});
|
||||
assert.deepEqual(events1, []);
|
||||
|
||||
const events2 = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: 123,
|
||||
input: "some script",
|
||||
});
|
||||
assert.deepEqual(events2, []);
|
||||
});
|
||||
|
||||
it("uses the LAST detection in a batch to move the lane forward", () => {
|
||||
// Within one ingest pass, if multiple tool calls map to different stages,
|
||||
// recordDetection is called in order. Only the LAST one that actually writes
|
||||
// should move the lane forward (forward-only). Simulate two tool calls:
|
||||
// first impl call (implement), then tests call (tests).
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-batch-last-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-batch-last.jsonl");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd, session_id: "batch" }, "session_meta"),
|
||||
// Edit event (implement node)
|
||||
line({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input: 'await tools.edit("src/main.js", "add x");',
|
||||
}),
|
||||
// Bash event (tests node)
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
}),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
const after = lanes.getLane(lane.id);
|
||||
// Should detect the tests node (later in batch), not implement
|
||||
assert.equal(after.detected_stage, "tests");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("limits file scan to MAX_FILES newest files", () => {
|
||||
// Create more than MAX_FILES journal files and verify only newest are scanned
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-max-files-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
|
||||
// Create 50 old files (more than MAX_FILES=40)
|
||||
const oldTime = Date.now() - 2 * 60 * 60 * 1000;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const file = path.join(DIR, `rollout-old-${String(i).padStart(2, "0")}.jsonl`);
|
||||
fs.writeFileSync(file, line({ cwd, session_id: `old-${i}` }, "session_meta") + "\n");
|
||||
fs.utimesSync(file, oldTime / 1000, oldTime / 1000);
|
||||
}
|
||||
codex.__resetOffsets();
|
||||
|
||||
// The scan should not stall and should return (no detection expected since all files are stale)
|
||||
const result = codex.ingestCodexOnce({});
|
||||
// All files are stale and not active, so no broadcast
|
||||
assert.equal(result, 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("detects correctly with Delete File in patch heredoc", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input: 'const patch = "*** Begin Patch\\n*** Delete File: old/removed.js\\n*** End Patch";',
|
||||
});
|
||||
// Delete creates an Edit event (not Write, not ignored)
|
||||
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "old/removed.js" } }]);
|
||||
});
|
||||
|
||||
it("handles file paths with whitespace in patch patches", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input:
|
||||
'const patch = "*** Begin Patch\\n*** Update File: src/my file.js \\n*** End Patch";',
|
||||
});
|
||||
// File path should be trimmed
|
||||
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "src/my file.js" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* @file Unit tests for the codex evidence safety net: nudges a live codex pane
|
||||
* to run `agenthub stage --evidence` when the lane has sat at an inferred (
|
||||
* evidence-less) stage past the delay, without ever writing stage itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const SUITE_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`dashboard-codex-nudge-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { db } = require("../db");
|
||||
const lanes = require("../lib/lanes");
|
||||
const tmux = require("../lib/tmux");
|
||||
const nudge = require("../lib/codex-nudge");
|
||||
|
||||
/** Backdate the current stage's enteredAt so it looks `minutes` old. */
|
||||
function backdateStage(laneId, minutes) {
|
||||
const lane = lanes.getLane(laneId);
|
||||
const stages = { ...lane.stages };
|
||||
stages[lane.stage] = {
|
||||
...stages[lane.stage],
|
||||
enteredAt: new Date(Date.now() - minutes * 60_000).toISOString(),
|
||||
};
|
||||
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId);
|
||||
}
|
||||
|
||||
describe("codex-nudge sweep", () => {
|
||||
let calls;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "codex"; // pane running the codex CLI
|
||||
return "";
|
||||
});
|
||||
nudge.__resetNudgeState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tmux.__reset();
|
||||
});
|
||||
|
||||
function makeCodexLane({ cwd, stage = "tests", evidence }) {
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
|
||||
lanes.setStage(lane.id, { stage, evidence });
|
||||
return lane;
|
||||
}
|
||||
|
||||
it("nudges once the lane has held an evidence-less stage past the delay", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` });
|
||||
backdateStage(lane.id, 3); // older than the 2 min delay
|
||||
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
|
||||
assert.equal(sendKeys.length, 1);
|
||||
assert.match(sendKeys[0][4], /agenthub stage tests --tool codex --evidence/);
|
||||
|
||||
// A second pass for the same still-unevidenced stage must not repeat it.
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges before the delay has elapsed", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` });
|
||||
// enteredAt is "now" — setStage just wrote it.
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a stage the session already backed with evidence", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" });
|
||||
backdateStage(lane.id, 10);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a lane that has not entered a pipeline node", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" });
|
||||
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a claude lane", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
|
||||
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
|
||||
lanes.setStage(lane.id, { stage: "tests" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a lane with no live run", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" });
|
||||
lanes.setStage(lane.id, { stage: "tests" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never types into a pane sitting at a bare shell prompt", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` });
|
||||
backdateStage(lane.id, 3);
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "bash";
|
||||
return "";
|
||||
});
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("nudges again once the lane moves to another evidence-less stage", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
|
||||
lanes.setStage(lane.id, { stage: "review" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
});
|
||||
@@ -661,6 +661,107 @@ describe("runGitAction — one hunk at a time", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runGitAction — one change block inside a hunk (issue #50)", () => {
|
||||
beforeEach(freshFixture);
|
||||
|
||||
const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
||||
/** Three change blocks close enough for git to merge into ONE hunk: lines
|
||||
* 4-5 replaced, a line inserted after 8, line 11 deleted. */
|
||||
function threeBlocks() {
|
||||
write(LANE, "near.txt", base.join("\n") + "\n");
|
||||
gc(LANE, "add", "near.txt");
|
||||
gc(LANE, "commit", "-qm", "near");
|
||||
const next = [...base];
|
||||
next.splice(3, 2, "FOUR", "FIVE");
|
||||
next.splice(8, 0, "INSERTED");
|
||||
next.splice(11, 1); // "line 11"
|
||||
write(LANE, "near.txt", next.join("\n") + "\n");
|
||||
return next;
|
||||
}
|
||||
const hunkOf = async () => {
|
||||
const [file] = (await collectChanges(LANE, { range: "work", file: "near.txt" })).files;
|
||||
return file.hunks;
|
||||
};
|
||||
const read = () => fs.readFileSync(path.join(LANE, "near.txt"), "utf8").split("\n");
|
||||
|
||||
it("sees the three blocks as one git hunk (the shape the issue reports)", async () => {
|
||||
threeBlocks();
|
||||
assert.equal((await hunkOf()).length, 1);
|
||||
});
|
||||
|
||||
it("stages ONE block, leaving the other two in the working tree", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 1 });
|
||||
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
|
||||
assert.match(staged, /^\+INSERTED$/m);
|
||||
assert.doesNotMatch(staged, /FOUR|FIVE|^-line 11$/m);
|
||||
const unstaged = g(LANE, "diff", "--", "near.txt");
|
||||
assert.match(unstaged, /^\+FOUR$/m);
|
||||
assert.match(unstaged, /^-line 11$/m);
|
||||
assert.doesNotMatch(unstaged, /^\+INSERTED$/m); // staged: only context now
|
||||
});
|
||||
|
||||
it("stages the LAST block after earlier ones shifted the line count", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 2 });
|
||||
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
|
||||
assert.match(staged, /^-line 11$/m);
|
||||
assert.doesNotMatch(staged, /FOUR|INSERTED/);
|
||||
});
|
||||
|
||||
it("undoes ONE block in the working tree, keeping the blocks around it", async () => {
|
||||
const edited = threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await runGitAction(LANE, "discard-hunk", {
|
||||
path: "near.txt",
|
||||
hunk: hunk.header,
|
||||
block: 0,
|
||||
confirm: true,
|
||||
});
|
||||
const want = [...edited];
|
||||
want.splice(3, 2, "line 4", "line 5");
|
||||
assert.deepEqual(read(), [...want, ""]);
|
||||
});
|
||||
|
||||
it("splits a hunk whose last block only drops the final newline", async () => {
|
||||
// One hunk: line 2 replaced, and line 6 losing its final newline — git
|
||||
// writes that second block with a "\\ No newline" marker after its "+".
|
||||
write(LANE, "tail.txt", "l1\nl2\nl3\nl4\nl5\nl6\n");
|
||||
gc(LANE, "add", "tail.txt");
|
||||
gc(LANE, "commit", "-qm", "tail");
|
||||
write(LANE, "tail.txt", "l1\nTWO\nl3\nl4\nl5\nl6");
|
||||
const [file] = (await collectChanges(LANE, { range: "work", file: "tail.txt" })).files;
|
||||
assert.equal(file.hunks.length, 1);
|
||||
const header = file.hunks[0].header;
|
||||
|
||||
await runGitAction(LANE, "stage-hunk", { path: "tail.txt", hunk: header, block: 1 });
|
||||
const staged = g(LANE, "diff", "--cached", "--", "tail.txt");
|
||||
assert.match(staged, /No newline at end of file/);
|
||||
assert.doesNotMatch(staged, /TWO/);
|
||||
|
||||
await runGitAction(LANE, "discard-hunk", {
|
||||
path: "tail.txt",
|
||||
hunk: header,
|
||||
block: 0,
|
||||
confirm: true,
|
||||
});
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
|
||||
});
|
||||
|
||||
it("refuses a block the hunk does not have", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
for (const block of [3, -1, 1.5, "0"]) {
|
||||
await assert.rejects(
|
||||
() => runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block }),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unborn repo and `publish`", () => {
|
||||
const EMPTY = path.join(ROOT, "empty");
|
||||
const URL_OK = "https://example.test/owner/r.git";
|
||||
|
||||
@@ -18,7 +18,7 @@ const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { installAgents } = require("../lib/lane-agents");
|
||||
const { installAgents, removeStageDoc } = require("../lib/lane-agents");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
@@ -116,29 +116,77 @@ describe("installAgents", () => {
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
|
||||
});
|
||||
|
||||
it("installs the AGENTS.md stage block for a codex lane, not .claude/agents/", async () => {
|
||||
it("installs nothing for a codex lane — no .claude/agents/, no AGENTS.md", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
|
||||
|
||||
const result = await installAgents(lane);
|
||||
assert.deepEqual(result.installed, ["AGENTS.md"]);
|
||||
assert.deepEqual(result.installed, []);
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||
const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
|
||||
assert.match(doc, /agenthub stage <stage> --tool codex/);
|
||||
});
|
||||
|
||||
it("preserves the user's own AGENTS.md prose and replaces only the block", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
|
||||
const file = path.join(cwd, "AGENTS.md");
|
||||
fs.writeFileSync(file, "# House rules\n\nNever force-push.\n");
|
||||
|
||||
await installAgents(lane);
|
||||
await installAgents(lane); // reinstall must not duplicate the block
|
||||
|
||||
const doc = fs.readFileSync(file, "utf8");
|
||||
assert.match(doc, /Never force-push\./);
|
||||
assert.equal(doc.split("<!-- agenthub:stage:start -->").length - 1, 1);
|
||||
assert.equal(doc.split("<!-- agenthub:stage:end -->").length - 1, 1);
|
||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex pipeline cleanup", () => {
|
||||
const START = "<!-- agenthub:stage:start -->";
|
||||
const END = "<!-- agenthub:stage:end -->";
|
||||
const BLOCK = `${START}\n## Declare your lane stage as you work\n\nagenthub stage …\n${END}\n`;
|
||||
|
||||
it("removes only the AgentHub block, keeping the user's own AGENTS.md prose", () => {
|
||||
const cwd = makeRepoDir();
|
||||
const file = path.join(cwd, "AGENTS.md");
|
||||
fs.writeFileSync(file, `# House rules\n\nNever force-push.\n\n${BLOCK}\n## More\n\nKeep me.\n`);
|
||||
|
||||
assert.equal(removeStageDoc(cwd), true);
|
||||
const doc = fs.readFileSync(file, "utf8");
|
||||
assert.ok(!doc.includes(START) && !doc.includes(END));
|
||||
assert.match(doc, /Never force-push\./);
|
||||
assert.match(doc, /Keep me\./);
|
||||
});
|
||||
|
||||
it("deletes an AGENTS.md that held nothing but the block", () => {
|
||||
const cwd = makeRepoDir();
|
||||
fs.writeFileSync(path.join(cwd, "AGENTS.md"), BLOCK);
|
||||
assert.equal(removeStageDoc(cwd), true);
|
||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||
});
|
||||
|
||||
it("leaves a file without the block, or no file at all, untouched", () => {
|
||||
const cwd = makeRepoDir();
|
||||
assert.equal(removeStageDoc(cwd), false);
|
||||
fs.writeFileSync(path.join(cwd, "AGENTS.md"), "# Mine\n");
|
||||
assert.equal(removeStageDoc(cwd), false);
|
||||
assert.equal(fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"), "# Mine\n");
|
||||
});
|
||||
|
||||
it("wipes a codex lane's stage, detection, stage log and features — and no claude lane's", () => {
|
||||
const codex = lanesLib.createLane({ title: "cx", cwd: makeRepoDir(), tool: "codex" });
|
||||
const claude = lanesLib.createLane({ title: "cc", cwd: makeRepoDir(), tool: "claude" });
|
||||
for (const lane of [codex, claude]) {
|
||||
lanesLib.setStage(lane.id, { stage: "tests", evidence: "old" });
|
||||
lanesLib.recordDetection(lane.id, { nodeId: "review", signal: "npm test" });
|
||||
}
|
||||
const { db } = require("../db");
|
||||
const insertFeature = db.prepare("INSERT INTO lane_features (lane_id, slug) VALUES (?, ?)");
|
||||
insertFeature.run(codex.id, "old-feature");
|
||||
insertFeature.run(claude.id, "kept-feature");
|
||||
|
||||
assert.equal(lanesLib.clearCodexPipelines(), 1);
|
||||
|
||||
const cx = lanesLib.getLane(codex.id);
|
||||
assert.equal(cx.stage, "idle");
|
||||
assert.deepEqual(cx.stages, {});
|
||||
assert.equal(cx.detected_stage, null);
|
||||
const count = (table, id) =>
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE lane_id = ?`).get(id).n;
|
||||
assert.equal(count("lane_stage_events", codex.id), 0);
|
||||
assert.equal(count("lane_features", codex.id), 0);
|
||||
|
||||
const cc = lanesLib.getLane(claude.id);
|
||||
assert.equal(cc.stage, "tests");
|
||||
assert.ok(count("lane_stage_events", claude.id) > 0);
|
||||
assert.equal(count("lane_features", claude.id), 1);
|
||||
|
||||
assert.equal(lanesLib.clearCodexPipelines(), 0); // idempotent
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcp-"));
|
||||
const FAKE_HOME = path.join(SUITE_ROOT, "home");
|
||||
@@ -163,21 +164,77 @@ describe("syncMcp — profile seeding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncMcp — .git/info/exclude idempotency", () => {
|
||||
it("appends .mcp.json once and does not duplicate it on a second call", async () => {
|
||||
describe("syncMcp — keeping .mcp.json out of git", () => {
|
||||
// Real repos, asserted through `git status`: git reads info/exclude from the
|
||||
// COMMON git dir only, which a fake `.git` dir cannot show.
|
||||
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
|
||||
// would point these fixture calls at the outer repo.
|
||||
const git = (cwd, ...args) => {
|
||||
const env = { ...process.env };
|
||||
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
||||
delete env[name];
|
||||
}
|
||||
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
|
||||
};
|
||||
const untracked = (cwd) => git(cwd, "status", "--porcelain").split("\n").filter(Boolean);
|
||||
|
||||
function mcpLane(cwd) {
|
||||
const sourceRepo = path.join(SUITE_ROOT, "src-exclude");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
writeClaudeJson({
|
||||
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
|
||||
});
|
||||
const lane = makeLane(sourceRepo);
|
||||
fs.mkdirSync(path.join(lane.cwd, ".git"), { recursive: true });
|
||||
return { cwd, source_repo: sourceRepo };
|
||||
}
|
||||
|
||||
await laneMcp.syncMcp(lane);
|
||||
await laneMcp.syncMcp(lane);
|
||||
function makeRepo() {
|
||||
const repo = makeLane(null).cwd;
|
||||
git(repo, "init", "-q");
|
||||
git(
|
||||
repo,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init"
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
const exclude = fs.readFileSync(path.join(lane.cwd, ".git", "info", "exclude"), "utf8");
|
||||
const matches = exclude.split("\n").filter((line) => line === ".mcp.json");
|
||||
assert.equal(matches.length, 1);
|
||||
it("hides .mcp.json, once, however many times it syncs", async () => {
|
||||
const repo = makeRepo();
|
||||
await laneMcp.syncMcp(mcpLane(repo));
|
||||
await laneMcp.syncMcp(mcpLane(repo));
|
||||
|
||||
assert.deepEqual(untracked(repo), []);
|
||||
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
|
||||
assert.equal(exclude.split("\n").filter((line) => line.includes(".mcp.json")).length, 1);
|
||||
});
|
||||
|
||||
it("hides it in a worktree lane, whose .git is a pointer file", async () => {
|
||||
const repo = makeRepo();
|
||||
const wt = path.join(SUITE_ROOT, `wt-${laneSeq}`);
|
||||
git(repo, "worktree", "add", "-q", "-b", "lane1", wt);
|
||||
await laneMcp.syncMcp(mcpLane(wt));
|
||||
assert.deepEqual(untracked(wt), []);
|
||||
});
|
||||
|
||||
it("hides it in a lane that is a subdirectory of its repo", async () => {
|
||||
const repo = makeRepo();
|
||||
const sub = path.join(repo, "packages", "app");
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
await laneMcp.syncMcp(mcpLane(sub));
|
||||
assert.deepEqual(untracked(repo), []);
|
||||
});
|
||||
|
||||
it("creates no .git in a lane that is not a repo", async () => {
|
||||
const cwd = makeLane(null).cwd;
|
||||
await laneMcp.syncMcp(mcpLane(cwd));
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".mcp.json")));
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".git")), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @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");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
@@ -144,50 +145,84 @@ describe("router-apply — Claude lane", () => {
|
||||
});
|
||||
|
||||
describe("router-apply — the written file must survive a lane reset", () => {
|
||||
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
|
||||
// Real repos, asserted through `git status`: a fake `.git` dir let a test pass
|
||||
// while git itself never read the exclude file it checked.
|
||||
const REPO = path.join(SUITE_ROOT, "repo");
|
||||
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
|
||||
// would point these fixture calls at the outer repo.
|
||||
const git = (cwd, ...args) => {
|
||||
const env = { ...process.env };
|
||||
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
||||
delete env[name];
|
||||
}
|
||||
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
|
||||
};
|
||||
const untracked = (cwd) =>
|
||||
git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no")
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
// A machine-wide ignore (Claude Code adds one for settings.local.json) would
|
||||
// hide the very file these tests look for.
|
||||
beforeEach(() => {
|
||||
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
git(REPO, "init", "-q");
|
||||
git(
|
||||
REPO,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init"
|
||||
);
|
||||
});
|
||||
|
||||
const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" });
|
||||
|
||||
it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => {
|
||||
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
|
||||
// Without -x that spares IGNORED files only — an untracked settings file
|
||||
// would be deleted, silently detaching the lane from its router. The same
|
||||
// entry also stops an API key from being committable.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("excludes it for a native lane too, not only a lane on a router", () => {
|
||||
// The blanked connection pair is load-bearing in its own right: losing it
|
||||
// to `git clean -fd` lets a USER-level router take over a lane that
|
||||
// explicitly asked for native.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => {
|
||||
// git reads info/exclude from the common git dir only; a copy under
|
||||
// .git/worktrees/<name>/info/ is never consulted.
|
||||
const wt = path.join(SUITE_ROOT, "wt");
|
||||
fs.rmSync(wt, { recursive: true, force: true });
|
||||
git(REPO, "worktree", "add", "-q", "-b", "lane1", wt);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(laneAt(wt), name);
|
||||
applyRouterToLane(laneAt(wt), "native");
|
||||
assert.deepEqual(untracked(wt), []);
|
||||
});
|
||||
|
||||
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
|
||||
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
|
||||
// would be a path that cannot exist.
|
||||
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
|
||||
it("hides them in a lane that is a subdirectory of its repo", () => {
|
||||
const sub = path.join(REPO, "packages", "app");
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
applyRouterToLane(laneAt(sub), name);
|
||||
applyRouterToLane(laneAt(sub), "native");
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("adds the entry once, however many times a router is applied", () => {
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
|
||||
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user