20d85d3450
The panel aggregated every lane at once, which answers "does this stage get proved across the project" but never "does THIS lane prove it". A lane picker now narrows the same aggregation through ?lane=<id> on GET /api/lanes/pipeline-stats, counting that lane plus its own archived features — dropping the features would hide most of a lane's history the moment it starts its next one. The response carries a `lanes` array that the filter never narrows: it populates the picker, so it has to keep offering every other lane even while the counts show one. For the same reason the picker renders above the empty branch rather than inside the populated one — narrowing to a lane that has recorded nothing would otherwise be a one-way trip, with no control left to select anything else. A lane filter that is not a positive integer is rejected with 400 EBADLANE rather than silently aggregating everything.
209 lines
8.1 KiB
JavaScript
209 lines
8.1 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 }
|
|
);
|
|
});
|
|
});
|