/** * @file Tests for the `agenthub stage` / `agenthub lanes` CLI subcommands: lane * resolution from the current directory, the stage round-trip through the HTTP * API, and the non-zero exit when the cwd belongs to no lane. * @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 fs = require("fs"); const http = require("http"); const { spawn } = require("child_process"); const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-cli-${Date.now()}-${process.pid}.db`); const PIPELINE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-pipeline-fixture-")); const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-managed-lanes-cli-")); const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-worktree-source-")); const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-worktree-remote-")); process.env.DASHBOARD_DB_PATH = TEST_DB; process.env.DASHBOARD_PIPELINES_DIR = PIPELINE_DIR; // AgentHub ships no pipeline template; the CLI's stage commands are tested // against the two old ones kept as fixtures. for (const f of ["default.json", "ship-feature.json"]) { fs.copyFileSync(path.join(__dirname, "fixtures", "pipelines", f), path.join(PIPELINE_DIR, f)); } process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.LANES_ROOT = LANES_ROOT; // Create a fixture pipeline template for testing custom pipeline selection fs.writeFileSync( path.join(PIPELINE_DIR, "test-pipeline.json"), JSON.stringify({ id: "test-pipeline", name: "Test Pipeline", nodes: [ { id: "start", label: "start", icon: "🚀", gate: false, aliases: [] }, { id: "end", label: "end", icon: "✓", gate: false, aliases: [] }, ], }) ); const { createApp, startServer } = require("../index"); const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-cli-")); const CLI = path.join(__dirname, "..", "..", "bin", "agenthub.js"); let server; let BASE; // Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this // very suite) sets in its own environment — those leak to every child // process and override an explicit `cwd`, so without this a git command // meant for this test's throwaway tmp repo silently operates on the real // repo running the hook instead. const GIT_ENV = { ...process.env }; delete GIT_ENV.GIT_DIR; delete GIT_ENV.GIT_WORK_TREE; delete GIT_ENV.GIT_INDEX_FILE; delete GIT_ENV.GIT_COMMON_DIR; delete GIT_ENV.GIT_OBJECT_DIRECTORY; delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES; delete GIT_ENV.GIT_PREFIX; delete GIT_ENV.GIT_NAMESPACE; delete GIT_ENV.GIT_CONFIG_PARAMETERS; function git(args, cwd) { return new Promise((resolve, reject) => { const child = spawn("git", args, { cwd, env: GIT_ENV }); let stderr = ""; child.stderr.on("data", (chunk) => (stderr += chunk)); child.on("error", reject); child.on("close", (status) => { if (status === 0) return resolve(); reject(new Error(`git ${args.join(" ")} failed (${status}): ${stderr}`)); }); }); } function post(urlPath, body) { return new Promise((resolve, reject) => { const url = new URL(urlPath, BASE); const payload = JSON.stringify(body); const req = http.request( { hostname: url.hostname, port: url.port, path: `${url.pathname}${url.search}`, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), }, }, (res) => { let d = ""; res.on("data", (c) => (d += c)); res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") })); } ); req.on("error", reject); req.write(payload); req.end(); }); } function get(urlPath) { return new Promise((resolve, reject) => { const url = new URL(urlPath, BASE); const req = http.request( { hostname: url.hostname, port: url.port, path: `${url.pathname}${url.search}`, method: "GET", }, (res) => { let d = ""; res.on("data", (c) => (d += c)); res.on("end", () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null })); } ); req.on("error", reject); req.end(); }); } // MUST be async: the test server runs in THIS process, so a blocking // spawnSync would stall the event loop and the CLI child's request to // 127.0.0.1 would never be served — a deadlock that looks like a network // sandbox blocking loopback. function cli(args, cwd, extraEnv = {}) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [CLI, ...args], { cwd, env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port), ...extraEnv }, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (c) => (stdout += c)); child.stderr.on("data", (c) => (stderr += c)); child.on("error", reject); child.on("close", (status) => resolve({ status, stdout, stderr })); }); } before(async () => { await git(["init", "--bare"], REMOTE_REPO); await git(["init", "--initial-branch=main"], SOURCE_REPO); await git(["config", "user.email", "lanes-cli@example.test"], SOURCE_REPO); await git(["config", "user.name", "Lanes CLI Test"], SOURCE_REPO); fs.writeFileSync(path.join(SOURCE_REPO, "README.md"), "fixture\n"); await git(["add", "README.md"], SOURCE_REPO); await git(["commit", "-m", "fixture"], SOURCE_REPO); await git(["remote", "add", "origin", REMOTE_REPO], SOURCE_REPO); await git(["push", "-u", "origin", "main"], SOURCE_REPO); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; // Poll for server readiness let ready = false; const deadline = Date.now() + 2000; // 2 second timeout while (!ready && Date.now() < deadline) { try { const res = await get("/api/health"); if (res.status === 200) ready = true; } catch { /* not ready yet */ } if (!ready) await new Promise((r) => setTimeout(r, 50)); } if (!ready) throw new Error("Server failed to become ready within 2s"); await post("/api/lanes", { cwd: LANE_DIR, title: "CLI lane" }); }); after(() => { try { if (server) server.close(); fs.rmSync(LANE_DIR, { recursive: true, force: true }); fs.rmSync(PIPELINE_DIR, { recursive: true, force: true }); fs.rmSync(LANES_ROOT, { recursive: true, force: true }); fs.rmSync(SOURCE_REPO, { recursive: true, force: true }); fs.rmSync(REMOTE_REPO, { recursive: true, force: true }); } finally { // Clean up TEST_DB and its WAL/SHM siblings (always runs, even if earlier cleanup fails) fs.rmSync(TEST_DB, { force: true }); fs.rmSync(`${TEST_DB}-wal`, { force: true }); fs.rmSync(`${TEST_DB}-shm`, { force: true }); } }); describe("agenthub workspaces", () => { it("is the lanes command under the UI's word", async () => { const [lanes, workspaces, workspace] = await Promise.all([ cli(["lanes"], LANE_DIR), cli(["workspaces"], LANE_DIR), cli(["workspace"], LANE_DIR), ]); assert.equal(workspaces.status, 0, workspaces.stderr); assert.match(workspaces.stdout, /CLI lane/); assert.equal(workspaces.stdout, lanes.stdout); assert.equal(workspace.stdout, lanes.stdout); // The old name still works but says so — on stderr, never in stdout. assert.match(lanes.stderr, /old name.*agenthub workspace/); assert.doesNotMatch(workspaces.stderr, /old name/); assert.doesNotMatch(lanes.stdout, /old name/); }); }); describe("agenthub stage", () => { it("reports a stage for the lane owning the current directory", async () => { const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /review/); const list = await cli(["lanes"], LANE_DIR); assert.match(list.stdout, /review/); }); it("exits non-zero when no lane owns the cwd", async () => { const r = await cli(["stage", "review"], os.tmpdir()); assert.notEqual(r.status, 0); assert.match(`${r.stdout}${r.stderr}`, /no workspace/i); }); it("refuses a stage name matching no pipeline node", async () => { // It used to be recorded with a warning, which renders nowhere (phaseIdx // -1, progress 0) and counts nowhere. A warning on stderr proved easy to // miss — a real lane accumulated five such names — so the server rejects // it and the agent is told which names exist. const r = await cli(["stage", "revieww"], LANE_DIR); assert.notEqual(r.status, 0); const out = `${r.stdout}${r.stderr}`; assert.match(out, /revieww/); assert.match(out, /not a stage/i); assert.match(out, /review/); }); it("accepts a stage declared by alias", async () => { const r = await cli(["stage", "planning"], LANE_DIR); assert.equal(r.status, 0, r.stderr); }); it("accepts a valid stage reported as failed", async () => { // --result fail paints the node `failed`, not `current`; the validation // must not read that as an unknown stage. const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR); assert.equal(r.status, 0, r.stderr); }); }); // A cwd can back one `claude` lane AND one `codex` lane (idx_lanes_cwd_tool), // so cwd alone no longer identifies a lane. Picking the first of the tie means // a codex session's `agenthub stage` silently drives the claude lane's card. describe("agenthub stage in a directory shared by two lanes", () => { const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-dual-lane-")); let claudeLane; let codexLane; // The suite itself runs under Claude Code, whose CLAUDECODE marker would // leak into every CLI child and answer the question under test. const NO_HARNESS = { CLAUDECODE: "", CLAUDE_CODE_ENTRYPOINT: "" }; before(async () => { claudeLane = (await post("/api/lanes", { cwd: DUAL_DIR, title: "dual claude" })).body.lane; codexLane = (await post("/api/lanes", { cwd: DUAL_DIR, title: "dual codex", tool: "codex" })) .body.lane; assert.notEqual(claudeLane.id, codexLane.id); }); async function stageOf(id) { return (await get(`/api/lanes/${id}`)).body.lane.stage; } it("AGENTHUB_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => { const r = await cli(["stage", "gate"], DUAL_DIR, { ...NO_HARNESS, AGENTHUB_LANE_ID: String(codexLane.id), }); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(codexLane.id), "gate"); assert.notEqual(await stageOf(claudeLane.id), "gate"); }); it("--tool picks the lane when there is no env marker", async () => { const r = await cli(["stage", "tests", "--tool", "codex"], DUAL_DIR, NO_HARNESS); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(codexLane.id), "tests"); assert.notEqual(await stageOf(claudeLane.id), "tests"); }); it("CLAUDECODE in the environment resolves to the claude lane", async () => { const r = await cli(["stage", "implement"], DUAL_DIR, { CLAUDECODE: "1" }); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(claudeLane.id), "implement"); assert.notEqual(await stageOf(codexLane.id), "implement"); }); it("--workspace names the lane like --lane", async () => { const r = await cli( ["stage", "review", "--workspace", String(codexLane.id)], DUAL_DIR, NO_HARNESS ); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(codexLane.id), "review"); assert.notEqual(await stageOf(claudeLane.id), "review"); }); it("fails loudly, naming both candidates, when nothing identifies the tool", async () => { const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)]; const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS); assert.notEqual(r.status, 0); const out = `${r.stdout}${r.stderr}`; assert.match(out, /--tool/); assert.match(out, new RegExp(`#${claudeLane.id}\\b`)); assert.match(out, new RegExp(`#${codexLane.id}\\b`)); assert.deepEqual([await stageOf(claudeLane.id), await stageOf(codexLane.id)], before); }); it("refuses when the requested tool owns no lane here", async () => { const solo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-solo-lane-")); await post("/api/lanes", { cwd: solo, title: "solo claude" }); const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS); assert.notEqual(r.status, 0); assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i); }); it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => { // Even with two lanes at the same cwd, --lane bypasses all resolution const r = await cli(["stage", "review", "--lane", String(codexLane.id)], DUAL_DIR, NO_HARNESS); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(codexLane.id), "review"); assert.notEqual(await stageOf(claudeLane.id), "review"); }); it("positional numeric ID acts like --lane for commands that support it (e.g. agenthub lanes pipeline)", async () => { // Commands like `agenthub lanes pipeline` treat a leading numeric arg as the lane ID. // Other commands like `agenthub stage` have their own positional argument and don't support it. const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`)); }); it("non-numeric AGENTHUB_LANE_ID is ignored", async () => { // If AGENTHUB_LANE_ID is garbage (not a number), it should be skipped and // fall through to --tool or env markers const r = await cli(["stage", "ship"], DUAL_DIR, { ...NO_HARNESS, AGENTHUB_LANE_ID: "not-a-number", CLAUDECODE: "1", // This env marker should pick claude lane }); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(claudeLane.id), "ship"); assert.notEqual(await stageOf(codexLane.id), "ship"); }); it("a AGENTHUB_LANE_ID naming no lane falls through instead of addressing it", async () => { // A pane outlives the lane it was started for, and lane ids are reused // after a delete. Trusting the number blind would either 404 or, worse, // write this session's stage onto whatever lane inherited that id. const r = await cli(["stage", "gate"], DUAL_DIR, { ...NO_HARNESS, AGENTHUB_LANE_ID: "999999", CLAUDECODE: "1", }); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(claudeLane.id), "gate"); }); it("a AGENTHUB_LANE_ID for a lane outside this directory is not used", async () => { // The pane's lane is the truth only while the command runs inside it; an // agent that cd'd into a different repo must resolve by that directory. const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-elsewhere-")); const other = (await post("/api/lanes", { cwd: elsewhere, title: "elsewhere" })).body.lane; const r = await cli(["stage", "review"], DUAL_DIR, { ...NO_HARNESS, AGENTHUB_LANE_ID: String(other.id), CLAUDECODE: "1", }); assert.equal(r.status, 0, r.stderr); assert.equal(await stageOf(claudeLane.id), "review"); assert.notEqual(await stageOf(other.id), "review"); }); }); describe("agenthub lanes add", () => { it("creates a lane and it appears in lanes list", async () => { const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-")); try { const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /Created workspace/i); const list = await cli(["lanes"], addDir); assert.equal(list.status, 0, list.stderr); assert.match(list.stdout, /CLI added/); } finally { fs.rmSync(addDir, { recursive: true, force: true }); } }); it("defaults to 'default' pipeline when --pipeline is omitted", async () => { const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-")); try { const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /Created workspace/i); const details = await get(`/api/lanes`); const lane = details.body.lanes.find((l) => l.title === "Default pipeline"); assert.ok(lane, "lane not found"); assert.equal(lane.pipeline, "default", "should use default pipeline when omitted"); } finally { fs.rmSync(addDir, { recursive: true, force: true }); } }); it("accepts --pipeline flag and creates lane with custom pipeline", async () => { const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-custom-pipeline-")); try { const r = await cli( [ "lanes", "add", "--cwd", addDir, "--title", "Custom pipeline", "--pipeline", "test-pipeline", ], addDir ); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /Created workspace/i); const details = await get(`/api/lanes`); const lane = details.body.lanes.find((l) => l.title === "Custom pipeline"); assert.ok(lane, "lane not found"); assert.equal(lane.pipeline, "test-pipeline", "should use specified pipeline"); } finally { fs.rmSync(addDir, { recursive: true, force: true }); } }); it("switches an existing lane's pipeline, and shows it when given no target", async () => { // `--pipeline` was creation-only, so every lane added from the dashboard // was pinned to `default`'s 8 nodes with no way to reach a longer template. const show = await cli(["lanes", "pipeline"], LANE_DIR); assert.equal(show.status, 0, show.stderr); assert.match(show.stdout, /default/); assert.match(show.stdout, /available:.*ship-feature/); const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR); assert.equal(set.status, 0, set.stderr); assert.match(set.stdout, /ship-feature/); assert.match(set.stdout, /16 nodes/); const back = await cli(["lanes", "pipeline", "default"], LANE_DIR); assert.equal(back.status, 0, back.stderr); assert.match(back.stdout, /8 nodes/); }); it("refuses an unknown pipeline id instead of silently falling back to default", async () => { // getPipeline() returns the default template for an unknown id, so without // a write-side check a typo would store, render `default`, and look fine. const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR); assert.notEqual(r.status, 0); assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i); const after = await cli(["lanes", "pipeline"], LANE_DIR); assert.match(after.stdout, /default/); }); it("provisions a managed worktree lane and reports it ready", async () => { const r = await cli( ["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"], SOURCE_REPO ); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /Worktree workspace #\d+ ready: CLI worktree/); const list = await get("/api/lanes"); const lane = list.body.lanes.find((l) => l.title === "CLI worktree"); assert.ok(lane, "managed lane not found"); assert.equal(lane.kind, "managed"); assert.equal(lane.status, "idle"); assert.ok(fs.existsSync(lane.cwd), "provisioned worktree directory is missing"); }); it("reports the failure notes, not success, when provisioning fails", async () => { // A branch named `feat` makes the default `feat/` impossible to // create, so provisioning fails inside git (an unborn repo is now refused // up front with EEMPTYREPO and never reaches provisioning). const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-clash-repo-")); try { await git(["init", "-b", "main"], emptyRepo); await git( ["-c", "user.email=t@h", "-c", "user.name=t", "commit", "--allow-empty", "-m", "i"], emptyRepo ); await git(["branch", "feat"], emptyRepo); const r = await cli( ["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"], emptyRepo ); assert.notEqual(r.status, 0); assert.match(`${r.stdout}${r.stderr}`, /Worktree workspace #\d+ failed:/); assert.match(`${r.stdout}${r.stderr}`, /fatal:|ambiguous argument|unknown revision/i); assert.doesNotMatch(r.stdout, /ready/i); } finally { fs.rmSync(emptyRepo, { recursive: true, force: true }); } }); }); describe("agenthub lanes destructive lifecycle", () => { // Every test provisions its OWN lane. These tests used to share one mutable // `managedLane` across describe blocks, which meant they only ever exercised // whatever state the previous test happened to leave behind — the same // shared-fixture reuse that let removeWorktree's destroy guard ship unpinned. let laneSeq = 0; async function ownLane(title) { const slug = `cli-destructive-${(laneSeq += 1)}`; const added = await cli( ["lanes", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug], SOURCE_REPO ); assert.equal(added.status, 0, added.stderr); const list = await get("/api/lanes"); const lane = list.body.lanes.find((l) => l.slug === slug); assert.ok(lane, `lane ${slug} not found`); assert.equal(lane.status, "idle"); return lane; } it("prints reset preflight facts and changes nothing without --yes", async () => { const lane = await ownLane("CLI reset dry run"); const dirtyFile = path.join(lane.cwd, "dirty.txt"); fs.writeFileSync(dirtyFile, "must survive\n"); const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO); assert.notEqual(r.status, 0); assert.match(r.stdout, /Preflight for reset workspace #\d+:/); assert.match(r.stdout, /dirty\s+0/); assert.match(r.stdout, /untracked\s+1/); assert.match(`${r.stdout}${r.stderr}`, /without --yes/); assert.equal(fs.readFileSync(dirtyFile, "utf8"), "must survive\n"); }); it("resets a managed worktree with --yes and leaves it clean", async () => { const lane = await ownLane("CLI reset applied"); fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n"); const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`)); assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false); const preflight = await get(`/api/lanes/${lane.id}/preflight?action=reset`); assert.equal(preflight.status, 200, JSON.stringify(preflight.body)); assert.equal(preflight.body.dirty, 0); assert.equal(preflight.body.untracked, 0); assert.equal(preflight.body.unpushed, 0); }); it("prints expected-versus-current preflight facts on 409 ESTALE", async () => { // Both CLI invocations read the same (clean) preflight before either takes // the per-lane lock. The untracked file makes each read a non-trivial // `untracked: 1`. Whichever process's POST acquires the lock first performs // the reset (cleaning the file); the lock forces the second POST to wait, // so by the time it re-checks preflight under the lock, the facts it // captured before starting are stale. const lane = await ownLane("CLI reset race"); fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n"); const [a, b] = await Promise.all([ cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO), cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO), ]); const [winner, loser] = a.status === 0 ? [a, b] : [b, a]; assert.equal(winner.status, 0, winner.stderr); assert.notEqual(loser.status, 0); assert.match(`${loser.stdout}${loser.stderr}`, /workspace state changed since preflight/); assert.match(loser.stdout, /Expected preflight:/); assert.match(loser.stdout, /Current preflight:/); }); it("removes a managed lane's record with --yes, leaving its directory on disk", async () => { const lane = await ownLane("CLI remove managed"); assert.equal(fs.existsSync(lane.cwd), true); const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`)); assert.match(r.stdout, /left for you to delete yourself/); assert.equal(fs.existsSync(lane.cwd), true); assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404); }); it("refuses to RESET an adopted lane but FORGETS it on remove, leaving the directory intact", async () => { const adoptedDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-adopted-remove-")); const preserved = path.join(adoptedDir, "preserve.txt"); fs.writeFileSync(preserved, "do not delete\n"); try { const created = await post("/api/lanes", { cwd: adoptedDir, title: "Adopted forget", }); const id = created.body.lane.id; const reset = await cli(["lanes", "reset", String(id), "--yes"], adoptedDir); assert.notEqual(reset.status, 0); assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i); // The server permits `remove` for an adopted lane — it drops the dashboard // record only — so the CLI must not refuse it, or the capability is // unreachable from the terminal. const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir); assert.equal(removed.status, 0, removed.stderr); assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`)); assert.equal((await get(`/api/lanes/${id}`)).status, 404); assert.equal(fs.existsSync(adoptedDir), true); assert.equal(fs.readFileSync(preserved, "utf8"), "do not delete\n"); } finally { fs.rmSync(adoptedDir, { recursive: true, force: true }); } }); it("reports purge counts that exactly match its preflight", async () => { const lane = await ownLane("CLI purge"); const preflight = await get(`/api/lanes/${lane.id}/preflight?action=purge`); assert.equal(preflight.status, 200); const { sessions, events, tokenRows } = preflight.body; const r = await cli(["lanes", "purge", String(lane.id), "--yes"], SOURCE_REPO); assert.equal(r.status, 0, r.stderr); assert.match( r.stdout, new RegExp( `Purged workspace #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows` ) ); }); }); describe("agenthub lanes — inferred stage detection", () => { it("prints the inferred stage when detection leads the declared stage", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-")); try { const created = await post("/api/lanes", { cwd: dir, title: "Detect Lead" }); const id = created.body.lane.id; // Default declared stage is 'idle' (no pipeline node), so a Bash // test-run hook — which the default pipeline's `tests` node detects — // leads it. Real path: POST /api/hooks/event, not writing the column // directly. await post("/api/hooks/event", { hook_type: "PostToolUse", data: { session_id: "sess-detect-lead", cwd: dir, tool_name: "Bash", tool_input: { command: "npm run test:server" }, }, }); const r = await cli(["lanes"], dir); assert.equal(r.status, 0, r.stderr); const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`)); assert.ok(line, "lane row not found in agenthub lanes output"); assert.match(line, /⇢ detected:tests/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); it("does not print an inferred stage when the declaration already leads", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-behind-")); try { const created = await post("/api/lanes", { cwd: dir, title: "Detect Behind" }); const id = created.body.lane.id; // Declare 'ship' (past 'tests' in the default pipeline) first, so the // detection below is behind the declaration and recordDetection's // declared-wins guard refuses to write it. const staged = await cli(["stage", "ship", "--lane", String(id)], dir); assert.equal(staged.status, 0, staged.stderr); await post("/api/hooks/event", { hook_type: "PostToolUse", data: { session_id: "sess-detect-behind", cwd: dir, tool_name: "Bash", tool_input: { command: "npm run test:server" }, }, }); const r = await cli(["lanes"], dir); assert.equal(r.status, 0, r.stderr); const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`)); assert.ok(line, "lane row not found in agenthub lanes output"); assert.doesNotMatch(line, /detected:/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); }); describe("agenthub lanes node", () => { it("with no node id, lists every node and its command, and exits non-zero with usage", async () => { const r = await cli(["lanes", "node"], LANE_DIR); assert.notEqual(r.status, 0); assert.match(r.stdout, /\bintake\b/); assert.match(r.stdout, /\bdone\b/); assert.match(r.stderr, /usage: agenthub workspace node/); }); it("--print shows a slash-command node as a claude prompt, extra args appended", async () => { await cli(["lanes", "pipeline", "default"], LANE_DIR); const r = await cli(["lanes", "node", "plan", "the", "export", "button", "--print"], LANE_DIR); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /"claude" "\/superpowers:writing-plans the export button"/); }); it("--print shows a shell node split into argv, never a shell string", async () => { await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR); const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /"agenthub" "lanes" "hook" "ci-gate"/); }); it("every node of both shipped templates declares a runnable command", async () => { const { body } = await get("/api/lanes/pipelines"); for (const p of body.pipelines.filter((x) => ["default", "ship-feature"].includes(x.id))) { for (const n of p.nodes) { assert.ok(n.command, `${p.id}/${n.id} has no command`); } } }); it("matches a node by alias, so one slash command works on either template", async () => { // `default` calls the step `implement`, `ship-feature` calls it // `implementing` — /agenthub:implement must not depend on which one is active. await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR); const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR); assert.equal(ship.status, 0, ship.stderr); assert.match(ship.stdout, /test-driven-development/); await cli(["lanes", "pipeline", "default"], LANE_DIR); const def = await cli(["lanes", "node", "implementing", "--print"], LANE_DIR); assert.equal(def.status, 0, def.stderr); assert.match(def.stdout, /test-driven-development/); }); it("names the pipeline's nodes when asked for one that is not in it", async () => { const r = await cli(["lanes", "node", "not-a-node", "--print"], LANE_DIR); assert.notEqual(r.status, 0); assert.match(r.stderr, /no node "not-a-node"/); assert.match(r.stdout, /\bintake\b/); }); }); describe("agenthub lanes ci status", () => { let restorePath; let bin; before(() => { bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-cli-bin-")); fs.writeFileSync( path.join(bin, "gh"), `#!/usr/bin/env bash if [ "$1" = "run" ] && [ "$2" = "list" ]; then if [ "$CI_STATUS_FIXTURE" = "failure" ]; then echo '[{"databaseId":1,"status":"completed","conclusion":"failure"}]' elif [ "$CI_STATUS_FIXTURE" = "pending" ]; then echo '[]' else echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]' fi exit 0 fi if [ "$1" = "run" ] && [ "$2" = "view" ]; then if [ "$CI_STATUS_FIXTURE" = "failure" ]; then echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"failure","url":"https://x/1"}]}' else echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}' fi exit 0 fi exit 1 ` ); fs.chmodSync(path.join(bin, "gh"), 0o755); const originalPath = process.env.PATH; process.env.PATH = `${bin}${path.delimiter}${originalPath}`; restorePath = () => { process.env.PATH = originalPath; }; }); after(() => { if (restorePath) restorePath(); fs.rmSync(bin, { recursive: true, force: true }); }); it("exits 0 and prints success state", async () => { const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR); assert.equal(r.status, 0); assert.match(r.stdout, /"state": "success"/); }); it("exits 1 on a failed run", async () => { const orig = process.env.CI_STATUS_FIXTURE; try { process.env.CI_STATUS_FIXTURE = "failure"; const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { CI_STATUS_FIXTURE: "failure", }); assert.equal(r.status, 1); assert.match(r.stdout, /"state": "failure"/); } finally { process.env.CI_STATUS_FIXTURE = orig; } }); it("exits 3 when no run exists yet", async () => { const orig = process.env.CI_STATUS_FIXTURE; try { process.env.CI_STATUS_FIXTURE = "pending"; const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { CI_STATUS_FIXTURE: "pending", }); assert.equal(r.status, 3); assert.match(r.stdout, /"state": "pending"/); } finally { process.env.CI_STATUS_FIXTURE = orig; } }); });