Files
Claude-Code-Monitor/server/__tests__/lanes-api.test.js
T
nntrivi2001 82bf803c2e fix(lanes): bridge routes/lanes.js to pty-run.js
Replace run-spawner imports and APIs with pty-run:
- Import pty-run instead of run-spawner
- Delete setRunExitHandler registration, replace with read-time self-heal in payload()
- Remove mode validation (mode no longer exists in pty-run)
- Update spawnRun call to use new parameter names (initialPrompt, not prompt/mode)
- Replace "message" action with explicit 400 EUNSUPPORTED response
- Fix stopLaneRun to poll on status !== "gone" instead of !actualExitedAt

Adapt tests to tmux-based run model:
- Delete tests about mode-specific behavior (removed feature)
- Rewrite lane release tests using tmux.__setExecImpl mocks instead of withFakeClaude
- Update assertions to check status === "gone" instead of specific exit codes
- Update ERUNTIMEOUT test to mock tmux sessions instead of child processes

All lane-related tests pass; only pre-existing port conflicts in lane-detect.test.js remain.
2026-08-12 10:53:23 +07:00

931 lines
34 KiB
JavaScript

/**
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
* counters the header badges read, and the last-event age that drives liveness.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const { WebSocket } = require("ws");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: {
...headers,
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
},
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch {
/* non-JSON body */
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());
describe("/api/lanes", () => {
let laneId;
it("creates a lane", async () => {
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.title, "Lane A");
assert.equal(r.body.lane.stage, "idle");
laneId = r.body.lane.id;
});
it("rejects a relative cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
assert.equal(r.status, 400);
});
it("rejects a duplicate cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 409);
});
it("reports a stage and returns node states", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
stage: "review",
status: "running",
evidence: null,
});
assert.equal(r.status, 200);
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "current");
assert.ok(r.body.lane.progress > 0);
});
it("404s on an unknown lane", async () => {
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
assert.equal(r.status, 404);
});
it("lists lanes with counters", async () => {
const r = await request("GET", "/api/lanes");
assert.equal(r.status, 200);
assert.ok(r.body.lanes.length >= 1);
assert.equal(r.body.counts.total, r.body.lanes.length);
assert.equal(typeof r.body.counts.running, "number");
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
assert.ok(r.body.pipelines.some((p) => p.id === "default"));
});
it("patches and deletes", async () => {
const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
assert.equal(p.body.lane.ci_status, "green");
const d = await request("DELETE", `/api/lanes/${laneId}`);
assert.equal(d.status, 200);
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
});
it("rejects cross-origin lane deletion", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
const id = created.body.lane.id;
const rejected = await request("DELETE", `/api/lanes/${id}`, undefined, {
Origin: "https://attacker.example",
});
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 200);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane patching", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-patch" });
const id = created.body.lane.id;
const rejected = await request(
"PATCH",
`/api/lanes/${id}`,
{ run_id: "attacker-run" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.run_id, null);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane creation", async () => {
const rejected = await request(
"POST",
"/api/lanes",
{ cwd: "/tmp/lane-cross-origin-create" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
});
it("rejects cross-origin stage reporting", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-stage" });
const id = created.body.lane.id;
const rejected = await request(
"POST",
`/api/lanes/${id}/stage`,
{ stage: "implement" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.stage, "idle");
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("broadcasts lane_update on create", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
const messages = [];
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("ws test timeout")), 5000);
});
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
Promise.race([timeout]).catch(reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
const createResp = await request("POST", "/api/lanes", {
title: "WS Test Lane",
cwd: "/tmp/ws-test-lane",
});
assert.equal(createResp.status, 201);
const newLaneId = createResp.body.lane.id;
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const laneUpdateMsg = messages.find(
(m) => m.type === "lane_update" && m.data.lane?.id === newLaneId
);
assert.ok(laneUpdateMsg, "create should broadcast lane_update");
assert.equal(laneUpdateMsg.data.lane.id, newLaneId);
assert.equal(laneUpdateMsg.data.lane.title, "WS Test Lane");
// Test delete broadcast
messages.length = 0;
const deleteResp = await request("DELETE", `/api/lanes/${newLaneId}`);
assert.equal(deleteResp.status, 200);
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const deleteMsg = messages.find(
(m) => m.type === "lane_update" && m.data.removed === newLaneId
);
assert.ok(deleteMsg, "delete should broadcast lane_update with removed");
assert.equal(deleteMsg.data.removed, newLaneId);
ws.close();
});
});
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-hook-a",
title: "Hooked",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("ignores a hook whose cwd is under no lane", async () => {
const before = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
});
it("clears needs_action only from the session that raised it, not from cross-session rebinds", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-cross-session",
title: "Cross-Session",
});
const id = created.body.lane.id;
// Session A sets needs_action
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session", message: "blocked" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-a");
// Session B PostToolUse: rebinds lane to B, but does NOT clear A's flag
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// Session B's second PostToolUse clears the flag (B is now bound)
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("raises needs_action with default 'needs you' when Notification has no message", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-default-msg",
title: "Default Msg",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs you");
await request("DELETE", `/api/lanes/${id}`);
});
it("the bare idle nudge never raises needs_action and clears a stale one", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-idle-nudge",
title: "Idle Nudge",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude needs your permission to use Bash",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"Claude needs your permission to use Bash"
);
// The 60s idle nudge fires after Stop: it proves the CLI is parked at an
// idle prompt, so it must clear rather than pin the banner.
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude is waiting for your input",
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null);
assert.equal(lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-turn-status",
title: "Turn Status",
});
const id = created.body.lane.id;
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
// A subagent finishing is not the end of the turn.
await request("POST", "/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
await request("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("hook → stage detection", () => {
it("a Bash test-run hook sets detected_stage to tests, a following Read leaves it unchanged", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-a",
title: "Detect",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
let lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Read",
tool_input: { file_path: "/tmp/lane-detect-a/foo.js" },
},
});
lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("a hook whose cwd is under no lane changes nothing", async () => {
const beforeLanes = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-orphan",
cwd: "/tmp/not-a-lane-detect",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, beforeLanes);
});
it("a lane already declared at ship ignores an implement detection", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-declared",
title: "Declared",
});
const id = created.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "ship", status: "running" });
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-2",
cwd: "/tmp/lane-detect-declared",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-declared/foo.js" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("a malformed hook payload still returns 200 and leaves the lane untouched", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-malformed",
title: "Malformed",
});
const id = created.body.lane.id;
const r1 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-3",
cwd: "/tmp/lane-detect-malformed",
tool_input: "just a string, no tool_name",
},
});
assert.equal(r1.status, 200);
const r2 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-detect-4", cwd: "/tmp/lane-detect-malformed" },
});
assert.equal(r2.status, 200);
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("detection is visible in GET /api/lanes/:id with a detected node, and no node is 'done'", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-visible",
title: "Visible",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-5",
cwd: "/tmp/lane-detect-visible",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
const testsNode = lane.pipeline_nodes.find((n) => n.id === "tests");
assert.equal(testsNode.detected, true);
assert.ok(!lane.pipeline_nodes.some((n) => n.state === "done"));
await request("DELETE", `/api/lanes/${id}`);
});
it("a throwing recordDetection does not cost the lane bookkeeping in the same hook", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-throws",
title: "Throws",
});
const id = created.body.lane.id;
// Bind the session and raise needs_action, exactly as a permission prompt does.
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-detect-throw", cwd: "/tmp/lane-detect-throws" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
message: "needs permission",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
// Stand in for the real failures recordDetection can raise mid-hook: ENOLANE
// when the lane is deleted between resolveLaneByCwd and its lookup, or
// SQLITE_BUSY from another process on the same database.
const lanesLib = require("../lib/lanes");
const realRecordDetection = lanesLib.recordDetection;
lanesLib.recordDetection = () => {
throw Object.assign(new Error("no lane 999"), { code: "ENOLANE" });
};
try {
const r = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-throws/foo.js" },
},
});
assert.equal(r.status, 200);
} finally {
lanesLib.recordDetection = realRecordDetection;
}
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null, "bookkeeping must survive a failed detection");
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("lane actions", () => {
it("requires confirmation, then removes an adopted lane row without its directory", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
const id = c.body.lane.id;
assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
const preflight = await request("GET", `/api/lanes/${id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${id}/remove`, {
confirm: true,
expect: {
head: preflight.body.head,
dirty: preflight.body.dirty,
untracked: preflight.body.untracked,
unpushed: preflight.body.unpushed,
},
});
assert.equal(removed.status, 200);
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
});
it("rejects an unknown action", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
const id = c.body.lane.id;
const before = await request("GET", `/api/lanes/${id}`);
const r = await request("POST", `/api/lanes/${id}/frobnicate`, {});
assert.equal(r.status, 400);
const after = await request("GET", `/api/lanes/${id}`);
assert.equal(after.body.lane.stage, before.body.lane.stage);
assert.equal(after.body.lane.status, before.body.lane.status);
await request("DELETE", `/api/lanes/${id}`);
});
it("clear resets stage state but keeps the lane", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
const id = c.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
const r = await request("POST", `/api/lanes/${id}/clear`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.stage, "idle");
assert.equal(r.body.lane.title, "Keep me");
assert.deepEqual(r.body.lane.stages, {});
await request("DELETE", `/api/lanes/${id}`);
});
it("stop on a lane with no run is a no-op, not a 500", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.status, "idle");
await request("DELETE", `/api/lanes/${c.body.lane.id}`);
});
it("message on a lane is no longer supported via REST", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
const id = c.body.lane.id;
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EUNSUPPORTED");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/:id/git", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-git-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("reports the working-copy facts for a lane pointing at a real repo", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/api-facts");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "one\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "api facts fixture");
fs.writeFileSync(path.join(dir, "a.txt"), "two\n");
fs.writeFileSync(path.join(dir, "untracked.txt"), "u\n");
const created = await request("POST", "/api/lanes", { cwd: dir, title: "git facts" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.branch, "feat/api-facts");
assert.equal(r.body.subject, "api facts fixture");
assert.equal(r.body.head, g(dir, "rev-parse", "--short", "HEAD").trim());
assert.equal(r.body.dirty, 1);
assert.equal(r.body.untracked, 1);
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false for a plain directory, not an error", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "plain dir" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false when the lane's directory is gone", async () => {
const dir = path.join(ROOT, "vanishes");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "vanishing" });
const id = created.body.lane.id;
fs.rmSync(dir, { recursive: true, force: true });
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("404s for a lane that does not exist", async () => {
const r = await request("GET", "/api/lanes/999999/git");
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOLANE");
});
it("resolves to the facts route, not an action error", async () => {
// The `/:id/:action` catch-all is a POST and cannot shadow this GET, so
// this pins the response SHAPE rather than any registration order.
const dir = path.join(ROOT, "ordering");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "ordering" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.ok("available" in r.body, JSON.stringify(r.body));
assert.equal(r.body.error, undefined);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/branches", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-branches-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("lists the repo's local branches and the current one", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "init");
g(dir, "branch", "feat/x");
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 200);
assert.deepEqual([...r.body.branches].sort(), ["feat/x", "main"]);
assert.equal(r.body.current, "main");
});
it("400s for a relative path", async () => {
const r = await request("GET", "/api/lanes/branches?repo=relative/path");
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a path that does not exist", async () => {
const r = await request(
"GET",
`/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`
);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a directory that is not a git repository", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("is not swallowed by the /:id catch-all", async () => {
// "/branches" would otherwise be read as a lane id and 404 with ENOLANE.
const r = await request("GET", "/api/lanes/branches?repo=%2Fdoes%2Fnot%2Fmatter");
assert.notEqual(r.body?.error?.code, "ENOLANE");
});
});
describe("detection attributes work to the lane the work touched", () => {
// Measured on a real session: 325 of 400 hook events carried the SESSION's
// cwd, while the actual edits and test runs happened inside a different
// repo reached with `cd <other> && ...`. The lane doing the work detected
// nothing; the lane the terminal happened to start in absorbed all of it.
it("credits a Bash command that cd's into another lane to THAT lane", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-session",
title: "where the shell started",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-worked",
title: "where the work happened",
});
const sessionId = session.body.lane.id;
const workedId = worked.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-1",
cwd: "/tmp/lane-attr-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-attr-worked && npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${workedId}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${sessionId}`)).body.lane.detected_stage,
null,
"the session's own lane did no work and must not be credited"
);
await request("DELETE", `/api/lanes/${workedId}`);
await request("DELETE", `/api/lanes/${sessionId}`);
});
it("credits an Edit to the lane owning the edited file", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-2",
cwd: "/tmp/lane-edit-session",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-edit-worked/server/lib/x.js" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.detected_stage,
"implement"
);
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.detected_stage,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("falls back to the session's lane when the tool names no other lane's path", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-fallback",
title: "fallback",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-3",
cwd: "/tmp/lane-attr-fallback",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("keeps session bookkeeping on the session's lane, not the worked-in lane", async () => {
// session_id / needs_action ARE session-scoped facts: the session really is
// bound to the directory it started in. Only the stage inference follows
// the work.
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-4",
cwd: "/tmp/lane-book-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-book-worked && npm test" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.session_id,
"sess-attr-4"
);
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
});