Files
AgentHub/server/__tests__/lanes-cli.test.js
T
nntrivi2001 2103dde889 feat(lanes): drive runs from the pipeline template's own skill
A pipeline template may now declare the slash command that drives it
(`skill`), so the choice of pipeline is the choice of workflow rather
than just a picture on the dashboard:

- `ccam ship-full "<req>"` runs the 16-node `ship-feature` template's
  skill (`/ccam:ship-feature-lane`) — through gates, QC and the senior
  gate to a published PR, watched until a human merges it.
- `ccam ship-quick "<req>"` runs the 8-node `default` template's skill
  (`/ccam:ship-feature`) — in-repo feature work that stops at the gate.

Each command puts the lane on its own template before spawning, so the
dashboard renders the right node count from the first stage instead of
after the skill self-corrects. `--print` shows the command, switches
nothing and runs nothing. The prompt reaches `claude` as one argv
element, never through a shell.

The same field feeds the Workspace run console, which pre-fills its
prompt with the lane's driving skill and never overwrites a prompt the
user has already edited.
2026-08-24 16:34:55 +07:00

576 lines
23 KiB
JavaScript

/**
* @file Tests for the `ccam stage` / `ccam 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ĩ <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 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(), "ccam-pipeline-fixture-"));
const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-managed-lanes-cli-"));
const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-source-"));
const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-remote-"));
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_PIPELINES_DIR = PIPELINE_DIR;
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(), "ccam-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "ccam.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) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI, ...args], {
cwd,
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) },
});
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("ccam 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 lane/i);
});
it("warns, but still records, a stage name matching no pipeline node", async () => {
// setStage stores the string verbatim, so a typo'd stage is accepted and
// then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
// a lane ends up looking unstarted for an entire pipeline run.
const r = await cli(["stage", "revieww"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /revieww/);
assert.match(r.stderr, /matches no node/i);
assert.match(r.stderr, /revieww/);
});
it("does not warn for a stage declared by alias", async () => {
const r = await cli(["stage", "planning"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stderr, /matches no node/i);
});
it("does not warn for a valid stage reported as failed", async () => {
// --result fail paints the node `failed`, not `current`; the warning 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);
assert.doesNotMatch(r.stderr, /matches no node/i);
});
});
describe("ccam lanes add", () => {
it("creates a lane and it appears in lanes list", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-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 lane/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(), "ccam-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 lane/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(), "ccam-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 lane/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 lane #\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 () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-cli-unborn-repo-"));
try {
await git(["init", "-b", "main"], 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 lane #\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("ccam 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 lane #\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 lane #${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}`, /lane state changed since preflight/);
assert.match(loser.stdout, /Expected preflight:/);
assert.match(loser.stdout, /Current preflight:/);
});
it("removes a managed lane's worktree and directory with --yes", 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 lane #${lane.id}`));
assert.equal(fs.existsSync(lane.cwd), false);
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(), "ccam-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 lane #${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 lane #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows`
)
);
});
});
describe("ccam lanes — inferred stage detection", () => {
it("prints the inferred stage when detection leads the declared stage", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-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 ccam 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(), "ccam-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 ccam lanes output");
assert.doesNotMatch(line, /detected:/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe("ccam ship-full / ccam ship-quick", () => {
it("ship-quick prints the 8-stage pipeline's skill, ship-full the 16-stage one", async () => {
const quick = await cli(["ship-quick", "add", "an", "export", "button", "--print"], LANE_DIR);
assert.equal(quick.status, 0, quick.stderr);
assert.match(quick.stdout, /claude "\/ccam:ship-feature add an export button"/);
const full = await cli(["ship-full", "add", "an", "export", "button", "--print"], LANE_DIR);
assert.equal(full.status, 0, full.stderr);
assert.match(full.stdout, /claude "\/ccam:ship-feature-lane add an export button"/);
});
it("--print switches nothing: the lane keeps whatever template it was on", async () => {
const before = await get(`/api/lanes`);
const lane = before.body.lanes.find((l) => l.cwd === LANE_DIR);
const r = await cli(["ship-full", "anything", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
const after = await get(`/api/lanes`);
assert.equal(after.body.lanes.find((l) => l.id === lane.id).pipeline, lane.pipeline);
});
it("exits non-zero with the command's own usage when no requirement is given", async () => {
const r = await cli(["ship-full"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /usage: ccam ship-full/);
const q = await cli(["ship-quick"], LANE_DIR);
assert.notEqual(q.status, 0);
assert.match(q.stderr, /usage: ccam ship-quick/);
});
});