/** * @file Tests for the `ccam` umbrella CLI (bin/ccam.js). Spawns the real CLI * as a subprocess against a live in-test dashboard server and asserts each * command's output shape across the whole surface: monitoring (health, stats, * kanban), data browsing (sessions, session detail, agents, events), insights * (analytics, workflows, runs, cost), alerts/rules/webhooks, pricing CRUD, * import, administration (doctor, info, export, cleanup, clear-data guard), * plus help and error paths. * * The CLI must be spawned ASYNCHRONOUSLY (child_process.spawn, not * spawnSync): the dashboard server lives in THIS process, so a synchronous * spawn would block the event loop and deadlock every request the child * makes. Port targeting uses the DASHBOARD_PORT env override, which takes * precedence over ~/.claude/.agent-dashboard.json discovery. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("path"); const fs = require("fs"); const os = require("os"); const http = require("http"); const { spawn } = require("child_process"); const STAMP = `ccam-cli-${Date.now()}-${process.pid}`; const TMP = path.join(os.tmpdir(), STAMP); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.CLAUDE_HOME = path.join(TMP, "home"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; // Every spawn below inherits `process.env` — a shell that forces color // (FORCE_COLOR/CCAM_COLOR set in the environment running this test) would // leak into the spawned CLI child and defeat its own "colors off when piped" // behavior, which every plain-text assertion in this file assumes. delete process.env.FORCE_COLOR; delete process.env.CCAM_COLOR; const { createApp, startServer } = require("../index"); const { db } = require("../db"); const CLI = path.resolve(__dirname, "..", "..", "bin", "ccam.js"); let server; let PORT; /** * Run the CLI asynchronously against the test server. Async is load-bearing: * the server runs in this same process, so a blocking spawnSync would starve * its event loop and every CLI request would hang until timeout. */ function ccam(...args) { return new Promise((resolve) => { const child = spawn(process.execPath, [CLI, ...args], { env: { ...process.env, DASHBOARD_PORT: String(PORT) }, }); let out = ""; let err = ""; child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); const killer = setTimeout(() => child.kill("SIGKILL"), 20_000); child.on("close", (code) => { clearTimeout(killer); resolve({ code, out, err }); }); }); } function post(urlPath, body) { return new Promise((resolve, reject) => { const payload = JSON.stringify(body); const req = http.request( { hostname: "127.0.0.1", port: PORT, path: urlPath, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), }, }, (res) => { res.resume(); res.on("end", () => resolve(res.statusCode)); } ); req.on("error", reject); req.write(payload); req.end(); }); } before(async () => { const app = createApp(); server = await startServer(app, 0); PORT = server.address().port; // Seed one session via the real hook path so list commands have a row. const code = await post("/api/hooks/event", { hook_type: "Stop", data: { session_id: "cli-test-session-0001", cwd: "/tmp/ccam-cli" }, }); assert.equal(code, 200); }); after(() => { if (server) server.close(); if (db) db.close(); try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* ignore */ } }); describe("ccam CLI — monitoring", () => { it("health reports the dashboard as up", async () => { const { code, out } = await ccam("health"); assert.equal(code, 0); assert.match(out, /Dashboard/); assert.match(out, /up/); assert.match(out, /v\d+\.\d+\.\d+/); assert.match(out, new RegExp(String(PORT))); }); it("stats prints totals including the seeded session", async () => { const { code, out } = await ccam("stats"); assert.equal(code, 0); assert.match(out, /Total sessions/); assert.match(out, /Events today/); assert.match(out, /Sessions by status/); }); it("kanban groups sessions and agents into status columns", async () => { const { code, out } = await ccam("kanban"); assert.equal(code, 0); assert.match(out, /Sessions/); assert.match(out, /Agents/); assert.match(out, /active \(1\)/); assert.match(out, /waiting \(1\)/); // the main agent lands in waiting after Stop }); }); describe("ccam CLI — data browsing", () => { it("sessions lists the seeded session", async () => { const { code, out } = await ccam("sessions", "--limit", "5"); assert.equal(code, 0); assert.match(out, /cli-test/); assert.match(out, /of 1 session/); }); it("sessions --status filters correctly", async () => { const { code, out } = await ccam("sessions", "--status", "completed"); assert.equal(code, 0); assert.match(out, /of 0 session/); }); it("session shows detail with agents and recent events", async () => { const { code, out } = await ccam("session", "cli-test-session-0001"); assert.equal(code, 0); assert.match(out, /cli-test-session-0001/); assert.match(out, /Agents/); assert.match(out, /Recent events/); }); it("session without an id exits 1 with usage", async () => { const { code, err } = await ccam("session"); assert.equal(code, 1); assert.match(err, /Usage: ccam session/); }); it("agents lists the auto-created main agent", async () => { const { code, out } = await ccam("agents", "--session", "cli-test-session-0001"); assert.equal(code, 0); assert.match(out, /main/); }); it("events lists the seeded Stop event", async () => { const { code, out } = await ccam("events", "--session", "cli-test-session-0001"); assert.equal(code, 0); assert.match(out, /Stop/); }); }); describe("ccam CLI — insights", () => { it("analytics prints token totals and averages", async () => { const { code, out } = await ccam("analytics"); assert.equal(code, 0); assert.match(out, /Tokens/); assert.match(out, /Input/); }); it("workflows prints intelligence stats", async () => { const { code, out } = await ccam("workflows"); assert.equal(code, 0); assert.match(out, /Workflow intelligence/); assert.match(out, /Sessions analyzed/); }); it("runs lists dynamic workflow runs (empty is fine)", async () => { const { code, out } = await ccam("runs"); assert.equal(code, 0); assert.match(out, /Run/); assert.match(out, /Status/); }); it("cost prints a total", async () => { const { code, out } = await ccam("cost"); assert.equal(code, 0); assert.match(out, /Total estimated cost: \$/); }); it("cost --session scopes the total to one session", async () => { const { code, out } = await ccam("cost", "--session", "cli-test-session-0001"); assert.equal(code, 0); assert.match(out, /Session cost/); assert.match(out, /cli-test-session-0001/); assert.match(out, /Total estimated cost: \$/); }); it("cost surfaces server-tool surcharges when web search was billed", async () => { // Feature surcharges accrue independent of per-model pricing rules, so a // web_search_requests count alone drives the line ($10 / 1k searches). db.prepare( "INSERT INTO token_usage (session_id, model, web_search_requests) VALUES (?, ?, ?)" ).run("cli-test-session-0001", "claude-sonnet-5", 200); try { const { code, out } = await ccam("cost", "--session", "cli-test-session-0001"); assert.equal(code, 0); assert.match(out, /Server-tool surcharges/); assert.match(out, /web search \$/); } finally { db.prepare( "DELETE FROM token_usage WHERE session_id = 'cli-test-session-0001' AND model = 'claude-sonnet-5'" ).run(); } }); it("cost warns about models with usage but no pricing rule", async () => { // Usage on a model no default pattern matches: the API prices it at $0 // and reports it via unpriced_models; the CLI must surface that. db.prepare( "INSERT INTO token_usage (session_id, model, input_tokens, output_tokens) VALUES (?, ?, ?, ?)" ).run("cli-test-session-0001", "ccam-mystery-model-9", 1200, 300); try { const { code, out } = await ccam("cost"); assert.equal(code, 0); assert.match(out, /no pricing rule/); assert.match(out, /ccam-mystery-model-9/); assert.match(out, /ccam pricing set/); } finally { db.prepare("DELETE FROM token_usage WHERE model = 'ccam-mystery-model-9'").run(); } }); }); describe("ccam CLI — alerts, rules, webhooks", () => { it("alerts lists the (empty) fired-alert feed", async () => { const { code, out } = await ccam("alerts"); assert.equal(code, 0); assert.match(out, /unacknowledged of/); }); it("alerts ack-all succeeds on an empty feed", async () => { const { code, out } = await ccam("alerts", "ack-all"); assert.equal(code, 0); assert.match(out, /Acknowledged/); }); it("rules lists alert rules", async () => { const { code, out } = await ccam("rules"); assert.equal(code, 0); assert.match(out, /Enabled/); }); it("webhooks lists targets", async () => { const { code, out } = await ccam("webhooks"); assert.equal(code, 0); assert.match(out, /Provider/); }); }); describe("ccam CLI — pricing", () => { it("pricing lists the default rules", async () => { const { code, out } = await ccam("pricing"); assert.equal(code, 0); assert.match(out, /Pattern/); assert.match(out, /claude/); }); it("pricing set / delete round-trips a custom rule", async () => { const set = await ccam( "pricing", "set", "ccam-test-model%", "--input", "1", "--output", "2", "--cache-read", "0.1", "--cache-write", "1.25" ); assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`); assert.match(set.out, /saved/); const list = await ccam("pricing"); assert.match(list.out, /ccam-test-model%/); const del = await ccam("pricing", "delete", "ccam-test-model%"); assert.equal(del.code, 0); assert.match(del.out, /deleted/); }); it("pricing set persists fast-mode and intro rates via flags", async () => { const set = await ccam( "pricing", "set", "ccam-fast-model%", "--input", "5", "--output", "25", "--fast-input", "10", "--fast-output", "50", "--intro-input", "2", "--intro-output", "10", "--intro-until", "2099-01-01" ); assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`); const list = await ccam("pricing"); assert.match(list.out, /ccam-fast-model%/); assert.match(list.out, /\$10\/\$50/); // fast in/out column assert.match(list.out, /\$2\/\$10/); // intro in/out column assert.match(list.out, /2099-01-01/); // A plain rate edit without intro flags must preserve the promo (the // intro block is only sent when an --intro-* flag is present). const edit = await ccam("pricing", "set", "ccam-fast-model%", "--input", "6", "--output", "30"); assert.equal(edit.code, 0); const after = await ccam("pricing"); assert.match(after.out, /\$6/); assert.match(after.out, /2099-01-01/); await ccam("pricing", "delete", "ccam-fast-model%"); }); }); describe("ccam CLI — import & administration", () => { it("import rescan runs against the (empty) default projects dir", async () => { const { code, out } = await ccam("import", "rescan"); assert.equal(code, 0); assert.match(out, /imported \d+/); }); it("import without a subcommand exits 1 with usage", async () => { const { code, err } = await ccam("import"); assert.equal(code, 1); assert.match(err, /Usage: ccam import/); }); it("doctor reports API, hooks, database, remotes, and uptime lines", async () => { const { code, out } = await ccam("doctor"); // Exit 1 when hooks are missing (common in the test harness) — still prints a full report. assert.ok(code === 0 || code === 1, `unexpected exit ${code}`); assert.match(out, /API reachable/); assert.match(out, /Claude Code hooks/); assert.match(out, /Database/); assert.match(out, /Server uptime/); assert.match(out, /Remote sources/); }); it("info dumps system info JSON", async () => { const { code, out } = await ccam("info"); assert.equal(code, 0); const parsed = JSON.parse(out); assert.ok(parsed.db); assert.ok(parsed.server); }); it("export writes a JSON file containing the seeded session", async () => { const file = path.join(TMP, "export.json"); const { code, out } = await ccam("export", file); assert.equal(code, 0); assert.match(out, /Exported to/); const data = JSON.parse(fs.readFileSync(file, "utf8")); assert.ok(JSON.stringify(data).includes("cli-test-session-0001")); }); it("update-check reports the checkout's update status", async () => { // The test process runs inside the real repo clone, so the route always // reports git_repo: true; the CLI exits 0 whether the checkout is behind, // current, or the remote is unreachable (fetch errors are informational). const { code, out } = await ccam("update-check"); assert.equal(code, 0); assert.match(out, /Dashboard updates/); assert.match(out, /checkout|commit|remote|update/i); }); it("cleanup without flags exits 1 with usage", async () => { const { code, err } = await ccam("cleanup"); assert.equal(code, 1); assert.match(err, /Usage: ccam cleanup/); }); it("cleanup with flags runs", async () => { const { code, out } = await ccam("cleanup", "--days", "3650"); assert.equal(code, 0); assert.match(out, /Cleanup done/); }); it("clear-data REFUSES without --yes", async () => { const { code, err } = await ccam("clear-data"); assert.equal(code, 1); assert.match(err, /--yes/); // Data must be intact. const { out } = await ccam("sessions"); assert.match(out, /of 1 session/); }); it("clear-data --yes wipes rows (runs last)", async () => { const { code, out } = await ccam("clear-data", "--yes"); assert.equal(code, 0); assert.match(out, /cleared/); const { out: after } = await ccam("sessions"); assert.match(after, /of 0 session/); }); }); describe("ccam CLI — offline mode (server down, DB read directly)", () => { // The online admin suite ends with clear-data, so re-seed one session here // (through the live server) for the offline reads to find. before(async () => { const code = await post("/api/hooks/event", { hook_type: "Stop", data: { session_id: "cli-test-offline-0002", cwd: "/tmp/ccam-cli-off" }, }); assert.equal(code, 200); }); /** Run the CLI with an unreachable port so it falls back to offline reads * of the SAME database file the in-test server uses (WAL second reader). */ function offline(...args) { return new Promise((resolve) => { const child = spawn(process.execPath, [CLI, ...args], { env: { ...process.env, DASHBOARD_PORT: "1" }, }); let out = ""; let err = ""; child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); const killer = setTimeout(() => child.kill("SIGKILL"), 20_000); child.on("close", (code) => { clearTimeout(killer); resolve({ code, out, err }); }); }); } it("sessions falls back to offline reads with the banner", async () => { const { code, out } = await offline("sessions", "--limit", "5"); assert.equal(code, 0); assert.match(out, /Offline mode/); assert.match(out, /ccam start/); assert.match(out, /cli-test/); assert.match(out, /of 1 session/); }); it("stats works offline", async () => { const { code, out } = await offline("stats"); assert.equal(code, 0); assert.match(out, /Total sessions/); assert.match(out, /offline/); }); it("session works offline and flags cost as server-only", async () => { const { code, out } = await offline("session", "cli-test-offline-0002"); assert.equal(code, 0); assert.match(out, /Agents/); assert.match(out, /Cost\s+requires the server/); }); it("kanban works offline", async () => { const { code, out } = await offline("kanban"); assert.equal(code, 0); assert.match(out, /Sessions/); assert.match(out, /Agents/); }); it("pricing list works offline; pricing set is refused with a reason", async () => { const list = await offline("pricing"); assert.equal(list.code, 0); assert.match(list.out, /claude/); const set = await offline("pricing", "set", "x%", "--input", "1", "--output", "1"); assert.equal(set.code, 1); assert.match(set.err, /pricing changes must go through the server/); }); it("rules and alerts list offline", async () => { const rules = await offline("rules"); assert.equal(rules.code, 0); const alerts = await offline("alerts"); assert.equal(alerts.code, 0); assert.match(alerts.out, /unacknowledged of/); }); it("export works offline and marks the payload", async () => { const file = path.join(TMP, "offline-export.json"); const { code } = await offline("export", file); assert.equal(code, 0); const data = JSON.parse(fs.readFileSync(file, "utf8")); assert.equal(data.exported_offline, true); assert.ok(JSON.stringify(data.sessions).includes("cli-test-offline-0002")); }); it("doctor works offline and reports the server as down", async () => { const { code, out } = await offline("doctor"); assert.equal(code, 1); assert.match(out, /NOT running/); assert.match(out, /Database/); assert.match(out, /rows: sessions/); assert.match(out, /Remote sources/); }); it("cost refuses offline with the server-side-math reason", async () => { const { code, err } = await offline("cost"); assert.equal(code, 1); assert.match(err, /cost math .* runs server-side/); }); it("clear-data --yes refuses offline — data stays intact", async () => { const { code, err } = await offline("clear-data", "--yes"); assert.equal(code, 1); assert.match(err, /data wipes must go through the server/); const { out } = await offline("sessions"); assert.match(out, /of 1 session/); }); it("prints the staleness caveat when the liveness probe is unavailable", async () => { // The suite env sets DASHBOARD_LIVENESS_PROBE=0, so the probe reports // unavailable and offline output must carry the "statuses as stored" // caveat instead of silently showing possibly-stale active rows. const { code, out } = await offline("sessions"); assert.equal(code, 0); assert.match(out, /Statuses are as stored/); }); it("corrects dead active sessions display-side when the probe can answer", async (t) => { // Only meaningful where the real probe works (macOS/Linux, not container). const { spawnSync } = require("child_process"); const avail = spawnSync( process.execPath, [ "-e", "const p=require(process.argv[1]).probeLiveCwds();console.log(p.available)", path.resolve(__dirname, "..", "lib", "session-liveness.js"), ], { encoding: "utf8", env: { ...process.env, DASHBOARD_LIVENESS_PROBE: "" } } ).stdout.trim(); if (avail !== "true") { t.skip("liveness probe unavailable on this platform"); return; } // Run offline WITHOUT the probe kill-switch: the seeded session's cwd // (/tmp/ccam-cli-off) has no running claude process, so its stored // "active" status must be DISPLAYED as completed, with the footnote — // and the database itself must keep the stored status. const r = await new Promise((resolve) => { const child = spawn(process.execPath, [CLI, "sessions"], { env: { ...process.env, DASHBOARD_PORT: "1", DASHBOARD_LIVENESS_PROBE: "" }, }); let out = ""; child.stdout.on("data", (d) => (out += d)); child.on("close", (code) => resolve({ code, out })); }); assert.equal(r.code, 0); assert.match(r.out, /displayed as completed by the process-liveness probe/); // DB unchanged: the stored status is still whatever the server left there. const stored = db .prepare("SELECT status FROM sessions WHERE id = 'cli-test-offline-0002'") .get(); assert.equal(stored.status, "active"); }); it("analytics/workflows/tail refuse offline with reasons", async () => { for (const cmd of ["analytics", "workflows", "tail"]) { const { code, err } = await offline(cmd); assert.equal(code, 1, `${cmd} should refuse offline`); assert.match(err, /No offline fallback/); } }); }); describe("ccam CLI — help & errors", () => { it("help lists every command group", async () => { const { code, out } = await ccam("help"); assert.equal(code, 0); for (const word of [ "status", "start", "health", "stats", "kanban", "tail", "sessions", "session ", "agents", "events", "analytics", "workflows", "runs", "cost", "alerts", "rules", "webhooks", "pricing", "import", "doctor", "info", "export", "cleanup", "reinstall-hooks", "update-check", "clear-data", "open", ]) { assert.ok(out.includes(word), `help should mention ${word}`); } }); it("no arguments prints help and exits 0", async () => { const { code, out } = await ccam(); assert.equal(code, 0); assert.match(out, /Usage: ccam /); }); it("version prints the package version", async () => { const pkg = JSON.parse( fs.readFileSync(path.resolve(__dirname, "..", "..", "package.json"), "utf8") ); const { code, out } = await ccam("version"); assert.equal(code, 0); assert.equal(out.trim(), `ccam ${pkg.version}`); const flag = await ccam("--version"); assert.equal(flag.out.trim(), `ccam ${pkg.version}`); }); it("--no-color is accepted anywhere on the command line", async () => { const before = await ccam("--no-color", "health"); assert.equal(before.code, 0); assert.match(before.out, /Dashboard/); const after = await ccam("sessions", "--no-color"); assert.equal(after.code, 0); assert.match(after.out, /of 1 session/); }); it("piped output contains no ANSI escape codes (colors off when not a TTY)", async () => { const { out } = await ccam("stats"); assert.ok(!out.includes("\x1b["), "piped output must be plain text"); }); it("tables render with box-drawing borders and an Updated column", async () => { const { out } = await ccam("sessions"); assert.ok(out.includes("╭"), "table should have a top border"); assert.ok(out.includes("│"), "table should have column separators"); assert.match(out, /Updated/); }); it("unknown command exits 1 with an error", async () => { const { code, err } = await ccam("frobnicate"); assert.equal(code, 1); assert.match(err, /Unknown command/); }); it("unreachable server exits 1 with the not-running indicator + start hint", async () => { const r = await new Promise((resolve) => { const child = spawn(process.execPath, [CLI, "health"], { env: { ...process.env, DASHBOARD_PORT: "1" }, // nothing listens on port 1 }); let err = ""; child.stderr.on("data", (d) => (err += d)); child.on("close", (code) => resolve({ code, err })); }); assert.equal(r.code, 1); assert.match(r.err, /Dashboard server is NOT running/); assert.match(r.err, /ccam start/); assert.match(r.err, /npm run dev/); }); it("status reports running when the server is up", async () => { const { code, out } = await ccam("status"); assert.equal(code, 0); assert.match(out, /running/); assert.match(out, new RegExp(String(PORT))); }); it("status exits 1 with the down indicator when the server is down", async () => { const r = await new Promise((resolve) => { const child = spawn(process.execPath, [CLI, "status"], { env: { ...process.env, DASHBOARD_PORT: "1" }, }); let out = ""; child.stdout.on("data", (d) => (out += d)); child.on("close", (code) => resolve({ code, out })); }); assert.equal(r.code, 1); assert.match(r.out, /NOT running/); assert.match(r.out, /ccam start/); }); it("start no-ops with a pointer when a server is already running", async () => { const { code, out } = await ccam("start"); assert.equal(code, 0); assert.match(out, /already running/); }); }); describe("ccam CLI — interactive REPL", () => { /** * Drive `ccam repl` with piped stdin (non-TTY). Each typed line is executed * as a child `ccam` process, so grandchildren reach the in-test server via * the DASHBOARD_PORT override; async spawn keeps the event loop free. */ function repl(input, port = PORT) { return new Promise((resolve) => { const child = spawn(process.execPath, [CLI, "repl"], { env: { ...process.env, DASHBOARD_PORT: String(port) }, }); let out = ""; let err = ""; child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); const killer = setTimeout(() => child.kill("SIGKILL"), 20_000); child.on("close", (code) => { clearTimeout(killer); resolve({ code, out, err }); }); child.stdin.write(input); child.stdin.end(); }); } it("runs piped commands in order and exits at EOF", async () => { const { code, out } = await repl("version\nstats\nexit\n"); assert.equal(code, 0); // version output precedes the stats table → sequential execution. const vIdx = out.indexOf("ccam "); const sIdx = out.indexOf("Total sessions"); assert.ok(vIdx >= 0 && sIdx >= 0 && vIdx < sIdx, `order: v=${vIdx} s=${sIdx}`); }); it("dispatches real commands (sessions) through child processes", async () => { const { code, out } = await repl("sessions --limit 5\nexit\n"); assert.equal(code, 0); assert.match(out, /of \d+ session/); }); it("the `commands` built-in lists every command", async () => { const { out } = await repl("commands\nexit\n"); assert.match(out, /sessions/); assert.match(out, /kanban/); assert.match(out, /repl/); }); it("the `help` built-in shows shell built-ins and the grouped catalog", async () => { const { out } = await repl("help\nexit\n"); assert.match(out, /built-ins/i); assert.match(out, /Server/); // catalog group headers are present assert.match(out, /Administration/); assert.match(out, /watch/); // the new built-in is documented }); it("`help ` shows that command's details", async () => { const { out } = await repl("help sessions\nexit\n"); assert.match(out, /sessions/); assert.match(out, /--status/); // the args hint / description is shown }); it("`commands` groups every command under its category", async () => { const { out } = await repl("commands\nexit\n"); assert.match(out, /Server/); assert.match(out, /Insights/); assert.match(out, /sessions/); }); it("an unknown command does not kill the shell — later commands still run", async () => { const { code, out, err } = await repl("frobnicate\nstats\nexit\n"); assert.equal(code, 0); assert.match(err, /Unknown command/); // the refusal lands on stderr assert.match(out, /Total sessions/); // the shell survived and ran stats }); it("a server-only refusal (offline) does not kill the shell", async () => { // Point the shell at a dead port: `cost` refuses, but the shell survives // to run `exit` and close cleanly. const { code, out, err } = await repl("cost\nexit\n", 1); assert.equal(code, 0); assert.match(err + out, /runs server-side|NOT running/); }); });