/** * @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(), "agenthub-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 laneFeatures = require("../lib/lane-features"); 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" }); } // Declare stages the way an agent does. Poking `lanes.stages` directly would // test the snapshot the pass-rate report no longer reads: that blob describes // the pass in flight, while the report counts the append-only stage log every // declaration writes. function declare(laneId, stages) { for (const [stage, rec] of Object.entries(stages)) { lanesLib.setStage(laneId, { stage, evidence: rec.evidence, result: rec.result }); } } 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(); declare(lane.id, { plan: { evidence: "plan.md", result: null }, implement: { evidence: null, result: null }, tests: { 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 a finished feature's run after the lane starts the next one", async () => { // The regression this guards: the report used to read the lane's live // `stages` blob, which the next feature overwrites. Ship two features // through one lane and the first one's gate had never happened. const lane = makeLane(); declare(lane.id, { gate: { evidence: "GO" } }); laneFeatures.activateFeature(lane.id, "next-feature"); declare(lane.id, { plan: { evidence: null } }); const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`); const gate = node(res.body, "default", "gate"); assert.equal(gate.passed, 1); assert.equal(gate.withEvidence, 1); assert.equal(node(res.body, "default", "plan").passed, 1); // Two passes through the pipeline, counted as two runs rather than one lane. assert.equal(res.body.pipelines.find((p) => p.id === "default").runs, 2); }); it("narrows to one lane and all of its own runs with ?lane=", async () => { const mine = makeLane(); const other = makeLane(); declare(mine.id, { plan: { evidence: "plan.md" }, done: {} }); declare(mine.id, { plan: { evidence: "again.md" } }); declare(other.id, { plan: { evidence: null } }); const res = await get(`/api/lanes/pipeline-stats?lane=${mine.id}`); assert.equal(res.status, 200); const plan = node(res.body, "default", "plan"); // Both of this lane's passes — 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: "agenthub:ship-feature" } }); ev("PostToolUse", "Skill", { tool_input: { skill: "agenthub:ship-feature" } }); } ev("PreToolUse", "Skill", { tool_input: { skill: "daily-report" } }); // AgentHub no longer ships an MCP server, but events recorded while it did // are still in the database, and analytics must keep attributing them. ev("PreToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {}); ev("PostToolUse", "mcp__plugin_agenthub_agenthub__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 === "agenthub: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 === "agenthub:ship-feature").plugin, "agenthub" ); 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 agenthub = res.body.plugins.find((p) => p.name === "agenthub"); assert.deepEqual( { skills: agenthub.skills, skillCalls: agenthub.skillCalls, mcpCalls: agenthub.mcpCalls }, { skills: 1, skillCalls: 2, mcpCalls: 1 } ); }); });