Files
AgentHub/server/__tests__/workflows-usage-api.test.js
T
nntrivi2001 c7dbaf2d9f refactor: finish the AgentHub rename and drop the MCP server
Two leftovers from the rebrand, done together because they touch the same
docs and manifests.

**Data directory.** `agent-dashboard` could not simply be renamed in the
source: it names a real directory holding the live SQLite database. Startup
now moves it once, via `migrateLegacyDataDir()` in `server/lib/claude-home.js`
— a same-filesystem rename, so it is instant even at multiple gigabytes and
cannot leave two divergent copies. `getDataDir()` stays PURE and resolves to
the legacy path until the move happens; the first version of this put the
rename inside the getter, which meant `npm run test:server` relocated the
developer's real database as a side effect. The call sits behind
`require.main === module` and ahead of the router requires, because those pull
in db.js, which opens SQLite at module load. A failed rename is silent and the
old directory keeps being used: an unfashionable directory name beats a blank
database beside an unreachable one. Same treatment for the port-discovery file
(`.agent-dashboard.json` → `.agenthub.json`, old one dropped on write).

Not renamed on purpose: the Sagi localStorage keys (renaming silently resets
everyone's prefs) and the PWA manifest `id` (changing it orphans an installed
app).

**MCP server.** 4 tool calls in 46 days across 577,132 events, against 4,556
lines of TypeScript, a 2 MB committed build artifact, and a pre-commit guard
that had already blocked commits twice. Deleted: `mcp/`, the maintainer skills
and `mcp-reviewer` agent, the build check, `plugins/agenthub/.mcp.json`,
`docs/MCP.md`, 12 npm scripts, 5 Makefile targets, and every doc section
describing it.

Kept, because they are unrelated things sharing the name: the Config
Explorer's MCP tab and its API, per-lane Playwright MCP, the agenthub-config
plugin's MCP audit, and the historical `mcp__plugin_agenthub_*` events that
analytics must keep attributing.

Verified: server 1432/1432, client 459/459, tsc clean, prettier clean. Both
directions of the migration proven — a sandbox-HOME test run leaves the legacy
directory untouched, and a real boot moves it with its contents intact.
2026-09-08 09:48:40 +07:00

214 lines
8.3 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(), "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 { 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: "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 }
);
});
});