fd7f57929c
A lane on the 16-node `ship-feature` template showed an untouched pipeline
map and never declared a stage. Four independent causes, fixed at their
shared choke points:
1. Codex emits no hooks, so `touchLaneFromHook` — the only caller of
`stage-detect.detect()`, and correctly pinned to `claude` — could never
see a `codex` lane. New `server/lib/codex-ingest.js` tails Codex's own
rollout journals (`$CODEX_HOME/sessions/**/rollout-*.jsonl`), maps its
tool calls onto the hook-shaped `{tool_name, tool_input}` the matcher
already understands, and feeds the existing detect path plus an
idle/running status mirror under the same guard hooks use. Polled every
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables).
2. `resolveLaneArg` matched on cwd alone, so with a claude and a codex lane
at one directory (the composite `idx_lanes_cwd_tool`) a `ccam stage`
call could drive the sibling lane's card. It now resolves by
`--lane` → `CCAM_LANE_ID` → `--tool`/harness marker → owning lane, and
errors naming both candidates rather than picking one. Every pane the
dashboard starts carries `CCAM_LANE_ID` (`tmux new-session -e`, or an
assignment prefix when typing into a live shell pane).
3. `recordDetection`'s declared-wins veto had no exit: a lane left at the
pipeline's last node rejected every detection for the rest of its life.
A declaration on the LAST node no longer vetoes — the pipeline is over
there, so a later detection is new work. Nothing else is relaxed, and
detection still never writes `stage` nor renders `done`.
4. The run console pre-filled the pipeline's Claude slash command into
codex panes, which Codex reads as prose — so the skill that would have
called `ccam stage` never ran. Claude lanes only now.
A codex lane's *declared* stage still depends on the session calling
`ccam stage`; `AGENTS.md` is the only lever CCAM has, so it now says so.
Verified: server 1345/1345, client 441/441, and end-to-end against a real
codex lane row (detected_stage/detected_signal set, declared stage
untouched, no node rendered done).
777 lines
31 KiB
JavaScript
777 lines
31 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, 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("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);
|
|
});
|
|
});
|
|
|
|
// 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 `ccam stage` silently drives the claude lane's card.
|
|
describe("ccam stage in a directory shared by two lanes", () => {
|
|
const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-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("CCAM_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => {
|
|
const r = await cli(["stage", "gate"], DUAL_DIR, {
|
|
...NO_HARNESS,
|
|
CCAM_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("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(), "ccam-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 lane/i);
|
|
});
|
|
|
|
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
|
|
// Even with two lanes at the same cwd, --lane <id> 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. ccam lanes pipeline)", async () => {
|
|
// Commands like `ccam lanes pipeline` treat a leading numeric arg as the lane ID.
|
|
// Other commands like `ccam 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(`lane #${codexLane.id}`));
|
|
});
|
|
|
|
it("non-numeric CCAM_LANE_ID is ignored", async () => {
|
|
// If CCAM_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,
|
|
CCAM_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 CCAM_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,
|
|
CCAM_LANE_ID: "999999",
|
|
CLAUDECODE: "1",
|
|
});
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(await stageOf(claudeLane.id), "gate");
|
|
});
|
|
|
|
it("a CCAM_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(), "ccam-elsewhere-"));
|
|
const other = (await post("/api/lanes", { cwd: elsewhere, title: "elsewhere" })).body.lane;
|
|
const r = await cli(["stage", "review"], DUAL_DIR, {
|
|
...NO_HARNESS,
|
|
CCAM_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("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/);
|
|
});
|
|
});
|
|
|
|
describe("ccam lanes ci status", () => {
|
|
let restorePath;
|
|
let bin;
|
|
|
|
before(() => {
|
|
bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-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;
|
|
}
|
|
});
|
|
});
|