feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,127 @@
/**
* @file Verifies the legacy `agents_new` rebuild in db.js (triggered when the
* agents table's stored CHECK constraint still contains the old 'idle'
* status) preserves the workflow_run_id/workflow_phase columns added by the
* earlier workflow migration, plus awaiting_reason, and recreates
* idx_agents_workflow.
*
* Regression coverage for a bug flagged on PR #228: the rebuild's CREATE
* TABLE agents_new / INSERT INTO agents_new SELECT / index-recreate block was
* written before the workflow_run_id + workflow_phase columns existed, so it
* silently dropped them (and idx_agents_workflow) for any DB old enough to
* still carry the legacy CHECK. Because the workflow_run_id/workflow_phase
* prepared statements are compiled at module load time, right after the
* migrations run, this crashed startup for such legacy DBs.
*
* @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 Database = require("better-sqlite3");
let TEST_DB;
before(() => {
TEST_DB = path.join(os.tmpdir(), `dashboard-agents-legacy-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
// Hand-build a pre-workflow, pre-awaiting-reason legacy DB: a sessions table
// just rich enough to satisfy the agents FK, and an agents table using the
// old 3/5-status CHECK (still containing 'idle') with none of the columns
// added by later migrations (workflow_run_id, workflow_phase, updated_at,
// awaiting_input_since, awaiting_reason). This is the exact shape the
// 'idle' CHECK detector at db.js:~731 is looking for.
const raw = new Database(TEST_DB);
raw.pragma("foreign_keys = OFF");
raw.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
name TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','completed','error','abandoned')),
cwd TEXT,
model TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
ended_at TEXT,
metadata TEXT
);
CREATE TABLE agents (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'main' CHECK(type IN ('main','subagent')),
subagent_type TEXT,
status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle','connected','working','completed','error')),
task TEXT,
current_tool TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
ended_at TEXT,
parent_agent_id TEXT,
metadata TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (parent_agent_id) REFERENCES agents(id) ON DELETE SET NULL
);
`);
raw
.prepare("INSERT INTO sessions (id, name, status, cwd, model) VALUES (?, ?, ?, ?, ?)")
.run("s-legacy-1", "legacy session", "active", "/tmp/legacy-proj", "claude");
raw
.prepare(
"INSERT INTO agents (id, session_id, name, type, status, task) VALUES (?, ?, ?, ?, ?, ?)"
)
.run("a-legacy-1", "s-legacy-1", "legacy main agent", "main", "idle", "legacy task");
raw.close();
});
after(() => {
try {
delete require.cache[require.resolve("../db")];
} catch {}
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {}
}
});
describe("legacy agents_new rebuild (pre-workflow, pre-awaiting-reason DB)", () => {
it("loads db.js against the legacy DB without throwing", () => {
delete require.cache[require.resolve("../db")];
assert.doesNotThrow(() => require("../db"));
});
it("adds workflow_run_id, workflow_phase and awaiting_reason to agents", () => {
const { db } = require("../db");
const cols = db.prepare("PRAGMA table_info(agents)").all();
const names = cols.map((c) => c.name);
for (const col of ["workflow_run_id", "workflow_phase", "awaiting_reason"]) {
assert.ok(names.includes(col), `expected agents.${col}; got: ${names.join(",")}`);
}
});
it("recreates idx_agents_workflow", () => {
const { db } = require("../db");
const idx = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_agents_workflow'"
)
.get();
assert.ok(idx, "expected idx_agents_workflow to exist after the rebuild");
});
it("preserves the pre-existing agent row and remaps its status idle -> waiting", () => {
const { db } = require("../db");
const row = db.prepare("SELECT * FROM agents WHERE id = ?").get("a-legacy-1");
assert.ok(row, "expected the legacy agent row to survive the rebuild");
assert.equal(row.status, "waiting");
assert.equal(row.session_id, "s-legacy-1");
assert.equal(row.name, "legacy main agent");
assert.equal(row.task, "legacy task");
assert.equal(row.workflow_run_id, null);
assert.equal(row.workflow_phase, null);
});
});
+398
View File
@@ -0,0 +1,398 @@
/**
* @file Tests for the rules-based alerting engine: rule CRUD validation,
* event-driven evaluation on hook ingest (pattern match, count-in-window
* threshold, token threshold), cooldown dedup, the time-based sweep
* (inactivity, stuck-agent status duration), and acknowledge endpoints.
* @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 http = require("http");
// Set up test database BEFORE requiring any server modules
const TEST_DB = path.join(os.tmpdir(), `dashboard-alerts-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const { sweepTimeRules } = require("../lib/alerts");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
function post(urlPath, body) {
return fetch(urlPath, { method: "POST", body });
}
function patch(urlPath, body) {
return fetch(urlPath, { method: "PATCH", body });
}
function del(urlPath) {
return fetch(urlPath, { method: "DELETE" });
}
function postHook(hookType, data) {
return post("/api/hooks/event", { hook_type: hookType, data });
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
const addr = server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
after(() => {
server?.close();
try {
db.close();
} catch {
/* already closed */
}
});
describe("Alert rule CRUD", () => {
it("rejects a rule without a name", async () => {
const res = await post("/api/alerts/rules", {
rule_type: "inactivity",
config: { minutes: 5 },
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("rejects an unknown rule_type", async () => {
const res = await post("/api/alerts/rules", {
name: "bad",
rule_type: "nope",
config: {},
});
assert.equal(res.status, 400);
});
it("rejects event_pattern without any pattern field", async () => {
const res = await post("/api/alerts/rules", {
name: "bad pattern",
rule_type: "event_pattern",
config: { count: 2 },
});
assert.equal(res.status, 400);
assert.match(res.body.error.message, /at least one/);
});
it("rejects invalid type-specific config values", async () => {
const badMinutes = await post("/api/alerts/rules", {
name: "bad minutes",
rule_type: "inactivity",
config: { minutes: -3 },
});
assert.equal(badMinutes.status, 400);
const badStatus = await post("/api/alerts/rules", {
name: "bad status",
rule_type: "status_duration",
config: { status: "completed", minutes: 5 },
});
assert.equal(badStatus.status, 400);
const badTokens = await post("/api/alerts/rules", {
name: "bad tokens",
rule_type: "token_threshold",
config: { total_tokens: 0 },
});
assert.equal(badTokens.status, 400);
});
it("creates, lists, updates, and deletes a rule", async () => {
const created = await post("/api/alerts/rules", {
name: "CRUD rule",
rule_type: "inactivity",
config: { minutes: 30 },
});
assert.equal(created.status, 201);
const rule = created.body.rule;
assert.ok(rule.id);
assert.equal(rule.enabled, true);
assert.equal(rule.cooldown_seconds, 300);
assert.deepEqual(rule.config, { minutes: 30 });
const list = await fetch("/api/alerts/rules");
assert.equal(list.status, 200);
assert.ok(list.body.rules.some((r) => r.id === rule.id));
const updated = await patch(`/api/alerts/rules/${rule.id}`, {
name: "CRUD rule v2",
enabled: false,
cooldown_seconds: 60,
});
assert.equal(updated.status, 200);
assert.equal(updated.body.rule.name, "CRUD rule v2");
assert.equal(updated.body.rule.enabled, false);
assert.equal(updated.body.rule.cooldown_seconds, 60);
const badPatch = await patch(`/api/alerts/rules/${rule.id}`, {
config: { minutes: "soon" },
});
assert.equal(badPatch.status, 400);
const deleted = await del(`/api/alerts/rules/${rule.id}`);
assert.equal(deleted.status, 200);
const again = await del(`/api/alerts/rules/${rule.id}`);
assert.equal(again.status, 404);
});
});
describe("Event-driven alert evaluation", () => {
it("fires on a matching event and dedups within cooldown", async () => {
const created = await post("/api/alerts/rules", {
name: "Bash watcher",
rule_type: "event_pattern",
config: { tool_name: "Bash" },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-pattern-${Date.now()}`;
await postHook("PreToolUse", { session_id: sessionId, tool_name: "Bash" });
let feed = await fetch("/api/alerts");
let fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].session_id, sessionId);
assert.equal(fired[0].rule_name, "Bash watcher");
assert.equal(fired[0].acknowledged_at, null);
// Second matching event inside the 300s default cooldown — no new alert.
await postHook("PreToolUse", { session_id: sessionId, tool_name: "Bash" });
feed = await fetch("/api/alerts");
fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
await del(`/api/alerts/rules/${ruleId}`);
});
it("only fires a count threshold once N events land in the window", async () => {
const created = await post("/api/alerts/rules", {
name: "Error burst",
rule_type: "event_pattern",
config: { event_type: "BurstProbe", count: 3, window_minutes: 5 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-burst-${Date.now()}`;
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId });
let feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
await postHook("BurstProbe", { session_id: sessionId });
feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.match(fired[0].message, /3 matching events/);
await del(`/api/alerts/rules/${ruleId}`);
});
it("fires when session token usage crosses the threshold", async () => {
const created = await post("/api/alerts/rules", {
name: "Token spike",
rule_type: "token_threshold",
config: { total_tokens: 1000000 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-tokens-${Date.now()}`;
// Seed the session, then push usage past the threshold directly.
await postHook("SessionStart", { session_id: sessionId });
stmts.upsertTokenUsage.run(sessionId, "claude-test-model", 700000, 600000, 0, 0);
// PostToolUse is a token-bearing event type, so evaluation runs.
await postHook("PostToolUse", { session_id: sessionId, tool_name: "Read" });
const feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].session_id, sessionId);
await del(`/api/alerts/rules/${ruleId}`);
});
it("does not fire disabled rules", async () => {
const created = await post("/api/alerts/rules", {
name: "Disabled watcher",
rule_type: "event_pattern",
config: { tool_name: "Grep" },
enabled: false,
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-disabled-${Date.now()}`;
await postHook("PreToolUse", { session_id: sessionId, tool_name: "Grep" });
const feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
await del(`/api/alerts/rules/${ruleId}`);
});
});
describe("Time-based alert sweep", () => {
it("fires inactivity alerts for stale active sessions", async () => {
const created = await post("/api/alerts/rules", {
name: "Idle session",
rule_type: "inactivity",
config: { minutes: 30 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-idle-${Date.now()}`;
await postHook("SessionStart", { session_id: sessionId });
// Backdate the session's last activity to one hour ago.
db.prepare(
"UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-60 minutes') WHERE id = ?"
).run(sessionId);
sweepTimeRules();
const feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].session_id, sessionId);
// Re-sweeping inside the cooldown must not duplicate the alert.
sweepTimeRules();
const again = await fetch("/api/alerts");
assert.equal(again.body.alerts.filter((a) => a.rule_id === ruleId).length, 1);
await del(`/api/alerts/rules/${ruleId}`);
});
it("fires status_duration alerts for stuck agents", async () => {
const created = await post("/api/alerts/rules", {
name: "Stuck agent",
rule_type: "status_duration",
config: { status: "working", minutes: 10 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-stuck-${Date.now()}`;
await postHook("SessionStart", { session_id: sessionId });
// ensureSession created `<id>-main` as working; backdate its activity.
db.prepare(
"UPDATE agents SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') WHERE id = ?"
).run(`${sessionId}-main`);
sweepTimeRules();
const feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].agent_id, `${sessionId}-main`);
assert.equal(fired[0].session_id, sessionId);
await del(`/api/alerts/rules/${ruleId}`);
});
});
describe("Alert feed and acknowledgement", () => {
it("acks a single alert, filters unacked, and acks all", async () => {
const created = await post("/api/alerts/rules", {
name: "Ack target",
rule_type: "event_pattern",
config: { event_type: "AckProbe" },
cooldown_seconds: 0,
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionA = `alerts-ack-a-${Date.now()}`;
const sessionB = `alerts-ack-b-${Date.now()}`;
await postHook("AckProbe", { session_id: sessionA });
await postHook("AckProbe", { session_id: sessionB });
let feed = await fetch("/api/alerts?unacked=true");
const mine = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(mine.length, 2);
const ackOne = await post(`/api/alerts/${mine[0].id}/ack`);
assert.equal(ackOne.status, 200);
assert.ok(ackOne.body.alert.acknowledged_at);
feed = await fetch("/api/alerts?unacked=true");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 1);
const missing = await post("/api/alerts/999999/ack");
assert.equal(missing.status, 404);
const ackAll = await post("/api/alerts/ack-all");
assert.equal(ackAll.status, 200);
assert.ok(ackAll.body.acknowledged >= 1);
feed = await fetch("/api/alerts?unacked=true");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
assert.equal(feed.body.unacked, 0);
await del(`/api/alerts/rules/${ruleId}`);
});
it("deleting a rule cascades its alert history away", async () => {
const created = await post("/api/alerts/rules", {
name: "Cascade check",
rule_type: "event_pattern",
config: { event_type: "CascadeProbe" },
});
const ruleId = created.body.rule.id;
const sessionId = `alerts-cascade-${Date.now()}`;
await postHook("CascadeProbe", { session_id: sessionId });
let feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 1);
await del(`/api/alerts/rules/${ruleId}`);
feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
/**
* @file Regression: a BACKGROUND subagent's tool events must not clear a
* genuine 'notification' waiting flag held by the MAIN agent (blocked on the
* user via AskUserQuestion / permission). Before the fix, PreToolUse/PostToolUse
* cleared awaiting_input_since unconditionally, so a session that was truly
* "waiting for you" oscillated back to active on every subagent tool event —
* AI-Deck (12s poll) and deck-web (5s WS) then disagreed about the same session.
*
* The guard reuses the existing subagent-actor heuristic (findDeepestWorkingAgent
* while main is 'waiting'): when a subagent is the actor, only PASSIVE waits
* (stop/session_start/interrupted) are cleared; a 'notification' wait is
* preserved. When MAIN is the actor, clearing is unconditional (keeps the
* documented permission-mid-tool path intact).
*
* This test lives in the fork's own suite so a future upstream merge that
* silently reverts the guard fails loudly here (home-network monitor fork,
* see decisions/).
* @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 http = require("http");
const TEST_DB = path.join(os.tmpdir(), `awaiting-guard-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function fetch(urlPath, options = {}) {
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: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
},
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const post = (p, body) => fetch(p, { method: "POST", body });
const hook = (hook_type, data) => post("/api/hooks/event", { hook_type, data });
const sessionOf = async (id) => (await fetch(`/api/sessions/${id}`)).body.session;
/**
* Drive a session into: main agent 'waiting' with the given reason, AND a live
* working subagent (so findDeepestWorkingAgent returns it). Returns nothing;
* asserts each precondition so a harness regression is obvious.
*/
async function sessionWaitingWithWorkingSubagent(sid, { notification }) {
await hook("SessionStart", { session_id: sid });
// UserPromptSubmit clears the session_start wait and promotes main → working.
await hook("UserPromptSubmit", { session_id: sid, prompt: "go" });
// Main (working) spawns a subagent → subagent row inserted with status working.
await hook("PreToolUse", {
session_id: sid,
tool_name: "Agent",
tool_input: { subagent_type: "reviewer", prompt: "review" },
});
// Now block the MAIN agent. A waiting-for-user Notification stamps
// reason='notification'; a Stop stamps the passive reason='stop'.
if (notification) {
await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" });
} else {
await hook("Stop", { session_id: sid });
}
const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "precondition: session should be awaiting");
assert.equal(
sess.awaiting_reason,
notification ? "notification" : "stop",
"precondition: expected awaiting_reason"
);
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
});
describe("awaiting guard: subagent tool events vs. main-agent waiting", () => {
it("PRESERVES a 'notification' wait when a background subagent fires PreToolUse", async () => {
const sid = "guard-notif-pre";
await sessionWaitingWithWorkingSubagent(sid, { notification: true });
// Subagent (deepest working, main is waiting) runs a tool. This must NOT
// clear the main agent's genuine "waiting for you" flag.
await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });
const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PreToolUse");
assert.equal(sess.awaiting_reason, "notification");
});
it("PRESERVES a 'notification' wait when a background subagent fires PostToolUse", async () => {
const sid = "guard-notif-post";
await sessionWaitingWithWorkingSubagent(sid, { notification: true });
await hook("PostToolUse", { session_id: sid, tool_name: "Bash" });
const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PostToolUse");
assert.equal(sess.awaiting_reason, "notification");
});
it("CLEARS a passive 'stop' wait when a background subagent fires a tool event", async () => {
// Passive-clear is desirable: a backgrounded subagent's activity should flip
// a merely-Stopped session back to active ("done/idle only while no agent works").
const sid = "guard-stop-pre";
await sessionWaitingWithWorkingSubagent(sid, { notification: false });
await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });
const sess = await sessionOf(sid);
assert.equal(
sess.awaiting_input_since,
null,
"passive stop wait should be cleared by subagent activity"
);
assert.equal(sess.awaiting_reason, null);
});
it("CLEARS a 'notification' wait when MAIN (no working subagent) resumes with a tool", async () => {
// Control: main was waiting on the user, no subagent running. A PreToolUse
// means main itself resumed — clearing is correct (unchanged behaviour).
const sid = "guard-notif-mainactor";
await hook("SessionStart", { session_id: sid });
await hook("UserPromptSubmit", { session_id: sid, prompt: "go" });
await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" });
let sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since && sess.awaiting_reason === "notification", "precondition");
await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });
sess = await sessionOf(sid);
assert.equal(sess.awaiting_input_since, null, "main resuming must clear its own wait");
assert.equal(sess.awaiting_reason, null);
});
});
+825
View File
@@ -0,0 +1,825 @@
/**
* @file cc-config.test.js
* @description Tests for /api/cc-config — Claude Code configuration explorer.
* Builds a fake CLAUDE_HOME and project .claude/ in tmpdir, points the
* server at it, and exercises every surface plus path-containment guards,
* write/delete with backup, plugin contributions, marketplaces, keybindings,
* statusline, and hook scripts.
* @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 fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const http = require("node:http");
// Build the fixture FIRST, set CLAUDE_HOME, then require the server. Order
// matters: claude-home.js caches the env var on first require.
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-config-test-"));
const FAKE_HOME = path.join(TMP, "home", ".claude");
const FAKE_PROJECT = path.join(TMP, "project");
const FAKE_PROJECT_CLAUDE = path.join(FAKE_PROJECT, ".claude");
fs.mkdirSync(path.join(FAKE_HOME, "skills", "demo-skill"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "agents"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "commands"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "plugins"), { recursive: true });
fs.mkdirSync(path.join(FAKE_PROJECT_CLAUDE, "skills", "proj-skill"), { recursive: true });
fs.mkdirSync(path.join(FAKE_PROJECT_CLAUDE, "agents"), { recursive: true });
fs.writeFileSync(
path.join(FAKE_HOME, "skills", "demo-skill", "SKILL.md"),
`---\nname: demo-skill\ndescription: A demo skill for tests\n---\n\nBody text here.`
);
fs.writeFileSync(
path.join(FAKE_HOME, "agents", "demo-agent.md"),
`---\nname: demo-agent\ntools: Read, Bash\nmodel: sonnet\n---\n\nAgent body.`
);
fs.writeFileSync(
path.join(FAKE_HOME, "commands", "deploy.md"),
`---\ndescription: ship it\n---\n\nDeploy command body.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CLAUDE, "skills", "proj-skill", "SKILL.md"),
`---\nname: proj-skill\n---\n\nProject skill.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CLAUDE, "agents", "proj-agent.md"),
`---\nname: proj-agent\n---\n\nProject agent.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CLAUDE, "settings.local.json"),
JSON.stringify({
permissions: { allow: ["Bash(npm:*)"] },
hooks: {
Stop: [{ matcher: "*", hooks: [{ type: "command", command: "echo hi" }] }],
},
})
);
fs.writeFileSync(
path.join(FAKE_HOME, "settings.json"),
JSON.stringify({
model: "opus",
apiKeyHelper: "should-be-redacted",
hooks: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "node x.js" }] }],
},
})
);
// Build a minimal plugin install tree so contributions counter has something to count
const PLUGIN_INSTALL = path.join(FAKE_HOME, "plugins", "cache", "market", "demo-plugin", "1.0.0");
fs.mkdirSync(path.join(PLUGIN_INSTALL, ".claude-plugin"), { recursive: true });
fs.mkdirSync(path.join(PLUGIN_INSTALL, "skills", "plugin-skill"), { recursive: true });
fs.writeFileSync(
path.join(PLUGIN_INSTALL, "skills", "plugin-skill", "SKILL.md"),
"---\nname: x\n---\nbody"
);
fs.mkdirSync(path.join(PLUGIN_INSTALL, "agents"), { recursive: true });
fs.writeFileSync(path.join(PLUGIN_INSTALL, "agents", "plug-agent.md"), "---\nname: pa\n---\n");
fs.writeFileSync(
path.join(PLUGIN_INSTALL, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "demo-plugin", description: "Demo", version: "1.0.0" })
);
fs.writeFileSync(
path.join(FAKE_HOME, "plugins", "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: {
"demo-plugin@market": [
{
scope: "user",
installPath: PLUGIN_INSTALL,
version: "1.0.0",
installedAt: "2026-01-01T00:00:00Z",
},
],
},
})
);
// Marketplace fixture
const MARKETPLACE_DIR = path.join(FAKE_HOME, "plugins", "marketplaces", "demo-mp");
fs.mkdirSync(path.join(MARKETPLACE_DIR, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(MARKETPLACE_DIR, ".claude-plugin", "marketplace.json"),
JSON.stringify({
name: "demo-mp",
description: "Demo marketplace",
owner: { name: "demo" },
plugins: [{ name: "p1" }, { name: "p2" }, { name: "p3" }],
})
);
fs.writeFileSync(
path.join(FAKE_HOME, "plugins", "known_marketplaces.json"),
JSON.stringify({
"demo-mp": {
source: { source: "github", repo: "demo/demo" },
installLocation: MARKETPLACE_DIR,
lastUpdated: "2026-01-15T00:00:00Z",
},
})
);
// Keybindings fixture
fs.writeFileSync(
path.join(FAKE_HOME, "keybindings.json"),
JSON.stringify({
$schema: "https://www.schemastore.org/x.json",
bindings: [
{ context: "Global", bindings: { "ctrl+t": "toggleTodos" } },
{ context: "Chat", bindings: { escape: "cancel", "ctrl+f": "killAgents" } },
],
})
);
// Statusline scripts
fs.writeFileSync(path.join(FAKE_HOME, "statusline.py"), "# fake statusline\nprint('ok')\n");
fs.writeFileSync(path.join(FAKE_HOME, "statusline-command.sh"), "#!/bin/sh\necho ok\n");
// Hook scripts dir
fs.mkdirSync(path.join(FAKE_HOME, "hooks"), { recursive: true });
fs.writeFileSync(path.join(FAKE_HOME, "hooks", "logger.py"), "# fake logger\n");
fs.writeFileSync(path.join(FAKE_HOME, "hooks", "scanner.py"), "# fake scanner\n");
// Mark a plugin as enabled in user settings
fs.writeFileSync(
path.join(FAKE_HOME, "settings.json"),
JSON.stringify({
model: "opus",
apiKeyHelper: "should-be-redacted",
statusLine: { type: "command", command: "sh /tmp/fake-status.sh" },
enabledPlugins: { "demo-plugin@market": true },
hooks: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "node x.js" }] }],
},
})
);
fs.writeFileSync(path.join(FAKE_PROJECT, "CLAUDE.md"), "# Project memory\nHello.");
// Per-project file-based memory store under ~/.claude/projects/<slug>/memory/
// (MEMORY.md index + one file per remembered fact). notes.txt is a non-md
// file that must be ignored.
const FAKE_AUTO_MEM = path.join(FAKE_HOME, "projects", "-Users-test-proj", "memory");
fs.mkdirSync(FAKE_AUTO_MEM, { recursive: true });
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "MEMORY.md"), "- [Foo fact](foo.md) — a hook\n");
fs.writeFileSync(
path.join(FAKE_AUTO_MEM, "foo.md"),
"---\nname: foo\n---\nFoo fact body about widgets.\n"
);
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "bar.md"), "Bar fact body.\n");
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "notes.txt"), "ignored non-md\n");
process.env.CLAUDE_HOME = FAKE_HOME;
const TEST_DB = path.join(TMP, "dashboard-test.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp } = require("../index");
let server;
let BASE;
function fetchJson(p, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) };
let bodyBuf;
if (opts.body !== undefined) {
bodyBuf = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json";
headers["Content-Length"] = bodyBuf.length;
}
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: opts.method || "GET",
headers,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
let json;
try {
json = JSON.parse(body);
} catch {
json = body;
}
resolve({ status: res.statusCode, body: json });
});
}
);
req.on("error", reject);
if (bodyBuf) req.write(bodyBuf);
req.end();
});
}
describe("/api/cc-config", () => {
before(async () => {
const app = createApp();
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
const port = server.address().port;
BASE = `http://127.0.0.1:${port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
// On Windows rmSync can hit EPERM when a handle under TMP (fixture files /
// the OS releasing directory handles) is still held. maxRetries covers
// transient locks; the try/catch makes the rest best-effort — a leftover
// temp dir must not fail the suite (the OS reclaims os.tmpdir()).
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
it("overview reports counts and roots", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.equal(body.roots.claudeHome, FAKE_HOME);
assert.equal(body.roots.projectClaudeDir, FAKE_PROJECT_CLAUDE);
assert.equal(body.counts.skills.user, 1);
assert.equal(body.counts.skills.project, 1);
assert.equal(body.counts.agents.user, 1);
assert.equal(body.counts.commands.user, 1);
assert.equal(body.counts.plugins, 1);
// 1 project CLAUDE.md + 3 auto-memory files (MEMORY.md, foo.md, bar.md);
// notes.txt is ignored.
assert.equal(body.counts.memory, 4);
});
it("skills returns user + project items with parsed frontmatter", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/skills?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
const demo = body.items.find((s) => s.name === "demo-skill");
assert.equal(demo.scope, "user");
assert.equal(demo.frontmatter.name, "demo-skill");
assert.match(demo.preview, /Body text here/);
const proj = body.items.find((s) => s.name === "proj-skill");
assert.equal(proj.scope, "project");
});
it("scope=user filters out project items", async () => {
const { body } = await fetchJson(
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.ok(body.items.every((s) => s.scope === "user"));
});
it("agents parses tools/model frontmatter", async () => {
const { body } = await fetchJson(
`/api/cc-config/agents?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const a = body.items.find((x) => x.name === "demo-agent");
assert.equal(a.frontmatter.model, "sonnet");
assert.match(a.frontmatter.tools, /Read/);
});
it("plugins returns installed manifest with contributions and enabled state", async () => {
const { body } = await fetchJson("/api/cc-config/plugins");
assert.equal(body.manifestExists, true);
assert.equal(body.plugins.length, 1);
const p = body.plugins[0];
assert.equal(p.name, "demo-plugin");
assert.equal(p.marketplace, "market");
assert.equal(p.version, "1.0.0");
assert.equal(p.enabled, true);
assert.ok(p.contributes, "contributions surfaced");
assert.equal(p.contributes.skills, 1);
assert.equal(p.contributes.agents, 1);
assert.equal(p.contributes.commands, 0);
assert.equal(p.contributes.pluginJson.name, "demo-plugin");
});
it("marketplaces returns known marketplaces with plugin counts", async () => {
const { status, body } = await fetchJson("/api/cc-config/marketplaces");
assert.equal(status, 200);
assert.equal(body.knownExists, true);
assert.equal(body.items.length, 1);
const m = body.items[0];
assert.equal(m.name, "demo-mp");
assert.equal(m.pluginCount, 3);
assert.equal(m.marketplaceName, "demo-mp");
assert.equal(m.marketplaceOwner.name, "demo");
});
it("keybindings returns parsed groups", async () => {
const { body } = await fetchJson("/api/cc-config/keybindings");
assert.equal(body.exists, true);
assert.equal(body.groups.length, 2);
const chat = body.groups.find((g) => g.context === "Chat");
assert.ok(chat);
assert.equal(chat.bindings.length, 2);
const escapeBinding = chat.bindings.find((b) => b.key === "escape");
assert.equal(escapeBinding.action, "cancel");
});
it("statusline returns config + script content", async () => {
const { body } = await fetchJson("/api/cc-config/statusline");
assert.ok(body.config);
assert.equal(body.config.type, "command");
assert.equal(body.scripts.length, 2);
assert.match(body.scripts[0].preview, /fake statusline|fake/);
});
it("hook-scripts lists files inside ~/.claude/hooks/", async () => {
const { body } = await fetchJson("/api/cc-config/hook-scripts");
const names = body.items.map((i) => i.name).sort();
assert.deepEqual(names, ["logger.py", "scanner.py"]);
});
it("overview includes the new counters", async () => {
const { body } = await fetchJson(
`/api/cc-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(body.counts.marketplaces, 1);
assert.equal(body.counts.keybindings, 3);
assert.equal(body.counts.pluginsEnabled, 1);
assert.equal(body.counts.pluginsDisabled, 0);
});
it("hooks aggregates across user + project + project-local", async () => {
const { body } = await fetchJson(
`/api/cc-config/hooks?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const userSrc = body.items.find((x) => x.scope === "user");
assert.equal(userSrc.exists, true);
assert.equal(userSrc.hooks.PreToolUse.length, 1);
const local = body.items.find((x) => x.scope === "project-local");
assert.equal(local.exists, true);
assert.equal(local.hooks.Stop.length, 1);
});
it("settings redacts secret-like keys", async () => {
const { body } = await fetchJson(
`/api/cc-config/settings?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const userSettings = body.items.find((x) => x.scope === "user");
assert.equal(userSettings.exists, true);
assert.equal(userSettings.data.apiKeyHelper, "<redacted>");
assert.equal(userSettings.data.model, "opus");
});
it("memory returns project CLAUDE.md", async () => {
const { body } = await fetchJson(
`/api/cc-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const proj = body.items.find((x) => x.scope === "project");
assert.ok(proj);
assert.match(proj.preview, /Project memory/);
});
it("memory surfaces per-project auto-memory files (index sorted first)", async () => {
const { body } = await fetchJson(
`/api/cc-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const auto = body.items.filter((x) => x.scope === "auto-memory");
assert.equal(auto.length, 3); // MEMORY.md + foo.md + bar.md; notes.txt ignored
// Index file (MEMORY.md) sorts before the per-fact files.
assert.equal(auto[0].name, "MEMORY.md");
assert.equal(auto[0].isIndex, true);
assert.equal(auto[0].project, "-Users-test-proj");
const foo = auto.find((x) => x.name === "foo.md");
assert.ok(foo);
assert.equal(foo.isIndex, false);
assert.equal(foo.frontmatter.name, "foo"); // frontmatter parsed
assert.match(foo.preview, /Foo fact body/); // preview is the body, sans frontmatter
assert.doesNotMatch(foo.preview, /name: foo/);
});
it("file endpoint reads auto-memory files (they live under CLAUDE_HOME)", async () => {
const target = path.join(FAKE_AUTO_MEM, "foo.md");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.match(body.text, /Foo fact body/);
});
it("file endpoint reads inside CLAUDE_HOME", async () => {
const target = path.join(FAKE_HOME, "agents", "demo-agent.md");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.match(body.text, /Agent body/);
});
it("file endpoint blocks paths outside allowed roots", async () => {
const outside = path.join(TMP, "evil.md");
fs.writeFileSync(outside, "secret");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(outside)}`
);
assert.equal(status, 400);
assert.equal(body.error.code, "READ_DENIED");
});
it("file endpoint blocks .. traversal", async () => {
const tricky = path.join(FAKE_HOME, "..", "..", "etc", "passwd");
const { status } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(tricky)}`
);
assert.equal(status, 400);
});
it("file endpoint requires a path", async () => {
const { status, body } = await fetchJson("/api/cc-config/file");
assert.equal(status, 400);
assert.equal(body.error.code, "BAD_PATH");
});
// ── Phase 2: write/delete ─────────────────────────────────────────
it("PUT /file creates a new agent (no backup, file did not exist)", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "agents",
name: "fresh-agent",
content: `---\nname: fresh-agent\n---\n\nFresh body.`,
},
}
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.created, true);
assert.equal(body.backupPath, null);
assert.equal(fs.readFileSync(body.file, "utf8").includes("Fresh body"), true);
});
it("PUT /file overwrites an existing agent and creates a backup", async () => {
const before = fs.readFileSync(path.join(FAKE_HOME, "agents", "demo-agent.md"), "utf8");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "agents",
name: "demo-agent",
content: `---\nname: demo-agent\n---\n\nUpdated body.`,
},
}
);
assert.equal(status, 200);
assert.equal(body.created, false);
assert.ok(body.backupPath, "backup path returned");
assert.equal(fs.readFileSync(body.backupPath, "utf8"), before);
assert.match(fs.readFileSync(body.file, "utf8"), /Updated body/);
});
it("PUT /file creates a new skill dir with SKILL.md", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "skills",
name: "brand-new-skill",
content: `---\nname: brand-new-skill\n---\n\nHello.`,
},
}
);
assert.equal(status, 200);
assert.equal(body.created, true);
assert.ok(fs.existsSync(body.file));
assert.equal(path.basename(body.file), "SKILL.md");
});
it("PUT /file rejects malicious names (traversal)", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "agents",
name: "../../etc/passwd",
content: "evil",
},
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
it("PUT /file rejects unknown type", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "plugins", name: "x", content: "y" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADTYPE");
});
it("PUT /file rejects oversize content", async () => {
const huge = "x".repeat(256 * 1024 + 1);
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "agents", name: "huge", content: huge },
}
);
assert.equal(status, 413);
assert.equal(body.error.code, "ETOOLARGE");
});
it("PUT /file edits memory CLAUDE.md without a name", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "project", type: "memory", content: "# new project memory" },
}
);
assert.equal(status, 200);
assert.ok(body.backupPath, "previous CLAUDE.md should be backed up");
assert.equal(fs.readFileSync(body.file, "utf8"), "# new project memory");
});
it("DELETE /file backs up and removes a single-file agent", async () => {
fs.writeFileSync(path.join(FAKE_HOME, "agents", "to-delete.md"), "bye");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "agents", name: "to-delete" },
}
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.ok(body.backupPath);
assert.equal(fs.existsSync(path.join(FAKE_HOME, "agents", "to-delete.md")), false);
assert.equal(fs.readFileSync(body.backupPath, "utf8"), "bye");
});
it("DELETE /file backs up and removes a skill dir (preserves bundled assets in backup)", async () => {
const skillDir = path.join(FAKE_HOME, "skills", "with-assets");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: with-assets\n---\nbody");
fs.writeFileSync(path.join(skillDir, "asset.txt"), "important payload");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "skills", name: "with-assets" },
}
);
assert.equal(status, 200);
assert.ok(body.backupPath);
assert.equal(fs.existsSync(skillDir), false);
assert.equal(
fs.readFileSync(path.join(body.backupPath, "asset.txt"), "utf8"),
"important payload"
);
});
it("DELETE /file 404s on a missing item", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "agents", name: "never-existed" },
}
);
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("backups endpoint lists everything we just created", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
// We should have at least: demo-agent overwrite + memory overwrite +
// to-delete + with-assets dir.
assert.ok(body.items.length >= 4, `expected ≥4 backups, got ${body.items.length}`);
assert.ok(body.items.every((b) => typeof b.backupPath === "string"));
});
it("write is atomic: tmp file is gone after success", async () => {
await fetchJson(`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`, {
method: "PUT",
body: {
scope: "user",
type: "commands",
name: "atomic-test",
content: "---\ndescription: atomic\n---\nbody",
},
});
const cmdsDir = path.join(FAKE_HOME, "commands");
const stragglers = fs
.readdirSync(cmdsDir)
.filter((n) => n.startsWith(".atomic-test.md.") && n.endsWith(".tmp"));
assert.deepEqual(stragglers, []);
});
// ── auto-memory mutations (per-project file-based memory) ───────────────
const AUTO_SLUG = "-Users-test-proj";
it("PUT /file creates a new auto-memory fact file", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: AUTO_SLUG,
name: "new_fact.md",
content: "---\nname: new-fact\n---\nA freshly written fact.",
},
}
);
assert.equal(status, 200);
assert.equal(body.created, true);
assert.equal(body.backupPath, null); // brand-new file → nothing to back up
assert.equal(
fs.readFileSync(path.join(FAKE_AUTO_MEM, "new_fact.md"), "utf8"),
"---\nname: new-fact\n---\nA freshly written fact."
);
});
it("PUT /file edits an existing auto-memory file and backs it up", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: AUTO_SLUG,
name: "foo.md",
content: "edited foo body",
},
}
);
assert.equal(status, 200);
assert.ok(body.backupPath, "existing foo.md should be backed up");
assert.match(body.backupPath, /\.cc-config-backups[\\/]auto-memory[\\/]foo\.md\./);
assert.equal(fs.readFileSync(path.join(FAKE_AUTO_MEM, "foo.md"), "utf8"), "edited foo body");
});
it("DELETE /file backs up and removes an auto-memory file", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "auto-memory", type: "auto-memory", project: AUTO_SLUG, name: "bar.md" },
}
);
assert.equal(status, 200);
assert.ok(body.backupPath);
assert.equal(fs.existsSync(path.join(FAKE_AUTO_MEM, "bar.md")), false);
assert.equal(fs.readFileSync(body.backupPath, "utf8"), "Bar fact body.\n");
});
it("backups endpoint includes auto-memory backups (with project)", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
const auto = body.items.filter((b) => b.scope === "auto-memory");
assert.ok(auto.length >= 2, `expected ≥2 auto-memory backups, got ${auto.length}`);
assert.ok(auto.every((b) => b.project === AUTO_SLUG && b.type === "auto-memory"));
assert.ok(auto.some((b) => /^foo\.md\./.test(b.name)));
});
it("PUT /file rejects an auto-memory project slug that traverses", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: "../../etc",
name: "x.md",
content: "evil",
},
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADPROJECT");
});
it("PUT /file rejects an auto-memory name without a .md extension", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: AUTO_SLUG,
name: "../escape",
content: "evil",
},
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
// ── Keybindings structured edit (PUT /keybindings) ─────────────────────
// These run last so the earlier overview count assertion (keybindings === 3)
// sees the original fixture before we rewrite the file here.
it("PUT /keybindings overwrites, backs up, and preserves top-level metadata", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: {
groups: [
{
context: "Global",
bindings: [
{ key: "ctrl+t", action: "toggleTodos" },
{ key: "ctrl+n", action: "newThing" },
],
},
{ context: "Chat", bindings: [{ key: "escape", action: "cancel" }] },
],
},
});
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.created, false);
assert.ok(body.backupPath, "existing keybindings.json should be backed up");
const onDisk = JSON.parse(fs.readFileSync(path.join(FAKE_HOME, "keybindings.json"), "utf8"));
// $schema from the fixture must survive a structured rewrite.
assert.equal(onDisk.$schema, "https://www.schemastore.org/x.json");
const global = onDisk.bindings.find((g) => g.context === "Global");
assert.equal(global.bindings["ctrl+n"], "newThing");
assert.equal(Object.keys(onDisk.bindings.find((g) => g.context === "Chat").bindings).length, 1);
// Re-reading through the API returns the updated groups.
const after = await fetchJson("/api/cc-config/keybindings");
const chat = after.body.groups.find((g) => g.context === "Chat");
assert.equal(chat.bindings.length, 1);
});
it("PUT /keybindings rejects a duplicate key within one context", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: {
groups: [
{
context: "Global",
bindings: [
{ key: "ctrl+t", action: "toggleTodos" },
{ key: "ctrl+t", action: "somethingElse" },
],
},
],
},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCONTENT");
});
it("PUT /keybindings rejects a non-array groups payload", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: { groups: "nope" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADREQ");
});
it("PUT /keybindings rejects an empty action", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: { groups: [{ context: "Global", bindings: [{ key: "ctrl+z", action: "" }] }] },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCONTENT");
});
});
@@ -0,0 +1,334 @@
/**
* @file cc-discovery-helpers.test.js
* @description Direct unit tests for the exported helpers in
* `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder,
* isFileLike, the symlinked skill-directory and agent/command markdown-file
* discovery paths (readSkills / readAgents), and the MAX_FILE_BYTES
* constant. The integration tests in
* cc-config.test.js exercise these indirectly through HTTP routes; this
* file pins down their behavior at the function level so future refactors
* surface regressions immediately.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
parseFrontmatter,
redactSettings,
isUnder,
isFileLike,
readSkills,
readAgents,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
} = require("../lib/cc-discovery");
describe("parseFrontmatter", () => {
it("returns null frontmatter for plain markdown", () => {
const r = parseFrontmatter("# Just a heading\n\nNo frontmatter here.");
assert.equal(r.frontmatter, null);
assert.match(r.body, /Just a heading/);
});
it("parses simple key/value frontmatter", () => {
const r = parseFrontmatter("---\nname: my-skill\ndescription: A simple skill\n---\nbody text");
assert.equal(r.frontmatter.name, "my-skill");
assert.equal(r.frontmatter.description, "A simple skill");
assert.equal(r.body.trim(), "body text");
});
it("strips surrounding double quotes from values", () => {
const r = parseFrontmatter('---\nname: "quoted name"\n---\n');
assert.equal(r.frontmatter.name, "quoted name");
});
it("strips surrounding single quotes from values", () => {
const r = parseFrontmatter("---\nname: 'single quoted'\n---\n");
assert.equal(r.frontmatter.name, "single quoted");
});
it("preserves quotes inside the value", () => {
const r = parseFrontmatter('---\ndescription: text with "inner" quotes\n---\n');
assert.equal(r.frontmatter.description, 'text with "inner" quotes');
});
it("supports multiline indented continuation values", () => {
const r = parseFrontmatter(
"---\ndescription: line one\n line two\n line three\nname: x\n---\n"
);
assert.match(r.frontmatter.description, /line one/);
assert.match(r.frontmatter.description, /line two/);
assert.match(r.frontmatter.description, /line three/);
assert.equal(r.frontmatter.name, "x");
});
it("returns null frontmatter when --- is present but never closed", () => {
const r = parseFrontmatter("---\nname: never closed\nbody text below");
assert.equal(r.frontmatter, null);
// body falls back to the entire input
assert.match(r.body, /never closed/);
});
it("ignores lines that don't match key:value", () => {
const r = parseFrontmatter("---\nname: ok\nthis is not a key value\n---\n");
assert.equal(r.frontmatter.name, "ok");
assert.equal(r.frontmatter["this is not a key value"], undefined);
});
it("handles non-string input gracefully", () => {
const r1 = parseFrontmatter(null);
const r2 = parseFrontmatter(undefined);
const r3 = parseFrontmatter(123);
for (const r of [r1, r2, r3]) {
assert.equal(r.frontmatter, null);
assert.equal(r.body, "");
}
});
it("trims trailing whitespace in values", () => {
const r = parseFrontmatter("---\nname: trailing-space \n---\n");
assert.equal(r.frontmatter.name, "trailing-space");
});
it("body preserves its content verbatim after the closing ---", () => {
const r = parseFrontmatter("---\nname: x\n---\n\n# Heading\n\nParagraph.");
assert.match(r.body, /^# Heading/);
assert.match(r.body, /Paragraph\.$/);
});
});
describe("redactSettings", () => {
it("redacts string values whose key matches token/secret/password/key/auth", () => {
const input = {
apiKey: "sk-abc123",
authToken: "bearer xyz",
password: "hunter2",
secret_key: "shh",
apiSecret: "private",
regularField: "stays",
};
const out = redactSettings(input);
assert.equal(out.apiKey, "<redacted>");
assert.equal(out.authToken, "<redacted>");
assert.equal(out.password, "<redacted>");
assert.equal(out.secret_key, "<redacted>");
assert.equal(out.apiSecret, "<redacted>");
assert.equal(out.regularField, "stays");
});
it("handles api-key and api_key variants", () => {
const out = redactSettings({ "api-key": "x", api_key: "y", apikey: "z" });
assert.equal(out["api-key"], "<redacted>");
assert.equal(out.api_key, "<redacted>");
assert.equal(out.apikey, "<redacted>");
});
it("does not redact non-string values even if key matches", () => {
// The redactor only swaps STRING values; nested objects/arrays/numbers
// pass through unchanged so structure is preserved.
const out = redactSettings({ apiKey: 12345, secrets: { x: "stay" } });
assert.equal(out.apiKey, 12345);
assert.deepEqual(out.secrets, { x: "stay" });
});
it("recurses into nested objects", () => {
const out = redactSettings({
provider: { name: "anthropic", apiKey: "sk-...", endpoint: "https://api" },
});
assert.equal(out.provider.apiKey, "<redacted>");
assert.equal(out.provider.name, "anthropic");
assert.equal(out.provider.endpoint, "https://api");
});
it("recurses into arrays of objects", () => {
const out = redactSettings({
mcpServers: [
{ name: "a", apiKey: "sk-1" },
{ name: "b", token: "tok-2" },
],
});
assert.equal(out.mcpServers[0].apiKey, "<redacted>");
assert.equal(out.mcpServers[1].token, "<redacted>");
assert.equal(out.mcpServers[0].name, "a");
});
it("passes scalars through unchanged", () => {
assert.equal(redactSettings(null), null);
assert.equal(redactSettings(undefined), undefined);
assert.equal(redactSettings(42), 42);
assert.equal(redactSettings(true), true);
assert.equal(redactSettings("plain"), "plain");
});
it("is case-insensitive on key match", () => {
const out = redactSettings({ APIKEY: "x", AuthHeader: "y", ToKeN: "z" });
assert.equal(out.APIKEY, "<redacted>");
assert.equal(out.AuthHeader, "<redacted>");
assert.equal(out.ToKeN, "<redacted>");
});
it("does not match keys that contain unrelated substrings", () => {
// 'kept' contains 'kep' not 'key' - should stay
const out = redactSettings({ kept: "stays", description: "stays too" });
assert.equal(out.kept, "stays");
assert.equal(out.description, "stays too");
});
});
describe("isUnder", () => {
it("returns true when target equals root", () => {
assert.equal(isUnder("/a/b", "/a/b"), true);
});
it("returns true when target is inside root", () => {
assert.equal(isUnder("/a/b", "/a/b/c"), true);
assert.equal(isUnder("/a/b", "/a/b/c/d/e.txt"), true);
});
it("returns false when target is outside root", () => {
assert.equal(isUnder("/a/b", "/a/c"), false);
assert.equal(isUnder("/a/b", "/x/y"), false);
});
it("rejects sibling paths that share a prefix", () => {
// /a/b vs /a/bb - bb starts with b but is not under /a/b
assert.equal(isUnder("/a/b", "/a/bb"), false);
assert.equal(isUnder("/a/b", "/a/b-extra"), false);
});
it("normalises relative segments via path.resolve", () => {
// .. inside the target gets resolved away
assert.equal(isUnder("/a/b", "/a/b/c/../d"), true); // /a/b/d
assert.equal(isUnder("/a/b", "/a/b/../c"), false); // /a/c
});
it("returns true regardless of trailing slash", () => {
assert.equal(isUnder("/a/b/", "/a/b/c"), true);
assert.equal(isUnder("/a/b", "/a/b/c/"), true);
});
it("works with paths produced by path.resolve", () => {
const root = path.resolve("/tmp/cc-test");
const inside = path.resolve("/tmp/cc-test/skills/x/SKILL.md");
const outside = path.resolve("/tmp/elsewhere/file");
assert.equal(isUnder(root, inside), true);
assert.equal(isUnder(root, outside), false);
});
});
describe("readSkills (symlinked skill directories)", () => {
it("includes a skill directory that is a symlink to a real directory", () => {
// Dirent.isDirectory() returns false for a symlink even when it points
// to a directory (Node fs quirk) — readSkillsAt must still follow it.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-symlink-"));
const realDir = path.join(tmp, "real-skills", "second-brain");
fs.mkdirSync(realDir, { recursive: true });
fs.writeFileSync(
path.join(realDir, "SKILL.md"),
"---\nname: second-brain\n---\n\nBody text.\n"
);
const projectRoot = path.join(tmp, "project");
const skillsDir = path.join(projectRoot, ".claude", "skills");
fs.mkdirSync(skillsDir, { recursive: true });
fs.symlinkSync(realDir, path.join(skillsDir, "second-brain"), "dir");
const items = readSkills({ scope: "project", cwd: projectRoot });
const names = items.map((s) => s.name);
assert.ok(names.includes("second-brain"), `expected symlinked skill in ${names}`);
});
it("skips a broken symlink without throwing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-broken-symlink-"));
const projectRoot = path.join(tmp, "project");
const skillsDir = path.join(projectRoot, ".claude", "skills");
fs.mkdirSync(skillsDir, { recursive: true });
fs.symlinkSync(path.join(tmp, "does-not-exist"), path.join(skillsDir, "broken"), "dir");
let items;
assert.doesNotThrow(() => {
items = readSkills({ scope: "project", cwd: projectRoot });
});
assert.ok(!items.map((s) => s.name).includes("broken"));
});
});
describe("readAgents (symlinked markdown files)", () => {
it("includes an agent .md that is a symlink to a real file", () => {
// Same Dirent quirk as symlinked skill directories, but for files:
// ent.isFile() is false for a symlink pointing at a regular file, so a
// version-controlled agent linked into .claude/agents/ was invisible.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-symlink-"));
const realFile = path.join(tmp, "repo", "reviewer.md");
fs.mkdirSync(path.dirname(realFile), { recursive: true });
fs.writeFileSync(realFile, "---\nname: reviewer\ndescription: Reviews diffs\n---\n\nBody.\n");
const projectRoot = path.join(tmp, "project");
const agentsDir = path.join(projectRoot, ".claude", "agents");
fs.mkdirSync(agentsDir, { recursive: true });
fs.symlinkSync(realFile, path.join(agentsDir, "reviewer.md"), "file");
const items = readAgents({ scope: "project", cwd: projectRoot });
const names = items.map((a) => a.name);
assert.ok(names.includes("reviewer"), `expected symlinked agent in ${names}`);
});
it("skips a broken .md symlink without throwing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-broken-"));
const projectRoot = path.join(tmp, "project");
const agentsDir = path.join(projectRoot, ".claude", "agents");
fs.mkdirSync(agentsDir, { recursive: true });
fs.symlinkSync(path.join(tmp, "gone.md"), path.join(agentsDir, "ghost.md"), "file");
let items;
assert.doesNotThrow(() => {
items = readAgents({ scope: "project", cwd: projectRoot });
});
assert.ok(!items.some((a) => /ghost/.test(a.file || "")));
});
});
describe("isFileLike", () => {
it("true for a regular file dirent", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-"));
fs.writeFileSync(path.join(tmp, "a.md"), "x");
const ent = fs.readdirSync(tmp, { withFileTypes: true })[0];
assert.equal(isFileLike(ent, path.join(tmp, ent.name)), true);
});
it("true for a symlink resolving to a file, false for one resolving to a directory", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-sym-"));
fs.writeFileSync(path.join(tmp, "real.md"), "x");
fs.mkdirSync(path.join(tmp, "realdir"));
fs.symlinkSync(path.join(tmp, "real.md"), path.join(tmp, "file-link.md"), "file");
fs.symlinkSync(path.join(tmp, "realdir"), path.join(tmp, "dir-link"), "dir");
const ents = Object.fromEntries(
fs.readdirSync(tmp, { withFileTypes: true }).map((e) => [e.name, e])
);
assert.equal(isFileLike(ents["file-link.md"], path.join(tmp, "file-link.md")), true);
assert.equal(isFileLike(ents["dir-link"], path.join(tmp, "dir-link")), false);
});
});
describe("module exports", () => {
it("MAX_FILE_BYTES is 256 KB", () => {
assert.equal(MAX_FILE_BYTES, 256 * 1024);
});
it("HOOK_EVENT_TYPES covers all canonical Claude Code events", () => {
const expected = [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"Notification",
"PreCompact",
];
for (const t of expected) assert.ok(HOOK_EVENT_TYPES.includes(t), `missing ${t}`);
});
});
+796
View File
@@ -0,0 +1,796 @@
/**
* @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";
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/);
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* @file Round-trip correctness for the full-dataset export/import (backup /
* restore) in server/lib/data-transfer.js.
*
* Verifies the two product requirements:
* 1. Export captures ALL user data (sessions, agents, events, token_usage,
* workflows, dashboard_runs, alert_rules, model_pricing) with a
* format/version stamp.
* 2. Re-importing that export reproduces the data accurately, is idempotent
* (re-import skips existing sessions, never duplicates), and merges cleanly
* when consolidating another machine's sessions into an existing DB.
*
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-data-transfer-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const {
buildExportBundle,
importExportBundle,
EXPORT_FORMAT,
EXPORT_VERSION,
} = require("../lib/data-transfer");
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
function seedSession(id, { events = 2 } = {}) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at) VALUES (?,?,?,?,?,?,?)"
).run(
id,
`Session ${id}`,
"completed",
"/tmp/x",
"claude-opus-4-8",
"2026-06-01T00:00:00.000Z",
"2026-06-01T01:00:00.000Z"
);
const mainId = `agent_main_${id}`;
const subId = `agent_sub_${id}`;
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES (?,?,?,?,?,?)"
).run(mainId, id, "Main", "main", "completed", "2026-06-01T00:00:00.000Z");
// Child references parent — exercises deferred-FK ordering on restore.
db.prepare(
"INSERT INTO agents (id, session_id, name, type, subagent_type, status, started_at, parent_agent_id) VALUES (?,?,?,?,?,?,?,?)"
).run(subId, id, "Sub", "subagent", "explorer", "completed", "2026-06-01T00:10:00.000Z", mainId);
for (let i = 0; i < events; i++) {
db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, created_at) VALUES (?,?,?,?,?,?)"
).run(id, mainId, "PostToolUse", "Bash", `evt ${i}`, `2026-06-01T00:0${i}:00.000Z`);
}
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens, output_tokens, baseline_input) VALUES (?,?,?,?,?)"
).run(id, "claude-opus-4-8", 1000, 500, 250);
db.prepare(
"INSERT INTO workflows (run_id, session_id, name, status, agent_count, total_tokens) VALUES (?,?,?,?,?,?)"
).run(`wf_${id}`, id, "wf", "completed", 2, 1500);
}
describe("data-transfer export/import round-trip", () => {
before(() => {
// Config-like rows (independent of sessions).
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status) VALUES (?,?,?,?,?)"
).run("run_1", "S1", "headless", "/tmp/x", "completed");
db.prepare("INSERT INTO alert_rules (id, name, rule_type, config) VALUES (?,?,?,?)").run(
"rule_1",
"My Rule",
"inactivity",
"{}"
);
stmts.upsertPricing.run("custom-model-*", "Custom", 3, 15, 0.3, 3.75, 6, 0, 0);
seedSession("S1", { events: 3 });
seedSession("S2", { events: 2 });
});
it("exports every table with a format/version stamp", () => {
const bundle = buildExportBundle(db, stmts);
assert.equal(bundle.format, EXPORT_FORMAT);
assert.equal(bundle.version, EXPORT_VERSION);
assert.ok(bundle.exported_at);
assert.equal(bundle.sessions.length, 2);
assert.equal(bundle.agents.length, 4);
assert.equal(bundle.events.length, 5);
assert.equal(bundle.token_usage.length, 2);
assert.equal(bundle.workflows.length, 2);
assert.ok(bundle.dashboard_runs.some((r) => r.id === "run_1"));
assert.ok(bundle.alert_rules.some((r) => r.id === "rule_1"));
assert.ok(bundle.model_pricing.some((p) => p.model_pattern === "custom-model-*"));
});
it("restores an exported bundle accurately into a fresh DB", () => {
const bundle = JSON.parse(JSON.stringify(buildExportBundle(db, stmts)));
// Simulate a fresh machine: wipe everything the bundle carries.
db.exec(
"DELETE FROM events; DELETE FROM token_usage; DELETE FROM workflows; DELETE FROM agents; DELETE FROM sessions; DELETE FROM dashboard_runs; DELETE FROM alert_rules; DELETE FROM model_pricing;"
);
const c = importExportBundle(db, bundle);
assert.equal(c.sessions_imported, 2);
assert.equal(c.sessions_skipped, 0);
assert.equal(c.agents, 4);
assert.equal(c.events, 5);
assert.equal(c.token_usage, 2);
assert.equal(c.workflows, 2);
assert.equal(c.dashboard_runs, 1);
assert.equal(c.alert_rules, 1);
// model_pricing includes the seeded default rows plus our custom one; all
// are restored into the wiped DB.
assert.equal(c.model_pricing, bundle.model_pricing.length);
assert.ok(
db.prepare("SELECT 1 FROM model_pricing WHERE model_pattern = 'custom-model-*'").get()
);
// Accuracy: token totals (incl. baseline) restored verbatim.
const tu = db.prepare("SELECT * FROM token_usage WHERE session_id = 'S1'").get();
assert.equal(tu.input_tokens, 1000);
assert.equal(tu.baseline_input, 250);
// Parent/child agent link survived deferred-FK restore.
const sub = db.prepare("SELECT parent_agent_id FROM agents WHERE id = 'agent_sub_S1'").get();
assert.equal(sub.parent_agent_id, "agent_main_S1");
});
it("is idempotent — re-importing skips existing sessions, no duplicate events", () => {
const bundle = buildExportBundle(db, stmts);
const eventsBefore = db.prepare("SELECT COUNT(*) c FROM events").get().c;
const c = importExportBundle(db, bundle);
assert.equal(c.sessions_imported, 0);
assert.equal(c.sessions_skipped, 2);
assert.equal(c.events, 0);
const eventsAfter = db.prepare("SELECT COUNT(*) c FROM events").get().c;
assert.equal(eventsAfter, eventsBefore, "events must not be duplicated on re-import");
});
it("merges a second machine's sessions without touching existing ones", () => {
// Current DB has S1, S2. Build a bundle that adds a brand-new session S3.
const bundle = buildExportBundle(db, stmts);
seedSession("S3", { events: 4 });
const merged = buildExportBundle(db, stmts);
// Roll the seeded S3 back out so the DB looks like the "target" (S1,S2)
// and the bundle is the "source" (S1,S2,S3).
db.exec(
"DELETE FROM events WHERE session_id='S3'; DELETE FROM token_usage WHERE session_id='S3'; DELETE FROM workflows WHERE session_id='S3'; DELETE FROM agents WHERE session_id='S3'; DELETE FROM sessions WHERE id='S3';"
);
void bundle;
const c = importExportBundle(db, merged);
assert.equal(c.sessions_imported, 1, "only the new session imports");
assert.equal(c.sessions_skipped, 2, "existing sessions are skipped");
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions").get().c, 3);
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='S3'").get().c, 4);
});
it("rejects a non-export object", () => {
assert.throws(() => importExportBundle(db, { foo: "bar" }), /recognizable dashboard export/);
assert.throws(() => importExportBundle(db, { format: "something-else" }), /Unrecognized/);
});
});
@@ -0,0 +1,485 @@
/**
* @file Tests for the first-user-prompt fallback descriptor (issue #201).
* Covers:
* - TranscriptCache capturing the first real user message (tool-result,
* meta/caveat, slash-command plumbing, and interrupt entries skipped),
* normalized and length-capped, first value winning across incremental
* re-reads.
* - The hook ingestor filling placeholder session/main-agent names and the
* main agent task from the descriptor — without clobbering real titles,
* user-set names, or an in-flight current_tool — and staying idempotent.
* - A later ai-title replacing a descriptor-filled session name.
* - importSession using the descriptor for imported sessions (new rows and
* the re-import backfill path).
* Uses Node's built-in test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR.
* @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 STAMP = `first-user-desc-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
const { createApp, startServer } = require("../index");
const dbModule = require("../db");
const { db, stmts } = dbModule;
const TranscriptCache = require("../lib/transcript-cache");
const { parseSessionFile, importSession } = require("../../scripts/import-history");
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const PROJECTS = path.join(CLAUDE_HOME, "projects");
function jsonl(lines) {
return lines.map((o) => JSON.stringify(o)).join("\n") + "\n";
}
function transcriptPath(cwd, sessionId) {
return path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
}
function writeTranscript(cwd, sessionId, lines) {
const p = transcriptPath(cwd, sessionId);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, jsonl(lines));
return p;
}
function appendTranscript(cwd, sessionId, lines) {
const p = transcriptPath(cwd, sessionId);
fs.appendFileSync(p, jsonl(lines));
return p;
}
function req(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b || "{}");
} catch {
parsed = b;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
r.on("error", reject);
if (payload) r.write(payload);
r.end();
});
}
let server;
let BASE;
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("TranscriptCache — first user message extraction", () => {
it("captures the first real prompt, skipping caveat/command/tool-result entries", () => {
const cwd = "/tmp/fud-cache-skip";
const sid = "cache-skip";
const p = writeTranscript(cwd, sid, [
{
type: "user",
isMeta: true,
message: {
role: "user",
content: "<local-command-caveat>Caveat: local commands</local-command-caveat>",
},
},
{
type: "user",
message: { role: "user", content: "<command-name>/model</command-name>" },
},
{
type: "user",
message: {
role: "user",
content: "<local-command-stdout>Set model</local-command-stdout>",
},
},
{
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }],
},
},
{
type: "user",
message: { role: "user", content: "fix the login\nbug in auth.ts" },
},
{ type: "user", message: { role: "user", content: "a later prompt" } },
]);
const r = new TranscriptCache().extract(p);
assert.ok(r, "result should not be null");
// Whitespace runs / newlines collapse to single spaces; first prompt wins.
assert.equal(r.firstUserMessage, "fix the login bug in auth.ts");
});
it("skips user-interrupt entries and caps the captured text at 500 chars", () => {
const cwd = "/tmp/fud-cache-cap";
const sid = "cache-cap";
const long = "x".repeat(600);
const p = writeTranscript(cwd, sid, [
{
type: "user",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
},
{ type: "user", message: { role: "user", content: long } },
]);
const r = new TranscriptCache().extract(p);
assert.equal(r.firstUserMessage, "x".repeat(500));
});
it("returns a result for a transcript that has ONLY a user prompt", () => {
const cwd = "/tmp/fud-cache-only";
const sid = "cache-only";
const p = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "just a prompt" } },
]);
const r = new TranscriptCache().extract(p);
assert.ok(r, "result should not be null");
assert.equal(r.firstUserMessage, "just a prompt");
});
it("keeps the FIRST prompt across incremental appends (first wins)", () => {
const cwd = "/tmp/fud-cache-incr";
const sid = "cache-incr";
const cache = new TranscriptCache();
const p = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "original prompt" } },
]);
assert.equal(cache.extract(p).firstUserMessage, "original prompt");
appendTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "second prompt" } },
]);
// Force a fresh stat (mtime granularity) by touching size — append above
// already grew the file, so the incremental path runs.
assert.equal(cache.extract(p).firstUserMessage, "original prompt");
});
});
describe("hook ingestor — descriptor fills placeholders", () => {
it("fills placeholder session name and main agent name/task on UserPromptSubmit", async () => {
const cwd = "/tmp/fud-hook-fill";
const sid = "10000000-0000-0000-0000-000000000001";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "add dark mode to settings" } },
]);
const res = await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "add dark mode to settings");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - add dark mode to settings");
assert.equal(main.task, "add dark mode to settings");
});
it("truncates long prompts to 60 chars for names but keeps the task longer", async () => {
const cwd = "/tmp/fud-hook-trunc";
const sid = "10000000-0000-0000-0000-000000000002";
const prompt = "p".repeat(100);
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: prompt } },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "p".repeat(57) + "...");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, `Main Agent - ${"p".repeat(57)}...`);
assert.equal(main.task, prompt);
});
it("title still wins: ai-title takes the session name, descriptor fills the agent", async () => {
const cwd = "/tmp/fud-hook-title";
const sid = "10000000-0000-0000-0000-000000000003";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "investigate the flaky test" } },
{ type: "ai-title", aiTitle: "Flaky test investigation", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "Flaky test investigation");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - investigate the flaky test");
assert.equal(main.task, "investigate the flaky test");
});
it("a later ai-title replaces a descriptor-filled session name", async () => {
const cwd = "/tmp/fud-hook-later-title";
const sid = "10000000-0000-0000-0000-000000000004";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "refactor the websocket layer" } },
]);
await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "refactor the websocket layer");
appendTranscript(cwd, sid, [
{ type: "ai-title", aiTitle: "WebSocket refactor", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "WebSocket refactor");
});
it("never clobbers a user-set session name or a renamed main agent", async () => {
const cwd = "/tmp/fud-hook-user-set";
const sid = "10000000-0000-0000-0000-000000000005";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "descriptor text" } },
]);
// Seed the session, then simulate user-chosen names on both rows.
await req("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid, cwd },
});
stmts.updateSessionName.run("my chosen name", sid, "my chosen name");
stmts.updateAgent.run("My Renamed Agent", null, "my task", null, null, null, `${sid}-main`);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "my chosen name");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "My Renamed Agent");
assert.equal(main.task, "my task");
});
it("preserves an in-flight current_tool when filling the main agent", async () => {
const cwd = "/tmp/fud-hook-tool";
const sid = "10000000-0000-0000-0000-000000000006";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "run the test suite" } },
]);
// PreToolUse stamps current_tool = Bash in the same transaction that then
// applies the descriptor — the fill must not wipe the in-flight tool.
await req("POST", "/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: sid, cwd, transcript_path: tpath, tool_name: "Bash" },
});
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.current_tool, "Bash");
assert.equal(main.name, "Main Agent - run the test suite");
assert.equal(main.task, "run the test suite");
});
it("is idempotent — a second event changes nothing", async () => {
const cwd = "/tmp/fud-hook-idem";
const sid = "10000000-0000-0000-0000-000000000007";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "one prompt only" } },
]);
await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const before = {
session: stmts.getSession.get(sid).name,
agent: stmts.getAgent.get(`${sid}-main`),
};
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, before.session);
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, before.agent.name);
assert.equal(main.task, before.agent.task);
});
});
describe("import — descriptor for imported sessions", () => {
it("parseSessionFile falls back to the first user prompt below titles", async () => {
const cwd = "/tmp/fud-import-parse";
const sid = "20000000-0000-0000-0000-000000000001";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "build the CSV exporter" },
},
]);
const parsed = await parseSessionFile(p);
assert.equal(parsed.firstUserMessage, "build the CSV exporter");
assert.equal(parsed.name, "build the CSV exporter");
});
it("a real title still outranks the descriptor in parseSessionFile", async () => {
const cwd = "/tmp/fud-import-title";
const sid = "20000000-0000-0000-0000-000000000002";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "some prompt" },
},
{ type: "ai-title", aiTitle: "Real title", sessionId: sid },
]);
const parsed = await parseSessionFile(p);
assert.equal(parsed.name, "Real title");
assert.equal(parsed.firstUserMessage, "some prompt");
});
it("importSession names new rows and their main agent from the descriptor", async () => {
const cwd = "/tmp/fud-import-new";
const sid = "20000000-0000-0000-0000-000000000003";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "wire up the pricing table" },
},
]);
const parsed = await parseSessionFile(p);
importSession(dbModule, parsed);
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "wire up the pricing table");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - wire up the pricing table");
assert.equal(main.task, "wire up the pricing table");
});
it("re-import backfills placeholder-named rows created by earlier imports", async () => {
const cwd = "/tmp/fud-import-backfill";
const sid = "20000000-0000-0000-0000-000000000004";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "migrate the alerts schema" },
},
]);
// Simulate an old import: cwd-derived placeholder session + agent names.
// metadata.imported = true is what routes importSession to its backfill
// branch (hand-created sessions are never touched by re-imports).
const base = path.basename(cwd);
stmts.insertSession.run(
sid,
`${base} - ${sid.slice(0, 8)}`,
"completed",
cwd,
null,
JSON.stringify({ imported: true })
);
stmts.insertAgent.run(
`${sid}-main`,
sid,
`Main Agent - ${base} - ${sid.slice(0, 8)}`,
"main",
null,
"completed",
null,
null,
null
);
const parsed = await parseSessionFile(p);
const result = importSession(dbModule, parsed);
assert.equal(result.backfilled, true);
assert.equal(stmts.getSession.get(sid).name, "migrate the alerts schema");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - migrate the alerts schema");
assert.equal(main.task, "migrate the alerts schema");
});
it("re-import keeps user-picked names intact", async () => {
const cwd = "/tmp/fud-import-keep";
const sid = "20000000-0000-0000-0000-000000000005";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "descriptor that must not apply" },
},
]);
stmts.insertSession.run(
sid,
"picked by hand",
"completed",
cwd,
null,
JSON.stringify({ imported: true })
);
stmts.insertAgent.run(
`${sid}-main`,
sid,
"Main Agent - picked by hand",
"main",
null,
"completed",
"hand-written task",
null,
null
);
const parsed = await parseSessionFile(p);
importSession(dbModule, parsed);
assert.equal(stmts.getSession.get(sid).name, "picked by hand");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - picked by hand");
assert.equal(main.task, "hand-written task");
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* @file Regression tests for scripts/hook-handler.js delivery behavior. The
* handler must never block Claude Code waiting for the dashboard's HTTP
* response — it delivers the event (flushes the request) and exits, leaving the
* local server to process the buffered request on its own schedule. These tests
* lock in that non-blocking contract so a future refactor can't reintroduce the
* "stuck running hooks" stall (handler waiting up to the per-request timeout for
* a slow/busy/wedged dashboard to reply).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const http = require("http");
const { spawn } = require("child_process");
const HANDLER = path.resolve(__dirname, "../../scripts/hook-handler.js");
// A mock dashboard that fully RECEIVES the request (records the body) but can be
// told to delay its HTTP response — emulating a busy/slow/wedged server.
function startMockServer({ responseDelayMs }) {
const received = [];
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
received.push(body);
const reply = () => {
try {
res.end('{"ok":true}');
} catch {
/* client already gone — expected when the handler exits early */
}
};
if (responseDelayMs > 0) setTimeout(reply, responseDelayMs);
else reply();
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve({ server, port: server.address().port, received });
});
});
}
// Spawn the real handler, pipe a hook payload to stdin, and time how long it
// takes to exit.
function runHandler({ port, hookType = "Stop", payload }) {
return new Promise((resolve, reject) => {
const start = process.hrtime.bigint();
const child = spawn(process.execPath, [HANDLER, hookType], {
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(port) },
stdio: ["pipe", "ignore", "ignore"],
});
child.on("error", reject);
child.on("exit", (code) => {
resolve({ code, ms: Number(process.hrtime.bigint() - start) / 1e6 });
});
child.stdin.write(JSON.stringify(payload));
child.stdin.end();
});
}
describe("hook-handler non-blocking delivery", () => {
it("exits without waiting for a slow dashboard response, yet still delivers the event", async () => {
// Server takes 5s to respond — far longer than the handler's own safety net.
const { server, port, received } = await startMockServer({ responseDelayMs: 5000 });
try {
const { code, ms } = await runHandler({
port,
payload: { session_id: "hh-slow", stop_reason: "end_turn" },
});
assert.equal(code, 0, "handler should exit cleanly");
// Must NOT have waited on the 5s response (and must beat its 2.5s safety
// net): a healthy deliver-and-exit is well under a second.
assert.ok(
ms < 2000,
`handler should exit fast (was ${ms.toFixed(0)}ms) despite the 5s server response`
);
// Delivery is preserved even though we exited before the reply.
await new Promise((r) => setTimeout(r, 200));
assert.equal(received.length, 1, "event should be delivered exactly once");
assert.match(received[0], /hh-slow/, "delivered payload should carry the session id");
assert.match(received[0], /"hook_type":"Stop"/, "payload should be wrapped with hook_type");
} finally {
server.close();
}
});
it("exits promptly when no dashboard is listening (connection refused)", async () => {
// Grab a port then close it so nothing is listening there.
const { server, port } = await startMockServer({ responseDelayMs: 0 });
await new Promise((r) => server.close(r));
const { code, ms } = await runHandler({
port,
payload: { session_id: "hh-dead", stop_reason: "end_turn" },
});
assert.equal(code, 0, "handler should still exit cleanly with no listener");
assert.ok(
ms < 2000,
`handler should exit fast on a refused connection (was ${ms.toFixed(0)}ms)`
);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @file Import-pipeline correctness regressions.
*
* Covers three defects found in an import audit:
* 1. Duplicate subagent rows — importSession must NOT create `-subagent-N`
* rows from the main transcript's Agent blocks when subagent TRANSCRIPTS
* (parsedSubagents) exist, since those `-jsonl-` rows are authoritative.
* The fallback (no transcripts) must still create `-subagent-N` rows.
* 2. transcript_path must be persisted on import (was NULL for every imported
* session, breaking the abandon sweep / compaction scan / cost backfill).
* 3. classifyJsonl must treat the dynamic-workflow tree
* (subagents/workflows/<run>/agent-*.jsonl) as a subagent, not a top-level
* session.
*
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-import-correct-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// A parsed subagent as parseSubagentFile would return it.
function makeSubData(agentId, agentType) {
return {
agentId,
agentType,
task: "recon",
model: "claude-haiku-4-5-20251001",
startedAt: "2026-04-18T12:00:10.000Z",
endedAt: "2026-04-18T12:01:00.000Z",
userMessages: 1,
assistantMessages: 2,
tokensByModel: {},
toolNames: ["Bash", "Read"],
thinkingBlockCount: 0,
toolEvents: [],
spawnedChildren: [],
};
}
// A parsed session as parseSessionFile would return it, with one Agent tool_use
// block. `parsedSubagents` is attached by the caller (importFromDirectory), so
// we set it explicitly here.
function makeSession(sessionId, { withTranscripts, transcriptPath }) {
const agentTs = "2026-04-18T11:59:00.000Z"; // >tolerance before the sub's start
return {
sessionId,
name: "T",
customTitle: null,
aiTitle: null,
cwd: "/tmp/proj",
model: "claude-opus-4-8",
version: null,
slug: "slug",
gitBranch: null,
transcriptPath,
startedAt: "2026-04-18T11:58:00.000Z",
endedAt: "2026-04-18T12:01:00.000Z",
teams: [],
userMessages: 1,
assistantMessages: 1,
tokensByModel: {},
messageTimestamps: ["2026-04-18T12:01:00.000Z"],
toolUses: [
{
id: "toolu_agent1",
name: "Agent",
input: { subagent_type: "Explore", description: "do x", prompt: "p" },
timestamp: agentTs,
},
],
compactions: [],
apiErrors: [],
fileModifiedAt: 0,
turnDurations: [],
entrypoint: null,
permissionMode: null,
thinkingBlockCount: 0,
toolResultErrors: [],
usageExtras: { service_tiers: [], speeds: [], inference_geos: [] },
parsedSubagents: withTranscripts ? [makeSubData("aaaa0001", "Explore")] : [],
};
}
describe("import correctness", () => {
it("does NOT create duplicate -subagent-N rows when subagent transcripts exist", () => {
const SID = "11110000-0000-4000-8000-000000000001";
importHistory.importSession(
dbModule,
makeSession(SID, { withTranscripts: true, transcriptPath: `/tmp/proj/${SID}.jsonl` })
);
const subs = stmts.listAgentsBySession.all(SID).filter((a) => a.type === "subagent");
const nSubagentN = subs.filter((a) => a.id.includes("-subagent-")).length;
const nJsonl = subs.filter((a) => a.id.includes("-jsonl-")).length;
assert.equal(nJsonl, 1, "one authoritative -jsonl- row");
assert.equal(nSubagentN, 0, "no duplicate -subagent-N row from the Agent block");
});
it("still creates -subagent-N rows when there are NO subagent transcripts", () => {
const SID = "11110000-0000-4000-8000-000000000002";
importHistory.importSession(
dbModule,
makeSession(SID, { withTranscripts: false, transcriptPath: `/tmp/proj/${SID}.jsonl` })
);
const subs = stmts.listAgentsBySession.all(SID).filter((a) => a.type === "subagent");
assert.equal(subs.filter((a) => a.id.includes("-subagent-")).length, 1, "fallback row created");
});
it("persists transcript_path on import", () => {
const SID = "11110000-0000-4000-8000-000000000003";
const tp = `/tmp/proj/${SID}.jsonl`;
importHistory.importSession(
dbModule,
makeSession(SID, { withTranscripts: false, transcriptPath: tp })
);
assert.equal(stmts.getSession.get(SID).transcript_path, tp);
});
it("classifyJsonl treats the workflow subagent tree as a subagent, not a session", () => {
const base = "/x/.claude/projects/-Users-x/sid";
assert.equal(importHistory.classifyJsonl(`${base}/subagents/agent-1.jsonl`), "subagent");
assert.equal(
importHistory.classifyJsonl(`${base}/subagents/workflows/wf_abc/agent-2.jsonl`),
"subagent"
);
assert.equal(importHistory.classifyJsonl(`/x/.claude/projects/-Users-x/sid.jsonl`), "session");
});
});
@@ -0,0 +1,208 @@
/**
* @file Regression: the offline CLI import path must link Workflow-tool inner
* agents to their run.
*
* A Claude Code Workflow-tool run (dynamic workflow / fleet of sub-agents)
* emits NO hooks, so in a headless `claude -p` run, a CI job, or an HPC/cluster
* compute node its per-run journal is never ingested live. Before this fix the
* CLI import path (`ccam import rescan` → importAllSessions, `ccam import path`
* → importFromDirectory) never called the workflow-journal ingest, so the
* nested inner-agent transcripts under
* `<sid>/subagents/workflows/<runId>/agent-*.jsonl` were imported with
* `workflow_run_id = NULL` — orphaned from their run — leaving the workflow
* stuck showing 1 agent instead of N.
*
* These tests build a fixture session tree with a streaming
* `subagents/workflows/<runId>/journal.jsonl` plus a few `agent-*.jsonl`, run
* the CLI import path (never the server), and assert every inner agent ends up
* with `workflow_run_id = <runId>` and the workflows row reports the full fleet.
*
* @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 fs = require("fs");
const os = require("os");
const path = require("path");
// Scope the importer at a throwaway CLAUDE_HOME and DB before any server module
// loads — import-history captures PROJECTS_DIR from CLAUDE_HOME at require time.
const TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), `ccam-wf-link-${process.pid}-`));
process.env.CLAUDE_HOME = TMP_ROOT;
process.env.DASHBOARD_DATA_DIR = path.join(TMP_ROOT, "data");
process.env.DASHBOARD_DB_PATH = path.join(TMP_ROOT, "dashboard.db");
const dbModule = require("../db");
const { importAllSessions, importFromDirectory } = require("../../scripts/import-history");
const PROJECTS_DIR = path.join(TMP_ROOT, "projects");
/** A minimal session transcript with one timestamped assistant turn. */
function sessionJsonl(cwd) {
return [
{
type: "user",
timestamp: "2026-05-01T00:00:00.000Z",
cwd,
message: { content: "run the workflow" },
},
{
type: "assistant",
timestamp: "2026-05-01T00:00:01.000Z",
cwd,
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "starting" }],
usage: {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
}
/** A parseable inner-agent transcript with token usage + one tool call. */
function agentJsonl(agentId) {
return [
{
type: "user",
timestamp: "2026-05-01T00:00:02.000Z",
message: { content: `task for ${agentId}` },
},
{
type: "assistant",
timestamp: "2026-05-01T00:00:03.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "tool_use", id: `${agentId}-t1`, name: "Read", input: {} }],
usage: {
input_tokens: 100,
output_tokens: 20,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
}
/**
* Write a session tree with a Workflow-tool run to disk under PROJECTS_DIR:
* <projName>/<sid>.jsonl
* <projName>/<sid>/subagents/workflows/<runId>/journal.jsonl (streaming)
* <projName>/<sid>/subagents/workflows/<runId>/agent-<id>.jsonl (one per agent)
* Returns the project dir path.
*/
function writeFixture(projName, sid, runId, agentIds) {
const projDir = path.join(PROJECTS_DIR, projName);
const cwd = `/tmp/${projName}`;
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(path.join(projDir, `${sid}.jsonl`), sessionJsonl(cwd));
const runDir = path.join(projDir, sid, "subagents", "workflows", runId);
fs.mkdirSync(runDir, { recursive: true });
const journalLines = [];
for (const id of agentIds) {
fs.writeFileSync(path.join(runDir, `agent-${id}.jsonl`), agentJsonl(id));
journalLines.push(JSON.stringify({ type: "started", agentId: id }));
journalLines.push(JSON.stringify({ type: "result", agentId: id, result: "ok" }));
}
fs.writeFileSync(path.join(runDir, "journal.jsonl"), journalLines.join("\n"));
return projDir;
}
const linkedCount = (runId) =>
dbModule.db.prepare("SELECT COUNT(*) AS c FROM agents WHERE workflow_run_id = ?").get(runId).c;
const workflowRow = (runId) =>
dbModule.db.prepare("SELECT agent_count FROM workflows WHERE run_id = ?").get(runId);
const linkedIds = (runId) =>
dbModule.db
.prepare("SELECT id FROM agents WHERE workflow_run_id = ? ORDER BY id")
.all(runId)
.map((r) => r.id);
before(() => {
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
});
after(() => {
try {
dbModule.db.close();
} catch {
/* ignore */
}
try {
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("offline CLI import links Workflow-tool inner agents", () => {
it("importFromDirectory (ccam import path) links every inner agent to its run", async () => {
const SID = "aaaa1111-0000-4000-8000-000000000001";
const RUN = "wf_dirimport01";
const AGENTS = ["d1", "d2", "d3"];
const projDir = writeFixture("-tmp-projA", SID, RUN, AGENTS);
assert.equal(linkedCount(RUN), 0, "no inner agents linked before import");
await importFromDirectory(dbModule, projDir);
assert.equal(linkedCount(RUN), AGENTS.length, "all inner agents linked to the run");
assert.equal(
workflowRow(RUN)?.agent_count,
AGENTS.length,
"workflows.agent_count reflects fleet"
);
assert.deepEqual(
linkedIds(RUN),
AGENTS.map((a) => `${SID}-jsonl-${a}`).sort(),
"each inner agent linked under its <sid>-jsonl-<agentId> id"
);
});
it("importAllSessions (ccam import rescan) links every inner agent to its run", async () => {
const SID = "bbbb2222-0000-4000-8000-000000000002";
const RUN = "wf_rescan02";
const AGENTS = ["r1", "r2", "r3", "r4"];
writeFixture("-tmp-projB", SID, RUN, AGENTS);
assert.equal(linkedCount(RUN), 0, "no inner agents linked before rescan");
await importAllSessions(dbModule);
assert.equal(linkedCount(RUN), AGENTS.length, "all inner agents linked to the run");
assert.equal(
workflowRow(RUN)?.agent_count,
AGENTS.length,
"workflows.agent_count reflects fleet"
);
});
it("re-running the import is idempotent — no duplicate links or rows", async () => {
const SID = "cccc3333-0000-4000-8000-000000000003";
const RUN = "wf_idem03";
const AGENTS = ["i1", "i2"];
const projDir = writeFixture("-tmp-projC", SID, RUN, AGENTS);
await importFromDirectory(dbModule, projDir);
await importFromDirectory(dbModule, projDir);
assert.equal(linkedCount(RUN), AGENTS.length, "still exactly N links after a second import");
// N inner agents + the one main agent for the session, nothing duplicated.
const total = dbModule.db
.prepare("SELECT COUNT(*) AS c FROM agents WHERE session_id = ?")
.get(SID).c;
assert.equal(total, AGENTS.length + 1, "no duplicate agent rows on re-import");
});
});
+553
View File
@@ -0,0 +1,553 @@
/**
* @file Tests for the Import History feature — the generalized directory
* importer and the /api/import routes. Verifies that token counts and cost
* computations come out identical between auto-import and manual import
* for the same JSONL fixtures, that re-imports are idempotent, and that
* archive extraction rejects path-traversal entries.
*
* @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 zlib = require("zlib");
const TEST_DB = path.join(os.tmpdir(), `dashboard-import-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const importHistory = require("../../scripts/import-history");
const archive = require("../lib/archive");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
function post(urlPath, body) {
return fetch(urlPath, { method: "POST", body });
}
// ────────────────────────────────────────────────────────────────────────────
// Fixtures — deterministic JSONL sessions with known token counts so we can
// assert imported values match byte-for-byte.
// ────────────────────────────────────────────────────────────────────────────
const SESSION_A = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa";
const SESSION_B = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb";
function fixtureLines(sessionId, cwd, model, inputTok, outputTok) {
const base = "2026-04-18T12:00:00.000Z";
return [
{ type: "user", cwd, sessionId, timestamp: base, message: { content: "hi" } },
{
type: "assistant",
cwd,
sessionId,
timestamp: base,
message: {
model,
content: [{ type: "text", text: "ok" }],
usage: {
input_tokens: inputTok,
output_tokens: outputTok,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
{
type: "assistant",
cwd,
sessionId,
timestamp: "2026-04-18T12:00:01.000Z",
message: {
model,
content: [{ type: "tool_use", name: "Read", input: { file_path: "/tmp/foo" } }],
usage: {
input_tokens: inputTok,
output_tokens: outputTok,
cache_read_input_tokens: 10,
cache_creation_input_tokens: 20,
},
},
},
];
}
function writeFixtureDir() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fixture-"));
const projDir = path.join(root, "-Users-demo-project");
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(
path.join(projDir, `${SESSION_A}.jsonl`),
fixtureLines(SESSION_A, "/Users/demo/project", "claude-opus-4-7", 100, 50)
.map((o) => JSON.stringify(o))
.join("\n")
);
fs.writeFileSync(
path.join(projDir, `${SESSION_B}.jsonl`),
fixtureLines(SESSION_B, "/Users/demo/project", "claude-sonnet-4-6", 200, 100)
.map((o) => JSON.stringify(o))
.join("\n")
);
return root;
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// ────────────────────────────────────────────────────────────────────────────
describe("GET /api/import/guide", () => {
it("returns OS-aware instructions and supported extensions", async () => {
const res = await fetch("/api/import/guide");
assert.equal(res.status, 200);
assert.equal(typeof res.body.default_projects_dir, "string");
assert.ok(res.body.supported_extensions.includes(".jsonl"));
assert.ok(res.body.supported_extensions.includes(".tar.gz"));
assert.ok(res.body.supported_extensions.includes(".zip"));
assert.ok(Array.isArray(res.body.steps));
assert.ok(res.body.steps.length >= 4);
assert.ok(res.body.archive_command.includes("tar"));
});
});
describe("POST /api/import/scan-path validation", () => {
it("rejects missing path", async () => {
const res = await post("/api/import/scan-path", {});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("rejects relative paths", async () => {
const res = await post("/api/import/scan-path", { path: "./somewhere" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("rejects non-existent paths", async () => {
const res = await post("/api/import/scan-path", {
path: "/definitely/does/not/exist/ccam-" + Date.now(),
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "PATH_NOT_FOUND");
});
it("rejects files (not directories)", async () => {
const tmp = path.join(os.tmpdir(), `ccam-not-dir-${Date.now()}.txt`);
fs.writeFileSync(tmp, "hello");
try {
const res = await post("/api/import/scan-path", { path: tmp });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "NOT_A_DIRECTORY");
} finally {
fs.unlinkSync(tmp);
}
});
});
describe("POST /api/import/scan-path happy path", () => {
it("imports sessions from a custom folder and records token usage", async () => {
const root = writeFixtureDir();
try {
const res = await post("/api/import/scan-path", { path: root });
assert.equal(res.status, 200);
assert.ok(res.body.ok);
// Both sessions should import the first time.
assert.ok(res.body.imported >= 2);
assert.equal(res.body.errors, 0);
const sessA = stmts.getSession.get(SESSION_A);
const sessB = stmts.getSession.get(SESSION_B);
assert.ok(sessA, "session A should exist in DB");
assert.ok(sessB, "session B should exist in DB");
// Tokens: each fixture has 2 assistant messages with usage.
const tokA = stmts.getTokensBySession.all(SESSION_A);
const opus = tokA.find((t) => /opus/.test(t.model));
assert.ok(opus, "expected opus tokens");
assert.equal(opus.input_tokens, 200);
assert.equal(opus.output_tokens, 100);
assert.equal(opus.cache_read_tokens, 10);
assert.equal(opus.cache_write_tokens, 20);
// Cost endpoint should produce a non-zero result after we add a pricing rule.
const ruleRes = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: "claude-opus-4-7",
display_name: "Opus 4.7",
input_per_mtok: 15,
output_per_mtok: 75,
cache_read_per_mtok: 1.5,
cache_write_per_mtok: 18.75,
},
});
assert.equal(ruleRes.status, 200);
const costRes = await fetch(`/api/pricing/cost/${SESSION_A}`);
assert.equal(costRes.status, 200);
assert.ok(costRes.body.total_cost > 0);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("is idempotent: a second scan does not duplicate tokens", async () => {
const before = stmts.getTokensBySession.all(SESSION_A);
const root = writeFixtureDir();
try {
const res = await post("/api/import/scan-path", { path: root });
assert.equal(res.status, 200);
const after = stmts.getTokensBySession.all(SESSION_A);
const beforeOpus = before.find((t) => /opus/.test(t.model));
const afterOpus = after.find((t) => /opus/.test(t.model));
assert.equal(afterOpus.input_tokens, beforeOpus.input_tokens);
assert.equal(afterOpus.output_tokens, beforeOpus.output_tokens);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
describe("archive helpers", () => {
it("isPathInside rejects traversal", () => {
const root = path.resolve("/tmp/ccam-root");
assert.equal(archive.isPathInside(root, "/tmp/ccam-root/ok.jsonl"), true);
assert.equal(archive.isPathInside(root, "/tmp/other/bad.jsonl"), false);
assert.equal(archive.isPathInside(root, "/tmp/ccam-root/../escape"), false);
});
it("safeJoin rejects absolute and traversal entries", () => {
const root = path.resolve("/tmp/ccam-root");
assert.equal(archive.safeJoin(root, "/etc/passwd"), path.join(root, "etc/passwd"));
assert.equal(archive.safeJoin(root, "../escape.txt"), null);
assert.equal(archive.safeJoin(root, "deep/../../escape"), null);
assert.ok(archive.safeJoin(root, "good/file.jsonl").startsWith(root));
});
it("detectKind handles common extensions", () => {
assert.equal(archive.detectKind("a.jsonl"), "jsonl");
assert.equal(archive.detectKind("a.meta.json"), "meta");
assert.equal(archive.detectKind("a.zip"), "zip");
assert.equal(archive.detectKind("a.tar"), "tar");
assert.equal(archive.detectKind("a.tar.gz"), "tgz");
assert.equal(archive.detectKind("a.tgz"), "tgz");
assert.equal(archive.detectKind("a.gz"), "gz");
assert.equal(archive.detectKind("random.bin"), "unknown");
});
it("extractGzSingle decompresses plain gz", async () => {
const dest = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gz-"));
const src = path.join(dest, "sample.jsonl.gz");
fs.writeFileSync(src, zlib.gzipSync(Buffer.from('{"ok":true}\n')));
try {
const result = await archive.extractGzSingle(src, dest);
assert.equal(result.extracted, 1);
assert.ok(fs.existsSync(path.join(dest, "sample.jsonl")));
} finally {
fs.rmSync(dest, { recursive: true, force: true });
}
});
});
describe("importFromDirectory directly", () => {
it("reports progress and never throws on empty dirs", async () => {
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-empty-"));
try {
const events = [];
const counters = await importHistory.importFromDirectory({ db, stmts }, empty, {
onProgress: (p) => events.push(p.phase),
});
assert.equal(counters.filesScanned, 0);
assert.ok(events.includes("complete"));
} finally {
fs.rmSync(empty, { recursive: true, force: true });
}
});
it("matches the legacy importer's token totals on the same fixtures", async () => {
// Clean any tokens from prior tests for a fresh comparison.
const freshSession = "cccccccc-3333-4333-8333-cccccccccccc";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fresh-"));
const projDir = path.join(root, "-Users-demo-fresh");
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(
path.join(projDir, `${freshSession}.jsonl`),
fixtureLines(freshSession, "/Users/demo/fresh", "claude-haiku-4-5", 7, 3)
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
await importHistory.importFromDirectory({ db, stmts }, root);
const tok = stmts.getTokensBySession.all(freshSession);
const haiku = tok.find((t) => /haiku/.test(t.model));
assert.ok(haiku);
assert.equal(haiku.input_tokens, 14); // 7 * 2 messages
assert.equal(haiku.output_tokens, 6); // 3 * 2 messages
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("snapshots the transcript into the data dir so it survives Claude Code pruning", async () => {
// Regression: the Conversation tab reads JSONL live from ~/.claude/projects,
// but Claude Code deletes session files older than cleanupPeriodDays
// (default 30 days). Import must snapshot the transcript into the
// dashboard's own data dir so the conversation survives that deletion.
const prevDataDir = process.env.DASHBOARD_DATA_DIR;
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-data-"));
process.env.DASHBOARD_DATA_DIR = dataDir;
const src = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-src-"));
const sessionId = "dddddddd-4444-4444-8444-dddddddddddd";
fs.writeFileSync(
path.join(src, `${sessionId}.jsonl`),
fixtureLines(sessionId, "/Users/demo/snap", "claude-opus-4-8", 5, 3)
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
await importHistory.importFromDirectory({ db, stmts }, src);
const snapshot = path.join(dataDir, "transcripts", `${sessionId}.jsonl`);
assert.ok(fs.existsSync(snapshot), "transcript should be snapshotted into the data dir");
assert.ok(
fs.readFileSync(snapshot, "utf8").includes('"text":"ok"'),
"snapshot should contain the original conversation"
);
// And the read route should resolve it via the snapshot helper.
const { getSnapshotTranscriptPath } = require("../lib/claude-home");
assert.equal(getSnapshotTranscriptPath(sessionId), snapshot);
} finally {
if (prevDataDir === undefined) delete process.env.DASHBOARD_DATA_DIR;
else process.env.DASHBOARD_DATA_DIR = prevDataDir;
fs.rmSync(src, { recursive: true, force: true });
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
});
describe("POST /api/import/rescan", () => {
it("runs without crashing even when default projects dir is missing", async () => {
// We can't mutate the real projects dir, but we can assert the endpoint
// always returns a JSON envelope regardless of whether it found anything.
const res = await post("/api/import/rescan");
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(typeof res.body.imported, "number");
assert.equal(typeof res.body.skipped, "number");
});
});
// ────────────────────────────────────────────────────────────────────────────
// Hardening tests
// ────────────────────────────────────────────────────────────────────────────
describe("tar path-traversal hardening", () => {
it("extractTar rejects entries with ../ segments", async () => {
const tar = require("tar");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-tar-bad-"));
const stageDir = path.join(tmp, "stage");
const targetDir = path.join(tmp, "target");
fs.mkdirSync(stageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
// Build a tar that tries to write "../escape.jsonl".
const inner = path.join(stageDir, "legit.jsonl");
fs.writeFileSync(inner, '{"ok":true}\n');
const tarPath = path.join(tmp, "evil.tar");
await tar.c({ file: tarPath, cwd: stageDir, prefix: "../" }, ["legit.jsonl"]);
try {
await archive.extractTar(tarPath, targetDir);
// Anything extracted must remain inside targetDir.
const walked = [];
(function walk(d) {
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, ent.name);
if (ent.isDirectory()) walk(p);
else walked.push(p);
}
})(targetDir);
for (const p of walked) {
assert.ok(
path.resolve(p).startsWith(path.resolve(targetDir) + path.sep) ||
path.resolve(p) === path.resolve(targetDir),
`traversal escape detected: ${p}`
);
}
// Nothing should exist one level above targetDir with name escape.jsonl
assert.equal(fs.existsSync(path.join(tmp, "escape.jsonl")), false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("extraction size cap", () => {
it("extractGzSingle aborts past MAX_EXTRACT_BYTES", async () => {
const prev = process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = "128";
// Re-require to pick up the lowered limit for this one check.
delete require.cache[require.resolve("../lib/archive")];
const localArchive = require("../lib/archive");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-bomb-"));
const gzPath = path.join(tmp, "bomb.jsonl.gz");
// 2 KB of zeros compresses to a few bytes — decompressing blows past 128 B.
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(2048, 0)));
try {
await assert.rejects(
() => localArchive.extractGzSingle(gzPath, tmp),
(err) => err.code === "EXTRACTION_LIMIT_EXCEEDED"
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
if (prev === undefined) delete process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
else process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = prev;
// Restore the module with production limits for subsequent tests.
delete require.cache[require.resolve("../lib/archive")];
require("../lib/archive");
}
});
});
describe("orphan subagent inference", () => {
it("attaches subagent via Layout 2: <proj>/subagents/<sessionId>/agent.jsonl", async () => {
const orphanSession = "dddddddd-4444-4444-8444-dddddddddddd";
// Seed a parent session first.
const seedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-seed-"));
const seedProj = path.join(seedRoot, "project");
fs.mkdirSync(seedProj, { recursive: true });
fs.writeFileSync(
path.join(seedProj, `${orphanSession}.jsonl`),
fixtureLines(orphanSession, "/Users/demo/orphan", "claude-opus-4-7", 5, 5)
.map((o) => JSON.stringify(o))
.join("\n")
);
await importHistory.importFromDirectory({ db, stmts }, seedRoot);
assert.ok(stmts.getSession.get(orphanSession), "parent session must exist before orphan pass");
// Now create an "orphan" subagent tree in the non-standard layout.
const orphanRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-"));
const orphanLayoutDir = path.join(orphanRoot, "project", "subagents", orphanSession);
fs.mkdirSync(orphanLayoutDir, { recursive: true });
const subAgentId = "agent-xyz";
fs.writeFileSync(
path.join(orphanLayoutDir, `${subAgentId}.jsonl`),
[
{ type: "user", timestamp: "2026-04-18T12:00:00.000Z", message: { content: "hi" } },
{
type: "assistant",
timestamp: "2026-04-18T12:00:00.000Z",
message: {
model: "claude-opus-4-7",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 1, output_tokens: 1 },
},
},
]
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
const before = db
.prepare("SELECT COUNT(*) as c FROM agents WHERE session_id = ?")
.get(orphanSession).c;
await importHistory.importFromDirectory({ db, stmts }, orphanRoot);
const after = db
.prepare("SELECT COUNT(*) as c FROM agents WHERE session_id = ?")
.get(orphanSession).c;
assert.ok(after > before, "orphan subagent should attach under known session");
} finally {
fs.rmSync(seedRoot, { recursive: true, force: true });
fs.rmSync(orphanRoot, { recursive: true, force: true });
}
});
});
describe("concurrent scan-path requests", () => {
it("two concurrent imports of different folders both succeed without clobbering", async () => {
const sessA = "eeeeeeee-5555-4555-8555-eeeeeeeeeeee";
const sessB = "ffffffff-6666-4666-8666-ffffffffffff";
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-a-"));
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-b-"));
fs.mkdirSync(path.join(rootA, "-Users-demo-a"), { recursive: true });
fs.mkdirSync(path.join(rootB, "-Users-demo-b"), { recursive: true });
fs.writeFileSync(
path.join(rootA, "-Users-demo-a", `${sessA}.jsonl`),
fixtureLines(sessA, "/Users/demo/a", "claude-opus-4-7", 3, 2)
.map((o) => JSON.stringify(o))
.join("\n")
);
fs.writeFileSync(
path.join(rootB, "-Users-demo-b", `${sessB}.jsonl`),
fixtureLines(sessB, "/Users/demo/b", "claude-sonnet-4-6", 4, 1)
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
const [rA, rB] = await Promise.all([
post("/api/import/scan-path", { path: rootA }),
post("/api/import/scan-path", { path: rootB }),
]);
assert.equal(rA.status, 200);
assert.equal(rB.status, 200);
assert.ok(stmts.getSession.get(sessA), "session A should be imported");
assert.ok(stmts.getSession.get(sessB), "session B should be imported");
} finally {
fs.rmSync(rootA, { recursive: true, force: true });
fs.rmSync(rootB, { recursive: true, force: true });
}
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* @file Tests the host-only guard in scripts/install-hooks.js (issue #193):
* the installer must refuse to write a container-internal handler path into a
* (possibly bind-mounted) host ~/.claude/settings.json. Container detection is
* driven deterministically via CCAM_FORCE_CONTAINER / CCAM_FORCE_HOST so these
* tests pass whether or not the CI runner itself is containerized.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Point the installer at a throwaway CLAUDE_HOME BEFORE requiring it — the
// settings path is resolved at module load. (`node --test` isolates each test
// file in its own process, so this does not leak into other suites.)
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-hooks-"));
process.env.CLAUDE_HOME = TMP_HOME;
const SETTINGS = path.join(TMP_HOME, "settings.json");
const { installHooks, isInsideContainer } = require("../../scripts/install-hooks");
const HOOK_TYPES = [
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"Notification",
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
];
function clearEnv() {
delete process.env.CCAM_FORCE_CONTAINER;
delete process.env.CCAM_FORCE_HOST;
delete process.env.CCAM_ALLOW_CONTAINER_HOOKS;
}
function rmSettings() {
try {
fs.unlinkSync(SETTINGS);
} catch {
/* not present */
}
}
describe("install-hooks host-only guard (#193)", () => {
beforeEach(() => {
clearEnv();
rmSettings();
});
after(() => {
clearEnv();
try {
fs.rmSync(TMP_HOME, { recursive: true, force: true });
} catch {
/* best effort */
}
});
it("refuses inside a container and writes no settings file", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
const ok = installHooks(true);
assert.equal(ok, false);
assert.equal(
fs.existsSync(SETTINGS),
false,
"settings.json must not be created in a container"
);
});
it("writes when the explicit container override is set", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
process.env.CCAM_ALLOW_CONTAINER_HOOKS = "1";
const ok = installHooks(true);
assert.equal(ok, true);
assert.ok(fs.existsSync(SETTINGS));
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
for (const type of HOOK_TYPES) {
assert.ok(Array.isArray(settings.hooks[type]), `missing hook list for ${type}`);
assert.match(JSON.stringify(settings.hooks[type]), /hook-handler\.js/, `${type} not wired`);
}
});
it("writes on a host (not a container)", () => {
process.env.CCAM_FORCE_HOST = "1";
const ok = installHooks(true);
assert.equal(ok, true);
assert.ok(fs.existsSync(SETTINGS));
});
it("is idempotent — re-running updates in place with no duplicate entries", () => {
process.env.CCAM_FORCE_HOST = "1";
installHooks(true);
installHooks(true);
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
const ours = settings.hooks.PreToolUse.filter((e) =>
JSON.stringify(e).includes("hook-handler.js")
);
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
});
it("isInsideContainer honors the force flags", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
assert.equal(isInsideContainer(), true);
delete process.env.CCAM_FORCE_CONTAINER;
process.env.CCAM_FORCE_HOST = "1";
assert.equal(isInsideContainer(), false);
});
});
File diff suppressed because it is too large Load Diff
+858
View File
@@ -0,0 +1,858 @@
/**
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
* counters the header badges read, and the last-event age that drives liveness.
* @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 http = require("http");
const { WebSocket } = require("ws");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: {
...headers,
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
},
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch {
/* non-JSON body */
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());
describe("/api/lanes", () => {
let laneId;
it("creates a lane", async () => {
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.title, "Lane A");
assert.equal(r.body.lane.stage, "idle");
laneId = r.body.lane.id;
});
it("rejects a relative cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
assert.equal(r.status, 400);
});
it("rejects a duplicate cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 409);
});
it("reports a stage and returns node states", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
stage: "review",
status: "running",
evidence: null,
});
assert.equal(r.status, 200);
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "current");
assert.ok(r.body.lane.progress > 0);
});
it("404s on an unknown lane", async () => {
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
assert.equal(r.status, 404);
});
it("lists lanes with counters", async () => {
const r = await request("GET", "/api/lanes");
assert.equal(r.status, 200);
assert.ok(r.body.lanes.length >= 1);
assert.equal(r.body.counts.total, r.body.lanes.length);
assert.equal(typeof r.body.counts.running, "number");
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
assert.ok(r.body.pipelines.some((p) => p.id === "default"));
});
it("patches and deletes", async () => {
const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
assert.equal(p.body.lane.ci_status, "green");
const d = await request("DELETE", `/api/lanes/${laneId}`);
assert.equal(d.status, 200);
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
});
it("rejects cross-origin lane deletion", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
const id = created.body.lane.id;
const rejected = await request("DELETE", `/api/lanes/${id}`, undefined, {
Origin: "https://attacker.example",
});
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 200);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane patching", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-patch" });
const id = created.body.lane.id;
const rejected = await request(
"PATCH",
`/api/lanes/${id}`,
{ run_id: "attacker-run" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.run_id, null);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane creation", async () => {
const rejected = await request(
"POST",
"/api/lanes",
{ cwd: "/tmp/lane-cross-origin-create" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
});
it("rejects cross-origin stage reporting", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-stage" });
const id = created.body.lane.id;
const rejected = await request(
"POST",
`/api/lanes/${id}/stage`,
{ stage: "implement" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.stage, "idle");
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("broadcasts lane_update on create", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
const messages = [];
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("ws test timeout")), 5000);
});
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
Promise.race([timeout]).catch(reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
const createResp = await request("POST", "/api/lanes", {
title: "WS Test Lane",
cwd: "/tmp/ws-test-lane",
});
assert.equal(createResp.status, 201);
const newLaneId = createResp.body.lane.id;
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const laneUpdateMsg = messages.find(
(m) => m.type === "lane_update" && m.data.lane?.id === newLaneId
);
assert.ok(laneUpdateMsg, "create should broadcast lane_update");
assert.equal(laneUpdateMsg.data.lane.id, newLaneId);
assert.equal(laneUpdateMsg.data.lane.title, "WS Test Lane");
// Test delete broadcast
messages.length = 0;
const deleteResp = await request("DELETE", `/api/lanes/${newLaneId}`);
assert.equal(deleteResp.status, 200);
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const deleteMsg = messages.find(
(m) => m.type === "lane_update" && m.data.removed === newLaneId
);
assert.ok(deleteMsg, "delete should broadcast lane_update with removed");
assert.equal(deleteMsg.data.removed, newLaneId);
ws.close();
});
});
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-hook-a",
title: "Hooked",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("ignores a hook whose cwd is under no lane", async () => {
const before = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
});
it("clears needs_action only from the session that raised it, not from cross-session rebinds", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-cross-session",
title: "Cross-Session",
});
const id = created.body.lane.id;
// Session A sets needs_action
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session", message: "blocked" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-a");
// Session B PostToolUse: rebinds lane to B, but does NOT clear A's flag
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// Session B's second PostToolUse clears the flag (B is now bound)
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("raises needs_action with default 'needs you' when Notification has no message", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-default-msg",
title: "Default Msg",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs you");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("hook → stage detection", () => {
it("a Bash test-run hook sets detected_stage to tests, a following Read leaves it unchanged", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-a",
title: "Detect",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
let lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Read",
tool_input: { file_path: "/tmp/lane-detect-a/foo.js" },
},
});
lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("a hook whose cwd is under no lane changes nothing", async () => {
const beforeLanes = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-orphan",
cwd: "/tmp/not-a-lane-detect",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, beforeLanes);
});
it("a lane already declared at ship ignores an implement detection", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-declared",
title: "Declared",
});
const id = created.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "ship", status: "running" });
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-2",
cwd: "/tmp/lane-detect-declared",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-declared/foo.js" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("a malformed hook payload still returns 200 and leaves the lane untouched", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-malformed",
title: "Malformed",
});
const id = created.body.lane.id;
const r1 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-3",
cwd: "/tmp/lane-detect-malformed",
tool_input: "just a string, no tool_name",
},
});
assert.equal(r1.status, 200);
const r2 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-detect-4", cwd: "/tmp/lane-detect-malformed" },
});
assert.equal(r2.status, 200);
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("detection is visible in GET /api/lanes/:id with a detected node, and no node is 'done'", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-visible",
title: "Visible",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-5",
cwd: "/tmp/lane-detect-visible",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
const testsNode = lane.pipeline_nodes.find((n) => n.id === "tests");
assert.equal(testsNode.detected, true);
assert.ok(!lane.pipeline_nodes.some((n) => n.state === "done"));
await request("DELETE", `/api/lanes/${id}`);
});
it("a throwing recordDetection does not cost the lane bookkeeping in the same hook", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-throws",
title: "Throws",
});
const id = created.body.lane.id;
// Bind the session and raise needs_action, exactly as a permission prompt does.
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-detect-throw", cwd: "/tmp/lane-detect-throws" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
message: "needs permission",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
// Stand in for the real failures recordDetection can raise mid-hook: ENOLANE
// when the lane is deleted between resolveLaneByCwd and its lookup, or
// SQLITE_BUSY from another process on the same database.
const lanesLib = require("../lib/lanes");
const realRecordDetection = lanesLib.recordDetection;
lanesLib.recordDetection = () => {
throw Object.assign(new Error("no lane 999"), { code: "ENOLANE" });
};
try {
const r = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-throws/foo.js" },
},
});
assert.equal(r.status, 200);
} finally {
lanesLib.recordDetection = realRecordDetection;
}
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null, "bookkeeping must survive a failed detection");
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("lane actions", () => {
it("requires confirmation, then removes an adopted lane row without its directory", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
const id = c.body.lane.id;
assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
const preflight = await request("GET", `/api/lanes/${id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${id}/remove`, {
confirm: true,
expect: {
head: preflight.body.head,
dirty: preflight.body.dirty,
untracked: preflight.body.untracked,
unpushed: preflight.body.unpushed,
},
});
assert.equal(removed.status, 200);
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
});
it("rejects an unknown action", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
const id = c.body.lane.id;
const before = await request("GET", `/api/lanes/${id}`);
const r = await request("POST", `/api/lanes/${id}/frobnicate`, {});
assert.equal(r.status, 400);
const after = await request("GET", `/api/lanes/${id}`);
assert.equal(after.body.lane.stage, before.body.lane.stage);
assert.equal(after.body.lane.status, before.body.lane.status);
await request("DELETE", `/api/lanes/${id}`);
});
it("clear resets stage state but keeps the lane", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
const id = c.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
const r = await request("POST", `/api/lanes/${id}/clear`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.stage, "idle");
assert.equal(r.body.lane.title, "Keep me");
assert.deepEqual(r.body.lane.stages, {});
await request("DELETE", `/api/lanes/${id}`);
});
it("stop on a lane with no run is a no-op, not a 500", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.status, "idle");
await request("DELETE", `/api/lanes/${c.body.lane.id}`);
});
it("message on a lane with a recorded-but-not-live run returns 409", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
const id = c.body.lane.id;
// Patch the lane with a bogus run_id (never existed, so not live).
await request("PATCH", `/api/lanes/${id}`, { run_id: "nonexistent-run" });
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ENORUN");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/:id/git", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-git-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("reports the working-copy facts for a lane pointing at a real repo", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/api-facts");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "one\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "api facts fixture");
fs.writeFileSync(path.join(dir, "a.txt"), "two\n");
fs.writeFileSync(path.join(dir, "untracked.txt"), "u\n");
const created = await request("POST", "/api/lanes", { cwd: dir, title: "git facts" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.branch, "feat/api-facts");
assert.equal(r.body.subject, "api facts fixture");
assert.equal(r.body.head, g(dir, "rev-parse", "--short", "HEAD").trim());
assert.equal(r.body.dirty, 1);
assert.equal(r.body.untracked, 1);
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false for a plain directory, not an error", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "plain dir" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false when the lane's directory is gone", async () => {
const dir = path.join(ROOT, "vanishes");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "vanishing" });
const id = created.body.lane.id;
fs.rmSync(dir, { recursive: true, force: true });
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("404s for a lane that does not exist", async () => {
const r = await request("GET", "/api/lanes/999999/git");
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOLANE");
});
it("resolves to the facts route, not an action error", async () => {
// The `/:id/:action` catch-all is a POST and cannot shadow this GET, so
// this pins the response SHAPE rather than any registration order.
const dir = path.join(ROOT, "ordering");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "ordering" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.ok("available" in r.body, JSON.stringify(r.body));
assert.equal(r.body.error, undefined);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/branches", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-branches-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("lists the repo's local branches and the current one", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "init");
g(dir, "branch", "feat/x");
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 200);
assert.deepEqual([...r.body.branches].sort(), ["feat/x", "main"]);
assert.equal(r.body.current, "main");
});
it("400s for a relative path", async () => {
const r = await request("GET", "/api/lanes/branches?repo=relative/path");
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a path that does not exist", async () => {
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a directory that is not a git repository", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("is not swallowed by the /:id catch-all", async () => {
// "/branches" would otherwise be read as a lane id and 404 with ENOLANE.
const r = await request("GET", "/api/lanes/branches?repo=%2Fdoes%2Fnot%2Fmatter");
assert.notEqual(r.body?.error?.code, "ENOLANE");
});
});
describe("detection attributes work to the lane the work touched", () => {
// Measured on a real session: 325 of 400 hook events carried the SESSION's
// cwd, while the actual edits and test runs happened inside a different
// repo reached with `cd <other> && ...`. The lane doing the work detected
// nothing; the lane the terminal happened to start in absorbed all of it.
it("credits a Bash command that cd's into another lane to THAT lane", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-session",
title: "where the shell started",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-worked",
title: "where the work happened",
});
const sessionId = session.body.lane.id;
const workedId = worked.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-1",
cwd: "/tmp/lane-attr-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-attr-worked && npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${workedId}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${sessionId}`)).body.lane.detected_stage,
null,
"the session's own lane did no work and must not be credited"
);
await request("DELETE", `/api/lanes/${workedId}`);
await request("DELETE", `/api/lanes/${sessionId}`);
});
it("credits an Edit to the lane owning the edited file", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-2",
cwd: "/tmp/lane-edit-session",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-edit-worked/server/lib/x.js" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.detected_stage,
"implement"
);
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.detected_stage,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("falls back to the session's lane when the tool names no other lane's path", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-fallback",
title: "fallback",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-3",
cwd: "/tmp/lane-attr-fallback",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("keeps session bookkeeping on the session's lane, not the worked-in lane", async () => {
// session_id / needs_action ARE session-scoped facts: the session really is
// bound to the directory it started in. Only the stage inference follows
// the work.
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-4",
cwd: "/tmp/lane-book-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-book-worked && npm test" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.session_id,
"sess-attr-4"
);
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
});
+474
View File
@@ -0,0 +1,474 @@
/**
* @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;
function git(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn("git", args, { cwd });
let stderr = "";
child.stderr.on("data", (chunk) => (stderr += chunk));
child.on("error", reject);
child.on("close", (status) => {
if (status === 0) return resolve();
reject(new Error(`git ${args.join(" ")} failed (${status}): ${stderr}`));
});
});
}
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
function get(urlPath) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method: "GET",
},
(res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null }));
}
);
req.on("error", reject);
req.end();
});
}
// MUST be async: the test server runs in THIS process, so a blocking
// spawnSync would stall the event loop and the CLI child's request to
// 127.0.0.1 would never be served — a deadlock that looks like a network
// sandbox blocking loopback.
function cli(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI, ...args], {
cwd,
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => (stdout += c));
child.stderr.on("data", (c) => (stderr += c));
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}
before(async () => {
await git(["init", "--bare"], REMOTE_REPO);
await git(["init", "--initial-branch=main"], SOURCE_REPO);
await git(["config", "user.email", "lanes-cli@example.test"], SOURCE_REPO);
await git(["config", "user.name", "Lanes CLI Test"], SOURCE_REPO);
fs.writeFileSync(path.join(SOURCE_REPO, "README.md"), "fixture\n");
await git(["add", "README.md"], SOURCE_REPO);
await git(["commit", "-m", "fixture"], SOURCE_REPO);
await git(["remote", "add", "origin", REMOTE_REPO], SOURCE_REPO);
await git(["push", "-u", "origin", "main"], SOURCE_REPO);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
// Poll for server readiness
let ready = false;
const deadline = Date.now() + 2000; // 2 second timeout
while (!ready && Date.now() < deadline) {
try {
const res = await get("/api/health");
if (res.status === 200) ready = true;
} catch {
/* not ready yet */
}
if (!ready) await new Promise((r) => setTimeout(r, 50));
}
if (!ready) throw new Error("Server failed to become ready within 2s");
await post("/api/lanes", { cwd: LANE_DIR, title: "CLI lane" });
});
after(() => {
try {
if (server) server.close();
fs.rmSync(LANE_DIR, { recursive: true, force: true });
fs.rmSync(PIPELINE_DIR, { recursive: true, force: true });
fs.rmSync(LANES_ROOT, { recursive: true, force: true });
fs.rmSync(SOURCE_REPO, { recursive: true, force: true });
fs.rmSync(REMOTE_REPO, { recursive: true, force: true });
} finally {
// Clean up TEST_DB and its WAL/SHM siblings (always runs, even if earlier cleanup fails)
fs.rmSync(TEST_DB, { force: true });
fs.rmSync(`${TEST_DB}-wal`, { force: true });
fs.rmSync(`${TEST_DB}-shm`, { force: true });
}
});
describe("ccam stage", () => {
it("reports a stage for the lane owning the current directory", async () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /review/);
const list = await cli(["lanes"], LANE_DIR);
assert.match(list.stdout, /review/);
});
it("exits non-zero when no lane owns the cwd", async () => {
const r = await cli(["stage", "review"], os.tmpdir());
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
});
});
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("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 });
}
});
});
+828
View File
@@ -0,0 +1,828 @@
/**
* @file Unit tests for pipeline templates and durable lane storage helpers,
* including lifecycle transitions, recovery, liveness, and worktree metadata.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const pathMod = require("node:path");
// Every test in this file must hit THIS database. Migration tests below
// re-point DASHBOARD_DB_PATH at a throwaway file; their cleanup must restore
// this value rather than delete it - an unset DASHBOARD_DB_PATH resolves to the
// operator's REAL dashboard DB, so a later test would read and write live data.
const SUITE_DB_PATH = pathMod.join(
os.tmpdir(),
`dashboard-lanes-lib-${Date.now()}-${process.pid}.db`
);
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_PIPELINE_ID,
getPipeline,
listPipelines,
phaseIdx,
nodeStates,
progressPct,
} = require("../lib/pipelines");
describe("pipelines", () => {
it("exposes a default template and falls back to it for unknown ids", () => {
const def = getPipeline(DEFAULT_PIPELINE_ID);
assert.ok(def.nodes.length > 3);
assert.equal(getPipeline("does-not-exist").id, DEFAULT_PIPELINE_ID);
assert.ok(listPipelines().some((p) => p.id === DEFAULT_PIPELINE_ID));
});
it("resolves a stage through node id and through aliases", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
assert.equal(
phaseIdx(p, "plan"),
p.nodes.findIndex((n) => n.id === "plan")
);
assert.equal(phaseIdx(p, "planning"), phaseIdx(p, "plan"));
assert.equal(phaseIdx(p, "totally-unknown-stage"), -1);
});
it("marks the current stage current, recorded-with-evidence done, recorded-without amber", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: {
plan: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" },
implement: { enteredAt: "2026-07-27T01:00:00Z", evidence: null },
review: { enteredAt: "2026-07-27T02:00:00Z", evidence: null },
},
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
assert.equal(byId.implement, "passed-no-evidence");
assert.equal(byId.review, "current");
assert.equal(byId.done, "pending");
});
it("marks a failed stage failed even when it is the current stage", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = { stage: "gate", stages: { gate: { enteredAt: "x", result: "fail" } } };
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.gate, "failed");
});
it("treats skipped earlier nodes as passed-without-evidence, not done", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = { stage: "review", stages: { review: { enteredAt: "x" } } };
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "passed-no-evidence");
});
it("computes progress from node position, 0 for an unknown stage", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0);
assert.equal(progressPct(p, { stage: p.nodes[p.nodes.length - 1].id, stages: {} }), 100);
assert.equal(progressPct(p, { stage: "nope", stages: {} }), 0);
});
});
const lanes = require("../lib/lanes");
describe("lanes lib", () => {
it("creates, lists, updates and deletes a lane", () => {
const l = lanes.createLane({ title: "Feature A", cwd: "/tmp/wt/a", branch: "feat/a" });
assert.equal(l.title, "Feature A");
assert.equal(l.stage, "idle");
assert.equal(lanes.getLane(l.id).cwd, "/tmp/wt/a");
assert.ok(lanes.listLanes().length >= 1);
assert.equal(lanes.updateLane(l.id, { ci_status: "green" }).ci_status, "green");
assert.equal(lanes.deleteLane(l.id), true);
assert.equal(lanes.getLane(l.id), null);
});
it("recovers only provisioning lanes interrupted by a restart", () => {
const provisioning = lanes.createLane({
cwd: "/tmp/wt/provisioning-recovery",
branch: "feat/provisioning-recovery",
kind: "managed",
});
lanes.updateLane(provisioning.id, { status: "provisioning" });
const idle = lanes.createLane({ cwd: "/tmp/wt/idle-recovery", branch: "feat/idle-recovery" });
const failed = lanes.createLane({
cwd: "/tmp/wt/failed-recovery",
branch: "feat/failed-recovery",
});
lanes.updateLane(failed.id, { status: "failed", notes: "existing failure" });
assert.equal(lanes.recoverInterruptedProvisioning(), 1);
const recovered = lanes.getLane(provisioning.id);
assert.equal(recovered.status, "failed");
assert.equal(recovered.notes, "Provisioning was interrupted by a server restart.");
assert.equal(recovered.kind, "managed");
assert.equal(recovered.cwd, "/tmp/wt/provisioning-recovery");
assert.equal(recovered.branch, "feat/provisioning-recovery");
assert.equal(lanes.getLane(idle.id).status, "idle");
assert.equal(lanes.getLane(failed.id).status, "failed");
assert.equal(lanes.getLane(failed.id).notes, "existing failure");
lanes.deleteLane(provisioning.id);
lanes.deleteLane(idle.id);
lanes.deleteLane(failed.id);
});
it("bumps stage_since only when the stage actually changes", async () => {
const l = lanes.createLane({ cwd: "/tmp/wt/b" });
const a = lanes.setStage(l.id, { stage: "plan" });
await new Promise((r) => setTimeout(r, 1100));
const b = lanes.setStage(l.id, { stage: "plan", note: "still planning" });
assert.equal(a.stage_since, b.stage_since);
const c = lanes.setStage(l.id, { stage: "implement" });
assert.notEqual(c.stage_since, b.stage_since);
lanes.deleteLane(l.id);
});
it("records evidence per stage so the map can tell done from amber", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/c" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
const after = lanes.setStage(l.id, { stage: "implement" });
assert.equal(after.stages.plan.evidence, "docs/plan.md");
assert.ok(after.stages.plan.enteredAt);
lanes.deleteLane(l.id);
});
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
const outer = lanes.createLane({ cwd: "/tmp/wt" });
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/inner/src").id, inner.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/other").id, outer.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt-sibling"), null);
assert.equal(lanes.resolveLaneByCwd(null), null);
lanes.deleteLane(inner.id);
lanes.deleteLane(outer.id);
});
it("classifies liveness: silent watcher is dead, silent idle lane is not", () => {
const d = 300;
assert.equal(
lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 10 }, d),
"active"
);
assert.equal(
lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 999 }, d),
"dead"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "watching-pr", ageSec: 999 }, d),
"dead"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: 99999 }, d),
"idle"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: null }, d),
"idle"
);
});
it("payload carries node states and progress", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
lanes.setStage(l.id, { stage: "review" });
const p = lanes.lanePayload(lanes.getLane(l.id), 5);
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
assert.ok(p.progress > 0 && p.progress < 100);
assert.equal(p.liveness, "idle");
lanes.deleteLane(l.id);
});
});
const { withLaneLock } = require("../lib/lane-lock");
describe("lane kind, worktree fields and purge", () => {
it("defaults to adopted and stores worktree fields when given", () => {
const a = lanes.createLane({ cwd: "/tmp/wt-kind-a" });
assert.equal(a.kind, "adopted");
const m = lanes.createLane({
cwd: "/tmp/wt-kind-b",
kind: "managed",
source_repo: "/tmp/src",
base_branch: "main",
slug: "b",
});
assert.equal(m.kind, "managed");
assert.equal(m.source_repo, "/tmp/src");
assert.equal(m.base_branch, "main");
assert.equal(m.slug, "b");
lanes.deleteLane(a.id);
lanes.deleteLane(m.id);
});
it("rejects an unknown kind", () => {
assert.throws(
() => lanes.createLane({ cwd: "/tmp/wt-kind-c", kind: "gremlin" }),
(e) => e.code === "EBADKIND"
);
});
it("purges a lane's sessions, their events and orphaned token rows, sparing the live one", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-purge" });
const { db } = require("../db");
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"purge-1",
"/tmp/wt-purge/sub"
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"purge-live",
"/tmp/wt-purge"
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'PostToolUse')").run(
"purge-1"
);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 5)").run(
"purge-1"
);
lanes.updateLane(l.id, { session_id: "purge-live" });
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 1);
assert.equal(counts.tokenRows, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='purge-live'").get().c, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='purge-1'").get().c, 0);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM token_usage WHERE session_id='purge-1'").get().c,
0
);
lanes.deleteLane(l.id);
});
it("never counts or deletes a sibling directory whose name differs only at an underscore", () => {
// Every managed lane directory is named `<repo>__<slug>` — two literal
// underscores, each of which LIKE treats as "any single character". Without
// ESCAPE, a lane at /root/myrepo__feat-foo purged /root/myrepoXXfeat-foo's
// sessions too, and the preflight count reported the victims, so the
// confirmation was consistently wrong rather than detectably wrong.
const { db } = require("../db");
const laneCwd = "/root/myrepo__feat-foo";
const siblingCwd = "/root/myrepoXXfeat-foo";
const l = lanes.createLane({ cwd: laneCwd, kind: "managed" });
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-mine",
`${laneCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-victim",
`${siblingCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"like-victim-active",
`${siblingCwd}/other`
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'Stop')").run("like-victim");
// The counter sees only this lane's own session.
const candidates = lanes.purgeCandidateSessions(lanes.getLane(l.id));
assert.deepEqual(
candidates.map((s) => s.id),
["like-mine"]
);
// ...and does not attribute the sibling's live session to this lane either.
assert.equal(lanes.hasActiveLaneSession(lanes.getLane(l.id)), false);
// The deleter agrees with the counter, exactly.
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 0);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim'").get().c, 1);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim-active'").get().c,
1
);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='like-victim'").get().c,
1
);
lanes.deleteLane(l.id);
});
it("a PATCH-style updateLane cannot change kind, source_repo, slug or base_branch", () => {
// kind is check 1 of the destroy guard: a client that could flip it to
// "managed" could point the guard at a directory the user owns.
const l = lanes.createLane({
cwd: "/tmp/wt-patch-guard",
kind: "adopted",
source_repo: "/tmp/original-src",
base_branch: "main",
slug: "original",
});
const after = lanes.updateLane(l.id, {
kind: "managed",
source_repo: "/tmp/attacker-src",
base_branch: "attacker",
slug: "attacker",
title: "this one IS patchable",
});
assert.equal(after.kind, "adopted");
assert.equal(after.source_repo, "/tmp/original-src");
assert.equal(after.base_branch, "main");
assert.equal(after.slug, "original");
assert.equal(after.title, "this one IS patchable");
// Provisioning has its own internal writer for those facts.
const provisioned = lanes.setProvisioningFacts(l.id, { base_branch: "resolved-base" });
assert.equal(provisioned.base_branch, "resolved-base");
assert.equal(provisioned.kind, "adopted");
lanes.deleteLane(l.id);
});
it("serialises work per lane and releases the lock when the body throws", async () => {
const order = [];
const slow = withLaneLock(7, async () => {
order.push("a-start");
await new Promise((r) => setTimeout(r, 50));
order.push("a-end");
});
const fast = withLaneLock(7, async () => {
order.push("b");
});
await Promise.all([slow, fast]);
assert.deepEqual(order, ["a-start", "a-end", "b"]);
await assert.rejects(() =>
withLaneLock(7, async () => {
throw new Error("boom");
})
);
await withLaneLock(7, async () => order.push("c"));
assert.equal(order[order.length - 1], "c");
});
it("updateLane rejects an unknown kind with EBADKIND and leaves stored value unchanged", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-kind-update", kind: "adopted" });
assert.equal(l.kind, "adopted");
assert.throws(
() => lanes.updateLane(l.id, { kind: "MANAGED" }),
(e) => e.code === "EBADKIND"
);
const after = lanes.getLane(l.id);
assert.equal(after.kind, "adopted");
lanes.deleteLane(l.id);
});
it("different lane ids have separate locks and run in parallel", async () => {
const order = [];
const lane1Slow = withLaneLock(10, async () => {
order.push("lane1-start");
await new Promise((r) => setTimeout(r, 50));
order.push("lane1-end");
});
const lane2 = withLaneLock(11, async () => {
order.push("lane2");
});
await Promise.all([lane1Slow, lane2]);
assert.ok(order.includes("lane2"), "lane 2 should run without waiting for lane 1");
assert.notDeepEqual(order, ["lane1-start", "lane1-end", "lane2"]);
});
it("migration: old schema database gains all four columns idempotently", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-migration-${Date.now()}-${process.pid}.db`
);
try {
// Create an old-schema lanes table with only id, cwd, title columns
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
oldDb
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)"
)
.run("old-lane", "/tmp/old-lane-path", "main", "default", "2026-07-27T00:00:00Z");
oldDb.close();
// Load db.js against it, which should add the four columns
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
// Verify all four columns exist
const checkCols = [
"SELECT kind FROM lanes LIMIT 1",
"SELECT source_repo FROM lanes LIMIT 1",
"SELECT base_branch FROM lanes LIMIT 1",
"SELECT slug FROM lanes LIMIT 1",
];
checkCols.forEach((sql) => {
assert.doesNotThrow(() => newDb.prepare(sql).get());
});
// Verify existing row has kind='adopted' and others NULL
const row = newDb.prepare("SELECT * FROM lanes WHERE cwd = ?").get("/tmp/old-lane-path");
assert.equal(row.kind, "adopted");
assert.equal(row.source_repo, null);
assert.equal(row.base_branch, null);
assert.equal(row.slug, null);
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
});
describe("stage detection", () => {
it("writes a forward detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-forward" });
const result = lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
assert.deepEqual(result, { written: true });
const after = lanes.getLane(l.id);
assert.equal(after.detected_stage, "implement");
assert.equal(after.detected_signal, "`Edit`");
assert.ok(after.detected_at);
lanes.deleteLane(l.id);
});
it("drops a detection that does not advance past the current detected stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-backward" });
lanes.recordDetection(l.id, { nodeId: "tests" });
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("drops a detection at or behind the declared stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-declared" });
lanes.setStage(l.id, { stage: "review" });
const atDeclared = lanes.recordDetection(l.id, { nodeId: "review" });
assert.deepEqual(atDeclared, { written: false, reason: "behind-declared" });
const behindDeclared = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(behindDeclared, { written: false, reason: "behind-declared" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("reports unknown-node for a node id the pipeline does not have", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-unknown" });
const result = lanes.recordDetection(l.id, { nodeId: "not-a-real-node" });
assert.deepEqual(result, { written: false, reason: "unknown-node" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a lane with only detections and no declarations has no done node", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-done" });
lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" });
const payload = lanes.lanePayload(lanes.getLane(l.id));
assert.ok(
!payload.pipeline_nodes.some((n) => n.state === "done"),
"inference must never render a node done — only a declared stage carrying evidence may"
);
// lane.stage is still the default "idle", which is not itself a pipeline
// node/alias, so nodeStates() (unaware of detection) reports "pending" for
// every node here — detection only ever adds the `detected` flag alongside
// whatever nodeStates() already computed from the declared stage.
const review = payload.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "pending");
assert.equal(review.detected, true);
const implement = payload.pipeline_nodes.find((n) => n.id === "implement");
assert.equal(implement.detected, true);
const done = payload.pipeline_nodes.find((n) => n.id === "done");
assert.equal(done.detected, false);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a node declared WITH evidence stays done and unflagged under a detection ahead of it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-done-guard" });
// implement declared with evidence, then the lane declares tests; a review
// detection lands ahead of both. `implement` is the adversarial node: it is
// `done` (declared + evidence) and it sits before the detected index.
lanes.setStage(l.id, { stage: "implement", evidence: "server/lib/x.js" });
lanes.setStage(l.id, { stage: "tests" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "done");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("a stage declared by ALIAS keeps its current ring instead of reading as an inference", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-alias-current" });
// "coding" is an alias of the `implement` node, so `stages` gets no entry
// under "implement" — the declared current node must still not be flagged.
lanes.setStage(l.id, { stage: "coding", evidence: "server/lib/x.js" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "current");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("never writes the declared stage: stage and stage_since are byte-identical after a detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-stage-write" });
lanes.setStage(l.id, { stage: "plan" });
const before = lanes.getLane(l.id);
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const after = lanes.getLane(l.id);
assert.equal(after.stage, before.stage);
assert.equal(after.stage_since, before.stage_since);
lanes.deleteLane(l.id);
});
it("clearLane nulls the detection columns and lets a fresh detection land immediately", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-clear" });
lanes.recordDetection(l.id, { nodeId: "ship", signal: "`git push`" });
assert.equal(lanes.getLane(l.id).detected_stage, "ship");
const cleared = lanes.clearLane(l.id);
assert.equal(cleared.detected_stage, null);
assert.equal(cleared.detected_signal, null);
assert.equal(cleared.detected_at, null);
assert.ok(
!lanes.lanePayload(cleared).pipeline_nodes.some((n) => n.detected),
"a cleared lane must claim no inferred progress"
);
// Without the reset, `ship` would forever outrank every later detection.
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("migration: detection columns are added to a database holding an old-schema lanes row", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-detect-migration-${Date.now()}-${process.pid}.db`
);
try {
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
kind TEXT NOT NULL DEFAULT 'adopted',
source_repo TEXT,
base_branch TEXT,
slug TEXT
)
`);
oldDb
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)"
)
.run(
"pre-detect-lane",
"/tmp/pre-detect-lane-path",
"main",
"default",
"2026-07-27T00:00:00Z"
);
oldDb.close();
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
["detected_stage", "detected_signal", "detected_at"].forEach((col) => {
assert.doesNotThrow(() => newDb.prepare(`SELECT ${col} FROM lanes LIMIT 1`).get());
});
const row = newDb
.prepare("SELECT * FROM lanes WHERE cwd = ?")
.get("/tmp/pre-detect-lane-path");
assert.equal(row.detected_stage, null);
assert.equal(row.detected_signal, null);
assert.equal(row.detected_at, null);
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
it("migration: a crash after only the first detection column self-heals on the next load", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-detect-crash-${Date.now()}-${process.pid}.db`
);
try {
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
kind TEXT NOT NULL DEFAULT 'adopted',
source_repo TEXT,
base_branch TEXT,
slug TEXT
)
`);
// Simulate a process death that landed only the first ALTER.
oldDb.exec("ALTER TABLE lanes ADD COLUMN detected_stage TEXT");
oldDb.close();
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
["detected_stage", "detected_signal", "detected_at"].forEach((col) => {
assert.doesNotThrow(() => newDb.prepare(`SELECT ${col} FROM lanes LIMIT 1`).get());
});
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
it("detection expiry: backward detection inside window is rejected", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-window" });
// Set an initial forward detection
lanes.recordDetection(l.id, { nodeId: "tests" });
// Try backward detection - should still be rejected within window
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("detection expiry: backward detection beyond TTL is accepted and written", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-stale" });
// Set an initial forward detection with a stale timestamp
lanes.recordDetection(l.id, { nodeId: "tests" });
// Manually set detected_at to an old time (2 hours ago, assuming default TTL of 30 min)
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
// Try backward detection - should be accepted because detection is stale
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("detection expiry: stale detection behind declared stage still rejects", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" });
// Stand up the detection BEFORE declaring, otherwise declared-wins refuses
// it and there is no standing detection left to age.
lanes.recordDetection(l.id, { nodeId: "tests" });
lanes.setStage(l.id, { stage: "review" });
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
// Try backward detection to implement - should still be rejected because declared stage is ahead
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-declared" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("detection expiry: NULL detected_at is treated as stale", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-null" });
// Set a detection and then nullify detected_at
lanes.recordDetection(l.id, { nodeId: "tests" });
db.prepare("UPDATE lanes SET detected_at = NULL WHERE id = ?").run(l.id);
// Try backward detection - should be accepted because detected_at is NULL (unknown age)
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("detection expiry: DETECTION_TTL_MS env var controls the window", () => {
const { db } = require("../db");
const oldTTL = process.env.DETECTION_TTL_MS;
try {
// Set a very short TTL (1 second)
process.env.DETECTION_TTL_MS = "1000";
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-ttl" });
// Set initial detection
lanes.recordDetection(l.id, { nodeId: "tests" });
// Set detected_at to 2 seconds ago (beyond the 1-second TTL)
const staleTime = new Date(Date.now() - 2000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(staleTime, l.id);
// Try backward detection - should be accepted because we're beyond TTL
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
lanes.deleteLane(l.id);
} finally {
if (oldTTL === undefined) {
delete process.env.DETECTION_TTL_MS;
} else {
process.env.DETECTION_TTL_MS = oldTTL;
}
}
});
});
describe("the forward-only hold is short enough for one work session", () => {
it("lets a newer signal move the lane back within a few minutes", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-hold-window" });
// A real session cycles implement -> tests -> ship -> implement -> tests.
// With a 30-minute hold, one push pinned the lane at `ship` for half an
// hour while the agent was demonstrably back to running tests.
lanes.recordDetection(l.id, { nodeId: "ship" });
const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(sixMinutesAgo, l.id);
const result = lanes.recordDetection(l.id, { nodeId: "tests", signal: "node --test" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("still smooths noise inside one burst of activity", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-hold-burst" });
lanes.recordDetection(l.id, { nodeId: "tests" });
// Same second: a Read after an Edit must not drag the lane backwards.
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* @file Tests for the Prometheus metrics endpoint (GET /api/metrics). Verifies
* the exposition format (HELP/TYPE headers, content-type, no NaN samples), that
* the enumerated status gauges are always present (so a series never drops out
* at zero), and that the numbers track real data seeded through the hook API.
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-metrics-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function request(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
/** Parse a `name{labels} value` sample line's value, or null if not present. */
function sampleValue(text, name, labels) {
const needle = labels ? `${name}{${labels}}` : name;
for (const line of text.split("\n")) {
if (line.startsWith("#")) continue;
if (labels ? line.startsWith(needle + " ") : line.startsWith(needle + " ")) {
return Number(line.slice(needle.length).trim());
}
}
return null;
}
describe("GET /api/metrics", () => {
it("serves a well-formed Prometheus exposition", async () => {
const res = await request("GET", "/api/metrics");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /text\/plain/);
assert.match(res.headers["content-type"], /version=0\.0\.4/);
// Core families present with HELP + TYPE headers.
for (const name of [
"ccam_up",
"ccam_build_info",
"ccam_process_uptime_seconds",
"ccam_process_resident_memory_bytes",
"ccam_sessions",
"ccam_agents",
"ccam_events_total",
"ccam_websocket_clients",
"ccam_remote_sources",
"ccam_tokens_total",
]) {
assert.ok(res.body.includes(`# HELP ${name} `), `HELP for ${name}`);
assert.ok(res.body.includes(`# TYPE ${name} `), `TYPE for ${name}`);
}
assert.equal(sampleValue(res.body, "ccam_up"), 1);
// No sample line may carry a NaN/undefined value.
for (const line of res.body.split("\n")) {
if (!line || line.startsWith("#")) continue;
const value = line.slice(line.lastIndexOf(" ") + 1);
assert.ok(!Number.isNaN(Number(value)), `numeric sample value on line: ${line}`);
}
});
it("emits every enumerated status series even at zero", async () => {
const res = await request("GET", "/api/metrics");
for (const status of ["active", "completed", "error", "abandoned"]) {
assert.notEqual(
sampleValue(res.body, "ccam_sessions", `status="${status}"`),
null,
`ccam_sessions status=${status} present`
);
}
for (const status of ["working", "waiting", "completed", "error"]) {
assert.notEqual(
sampleValue(res.body, "ccam_agents", `status="${status}"`),
null,
`ccam_agents status=${status} present`
);
}
});
it("reflects real data seeded through the hook API", async () => {
const before = sampleValue(await scrapeText(), "ccam_sessions", 'status="active"') || 0;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "metrics-sess-1", cwd: "/tmp/metrics" },
});
const after = sampleValue(await scrapeText(), "ccam_sessions", 'status="active"');
assert.equal(after, before + 1, "a new active session bumps the gauge by 1");
});
async function scrapeText() {
return (await request("GET", "/api/metrics")).body;
}
});
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Unit tests for cross-platform monitoring binary URL/path resolution.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
prometheusUrl,
grafanaUrl,
prometheusArchiveExt,
grafanaArchiveExt,
prometheusPlatform,
prometheusArchiveName,
grafanaArchiveName,
toGrafanaPath,
} = require("../../monitoring/scripts/paths");
describe("monitoring paths", () => {
it("builds official download URLs for the current platform", () => {
const plat = prometheusPlatform();
const promExt = prometheusArchiveExt();
const grafExt = grafanaArchiveExt();
assert.match(
prometheusUrl(),
new RegExp(`${prometheusArchiveName()}\\.${promExt.replace(".", "\\.")}$`)
);
assert.match(
grafanaUrl(),
new RegExp(`grafana-[0-9.]+\\.${plat}\\.${grafExt.replace(".", "\\.")}$`)
);
if (process.platform === "win32") {
assert.equal(promExt, "zip");
assert.equal(grafExt, "zip");
} else {
assert.equal(promExt, "tar.gz");
assert.equal(grafExt, "tar.gz");
}
});
it("normalizes Windows paths for Grafana YAML", () => {
assert.equal(
toGrafanaPath("C:\\ccam\\monitoring\\grafana\\dashboards"),
"C:/ccam/monitoring/grafana/dashboards"
);
});
it("uses consistent archive naming", () => {
assert.match(prometheusArchiveName(), /^prometheus-[0-9.]+\./);
assert.match(grafanaArchiveName(), /^grafana-[0-9.]+\./);
});
});
@@ -0,0 +1,170 @@
/**
* @file plugins-marketplace.test.js
* @description Structural validation for the bundled Claude Code plugin
* marketplace (.claude-plugin/marketplace.json + plugins/*). Guards that
* every marketplace entry resolves to a real plugin dir with a valid
* plugin.json, that names line up, and that every agent / skill / command
* file carries the frontmatter Claude Code requires. Pure file reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { parseFrontmatter } = require("../lib/cc-discovery");
const REPO_ROOT = path.join(__dirname, "..", "..");
const PLUGINS_DIR = path.join(REPO_ROOT, "plugins");
const MARKETPLACE = path.join(REPO_ROOT, ".claude-plugin", "marketplace.json");
function readJson(p) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function listDirs(p) {
return fs
.readdirSync(p, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
}
function listMd(dir) {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isFile() && e.name.endsWith(".md"))
.map((e) => e.name);
} catch {
return [];
}
}
describe("plugin marketplace", () => {
const marketplace = readJson(MARKETPLACE);
const pluginDirs = listDirs(PLUGINS_DIR).sort();
const entryNames = marketplace.plugins.map((p) => p.name).sort();
it("marketplace.json has the required top-level shape", () => {
assert.equal(typeof marketplace.name, "string");
assert.ok(marketplace.name.length > 0);
assert.equal(typeof marketplace.description, "string");
assert.ok(marketplace.owner && typeof marketplace.owner.name === "string");
assert.ok(Array.isArray(marketplace.plugins));
});
it("ships at least 10 plugins", () => {
assert.ok(
marketplace.plugins.length >= 10,
`expected >=10 marketplace entries, got ${marketplace.plugins.length}`
);
assert.ok(pluginDirs.length >= 10, `expected >=10 plugin dirs, got ${pluginDirs.length}`);
});
it("marketplace entries and plugin dirs are a bijection", () => {
assert.deepEqual(
entryNames,
pluginDirs,
`marketplace entries (${entryNames}) must match plugin dirs (${pluginDirs})`
);
});
for (const entry of marketplace.plugins) {
describe(`entry: ${entry.name}`, () => {
it("has name, path, description, tags", () => {
assert.equal(typeof entry.name, "string");
assert.equal(entry.path, `plugins/${entry.name}`);
assert.equal(typeof entry.description, "string");
assert.ok(entry.description.length > 20);
assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0);
});
it("path exists on disk", () => {
assert.ok(fs.existsSync(path.join(REPO_ROOT, entry.path)));
});
});
}
for (const dir of pluginDirs) {
describe(`plugin: ${dir}`, () => {
const root = path.join(PLUGINS_DIR, dir);
const manifestPath = path.join(root, ".claude-plugin", "plugin.json");
it("has a valid plugin.json whose name matches the dir", () => {
assert.ok(fs.existsSync(manifestPath), `${dir} is missing .claude-plugin/plugin.json`);
const m = readJson(manifestPath);
assert.equal(m.name, dir, `${dir}/plugin.json name must equal the dir name`);
assert.equal(typeof m.description, "string");
assert.ok(m.description.length > 20);
assert.equal(typeof m.version, "string");
assert.ok(m.author && typeof m.author.name === "string");
assert.equal(typeof m.license, "string");
assert.ok(Array.isArray(m.keywords) && m.keywords.length > 0);
});
it("agents carry valid frontmatter (name === filename, description)", () => {
const agentsDir = path.join(root, "agents");
for (const f of listMd(agentsDir)) {
const { frontmatter } = parseFrontmatter(
fs.readFileSync(path.join(agentsDir, f), "utf8")
);
assert.ok(frontmatter, `${dir}/agents/${f} has no frontmatter`);
assert.equal(
frontmatter.name,
f.replace(/\.md$/, ""),
`${dir}/agents/${f} frontmatter name must equal the filename`
);
assert.ok(frontmatter.description, `${dir}/agents/${f} missing description`);
}
});
it("skills carry a description in SKILL.md frontmatter", () => {
const skillsDir = path.join(root, "skills");
let skillDirs = [];
try {
skillDirs = listDirs(skillsDir);
} catch {
skillDirs = [];
}
for (const s of skillDirs) {
const file = path.join(skillsDir, s, "SKILL.md");
assert.ok(fs.existsSync(file), `${dir}/skills/${s} is missing SKILL.md`);
const { frontmatter } = parseFrontmatter(fs.readFileSync(file, "utf8"));
assert.ok(frontmatter, `${dir}/skills/${s}/SKILL.md has no frontmatter`);
assert.ok(frontmatter.description, `${dir}/skills/${s}/SKILL.md missing description`);
}
});
it("commands carry a description in frontmatter", () => {
const cmdDir = path.join(root, "commands");
for (const f of listMd(cmdDir)) {
const { frontmatter } = parseFrontmatter(fs.readFileSync(path.join(cmdDir, f), "utf8"));
assert.ok(frontmatter, `${dir}/commands/${f} has no frontmatter`);
assert.ok(frontmatter.description, `${dir}/commands/${f} missing description`);
}
});
it("hooks.json (if present) is valid JSON with a hooks object", () => {
const hooksFile = path.join(root, "hooks", "hooks.json");
if (!fs.existsSync(hooksFile)) return;
const h = readJson(hooksFile);
assert.ok(
h.hooks && typeof h.hooks === "object",
`${dir}/hooks/hooks.json needs a hooks object`
);
});
it("contributes at least one skill or agent", () => {
const skills = (() => {
try {
return listDirs(path.join(root, "skills")).length;
} catch {
return 0;
}
})();
const agents = listMd(path.join(root, "agents")).length;
assert.ok(skills + agents > 0, `${dir} contributes no skills or agents`);
});
});
}
});
+326
View File
@@ -0,0 +1,326 @@
/**
* @file Unit tests for the enhanced pricing calculator and the shared token-usage
* normalizer: 5m/1h cache-write split, server-tool surcharges, and the per-bucket
* pricing modifiers (fast mode, US data residency, Batch API).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { calculateCost } = require("../routes/pricing");
const {
normalizeSpeed,
normalizeGeo,
normalizeTier,
extractUsageFields,
} = require("../lib/token-usage");
const M = 1_000_000;
// One Opus-4.8-shaped rule with fast pricing, used across the cost tests.
const RULES = [
{
model_pattern: "claude-opus-4-8%",
display_name: "Claude Opus 4.8",
input_per_mtok: 5,
output_per_mtok: 25,
cache_read_per_mtok: 0.5,
cache_write_per_mtok: 6.25,
cache_write_1h_per_mtok: 10,
fast_input_per_mtok: 10,
fast_output_per_mtok: 50,
},
];
function bucket(extra) {
return {
model: "claude-opus-4-8",
speed: "standard",
inference_geo: "global",
service_tier: "standard",
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cache_write_1h_tokens: 0,
web_search_requests: 0,
web_fetch_requests: 0,
code_execution_requests: 0,
...extra,
};
}
describe("token-usage normalizer", () => {
it("normalizes pricing dimensions, collapsing unknowns to standard/global", () => {
assert.equal(normalizeSpeed({ speed: "fast" }), "fast");
assert.equal(normalizeSpeed({ speed: "standard" }), "standard");
assert.equal(normalizeSpeed({}), "standard");
assert.equal(normalizeGeo({ inference_geo: "us" }), "us");
assert.equal(normalizeGeo({ inference_geo: "not_available" }), "global");
assert.equal(normalizeGeo({}), "global");
assert.equal(normalizeTier({ service_tier: "batch" }), "batch");
assert.equal(normalizeTier({ service_tier: "priority" }), "standard");
});
it("splits 5m vs 1h cache writes from cache_creation breakdown", () => {
const f = extractUsageFields({
input_tokens: 100,
output_tokens: 200,
cache_read_input_tokens: 50,
cache_creation_input_tokens: 80,
cache_creation: { ephemeral_5m_input_tokens: 30, ephemeral_1h_input_tokens: 50 },
server_tool_use: {
web_search_requests: 2,
web_fetch_requests: 1,
code_execution_requests: 3,
},
});
assert.equal(f.input, 100);
assert.equal(f.output, 200);
assert.equal(f.cacheRead, 50);
assert.equal(f.cacheWrite, 80);
assert.equal(f.cacheWrite1h, 50);
assert.equal(f.webSearch, 2);
assert.equal(f.webFetch, 1);
assert.equal(f.codeExec, 3);
});
it("treats the old shape (no breakdown / no tool use) as all-5m, zero tools", () => {
const f = extractUsageFields({
input_tokens: 10,
output_tokens: 20,
cache_read_input_tokens: 5,
cache_creation_input_tokens: 40,
});
assert.equal(f.cacheWrite, 40);
assert.equal(f.cacheWrite1h, 0); // backward compatible: priced at the 5m rate
assert.equal(f.webSearch, 0);
assert.equal(f.codeExec, 0);
});
});
describe("calculateCost — token rates", () => {
it("prices standard input/output/read/5m/1h correctly", () => {
const r = calculateCost(
[
bucket({
input_tokens: M,
output_tokens: M,
cache_read_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: 0,
}),
],
RULES
);
// 5 + 25 + 0.5 + 6.25(5m) = 36.75
assert.equal(r.total_cost, 36.75);
});
it("splits a mixed cache_write into 5m and 1h portions", () => {
const r = calculateCost(
[bucket({ cache_write_tokens: M, cache_write_1h_tokens: 0.4 * M })],
RULES
);
// 0.6M @ 6.25 + 0.4M @ 10 = 3.75 + 4 = 7.75
assert.equal(r.total_cost, 7.75);
});
it("falls back to zero cost when no rule matches and surfaces the unpriced model", () => {
const r = calculateCost([bucket({ model: "gpt-4o", input_tokens: M })], RULES);
assert.equal(r.total_cost, 0);
assert.equal(r.breakdown[0].matched_rule, null);
assert.equal(r.unpriced_models.length, 1);
assert.equal(r.unpriced_models[0].model, "gpt-4o");
assert.equal(r.unpriced_models[0].input_tokens, M);
});
});
describe("calculateCost — modifiers", () => {
it("applies fast-mode premium (input/output) and scales cache from fast input", () => {
const r = calculateCost(
[
bucket({
speed: "fast",
input_tokens: M,
output_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: M,
}),
],
RULES
);
// fast input 10, output 50, 1h-write = 10 * (10/5) = 20 => 80
assert.equal(r.total_cost, 80);
});
it("applies the US data-residency 1.1x multiplier", () => {
const r = calculateCost(
[
bucket({
inference_geo: "us",
input_tokens: M,
output_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: M,
}),
],
RULES
);
// (5 + 25 + 10) * 1.1 = 44
assert.equal(r.total_cost, 44);
});
it("applies the Batch API 50% discount", () => {
const r = calculateCost(
[
bucket({
service_tier: "batch",
input_tokens: M,
output_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: M,
}),
],
RULES
);
// (5 + 25 + 10) * 0.5 = 20
assert.equal(r.total_cost, 20);
});
});
describe("calculateCost — server-tool surcharges", () => {
it("charges web search at $10 / 1,000 searches", () => {
const r = calculateCost([bucket({ web_search_requests: 2500 })], RULES);
assert.equal(r.total_cost, 25);
assert.equal(r.feature_costs.web_search_cost, 25);
});
it("charges nothing for web fetch", () => {
const r = calculateCost([bucket({ web_fetch_requests: 9999 })], RULES);
assert.equal(r.total_cost, 0);
assert.equal(r.feature_costs.web_fetch_cost, 0);
});
it("treats code execution as free under the monthly allowance", () => {
const r = calculateCost([bucket({ code_execution_requests: 100 })], RULES);
assert.equal(r.feature_costs.code_execution_cost, 0); // well under 1550 free hours
assert.ok(r.feature_costs.code_execution_hours_estimated > 0);
});
it("treats code execution as free when used alongside web search", () => {
const r = calculateCost(
[bucket({ code_execution_requests: 1000000, web_search_requests: 1 })],
RULES
);
// free-with-search => 0 estimated hours despite huge request count (search surcharge only)
assert.equal(r.feature_costs.code_execution_hours_estimated, 0);
assert.equal(r.feature_costs.code_execution_cost, 0);
});
it("charges code execution beyond the free allowance", () => {
// 12 requests/hour at the 5-min minimum; exceed 1550 free hours to force a charge.
const requests = (1550 + 100) * 12; // 100 billable hours over the allowance
const r = calculateCost([bucket({ code_execution_requests: requests })], RULES);
assert.equal(r.feature_costs.code_execution_cost, 5); // 100 hrs * $0.05
});
});
describe("calculateCost — model_pattern matching (dated ids, no cross-match)", () => {
// Sonnet-5 alongside Sonnet-4.6 + Opus, mirroring the seeded DEFAULT_PRICING.
const FAMILY = [
{ model_pattern: "claude-opus-4-8%", input_per_mtok: 5, output_per_mtok: 25 },
{ model_pattern: "claude-sonnet-5%", input_per_mtok: 3, output_per_mtok: 15 },
{ model_pattern: "claude-sonnet-4-6%", input_per_mtok: 3, output_per_mtok: 15 },
];
const priceOf = (model) => {
const r = calculateCost([bucket({ model, output_tokens: M })], FAMILY);
return { cost: r.total_cost, unpriced: r.unpriced_models.map((u) => u.model) };
};
it("prices bare claude-sonnet-5 (not $0, not unpriced)", () => {
const { cost, unpriced } = priceOf("claude-sonnet-5");
assert.equal(cost, 15); // 1M output * $15
assert.deepEqual(unpriced, []);
});
it("prices a dated claude-sonnet-5-YYYYMMDD via the % suffix", () => {
const { cost, unpriced } = priceOf("claude-sonnet-5-20260615");
assert.equal(cost, 15);
assert.deepEqual(unpriced, []);
});
it("does not cross-match sonnet-5 ↔ sonnet-4.x (both stay priced by their own rule)", () => {
// If claude-sonnet-5 wrongly matched the 4.6 rule (or vice versa) via a
// greedy/short pattern, one of these would resolve to the wrong row. Both
// are $3/$15 here, so the real guard is that neither is left UNPRICED and
// the sonnet-4.5 (absent) case IS surfaced as unpriced.
assert.deepEqual(priceOf("claude-sonnet-4-6").unpriced, []);
assert.deepEqual(priceOf("claude-sonnet-5").unpriced, []);
// A model with no rule (sonnet-4-5 not in FAMILY) must be reported unpriced,
// proving sonnet-5%/sonnet-4-6% don't greedily swallow it.
assert.deepEqual(priceOf("claude-sonnet-4-5").unpriced, ["claude-sonnet-4-5"]);
});
});
describe("calculateCost — date-effective (intro) pricing", () => {
// Sonnet-5-shaped rule: intro $2/$10 through 2026-08-31, standard $3/$15 after.
const INTRO = [
{
model_pattern: "claude-sonnet-5%",
input_per_mtok: 3,
output_per_mtok: 15,
cache_read_per_mtok: 0.3,
cache_write_per_mtok: 3.75,
cache_write_1h_per_mtok: 6,
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
intro_cache_read_per_mtok: 0.2,
intro_cache_write_per_mtok: 2.5,
intro_cache_write_1h_per_mtok: 4,
intro_until: "2026-08-31",
},
];
// 1M output → intro $10, standard $15.
const cost = (asOf, rowDate) =>
calculateCost(
[{ ...bucket({ model: "claude-sonnet-5", output_tokens: M }), date: rowDate }],
INTRO,
asOf
).total_cost;
it("uses intro rate before the cutoff (asOf)", () => {
assert.equal(cost("2026-07-01", undefined), 10);
});
it("uses intro rate on the cutoff day (inclusive)", () => {
assert.equal(cost("2026-08-31", undefined), 10);
});
it("uses standard rate after the cutoff", () => {
assert.equal(cost("2026-09-01", undefined), 15);
assert.equal(cost("2026-10-15", undefined), 15);
});
it("prefers the row's own date over asOf (per-day pricing)", () => {
// asOf is post-cutoff, but the row is dated pre-cutoff → intro applies.
assert.equal(cost("2026-12-01", "2026-08-01"), 10);
// and vice versa
assert.equal(cost("2026-07-01", "2026-09-15"), 15);
});
it("a rule with no intro_until always uses standard rate", () => {
const NO_INTRO = [
{ model_pattern: "claude-sonnet-5%", input_per_mtok: 3, output_per_mtok: 15 },
];
assert.equal(
calculateCost(
[bucket({ model: "claude-sonnet-5", output_tokens: M })],
NO_INTRO,
"2026-07-01"
).total_cost,
15
);
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* @file Tests for editing time-limited introductory pricing via PUT /api/pricing.
*
* The Settings page lets users edit a model's introductory (promo) rates, not
* just its standard rates. This suite verifies the route contract that backs
* that UI, and that the mechanism is generic (works for any model pattern, not
* just the seeded Sonnet 5 promo):
*
* 1. PUT with intro fields + a valid intro_until persists the intro rates.
* 2. PUT that omits every intro field preserves an existing promo (backward
* compatible with older clients that only send standard rates).
* 3. PUT with an empty intro_until clears the promo AND zeroes the intro
* rates so a stale value can't resurface later.
* 4. A malformed intro_until is rejected with 400 and writes nothing.
* 5. Editing standard rates never disturbs the intro block.
*
* It also covers the route's numeric-rate validation: any present rate field
* must be a non-negative finite number, else the PUT is rejected with 400 and
* nothing is written (NaN/negative rates would corrupt all cost math).
*
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-intro-edit-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const PATTERN = "test-promo-model%";
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
describe("PUT /api/pricing — introductory rate editing", () => {
it("persists intro rates when a valid intro_until is supplied", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 3,
output_per_mtok: 15,
intro_until: "2026-08-31",
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
intro_cache_read_per_mtok: 0.2,
intro_cache_write_per_mtok: 2.5,
intro_cache_write_1h_per_mtok: 4,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.intro_until, "2026-08-31");
assert.equal(row.intro_input_per_mtok, 2);
assert.equal(row.intro_output_per_mtok, 10);
assert.equal(row.intro_cache_read_per_mtok, 0.2);
assert.equal(row.intro_cache_write_per_mtok, 2.5);
assert.equal(row.intro_cache_write_1h_per_mtok, 4);
// Standard rates persisted too.
assert.equal(row.input_per_mtok, 3);
assert.equal(row.output_per_mtok, 15);
});
it("preserves an existing promo when the PUT omits all intro fields", async () => {
// Older client shape: standard rates only, no intro_* keys at all.
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model (renamed)",
input_per_mtok: 4,
output_per_mtok: 16,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
// Standard rates updated…
assert.equal(row.input_per_mtok, 4);
assert.equal(row.display_name, "Test Promo Model (renamed)");
// …but the promo is untouched.
assert.equal(row.intro_until, "2026-08-31");
assert.equal(row.intro_input_per_mtok, 2);
assert.equal(row.intro_output_per_mtok, 10);
});
it("rejects a malformed intro_until without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: 16,
intro_until: "August 31",
intro_input_per_mtok: 1,
},
});
assert.equal(res.status, 400);
const after = stmts.getPricing.get(PATTERN);
assert.deepEqual(after, before);
});
it("clears the promo and zeroes intro rates when intro_until is emptied", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: 16,
intro_until: "",
// Even though rates are still sent, an empty date clears them so a
// re-added date later can't silently resurrect stale values.
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.intro_until, null);
assert.equal(row.intro_input_per_mtok, 0);
assert.equal(row.intro_output_per_mtok, 0);
});
});
describe("PUT /api/pricing — numeric rate validation", () => {
it("rejects a non-numeric rate without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: "abc",
output_per_mtok: 16,
},
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
assert.match(res.body.error.message, /input_per_mtok/);
assert.deepEqual(stmts.getPricing.get(PATTERN), before);
});
it("rejects a negative rate without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: -1,
},
});
assert.equal(res.status, 400);
assert.match(res.body.error.message, /output_per_mtok/);
assert.deepEqual(stmts.getPricing.get(PATTERN), before);
});
it("rejects NaN in intro and fast rate fields too", async () => {
for (const field of ["fast_input_per_mtok", "intro_output_per_mtok"]) {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
intro_until: "2026-12-31",
[field]: "not-a-number",
},
});
assert.equal(res.status, 400, `${field} should be rejected`);
assert.match(res.body.error.message, new RegExp(field));
}
});
it("accepts numeric strings by coercing them to numbers", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: "4.5",
output_per_mtok: "18",
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.input_per_mtok, 4.5);
assert.equal(row.output_per_mtok, 18);
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* @file Tests for push notification API endpoints, covering subscription management and sending notifications. It verifies that the server correctly handles subscription creation, deletion, and sending push messages, ensuring proper validation and response formats.
* @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 http = require("http");
// Isolate test database
const TEST_DB = path.join(os.tmpdir(), `push-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const bodyString = options.body ? JSON.stringify(options.body) : null;
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
...(bodyString ? { "Content-Length": Buffer.byteLength(bodyString) } : {}),
...options.headers,
},
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (bodyString) req.write(bodyString);
req.end();
});
}
function post(urlPath, body) {
return request(urlPath, { method: "POST", body });
}
function del(urlPath, body) {
return request(urlPath, { method: "DELETE", body });
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
const addr = server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
const fs = require("fs");
try {
fs.unlinkSync(TEST_DB);
} catch {}
});
describe("GET /api/push/vapid-public-key", () => {
it("returns a non-empty public key string", async () => {
const res = await request("/api/push/vapid-public-key");
assert.equal(res.status, 200);
assert.ok(typeof res.body.publicKey === "string");
assert.ok(res.body.publicKey.length > 0);
});
});
describe("POST /api/push/subscribe", () => {
it("stores a subscription and returns ok", async () => {
const res = await post("/api/push/subscribe", {
endpoint: "https://example.com/push/abc123",
keys: { p256dh: "dGVzdA==", auth: "dGVzdA==" },
});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
});
it("returns 400 when endpoint is missing", async () => {
const res = await post("/api/push/subscribe", {
keys: { p256dh: "dGVzdA==", auth: "dGVzdA==" },
});
assert.equal(res.status, 400);
});
it("returns 400 when keys are missing", async () => {
const res = await post("/api/push/subscribe", {
endpoint: "https://example.com/push/abc123",
});
assert.equal(res.status, 400);
});
});
describe("DELETE /api/push/subscribe", () => {
it("removes a subscription and returns ok", async () => {
const endpoint = "https://example.com/push/to-delete";
await post("/api/push/subscribe", {
endpoint,
keys: { p256dh: "dGVzdA==", auth: "dGVzdA==" },
});
const res = await del("/api/push/subscribe", { endpoint });
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
});
it("returns 400 when endpoint is missing", async () => {
const res = await del("/api/push/subscribe", {});
assert.equal(res.status, 400);
});
});
describe("POST /api/push/send", () => {
it("returns ok when there are no subscriptions", async () => {
const res = await post("/api/push/send", {
title: "Test",
body: "Hello",
});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
});
it("returns 400 when title is missing", async () => {
const res = await post("/api/push/send", { body: "Hello" });
assert.equal(res.status, 400);
});
});
+716
View File
@@ -0,0 +1,716 @@
/**
* @file Tests for the Remote Data Sources feature: input validation + command
* builders in server/lib/remote-sync.js, the /api/remote-sources route CRUD, and
* the source-scoped data filter threaded through the sessions/events/agents/
* stats/analytics endpoints. The actual SSH/rsync transfer is not exercised
* (that needs a live remote); everything up to and around it is.
* @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");
// Isolate the DB and disable background pollers/probes before loading server.
const TEST_DB = path.join(os.tmpdir(), `dashboard-remote-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const remoteSync = require("../lib/remote-sync");
const sourceFilter = require("../lib/source-filter");
let server;
let BASE;
function fetchJson(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const get = (p) => fetchJson(p);
const post = (p, body) => fetchJson(p, { method: "POST", body });
const patch = (p, body) => fetchJson(p, { method: "PATCH", body });
const del = (p) => fetchJson(p, { method: "DELETE" });
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// ── Validation ────────────────────────────────────────────────────────────────
describe("remote-sync validateSourceInput", () => {
it("accepts a valid full config and expands ~ in identity_file", () => {
const v = remoteSync.validateSourceInput({
label: "Dev Box",
host: "son@dev.local",
ssh_port: 22,
identity_file: "~/.ssh/id_ed25519",
remote_home: "~/.claude",
});
assert.equal(v.label, "Dev Box");
assert.equal(v.host, "son@dev.local");
assert.equal(v.sshPort, 22);
assert.ok(path.isAbsolute(v.identityFile));
assert.equal(v.remoteHome, "~/.claude");
});
it("accepts a config-alias host with no user", () => {
const v = remoteSync.validateSourceInput({ label: "x", host: "mybox" });
assert.equal(v.host, "mybox");
});
const rejects = [
[
"leading-dash host (ssh option injection)",
{ label: "x", host: "-oProxyCommand=evil" },
"INVALID_HOST",
],
["host with space", { label: "x", host: "a b" }, "INVALID_HOST"],
["host with ;", { label: "x", host: "a;rm -rf /" }, "INVALID_HOST"],
["host with : (breaks scp spec)", { label: "x", host: "a:b" }, "INVALID_HOST"],
["missing label", { host: "a" }, "INVALID_LABEL"],
["port out of range", { label: "x", host: "a", ssh_port: 99999 }, "INVALID_PORT"],
[
"remote_home with ..",
{ label: "x", host: "a", remote_home: "~/../etc" },
"INVALID_REMOTE_HOME",
],
[
"relative remote_home",
{ label: "x", host: "a", remote_home: "rel/path" },
"INVALID_REMOTE_HOME",
],
[
"identity_file with newline",
{ label: "x", host: "a", identity_file: "/a\nb" },
"INVALID_IDENTITY_FILE",
],
];
for (const [name, input, code] of rejects) {
it(`rejects ${name}`, () => {
assert.throws(
() => remoteSync.validateSourceInput(input),
(err) => err.code === code
);
});
}
it("allows hyphens inside an identity_file path", () => {
const v = remoteSync.validateSourceInput({
label: "x",
host: "a",
identity_file: "/home/u/.ssh/id-ed25519",
});
assert.equal(v.identityFile, "/home/u/.ssh/id-ed25519");
});
});
describe("remote-sync command builders", () => {
it("builds ssh option args with port + identity", async () => {
const args = await remoteSync.sshOptionArgs({ ssh_port: 2222, identity_file: "/k" });
assert.ok(args.includes("-p"));
assert.equal(args[args.indexOf("-p") + 1], "2222");
assert.ok(args.includes("-i"));
assert.equal(args[args.indexOf("-i") + 1], "/k");
assert.ok(args.includes("IdentitiesOnly=yes"));
});
it("builds scp option args with capital -P for port", async () => {
const args = await remoteSync.scpOptionArgs({ ssh_port: 2222, identity_file: "/k" });
assert.ok(args.includes("-P"));
assert.equal(args[args.indexOf("-P") + 1], "2222");
});
it("parses ssh -G output for identity agent discovery", () => {
const cfg = remoteSync.parseSshGOutput(
"hostname example.com\nidentityagent /tmp/agent.sock\nport 22\n"
);
assert.equal(cfg.identityagent, "/tmp/agent.sock");
assert.equal(cfg.port, "22");
});
it("buildSshChildEnv sets HOME and does not override SSH_AUTH_SOCK", () => {
const env = remoteSync.buildSshChildEnv();
assert.ok(env.HOME);
if (process.env.SSH_AUTH_SOCK) {
assert.equal(env.SSH_AUTH_SOCK, process.env.SSH_AUTH_SOCK);
}
});
it("identityAgentArgsFromConfig follows ssh -G only for concrete agent paths", () => {
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("none"), []);
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("SSH_AUTH_SOCK"), []);
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("/tmp/custom-agent.sock"), [
"-o",
"IdentityAgent=/tmp/custom-agent.sock",
]);
const home = os.homedir();
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("~/Library/agent.sock"), [
"-o",
`IdentityAgent=${path.join(home, "Library/agent.sock")}`,
]);
});
it("adds PowerShell and WSL probes for ~-rooted remote homes (Windows remotes)", () => {
const probes = remoteSync.connectionProbeCommands({ remote_home: "~/.claude" });
assert.equal(probes.length, 3);
assert.match(probes[0], /sh -c/);
assert.match(probes[0], /~\/\.claude\/projects/);
assert.match(probes[1], /powershell\.exe/);
assert.match(probes[1], /\.claude\\projects/);
assert.match(probes[2], /wsl\.exe/);
assert.match(probes[2], /~\/\.claude\/projects/);
});
it("uses only wsl.exe for wsl: remote homes", () => {
const probes = remoteSync.connectionProbeCommands({ remote_home: "wsl:~/.claude" });
assert.equal(probes.length, 1);
assert.match(probes[0], /wsl\.exe/);
});
it("connectionSuccessMessage reflects explicit vs auto-detected WSL", () => {
const wslProbe = "wsl.exe -e sh -c 'test -d ~/.claude/projects && echo CCAM_OK'";
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: "wsl:~/.claude" }, wslProbe),
/wsl:~\/\.claude/
);
assert.doesNotMatch(
remoteSync.connectionSuccessMessage({ remote_home: "wsl:~/.claude" }, wslProbe),
/auto-detected/i
);
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: null }, wslProbe),
/auto-detected/i
);
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: null }, "sh -c 'echo CCAM_OK'"),
/Remote Claude Code history found/
);
});
it("accepts wsl: and UNC remote_home values", () => {
const wsl = remoteSync.validateSourceInput({
label: "WSL",
host: "u@win",
remote_home: "wsl:/home/hoang/.claude",
});
assert.equal(wsl.remoteHome, "wsl:/home/hoang/.claude");
assert.equal(
remoteSync.remoteProjectsPath({ remote_home: wsl.remoteHome }),
"wsl:/home/hoang/.claude/projects"
);
const unc = remoteSync.validateSourceInput({
label: "UNC",
host: "u@win",
remote_home: "//wsl.localhost/Ubuntu/home/hoang/.claude",
});
assert.equal(unc.remoteHome, "//wsl.localhost/Ubuntu/home/hoang/.claude");
assert.equal(
remoteSync.remoteProjectsPath({ remote_home: unc.remoteHome }),
"//wsl.localhost/Ubuntu/home/hoang/.claude/projects"
);
});
it("builds a wsl tar command for WSL-hosted Claude homes", () => {
assert.equal(
remoteSync.wslTarRemoteCmd("~/.claude"),
"wsl.exe -e sh -c 'tar -cC ~/.claude/projects .'"
);
});
it("adds cmd.exe probe only for Windows drive-letter remote homes", () => {
const probes = remoteSync.connectionProbeCommands({
remote_home: "C:/Users/hoang/.claude",
});
assert.equal(probes.length, 1);
assert.match(probes[0], /cmd \/c/);
assert.match(probes[0], /C:\\Users\\hoang\\.claude\\projects/);
});
it("uses sh probe for POSIX absolute remote homes", () => {
const probes = remoteSync.connectionProbeCommands({ remote_home: "/opt/cc" });
assert.deepEqual(probes, [
"sh -c 'test -d /opt/cc/projects && echo CCAM_OK || echo CCAM_NO_DIR'",
]);
});
it("accepts Windows-style remote_home with forward slashes", () => {
const v = remoteSync.validateSourceInput({
label: "Win",
host: "u@win",
remote_home: "C:/Users/hoang/.claude",
});
assert.equal(v.remoteHome, "C:/Users/hoang/.claude");
assert.equal(
remoteSync.remoteProjectsPath({ remote_home: v.remoteHome }),
"C:/Users/hoang/.claude/projects"
);
assert.equal(
remoteSync.scpRemoteSpec({ host: "u@win", remote_home: v.remoteHome }),
"u@win:C:/Users/hoang/.claude/projects/."
);
});
it("expands tilde in ssh -G IdentityAgent paths and strips quotes", () => {
const home = os.homedir();
assert.equal(
remoteSync.expandSshConfigPath("~/Library/agent.sock"),
path.join(home, "Library/agent.sock")
);
assert.equal(remoteSync.expandSshConfigPath('"/tmp/quoted.sock"'), "/tmp/quoted.sock");
assert.equal(remoteSync.expandSshConfigPath("/tmp/a"), "/tmp/a");
});
it("sshConfigFileArgs points at user config when present", () => {
const args = remoteSync.sshConfigFileArgs();
const cfg = path.join(os.homedir(), ".ssh", "config");
if (fs.existsSync(cfg)) {
assert.deepEqual(args, ["-F", cfg]);
} else {
assert.deepEqual(args, []);
}
});
it("treats blank identity_file as null", () => {
const v = remoteSync.validateSourceInput({
label: "x",
host: "a",
identity_file: " ",
});
assert.equal(v.identityFile, null);
});
it("detects legacy scp protocol errors for -O retry", () => {
assert.equal(
remoteSync.isLegacyScpProtocolError("subsystem request failed on channel 0"),
true
);
assert.equal(remoteSync.isLegacyScpProtocolError("Permission denied"), false);
});
it("strips ANSI escapes from command output", () => {
assert.equal(remoteSync.stripAnsi("\u001b[31;1mscp: not found\u001b[0m"), "scp: not found");
});
it("resolves ssh/scp binaries on Windows when OpenSSH is in System32", () => {
const prev = process.platform;
const prevWin = process.env.WINDIR;
try {
Object.defineProperty(process, "platform", { value: "win32" });
process.env.WINDIR = process.env.WINDIR || "C:\\Windows";
const ssh = remoteSync.resolveSshBinary("ssh");
assert.match(ssh, /ssh\.exe$/i);
} finally {
Object.defineProperty(process, "platform", { value: prev });
if (prevWin === undefined) delete process.env.WINDIR;
else process.env.WINDIR = prevWin;
}
});
it("defaults the remote projects path to ~/.claude/projects", () => {
assert.equal(remoteSync.remoteProjectsPath({}), "~/.claude/projects");
assert.equal(remoteSync.remoteProjectsPath({ remote_home: "/opt/cc" }), "/opt/cc/projects");
});
it("identifies top-level session ids in a mirrored tree (skips subagents)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-staged-"));
const proj = path.join(dir, "-Users-x-proj");
fs.mkdirSync(path.join(proj, "sess-1", "subagents"), { recursive: true });
fs.writeFileSync(path.join(proj, "sess-1.jsonl"), "{}\n");
fs.writeFileSync(path.join(proj, "sess-2.jsonl"), "{}\n");
fs.writeFileSync(path.join(proj, "sess-1", "subagents", "agent-abc.jsonl"), "{}\n");
const ids = remoteSync.stagedSessionIds(dir).sort();
assert.deepEqual(ids, ["sess-1", "sess-2"]);
fs.rmSync(dir, { recursive: true, force: true });
});
});
// ── source-filter helper ────────────────────────────────────────────────────
describe("source-filter helper", () => {
it("parses the sources csv, deduped; empty/absent → null", () => {
assert.deepEqual(sourceFilter.parseSources({ query: { sources: "local, a ,a,," } }), [
"local",
"a",
]);
assert.equal(sourceFilter.parseSources({ query: {} }), null);
assert.equal(sourceFilter.parseSources({ query: { sources: " ,, " } }), null);
});
it("builds a column clause and a subquery clause", () => {
assert.deepEqual(sourceFilter.sourceColumnClause(["local", "a"]), {
clause: "s.source IN (?,?)",
params: ["local", "a"],
});
assert.deepEqual(sourceFilter.sessionIdInSourcesClause(["local"], "e.session_id"), {
clause: "e.session_id IN (SELECT id FROM sessions WHERE source IN (?))",
params: ["local"],
});
assert.deepEqual(sourceFilter.sourceColumnClause(null), { clause: "", params: [] });
});
});
// ── Route CRUD ──────────────────────────────────────────────────────────────
describe("/api/remote-sources CRUD", () => {
let createdId;
it("starts empty", async () => {
const res = await get("/api/remote-sources");
assert.equal(res.status, 200);
assert.deepEqual(res.body.sources, []);
});
it("creates a source", async () => {
const res = await post("/api/remote-sources", { label: "Dev", host: "son@dev", ssh_port: 22 });
assert.equal(res.status, 201);
assert.equal(res.body.source.label, "Dev");
assert.equal(res.body.source.host, "son@dev");
assert.equal(res.body.source.enabled, true);
assert.equal(res.body.source.status, "idle");
assert.ok(res.body.source.id.startsWith("src_"));
createdId = res.body.source.id;
});
it("rejects an invalid host with a 400 + structured error", async () => {
const res = await post("/api/remote-sources", { label: "Bad", host: "-oProxyCommand=x" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_HOST");
});
it("patches label + enabled, leaving other fields intact", async () => {
const res = await patch(`/api/remote-sources/${createdId}`, {
label: "Renamed",
enabled: false,
});
assert.equal(res.status, 200);
assert.equal(res.body.source.label, "Renamed");
assert.equal(res.body.source.enabled, false);
assert.equal(res.body.source.ssh_port, 22); // unchanged
});
it("404s for an unknown id", async () => {
const res = await patch("/api/remote-sources/src_nope", { label: "x" });
assert.equal(res.status, 404);
});
it("delete without purge detaches its sessions back to local", async () => {
// Attach a session to the source, then delete without purge.
stmts.insertSession.run("rs-detach-1", "s", "active", "/x", "claude-opus-4-8", null);
stmts.setSessionSource.run(createdId, "rs-detach-1");
const res = await del(`/api/remote-sources/${createdId}`);
assert.equal(res.status, 200);
assert.equal(res.body.purged, 0);
assert.equal(stmts.getSession.get("rs-detach-1").source, "local");
assert.equal(stmts.getRemoteSource.get(createdId), undefined);
});
it("delete with purge removes the source's sessions", async () => {
const c = await post("/api/remote-sources", { label: "P", host: "p@h" });
const id = c.body.source.id;
stmts.insertSession.run("rs-purge-1", "s", "active", "/x", "claude-opus-4-8", null);
stmts.setSessionSource.run(id, "rs-purge-1");
const res = await del(`/api/remote-sources/${id}?purge=true`);
assert.equal(res.status, 200);
assert.equal(res.body.purged, 1);
assert.equal(stmts.getSession.get("rs-purge-1"), undefined);
});
});
// ── Source-scoped data endpoints ──────────────────────────────────────────────
describe("source scoping across data endpoints", () => {
before(async () => {
// A local session and a remote-tagged session, each with one event.
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "scope-local", cwd: "/local" },
});
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "scope-remote", cwd: "/remote" },
});
stmts.setSessionSource.run("src_scope", "scope-remote");
});
it("facets lists distinct sources (local + tagged)", async () => {
const res = await get("/api/sessions/facets");
assert.ok(res.body.sources.includes("local"));
assert.ok(res.body.sources.includes("src_scope"));
});
it("sessions?sources=local excludes the remote session", async () => {
const res = await get("/api/sessions?sources=local&limit=1000");
const ids = res.body.sessions.map((s) => s.id);
assert.ok(ids.includes("scope-local"));
assert.ok(!ids.includes("scope-remote"));
});
it("sessions?sources=src_scope returns only the remote session", async () => {
const res = await get("/api/sessions?sources=src_scope&limit=1000");
const ids = res.body.sessions.map((s) => s.id);
assert.deepEqual(ids, ["scope-remote"]);
assert.equal(res.body.sessions[0].source, "src_scope");
});
it("sessions with no sources param returns both", async () => {
const res = await get("/api/sessions?limit=1000");
const ids = res.body.sessions.map((s) => s.id);
assert.ok(ids.includes("scope-local") && ids.includes("scope-remote"));
});
it("stats respects the source scope", async () => {
const all = await get("/api/stats");
const local = await get("/api/stats?sources=local");
const remote = await get("/api/stats?sources=src_scope");
assert.ok(all.body.total_sessions >= 2);
assert.equal(
local.body.total_sessions + remote.body.total_sessions <= all.body.total_sessions,
true
);
// The remote scope sees exactly its one tagged session.
assert.equal(remote.body.total_sessions, 1);
});
it("analytics respects the source scope", async () => {
const remote = await get("/api/analytics?sources=src_scope");
assert.equal(remote.body.overview.total_sessions, 1);
});
it("events?sources=src_scope only returns the remote session's events", async () => {
const res = await get("/api/events?sources=src_scope&limit=1000");
assert.ok(res.body.events.every((e) => e.session_id === "scope-remote"));
});
it("agents?sources=local excludes the remote session's agents", async () => {
const res = await get("/api/agents?sources=local");
assert.ok(res.body.agents.every((a) => a.session_id !== "scope-remote"));
});
it("pricing cost respects the source scope (regression: total cost was global)", async () => {
// Equal usage on the local + the remote session; the default claude-opus-4-8
// pricing rule prices it. Before the fix, /pricing/cost ignored `sources`, so
// every scope returned the same global total and the Dashboard cost never
// moved when the scope changed.
stmts.upsertTokenUsage.run("scope-local", "claude-opus-4-8", 1_000_000, 0, 0, 0);
stmts.upsertTokenUsage.run("scope-remote", "claude-opus-4-8", 1_000_000, 0, 0, 0);
const all = await get("/api/pricing/cost");
const local = await get("/api/pricing/cost?sources=local");
const remote = await get("/api/pricing/cost?sources=src_scope");
assert.ok(all.body.total_cost > 0, "some cost recorded across all sources");
assert.ok(local.body.total_cost > 0, "local scope has cost");
assert.ok(remote.body.total_cost > 0, "remote scope has cost");
// The fix: each scope is strictly less than the combined total.
assert.ok(local.body.total_cost < all.body.total_cost, "local scope excludes remote cost");
assert.ok(remote.body.total_cost < all.body.total_cost, "remote scope excludes local cost");
// The two disjoint scopes partition the whole (no other opus-4-8 usage here).
assert.ok(
Math.abs(local.body.total_cost + remote.body.total_cost - all.body.total_cost) < 1e-6,
"local + remote cost sums to the unscoped total"
);
});
});
describe("/api/remote-sources session_count", () => {
it("reports the live number of sessions attributed to each source", async () => {
const c = await post("/api/remote-sources", { label: "Counted", host: "c@h" });
const id = c.body.source.id;
// A freshly-added source has no sessions yet.
assert.equal(c.body.source.session_count ?? 0, 0);
// Tag two sessions to it, then confirm the list reflects the count.
stmts.insertSession.run("rs-count-1", "s", "active", "/x", "claude-opus-4-8", null);
stmts.insertSession.run("rs-count-2", "s", "active", "/y", "claude-opus-4-8", null);
stmts.setSessionSource.run(id, "rs-count-1");
stmts.setSessionSource.run(id, "rs-count-2");
const list = await get("/api/remote-sources");
const row = list.body.sources.find((s) => s.id === id);
assert.equal(row.session_count, 2);
});
});
// ── Remote session status reconciliation ─────────────────────────────────────
// A remote session gets NO live hooks and is excluded from every local liveness/
// staleness sweep, so its active/completed state is driven solely by whether its
// mirrored transcript is still advancing. These exercise that reconciliation
// directly against a staged tree with controlled mtimes.
describe("reconcileRemoteSessionStatus", () => {
const dbModule = require("../db");
const SRC = { id: "src_recon" };
let stageRoot;
before(() => {
stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-recon-"));
});
after(() => {
try {
fs.rmSync(stageRoot, { recursive: true, force: true });
} catch {
/* ignore */
}
});
function stageSession(id, ageMs, contentLine = "{}") {
const proj = path.join(stageRoot, "-Users-x-proj");
fs.mkdirSync(proj, { recursive: true });
const f = path.join(proj, `${id}.jsonl`);
fs.writeFileSync(f, `${contentLine}\n`);
const t = new Date(Date.now() - ageMs);
fs.utimesSync(f, t, t);
return f;
}
it("heals a wrongly-completed remote session whose mirror is still fresh", () => {
stmts.insertSession.run(
"recon-fresh",
"s",
"completed",
"/home/ubuntu/matroid",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run(SRC.id, "recon-fresh");
stmts.insertAgent.run(
"recon-fresh-main",
"recon-fresh",
"Main",
"main",
null,
"completed",
null,
null,
null
);
stageSession("recon-fresh", 1_000); // 1s old → still running
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-fresh").status, "active");
assert.equal(stmts.getSession.get("recon-fresh").ended_at, null, "ended_at cleared on heal");
assert.equal(stmts.getAgent.get("recon-fresh-main").status, "waiting");
});
it("completes an active remote session whose mirror has gone stale", () => {
stmts.insertSession.run(
"recon-stale",
"s",
"active",
"/home/ubuntu/other",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run(SRC.id, "recon-stale");
stmts.insertAgent.run(
"recon-stale-main",
"recon-stale",
"Main",
"main",
null,
"waiting",
null,
null,
null
);
stageSession("recon-stale", 20 * 60 * 1000); // 20 min idle → ended
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-stale").status, "completed");
assert.ok(stmts.getSession.get("recon-stale").ended_at, "ended_at stamped");
assert.equal(stmts.getAgent.get("recon-stale-main").status, "completed");
});
it("completes an active session when mtime is fresh but transcript content is stale", () => {
stmts.insertSession.run(
"recon-touch",
"s",
"active",
"/home/ubuntu/touched",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run(SRC.id, "recon-touch");
stmts.insertAgent.run(
"recon-touch-main",
"recon-touch",
"Main",
"main",
null,
"waiting",
null,
null,
null
);
const stale = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
stageSession("recon-touch", 1_000, `{"timestamp":"${stale}"}`);
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-touch").status, "completed");
assert.equal(stmts.getAgent.get("recon-touch-main").status, "completed");
});
it("never touches a session owned by a different source", () => {
stmts.insertSession.run(
"recon-other",
"s",
"active",
"/home/ubuntu/z",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run("src_different", "recon-other");
stageSession("recon-other", 20 * 60 * 1000); // stale, but not this source's
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-other").status, "active");
});
});
describe("POST /api/remote-sources/sync-all", () => {
it("syncs only enabled sources and isolates per-source outcomes", async () => {
// Disable every existing source so this exercises the wiring without any
// real SSH/rsync shell-out (nothing enabled → nothing to pull).
const { body } = await get("/api/remote-sources");
for (const s of body.sources) {
if (s.enabled) await patch(`/api/remote-sources/${s.id}`, { enabled: false });
}
const res = await post("/api/remote-sources/sync-all");
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.synced, 0);
assert.deepEqual(res.body.results, []);
});
it("does not collide with the /:id/sync route", async () => {
// "sync-all" is a single path segment, so it must not be treated as an :id.
const res = await post("/api/remote-sources/sync-all");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.results));
});
});
+543
View File
@@ -0,0 +1,543 @@
/**
* @file run.test.js
* @description Tests for the Run feature: spawner injection, route
* validation, same-origin guard, cwd suggestions, resume validation,
* envelope storage / attach, and end-to-end handle lifecycle. Uses a fake
* child (PassThrough streams + EventEmitter) so we never invoke the real
* `claude` binary.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const http = require("node:http");
const { PassThrough } = require("node:stream");
const { EventEmitter } = require("node:events");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { createApp } = require("../index");
const runs = require("../lib/run-spawner");
const runRoute = require("../routes/run");
let server;
let BASE;
function fetchJson(p, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) };
let body;
if (opts.body !== undefined) {
body = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json";
headers["Content-Length"] = body.length;
}
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: opts.method || "GET",
headers,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
parsed = raw;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (body) req.write(body);
req.end();
});
}
function makeFakeChild() {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.killed = false;
child.kill = function (sig) {
this.killed = true;
setImmediate(() => this.emit("exit", sig === "SIGTERM" ? 143 : 0, sig || null));
};
return child;
}
describe("/api/run", () => {
before(async () => {
const app = createApp();
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
const port = server.address().port;
BASE = `http://127.0.0.1:${port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
// The SQLite DB lives under TMP and better-sqlite3 holds it open, so on
// Windows rmSync hits EPERM (can't remove a dir with an open handle).
// maxRetries covers transient locks; the try/catch makes the rest
// best-effort — a leftover temp dir must not fail the suite (the OS
// reclaims os.tmpdir()).
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
beforeEach(() => {
runs.__reset();
});
it("rejects cross-origin browser requests", async () => {
const { status, body } = await fetchJson("/api/run", {
headers: { Origin: "http://evil.example.com" },
});
assert.equal(status, 403);
assert.equal(body.error.code, "EBADORIGIN");
});
it("allows requests with no Origin (CLI/curl)", async () => {
const { status, body } = await fetchJson("/api/run");
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
});
it("allows localhost Origin", async () => {
const { status } = await fetchJson("/api/run", {
headers: { Origin: "http://localhost:5173" },
});
assert.equal(status, 200);
});
it("POST / requires prompt", async () => {
const { status, body } = await fetchJson("/api/run", { method: "POST", body: {} });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADPROMPT");
});
it("POST / rejects non-existent cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "/nope/does/not/exist" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("POST / rejects relative cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "./relative" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id returns 404 for unknown id", async () => {
const { status, body } = await fetchJson("/api/run/does-not-exist");
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("DELETE /:id returns 404 for unknown id", async () => {
const { status } = await fetchJson("/api/run/does-not-exist", { method: "DELETE" });
assert.equal(status, 404);
});
it("POST /:id/message rejects empty text", async () => {
const { status, body } = await fetchJson("/api/run/x/message", {
method: "POST",
body: {},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADINPUT");
});
// ── /api/run/cwds suggestions ─────────────────────────────────────
it("GET /cwds returns dashboard + home suggestions with absolute paths", async () => {
const { status, body } = await fetchJson("/api/run/cwds");
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
const kinds = body.items.map((i) => i.kind);
assert.ok(kinds.includes("dashboard"), "dashboard cwd present");
assert.ok(kinds.includes("home"), "home present");
for (const it of body.items) {
assert.equal(typeof it.path, "string");
// path.isAbsolute is platform-aware: "/x" on POSIX, "C:\\x" on Windows.
assert.ok(path.isAbsolute(it.path), "absolute path");
assert.equal(typeof it.label, "string");
}
});
// ── /api/run/binary probe ─────────────────────────────────────────
it("GET /binary returns shape { found, path }", async () => {
const { status, body } = await fetchJson("/api/run/binary");
assert.equal(status, 200);
assert.equal(typeof body.found, "boolean");
if (body.found) assert.equal(typeof body.path, "string");
});
// ── Resume validation ─────────────────────────────────────────────
it("POST / rejects bad resumeSessionId format", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", resumeSessionId: "x" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADSESSION");
});
it("POST / rejects unknown effort level", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", effort: "ludicrous" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADEFFORT");
});
it("POST / rejects resumeSessionId with headless mode", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: {
prompt: "hi",
mode: "headless",
resumeSessionId: "deadbeef-cafe-1234-5678-feedfacefeed",
},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADMODE");
});
// ── HTTP GET /:id?envelopes=1 (attach payload) ────────────────────
it("GET /:id?envelopes=1 returns the in-memory envelope log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"sX"}\n`);
await new Promise((r) => setImmediate(r));
const { status, body } = await fetchJson(`/api/run/${handle.id}?envelopes=1`);
assert.equal(status, 200);
assert.ok(Array.isArray(body.envelopes));
assert.equal(body.envelopes.length, 1);
assert.equal(body.envelopes[0].type, "system");
});
it("GET /files returns paths matching q, skipping node_modules", async () => {
// Build a tiny fixture under tmp so the test is hermetic.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "run-files-"));
fs.mkdirSync(path.join(tmp, "src"));
fs.mkdirSync(path.join(tmp, "node_modules", "leftover-pkg"), { recursive: true });
fs.writeFileSync(path.join(tmp, "README.md"), "x");
fs.writeFileSync(path.join(tmp, "src", "index.ts"), "x");
fs.writeFileSync(path.join(tmp, "node_modules", "leftover-pkg", "x.js"), "x");
try {
const { status, body } = await fetchJson(
`/api/run/files?cwd=${encodeURIComponent(tmp)}&q=index`
);
assert.equal(status, 200);
assert.deepEqual(body.items.sort(), ["src/index.ts"]);
// No q → returns top-level files (excluding node_modules)
const all = await fetchJson(`/api/run/files?cwd=${encodeURIComponent(tmp)}`);
assert.ok(all.body.items.includes("README.md"));
assert.ok(!all.body.items.some((p) => p.startsWith("node_modules")));
} finally {
try {
fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup (Windows may hold a handle) */
}
}
});
it("GET /files rejects missing/invalid cwd", async () => {
const { status, body } = await fetchJson("/api/run/files?cwd=/does/not/exist");
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id without ?envelopes returns metadata only", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
const { body } = await fetchJson(`/api/run/${handle.id}`);
assert.equal(body.envelopes, undefined);
assert.equal(body.envelopeCount, 1);
});
});
describe("run-spawner unit", () => {
beforeEach(() => {
runs.__reset();
});
it("injected child parses stream-json envelopes and broadcasts", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(
`{"type":"system","subtype":"init","session_id":"sess-abc","model":"opus"}\n`
);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
// Allow the line parser to flush
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "running");
assert.equal(live.sessionId, "sess-abc");
assert.equal(live.envelopeCount, 2);
});
it("sendInput writes a stream-json envelope to stdin", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
// Force into running state via a parsed envelope first
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
await new Promise((r) => setImmediate(r));
const chunks = [];
fake.stdin.on("data", (c) => chunks.push(c.toString()));
runs.sendInput(handle.id, "follow-up");
await new Promise((r) => setImmediate(r));
const written = chunks.join("");
const lines = written.trim().split("\n");
const obj = JSON.parse(lines[lines.length - 1]);
assert.equal(obj.type, "user");
assert.equal(obj.message.content, "follow-up");
});
it("sendInput rejects on headless handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "headless" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /only conversation mode/);
});
it("kill marks handle as killed and emits exit", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
runs.killRun(handle.id);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "killed");
});
it("escalates to SIGKILL when SIGTERM was delivered but the child has not exited", async () => {
const fake = makeFakeChild();
const signals = [];
fake.kill = function (signal) {
this.killed = true;
signals.push(signal);
if (signal === "SIGKILL") setImmediate(() => this.emit("exit", 137, signal));
return true;
};
const handle = runs.__injectChildForTest({ child: fake });
const originalSetTimeout = global.setTimeout;
global.setTimeout = (callback, delay, ...args) => {
if (delay === 5000) {
callback(...args);
return { unref: () => {} };
}
return originalSetTimeout(callback, delay, ...args);
};
try {
runs.killRun(handle.id);
} finally {
global.setTimeout = originalSetTimeout;
}
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
assert.notEqual(runs.getRun(handle.id).actualExitedAt, null);
});
it("exit with code 0 marks completed", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "completed");
assert.equal(live.exitCode, 0);
});
it("exit with non-zero code marks error", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 1, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "error");
});
it("malformed JSON lines do not crash; go to stderr buffer", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.stdout.write("not valid json\n");
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.match(live.stderrTail, /parse-error/);
});
it("listRuns returns handles sorted newest first", async () => {
const a = runs.__injectChildForTest({ child: makeFakeChild() });
await new Promise((r) => setTimeout(r, 5));
const b = runs.__injectChildForTest({ child: makeFakeChild() });
const list = runs.listRuns();
assert.equal(list[0].id, b.id);
assert.equal(list[1].id, a.id);
});
});
describe("sameOriginGuard helper", () => {
it("loopback Origin passes", () => {
const next = () => "OK";
const res = {};
const result = runRoute.__sameOriginGuard(
{ headers: { origin: "http://127.0.0.1:4820" } },
res,
next
);
assert.equal(result, "OK");
});
it("missing Origin passes (CLI use case)", () => {
const next = () => "OK";
const result = runRoute.__sameOriginGuard({ headers: {} }, {}, next);
assert.equal(result, "OK");
});
it("non-loopback Origin is blocked", () => {
let captured = null;
const res = {
status(code) {
captured = { code };
return this;
},
json(body) {
captured.body = body;
return this;
},
};
runRoute.__sameOriginGuard({ headers: { origin: "http://attacker.com" } }, res, () => {});
assert.equal(captured.code, 403);
assert.equal(captured.body.error.code, "EBADORIGIN");
});
});
describe("run-spawner extras", () => {
beforeEach(() => {
runs.__reset();
});
it("getRun (no opts) returns metadata only — no envelopes field", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.envelopeCount, 2);
assert.equal(live.envelopes, undefined);
});
it("getRun({includeEnvelopes:true}) returns the in-memory log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"x"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.ok(Array.isArray(live.envelopes));
assert.equal(live.envelopes.length, 2);
assert.equal(live.envelopes[0].type, "system");
});
it("listRuns surfaces resumeSessionId (null for fresh)", async () => {
runs.__injectChildForTest({ child: makeFakeChild(), mode: "conversation" });
const list = runs.listRuns();
assert.equal(list.length, 1);
assert.equal(list[0].resumeSessionId, null);
});
it("killRun is idempotent on already-completed handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.equal(runs.getRun(handle.id).status, "completed");
// Second kill on a completed handle should be a safe no-op (returns true).
assert.equal(runs.killRun(handle.id), true);
assert.equal(runs.getRun(handle.id).status, "completed");
});
it("killRun returns false for an unknown id", () => {
assert.equal(runs.killRun("does-not-exist"), false);
});
it("sendInput throws ENOTFOUND for unknown id", () => {
assert.throws(() => runs.sendInput("nope", "hi"), /not found/);
});
it("sendInput throws ENOTRUNNING when handle has already exited", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /run is (completed|killed|error)/);
});
it("sendInput rejects empty text", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, ""), /text is required/);
});
it("envelope log is capped at 500 entries", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
let line = "";
for (let i = 0; i < 600; i++) line += `{"type":"assistant","i":${i}}\n`;
fake.stdout.write(line);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.equal(live.envelopeCount, 600);
assert.equal(live.envelopes.length, 500);
// The cap drops the OLDEST entries — last entry should be the latest.
assert.equal(live.envelopes[live.envelopes.length - 1].i, 599);
});
it("getMaxConcurrent respects RUN_MAX_CONCURRENT env override", () => {
const orig = process.env.RUN_MAX_CONCURRENT;
try {
process.env.RUN_MAX_CONCURRENT = "7";
assert.equal(runs.getMaxConcurrent(), 7);
process.env.RUN_MAX_CONCURRENT = "garbage";
assert.ok(runs.getMaxConcurrent() >= 1, "falls back to default on non-numeric");
delete process.env.RUN_MAX_CONCURRENT;
assert.ok(runs.getMaxConcurrent() >= 1);
} finally {
if (orig != null) process.env.RUN_MAX_CONCURRENT = orig;
else delete process.env.RUN_MAX_CONCURRENT;
}
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* @file security.test.js
* @description Tests the network-exposure hardening (GHSA-gr74-4xfh-6jw9):
* loopback-by-default bind, Host-header allowlist (anti DNS-rebinding),
* loopback-only CORS, and the optional bearer-token gate on /api/* + WebSocket.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const sec = require("../lib/security");
const ENV_KEYS = ["DASHBOARD_HOST", "DASHBOARD_ALLOWED_HOSTS", "DASHBOARD_TOKEN"];
afterEach(() => {
for (const k of ENV_KEYS) delete process.env[k];
});
function mockRes() {
return {
statusCode: 0,
body: null,
status(c) {
this.statusCode = c;
return this;
},
json(b) {
this.body = b;
return this;
},
};
}
describe("resolveHost", () => {
it("defaults to loopback (127.0.0.1)", () => {
assert.equal(sec.resolveHost(), "127.0.0.1");
});
it("honors an explicit DASHBOARD_HOST opt-in", () => {
process.env.DASHBOARD_HOST = "0.0.0.0";
assert.equal(sec.resolveHost(), "0.0.0.0");
assert.equal(sec.isLoopbackHostname("0.0.0.0"), true); // treated as loopback-equiv for Host checks
});
});
describe("Host allowlist (DNS-rebinding defense)", () => {
it("allows loopback Host headers", () => {
assert.equal(sec.isHostAllowed("localhost:4820"), true);
assert.equal(sec.isHostAllowed("127.0.0.1:4820"), true);
assert.equal(sec.isHostAllowed("[::1]:4820"), true);
assert.equal(sec.isHostAllowed(""), true); // missing Host (HTTP/1.0 / local tooling)
});
it("rejects a rebound attacker Host", () => {
assert.equal(sec.isHostAllowed("evil.example"), false);
assert.equal(sec.isHostAllowed("attacker.example:4820"), false);
});
it("permits operator-allowlisted hostnames", () => {
process.env.DASHBOARD_ALLOWED_HOSTS = "dash.internal, 192.168.1.50";
assert.equal(sec.isHostAllowed("dash.internal:4820"), true);
assert.equal(sec.isHostAllowed("192.168.1.50:4820"), true);
assert.equal(sec.isHostAllowed("evil.example"), false);
});
it("hostGuard middleware 403s a disallowed Host", () => {
const res = mockRes();
let nexted = false;
sec.hostGuard({ headers: { host: "evil.example" } }, res, () => (nexted = true));
assert.equal(nexted, false);
assert.equal(res.statusCode, 403);
assert.equal(res.body.error.code, "EBADHOST");
});
it("hostGuard middleware allows loopback", () => {
let nexted = false;
sec.hostGuard({ headers: { host: "localhost:4820" } }, mockRes(), () => (nexted = true));
assert.equal(nexted, true);
});
});
describe("CORS", () => {
const allowed = (origin) =>
new Promise((resolve) => sec.corsOptions().origin(origin, (_e, ok) => resolve(ok)));
it("allows same-origin / no-Origin (curl, the server's own client)", async () => {
assert.equal(await allowed(undefined), true);
});
it("allows loopback origins", async () => {
assert.equal(await allowed("http://localhost:5173"), true);
assert.equal(await allowed("http://127.0.0.1:4820"), true);
});
it("refuses cross-origin pages", async () => {
assert.equal(await allowed("https://evil.example"), false);
});
});
describe("token gate (optional, opt-in)", () => {
it("is a no-op when DASHBOARD_TOKEN is unset (default)", () => {
let nexted = false;
sec.tokenGuard({ path: "/stats", headers: {}, query: {} }, mockRes(), () => (nexted = true));
assert.equal(nexted, true);
});
it("rejects a missing/invalid token when configured", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
const res = mockRes();
let nexted = false;
sec.tokenGuard({ path: "/stats", headers: {}, query: {} }, res, () => (nexted = true));
assert.equal(nexted, false);
assert.equal(res.statusCode, 401);
assert.equal(res.body.error.code, "EUNAUTHORIZED");
const res2 = mockRes();
sec.tokenGuard(
{ path: "/stats", headers: { "x-dashboard-token": "wrong" }, query: {} },
res2,
() => {}
);
assert.equal(res2.statusCode, 401);
});
it("accepts a correct token via header, bearer, or query", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
const ok = (req) => {
let nexted = false;
sec.tokenGuard(req, mockRes(), () => (nexted = true));
return nexted;
};
assert.equal(
ok({ path: "/stats", headers: { "x-dashboard-token": "s3cret" }, query: {} }),
true
);
assert.equal(
ok({ path: "/stats", headers: { authorization: "Bearer s3cret" }, query: {} }),
true
);
assert.equal(ok({ path: "/stats", headers: {}, query: { token: "s3cret" } }), true);
});
it("exempts health, docs, and local hook ingestion even when a token is set", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
const ok = (path) => {
let nexted = false;
sec.tokenGuard({ path, headers: {}, query: {} }, mockRes(), () => (nexted = true));
return nexted;
};
assert.equal(ok("/health"), true);
assert.equal(ok("/openapi.json"), true);
assert.equal(ok("/hooks/event"), true);
assert.equal(ok("/sessions/abc"), false); // still gated
});
});
describe("WebSocket auth", () => {
it("allows any upgrade when no token is configured", () => {
assert.equal(sec.isWebSocketAuthorized({ url: "/ws", headers: {} }), true);
});
it("requires a matching ?token= when configured", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
assert.equal(sec.isWebSocketAuthorized({ url: "/ws?token=s3cret", headers: {} }), true);
assert.equal(sec.isWebSocketAuthorized({ url: "/ws?token=nope", headers: {} }), false);
assert.equal(sec.isWebSocketAuthorized({ url: "/ws", headers: {} }), false);
assert.equal(
sec.isWebSocketAuthorized({ url: "/ws", headers: { "x-dashboard-token": "s3cret" } }),
true
);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* @file Tests for multi-server discovery and hook-ingest deduplication by
* SQLite data directory (`server/lib/server-info.js`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
const STAMP = `server-info-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR_A = path.join(TMP, "data-a");
const DATA_DIR_B = path.join(TMP, "data-b");
function freshModule(dataDir) {
delete require.cache[require.resolve("../lib/server-info")];
delete require.cache[require.resolve("../lib/claude-home")];
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = dataDir;
return require("../lib/server-info");
}
function writeDiscovery(servers) {
const infoPath = path.join(CLAUDE_HOME, ".agent-dashboard.json");
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const recent = servers.reduce((a, b) =>
Date.parse(b.startedAt) > Date.parse(a.startedAt) ? b : a
);
fs.writeFileSync(
infoPath,
JSON.stringify(
{ port: recent.port, pid: recent.pid, startedAt: recent.startedAt, servers },
null,
2
)
);
}
describe("server-info hook ingest deduplication", () => {
beforeEach(() => {
fs.rmSync(TMP, { recursive: true, force: true });
fs.mkdirSync(DATA_DIR_A, { recursive: true });
fs.mkdirSync(DATA_DIR_B, { recursive: true });
});
afterEach(() => {
delete process.env.CLAUDE_DASHBOARD_PORT;
fs.rmSync(TMP, { recursive: true, force: true });
delete require.cache[require.resolve("../lib/server-info")];
delete require.cache[require.resolve("../lib/claude-home")];
});
it("dedupes hook targets when two live servers share one dataDir", () => {
const mod = freshModule(DATA_DIR_A);
writeDiscovery([
{
port: 4820,
pid: process.pid,
startedAt: "2026-01-01T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
{
port: 4821,
pid: process.pid,
startedAt: "2026-01-02T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [4820]);
assert.deepEqual(mod.resolveAllDashboardPorts(), [4820, 4821]);
});
it("fans out when live servers use different data directories", () => {
const mod = freshModule(DATA_DIR_A);
writeDiscovery([
{
port: 4820,
pid: process.pid,
startedAt: "2026-01-01T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
{
port: 4900,
pid: process.pid,
startedAt: "2026-01-02T00:00:00.000Z",
dataDir: DATA_DIR_B,
},
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [4820, 4900]);
});
it("treats legacy entries without dataDir as unique per port", () => {
const mod = freshModule(DATA_DIR_A);
writeDiscovery([
{ port: 4820, pid: process.pid, startedAt: "2026-01-01T00:00:00.000Z" },
{ port: 4821, pid: process.pid, startedAt: "2026-01-02T00:00:00.000Z" },
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [4820, 4821]);
});
it("records dataDir when writing server info", () => {
const mod = freshModule(DATA_DIR_A);
mod.writeServerInfo(4999);
const raw = JSON.parse(fs.readFileSync(mod.getServerInfoPath(), "utf8"));
const entry = raw.servers.find((s) => s.port === 4999);
assert.ok(entry);
assert.equal(mod.normalizeDataDir(entry.dataDir), mod.normalizeDataDir(DATA_DIR_A));
});
it("honors CLAUDE_DASHBOARD_PORT for hook ingest", () => {
const mod = freshModule(DATA_DIR_A);
process.env.CLAUDE_DASHBOARD_PORT = "7777";
writeDiscovery([
{
port: 4820,
pid: process.pid,
startedAt: "2026-01-01T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [7777]);
});
});
+395
View File
@@ -0,0 +1,395 @@
/**
* @file Tests for the watchdog's process-liveness reap. A session whose
* SessionEnd hook was lost (dashboard down when the user quit with Ctrl+C)
* must be completed once no running `claude` CLI process has the session's
* cwd — instead of sitting in Waiting until the 3 h stale sweep. Covers:
* - the claude-command matcher (`isClaudeCommand`),
* - probeLiveCwds shape + env escape hatch,
* - the reap itself: dead session → completed (agents completed, awaiting
* cleared, synthetic SessionEnd event), live session → untouched,
* - all fail-safe guards (probe unavailable, fresh activity, no cwd),
* - hook reactivation after a (hypothetical) false completion.
* The probe is stubbed by swapping `liveness.probeLiveCwds` on the shared
* module object — routes/hooks.js looks the function up at call time.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = 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 STAMP = `liveness-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
// Keep the REAL probe inert for any watchdog interval tick that fires while
// this suite runs — stubbed probes below bypass this env check entirely.
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const liveness = require("../lib/session-liveness");
const hooksRouter = require("../routes/hooks");
const realProbe = liveness.probeLiveCwds;
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const PROJECTS = path.join(CLAUDE_HOME, "projects");
function writeTranscript(cwd, sessionId, lines) {
const p = path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, lines.map((o) => JSON.stringify(o)).join("\n") + "\n");
return p;
}
/** Backdate a session row + its transcript so the idle gate passes. */
function backdate(sessionId, tpath, ageMs = 10 * 60 * 1000) {
const old = new Date(Date.now() - ageMs);
db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(old.toISOString(), sessionId);
if (tpath) fs.utimesSync(tpath, old, old);
}
function req(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b || "{}");
} catch {
parsed = b;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
r.on("error", reject);
if (payload) r.write(payload);
r.end();
});
}
/** Create a session + transcript via a real hook event, then backdate it. */
async function seedSession(sid, cwd, { old = true } = {}) {
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hello" } },
]);
const res = await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
if (old) backdate(sid, tpath);
return tpath;
}
let server;
let BASE;
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
liveness.probeLiveCwds = realProbe;
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore */
}
});
beforeEach(() => {
liveness.probeLiveCwds = realProbe;
});
describe("isClaudeCommand — claude CLI process matcher", () => {
const yes = [
"claude",
"claude --dangerously-skip-permissions",
"/usr/local/bin/claude -p hello",
"/Users/x/.local/bin/claude --resume abc",
"node /Users/x/.nvm/versions/node/v22.0.0/bin/claude",
"bun /opt/homebrew/bin/claude --model opus",
];
const no = [
"claude-mem --daemon",
"/Applications/Claude.app/Contents/MacOS/Claude",
"grep claude",
"node /app/server/index.js",
"node /Users/x/claude-dashboard/index.js",
"tail -f claude.log",
"",
];
for (const cmd of yes) {
it(`matches: ${cmd || "(empty)"}`, () => assert.equal(liveness.isClaudeCommand(cmd), true));
}
for (const cmd of no) {
it(`rejects: ${cmd || "(empty)"}`, () => assert.equal(liveness.isClaudeCommand(cmd), false));
}
});
describe("probeLiveCwds — probe availability", () => {
it("returns a well-formed result without throwing", () => {
delete process.env.DASHBOARD_LIVENESS_PROBE;
try {
const r = realProbe();
assert.equal(typeof r.available, "boolean");
assert.ok(r.cwds instanceof Set);
} finally {
process.env.DASHBOARD_LIVENESS_PROBE = "0";
}
});
it("is disabled by DASHBOARD_LIVENESS_PROBE=0", () => {
const r = realProbe(); // env is "0" for this whole suite
assert.equal(r.available, false);
assert.equal(r.cwds.size, 0);
});
it("is disabled inside a container (CCAM_FORCE_CONTAINER)", () => {
delete process.env.DASHBOARD_LIVENESS_PROBE;
process.env.CCAM_FORCE_CONTAINER = "1";
try {
assert.equal(realProbe().available, false);
} finally {
delete process.env.CCAM_FORCE_CONTAINER;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
}
});
});
describe("watchdog liveness reap", () => {
it("completes an idle active session whose cwd has no live claude process", async () => {
const sid = "dead0000-0000-0000-0000-000000000001";
const cwd = "/tmp/liveness-dead";
await seedSession(sid, cwd);
assert.equal(stmts.getSession.get(sid).status, "active");
assert.ok(stmts.getSession.get(sid).awaiting_input_since, "seeded as Waiting");
assert.equal(stmts.getSession.get(sid).awaiting_reason, "stop", "seeded via Stop hook");
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
const sess = stmts.getSession.get(sid);
assert.equal(sess.status, "completed");
assert.ok(sess.ended_at, "ended_at stamped");
assert.equal(sess.awaiting_input_since, null, "Waiting flag cleared");
assert.equal(sess.awaiting_reason, null, "awaiting_reason cleared alongside the flag");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.status, "completed");
assert.equal(main.awaiting_input_since, null);
assert.equal(main.awaiting_reason, null);
const evt = db
.prepare(
"SELECT * FROM events WHERE session_id = ? AND event_type = 'SessionEnd' ORDER BY created_at DESC LIMIT 1"
)
.get(sid);
assert.ok(evt, "synthetic SessionEnd event recorded");
assert.match(evt.summary, /no running claude process/);
assert.equal(JSON.parse(evt.data).source, "liveness-probe");
});
it("leaves a session alone when a claude process runs in its cwd", async () => {
const sid = "live0000-0000-0000-0000-000000000002";
const cwd = "/tmp/liveness-alive";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set([path.resolve(cwd)]) });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("spares a household-hook session with a non-POSIX (Windows) cwd, even with zero live local processes", async () => {
// A session forwarded from a Windows machine reports cwd in that origin
// machine's own syntax. path.resolve() on this (POSIX) host doesn't
// recognize it as absolute, so it can never match anything the local
// /proc or lsof scan produces — that mismatch must not be treated as
// "process is dead".
const sid = "wind0000-0000-0000-0000-00000000000c";
const cwd = "D:\\Git\\ai-deck";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("still reaps a genuinely local (POSIX) cwd not in probe.cwds — regression guard", async () => {
const sid = "posx0000-0000-0000-0000-00000000000d";
const cwd = "/home/claude/projects/some-repo";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "completed");
});
it("does nothing when the probe is unavailable", async () => {
const sid = "unav0000-0000-0000-0000-000000000003";
const cwd = "/tmp/liveness-unavailable";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: false, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("spares sessions with recent activity (idle gate)", async () => {
const sid = "frsh0000-0000-0000-0000-000000000004";
const cwd = "/tmp/liveness-fresh";
await seedSession(sid, cwd, { old: false }); // updated_at + mtime are NOW
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("reaps a just-imported dead session: fresh updated_at, old transcript mtime", async () => {
// The boot shape: the startup sync imports a transcript last touched when
// the user quit (old mtime) but stamps updated_at = NOW. The gate must key
// on the transcript mtime, or the dead session sits in Waiting for a full
// extra LIVENESS_IDLE_SECONDS after every dashboard start.
const sid = "boot0000-0000-0000-0000-000000000009";
const cwd = "/tmp/liveness-boot-import";
const tpath = await seedSession(sid, cwd, { old: false });
const old = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(tpath, old, old); // transcript stopped moving 10 min ago
// updated_at stays fresh (the import just wrote the row).
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "completed");
});
it("boot pass (ignoreIdleGate) reaps a session quit seconds before launch", async () => {
// The reported flow: quit the session, IMMEDIATELY start the dashboard.
// Transcript mtime is only seconds old, so the gated reap would wait a
// full LIVENESS_IDLE_SECONDS — the boot passes must skip the gate and
// trust the probe alone.
const sid = "qikq0000-0000-0000-0000-00000000000a";
const cwd = "/tmp/liveness-quick-quit";
await seedSession(sid, cwd, { old: false }); // mtime + updated_at are NOW
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap({ ignoreIdleGate: true });
assert.equal(stmts.getSession.get(sid).status, "completed");
});
it("boot pass still spares a session whose claude process is alive", async () => {
const sid = "qikl0000-0000-0000-0000-00000000000b";
const cwd = "/tmp/liveness-quick-live";
await seedSession(sid, cwd, { old: false });
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set([path.resolve(cwd)]) });
hooksRouter.livenessReap({ ignoreIdleGate: true });
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("skips sessions without a cwd", async () => {
const sid = "nocw0000-0000-0000-0000-000000000005";
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid },
});
backdate(sid, null);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("spares a remote-source session with a POSIX cwd and no live local process", async () => {
// A Remote Data Source session (source = a remote id) legitimately reports a
// POSIX-absolute cwd on another machine (e.g. /home/ubuntu/matroid) that no
// local claude process owns. The posix-cwd guard can't catch it, so the
// source guard must: local process liveness says nothing about a remote box.
const sid = "rmt00000-0000-0000-0000-00000000000e";
const cwd = "/home/ubuntu/matroid";
const tpath = await seedSession(sid, cwd);
db.prepare("UPDATE sessions SET source = ? WHERE id = ?").run("src_remotebox", sid);
backdate(sid, tpath);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active", "remote session must not be reaped");
});
it("does not touch error sessions", async () => {
const sid = "errr0000-0000-0000-0000-000000000006";
const cwd = "/tmp/liveness-error";
const tpath = await seedSession(sid, cwd);
stmts.updateSession.run(null, "error", null, null, sid);
backdate(sid, tpath);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "error");
});
it("a reaped session reactivates on the next hook event (self-heal)", async () => {
const sid = "heal0000-0000-0000-0000-000000000007";
const cwd = "/tmp/liveness-heal";
const tpath = await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "completed");
const res = await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
assert.equal(stmts.getSession.get(sid).status, "active");
assert.equal(stmts.getAgent.get(`${sid}-main`).status, "working");
});
it("full watchdogCheck runs the reap end-to-end", async () => {
const sid = "wdog0000-0000-0000-0000-000000000008";
const cwd = "/tmp/liveness-watchdog";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.watchdogCheck();
assert.equal(stmts.getSession.get(sid).status, "completed");
});
});
@@ -0,0 +1,416 @@
/**
* @file Tests for human-readable session names and the transcript rename
* marker. Covers:
* - TranscriptCache surfacing the latest custom-title / ai-title.
* - The hook ingestor syncing sessions.name from the transcript title, with
* custom-title winning and ai-title only filling placeholder names.
* - GET /:id/transcript surfacing custom-title (/rename) as a synthetic
* `session_event` marker, deduped, with ai-title excluded.
* - GET /:id/transcript surfacing mid-turn queued user messages
* (attachment/queued_command) as user rows, with queue-operation
* bookkeeping and other attachment subtypes dropped.
* Uses Node's built-in test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR.
* @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 STAMP = `sess-name-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const TranscriptCache = require("../lib/transcript-cache");
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const PROJECTS = path.join(CLAUDE_HOME, "projects");
function jsonl(lines) {
return lines.map((o) => JSON.stringify(o)).join("\n") + "\n";
}
function writeTranscript(cwd, sessionId, lines) {
const p = path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, jsonl(lines));
return p;
}
function req(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b || "{}");
} catch {
parsed = b;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
r.on("error", reject);
if (payload) r.write(payload);
r.end();
});
}
let server;
let BASE;
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("TranscriptCache — title extraction", () => {
it("returns the latest custom-title and ai-title (last value wins)", () => {
const cwd = "/tmp/cam-name-cache";
const sid = "cache-titles";
const p = writeTranscript(cwd, sid, [
{ type: "ai-title", aiTitle: "Auto one", sessionId: sid },
{ type: "user", message: { role: "user", content: "hi" } },
{ type: "ai-title", aiTitle: "Auto two", sessionId: sid },
{ type: "custom-title", customTitle: "my-feature", sessionId: sid },
]);
const r = new TranscriptCache().extract(p);
assert.equal(r.customTitle, "my-feature");
assert.equal(r.aiTitle, "Auto two");
});
it("returns a result for a transcript that has ONLY a title line", () => {
const cwd = "/tmp/cam-name-only";
const sid = "only-title";
const p = writeTranscript(cwd, sid, [
{ type: "custom-title", customTitle: "title-only", sessionId: sid },
]);
const r = new TranscriptCache().extract(p);
assert.ok(r, "result should not be null");
assert.equal(r.customTitle, "title-only");
});
});
describe("hook ingestor — sessions.name sync from transcript", () => {
it("sets the name to the custom-title on the next hook event", async () => {
const cwd = "/tmp/cam-name-custom";
const sid = "11111111-2222-3333-4444-555555555555";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hello" } },
{ type: "custom-title", customTitle: "auth-refactor", sessionId: sid },
]);
const res = await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
const row = stmts.getSession.get(sid);
assert.equal(row.name, "auth-refactor");
});
it("fills a placeholder name with the ai-title when there is no custom-title", async () => {
const cwd = "/tmp/cam-name-ai";
const sid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hello" } },
{ type: "ai-title", aiTitle: "Investigate flaky test", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const row = stmts.getSession.get(sid);
assert.equal(row.name, "Investigate flaky test");
});
it("does not let an ai-title clobber a user-chosen name, but a custom-title does", async () => {
const cwd = "/tmp/cam-name-precedence";
const sid = "99999999-8888-7777-6666-555555555555";
// Seed a real, user-chosen name via the sessions API.
await req("POST", "/api/sessions", { id: sid, name: "keep-me", cwd });
// An ai-title must NOT overwrite the user-chosen name.
let tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "x" } },
{ type: "ai-title", aiTitle: "Auto generated", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "keep-me");
// But an explicit /rename (custom-title) always wins.
tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "x" } },
{ type: "ai-title", aiTitle: "Auto generated", sessionId: sid },
{ type: "custom-title", customTitle: "renamed-explicitly", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "renamed-explicitly");
});
});
describe("GET /:id/transcript — rename markers", () => {
it("surfaces custom-title as a deduped session_event and excludes ai-title", async () => {
const cwd = "/tmp/cam-rename-marker";
const sid = "deadbeef-0000-1111-2222-333333333333";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "first" } },
{ type: "ai-title", aiTitle: "noise 1", sessionId: sid },
{ type: "custom-title", customTitle: "feature-x", sessionId: sid },
{ type: "ai-title", aiTitle: "noise 2", sessionId: sid },
// Duplicate custom-title with the SAME value — must be deduped away.
{ type: "custom-title", customTitle: "feature-x", sessionId: sid },
{
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "ok" }] },
},
{ type: "custom-title", customTitle: "feature-y", sessionId: sid },
]);
// Register the session so the endpoint doesn't 404.
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const events = res.body.messages.filter((m) => m.type === "session_event");
const titles = events.map((e) => e.title);
// feature-x once (dupe collapsed), then feature-y. No ai-title leaks in.
assert.deepEqual(titles, ["feature-x", "feature-y"]);
assert.ok(
events.every((e) => e.event_kind === "rename"),
"every marker is a rename"
);
assert.ok(
!res.body.messages.some((m) => m.type === "session_event" && /noise/.test(m.title || "")),
"ai-title values are never surfaced in the transcript stream"
);
});
});
describe("GET /:id/transcript — local slash-command output (system/local_command)", () => {
it("surfaces /color command + its stdout, skips empty + noise system lines", async () => {
const cwd = "/tmp/cam-local-cmd";
const sid = "c010rrrr-1111-2222-3333-444444444444";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hi" } },
// /color, current Claude Code shape: command + output as system/local_command
{
type: "system",
subtype: "local_command",
content:
"<command-name>/color</command-name>\n <command-message>color</command-message>\n <command-args></command-args>",
sessionId: sid,
},
{ type: "agent-color", agentColor: "cyan", sessionId: sid },
{
type: "system",
subtype: "local_command",
content: "<local-command-stdout>Session color set to: cyan</local-command-stdout>",
sessionId: sid,
},
// /clear writes a content-less local_command line — must NOT become a row
{ type: "system", subtype: "local_command", content: "", sessionId: sid },
// unrelated system subtype — pure noise, must be dropped
{ type: "system", subtype: "turn_duration", durationMs: 1200, sessionId: sid },
]);
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const texts = res.body.messages.flatMap((m) =>
m.content.filter((c) => c.type === "text").map((c) => c.text)
);
assert.ok(
texts.some((tx) => tx.includes("<command-name>/color</command-name>")),
"the /color command invocation is surfaced"
);
assert.ok(
texts.some((tx) => tx.includes("Session color set to: cyan")),
"the /color stdout is surfaced"
);
// Empty local_command (/clear) and turn_duration noise produce no message.
assert.ok(
!texts.some((tx) => /turn_duration|durationMs/.test(tx)),
"non-local_command system subtypes are not surfaced"
);
// Exactly two surfaced rows from the system lines (command + stdout), plus
// the one real user message — the empty + noise lines add nothing.
assert.equal(res.body.messages.length, 3);
});
});
describe("GET /:id/transcript — mid-turn queued user messages (attachment/queued_command)", () => {
it("surfaces a queued_command attachment as a user message; drops queue-operation and other attachments", async () => {
const cwd = "/tmp/cam-queued-msg";
const sid = "aaaa1111-2222-3333-4444-555566667777";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "start the docs sweep" } },
{
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "working on it" }] },
},
// A message typed while Claude was mid-turn is journaled as queue-operation
// bookkeeping plus a queued_command attachment — there is NO user line.
{
type: "queue-operation",
operation: "enqueue",
content: "doc sweep too pls",
sessionId: sid,
},
{
type: "queue-operation",
operation: "remove",
content: "doc sweep too pls",
sessionId: sid,
},
{
type: "attachment",
attachment: {
type: "queued_command",
prompt: "doc sweep too pls",
commandMode: "prompt",
origin: { kind: "human" },
timestamp: "2026-07-16T02:40:45.596Z",
},
timestamp: "2026-07-16T02:40:45.596Z",
sessionId: sid,
},
// Non-queued_command attachments are harness noise — must NOT surface.
{
type: "attachment",
attachment: { type: "task_reminder", content: "reminder noise" },
sessionId: sid,
},
// A content-less queued_command must not become an empty row either.
{
type: "attachment",
attachment: { type: "queued_command", prompt: " ", origin: { kind: "human" } },
sessionId: sid,
},
]);
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const queued = res.body.messages.filter((m) =>
m.content.some((c) => c.type === "text" && c.text === "doc sweep too pls")
);
assert.equal(queued.length, 1, "the mid-turn message surfaces exactly once");
assert.equal(queued[0].type, "user");
assert.equal(
queued[0].sender,
"user",
"a human-typed queued message is attributed to the user"
);
assert.equal(queued[0].timestamp, "2026-07-16T02:40:45.596Z");
assert.ok(
!res.body.messages.some((m) =>
m.content.some((c) => c.type === "text" && /reminder noise/.test(c.text || ""))
),
"non-queued_command attachments stay hidden"
);
// start + working + the queued message; queue-operation lines, the noise
// attachment, and the blank prompt add nothing.
assert.equal(res.body.messages.length, 3);
});
it("attributes harness-injected queued_command lines (task-notifications) to system, not the user", async () => {
const cwd = "/tmp/cam-queued-sys";
const sid = "bbbb1111-2222-3333-4444-555566667777";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "kick off the agents" } },
// Background-agent task-notification delivered through the SAME queue as
// typed messages — real shape: attachment has NO origin field at all.
{
type: "attachment",
attachment: {
type: "queued_command",
prompt:
"<task-notification>\n<task-id>a30201bfc90e18271</task-id>\n<status>completed</status>\n</task-notification>",
commandMode: "prompt",
timestamp: "2026-07-16T02:47:36.000Z",
},
timestamp: "2026-07-16T02:47:36.000Z",
sessionId: sid,
},
// Banner-prefixed variant must also be system.
{
type: "attachment",
attachment: {
type: "queued_command",
prompt: "[SYSTEM NOTIFICATION - NOT USER INPUT]\nautomated background-task event",
commandMode: "prompt",
},
sessionId: sid,
},
// Explicit non-human origin → system too.
{
type: "attachment",
attachment: {
type: "queued_command",
prompt: "sdk enqueued follow-up",
origin: { kind: "sdk" },
},
sessionId: sid,
},
// Missing origin but plain human-looking text stays user (older builds).
{
type: "attachment",
attachment: { type: "queued_command", prompt: "and update the docs" },
sessionId: sid,
},
]);
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const bySnippet = (s) =>
res.body.messages.find((m) =>
m.content.some((c) => c.type === "text" && (c.text || "").includes(s))
);
assert.equal(bySnippet("<task-notification>").sender, "system");
assert.equal(bySnippet("[SYSTEM NOTIFICATION").sender, "system");
assert.equal(bySnippet("sdk enqueued follow-up").sender, "system");
assert.equal(bySnippet("and update the docs").sender, "user");
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* @file Regression test for issue #223 — the packaged desktop app froze on a
* large ~/.claude/projects history. The desktop shell hosts the Express server
* IN the Electron main process, so a session sweep that scans every file
* synchronously (statSync + a getSession query per file) with no yield freezes
* the whole window. `syncDefaultProjects` must yield to the event loop
* periodically — even on the all-unchanged, cold-cache fast path that never
* parses a transcript — so a multi-thousand-session history can't monopolize
* the loop. Under `npm start` the server is its own process, which is why the
* hang only reproduced in the packaged app.
*
* Runs in its own process (node --test isolates files), so pointing CLAUDE_HOME
* and DASHBOARD_DB_PATH at temp locations before requiring the modules gives a
* clean, isolated projects dir + database without touching the real ones.
* @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 TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-yield-"));
process.env.CLAUDE_HOME = TMP_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP_HOME, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP_HOME, "data");
const PROJECTS_DIR = path.join(TMP_HOME, "projects");
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
const dbModule = require("../db");
const { syncDefaultProjects } = require("../../scripts/import-history");
// Enough sessions that the sweep crosses several yield boundaries
// (SWEEP_YIELD_EVERY_FILES = 100), so a cooperative sweep interleaves multiple
// event-loop ticks while a blocking one interleaves zero.
const SESSION_COUNT = 300;
function sessionLines(sessionId) {
return [
{
type: "user",
cwd: "/w",
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: { content: "hi" },
},
{
type: "assistant",
cwd: "/w",
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 10, output_tokens: 5 },
},
},
];
}
before(() => {
const projDir = path.join(PROJECTS_DIR, "-w");
fs.mkdirSync(projDir, { recursive: true });
for (let i = 0; i < SESSION_COUNT; i++) {
// Valid v4 UUID shape, unique per index.
const suffix = i.toString(16).padStart(12, "0");
const id = `00000000-0000-4000-8000-${suffix}`;
fs.writeFileSync(
path.join(projDir, `${id}.jsonl`),
sessionLines(id)
.map((o) => JSON.stringify(o))
.join("\n") + "\n"
);
}
});
after(() => {
if (dbModule.db) dbModule.db.close();
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
describe("syncDefaultProjects cooperative yielding (#223)", () => {
it("imports the full history on the first sweep", async () => {
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
assert.equal(
changed.length,
SESSION_COUNT,
"every session is imported on the cold first sweep"
);
});
it("yields to the event loop during an all-unchanged cold-cache sweep", async () => {
// Count event-loop ticks that fire DURING the sweep. A blocking sweep runs
// the whole scan synchronously (no await on the unchanged fast path), so a
// setImmediate chain scheduled alongside it gets zero ticks until it
// finishes. A cooperative sweep yields every SWEEP_YIELD_EVERY_FILES files,
// letting the chain advance mid-sweep.
let ticks = 0;
let active = true;
const pump = () => {
if (active) {
ticks += 1;
setImmediate(pump);
}
};
setImmediate(pump);
// Fresh (cold) cache but the rows already exist and the files are unchanged,
// so every file takes the fast path — the exact restart/poll scenario that
// froze the app. Nothing is reported as changed.
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
active = false;
assert.equal(changed.length, 0, "an unchanged sweep still reports no work");
assert.ok(
ticks >= 2,
`sweep must yield to the event loop on the fast path (observed ${ticks} ticks across ${SESSION_COUNT} files)`
);
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* @file Tests for syncDefaultProjects — the incremental, mtime-fingerprinted
* sync of ~/.claude/projects that backs the background session-sync poll
* (server/index.js startSessionSync). Verifies that a project added after the
* one-time backfill is discovered, that an unchanged sweep does no work, and
* that a grown session is reported as an update (not a new session).
*
* Runs in its own process (node --test isolates files), so pointing CLAUDE_HOME
* and DASHBOARD_DB_PATH at temp locations before requiring the modules gives a
* clean, isolated projects dir + database without touching the real ones.
* @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 TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-home-"));
process.env.CLAUDE_HOME = TMP_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP_HOME, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP_HOME, "data");
const PROJECTS_DIR = path.join(TMP_HOME, "projects");
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
const dbModule = require("../db");
const { syncDefaultProjects } = require("../../scripts/import-history");
const SESSION_NEW = "11111111-aaaa-4aaa-8aaa-111111111111";
function fixtureLines(sessionId, cwd, extra = []) {
return [
{
type: "user",
cwd,
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: { content: "hi" },
},
{
type: "assistant",
cwd,
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 10, output_tokens: 5 },
},
},
...extra,
];
}
function writeSession(projName, sessionId, lines) {
const projDir = path.join(PROJECTS_DIR, projName);
fs.mkdirSync(projDir, { recursive: true });
const file = path.join(projDir, `${sessionId}.jsonl`);
fs.writeFileSync(file, lines.map((o) => JSON.stringify(o)).join("\n") + "\n");
return file;
}
after(() => {
if (dbModule.db) dbModule.db.close();
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
describe("syncDefaultProjects", () => {
const mtimeCache = new Map();
it("discovers a newly added project and reports it as a new session", async () => {
writeSession("-work", SESSION_NEW, fixtureLines(SESSION_NEW, "/work"));
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache });
const hit = changed.find((c) => c.sessionId === SESSION_NEW);
assert.ok(hit, "the new session should be reported");
assert.equal(hit.isNew, true, "a session not yet in the DB is new");
assert.ok(dbModule.stmts.getSession.get(SESSION_NEW), "session should be imported into the DB");
});
it("does no work on a second sweep when nothing changed", async () => {
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache });
assert.equal(changed.length, 0, "an unchanged sweep reports no sessions");
});
it("reports a grown session as an update, not a new session", async () => {
const file = writeSession(
"-work",
SESSION_NEW,
fixtureLines(SESSION_NEW, "/work", [
{
type: "assistant",
cwd: "/work",
sessionId: SESSION_NEW,
timestamp: "2026-04-18T12:05:00.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "more" }],
usage: { input_tokens: 7, output_tokens: 3 },
},
},
])
);
// Force a clearly-later mtime so the sweep treats the file as changed.
const future = Date.now() / 1000 + 60;
fs.utimesSync(file, future, future);
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache });
const hit = changed.find((c) => c.sessionId === SESSION_NEW);
assert.ok(hit, "the grown session should be reported");
assert.equal(hit.isNew, false, "an already-imported session counts as an update");
});
it("never throws when the projects dir is empty of sessions", async () => {
const emptyCache = new Map();
const emptyProj = path.join(PROJECTS_DIR, "-empty");
fs.mkdirSync(emptyProj, { recursive: true });
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache: emptyCache });
// -empty contributes nothing; the pre-existing -work session is brand new to
// this fresh cache, so it is reported once — but the empty dir must not error.
assert.ok(Array.isArray(changed));
});
it("skips re-parsing an already-imported, unchanged session even on a cold cache", async () => {
// Models the immediate sweep after every server restart: mtimeCache is
// empty, but the DB already holds these sessions. An unchanged file must NOT
// be re-parsed/re-reported (the cold-restart fast path), so a large history
// doesn't re-parse every transcript on boot.
const SESSION_STABLE = "22222222-bbbb-4bbb-8bbb-222222222222";
writeSession("-stable", SESSION_STABLE, fixtureLines(SESSION_STABLE, "/stable"));
// First sweep (cold cache) imports it.
const first = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
assert.ok(
first.changed.find((c) => c.sessionId === SESSION_STABLE && c.isNew),
"first sweep imports the new session"
);
// Second sweep with a FRESH (cold) cache: the file is unchanged and the row
// exists, so the gate skips it — nothing reported for SESSION_STABLE.
const second = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
assert.equal(
second.changed.find((c) => c.sessionId === SESSION_STABLE),
undefined,
"an unchanged, already-imported session is skipped on a cold cache"
);
});
});
@@ -0,0 +1,114 @@
/**
* @file Verifies the idempotent ALTER TABLE migration adds a transcript_path
* column to sessions and that a fresh db.js load on an existing DB does not
* throw or duplicate the column.
* @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");
let TEST_DB;
before(() => {
TEST_DB = path.join(os.tmpdir(), `dashboard-tp-migration-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
});
after(() => {
try {
fs.unlinkSync(TEST_DB);
} catch {}
try {
fs.unlinkSync(TEST_DB + "-wal");
} catch {}
try {
fs.unlinkSync(TEST_DB + "-shm");
} catch {}
});
describe("sessions.transcript_path migration", () => {
it("adds transcript_path column on first load", () => {
delete require.cache[require.resolve("../db")];
const { db } = require("../db");
const cols = db.prepare("PRAGMA table_info(sessions)").all();
const names = cols.map((c) => c.name);
assert.ok(
names.includes("transcript_path"),
`expected transcript_path; got: ${names.join(",")}`
);
});
it("is idempotent — loading db.js a second time does not throw", () => {
delete require.cache[require.resolve("../db")];
assert.doesNotThrow(() => require("../db"));
});
it("transcript_path is nullable and accepts an UPDATE", () => {
const { db, stmts } = require("../db");
stmts.insertSession.run("s-tp-1", "name", "active", "/tmp/proj", "claude", null);
db.prepare("UPDATE sessions SET transcript_path = ? WHERE id = ?").run(
"/tmp/foo.jsonl",
"s-tp-1"
);
const row = db.prepare("SELECT transcript_path FROM sessions WHERE id = ?").get("s-tp-1");
assert.equal(row.transcript_path, "/tmp/foo.jsonl");
});
});
describe("hooks ingestion populates sessions.transcript_path", () => {
it("sets transcript_path on first event that carries it", async () => {
delete require.cache[require.resolve("../db")];
const { db, stmts } = require("../db");
// Pre-create a session without transcript_path (simulate legacy state)
stmts.insertSession.run("s-hook-1", "n", "active", "/tmp/proj", "claude", null);
let row = db.prepare("SELECT transcript_path FROM sessions WHERE id = ?").get("s-hook-1");
assert.equal(row.transcript_path, null);
// Spin up app and POST a hook event with transcript_path
delete require.cache[require.resolve("../index")];
const { createApp, startServer } = require("../index");
const app = createApp();
const server = await startServer(app, 0);
const port = server.address().port;
const payload = JSON.stringify({
hook_type: "PostToolUse",
data: {
session_id: "s-hook-1",
transcript_path: "/tmp/somewhere/session.jsonl",
cwd: "/tmp/proj",
},
});
await new Promise((resolve, reject) => {
const http = require("http");
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/hooks/event",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.once("end", resolve);
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
server.close();
row = db.prepare("SELECT transcript_path FROM sessions WHERE id = ?").get("s-hook-1");
assert.equal(row.transcript_path, "/tmp/somewhere/session.jsonl");
});
});
+450
View File
@@ -0,0 +1,450 @@
/**
* @file Tests the pure pipeline-rule matcher, including defensive input
* flattening, invalid-template handling, and later-stage precedence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const test = require("node:test");
const assert = require("node:assert/strict");
const { compileRules, detect, flattenInput } = require("../lib/stage-detect");
const { getPipeline } = require("../lib/pipelines");
const pipeline = {
nodes: [
{ id: "plan", detect: [{ tool: "Skill", match: "brainstorming" }] },
{ id: "implement", detect: [{ tool: "Edit" }] },
{ id: "tests", detect: [{ tool: "Bash", match: "npm run test:server" }] },
{ id: "ship", detect: [{ tool: "Bash", match: "git push" }] },
],
};
test("detects declared rules and reports a readable signal", () => {
assert.deepEqual(
detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }),
{
nodeId: "tests",
signal: "`npm run test:server`",
}
);
assert.deepEqual(
detect(pipeline, { tool_name: "Edit", tool_input: { file_path: "server/lib/app.js" } }),
{
nodeId: "implement",
signal: "`server/lib/app.js`",
}
);
assert.deepEqual(
detect(pipeline, { tool_name: "Skill", tool_input: { skill: "brainstorming" } }),
{
nodeId: "plan",
signal: "`brainstorming`",
}
);
assert.deepEqual(
detect(pipeline, { tool_name: "Bash", tool_input: { command: "git push origin main" } }),
{
nodeId: "ship",
signal: "`git push origin main`",
}
);
});
test("returns null for unmatched or malformed events", () => {
assert.equal(
detect(pipeline, { tool_name: "Read", tool_input: { file_path: "README.md" } }),
null
);
assert.equal(detect(pipeline, {}), null);
assert.equal(detect(null, { tool_name: "Edit", tool_input: {} }), null);
});
test("skips invalid regular expressions while retaining valid rules", () => {
const malformed = {
nodes: [
{ id: "broken", detect: [{ tool: "Bash", match: "[" }] },
{ id: "tests", detect: [{ tool: "Bash", match: "npm run test:server" }] },
],
};
assert.deepEqual(compileRules(malformed), [
{ nodeId: "broken", rules: [] },
{ nodeId: "tests", rules: [{ tool: "Bash", regex: /npm run test:server/ }] },
]);
assert.deepEqual(
detect(malformed, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }),
{
nodeId: "tests",
signal: "`npm run test:server`",
}
);
});
test("flattenInput handles awkward tool-input shapes without traversing deeply", () => {
assert.equal(flattenInput(null), "");
assert.equal(flattenInput("git push"), "git push");
assert.equal(flattenInput(["git", "push", { command: "origin main" }]), "git push origin main");
assert.equal(
flattenInput({
command: "npm test",
file_path: "server/app.js",
count: 1,
nested: { prompt: "ignored" },
}),
"npm test server/app.js"
);
assert.equal(flattenInput({ nested: { command: "never recurse" } }), "");
});
test("caps a long signal so it never inflates a DB column or a title attribute", () => {
const longCommand = `git push ${"x".repeat(4990)}`;
const result = detect(pipeline, { tool_name: "Bash", tool_input: { command: longCommand } });
assert.ok(result.signal.length < longCommand.length);
assert.ok(result.signal.startsWith("`git push xxxx"));
assert.ok(result.signal.endsWith("…`"));
});
test("flattenInput keeps only fields that identify what a tool did, dropping Edit's code payload", () => {
assert.equal(
flattenInput({
file_path: "server/lib/app.js",
old_string: "function old() { return 1; }",
new_string: "function updated() { return 2; }",
}),
"server/lib/app.js"
);
const signal = detect(pipeline, {
tool_name: "Edit",
tool_input: {
file_path: "server/lib/app.js",
old_string: "function old() { return 1; }",
new_string: "function updated() { return 2; }",
},
}).signal;
assert.ok(signal.includes("server/lib/app.js"));
assert.ok(!signal.includes("function old"));
assert.ok(!signal.includes("function updated"));
});
test("compileRules stays total on a null pipeline, non-array nodes, and a non-array detect", () => {
assert.deepEqual(compileRules(null), []);
assert.deepEqual(compileRules({ nodes: "not-an-array" }), []);
assert.deepEqual(compileRules({ nodes: [{ id: "weird", detect: "not-an-array" }] }), [
{ nodeId: "weird", rules: [] },
]);
});
test("detects stage through the real default pipeline template, end-to-end via getPipeline", () => {
const pipeline = getPipeline("default");
assert.equal(
detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }).nodeId,
"tests"
);
assert.equal(
detect(pipeline, { tool_name: "Edit", tool_input: { file_path: "server/lib/app.js" } }).nodeId,
"implement"
);
});
// Every rule default.json ships, asserted through the REAL loader. A rule
// pinned only against a hand-written fixture pipeline can ship inert — that is
// how both the dropped-`detect` loader bug and the shadowed `plan` Write rule
// got through review.
test("every shipped default.json rule fires end-to-end via getPipeline", () => {
const pipeline = getPipeline("default");
const cases = [
["plan", "Skill", { skill: "brainstorming" }],
["plan", "Skill", { skill: "writing-plans" }],
["implement", "Edit", { file_path: "server/lib/app.js" }],
["implement", "Write", { file_path: "server/lib/app.js" }],
["tests", "Bash", { command: "npm run test:server" }],
["tests", "Bash", { command: "pytest -q" }],
["review", "Skill", { skill: "requesting-code-review" }],
["review", "Bash", { command: "gh pr diff 12" }],
["ship", "Bash", { command: "git push origin HEAD" }],
["ship", "Bash", { command: "gh pr create --fill" }],
];
for (const [nodeId, tool_name, tool_input] of cases) {
const got = detect(pipeline, { tool_name, tool_input });
assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
}
});
test("a plan document written under docs/ reports plan, code written elsewhere reports implement", () => {
const pipeline = getPipeline("default");
// `implement`'s Write rule is a LATER node than `plan`'s and detect() takes
// the last match, so it must exclude docs/ paths or plan's rule can never fire.
assert.equal(
detect(pipeline, {
tool_name: "Write",
tool_input: { file_path: "docs/superpowers/plans/foo.md" },
}).nodeId,
"plan"
);
assert.equal(
detect(pipeline, {
tool_name: "Write",
tool_input: { file_path: "/home/me/repo/docs/superpowers/plans/foo.md" },
}).nodeId,
"plan"
);
assert.equal(
detect(pipeline, { tool_name: "Write", tool_input: { file_path: "server/lib/x.js" } }).nodeId,
"implement"
);
// A docs/ write that is not a plan document matches neither rule.
assert.equal(
detect(pipeline, { tool_name: "Write", tool_input: { file_path: "docs/README.md" } }),
null
);
});
test("loadAll preserves a node's detect rules, and tolerates a non-array detect without throwing", () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pipelines-detect-"));
fs.writeFileSync(
path.join(dir, "custom.json"),
JSON.stringify({
id: "custom-detect-test",
nodes: [
{ id: "a", detect: [{ tool: "Bash", match: "foo" }] },
{ id: "b", detect: "not-an-array" },
],
})
);
const { getPipeline, reload } = require("../lib/pipelines");
const prevDir = process.env.DASHBOARD_PIPELINES_DIR;
process.env.DASHBOARD_PIPELINES_DIR = dir;
reload();
try {
const p = getPipeline("custom-detect-test");
assert.deepEqual(p.nodes[0].detect, [{ tool: "Bash", match: "foo" }]);
assert.deepEqual(p.nodes[1].detect, []);
} finally {
if (prevDir === undefined) delete process.env.DASHBOARD_PIPELINES_DIR;
else process.env.DASHBOARD_PIPELINES_DIR = prevDir;
reload();
}
});
test("the last matching node wins", () => {
const overlapping = {
nodes: [
{ id: "implement", detect: [{ tool: "Bash", match: "git" }] },
{ id: "ship", detect: [{ tool: "Bash", match: "git push" }] },
],
};
assert.deepEqual(
detect(overlapping, { tool_name: "Bash", tool_input: { command: "git push" } }),
{
nodeId: "ship",
signal: "`git push`",
}
);
});
test.describe("signal reports the matched span", () => {
const pipeline = getPipeline("default");
test.it("keeps the matched command and drops a long unrelated prefix", () => {
const long = "cd /home/very/long/path/that/goes/on/forever && npm run test:server 2>&1";
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: long } });
assert.equal(r.nodeId, "tests");
assert.ok(r.signal.includes("npm run test:server"), r.signal);
assert.ok(!r.signal.includes("/home/very/long/path/that/goes/on/forever"), r.signal);
});
test.it("marks both ends it trimmed with an ellipsis", () => {
const long = `${"a".repeat(80)} npm test ${"b".repeat(80)}`;
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: long } });
assert.ok(r.signal.startsWith("`…"), r.signal);
assert.ok(r.signal.endsWith("…`"), r.signal);
});
test.it("keeps a match at the very start without a leading ellipsis", () => {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm test" } });
assert.equal(r.signal, "`npm test`");
});
test.it("falls back to the flattened input for a rule with no match pattern", () => {
const noMatch = {
id: "custom",
nodes: [{ id: "implement", label: "implement", aliases: [], detect: [{ tool: "Edit" }] }],
};
const r = detect(noMatch, { tool_name: "Edit", tool_input: { file_path: "server/lib/x.js" } });
assert.equal(r.signal, "`server/lib/x.js`");
});
test.it("still caps the fallback when a no-match rule sees a huge input", () => {
const noMatch = {
id: "custom",
nodes: [{ id: "implement", label: "implement", aliases: [], detect: [{ tool: "Edit" }] }],
};
const r = detect(noMatch, { tool_name: "Edit", tool_input: { file_path: "x".repeat(400) } });
// 120 chars + the ellipsis + the two wrapping backticks, minus nothing.
assert.equal(r.signal.length, 123);
assert.ok(r.signal.endsWith("\u2026`"), r.signal);
});
test.it("still returns null for a tool no rule mentions", () => {
assert.equal(detect(pipeline, { tool_name: "Read", tool_input: { file_path: "a.js" } }), null);
});
});
test.describe("rule precision against real command shapes", () => {
const pipeline = getPipeline("default");
test.it("does not call reading a diff a code review", () => {
// Measured on a real session: `git diff --stat openapi.yaml` was run to
// verify a generated file, and it pinned the lane at `review` — 4 such
// events outranked 81 test runs and 30 edits, because detect() takes the
// LAST matching node and recordDetection is forward-only. Reading a diff
// is what you do all day; it carries no stage information.
const r = detect(pipeline, {
tool_name: "Bash",
tool_input: { command: "git diff --stat openapi.yaml | tail -3" },
});
assert.notEqual(r && r.nodeId, "review");
});
test.it("still treats an explicit code-review skill as review", () => {
const r = detect(pipeline, { tool_name: "Skill", tool_input: { skill: "code-review" } });
assert.equal(r.nodeId, "review");
});
test.it("still treats a PR diff as review", () => {
const r = detect(pipeline, {
tool_name: "Bash",
tool_input: { command: "gh pr diff 42" },
});
assert.equal(r.nodeId, "review");
});
test.it("recognises a push that carries git flags before the subcommand", () => {
// The real push in this repo is
// GIT_TERMINAL_PROMPT=0 git -c credential.helper=... push --force-with-lease
// and `git push` as an adjacent pair never matched it, so six real pushes
// produced no `ship` signal at all.
const r = detect(pipeline, {
tool_name: "Bash",
tool_input: {
command:
'GIT_TERMINAL_PROMPT=0 git -c credential.helper=x push --force-with-lease origin main',
},
});
assert.equal(r.nodeId, "ship");
});
test.it("does not read an unrelated command as a push", () => {
for (const command of ["git add -A && git commit -q -m x", "npm run push-docs"]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.notEqual(r && r.nodeId, "ship", command);
}
});
});
test.describe("ship detection against this repo's actual push command", () => {
const pipeline = getPipeline("default");
// Verbatim from this repo's real push: the credential helper embeds `;` and
// `|`-free shell between `git` and `push`, so a same-segment pattern like
// `git[^;&|]*push` never matches it. Six real pushes produced no signal.
const REAL_PUSH =
'cd /repo && git add -A && git commit -q --amend --no-edit && ' +
'GIT_TERMINAL_PROMPT=0 git -c credential.helper=\'!f() { echo username=token; echo "password=$TK"; }; f\' push --force-with-lease origin main';
test.it("detects ship from the real push command", () => {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: REAL_PUSH } });
assert.equal(r && r.nodeId, "ship");
});
test.it("does not read a run-script named push-something as a push", () => {
const r = detect(pipeline, {
tool_name: "Bash",
tool_input: { command: "git add -A && npm run push-docs" },
});
assert.notEqual(r && r.nodeId, "ship");
});
test.it("does not read a bare non-git push as a push", () => {
const r = detect(pipeline, {
tool_name: "Bash",
tool_input: { command: "docker push myimage:latest" },
});
assert.notEqual(r && r.nodeId, "ship");
});
});
test.describe("ship must match a git push INVOCATION, not the word push", () => {
const pipeline = getPipeline("default");
// An unbounded `git … push` pattern was tried and it mis-fired within
// minutes on a live lane: `git log --oneline -1` in a command that also
// mentioned "push" inside a quoted string pinned the lane at `ship`. Because
// ship is near the end of the pipeline and detection is forward-only, that
// one over-read stuck — the same failure `git diff` caused for `review`.
test.it("ignores the word push when git is doing something else", () => {
const cases = [
'git log --oneline -1 && echo "probe: push thật"',
"git status && grep push file.txt",
'git commit -m "do not push this yet"',
"git log --oneline | head -3 # remember to push later",
];
for (const command of cases) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.notEqual(r && r.nodeId, "ship", command);
}
});
test.it("still matches a real push, including one behind git -c flags", () => {
const cases = [
"git push origin main",
"git push --force-with-lease",
'GIT_TERMINAL_PROMPT=0 git -c credential.helper=\'!f() { echo x; }; f\' push --force-with-lease origin main',
"cd /repo && git -c core.hooksPath=/dev/null push origin main",
];
for (const command of cases) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.equal(r && r.nodeId, "ship", command);
}
});
});
test.describe("tests rule covers the runners this repo actually uses", () => {
const pipeline = getPipeline("default");
test.it("detects node's own test runner invoked directly", () => {
// package.json's test:server IS `node --test server/__tests__/*.test.js`,
// and running that command directly (rather than through npm) is the
// common case while iterating on one file. It matched nothing.
for (const command of [
"node --test server/__tests__/stage-detect.test.js",
"node --test --test-name-pattern='detection' server/__tests__/lanes-api.test.js",
]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.equal(r && r.nodeId, "tests", command);
}
});
test.it("still detects the runners it already knew", () => {
for (const command of ["npm run test:server", "npm test", "cd client && npx vitest run src"]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.equal(r && r.nodeId, "tests", command);
}
});
test.it("does not read an unrelated node invocation as a test run", () => {
for (const command of [
"node scripts/generate-openapi-yaml.js",
"node -e \"console.log('hello')\"",
"node bin/ccam.js start",
]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.notEqual(r && r.nodeId, "tests", command);
}
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* @file stream-json-parser.test.js
* @description Unit tests for the newline-delimited JSON line buffer used to
* parse `claude --output-format stream-json` output. Verifies chunked input,
* partial lines spanning chunks, malformed lines, empty input, multiple
* objects per chunk, and flush semantics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { createLineParser } = require("../lib/stream-json-parser");
function collect() {
const objects = [];
const errors = [];
const parser = createLineParser(
(obj) => objects.push(obj),
(err, raw) => errors.push({ message: err.message, raw })
);
return { parser, objects, errors };
}
describe("stream-json-parser", () => {
it("parses a single complete line", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"system","subtype":"init"}\n');
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "system");
});
it("parses multiple lines in one chunk", () => {
const { parser, objects } = collect();
parser.push('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n');
assert.deepEqual(
objects.map((o) => o.type),
["a", "b", "c"]
);
});
it("reassembles a JSON object split across two chunks", () => {
const { parser, objects } = collect();
parser.push('{"type":"split","val":');
parser.push('"hello"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].val, "hello");
});
it("reassembles a JSON object split across many small chunks", () => {
const { parser, objects } = collect();
const full = '{"type":"chunky","payload":{"deep":{"nested":[1,2,3]}}}\n';
for (const ch of full) parser.push(ch);
assert.equal(objects.length, 1);
assert.deepEqual(objects[0].payload.deep.nested, [1, 2, 3]);
});
it("ignores blank lines between objects", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"a"}\n\n\n{"type":"b"}\n');
assert.equal(objects.length, 2);
assert.equal(errors.length, 0);
});
it("reports malformed JSON via onError without throwing", () => {
const { parser, objects, errors } = collect();
parser.push("not valid json\n");
parser.push('{"type":"ok"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "ok");
assert.equal(errors.length, 1);
assert.match(errors[0].raw, /not valid json/);
});
it("does not emit a partial line until newline arrives", () => {
const { parser, objects } = collect();
parser.push('{"type":"unfinished"');
assert.equal(objects.length, 0);
parser.push("}\n");
assert.equal(objects.length, 1);
});
it("flush() emits trailing line without newline", () => {
const { parser, objects } = collect();
parser.push('{"type":"trailing"}');
assert.equal(objects.length, 0);
parser.flush();
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "trailing");
});
it("flush() on empty buffer is a no-op", () => {
const { parser, objects, errors } = collect();
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 0);
});
it("flush() reports malformed trailing line via onError", () => {
const { parser, objects, errors } = collect();
parser.push("garbage{not-json");
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 1);
});
it("works without onError callback when input is malformed", () => {
let count = 0;
const parser = createLineParser((_o) => count++);
// No throw expected.
parser.push("garbage\n");
parser.push('{"type":"ok"}\n');
assert.equal(count, 1);
});
it("handles CRLF line endings cleanly (\\r is trimmed before parse)", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"crlf"}\r\n');
// Note: parser only splits on \n; the \r at end of line stays in the
// line. JSON.parse tolerates trailing whitespace including \r.
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "crlf");
});
it("handles a stream-json envelope with stream_event sub-event shape", () => {
const { parser, objects } = collect();
const env = JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
},
session_id: "sess",
});
parser.push(env + "\n");
assert.equal(objects.length, 1);
assert.equal(objects[0].event.delta.text, "Hello");
});
});
@@ -0,0 +1,754 @@
/**
* @file Tests for subagent tool-event attribution.
*
* Subagent tool calls (Read, Bash, Edit, etc.) never fire hooks on the
* parent session — they only show up in the subagent's own JSONL file.
* Without dedicated extraction, every subagent ends up with at most a
* single spawn event, leaving 561/561 historical subagents with 05
* events instead of the dozens-to-hundreds they actually performed.
*
* This suite verifies that:
* 1. parseSubagentFile pairs tool_use blocks with their tool_result
* counterparts and surfaces them as `toolEvents`.
* 2. importSubagentFromJsonl emits PreToolUse + PostToolUse events
* under the subagent's own `agent_id`, so the UI attributes them
* to the subagent rather than the main agent.
* 3. Re-running the import is idempotent — no duplicate event rows.
* 4. When a live subagent (created via PreToolUse "Agent" hook) matches
* the JSONL by type + start time, events attach to the live row
* instead of creating a duplicate JSONL-keyed row.
*
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-subagent-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
const { calculateCost } = require("../routes/pricing");
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// ── Fixture helpers ──────────────────────────────────────────────────
function writeSubagentJsonl(filePath, lines) {
fs.writeFileSync(filePath, lines.map((o) => JSON.stringify(o)).join("\n"));
}
/**
* Builds a minimal subagent JSONL with two tool calls — one Read with a
* paired tool_result, and one Bash with a paired error tool_result.
*/
function buildSubagentLines(agentType = "coder") {
return [
{
type: "user",
timestamp: "2026-04-28T10:00:00.000Z",
message: { content: [{ type: "text", text: "Investigate the bug" }] },
},
{
type: "assistant",
timestamp: "2026-04-28T10:00:01.000Z",
message: {
model: "claude-opus-4-7",
content: [
{
type: "tool_use",
id: "toolu_read_001",
name: "Read",
input: { file_path: "/tmp/foo.py" },
},
],
usage: {
input_tokens: 50,
output_tokens: 20,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
{
type: "user",
timestamp: "2026-04-28T10:00:02.000Z",
message: {
content: [
{
type: "tool_result",
tool_use_id: "toolu_read_001",
content: "def main():\n pass\n",
},
],
},
},
{
type: "assistant",
timestamp: "2026-04-28T10:00:03.000Z",
message: {
model: "claude-opus-4-7",
content: [
{
type: "tool_use",
id: "toolu_bash_002",
name: "Bash",
input: { command: "ls /tmp" },
},
],
},
},
{
type: "user",
timestamp: "2026-04-28T10:00:04.000Z",
message: {
content: [
{
type: "tool_result",
tool_use_id: "toolu_bash_002",
content: "ls: cannot access /tmp: not allowed",
is_error: true,
},
],
},
},
{
// Meta: keeps file timestamps coherent
type: "user",
timestamp: "2026-04-28T10:00:05.000Z",
message: { content: [{ type: "text", text: "done" }] },
},
].map((line, i) => {
// Inject agentType into one entry as a hint, mirroring real CC output
if (i === 0) line.agentType = agentType;
return line;
});
}
function writeMetaJson(filePath, agentType) {
fs.writeFileSync(filePath, JSON.stringify({ agentType }));
}
/**
* Minimal subagent JSONL on a specific model carrying one usage record, so
* parseSubagentFile yields a single token bucket keyed by that model.
*/
function buildModelSubLines(agentType, model, usage, startedAt = "2026-05-01T10:00:00.000Z") {
const t0 = startedAt;
return [
{
type: "user",
timestamp: t0,
agentType,
message: { content: [{ type: "text", text: "go" }] },
},
{
type: "assistant",
timestamp: "2026-05-01T10:00:01.000Z",
message: {
model,
content: [{ type: "tool_use", id: `toolu_${agentType}_1`, name: "Read", input: {} }],
usage: {
input_tokens: usage.input || 0,
output_tokens: usage.output || 0,
cache_read_input_tokens: usage.cacheRead || 0,
cache_creation_input_tokens: usage.cacheWrite || 0,
},
},
},
{
type: "user",
timestamp: "2026-05-01T10:00:02.000Z",
message: {
content: [{ type: "tool_result", tool_use_id: `toolu_${agentType}_1`, content: "ok" }],
},
},
];
}
// Lay out <base>/<sessionId>/subagents/agent-*.jsonl and return the matching
// transcriptPath (<base>/<sessionId>.jsonl) that scanAndImportSubagents expects.
function buildSubagentDir(sessionId, files) {
const base = fs.mkdtempSync(path.join(os.tmpdir(), `sa-scan-${process.pid}-`));
const subDir = path.join(base, sessionId, "subagents");
fs.mkdirSync(subDir, { recursive: true });
for (const f of files) {
writeSubagentJsonl(path.join(subDir, `agent-${f.id}.jsonl`), f.lines);
// Companion meta.json so parseSubagentFile resolves agentType (the live-match key).
if (f.agentType) {
fs.writeFileSync(
path.join(subDir, `agent-${f.id}.meta.json`),
JSON.stringify({ agentType: f.agentType })
);
}
}
return { transcriptPath: path.join(base, `${sessionId}.jsonl`), base };
}
// ── Tests ────────────────────────────────────────────────────────────
describe("parseSubagentFile — tool event extraction", () => {
it("pairs tool_use with tool_result and returns ordered toolEvents", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, buildSubagentLines("coder"));
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "coder");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
assert.ok(data, "subagent data should parse");
assert.equal(data.agentType, "coder");
assert.ok(Array.isArray(data.toolEvents));
assert.equal(data.toolEvents.length, 2);
const [readEv, bashEv] = data.toolEvents;
assert.equal(readEv.tool_use_id, "toolu_read_001");
assert.equal(readEv.tool_name, "Read");
assert.deepEqual(readEv.tool_input, { file_path: "/tmp/foo.py" });
assert.equal(readEv.is_error, false);
assert.equal(typeof readEv.pre_timestamp, "string");
assert.equal(typeof readEv.post_timestamp, "string");
assert.ok(readEv.tool_response);
assert.equal(bashEv.tool_use_id, "toolu_bash_002");
assert.equal(bashEv.is_error, true);
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
it("emits a tool_use even when no matching tool_result exists yet (live tail)", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-tail-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, [
{
type: "assistant",
timestamp: "2026-04-28T10:00:01.000Z",
message: {
model: "claude-opus-4-7",
content: [{ type: "tool_use", id: "toolu_pending", name: "Read", input: {} }],
},
},
]);
try {
const data = await importHistory.parseSubagentFile(tmpFile);
assert.equal(data.toolEvents.length, 1);
const ev = data.toolEvents[0];
assert.equal(ev.tool_use_id, "toolu_pending");
assert.equal(ev.post_timestamp, null);
assert.equal(ev.tool_response, null);
} finally {
fs.unlinkSync(tmpFile);
}
});
});
describe("importSubagentFromJsonl — event attribution", () => {
const sessionId = "test-sess-attribution";
const mainAgentId = `${sessionId}-main`;
before(() => {
// Seed session + main agent so importSubagentFromJsonl has parents to point at.
stmts.insertSession.run(sessionId, "Test Session", "active", "/tmp", null, null);
stmts.insertAgent.run(
mainAgentId,
sessionId,
"Main Agent",
"main",
null,
"waiting",
null,
null,
null
);
});
it("creates one subagent row and per-call PreToolUse + PostToolUse events", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-attr-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, buildSubagentLines("coder"));
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "coder");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
const created = importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
assert.ok(created > 0, "should create at least the agent + spawn + 4 events");
const subId = `${sessionId}-jsonl-${data.agentId}`;
const subAgent = stmts.getAgent.get(subId);
assert.ok(subAgent, "JSONL-keyed subagent row should exist");
assert.equal(subAgent.parent_agent_id, mainAgentId);
const toolEvents = db
.prepare(
"SELECT event_type, tool_name FROM events WHERE agent_id = ? AND event_type IN ('PreToolUse', 'PostToolUse') ORDER BY id ASC"
)
.all(subId);
assert.equal(toolEvents.length, 4, "expected 2 Pre + 2 Post events under subagent's id");
assert.deepEqual(
toolEvents.map((e) => `${e.event_type}:${e.tool_name}`),
["PreToolUse:Read", "PostToolUse:Read", "PreToolUse:Bash", "PostToolUse:Bash"]
);
// Spawn marker lives under the main agent so the parent chain shows
// "Subagent spawned: coder" alongside main's other actions.
const spawnEvents = db
.prepare(
"SELECT 1 FROM events WHERE agent_id = ? AND event_type = 'PreToolUse' AND tool_name = 'Agent'"
)
.all(mainAgentId);
assert.equal(spawnEvents.length, 1);
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
it("is idempotent — re-running does not duplicate events", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-idem-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, buildSubagentLines("reviewer"));
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "reviewer");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
const subId = `${sessionId}-jsonl-${data.agentId}`;
const before = db.prepare("SELECT COUNT(*) AS c FROM events WHERE agent_id = ?").get(subId).c;
// Second run — should be a no-op.
importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
const after = db.prepare("SELECT COUNT(*) AS c FROM events WHERE agent_id = ?").get(subId).c;
assert.equal(after, before, "idempotent re-import — no new rows");
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
it("merges into a live subagent when one matches — no JSONL-keyed duplicate row", async () => {
// Simulate a live PreToolUse Agent hook having pre-created a subagent row.
const liveSubId = "live-uuid-xyz";
const startedAt = "2026-04-28T10:00:00.000Z";
stmts.insertAgent.run(
liveSubId,
sessionId,
"Live Coder",
"subagent",
"live-coder",
"completed",
"task",
mainAgentId,
null
);
db.prepare("UPDATE agents SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?").run(
startedAt,
startedAt,
startedAt,
liveSubId
);
const tmpFile = path.join(os.tmpdir(), `agent-live-${Date.now()}-${process.pid}.jsonl`);
const lines = buildSubagentLines("live-coder");
writeSubagentJsonl(tmpFile, lines);
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "live-coder");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
const jsonlSubId = `${sessionId}-jsonl-${data.agentId}`;
assert.equal(
stmts.getAgent.get(jsonlSubId),
undefined,
"no JSONL-keyed row when a live subagent absorbed the events"
);
const eventsUnderLive = db
.prepare(
"SELECT 1 FROM events WHERE agent_id = ? AND event_type IN ('PreToolUse', 'PostToolUse')"
)
.all(liveSubId);
assert.ok(eventsUnderLive.length >= 4, "events should attach to the live subagent's id");
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
});
// ── Per-subagent model token attribution (issue #185) ──────────────────
describe("scanAndImportSubagents — per-subagent model token attribution", () => {
it("buckets each subagent's tokens under its OWN model, skipping the parent model", async () => {
const sessionId = "sess-185-tiered";
// Orchestrator on Opus; subagents tiered to Sonnet + Haiku, plus one on the
// SAME model as the parent (Opus) to verify that bucket is intentionally skipped.
stmts.insertSession.run(sessionId, "Tiered", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "hq",
lines: buildModelSubLines("qa", "claude-haiku-4-5-20251001", { input: 1000, output: 500 }),
},
{
id: "se",
lines: buildModelSubLines("engineer", "claude-sonnet-4-6", { input: 2000, output: 800 }),
},
{
id: "op",
lines: buildModelSubLines("planner", "claude-opus-4-8", { input: 9999, output: 9999 }),
},
]);
try {
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
const rows = stmts.getTokensBySession.all(sessionId);
const byModel = Object.fromEntries(rows.map((r) => [r.model, r]));
const models = Object.keys(byModel).sort();
// Haiku + Sonnet buckets are written under their own models.
assert.deepEqual(models, ["claude-haiku-4-5-20251001", "claude-sonnet-4-6"]);
assert.equal(byModel["claude-haiku-4-5-20251001"].input_tokens, 1000);
assert.equal(byModel["claude-haiku-4-5-20251001"].output_tokens, 500);
assert.equal(byModel["claude-sonnet-4-6"].input_tokens, 2000);
assert.equal(byModel["claude-sonnet-4-6"].output_tokens, 800);
// The Opus subagent's tokens are NOT written here — that bucket belongs to
// the main-transcript writer; double-writing it would inflate via baseline.
assert.equal(byModel["claude-opus-4-8"], undefined, "parent-model bucket must be skipped");
// Cost is priced at the real (cheaper) per-subagent models, never Opus.
const cost = calculateCost(rows, stmts.listPricing.all());
const costModels = cost.breakdown.map((b) => b.model).sort();
assert.deepEqual(costModels, ["claude-haiku-4-5-20251001", "claude-sonnet-4-6"]);
assert.ok(cost.total_cost > 0);
// Each subagent row records its real model in metadata.
const hq = stmts.getAgent.get(`${sessionId}-jsonl-hq`);
assert.equal(JSON.parse(hq.metadata).model, "claude-haiku-4-5-20251001");
const se = stmts.getAgent.get(`${sessionId}-jsonl-se`);
assert.equal(JSON.parse(se.metadata).model, "claude-sonnet-4-6");
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("re-running does not inflate token buckets (idempotent per-model write)", async () => {
const sessionId = "sess-185-idem";
stmts.insertSession.run(sessionId, "Idem", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "hq",
lines: buildModelSubLines("qa", "claude-haiku-4-5-20251001", { input: 1000, output: 500 }),
},
]);
try {
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
const rows = stmts.getTokensBySession.all(sessionId);
const haiku = rows.find((r) => r.model === "claude-haiku-4-5-20251001");
// getTokensBySession already returns effective totals (current + baseline).
// Re-running must not double them — append-only subagent JSONLs never drop.
assert.equal(haiku.input_tokens, 1000);
assert.equal(haiku.output_tokens, 500);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("skips every model the MAIN transcript used, not just the latest (mid-session /model switch)", async () => {
const sessionId = "sess-185-switch";
// Orchestrator switched Opus → Sonnet mid-session; session.model holds the
// latest (Sonnet), but the main transcript wrote BOTH. A subagent on the
// earlier Opus must still be skipped to avoid colliding with the main writer.
stmts.insertSession.run(sessionId, "Switch", "active", "/tmp", "claude-sonnet-4-6", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "op",
lines: buildModelSubLines("planner", "claude-opus-4-8", { input: 5000, output: 5000 }),
},
{
id: "hq",
lines: buildModelSubLines("qa", "claude-haiku-4-5-20251001", { input: 100, output: 50 }),
},
]);
try {
// parentModels carries BOTH orchestrator models (as hooks.js would pass).
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath, {
parentModels: ["claude-sonnet-4-6", "claude-opus-4-8"],
});
const models = stmts.getTokensBySession
.all(sessionId)
.map((r) => r.model)
.sort();
// Only Haiku is written; both orchestrator models (Sonnet + the earlier
// Opus) are skipped even though Opus != session.model.
assert.deepEqual(models, ["claude-haiku-4-5-20251001"]);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("backfills a live subagent row's model from its own transcript", async () => {
const sessionId = "sess-185-live";
const startedAt = "2026-05-01T10:00:00.000Z";
stmts.insertSession.run(sessionId, "Live", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
// Live subagent created by the PreToolUse "Agent" hook — no model recorded.
const liveId = "live-185-qa";
stmts.insertAgent.run(
liveId,
sessionId,
"QA",
"subagent",
"qa",
"working",
"task",
`${sessionId}-main`,
null
);
db.prepare("UPDATE agents SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?").run(
startedAt,
startedAt,
startedAt,
liveId
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "qa1",
agentType: "qa",
lines: buildModelSubLines(
"qa",
"claude-haiku-4-5-20251001",
{ input: 10, output: 5 },
startedAt
),
},
]);
try {
assert.equal(stmts.getAgent.get(liveId).metadata, null, "live row starts with no model");
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
const meta = JSON.parse(stmts.getAgent.get(liveId).metadata || "{}");
assert.equal(meta.model, "claude-haiku-4-5-20251001", "live row backfilled with real model");
// No JSONL-keyed duplicate row was created (events merged into the live row).
assert.equal(stmts.getAgent.get(`${sessionId}-jsonl-qa1`), undefined);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
describe("scanAndImportSubagents — nested subagent hierarchy", () => {
// A subagent's own transcript records each child it spawned via the Task tool
// as `toolUseResult.agentId`. Build a spawner file that claims `childIds`.
function buildSpawnerLines(childIds, startedAt = "2026-06-01T10:00:00.000Z") {
const lines = [
{ type: "user", timestamp: startedAt, message: { content: [{ type: "text", text: "go" }] } },
];
childIds.forEach((cid, i) => {
const ts = `2026-06-01T10:00:0${i + 1}.000Z`;
lines.push({
type: "assistant",
timestamp: ts,
message: {
model: "claude-opus-4-8",
content: [{ type: "tool_use", id: `toolu_task_${cid}`, name: "Task", input: {} }],
},
});
lines.push({
type: "user",
timestamp: ts,
toolUseResult: { agentId: cid, status: "completed" },
message: {
content: [{ type: "tool_result", tool_use_id: `toolu_task_${cid}`, content: "spawned" }],
},
});
});
return lines;
}
function buildLeafLines(startedAt = "2026-06-01T10:01:00.000Z") {
return [
{
type: "user",
timestamp: startedAt,
message: { content: [{ type: "text", text: "work" }] },
},
{
type: "assistant",
timestamp: startedAt,
message: {
model: "claude-haiku-4-5-20251001",
content: [{ type: "tool_use", id: "toolu_leaf", name: "Read", input: {} }],
},
},
];
}
it("nests subagents under their true spawner instead of flattening to main", async () => {
const sessionId = "sess-nested-tree";
const mainAgentId = `${sessionId}-main`;
stmts.insertSession.run(sessionId, "Nested", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
mainAgentId,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
// main → orch; orch → leafA, leafB; main → solo
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{ id: "orch", lines: buildSpawnerLines(["leafA", "leafB"]) },
{ id: "leafA", lines: buildLeafLines() },
{ id: "leafB", lines: buildLeafLines() },
{ id: "solo", lines: buildLeafLines() },
]);
try {
const res = await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
assert.equal(res.reparented, 2, "leafA + leafB repointed under orch");
const parentOf = (id) => stmts.getAgent.get(`${sessionId}-jsonl-${id}`).parent_agent_id;
assert.equal(parentOf("orch"), mainAgentId, "orch is a direct child of main");
assert.equal(parentOf("solo"), mainAgentId, "solo stays under main");
assert.equal(parentOf("leafA"), `${sessionId}-jsonl-orch`, "leafA nests under orch");
assert.equal(parentOf("leafB"), `${sessionId}-jsonl-orch`, "leafB nests under orch");
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("is idempotent — a second scan repoints nothing", async () => {
const sessionId = "sess-nested-idem";
const mainAgentId = `${sessionId}-main`;
stmts.insertSession.run(sessionId, "NestedIdem", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
mainAgentId,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{ id: "orch2", lines: buildSpawnerLines(["leafC"]) },
{ id: "leafC", lines: buildLeafLines() },
]);
try {
const first = await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
assert.equal(first.reparented, 1);
const second = await importHistory.scanAndImportSubagents(
dbModule,
sessionId,
transcriptPath
);
assert.equal(second.reparented, 0, "no re-parenting on the second pass");
assert.equal(
stmts.getAgent.get(`${sessionId}-jsonl-leafC`).parent_agent_id,
`${sessionId}-jsonl-orch2`
);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
describe("events dedup index", () => {
it("has a (agent_id, event_type) index so per-tool-event dedup is not a full scan", () => {
// importSubagentFromJsonl dedups every tool event with
// "WHERE agent_id = ? AND event_type = ? AND data LIKE '%tool_use_id%'".
// Without this index each dedup full-scans the events table; on a large DB a
// single re-import (e.g. the startup sweep touching a subagent-heavy session)
// takes tens of seconds and blocks the event loop. Guard it from regressing.
const row = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_events_agent_type'"
)
.get();
assert.ok(row, "idx_events_agent_type must exist to keep subagent dedup indexed");
});
});
@@ -0,0 +1,146 @@
/**
* @file Tests the one-time backfill that stamps metadata.tokens onto subagent
* rows predating per-agent cost tracking.
*
* A historical session whose transcript never changes again is mtime-skipped by
* the continuous sync, so its subagents (imported before per-agent cost existed)
* would never gain a tokens bucket and their cards would show no cost. The
* startup backfill re-parses those transcripts and stamps the metadata — WITHOUT
* touching session token_usage. This suite verifies:
*
* 1. A pre-feature subagent row (no tokens key) gets its metadata.tokens
* stamped from its transcript, so attachAgentCosts can price it.
* 2. The backfill is metadata-only: it does not create/alter token_usage rows.
* 3. It is self-limiting — a second run stamps nothing new.
*
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-subcost-bf-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
const { attachAgentCosts } = require("../routes/pricing");
const SESSION = "dddddddd-4444-4444-8444-dddddddddddd";
const MAIN = `${SESSION}-main`;
const SUB_ID = "aaaa1111-bbbb-2222-cccc-333344445555";
const SUB_MODEL = "claude-haiku-4-5-20251001";
let tmpDir;
let transcriptPath;
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subcost-bf-"));
// Lay out the on-disk transcript tree the backfill walks:
// <proj>/<session>.jsonl (main transcript path)
// <proj>/<session>/subagents/agent-<id>.jsonl
transcriptPath = path.join(tmpDir, `${SESSION}.jsonl`);
fs.writeFileSync(transcriptPath, "");
const subDir = path.join(tmpDir, SESSION, "subagents");
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(
path.join(subDir, `agent-${SUB_ID}.jsonl`),
[
{
type: "user",
sessionId: SESSION,
timestamp: "2026-04-18T12:00:00.000Z",
message: { content: "go" },
},
{
type: "assistant",
sessionId: SESSION,
timestamp: "2026-04-18T12:00:05.000Z",
message: {
model: SUB_MODEL,
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 2_000_000, output_tokens: 1_000_000 },
},
},
]
.map((o) => JSON.stringify(o))
.join("\n")
);
// Session with transcript_path set, and a PRE-FEATURE subagent row: metadata
// has a model but NO tokens key (exactly what old imports produced).
stmts.insertSession.run(
SESSION,
"Backfill session",
"completed",
"/tmp/x",
"claude-opus-4-8",
null
);
db.prepare("UPDATE sessions SET transcript_path = ? WHERE id = ?").run(transcriptPath, SESSION);
stmts.insertAgent.run(MAIN, SESSION, "Main Agent", "main", null, "completed", null, null, null);
stmts.insertAgent.run(
`${SESSION}-jsonl-${SUB_ID}`,
SESSION,
"general-purpose",
"subagent",
"general-purpose",
"completed",
"recon",
MAIN,
JSON.stringify({ imported: true, source: "jsonl", model: SUB_MODEL })
);
// Deterministic Haiku pricing: $1 in / $5 out per MTok.
stmts.upsertPricing.run("claude-haiku%", "Claude Haiku 4.5", 1, 5, 0.1, 1.25, 2, 0, 0);
});
describe("subagent token backfill", () => {
it("stamps metadata.tokens on a pre-feature subagent row from its transcript", async () => {
const before = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_ID}`);
assert.ok(!/"tokens":/.test(before.metadata), "row starts without a tokens key");
const res = await importHistory.backfillSubagentTokenMetadata(dbModule);
assert.ok(res.stamped >= 1, "at least one subagent re-parsed");
const row = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_ID}`);
const meta = JSON.parse(row.metadata);
assert.ok(Array.isArray(meta.tokens) && meta.tokens.length === 1, "tokens stamped");
assert.equal(meta.tokens[0].input_tokens, 2_000_000);
assert.equal(meta.tokens[0].output_tokens, 1_000_000);
// Now priceable: 2M in @ $1 + 1M out @ $5 = $2 + $5 = $7.
const withCosts = attachAgentCosts(stmts.listAgentsBySession.all(SESSION));
const sub = withCosts.find((a) => a.id === `${SESSION}-jsonl-${SUB_ID}`);
assert.equal(sub.cost, 7);
});
it("is metadata-only — it creates no token_usage rows", () => {
const rows = db
.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = ?")
.get(SESSION);
assert.equal(rows.n, 0, "session token_usage untouched by the backfill");
});
it("is self-limiting — a second run stamps nothing new", async () => {
const res = await importHistory.backfillSubagentTokenMetadata(dbModule);
// The one session no longer matches the driving query (its subagent now has
// a tokens key), so no session is re-scanned.
assert.equal(res.sessions, 0);
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* @file Tests that a subagent's OWN cost is derived and surfaced per-agent.
*
* Subagent cards used to show the whole session's cost, which reads as if that
* one subagent cost the entire session's spend. The importer now stamps each
* subagent's own token buckets into its metadata, and the agent-list endpoints
* compute a per-agent `cost` from them (priced at current rates, like session
* cost). This suite verifies:
*
* 1. importSubagentFromJsonl stores the subagent's own token buckets in
* agent.metadata.tokens.
* 2. attachAgentCosts computes that subagent's cost from those buckets and the
* current pricing rules — independent of the session total.
* 3. A main agent (no per-agent tokens) gets cost 0 (its cost is the session
* total, shown separately).
*
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-subagent-cost-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
const { attachAgentCosts, calculateCost } = require("../routes/pricing");
const SESSION = "cccccccc-3333-4333-8333-cccccccccccc";
const MAIN = `${SESSION}-main`;
const SUB_AGENT_ID = "11112222-3333-4444-5555-666677778888";
const SUB_MODEL = "claude-haiku-4-5-20251001";
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
function writeJsonl(filePath, lines) {
fs.writeFileSync(filePath, lines.map((o) => JSON.stringify(o)).join("\n"));
}
/** A subagent transcript with one assistant turn carrying known Haiku usage. */
function subagentLines() {
const base = "2026-04-18T12:00:00.000Z";
return [
{ type: "user", sessionId: SESSION, timestamp: base, message: { content: "do the thing" } },
{
type: "assistant",
sessionId: SESSION,
timestamp: "2026-04-18T12:00:05.000Z",
message: {
model: SUB_MODEL,
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: 1_000_000,
output_tokens: 500_000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
];
}
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subcost-"));
// Session + main agent so the subagent FK holds.
stmts.insertSession.run(
SESSION,
"Cost test session",
"completed",
"/tmp/x",
"claude-opus-4-8",
null
);
stmts.insertAgent.run(MAIN, SESSION, "Main Agent", "main", null, "completed", null, null, null);
// Deterministic Haiku pricing: $1 in / $5 out per MTok.
stmts.upsertPricing.run("claude-haiku%", "Claude Haiku 4.5", 1, 5, 0.1, 1.25, 2, 0, 0);
});
describe("per-subagent cost", () => {
it("stamps the subagent's own token buckets into its metadata on import", async () => {
const file = path.join(tmpDir, `agent-${SUB_AGENT_ID}.jsonl`);
writeJsonl(file, subagentLines());
const subData = await importHistory.parseSubagentFile(file);
assert.ok(subData, "subData parsed");
importHistory.importSubagentFromJsonl(dbModule, SESSION, MAIN, subData);
const row = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_AGENT_ID}`);
assert.ok(row, "jsonl subagent row created");
const meta = JSON.parse(row.metadata);
assert.ok(Array.isArray(meta.tokens) && meta.tokens.length === 1, "one token bucket stored");
assert.equal(meta.tokens[0].model, SUB_MODEL);
assert.equal(meta.tokens[0].input_tokens, 1_000_000);
assert.equal(meta.tokens[0].output_tokens, 500_000);
});
it("computes the subagent's own cost from its buckets, not the session total", () => {
const agents = stmts.listAgentsBySession.all(SESSION);
const withCosts = attachAgentCosts(agents);
const sub = withCosts.find((a) => a.id === `${SESSION}-jsonl-${SUB_AGENT_ID}`);
const main = withCosts.find((a) => a.id === MAIN);
// 1M input @ $1 + 0.5M output @ $5 = $1 + $2.50 = $3.50.
assert.equal(sub.cost, 3.5);
// Cross-check against calculateCost directly.
const rules = stmts.listPricing.all();
assert.equal(
calculateCost(JSON.parse(sub.metadata).tokens, rules, "2026-04-18").total_cost,
3.5
);
// Main agent carries no per-agent tokens → 0 (its cost is the session total).
assert.equal(main.cost, 0);
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* @file Tests for replaceTokenUsage's compaction baseline semantics.
*
* The effective total for a bucket is `live + baseline`. It must behave as a
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
* transcript doesn't lose usage), but never inflate past the largest value ever
* seen (so two writers hitting the same bucket with different scopes — the live
* hook writer stores main-only tokens, importSession stores main+subagents —
* can't ratchet the baseline upward on every downward fluctuation).
*
* Regression guard for the runaway that inflated one 26-day session's baseline
* to ~11× its real usage (dashboard total $22.5k vs true ~$15.2k).
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-token-baseline-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// Write a cache_read value for a fixed bucket; return effective (live+baseline).
function writeCacheRead(sessionId, cacheRead) {
stmts.replaceTokenUsage.run(
sessionId,
"claude-opus-4-8",
"standard",
"global",
"standard",
0, // input
0, // output
cacheRead,
0, // cache_write
0, // cache_write_1h
0, // web_search
0, // web_fetch
0 // code_execution
);
}
function effectiveCacheRead(sessionId) {
const r = stmts.getTokensBySession.all(sessionId).find((x) => x.model === "claude-opus-4-8");
return r ? r.cache_read_tokens : 0;
}
function rawRow(sessionId) {
return db
.prepare(
"SELECT cache_read_tokens, baseline_cache_read FROM token_usage WHERE session_id = ? AND model = 'claude-opus-4-8'"
)
.get(sessionId);
}
describe("replaceTokenUsage — high-water-mark baseline", () => {
before(() => {
for (const id of ["hw-grow", "hw-idem", "hw-shrink", "hw-altern", "hw-recover"]) {
try {
stmts.insertSession.run(id, "t", "active", "/tmp", null, null);
} catch {
/* exists */
}
}
});
it("monotonic growth: effective follows the latest, no baseline", () => {
writeCacheRead("hw-grow", 100);
assert.equal(effectiveCacheRead("hw-grow"), 100);
writeCacheRead("hw-grow", 300);
assert.equal(effectiveCacheRead("hw-grow"), 300);
assert.equal(rawRow("hw-grow").baseline_cache_read, 0);
});
it("idempotent: rewriting the same value never changes effective", () => {
writeCacheRead("hw-idem", 500);
writeCacheRead("hw-idem", 500);
writeCacheRead("hw-idem", 500);
assert.equal(effectiveCacheRead("hw-idem"), 500);
assert.equal(rawRow("hw-idem").baseline_cache_read, 0);
});
it("decrease preserves the max (compaction never loses usage)", () => {
writeCacheRead("hw-shrink", 300);
writeCacheRead("hw-shrink", 100); // transcript shrank
assert.equal(effectiveCacheRead("hw-shrink"), 300, "effective must not drop below the peak");
const row = rawRow("hw-shrink");
assert.equal(row.cache_read_tokens, 100);
assert.equal(row.baseline_cache_read, 200); // 100 live + 200 baseline = 300
});
it("writer alternation does NOT run away (the bug)", () => {
// Two writers: 'big' (main+subagents) and 'small' (main-only) on one bucket.
for (let i = 0; i < 20; i++) {
writeCacheRead("hw-altern", 800); // big writer
writeCacheRead("hw-altern", 700); // small writer
}
// Effective must stay pinned at the true max (800), NOT accumulate.
assert.equal(effectiveCacheRead("hw-altern"), 800, "effective must stay at the peak, not grow");
assert.equal(rawRow("hw-altern").baseline_cache_read, 100); // 700 + 100 = 800
});
it("recovers upward: a new higher value zeroes stale baseline", () => {
writeCacheRead("hw-recover", 300);
writeCacheRead("hw-recover", 100); // baseline=200, eff=300
writeCacheRead("hw-recover", 500); // new peak
assert.equal(effectiveCacheRead("hw-recover"), 500);
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
});
});
@@ -0,0 +1,210 @@
/**
* @file Tests that TranscriptCache caps the size of each per-entry array
* (turnDurations / errors / compaction.entries / usageExtras.*) so a long
* session cannot grow a single cache entry without bound.
* @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 fs = require("fs");
const path = require("path");
const os = require("os");
const TranscriptCache = require("../lib/transcript-cache");
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-bounded-"));
});
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeJsonl(name, lines) {
const p = path.join(tmpDir, name);
fs.writeFileSync(p, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
return p;
}
describe("TranscriptCache._trimArray", () => {
it("exists and trims arrays to the given max length, keeping the tail", () => {
const cache = new TranscriptCache();
assert.equal(typeof cache._trimArray, "function");
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
cache._trimArray(arr, 3);
assert.deepEqual(arr, [8, 9, 10]);
});
it("is a no-op when array is within the cap", () => {
const cache = new TranscriptCache();
const arr = [1, 2, 3];
cache._trimArray(arr, 5);
assert.deepEqual(arr, [1, 2, 3]);
});
it("handles null/undefined safely", () => {
const cache = new TranscriptCache();
assert.doesNotThrow(() => cache._trimArray(null, 5));
assert.doesNotThrow(() => cache._trimArray(undefined, 5));
});
});
describe("TranscriptCache.extract — array caps", () => {
it("caps turnDurations at MAX_ARRAY_LEN on full read, keeping the tail", () => {
// 1500 turn_duration entries, ascending timestamps
const lines = [];
for (let i = 0; i < 1500; i++) {
lines.push({
type: "system",
subtype: "turn_duration",
durationMs: i + 1,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
}
const p = writeJsonl("turns.jsonl", lines);
process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN = "100";
// Re-require fresh to pick up the env override
delete require.cache[require.resolve("../lib/transcript-cache")];
const Fresh = require("../lib/transcript-cache");
const cache = new Fresh();
const result = cache.extract(p);
assert.ok(result, "expected non-null result");
assert.equal(result.turnDurations.length, 100);
// Tail-kept: durationMs should be 1401..1500
assert.equal(result.turnDurations[0].durationMs, 1401);
assert.equal(result.turnDurations[99].durationMs, 1500);
delete process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN;
delete require.cache[require.resolve("../lib/transcript-cache")];
});
it("caps errors and compaction.entries on full read", () => {
const lines = [];
for (let i = 0; i < 300; i++) {
lines.push({
isApiErrorMessage: true,
error: "rate_limit",
message: { content: [{ text: `err-${i}` }] },
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
lines.push({
isCompactSummary: true,
uuid: `c-${i}`,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
}
const p = writeJsonl("err-compact.jsonl", lines);
process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN = "50";
delete require.cache[require.resolve("../lib/transcript-cache")];
const Fresh = require("../lib/transcript-cache");
const cache = new Fresh();
const result = cache.extract(p);
assert.equal(result.errors.length, 50);
assert.equal(result.compaction.entries.length, 50);
assert.equal(
result.compaction.count,
300,
"count must reflect ALL parsed entries, not just retained"
);
delete process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN;
delete require.cache[require.resolve("../lib/transcript-cache")];
});
it("incremental merge respects cap (append to existing capped entry)", () => {
process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN = "100";
delete require.cache[require.resolve("../lib/transcript-cache")];
const Fresh = require("../lib/transcript-cache");
const cache = new Fresh();
// First batch: 80 turns
const linesA = [];
for (let i = 0; i < 80; i++) {
linesA.push({
type: "system",
subtype: "turn_duration",
durationMs: i + 1,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
}
const p = writeJsonl("incr.jsonl", linesA);
let result = cache.extract(p);
assert.equal(result.turnDurations.length, 80);
// Append 50 more — total 130, cache should retain only last 100
const fd = fs.openSync(p, "a");
for (let i = 80; i < 130; i++) {
const line =
JSON.stringify({
type: "system",
subtype: "turn_duration",
durationMs: i + 1,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
}) + "\n";
fs.writeSync(fd, line);
}
fs.closeSync(fd);
result = cache.extract(p);
assert.equal(result.turnDurations.length, 100);
// Tail check: last entry should be durationMs=130
assert.equal(result.turnDurations[99].durationMs, 130);
// Head should be durationMs=31 (130 - 100 + 1)
assert.equal(result.turnDurations[0].durationMs, 31);
delete process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN;
delete require.cache[require.resolve("../lib/transcript-cache")];
});
});
describe("TranscriptCache._set — single storage", () => {
it("cache entry contains ONLY {mtimeMs, size, bytesRead, result}", () => {
const p = writeJsonl("single.jsonl", [
{
type: "system",
subtype: "turn_duration",
durationMs: 100,
timestamp: "2026-01-01T00:00:00Z",
},
]);
const cache = new TranscriptCache();
cache.extract(p);
const entry = cache._cache.get(p);
assert.ok(entry, "entry should be cached");
const keys = Object.keys(entry).sort();
assert.deepEqual(keys, ["bytesRead", "mtimeMs", "result", "size"]);
});
it("does not store duplicate top-level errors/turnDurations/compaction", () => {
const p = writeJsonl("dup.jsonl", [
{
type: "system",
subtype: "turn_duration",
durationMs: 1,
timestamp: "2026-01-01T00:00:00Z",
},
{
isApiErrorMessage: true,
error: "x",
message: { content: [{ text: "y" }] },
timestamp: "2026-01-01T00:00:01Z",
},
{ isCompactSummary: true, uuid: "u1", timestamp: "2026-01-01T00:00:02Z" },
]);
const cache = new TranscriptCache();
cache.extract(p);
const entry = cache._cache.get(p);
assert.equal(entry.errors, undefined);
assert.equal(entry.turnDurations, undefined);
assert.equal(entry.compaction, undefined);
assert.equal(entry.tokensByModel, undefined);
assert.equal(entry.usageExtras, undefined);
assert.equal(entry.thinkingBlockCount, undefined);
assert.equal(entry.latestModel, undefined);
});
});
@@ -0,0 +1,33 @@
/**
* @file Verifies the sweep queries used in server/index.js have been migrated
* from json_extract(events.data,...) to sessions.transcript_path. Tests by
* checking the SQL strings that appear in the file rather than running the
* full setInterval the unit-level guarantee is what matters here.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const SRC = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8");
describe("server/index.js sweep queries", () => {
it("does NOT contain json_extract on events.data for transcript_path", () => {
const matches = SRC.match(/json_extract\([^)]*events?\.data[^)]*transcript_path/gi) || [];
assert.equal(
matches.length,
0,
`expected zero events.data json_extract for transcript_path; found:\n${matches.join("\n")}`
);
});
it("queries sessions.transcript_path for the active sweep", () => {
assert.match(
SRC,
/FROM sessions[^;]*WHERE[^;]*status\s*=\s*'active'[^;]*transcript_path/is,
"expected a SELECT from sessions with status='active' and transcript_path"
);
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* @file transcript-sender.test.js
* @description Unit tests for classifyTranscriptSender the transcript viewer
* must attribute each JSONL line to its TRUE sender, not blanket-label every
* `type:"user"` line as the human. Cases mirror real Claude Code transcripts:
* tool results, harness task-notifications, /loop (isMeta) re-injections, and a
* subagent's orchestrator-assigned task. (Reported transcript mis-attribution.)
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const fs = require("node:fs");
// Point the db at a throwaway file before requiring the router (it pulls in db).
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { classifyTranscriptSender } = require("../routes/sessions");
const { db } = require("../db");
after(() => {
if (db) db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + s);
} catch {
/* ignore */
}
}
});
// Shapes lifted from real ~/.claude transcripts.
const realUser = (text) => ({
type: "user",
message: { role: "user", content: text },
promptSource: "user_input",
origin: "cli",
});
const toolResult = () => ({
type: "user",
message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }] },
toolUseResult: { stdout: "ok" },
});
const assistant = () => ({ type: "assistant", message: { role: "assistant", content: [] } });
describe("classifyTranscriptSender — main transcript", () => {
it("real human message → user", () => {
assert.equal(classifyTranscriptSender(realUser("spin up a team of agents"), false), "user");
});
it("tool result (toolUseResult / tool_result content) → tool", () => {
assert.equal(classifyTranscriptSender(toolResult(), false), "tool");
});
it("harness task-notification → system", () => {
const e = {
type: "user",
message: { role: "user", content: "<task-notification>\n<task-id>abc</task-id>\n" },
promptSource: "task_notification",
origin: "system",
};
assert.equal(classifyTranscriptSender(e, false), "system");
});
it("[SYSTEM NOTIFICATION …] background-task banner → system", () => {
const e = {
type: "user",
message: {
role: "user",
content:
"[SYSTEM NOTIFICATION - NOT USER INPUT]\nThis is an automated background-task event…\n<task-notification>…",
},
};
assert.equal(classifyTranscriptSender(e, false), "system");
});
it("/loop re-injection (isMeta) → system", () => {
const e = {
type: "user",
isMeta: true,
message: { role: "user", content: "Sonnet cognition agent last one — stitch the brief" },
};
assert.equal(classifyTranscriptSender(e, false), "system");
});
it("assistant turn → assistant", () => {
assert.equal(classifyTranscriptSender(assistant(), false), "assistant");
});
it("@agent mention typed by the human → user", () => {
assert.equal(classifyTranscriptSender(realUser("@agent-ai-engineer hi"), false), "user");
});
it("local slash-command (type=system) → user", () => {
assert.equal(
classifyTranscriptSender(
{ type: "system", subtype: "local_command", content: "/color" },
false
),
"user"
);
});
});
describe("classifyTranscriptSender — subagent transcript", () => {
it("orchestrator-assigned task (no promptSource/origin) → orchestrator", () => {
const task = {
type: "user",
isSidechain: true,
agentId: "a484",
message: { role: "user", content: "Light research/synthesis task. Write a synthesis…" },
};
assert.equal(classifyTranscriptSender(task, true), "orchestrator");
});
it("tool result inside a subagent → tool", () => {
assert.equal(classifyTranscriptSender(toolResult(), true), "tool");
});
it("human directly messaging the subagent (has provenance) → user", () => {
const direct = {
type: "user",
isSidechain: true,
agentId: "a484",
message: { role: "user", content: "actually, focus on cognition" },
promptSource: "user_input",
origin: "cli",
};
assert.equal(classifyTranscriptSender(direct, true), "user");
});
it("assistant turn in a subagent → assistant", () => {
assert.equal(classifyTranscriptSender(assistant(), true), "assistant");
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* @file Branch- and fork-aware tests for getUpdatesStatus(). Each scenario
* builds throw-away git repos in a tmp dir and asserts the payload shape is
* accurate to the user's situation. skipFetch:true keeps these tests offline.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { getUpdatesStatus } = require("../lib/update-check");
function git(cwd, args) {
return execFileSync("git", args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).trim();
}
function makeBareRemote(parent, name) {
const repo = path.join(parent, `${name}.git`);
fs.mkdirSync(repo, { recursive: true });
// -c init.defaultBranch=master works on every git that supports -c init.*,
// i.e. far older than --initial-branch.
execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], {
stdio: "ignore",
});
return repo;
}
function makeWorkingRepo(parent, dir, originUrl) {
const repo = path.join(parent, dir);
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" });
fs.writeFileSync(path.join(repo, "README.md"), "fixture\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
git(repo, ["remote", "add", "origin", originUrl]);
git(repo, ["push", "-u", "origin", "master"]);
return repo;
}
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "updcheck-"));
});
after(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
});
describe("getUpdatesStatus — local on canonical default branch", () => {
it("with origin only: tracks_canonical=true, command pulls --ff-only", async () => {
const remote = makeBareRemote(tmpDir, "canon1");
const work = makeWorkingRepo(tmpDir, "work1", remote);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.equal(result.situation, "tracking_canonical");
assert.equal(result.situation_note, null);
assert.match(result.manual_command, /git pull --ff-only/);
});
});
describe("getUpdatesStatus — local on a feature branch", () => {
it("does NOT suggest git pull (would pull feature, not master)", async () => {
const remote = makeBareRemote(tmpDir, "canon2");
const work = makeWorkingRepo(tmpDir, "work2", remote);
git(work, ["checkout", "-b", "feature/foo"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, "feature/foo");
assert.equal(result.tracks_canonical, false);
assert.equal(result.situation, "feature_branch");
assert.ok(result.situation_note, "expected a situation_note for feature branches");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(
result.manual_command,
/git pull/,
"must not suggest git pull — would pull feature branch, not canonical"
);
assert.doesNotMatch(
result.manual_command,
/git merge --ff-only/,
"must not auto-merge canonical into the feature branch"
);
});
});
describe("getUpdatesStatus — fork layout (origin = fork, upstream = canonical)", () => {
it("ignores a stray upstream remote and tracks origin only", async () => {
const fork = makeBareRemote(tmpDir, "fork3");
const upstream = makeBareRemote(tmpDir, "upstream3");
const work = makeWorkingRepo(tmpDir, "work3", fork);
// Add a second remote AFTER the working clone so origin stays the fork.
git(work, ["remote", "add", "upstream", upstream]);
git(work, ["push", "upstream", "master"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
// This build tracks its own repository. A remote named `upstream` pointing
// at somebody else's copy must never become the update source.
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.doesNotMatch(result.manual_command, /upstream/);
});
});
describe("getUpdatesStatus — detached HEAD", () => {
it("reports detached_head and only suggests fetch", async () => {
const remote = makeBareRemote(tmpDir, "canon4");
const work = makeWorkingRepo(tmpDir, "work4", remote);
const sha = git(work, ["rev-parse", "HEAD"]);
git(work, ["checkout", sha]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, null);
assert.equal(result.situation, "detached_head");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(result.manual_command, /git pull/);
});
});
describe("getUpdatesStatus — no remotes configured", () => {
it("returns a soft no-remotes payload", async () => {
const repo = path.join(tmpDir, "noremote");
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" });
fs.writeFileSync(path.join(repo, "README.md"), "lonely\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
const result = await getUpdatesStatus(repo, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.update_available, false);
assert.match(result.message, /No git remotes configured/);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* @file Tests for dashboard self-update HTTP endpoints.
* @author Nguyễn Ngọc Trí <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 TEST_DB = path.join(os.tmpdir(), `dashboard-updates-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function httpFetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(options.body);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
const addr = server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.unlinkSync(TEST_DB);
fs.unlinkSync(`${TEST_DB}-wal`);
fs.unlinkSync(`${TEST_DB}-shm`);
} catch {
// ignore
}
});
describe("GET /api/updates/status", () => {
it("returns update check payload", async () => {
const res = await httpFetch("/api/updates/status");
assert.equal(res.status, 200);
assert.equal(typeof res.body.git_repo, "boolean");
assert.equal(typeof res.body.update_available, "boolean");
if (res.body.git_repo) {
assert.ok(typeof res.body.repo_root === "string");
}
});
});
describe("POST /api/updates/check", () => {
it("returns a fresh update status payload", async () => {
const res = await httpFetch("/api/updates/check", { method: "POST", body: "{}" });
assert.equal(res.status, 200);
assert.equal(typeof res.body.git_repo, "boolean");
assert.equal(typeof res.body.update_available, "boolean");
});
});
describe("removed POST /api/updates/apply", () => {
it("returns 404 because self-update has been removed", async () => {
const res = await httpFetch("/api/updates/apply", {
method: "POST",
body: "{}",
});
assert.equal(res.status, 404);
});
});
+611
View File
@@ -0,0 +1,611 @@
/**
* @file Tests for the universal webhook delivery layer: payload formatting per
* platform (slack/discord/teams/generic), HMAC signing, target CRUD with secret
* redaction, validation, the synchronous test probe, rule-scoped dispatch,
* disabled-target skipping, retry/backoff with delivery-log recording, and the
* clear-data wipe.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const crypto = require("crypto");
// Test DB + fast retry tunables must be set BEFORE requiring server modules
// (server/lib/webhooks.js reads the WEBHOOK_* env at module load).
const TEST_DB = path.join(os.tmpdir(), `dashboard-webhooks-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.WEBHOOK_MAX_ATTEMPTS = "2";
process.env.WEBHOOK_RETRY_BASE_MS = "10";
process.env.WEBHOOK_TIMEOUT_MS = "3000";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const webhooks = require("../lib/webhooks");
const providers = require("../lib/webhook-providers");
let server;
let BASE;
// Mock receiver — records every inbound request; behavior is tunable per-test.
const received = [];
let nextStatus = 200;
let nextBody = "ok"; // response body the mock returns (for body-veto tests)
let failTimes = 0; // respond 500 this many times before honoring nextStatus
let recvServer;
let RECV_URL;
function resetReceiver() {
received.length = 0;
nextStatus = 200;
nextBody = "ok";
failTimes = 0;
}
// Wipe all targets/deliveries between tests so an enabled target from one test
// never receives another test's dispatch (which would also fire real requests
// at example.com URLs from the CRUD tests).
beforeEach(() => {
db.prepare("DELETE FROM webhook_deliveries").run();
db.prepare("DELETE FROM webhook_targets").run();
webhooks.invalidateWebhookCache();
resetReceiver();
});
function fetchJson(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const post = (p, body) => fetchJson(p, { method: "POST", body });
const patch = (p, body) => fetchJson(p, { method: "PATCH", body });
const del = (p) => fetchJson(p, { method: "DELETE" });
const SAMPLE_ALERT = {
id: 42,
rule_id: "rule-abc",
rule_name: "Too many errors",
rule_type: "event_pattern",
session_id: "sess-1",
agent_id: "agent-1",
message: "5 matching events in 2 min (threshold 5)",
details: JSON.stringify({ observed_count: 5 }),
triggered_at: "2026-06-10T12:00:00.000Z",
};
before(async () => {
recvServer = http.createServer((req, res) => {
let b = "";
req.on("data", (c) => (b += c));
req.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b);
} catch {
parsed = b;
}
received.push({ method: req.method, headers: req.headers, body: parsed });
let status = nextStatus;
if (failTimes > 0) {
failTimes -= 1;
status = 500;
}
res.statusCode = status;
res.end(nextBody);
});
});
await new Promise((r) => recvServer.listen(0, "127.0.0.1", r));
RECV_URL = `http://127.0.0.1:${recvServer.address().port}/hook`;
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (recvServer) recvServer.close();
try {
db.close();
} catch {
/* ignore */
}
});
describe("payload formatting", () => {
it("slack: header + section + context blocks with fallback text", () => {
const p = webhooks.formatPayload("slack", SAMPLE_ALERT);
assert.ok(p.text.includes("Too many errors"));
assert.equal(p.blocks[0].type, "header");
assert.equal(p.blocks[1].type, "section");
assert.equal(p.blocks[2].type, "context");
assert.ok(p.blocks[1].text.text.includes("threshold 5"));
});
it("discord: single rich embed with fields", () => {
const p = webhooks.formatPayload("discord", SAMPLE_ALERT);
assert.equal(p.embeds.length, 1);
assert.ok(p.embeds[0].title.includes("Too many errors"));
assert.equal(p.embeds[0].timestamp, SAMPLE_ALERT.triggered_at);
assert.ok(p.embeds[0].fields.some((f) => f.name === "Session"));
});
it("teams: Adaptive Card wrapped in the Workflows message envelope", () => {
const p = webhooks.formatPayload("teams", SAMPLE_ALERT);
assert.equal(p.type, "message");
assert.equal(p.attachments[0].contentType, "application/vnd.microsoft.card.adaptive");
const card = p.attachments[0].content;
assert.equal(card.type, "AdaptiveCard");
const factSet = card.body.find((b) => b.type === "FactSet");
assert.ok(factSet.facts.some((f) => f.title === "Type"));
});
it("generic: stable envelope with parsed details", () => {
const p = webhooks.formatPayload("generic", SAMPLE_ALERT);
assert.equal(p.event, "alert.triggered");
assert.equal(p.alert.rule_name, "Too many errors");
assert.deepEqual(p.alert.details, { observed_count: 5 });
});
it("generic: HMAC signature header when secret set", () => {
const target = { type: "generic", url: "https://x.test", secret: "s3cr3t" };
const { body, headers } = webhooks.buildRequest(target, SAMPLE_ALERT);
const ts = headers["X-Webhook-Timestamp"];
const expected =
"sha256=" + crypto.createHmac("sha256", "s3cr3t").update(`${ts}.${body}`).digest("hex");
assert.equal(headers["X-Webhook-Signature"], expected);
});
it("generic: custom headers cannot clobber Content-Type or signature", () => {
const target = {
type: "generic",
url: "https://x.test",
headers: { "Content-Type": "text/plain", "X-Webhook-Signature": "fake", "X-Custom": "ok" },
};
const { headers } = webhooks.buildRequest(target, SAMPLE_ALERT);
assert.equal(headers["Content-Type"], "application/json");
assert.equal(headers["X-Custom"], "ok");
assert.notEqual(headers["X-Webhook-Signature"], "fake");
});
});
describe("target CRUD + redaction", () => {
it("creates a generic target and never returns the raw url/secret", async () => {
const res = await post("/api/webhooks", {
name: "My endpoint",
type: "generic",
url: "https://example.com/hook/SECRET-TOKEN-1234",
secret: "signing-secret",
headers: { Authorization: "Bearer abc" },
});
assert.equal(res.status, 201);
const t = res.body.target;
assert.equal(t.name, "My endpoint");
assert.equal(t.has_secret, true);
assert.ok(!("secret" in t));
assert.ok(!t.url_preview.includes("SECRET-TOKEN"));
assert.ok(t.url_preview.includes("example.com"));
assert.deepEqual(t.headers, { Authorization: "••••" }); // value masked
});
it("rejects an invalid url and a bad type", async () => {
const r1 = await post("/api/webhooks", { name: "x", type: "generic", url: "not a url" });
assert.equal(r1.status, 400);
const r2 = await post("/api/webhooks", {
name: "x",
type: "carrier-pigeon",
url: "https://x.io",
});
assert.equal(r2.status, 400);
});
it("requires https for slack/discord/teams", async () => {
const r = await post("/api/webhooks", {
name: "x",
type: "slack",
url: "http://insecure.test/x",
});
assert.equal(r.status, 400);
});
it("patches enabled without touching the url/secret", async () => {
const created = await post("/api/webhooks", {
name: "patch-me",
type: "generic",
url: "https://example.com/abc",
secret: "keep-me",
});
const id = created.body.target.id;
const res = await patch(`/api/webhooks/${id}`, { enabled: false });
assert.equal(res.status, 200);
assert.equal(res.body.target.enabled, false);
assert.equal(res.body.target.has_secret, true); // secret preserved
// raw row still has the secret
assert.equal(stmts.getWebhookTarget.get(id).secret, "keep-me");
});
it("deletes a target", async () => {
const created = await post("/api/webhooks", {
name: "delete-me",
type: "generic",
url: "https://example.com/del",
});
const id = created.body.target.id;
const res = await del(`/api/webhooks/${id}`);
assert.equal(res.status, 200);
assert.equal(stmts.getWebhookTarget.get(id), undefined);
});
});
describe("delivery", () => {
it("dispatches a fired alert to an enabled target with the generic payload", async () => {
resetReceiver();
const created = await post("/api/webhooks", {
name: "live",
type: "generic",
url: RECV_URL,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 1);
assert.equal(received[0].body.event, "alert.triggered");
assert.equal(received[0].body.alert.rule_name, "Too many errors");
// a success delivery row was recorded
const last = stmts.lastWebhookDeliveryForTarget.get(created.body.target.id);
assert.equal(last.status, "success");
});
it("skips disabled targets", async () => {
resetReceiver();
const created = await post("/api/webhooks", {
name: "off",
type: "generic",
url: RECV_URL,
enabled: false,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 0);
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id), undefined);
});
it("honors rule_ids scoping", async () => {
resetReceiver();
await post("/api/webhooks", {
name: "scoped",
type: "generic",
url: RECV_URL,
rule_ids: ["some-other-rule"],
});
await webhooks.dispatchAlert(SAMPLE_ALERT); // rule_id "rule-abc" not in scope
assert.equal(received.length, 0);
});
it("retries on 5xx then records success", async () => {
resetReceiver();
failTimes = 1; // first attempt 500, second 200
const created = await post("/api/webhooks", {
name: "retry",
type: "generic",
url: RECV_URL,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 2); // one failed + one retried
const last = stmts.lastWebhookDeliveryForTarget.get(created.body.target.id);
assert.equal(last.status, "success");
assert.equal(last.attempts, 2);
});
it("records a failure when all attempts return 5xx", async () => {
resetReceiver();
nextStatus = 500;
failTimes = 0;
const created = await post("/api/webhooks", {
name: "fail",
type: "generic",
url: RECV_URL,
});
const settled = await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(settled[0].value.ok, false);
const last = stmts.lastWebhookDeliveryForTarget.get(created.body.target.id);
assert.equal(last.status, "failed");
assert.equal(last.status_code, 500);
});
it("does not retry on 4xx", async () => {
resetReceiver();
nextStatus = 400;
const created = await post("/api/webhooks", {
name: "badreq",
type: "generic",
url: RECV_URL,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 1); // no retry
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id).status, "failed");
});
});
describe("test probe + clear-data", () => {
it("POST /:id/test delivers a synthetic alert and reports ok", async () => {
resetReceiver();
const created = await post("/api/webhooks", {
name: "probe",
type: "generic",
url: RECV_URL,
});
const res = await post(`/api/webhooks/${created.body.target.id}/test`);
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(received.length, 1);
assert.equal(received[0].body.alert.rule_type, "test");
});
it("clear-data wipes the delivery log but keeps targets", async () => {
resetReceiver();
const created = await post("/api/webhooks", { name: "keep", type: "generic", url: RECV_URL });
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.ok(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id));
await post("/api/settings/clear-data");
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id), undefined);
assert.ok(stmts.getWebhookTarget.get(created.body.target.id)); // target survives
});
});
describe("provider registry", () => {
it("exposes 14 first-class providers (+ generic = 15 types)", () => {
const firstClass = [
"slack",
"discord",
"teams",
"google_chat",
"mattermost",
"rocketchat",
"telegram",
"pagerduty",
"opsgenie",
"splunk_oncall",
"zapier",
"make",
"n8n",
"pipedream",
];
assert.equal(firstClass.length, 14);
for (const t of firstClass) {
assert.ok(providers.WEBHOOK_TYPES.includes(t), `${t} missing`);
}
assert.ok(providers.WEBHOOK_TYPES.includes("generic"));
assert.equal(providers.WEBHOOK_TYPES.length, 15);
});
it("GET /api/webhooks/providers returns redacted metadata", async () => {
const res = await fetchJson("/api/webhooks/providers");
assert.equal(res.status, 200);
const pd = res.body.providers.find((p) => p.type === "pagerduty");
assert.equal(pd.url_required, false); // has default URL
assert.ok(pd.fields.find((f) => f.key === "routing_key" && f.secret));
const slack = res.body.providers.find((p) => p.type === "slack");
assert.equal(slack.url_required, true);
});
});
describe("provider payload formatting", () => {
it("mattermost: Slack-style attachments", () => {
const p = providers.formatPayload("mattermost", SAMPLE_ALERT);
assert.ok(Array.isArray(p.attachments));
assert.ok(p.attachments[0].fields.some((f) => f.title === "Type"));
});
it("rocketchat: text + attachments", () => {
const p = providers.formatPayload("rocketchat", SAMPLE_ALERT);
assert.ok(p.text.includes("Too many errors"));
assert.ok(Array.isArray(p.attachments));
});
it("google_chat: simple text message", () => {
const p = providers.formatPayload("google_chat", SAMPLE_ALERT);
assert.ok(typeof p.text === "string" && p.text.includes("Too many errors"));
});
it("telegram: sendMessage shape with chat_id + HTML escaping", () => {
const p = providers.formatPayload(
"telegram",
{ ...SAMPLE_ALERT, rule_name: "a<b>c" },
{ chat_id: "123" }
);
assert.equal(p.chat_id, "123");
assert.equal(p.parse_mode, "HTML");
assert.ok(p.text.includes("a&lt;b&gt;c")); // escaped
});
it("pagerduty: Events API v2 with routing_key, severity, dedup_key", () => {
const p = providers.formatPayload("pagerduty", SAMPLE_ALERT, {
routing_key: "RK",
severity: "critical",
});
assert.equal(p.routing_key, "RK");
assert.equal(p.event_action, "trigger");
assert.equal(p.payload.severity, "critical");
assert.ok(p.dedup_key.includes(SAMPLE_ALERT.rule_id));
});
it("opsgenie: message + alias; api_key goes in the GenieKey header, not body", () => {
const p = providers.formatPayload("opsgenie", SAMPLE_ALERT, { api_key: "KEY" });
assert.ok(p.message.includes("Too many errors"));
assert.ok(!JSON.stringify(p).includes("KEY")); // key not in body
const headers = providers.resolveAuthHeaders({ type: "opsgenie", config: { api_key: "KEY" } });
assert.equal(headers.Authorization, "GenieKey KEY");
});
it("splunk_oncall: VictorOps message_type + entity", () => {
const p = providers.formatPayload("splunk_oncall", SAMPLE_ALERT, { severity: "CRITICAL" });
assert.equal(p.message_type, "CRITICAL");
assert.ok(p.entity_id.includes(SAMPLE_ALERT.rule_id));
});
it("generic family (zapier) uses the JSON envelope", () => {
const p = providers.formatPayload("zapier", SAMPLE_ALERT);
assert.equal(p.event, "alert.triggered");
});
});
describe("URL resolution", () => {
it("telegram derives its URL from the bot token", () => {
const url = providers.resolveUrl({
type: "telegram",
config: { bot_token: "TOK", chat_id: "1" },
});
assert.equal(url, "https://api.telegram.org/botTOK/sendMessage");
});
it("opsgenie picks the EU host when region=eu", () => {
assert.ok(
providers
.resolveUrl({ type: "opsgenie", config: { region: "eu" } })
.includes("api.eu.opsgenie.com")
);
assert.ok(
providers
.resolveUrl({ type: "opsgenie", config: { region: "us" } })
.includes("api.opsgenie.com")
);
});
it("pagerduty defaults to the Events API URL", () => {
assert.equal(
providers.resolveUrl({ type: "pagerduty", config: {} }),
"https://events.pagerduty.com/v2/enqueue"
);
});
});
describe("Splunk On-Call response-body veto", () => {
it("verifyResponse flags result=failure, trusts everything else", () => {
const vr = providers.PROVIDERS.splunk_oncall.verifyResponse;
assert.equal(vr('{"result":"failure","message":"bad routing key"}').ok, false);
assert.equal(vr('{"result":"success","entity_id":"x"}').ok, true);
assert.equal(vr("").ok, true); // empty body trusted
assert.equal(vr("not json").ok, true); // non-JSON 200 trusted
});
it("deliver() records a 200-with-result:failure as failed (no retry)", async () => {
resetReceiver();
nextStatus = 200;
nextBody = JSON.stringify({ result: "failure", message: "bad routing key" });
// Insert directly (splunk is https-only, can't go through the http-mock route).
const id = "splunk-veto-test";
stmts.insertWebhookTarget.run(
id,
"splunk",
"splunk_oncall",
RECV_URL,
1,
null,
null,
null,
JSON.stringify({ severity: "WARNING" })
);
webhooks.invalidateWebhookCache();
const target = webhooks.normalizeTarget(stmts.getWebhookTarget.get(id));
const res = await webhooks.deliver(target, SAMPLE_ALERT);
assert.equal(res.ok, false);
assert.match(res.error, /failure|bad routing key/i);
assert.equal(received.length, 1); // no retry on a logical rejection
assert.equal(stmts.lastWebhookDeliveryForTarget.get(id).status, "failed");
});
});
describe("provider CRUD + config redaction", () => {
it("creates a telegram target without a URL and redacts the bot token", async () => {
const res = await post("/api/webhooks", {
name: "tg",
type: "telegram",
config: { bot_token: "12345:SECRETTOKEN", chat_id: "999" },
});
assert.equal(res.status, 201);
const t = res.body.target;
assert.equal(t.config.bot_token, "••••"); // redacted
assert.equal(t.config.chat_id, "999"); // shown
assert.ok(!JSON.stringify(t).includes("SECRETTOKEN"));
assert.ok(t.url_preview.includes("api.telegram.org"));
});
it("requires routing_key for pagerduty", async () => {
const res = await post("/api/webhooks", { name: "pd", type: "pagerduty", config: {} });
assert.equal(res.status, 400);
});
it("rejects an unknown severity enum", async () => {
const res = await post("/api/webhooks", {
name: "pd2",
type: "pagerduty",
config: { routing_key: "RK", severity: "nope" },
});
assert.equal(res.status, 400);
});
it("patches opsgenie region without re-sending the api_key", async () => {
const created = await post("/api/webhooks", {
name: "og",
type: "opsgenie",
config: { api_key: "AAA", region: "us" },
});
const id = created.body.target.id;
const res = await patch(`/api/webhooks/${id}`, { config: { region: "eu" } });
assert.equal(res.status, 200);
assert.equal(res.body.target.config.region, "eu");
assert.equal(
stmts.getWebhookTarget.get(id) && JSON.parse(stmts.getWebhookTarget.get(id).config).api_key,
"AAA"
);
});
// pagerduty/opsgenie endpoints are https-only and (opsgenie) derive their own
// URL, so they can't point at the http test mock — assert the built request
// (URL + headers + body) directly instead.
it("buildRequest: pagerduty hits the Events API with the routing key in the body", () => {
const req = webhooks.buildRequest(
{ type: "pagerduty", config: { routing_key: "RK123", severity: "error" } },
SAMPLE_ALERT
);
assert.equal(req.url, "https://events.pagerduty.com/v2/enqueue");
const body = JSON.parse(req.body);
assert.equal(body.routing_key, "RK123");
assert.equal(body.payload.severity, "error");
});
it("buildRequest: opsgenie targets the region host and sets the GenieKey header", () => {
const req = webhooks.buildRequest(
{ type: "opsgenie", config: { api_key: "KEY9", region: "eu" } },
SAMPLE_ALERT
);
assert.ok(req.url.includes("api.eu.opsgenie.com"));
assert.equal(req.headers.Authorization, "GenieKey KEY9");
assert.ok(!req.body.includes("KEY9")); // key only in the header
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* @file Integration test for the Workflow-tool run feature (issue #167): a Stop
* hook with a transcript_path triggers on-disk journal ingestion off the
* response path, and the run then surfaces via GET /api/workflows/runs,
* GET /api/workflows/runs/:runId, and the session-detail `workflows[]` field.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-wfapi-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
let ROOT;
const SESSION_ID = "wfapi-sess-1";
function fetchJson(urlPath, options = {}) {
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: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
},
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
try {
resolve({ status: res.statusCode, body: body ? JSON.parse(body) : null });
} catch {
resolve({ status: res.statusCode, body });
}
});
}
);
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
before(async () => {
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "wfapi-fixture-"));
const transcriptPath = path.join(ROOT, `${SESSION_ID}.jsonl`);
fs.writeFileSync(transcriptPath, "");
// On-disk run journal next to the transcript.
const wfDir = path.join(ROOT, SESSION_ID, "workflows");
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(
path.join(wfDir, "wf_apitest1.json"),
JSON.stringify({
runId: "wf_apitest1",
workflowName: "api-review",
status: "completed",
startTime: 1700001000000,
durationMs: 4000,
defaultModel: "claude-opus-4-8",
agentCount: 1,
totalTokens: 999,
totalToolCalls: 2,
phases: [{ title: "Scan" }],
workflowProgress: [
{ type: "workflow_phase", index: 1, title: "Scan" },
{
type: "workflow_agent",
index: 1,
agentId: "x1",
state: "done",
phaseTitle: "Scan",
label: "scan:repo",
tokens: 999,
toolCalls: 2,
},
],
})
);
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
// Drive a Stop hook with the transcript_path — this is what triggers the
// post-response workflow ingest in the hooks router.
await fetchJson("/api/hooks/event", {
method: "POST",
body: {
hook_type: "Stop",
data: {
session_id: SESSION_ID,
cwd: "/tmp/proj",
transcript_path: transcriptPath,
},
},
});
// Ingest is fire-and-forget after res.json; poll until it lands.
for (let i = 0; i < 40; i++) {
const r = await fetchJson(`/api/workflows/runs?session_id=${SESSION_ID}`);
if (r.body && r.body.runs && r.body.runs.length > 0) break;
await sleep(50);
}
});
after(() => {
server?.close();
try {
db.close();
} catch {
/* ignore */
}
try {
fs.rmSync(ROOT, { recursive: true, force: true });
} catch {
/* ignore */
}
try {
fs.rmSync(TEST_DB, { force: true });
} catch {
/* ignore */
}
});
describe("GET /api/workflows/runs", () => {
it("lists the ingested run with hydrated phases/progress arrays", async () => {
const r = await fetchJson(`/api/workflows/runs?session_id=${SESSION_ID}`);
assert.equal(r.status, 200);
assert.ok(Array.isArray(r.body.runs));
const run = r.body.runs.find((x) => x.run_id === "wf_apitest1");
assert.ok(run, "run present");
assert.equal(run.name, "api-review");
assert.equal(run.status, "completed");
assert.equal(run.total_tokens, 999);
assert.ok(Array.isArray(run.phases) && run.phases.length === 1);
assert.ok(Array.isArray(run.progress) && run.progress.length === 2);
assert.equal(run.progress.filter((p) => p.type === "workflow_agent").length, 1);
assert.equal(typeof r.body.total, "number");
assert.ok(r.body.counts && typeof r.body.counts === "object");
assert.ok(r.body.counts.completed >= 1, "status counts include completed");
});
it("filters by status", async () => {
const completed = await fetchJson("/api/workflows/runs?status=completed");
assert.equal(completed.status, 200);
assert.ok(completed.body.runs.some((x) => x.run_id === "wf_apitest1"));
const running = await fetchJson("/api/workflows/runs?status=running");
assert.equal(running.status, 200);
assert.ok(
!running.body.runs.some((x) => x.run_id === "wf_apitest1"),
"completed run excluded from the running filter"
);
});
});
describe("GET /api/workflows/runs/:runId", () => {
it("returns the run with its linked inner agents", async () => {
const r = await fetchJson("/api/workflows/runs/wf_apitest1");
assert.equal(r.status, 200);
assert.equal(r.body.workflow.run_id, "wf_apitest1");
assert.ok(Array.isArray(r.body.agents));
const linked = r.body.agents.find((a) => a.id === `${SESSION_ID}-jsonl-x1`);
assert.ok(linked, "inner agent linked");
assert.equal(linked.workflow_run_id, "wf_apitest1");
assert.equal(linked.workflow_phase, "Scan");
});
it("404s an unknown run id", async () => {
const r = await fetchJson("/api/workflows/runs/wf_nope");
assert.equal(r.status, 404);
});
});
describe("GET /api/sessions/:id includes workflows[]", () => {
it("surfaces the run on the parent session detail", async () => {
const r = await fetchJson(`/api/sessions/${SESSION_ID}`);
assert.equal(r.status, 200);
assert.ok(Array.isArray(r.body.workflows));
assert.ok(r.body.workflows.some((w) => w.run_id === "wf_apitest1"));
});
});
+463
View File
@@ -0,0 +1,463 @@
/**
* @file Tests for Workflow-tool run ingestion (issue #167): parsing the on-disk
* run journal, upserting a workflows row, linking inner agents by the shared
* `${sessionId}-jsonl-<agentId>` id scheme, idempotency, runningcompleted
* detection with launch-time preservation, and folding inner-agent token usage
* into the session cost under a namespaced `workflow` service_tier.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Isolated test DB before requiring any server module.
const TEST_DB = path.join(os.tmpdir(), `dashboard-wf-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { stmts } = dbModule;
const {
ingestWorkflowsForSession,
ingestAllWorkflows,
workflowsMaxMtime,
extractRunId,
nameFromScript,
mapState,
} = require("../lib/workflow-ingest");
const SESSION_ID = "sess-wf-1";
let ROOT; // temp transcript root
let transcriptPath;
function writeJson(p, obj) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(obj));
}
// A minimal subagent transcript with token usage + one tool call.
function agentJsonl(model, input, output) {
return [
{ type: "user", timestamp: "2026-02-01T00:00:00.000Z", message: { content: "go" } },
{
type: "assistant",
timestamp: "2026-02-01T00:00:02.000Z",
message: {
model,
content: [{ type: "tool_use", id: "t1", name: "WebSearch", input: {} }],
usage: {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
}
function subagentDir() {
return path.join(ROOT, SESSION_ID, "subagents");
}
function workflowsDir() {
return path.join(ROOT, SESSION_ID, "workflows");
}
before(() => {
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "wf-fixture-"));
transcriptPath = path.join(ROOT, `${SESSION_ID}.jsonl`);
fs.writeFileSync(transcriptPath, ""); // only dirname + basename are used
// Parent session + main agent (FK targets).
stmts.insertSession.run(
SESSION_ID,
"WF test session",
"active",
"/tmp/proj",
"claude-opus-4-8",
null
);
stmts.insertAgent.run(
`${SESSION_ID}-main`,
SESSION_ID,
"Main",
"main",
null,
"completed",
null,
null,
null
);
// A completed run journal with two inner agents in two phases.
writeJson(path.join(workflowsDir(), "wf_test123.json"), {
runId: "wf_test123",
taskId: "task-1",
workflowName: "review-changes",
status: "completed",
startTime: 1700000000000,
durationMs: 5000,
defaultModel: "claude-opus-4-8",
agentCount: 2,
totalTokens: 12345,
totalToolCalls: 7,
phases: [
{ title: "Review", detail: "review the diff" },
{ title: "Verify", detail: "verify findings" },
],
workflowProgress: [
{ type: "workflow_phase", index: 1, title: "Review" },
{ type: "workflow_phase", index: 2, title: "Verify" },
{
type: "workflow_agent",
index: 1,
agentId: "a1",
model: "claude-opus-4-8",
state: "done",
label: "review:bugs",
phaseTitle: "Review",
startedAt: 1700000000000,
tokens: 5000,
toolCalls: 3,
durationMs: 2000,
lastToolName: "Read",
},
{
type: "workflow_agent",
index: 2,
agentId: "a2",
model: "claude-haiku-4-5",
state: "error",
label: "verify:x",
phaseTitle: "Verify",
startedAt: 1700000002000,
tokens: 7345,
toolCalls: 4,
durationMs: 3000,
lastToolName: "Bash",
},
],
});
// Inner-agent transcripts in the per-run nested dir, each with token usage so
// ingest can fold their spend into the session cost.
const runAgentDir = path.join(workflowsDir(), "..", "subagents", "workflows", "wf_test123");
const agentLines = (model, input, output) =>
[
{ type: "user", timestamp: "2026-01-01T00:00:00.000Z", message: { content: "go" } },
{
type: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
message: {
model,
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
fs.mkdirSync(runAgentDir, { recursive: true });
fs.writeFileSync(
path.join(runAgentDir, "agent-a1.jsonl"),
agentLines("claude-opus-4-8", 4000, 1000)
);
fs.writeFileSync(
path.join(runAgentDir, "agent-a2.jsonl"),
agentLines("claude-opus-4-8", 6000, 1345)
);
});
after(() => {
try {
fs.rmSync(ROOT, { recursive: true, force: true });
} catch {
/* ignore */
}
try {
dbModule.db.close();
} catch {
/* ignore */
}
try {
fs.rmSync(TEST_DB, { force: true });
} catch {
/* ignore */
}
});
describe("extractRunId / nameFromScript", () => {
it("derives the same run id from a journal and its launch script", () => {
assert.equal(extractRunId("wf_run999.json"), "wf_run999");
assert.equal(extractRunId("/x/y/myflow-wf_run999.js"), "wf_run999");
});
it("strips the -wf_<runId> tail to recover the workflow name", () => {
assert.equal(nameFromScript("review-changes-wf_abc123.js"), "review-changes");
});
});
describe("mapState", () => {
it("maps journal states to agent statuses", () => {
assert.equal(mapState("done"), "completed");
assert.equal(mapState("completed"), "completed");
assert.equal(mapState("success"), "completed");
assert.equal(mapState("error"), "error");
assert.equal(mapState("failed"), "error");
assert.equal(mapState("running"), "working");
assert.equal(mapState("queued"), "working");
assert.equal(mapState("in_progress"), "working");
assert.equal(mapState("anything-unknown"), "completed");
assert.equal(mapState(null), "completed");
});
});
describe("ingestWorkflowsForSession — completed journal", () => {
it("ingests the journal as a workflow row with parsed phases/progress", async () => {
const changed = await ingestWorkflowsForSession(dbModule, {
id: SESSION_ID,
transcript_path: transcriptPath,
});
assert.ok(changed.length >= 1);
const wf = stmts.getWorkflow.get("wf_test123");
assert.ok(wf, "workflow row exists");
assert.equal(wf.session_id, SESSION_ID);
assert.equal(wf.name, "review-changes");
assert.equal(wf.status, "completed");
assert.equal(wf.agent_count, 2);
assert.equal(wf.total_tokens, 12345);
assert.equal(wf.total_tool_calls, 7);
assert.equal(wf.source, "journal");
assert.ok(wf.started_at, "started_at populated");
assert.equal(wf.ended_at, new Date(1700000000000 + 5000).toISOString());
assert.equal(JSON.parse(wf.phases).length, 2);
// progress keeps all entries (2 phase markers + 2 agents)
assert.equal(JSON.parse(wf.progress).length, 4);
assert.equal(JSON.parse(wf.progress).filter((p) => p.type === "workflow_agent").length, 2);
});
it("links each inner agent by the shared jsonl id scheme, with phase + status", () => {
const a1 = stmts.getAgent.get(`${SESSION_ID}-jsonl-a1`);
const a2 = stmts.getAgent.get(`${SESSION_ID}-jsonl-a2`);
assert.ok(a1 && a2, "both inner-agent rows exist");
assert.equal(a1.workflow_run_id, "wf_test123");
assert.equal(a1.workflow_phase, "Review");
assert.equal(a1.status, "completed");
assert.equal(a2.workflow_run_id, "wf_test123");
assert.equal(a2.workflow_phase, "Verify");
assert.equal(a2.status, "error");
const linked = stmts.listAgentsByWorkflow.all("wf_test123");
assert.equal(linked.length, 2);
});
it("folds inner-agent tokens into the session under a 'workflow' service_tier", () => {
const rows = dbModule.db
.prepare("SELECT * FROM token_usage WHERE session_id = ?")
.all(SESSION_ID);
assert.ok(rows.length > 0, "workflow token rows written");
assert.ok(
rows.every((r) => r.service_tier === "workflow"),
"isolated under the workflow tier (no collision with main buckets)"
);
// a1(4000)+a2(6000)=10000 input, 1000+1345=2345 output (same model → one row)
const totalInput = rows.reduce((s, r) => s + r.input_tokens + r.baseline_input, 0);
const totalOutput = rows.reduce((s, r) => s + r.output_tokens + r.baseline_output, 0);
assert.equal(totalInput, 10000);
assert.equal(totalOutput, 2345);
});
it("is idempotent — re-ingest creates no duplicate rows and stable token totals", async () => {
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const wfCount = dbModule.db
.prepare("SELECT COUNT(*) AS n FROM workflows WHERE session_id = ?")
.get(SESSION_ID);
assert.equal(wfCount.n, 1);
const subCount = dbModule.db
.prepare("SELECT COUNT(*) AS n FROM agents WHERE session_id = ? AND type = 'subagent'")
.get(SESSION_ID);
assert.equal(subCount.n, 2);
// tokens not double-counted on re-ingest (replace semantics)
const tot = dbModule.db
.prepare(
"SELECT SUM(input_tokens + baseline_input) AS i FROM token_usage WHERE session_id = ?"
)
.get(SESSION_ID);
assert.equal(tot.i, 10000);
});
});
describe("running detection → completed transition", () => {
it("shows a launch-script-only run as running, then completes it preserving started_at", async () => {
// 1) Launch script, no journal yet.
fs.mkdirSync(path.join(workflowsDir(), "scripts"), { recursive: true });
fs.writeFileSync(path.join(workflowsDir(), "scripts", "deep-audit-wf_run999.js"), "// script");
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const running = stmts.getWorkflow.get("wf_run999");
assert.ok(running, "running row created from launch script");
assert.equal(running.status, "running");
assert.equal(running.source, "live");
assert.equal(running.name, "deep-audit");
assert.ok(running.started_at, "running row has a launch time");
const launchTime = running.started_at;
// 2) Journal lands → same run_id → becomes completed, launch time preserved.
writeJson(path.join(workflowsDir(), "wf_run999.json"), {
runId: "wf_run999",
workflowName: "deep-audit",
status: "completed",
startTime: 1700000500000,
durationMs: 1000,
agentCount: 0,
totalTokens: 0,
totalToolCalls: 0,
phases: [],
workflowProgress: [],
});
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const done = stmts.getWorkflow.get("wf_run999");
assert.equal(done.status, "completed");
assert.equal(done.started_at, launchTime, "launch time preserved across transition");
});
});
describe("workflowsMaxMtime", () => {
it("returns the newest artifact mtime for a session with workflows, 0 otherwise", () => {
assert.ok(workflowsMaxMtime(transcriptPath) > 0, "fingerprint > 0 when journals exist");
assert.equal(
workflowsMaxMtime(path.join(ROOT, "no-such-session.jsonl")),
0,
"0 when there are no workflow artifacts"
);
});
// Regression guard for the maintenance sweep (server/index.js step 3) and
// startWorkflowPoll: both skip a session whose workflow artifacts are
// unchanged and re-ingest only once the fingerprint advances. Before the
// gate, the 5-min sweep full-re-parsed every workflow journal and every
// inner agent-*.jsonl for every active session every cycle; on a large
// corpus each sweep outran the interval, sweeps overlapped, and the event
// loop pegged (dashboard stopped responding). This asserts the exact
// skip/re-ingest decision that gate relies on. Isolated root — no shared
// fixture state so later suites are unaffected.
it("gates skip vs re-ingest: stable fingerprint when unchanged, higher after a new artifact", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
try {
const sid = "sess-gate";
const tp = path.join(root, `${sid}.jsonl`);
fs.writeFileSync(tp, "");
const wdir = path.join(root, sid, "workflows");
writeJson(path.join(wdir, "wf_a.json"), {
runId: "wf_a",
status: "completed",
startTime: 1700000000000,
workflowProgress: [],
});
const seen = new Map(); // mirrors sweepWorkflowSeen / lastSeen
const m1 = workflowsMaxMtime(tp);
assert.ok(m1 > 0, "fingerprint > 0 with a journal");
assert.equal(m1 === 0 || seen.get(sid) === m1, false, "first sight ingests");
seen.set(sid, m1);
const m2 = workflowsMaxMtime(tp);
assert.equal(m2, m1, "fingerprint stable when nothing changes");
assert.equal(m2 === 0 || seen.get(sid) === m2, true, "unchanged session is skipped");
const later = path.join(wdir, "wf_b.json");
writeJson(later, {
runId: "wf_b",
status: "completed",
startTime: 1700000001000,
workflowProgress: [],
});
const bump = m1 / 1000 + 5; // seconds, safely newer than m1
fs.utimesSync(later, bump, bump);
const m3 = workflowsMaxMtime(tp);
assert.ok(m3 > m1, "fingerprint advances when a new artifact appears");
assert.equal(m3 === 0 || seen.get(sid) === m3, false, "changed session is re-ingested");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
describe("live running workflow (no terminal journal)", () => {
it("builds real-time progress + tokens from the streaming run dir", async () => {
const runId = "wf_live77";
const runDir = path.join(ROOT, SESSION_ID, "subagents", "workflows", runId);
fs.mkdirSync(runDir, { recursive: true });
// a1 finished (has a result event), a2 still running (started only)
fs.writeFileSync(path.join(runDir, "agent-a1.jsonl"), agentJsonl("claude-opus-4-8", 3000, 800));
fs.writeFileSync(path.join(runDir, "agent-a2.jsonl"), agentJsonl("claude-opus-4-8", 1500, 200));
fs.writeFileSync(
path.join(runDir, "journal.jsonl"),
[
JSON.stringify({ type: "started", agentId: "a1" }),
JSON.stringify({ type: "started", agentId: "a2" }),
// a3 started but has no transcript yet (queued) → minimal live entry
JSON.stringify({ type: "started", agentId: "a3" }),
JSON.stringify({ type: "result", agentId: "a1", result: { ok: true, note: "done" } }),
].join("\n")
);
// a launch script (no terminal journal) → name resolves from it
fs.mkdirSync(path.join(workflowsDir(), "scripts"), { recursive: true });
fs.writeFileSync(path.join(workflowsDir(), "scripts", `ds-pipeline-${runId}.js`), "// s");
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const wf = stmts.getWorkflow.get(runId);
assert.ok(wf, "live run row created");
assert.equal(wf.status, "running", "shown as running before terminal journal");
assert.equal(wf.source, "live");
assert.equal(wf.name, "ds-pipeline");
assert.equal(wf.agent_count, 3, "two transcripts + one queued agent");
assert.ok(wf.total_tokens > 0, "live tokens accumulated");
assert.ok(wf.total_tool_calls >= 2, "live tool calls counted");
const prog = JSON.parse(wf.progress);
assert.equal(prog.length, 3);
const a1 = prog.find((p) => p.agentId === "a1");
const a2 = prog.find((p) => p.agentId === "a2");
const a3 = prog.find((p) => p.agentId === "a3");
assert.equal(a1.state, "done", "a1 has a result → done");
assert.equal(a2.state, "running", "a2 only started → running");
assert.equal(a3.state, "running", "a3 queued (started, no transcript) → running");
assert.equal(a3.tokens, 0, "queued agent has no tokens yet");
assert.ok(a1.tokens > 0 && a1.toolCalls >= 1);
assert.ok(a1.resultPreview, "finished agent carries its result");
// inner agents linked + live workflow tokens folded under the workflow tier
assert.equal(stmts.listAgentsByWorkflow.all(runId).length, 2);
const liveTier = dbModule.db
.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = ? AND service_tier = ?")
.get(SESSION_ID, "workflow");
assert.ok(liveTier.n >= 1, "workflow-tier cost row written for the live run");
});
});
describe("ingestAllWorkflows backfill", () => {
it("ingests on-disk workflows for sessions whose transcript_path is in the DB", async () => {
// Backfill resolves the transcript from the session row, so persist it.
dbModule.db
.prepare("UPDATE sessions SET transcript_path = ? WHERE id = ?")
.run(transcriptPath, SESSION_ID);
const res = await ingestAllWorkflows(dbModule);
assert.ok(res.sessions >= 1, "at least one session backfilled");
assert.ok(res.workflows >= 1, "at least one workflow ingested");
// The completed fixture run is present after backfill.
assert.ok(stmts.getWorkflow.get("wf_test123"), "fixture run present");
});
});
@@ -0,0 +1,293 @@
/**
* @file Tests for dual-layout (flat + nested Workflow-tool) sub-agent transcript
* resolution. Covers the resolver helpers in server/lib/claude-home.js, the
* durable snapshot writer in scripts/import-history.js, and the end-to-end
* GET /:id/transcript?run_id= route that surfaces a workflow inner agent's full
* (un-truncated) prompt/result text in the Workflows UI. Uses Node's built-in
* test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR roots.
* @author Nguyễn Ngọc Trí <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");
// Isolate the dashboard DB and Claude Code home BEFORE requiring server modules.
const STAMP = `wf-transcript-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
const {
resolveAgentTranscriptInDir,
getSubagentTranscriptPath,
findSubagentTranscriptPath,
getSnapshotSubagentTranscriptPath,
} = require("../lib/claude-home");
const {
snapshotTranscript,
findSessionWorkflowSubagents,
} = require("../../scripts/import-history");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
// encodeCwd is not exported; mirror its rule for building project paths.
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const CWD = "/tmp/cam-wf-project";
const SESSION = "sess-wf-resolve";
const SESSION_SNAP = "sess-wf-snapshot";
const RUN1 = "wf_run1aaaaaa";
const RUN2 = "wf_run2bbbbbb";
const WF_ID = "ad18a79192af10ed1"; // workflow inner agent — nested only
const FLAT_ID = "bd29b80203bf21fe2"; // regular sub-agent — flat
const DUP_ID = "cc1122334455667788"; // present in BOTH runs — ambiguous
const PROJECTS = path.join(CLAUDE_HOME, "projects");
const ENC_DIR = path.join(PROJECTS, enc(CWD));
const SUBAGENTS = path.join(ENC_DIR, SESSION, "subagents");
// A result longer than the journal's truncated preview would ever carry, so the
// route test can prove the full text (not a "…"-suffixed teaser) comes back.
const LONG_RESULT =
"CONFIRMED: " +
"the orbital telemetry reconciles across all three independent ground stations. ".repeat(8);
const PROMPT_TEXT = "Adversarially verify the starship claim against primary sources.";
function writeFile(p, contents) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, contents);
}
function jsonl(lines) {
return lines.map((o) => JSON.stringify(o)).join("\n") + "\n";
}
function transcriptJsonl(prompt, result) {
return jsonl([
{ type: "user", message: { role: "user", content: [{ type: "text", text: prompt }] } },
{
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: result }] },
},
]);
}
function fetch(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 body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
req.end();
});
}
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,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(b || "{}") }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
let server;
let BASE;
before(async () => {
// Live project tree: one flat sub-agent + nested workflow agents, with DUP_ID
// deliberately present in two runs to exercise glob ambiguity.
writeFile(path.join(SUBAGENTS, `agent-${FLAT_ID}.jsonl`), transcriptJsonl("flat", "flat-result"));
writeFile(
path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`),
transcriptJsonl(PROMPT_TEXT, LONG_RESULT)
);
writeFile(
path.join(SUBAGENTS, "workflows", RUN1, `agent-${DUP_ID}.jsonl`),
transcriptJsonl("dup-r1", "dup-r1-result")
);
writeFile(
path.join(SUBAGENTS, "workflows", RUN2, `agent-${DUP_ID}.jsonl`),
transcriptJsonl("dup-r2", "dup-r2-result")
);
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore cleanup errors */
}
});
describe("resolveAgentTranscriptInDir", () => {
it("prefers the flat layout (regular sub-agents resolve unchanged)", () => {
const hit = resolveAgentTranscriptInDir(SUBAGENTS, FLAT_ID);
assert.equal(hit, path.join(SUBAGENTS, `agent-${FLAT_ID}.jsonl`));
});
it("resolves a nested Workflow agent directly when the runId is known", () => {
const hit = resolveAgentTranscriptInDir(SUBAGENTS, WF_ID, RUN1);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("resolves a nested agent by scanning when it appears in exactly one run", () => {
const hit = resolveAgentTranscriptInDir(SUBAGENTS, WF_ID);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("returns null for an agentId ambiguous across runs (no guessing)", () => {
assert.equal(resolveAgentTranscriptInDir(SUBAGENTS, DUP_ID), null);
});
it("disambiguates an across-run agentId once the runId is supplied", () => {
assert.equal(
resolveAgentTranscriptInDir(SUBAGENTS, DUP_ID, RUN2),
path.join(SUBAGENTS, "workflows", RUN2, `agent-${DUP_ID}.jsonl`)
);
});
it("never throws and returns null for missing inputs", () => {
assert.equal(resolveAgentTranscriptInDir(null, WF_ID), null);
assert.equal(resolveAgentTranscriptInDir(SUBAGENTS, "does-not-exist"), null);
assert.equal(resolveAgentTranscriptInDir(SUBAGENTS, WF_ID, "wf_nope"), null);
});
});
describe("getSubagentTranscriptPath / findSubagentTranscriptPath", () => {
it("resolves a nested Workflow agent via cwd + runId", () => {
const hit = getSubagentTranscriptPath(SESSION, CWD, WF_ID, RUN1);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("still resolves a flat regular sub-agent (no runId — no regression)", () => {
const hit = getSubagentTranscriptPath(SESSION, CWD, FLAT_ID);
assert.equal(hit, path.join(SUBAGENTS, `agent-${FLAT_ID}.jsonl`));
});
it("falls back to a project scan (cwd unknown) for the nested layout", () => {
const hit = findSubagentTranscriptPath(SESSION, WF_ID, RUN1);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("returns null when nothing resolves", () => {
assert.equal(getSubagentTranscriptPath(SESSION, CWD, "missing", RUN1), null);
assert.equal(getSubagentTranscriptPath(SESSION, null, WF_ID, RUN1), null);
});
});
describe("snapshotTranscript (durable nested-layout fallback)", () => {
it("discovers nested Workflow sub-agents separately from flat ones", () => {
const mainPath = path.join(ENC_DIR, `${SESSION}.jsonl`);
writeFile(mainPath, jsonl([{ type: "summary", summary: "x" }]));
const found = findSessionWorkflowSubagents(mainPath);
const rels = found.map((f) => f.rel).sort();
assert.deepEqual(rels, [
path.join("workflows", RUN1, `agent-${WF_ID}.jsonl`),
path.join("workflows", RUN1, `agent-${DUP_ID}.jsonl`),
path.join("workflows", RUN2, `agent-${DUP_ID}.jsonl`),
]);
});
it("preserves the nested layout into the snapshot so the read route resolves it", () => {
// Build an independent source session, snapshot it, then resolve from the
// snapshot dir exactly as the route's third fallback does.
const srcMain = path.join(ENC_DIR, `${SESSION_SNAP}.jsonl`);
const srcNested = path.join(
ENC_DIR,
SESSION_SNAP,
"subagents",
"workflows",
RUN1,
`agent-${WF_ID}.jsonl`
);
writeFile(srcMain, jsonl([{ type: "summary", summary: "snap" }]));
writeFile(srcNested, transcriptJsonl(PROMPT_TEXT, LONG_RESULT));
snapshotTranscript(srcMain, SESSION_SNAP);
const snapHit = getSnapshotSubagentTranscriptPath(SESSION_SNAP, WF_ID, RUN1);
assert.ok(snapHit, "snapshot resolver should find the preserved nested transcript");
assert.equal(fs.readFileSync(snapHit, "utf8"), fs.readFileSync(srcNested, "utf8"));
});
it("does not throw for a session with no transcripts", () => {
assert.doesNotThrow(() => snapshotTranscript(path.join(ENC_DIR, "nope.jsonl"), "nope"));
});
});
describe("GET /:id/transcript?run_id= (full workflow agent text)", () => {
it("returns the full, un-truncated prompt + result for a nested agent", async () => {
const created = await post("/api/sessions", { id: SESSION, name: "wf", cwd: CWD });
assert.equal(created.status, 201);
const res = await fetch(
`/api/sessions/${SESSION}/transcript?agent_id=${WF_ID}&run_id=${RUN1}&limit=200`
);
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.messages));
const texts = res.body.messages
.flatMap((m) => m.content || [])
.filter((b) => b.type === "text")
.map((b) => b.text);
assert.ok(texts.includes(PROMPT_TEXT), "user prompt should be present in full");
assert.ok(texts.includes(LONG_RESULT), "assistant result should be present in full");
// The whole point of the fix: full text, not a "…"-suffixed teaser.
assert.ok(!texts.some((t) => t.endsWith("…")));
});
it("returns an empty message list (no throw) for an unknown run/agent", async () => {
const res = await fetch(
`/api/sessions/${SESSION}/transcript?agent_id=${WF_ID}&run_id=wf_missing&limit=200`
);
assert.equal(res.status, 200);
assert.deepEqual(res.body.messages, []);
});
});
+457
View File
@@ -0,0 +1,457 @@
/**
* @file Tests for server/lib/worktree.js against a REAL git repository created
* in a temp directory. Every behaviour worth testing here is git's own branch
* collisions, what `clean -fd` spares, what `worktree list` reports so mocking
* git would only test our idea of git.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-wt-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes");
const wt = require("../lib/worktree");
const SRC = path.join(ROOT, "src-repo");
const g = (cwd, ...args) => {
// Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
// so the fixture builder doesn't inherit them from the test harness.
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
before(() => {
fs.mkdirSync(SRC, { recursive: true });
g(SRC, "init", "-b", "main");
g(SRC, "config", "user.email", "t@example.com");
g(SRC, "config", "user.name", "Test");
fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n");
g(SRC, "add", "-A");
g(SRC, "commit", "-m", "init");
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
function laneFor(dir, branch, over = {}) {
return {
id: 1,
kind: "managed",
cwd: dir,
branch,
source_repo: SRC,
base_branch: "main",
...over,
};
}
describe("worktree", () => {
it("slugifies a title into a safe single segment", () => {
assert.equal(wt.slugify("Rename Metric → Rule!"), "rename-metric-rule");
assert.equal(wt.slugify(" a//b "), "a-b");
assert.ok(wt.slugify("x".repeat(80)).length <= 40);
});
it("resolves the base branch, falling back when origin has none", async () => {
assert.equal(await wt.resolveBase(SRC, "main"), "main");
assert.equal(await wt.resolveBase(SRC, "does-not-exist"), "main");
});
it("creates a worktree on a new branch and lists it", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
const r = await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/alpha", base: "main" });
assert.equal(r.created, true);
assert.ok(fs.existsSync(path.join(dir, "README.md")));
const list = await wt.listWorktrees(SRC);
assert.ok(list.some((w) => w.path === dir && w.branch === "feat/alpha"));
});
it("refuses a branch already checked out in another worktree", async () => {
const dir2 = path.join(process.env.LANES_ROOT, "src-repo__alpha2");
await assert.rejects(
() => wt.addWorktree({ sourceRepo: SRC, dir: dir2, branch: "feat/alpha", base: "main" }),
(e) => e.code === "EBRANCHBUSY" && typeof e.checkedOutAt === "string"
);
});
it("counts dirty, untracked and unpushed work", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
fs.appendFileSync(path.join(dir, "README.md"), "edit\n");
fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n");
fs.mkdirSync(path.join(dir, "node_modules"), { recursive: true });
fs.writeFileSync(path.join(dir, "node_modules", "dep.js"), "x\n");
const s = await wt.statusCounts(dir);
assert.equal(s.dirty, 1);
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
assert.match(s.head, /^[0-9a-f]{7,40}$/);
// The fixture has no remotes. Given the lane's base branch, unpushedCount
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
assert.equal(await wt.unpushedCount(dir, "main"), 0);
// With no base to measure against (an adopted lane has none), it falls back
// to the total commit count, since every commit is then at risk.
assert.equal(await wt.unpushedCount(dir), 1);
});
it("reset restores base, drops untracked files, and spares gitignored ones", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
await wt.resetWorktree(laneFor(dir, "feat/alpha"));
assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hello\n");
assert.equal(fs.existsSync(path.join(dir, "scratch.txt")), false);
assert.equal(fs.existsSync(path.join(dir, "node_modules", "dep.js")), true);
const s = await wt.statusCounts(dir);
assert.equal(s.dirty, 0);
// Verify we're on the feature branch, not left on base
const branch = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
assert.equal(branch, "feat/alpha");
});
it("reset against a bogus base_branch throws ENOBASE and leaves worktree untouched", async () => {
// Create a new worktree with a bogus base that will exist as a branch
// but we'll change it to non-existent in the lane
const dir = path.join(process.env.LANES_ROOT, "src-repo__bogus-test");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/bogus-test", base: "main" });
// Make a modification to detect if the worktree is mutated
fs.writeFileSync(path.join(dir, "test-file.txt"), "test\n");
// Get the current state before the failed reset
const branchBefore = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
const statusBefore = wt.statusCounts(dir);
// Try to reset against a bogus base_branch
await assert.rejects(
() =>
wt.resetWorktree(laneFor(dir, "feat/bogus-test", { base_branch: "non-existent-branch" })),
(e) => e.code === "ENOBASE" && e.message.includes("non-existent-branch")
);
// Verify the worktree was not mutated: still on the same branch
const branchAfter = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
assert.equal(branchAfter, branchBefore);
// Verify the file still exists (no mutations happened)
assert.ok(fs.existsSync(path.join(dir, "test-file.txt")));
// Clean up
await wt.removeWorktree(laneFor(dir, "feat/bogus-test"));
});
it("refuses to destroy an adopted lane, a path outside LANES_ROOT, or a non-worktree", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
await assert.rejects(
() => wt.assertDestroyable(laneFor(dir, "feat/alpha", { kind: "adopted" })),
(e) => e.code === "ENOTMANAGED"
);
await assert.rejects(
() => wt.assertDestroyable(laneFor("/tmp", "feat/alpha")),
(e) => e.code === "EOUTSIDEROOT"
);
const ghost = path.join(process.env.LANES_ROOT, "src-repo__ghost");
fs.mkdirSync(ghost, { recursive: true });
await assert.rejects(
() => wt.assertDestroyable(laneFor(ghost, "feat/ghost")),
(e) => e.code === "ENOTWORKTREE"
);
});
// These call removeWorktree DIRECTLY. The route short-circuits adopted lanes
// before reaching it, so route-level tests can never pin its guard: deleting
// `await assertDestroyable(lane)` from removeWorktree left all 844 server tests
// green. Each case asserts the error code AND that nothing was destroyed.
describe("removeWorktree refuses what it must not destroy", () => {
it("refuses an adopted lane and leaves its directory and files alone", async () => {
const dir = path.join(ROOT, "adopted-project");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "real-work.txt");
fs.writeFileSync(file, "the user's own project\n");
await assert.rejects(
() => wt.removeWorktree(laneFor(dir, "feat/whatever", { kind: "adopted" })),
(e) => e.code === "ENOTMANAGED"
);
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(file, "utf8"), "the user's own project\n");
});
it("refuses a cwd outside LANES_ROOT and leaves that directory alone", async () => {
const dir = path.join(ROOT, "outside-root");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "keep.txt");
fs.writeFileSync(file, "outside the sandbox\n");
await assert.rejects(
() => wt.removeWorktree(laneFor(dir, "feat/outside")),
(e) => e.code === "EOUTSIDEROOT"
);
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(file, "utf8"), "outside the sandbox\n");
});
it("refuses a real directory inside LANES_ROOT that git does not list as a worktree", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__not-a-worktree");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "keep.txt");
fs.writeFileSync(file, "never registered with git\n");
await assert.rejects(
() => wt.removeWorktree(laneFor(dir, "feat/not-a-worktree")),
(e) => e.code === "ENOTWORKTREE"
);
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(file, "utf8"), "never registered with git\n");
});
});
it("removes a lane whose worktree was deleted by hand, pruning git's stale record", async () => {
// The design promises "the lane reports `missing` and only `remove` is
// offered, taking the prune path". Before this, check 2 mapped the vanished
// path to EOUTSIDEROOT and the lane could never be removed at all.
const dir = path.join(process.env.LANES_ROOT, "src-repo__hand-deleted");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/hand-deleted", base: "main" });
fs.rmSync(dir, { recursive: true, force: true });
assert.ok(
(await wt.listWorktrees(SRC)).some((w) => w.path === dir),
"git should still list the hand-deleted worktree"
);
await wt.removeWorktree(laneFor(dir, "feat/hand-deleted"));
assert.equal(
(await wt.listWorktrees(SRC)).some((w) => w.path === dir),
false
);
assert.equal(g(SRC, "branch", "--list", "feat/hand-deleted").trim(), "");
});
it("removes a lane git never knew about, when its directory is already gone", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__never-existed");
assert.equal(fs.existsSync(dir), false);
// No throw: there is nothing on disk and nothing in git to clean up.
await wt.removeWorktree(laneFor(dir, "feat/never-existed"));
});
it("still refuses the prune path for a missing cwd outside LANES_ROOT", async () => {
// The directory is gone, so check 2 cannot realpath it — but it must still
// hold, or a lane pointing anywhere could prune a source repo's worktrees.
await assert.rejects(
() => wt.removeWorktree(laneFor(path.join(ROOT, "gone-and-outside"), "feat/gone")),
(e) => e.code === "EOUTSIDEROOT"
);
});
it("one prunable sibling worktree does not break reset or remove for other lanes", async () => {
// git keeps listing a hand-deleted worktree as `prunable`. realpathSync on
// every listed entry threw ENOENT out of assertDestroyable, so a single
// stale sibling produced an opaque 500 naming an unrelated directory for
// every managed lane in the same repo.
const victimDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-victim");
await wt.addWorktree({ sourceRepo: SRC, dir: victimDir, branch: "feat/victim", base: "main" });
const siblingDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-sibling");
await wt.addWorktree({
sourceRepo: SRC,
dir: siblingDir,
branch: "feat/sibling",
base: "main",
});
fs.rmSync(siblingDir, { recursive: true, force: true });
assert.ok(
(await wt.listWorktrees(SRC)).some((w) => w.path === siblingDir),
"git should still list the prunable sibling"
);
await wt.assertDestroyable(laneFor(victimDir, "feat/victim"));
await wt.resetWorktree(laneFor(victimDir, "feat/victim"));
await wt.removeWorktree(laneFor(victimDir, "feat/victim"));
assert.equal(fs.existsSync(victimDir), false);
// Clean up the stale record so later tests see a tidy list.
await wt.removeWorktree(laneFor(siblingDir, "feat/sibling"));
});
it("removes a lane whose worktree's .git pointer is corrupt (unreadable), deregistering it without touching its directory", async () => {
// `git worktree remove --force` (even --force --force) refuses outright
// when the worktree's OWN .git file fails git's validation — this is what
// `unreadable` in the preflight actually is. All three assertDestroyable
// checks still pass (the directory exists, is inside LANES_ROOT, and is
// still listed by the source repo), so removeWorktree must not get stuck.
const dir = path.join(process.env.LANES_ROOT, "src-repo__corrupt");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/corrupt", base: "main" });
fs.writeFileSync(path.join(dir, "keep.txt"), "still here\n");
fs.writeFileSync(path.join(dir, ".git"), "garbage\n");
assert.throws(
() => g(dir, "status"),
"sanity: git itself must refuse to operate inside the corrupt worktree"
);
await wt.removeWorktree(laneFor(dir, "feat/corrupt"));
assert.equal(
(await wt.listWorktrees(SRC)).some((w) => w.path === dir),
false,
"git should no longer list the corrupt worktree"
);
assert.equal(g(SRC, "branch", "--list", "feat/corrupt").trim(), "");
// The directory and its files were never touched.
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(path.join(dir, "keep.txt"), "utf8"), "still here\n");
});
it("removes the worktree and its branch, leaving git's list clean", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
await wt.removeWorktree(laneFor(dir, "feat/alpha"));
assert.equal(fs.existsSync(dir), false);
const list = await wt.listWorktrees(SRC);
assert.equal(
list.some((w) => w.path === dir),
false
);
const branches = g(SRC, "branch", "--list", "feat/alpha").trim();
assert.equal(branches, "");
});
it("never deletes the base branch even if a lane claims it", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__beta");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/beta", base: "main" });
await wt.removeWorktree(laneFor(dir, "main")); // lane lies about its branch
assert.match(g(SRC, "branch", "--list", "main"), /main/);
});
it("scrubs git hook environment variables from child processes", async () => {
// Regression test: GIT_DIR, GIT_INDEX_FILE, etc. from git hooks must not
// corrupt git operations on worktrees (where .git is a file, not a directory).
// This test verifies that addWorktree and statusCounts work even when these
// variables are set to bogus values.
const oldGitDir = process.env.GIT_DIR;
const oldGitIndexFile = process.env.GIT_INDEX_FILE;
try {
process.env.GIT_DIR = "/nonexistent/.git";
process.env.GIT_INDEX_FILE = "/nonexistent/.git/index";
const dir = path.join(process.env.LANES_ROOT, "src-repo__scrub-test");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/scrub-test", base: "main" });
const s = await wt.statusCounts(dir);
assert.ok(s.head); // should have a commit hash
assert.equal(s.dirty, 0); // should be clean
// Clean up
await wt.removeWorktree(laneFor(dir, "feat/scrub-test"));
} finally {
// Restore original values
if (oldGitDir !== undefined) {
process.env.GIT_DIR = oldGitDir;
} else {
delete process.env.GIT_DIR;
}
if (oldGitIndexFile !== undefined) {
process.env.GIT_INDEX_FILE = oldGitIndexFile;
} else {
delete process.env.GIT_INDEX_FILE;
}
}
});
});
describe("gitFacts", () => {
it("reports branch, short head, subject and working-tree counts", async () => {
const dir = path.join(ROOT, "facts-repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/facts");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "tracked.txt"), "one\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "seed the facts fixture");
// one modified tracked file, one file git has never seen
fs.writeFileSync(path.join(dir, "tracked.txt"), "two\n");
fs.writeFileSync(path.join(dir, "brand-new.txt"), "x\n");
const facts = await wt.gitFacts(dir);
assert.equal(facts.branch, "feat/facts");
assert.equal(facts.head, g(dir, "rev-parse", "--short", "HEAD").trim());
assert.equal(facts.subject, "seed the facts fixture");
assert.equal(facts.dirty, 1);
assert.equal(facts.untracked, 1);
});
it("reports the literal HEAD git gives for a detached checkout", async () => {
const dir = path.join(ROOT, "facts-detached");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "only commit");
g(dir, "checkout", "--detach", "HEAD");
const facts = await wt.gitFacts(dir);
assert.equal(facts.branch, "HEAD");
assert.equal(facts.dirty, 0);
assert.equal(facts.untracked, 0);
});
it("rejects rather than reporting facts for a directory that is not a repo", async () => {
const dir = path.join(ROOT, "facts-plain");
fs.mkdirSync(dir, { recursive: true });
await assert.rejects(() => wt.gitFacts(dir));
});
});
describe("listBranches", () => {
it("lists local branches and names the current one", async () => {
const dir = path.join(ROOT, "branches-repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "init");
g(dir, "branch", "feat/one");
g(dir, "branch", "feat/two");
const { branches, current } = await wt.listBranches(dir);
assert.deepEqual([...branches].sort(), ["feat/one", "feat/two", "main"]);
assert.equal(current, "main");
});
it("reports no current branch for a detached HEAD, but still lists branches", async () => {
const dir = path.join(ROOT, "branches-detached");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "init");
g(dir, "checkout", "--detach", "HEAD");
const { branches, current } = await wt.listBranches(dir);
assert.deepEqual(branches, ["main"]);
assert.equal(current, null);
});
it("returns an empty branch list for a repo with no commits yet", async () => {
const dir = path.join(ROOT, "branches-empty");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
const { branches } = await wt.listBranches(dir);
assert.deepEqual(branches, []);
});
});