Files
AgentHub/server/__tests__/metrics.test.js
T
nntrivi2001 17d56d0e01 refactor!: rebrand CCAM to AgentHub
Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
2026-09-07 12:09:15 +07:00

150 lines
4.9 KiB
JavaScript

/**
* @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 [
"agenthub_up",
"agenthub_build_info",
"agenthub_process_uptime_seconds",
"agenthub_process_resident_memory_bytes",
"agenthub_sessions",
"agenthub_agents",
"agenthub_events_total",
"agenthub_websocket_clients",
"agenthub_remote_sources",
"agenthub_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, "agenthub_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, "agenthub_sessions", `status="${status}"`),
null,
`agenthub_sessions status=${status} present`
);
}
for (const status of ["working", "waiting", "completed", "error"]) {
assert.notEqual(
sampleValue(res.body, "agenthub_agents", `status="${status}"`),
null,
`agenthub_agents status=${status} present`
);
}
});
it("reflects real data seeded through the hook API", async () => {
const before = sampleValue(await scrapeText(), "agenthub_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(), "agenthub_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;
}
});