/** * @file Tests for the two aggregate endpoints the Workflows page reads: * GET /api/lanes/pipeline-stats (how often each pipeline node was passed, * over lanes AND archived features) and GET /api/workflows/skill-usage * (per-skill and per-plugin call counts). Both are pure reporting endpoints, * so the risks are miscounting — double-counting a tool call that emits both a * Pre and a Post event, or losing an archived feature's history. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const http = require("node:http"); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-usage-api-")); process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const { createApp } = require("../index"); const lanesLib = require("../lib/lanes"); const { db, stmts } = require("../db"); let server; let PORT; before(async () => { const app = createApp(); server = http.createServer(app); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); PORT = server.address().port; }); after(async () => { await new Promise((resolve) => server.close(resolve)); fs.rmSync(SUITE_ROOT, { recursive: true, force: true }); }); function get(urlPath) { return new Promise((resolve, reject) => { const req = http.request( { method: "GET", hostname: "127.0.0.1", port: PORT, path: urlPath }, (res) => { let raw = ""; res.on("data", (c) => (raw += c)); res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(raw) })); } ); req.on("error", reject); req.end(); }); } let laneSeq = 0; function makeLane() { laneSeq += 1; const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); fs.mkdirSync(cwd, { recursive: true }); return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); } function setStages(laneId, stages) { db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId); } function node(pipelineStats, pipelineId, nodeId) { const p = pipelineStats.pipelines.find((x) => x.id === pipelineId); return p && p.nodes.find((n) => n.id === nodeId); } describe("GET /api/lanes/pipeline-stats", () => { it("counts a pass with evidence apart from a pass without one, and a fail as neither", async () => { const lane = makeLane(); setStages(lane.id, { plan: { enteredAt: "2026-01-01T00:00:00Z", evidence: "plan.md", result: null }, implement: { enteredAt: "2026-01-01T01:00:00Z", evidence: null, result: null }, tests: { enteredAt: "2026-01-01T02:00:00Z", evidence: null, result: "fail" }, }); const res = await get("/api/lanes/pipeline-stats"); assert.equal(res.status, 200); const plan = node(res.body, "default", "plan"); assert.deepEqual( { entered: plan.entered, passed: plan.passed, withEvidence: plan.withEvidence }, { entered: 1, passed: 1, withEvidence: 1 } ); const implement = node(res.body, "default", "implement"); assert.deepEqual( { passed: implement.passed, withEvidence: implement.withEvidence }, { passed: 1, withEvidence: 0 } ); const tests = node(res.body, "default", "tests"); assert.deepEqual( { entered: tests.entered, passed: tests.passed, failed: tests.failed }, { entered: 1, passed: 0, failed: 1 } ); }); it("leaves a node never reached at zero", async () => { const res = await get("/api/lanes/pipeline-stats"); assert.equal(node(res.body, "default", "ship").entered, 0); }); it("keeps counting an archived feature's stages after the lane moves on", async () => { const lane = makeLane(); db.prepare( `INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at) VALUES (?, 'old', 'default', 'done', ?, '2026-01-02T00:00:00Z')` ).run(lane.id, JSON.stringify({ gate: { enteredAt: "x", evidence: "GO", result: null } })); const res = await get("/api/lanes/pipeline-stats"); const gate = node(res.body, "default", "gate"); assert.equal(gate.passed, 1); assert.equal(gate.withEvidence, 1); }); it("narrows to one lane and its own archived features with ?lane=", async () => { const mine = makeLane(); const other = makeLane(); setStages(mine.id, { plan: { enteredAt: "x", evidence: "plan.md", result: null } }); setStages(other.id, { plan: { enteredAt: "x", evidence: null, result: null } }); db.prepare( `INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at) VALUES (?, 'mine-old', 'default', 'done', ?, '2026-01-02T00:00:00Z')` ).run(mine.id, JSON.stringify({ plan: { enteredAt: "x", evidence: "old.md", result: null } })); const res = await get(`/api/lanes/pipeline-stats?lane=${mine.id}`); assert.equal(res.status, 200); const plan = node(res.body, "default", "plan"); // The lane itself plus its one archived feature — never the other lane's. assert.deepEqual( { entered: plan.entered, withEvidence: plan.withEvidence }, { entered: 2, withEvidence: 2 } ); }); it("always returns every lane for the picker, even when narrowed", async () => { const lane = makeLane(); const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`); assert.ok(res.body.lanes.length > 1); assert.ok(res.body.lanes.every((l) => l.id && typeof l.cwd === "string")); }); it("still describes the template for a lane that has recorded nothing", async () => { const lane = makeLane(); const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`); assert.equal(res.status, 200); assert.equal(node(res.body, "default", "plan").entered, 0); }); it("returns no pipelines at all for a lane id that does not exist", async () => { const res = await get("/api/lanes/pipeline-stats?lane=999999"); assert.equal(res.status, 200); assert.deepEqual(res.body.pipelines, []); }); it("rejects a lane filter that is not an id", async () => { const res = await get("/api/lanes/pipeline-stats?lane=../etc"); assert.equal(res.status, 400); assert.equal(res.body.error.code, "EBADLANE"); }); }); describe("GET /api/workflows/skill-usage", () => { before(() => { stmts.insertSession.run("usage-s1", "usage", "active", "/tmp/usage", null, null); const ev = (type, tool, data) => stmts.insertEvent.run("usage-s1", null, type, tool, "", JSON.stringify(data)); // Two full calls of the same skill: each emits Pre + Post, so a naive // COUNT(*) would report four. for (let i = 0; i < 2; i += 1) { ev("PreToolUse", "Skill", { tool_input: { skill: "ccam:ship-feature" } }); ev("PostToolUse", "Skill", { tool_input: { skill: "ccam:ship-feature" } }); } ev("PreToolUse", "Skill", { tool_input: { skill: "daily-report" } }); ev("PreToolUse", "mcp__plugin_ccam_ccam__dashboard_health_check", {}); ev("PostToolUse", "mcp__plugin_ccam_ccam__dashboard_health_check", {}); }); it("counts one call per invocation, not one per hook event", async () => { const res = await get("/api/workflows/skill-usage"); assert.equal(res.status, 200); const ship = res.body.skills.find((s) => s.name === "ccam:ship-feature"); assert.equal(ship.count, 2); assert.equal(ship.sessions, 1); }); it("attributes a prefixed skill to its plugin and leaves a bare one unattributed", async () => { const res = await get("/api/workflows/skill-usage"); assert.equal(res.body.skills.find((s) => s.name === "ccam:ship-feature").plugin, "ccam"); assert.equal(res.body.skills.find((s) => s.name === "daily-report").plugin, null); }); it("adds a plugin's own MCP tool calls to its totals", async () => { const res = await get("/api/workflows/skill-usage"); const ccam = res.body.plugins.find((p) => p.name === "ccam"); assert.deepEqual( { skills: ccam.skills, skillCalls: ccam.skillCalls, mcpCalls: ccam.mcpCalls }, { skills: 1, skillCalls: 2, mcpCalls: 1 } ); }); });