2c313084a8
The sandbox's ambient FORCE_COLOR=3 was inherited by every ccam-cli.test.js child process spawn, defeating the CLI's own "colors off when piped" default and breaking every plain-text output assertion (help text, piped output, offline-mode rendering).
803 lines
28 KiB
JavaScript
803 lines
28 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 <id> 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 <id> 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 <id>",
|
|
"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 <command>/);
|
|
});
|
|
|
|
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 <command>` 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/);
|
|
});
|
|
});
|