/** * @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ĩ */ 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 with a recorded-but-not-live run returns 409", async () => { const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" }); const id = c.body.lane.id; // Patch the lane with a bogus run_id (never existed, so not live). await request("PATCH", `/api/lanes/${id}`, { run_id: "nonexistent-run" }); const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" }); assert.equal(r.status, 409); assert.equal(r.body.error.code, "ENORUN"); 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 && ...`. 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}`); }); });