3223c8edac
A pipeline was all-or-nothing: `ccam ship-full` / `ship-quick` hand the whole
thing to a skill, and a node's own `command` — added so the map would stop
hiding the driver behind its detect regexes — was display-only. Re-running just
the CI gate, or redoing the brainstorm, meant clicking copy on the node and
pasting the string into a session by hand.
`ccam lanes node [<id>] [extra…] [--print]` runs it, in the lane's own cwd. A
slash-command node spawns `claude` with it as the prompt (same shape as
`ccam ship-full`); anything else is spawned directly. No id lists every node
and its command, which is also what the two error paths print.
The node is matched by id OR alias, the same way `ccam stage` matches one:
`default` calls the step `implement` and `ship-feature` calls it `implementing`,
and one command name must not depend on which template the lane happens to be
on. That needs the template's `aliases`, which the lane payload's
`pipeline_nodes` does not carry — so the two are merged, template for the
definition, lane for the `current` marker.
Both shipped templates now fill EVERY node's `command`. The seven that were
empty are the nodes whose only work IS the declaration — a terminal `done`, a
gate another agent declares — so they get `ccam stage <id>`, which keeps all 16
(and all 8) runnable instead of leaving holes a caller has to know about.
Slash commands for the same thing, because a session should not shell out to a
nested `claude`: `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`,
`/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`,
`/reported`, `/watching-pr`, `/merged`, `/done`, plus `/ship-full` and
`/ship-quick`. Each resolves its node through `ccam lanes node <id> --print` and
runs the result IN the current session — invoking the slash command itself when
that is what the node names, otherwise running the shell argv. Resolving at call
time rather than baking the string into the file is what stops the 18 command
files from drifting when a template changes.
Two limits are deliberate:
- The command is split on whitespace and spawned WITHOUT a shell. A template
is data — anyone can drop a file into DASHBOARD_PIPELINES_DIR — so its
`command` must never be interpreted as shell. A node needing a pipe names a
script that contains one.
- Running a node does not declare its stage. A command executing is not proof
the node passed, same boundary as the console never writing a lane's stage.
Verified: npm run test:server (1403, +3 covering alias matching, full command
coverage of both templates, and every command file being listed in the
manifest), npm run test:client (459), prettier and check-headers clean. Docs:
docs/LANES.md § Running ONE node, docs/PLUGINS.md commands table, README +
README.vi CLI blocks, CLAUDE.md marker.
831 lines
33 KiB
JavaScript
831 lines
33 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 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: ccam lanes 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, /"ccam" "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` — /ccam: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("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;
|
|
}
|
|
});
|
|
});
|