feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file Regression test for the alert_rules rebuild that drops its 4-type
|
||||
* `rule_type` CHECK: an existing DB keeps every rule and every fired alert
|
||||
* (alert_events cascades on rule delete, so the rebuild must run with foreign
|
||||
* keys off), and the new types can be stored afterwards.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "alert-mig-")));
|
||||
const DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.DASHBOARD_DB_PATH = DB_PATH;
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
|
||||
// The schema an older build created.
|
||||
{
|
||||
const Database = require("better-sqlite3");
|
||||
const old = new Database(DB_PATH);
|
||||
old.pragma("foreign_keys = ON");
|
||||
old.exec(`
|
||||
CREATE TABLE alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL CHECK(rule_type IN ('event_pattern','inactivity','status_duration','token_threshold')),
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
cooldown_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE alert_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
agent_id TEXT,
|
||||
message TEXT NOT NULL,
|
||||
details TEXT,
|
||||
triggered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
acknowledged_at TEXT,
|
||||
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('r1', 'Old', 'inactivity', '{"minutes":5}');
|
||||
INSERT INTO alert_events (rule_id, rule_name, rule_type, message) VALUES ('r1', 'Old', 'inactivity', 'fired');
|
||||
`);
|
||||
old.close();
|
||||
}
|
||||
|
||||
const { db, stmts } = require("../db");
|
||||
|
||||
it("keeps rules and fired alerts, and accepts the new types", () => {
|
||||
assert.equal(db.prepare("SELECT name FROM alert_rules WHERE id = 'r1'").get().name, "Old");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events WHERE rule_id = 'r1'").get().c, 1);
|
||||
stmts.insertAlertRule.run("r2", "Budget", "budget", '{"period":"day","limit_usd":1}', 1, 0);
|
||||
assert.equal(stmts.getAlertRule.get("r2").rule_type, "budget");
|
||||
// The cascade still works after the rename.
|
||||
stmts.deleteAlertRule.run("r1");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events").get().c, 0);
|
||||
assert.equal(db.pragma("foreign_keys", { simple: true }), 1);
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @file The Analytics date range: `parseRange` (local dates → UTC instants,
|
||||
* 400 on bad input), `?from=&to=` on GET /api/pricing/cost (totals, breakdown
|
||||
* and by_tool by spend day; daily_costs whole), GET /api/analytics (counts by
|
||||
* started_at / created_at, tokens and cost from the days in the range) and
|
||||
* GET /api/workflows.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "analytics-range-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const { parseRange, inRange } = require("../lib/source-filter");
|
||||
|
||||
const M = 1_000_000;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
async function get(url) {
|
||||
const res = await fetch(`${base}${url}`);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
function session(id, startedAt, tool = "claude") {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', ?, ?, ?)"
|
||||
).run(id, id, startedAt, startedAt, tool);
|
||||
}
|
||||
const usage = (id, input) =>
|
||||
stmts.setTokenUsage.run(
|
||||
id,
|
||||
"range-model",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
input,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const hour = (id, h, input) =>
|
||||
stmts.upsertHourlyUsage.run(
|
||||
id,
|
||||
h,
|
||||
"range-model",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
input,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const event = (sid, at, tool) =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES (?, 'PreToolUse', ?, ?)"
|
||||
)
|
||||
.run(sid, tool, at);
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('range-model%', 'R', 1, 0, 0, 0)"
|
||||
).run();
|
||||
// "a": started 2026-09-01, 1 M on the 1st, 2 M on the 10th (hourly rows).
|
||||
session("a", "2026-09-01T10:00:00.000Z");
|
||||
usage("a", 3 * M);
|
||||
hour("a", "2026-09-01T10", 1 * M);
|
||||
hour("a", "2026-09-10T10", 2 * M);
|
||||
// "b": a codex session started 2026-09-20 with 4 M and no hourly rows.
|
||||
session("b", "2026-09-20T10:00:00.000Z", "codex");
|
||||
usage("b", 4 * M);
|
||||
event("a", "2026-09-01T11:00:00.000Z", "Read");
|
||||
event("a", "2026-09-10T11:00:00.000Z", "Edit");
|
||||
event("b", "2026-09-20T11:00:00.000Z", "Bash");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRange", () => {
|
||||
const q = (query) => parseRange({ query });
|
||||
it("is null with no range and false on bad input", () => {
|
||||
assert.equal(q({}), null);
|
||||
assert.equal(q({ from: "", to: "" }), null);
|
||||
assert.equal(q({ from: "2026-02-30" }), false);
|
||||
assert.equal(q({ from: "09/01/2026" }), false);
|
||||
assert.equal(q({ from: "2026-09-10", to: "2026-09-01" }), false);
|
||||
assert.equal(q({ to: ["2026-09-01"] }), false);
|
||||
assert.equal(q({ to: "9999-12-31" }), false);
|
||||
});
|
||||
it("turns local dates into UTC instants with tz_offset, end exclusive", () => {
|
||||
assert.deepEqual(q({ from: "2026-09-01", to: "2026-09-02", tz_offset: "-420" }), {
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-02",
|
||||
start: "2026-08-31T17:00:00.000Z",
|
||||
end: "2026-09-02T17:00:00.000Z",
|
||||
});
|
||||
const open = q({ to: "2026-09-02" });
|
||||
assert.equal(open.start, null);
|
||||
assert.equal(open.end, "2026-09-03T00:00:00.000Z");
|
||||
assert.ok(inRange(open, "2026-01-01"));
|
||||
assert.ok(!inRange(open, "2026-09-03"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/pricing/cost?from=&to=", () => {
|
||||
it("prices only the spend days in the range and keeps the whole daily series", async () => {
|
||||
const all = await get("/api/pricing/cost?tz_offset=0");
|
||||
assert.equal(all.body.total_cost, 7);
|
||||
const r = await get("/api/pricing/cost?tz_offset=0&from=2026-09-05&to=2026-09-25");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.total_cost, 6); // 2 M on the 10th + b's 4 M on the 20th
|
||||
assert.deepEqual(
|
||||
r.body.by_tool.map((x) => [x.tool, x.cost]),
|
||||
[
|
||||
["codex", 4],
|
||||
["claude", 2],
|
||||
]
|
||||
);
|
||||
assert.equal(r.body.breakdown[0].input_tokens, 6 * M);
|
||||
assert.deepEqual(r.body.daily_costs, all.body.daily_costs);
|
||||
});
|
||||
it("answers 400 for a bad range", async () => {
|
||||
const r = await get("/api/pricing/cost?from=2026-13-01");
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/analytics?from=&to=", () => {
|
||||
it("leaves the unranged answer as it was", async () => {
|
||||
const r = await get("/api/analytics?tz_offset=0");
|
||||
assert.equal(r.body.overview.total_sessions, 2);
|
||||
assert.equal(r.body.overview.total_events, 3);
|
||||
assert.equal(r.body.tokens.total_input, 7 * M);
|
||||
assert.equal(r.body.total_cost, 7);
|
||||
});
|
||||
it("counts sessions and events in the range, tokens and cost by spend day", async () => {
|
||||
const r = await get("/api/analytics?tz_offset=0&from=2026-09-05&to=2026-09-15");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.overview.total_sessions, 0); // "a" started before
|
||||
assert.equal(r.body.overview.total_events, 1);
|
||||
assert.deepEqual(r.body.tool_usage, [{ tool_name: "Edit", count: 1 }]);
|
||||
assert.equal(r.body.tokens.total_input, 2 * M);
|
||||
assert.equal(r.body.total_cost, 2);
|
||||
// The daily chart keeps its own window.
|
||||
assert.equal(r.body.daily_events.length, 3);
|
||||
});
|
||||
it("combines with ?tool= and answers 400 for a bad range", async () => {
|
||||
const r = await get("/api/analytics?tz_offset=0&tool=codex&from=2026-09-01&to=2026-09-30");
|
||||
assert.equal(r.body.overview.total_sessions, 1);
|
||||
assert.equal(r.body.total_cost, 4);
|
||||
assert.equal((await get("/api/analytics?to=nope")).status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/workflows?from=&to=", () => {
|
||||
it("counts the sessions started in the range", async () => {
|
||||
const r = await get("/api/workflows?from=2026-09-15&to=2026-09-30");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.stats.totalSessions, 1);
|
||||
assert.equal((await get("/api/workflows?from=x")).status, 400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @file Tests for spend budgets and dollar alerts: period bounds in local time,
|
||||
* the `budget` and `cost_threshold` rule types (validation, firing once per
|
||||
* threshold per period), GET /api/budgets/status and the read-only rule
|
||||
* preview POST /api/alerts/rules/preview.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "budgets-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const { periodBounds, budgetSpend } = require("../lib/budgets");
|
||||
const alerts = require("../lib/alerts");
|
||||
|
||||
const M = 1_000_000;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function call(method, url, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
`${base}${url}`,
|
||||
{ method, headers: body ? { "content-type": "application/json" } : {} },
|
||||
(res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const iso = (ms) => new Date(ms).toISOString();
|
||||
function session(id, tool, startedAt, updatedAt = startedAt) {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', ?, ?, ?)"
|
||||
).run(id, id, startedAt, updatedAt, tool);
|
||||
}
|
||||
const spend = (id, input) =>
|
||||
stmts.setTokenUsage.run(
|
||||
id,
|
||||
"budget-model",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
input,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('budget-model%', 'B', 1, 0, 0, 0)"
|
||||
).run();
|
||||
});
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
beforeEach(() => {
|
||||
db.exec(
|
||||
"DELETE FROM alert_events; DELETE FROM alert_rules; DELETE FROM token_usage; DELETE FROM sessions;"
|
||||
);
|
||||
alerts.invalidateRuleCache();
|
||||
});
|
||||
|
||||
describe("periodBounds", () => {
|
||||
// 2026-10-05 20:00 UTC = 2026-10-06 03:00 in UTC+7 (tz_offset -420), a Tuesday.
|
||||
const now = Date.parse("2026-10-05T20:00:00Z");
|
||||
it("starts a day at local midnight", () => {
|
||||
const b = periodBounds("day", now, -420);
|
||||
assert.equal(b.start, "2026-10-06");
|
||||
assert.equal(b.end, "2026-10-06");
|
||||
assert.equal(b.startUtc, "2026-10-05T17:00:00.000Z");
|
||||
assert.equal(b.days, 1);
|
||||
assert.ok(Math.abs(b.elapsed - 3 / 24) < 1e-9);
|
||||
});
|
||||
it("starts a week on Monday and a month on the 1st", () => {
|
||||
assert.equal(periodBounds("week", now, -420).start, "2026-10-05");
|
||||
assert.equal(periodBounds("week", now, -420).end, "2026-10-11");
|
||||
const m = periodBounds("month", now, -420);
|
||||
assert.equal(m.start, "2026-10-01");
|
||||
assert.equal(m.end, "2026-10-31");
|
||||
assert.equal(m.days, 31);
|
||||
});
|
||||
it("walks back whole periods, which count as fully elapsed", () => {
|
||||
const m = periodBounds("month", now, 0, 1);
|
||||
assert.equal(m.start, "2026-09-01");
|
||||
assert.equal(m.end, "2026-09-30");
|
||||
assert.equal(m.elapsed, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget rules", () => {
|
||||
it("validates the config", async () => {
|
||||
const bad = await call("POST", "/api/alerts/rules", {
|
||||
name: "b",
|
||||
rule_type: "budget",
|
||||
config: { period: "year", limit_usd: 5 },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
const ok = await call("POST", "/api/alerts/rules", {
|
||||
name: "b",
|
||||
rule_type: "budget",
|
||||
config: { period: "day", limit_usd: 5, tool: "codex", tz_offset: -420 },
|
||||
});
|
||||
assert.equal(ok.status, 201);
|
||||
assert.deepEqual(ok.body.rule.config, {
|
||||
period: "day",
|
||||
limit_usd: 5,
|
||||
thresholds: [80, 100],
|
||||
tool: "codex",
|
||||
tz_offset: -420,
|
||||
});
|
||||
});
|
||||
|
||||
it("measures the period's spend in scope and projects it", () => {
|
||||
const now = Date.now();
|
||||
session("today", "codex", iso(now - 60_000));
|
||||
spend("today", 2 * M); // $2
|
||||
session("other-tool", "claude", iso(now - 60_000));
|
||||
spend("other-tool", 9 * M);
|
||||
session("yesterday", "codex", iso(now - 3 * 86_400_000));
|
||||
spend("yesterday", 7 * M);
|
||||
const s = budgetSpend(
|
||||
{ period: "day", limit_usd: 4, thresholds: [50], tool: "codex" },
|
||||
{ now }
|
||||
);
|
||||
assert.equal(s.spend, 2);
|
||||
assert.equal(s.pct, 50);
|
||||
assert.ok(s.projected >= 2);
|
||||
});
|
||||
|
||||
it("fires each crossed threshold once per period, and the status lists it", async () => {
|
||||
const now = Date.now();
|
||||
session("s1", "claude", iso(now - 60_000));
|
||||
spend("s1", 9 * M); // $9 of $10
|
||||
const created = await call("POST", "/api/alerts/rules", {
|
||||
name: "Daily",
|
||||
rule_type: "budget",
|
||||
config: { period: "day", limit_usd: 10, thresholds: [50, 80, 100] },
|
||||
});
|
||||
alerts.sweepTimeRules();
|
||||
alerts.sweepTimeRules();
|
||||
const fired = db.prepare("SELECT details FROM alert_events ORDER BY id").all();
|
||||
assert.deepEqual(
|
||||
fired.map((f) => JSON.parse(f.details).threshold),
|
||||
[50, 80]
|
||||
);
|
||||
const status = await call("GET", "/api/budgets/status");
|
||||
const b = status.body.budgets.find((x) => x.id === created.body.rule.id);
|
||||
assert.equal(b.spend, 9);
|
||||
assert.equal(b.pct, 90);
|
||||
assert.deepEqual(b.crossed, [50, 80]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cost_threshold", () => {
|
||||
it("fires when a session's cost reaches the threshold", async () => {
|
||||
session("pricey", "claude", iso(Date.now()));
|
||||
spend("pricey", 3 * M);
|
||||
await call("POST", "/api/alerts/rules", {
|
||||
name: "Expensive",
|
||||
rule_type: "cost_threshold",
|
||||
config: { usd: 2.5 },
|
||||
});
|
||||
alerts.evaluateEvent({ session_id: "pricey", event_type: "Stop" });
|
||||
const a = db.prepare("SELECT message, details FROM alert_events").get();
|
||||
assert.match(a.message, /\$3\.00/);
|
||||
assert.equal(JSON.parse(a.details).cost_usd, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rule preview", () => {
|
||||
it("replays an event pattern with its count window and cooldown, firing nothing", async () => {
|
||||
session("p1", "claude", iso(Date.now() - 3_600_000));
|
||||
const t0 = Date.now() - 30 * 60_000;
|
||||
for (const [i, m] of [0, 1, 2, 20].entries()) {
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('p1', 'PostToolUse', 'Bash', ?, ?)"
|
||||
).run(`e${i}`, iso(t0 + m * 60_000));
|
||||
}
|
||||
const r = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "event_pattern",
|
||||
config: { tool_name: "Bash", count: 2, window_minutes: 5 },
|
||||
cooldown_seconds: 300,
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
// e1 (2 in window) fires; e2 is inside the cooldown; e20 is alone in its window.
|
||||
assert.equal(r.body.fires, 1);
|
||||
assert.equal(r.body.sessions, 1);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events").get().c, 0);
|
||||
});
|
||||
it("lists past budget periods and refuses what cannot be replayed", async () => {
|
||||
const b = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "budget",
|
||||
config: { period: "week", limit_usd: 1 },
|
||||
});
|
||||
assert.equal(b.body.periods.length, 6);
|
||||
const s = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "status_duration",
|
||||
config: { status: "working", minutes: 5 },
|
||||
});
|
||||
assert.equal(s.body.supported, false);
|
||||
const bad = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "budget",
|
||||
config: { period: "week", limit_usd: 1 },
|
||||
days: 0,
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
* includes reasoning), duplicate totals, a mid-session model switch, a fork's
|
||||
* copied history skipped, a sub-agent thread folded into its root session,
|
||||
* incremental reads, a deleted session not resurrected, and a Claude row never
|
||||
* touched. CODEX_HOME and the DB live in a temp dir.
|
||||
* touched; a remote source's mirror read with its own source tag, home and
|
||||
* activity window; a folder the user ignores never written. CODEX_HOME and the
|
||||
* DB live in a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -427,4 +429,110 @@ describe("ingestCodexUsage", () => {
|
||||
const r = await ingestCodexUsage({ now: Date.now() });
|
||||
assert.deepEqual(r, { files: 0, sessions: 0 });
|
||||
});
|
||||
|
||||
describe("a remote source's mirror", () => {
|
||||
const MIRROR = path.join(ROOT, "remote-sources", "src_aaa", "codex");
|
||||
const remote = { home: MIRROR, source: "src_aaa", activeMs: 600_000 };
|
||||
function writeMirrored(id, lines, mtime) {
|
||||
const day = path.join(MIRROR, "sessions", "2026", "09", "29");
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const file = path.join(day, `rollout-2026-09-29T01-00-00-${id}.jsonl`);
|
||||
fs.writeFileSync(file, jsonl(lines));
|
||||
fs.utimesSync(file, mtime, mtime);
|
||||
}
|
||||
beforeEach(() => fs.rmSync(MIRROR, { recursive: true, force: true }));
|
||||
|
||||
it("writes its sessions and tokens tagged with the source, named from its own index", async () => {
|
||||
writeMirrored(
|
||||
"r1",
|
||||
[meta("r1"), ctx("gpt-a"), tc([10, 4, 2], [10, 4, 2])],
|
||||
new Date("2026-09-29T02:00:00Z")
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(MIRROR, "session_index.jsonl"),
|
||||
JSON.stringify({ id: "r1", thread_name: "Remote work" }) + "\n"
|
||||
);
|
||||
const r = await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z"), ...remote });
|
||||
assert.equal(r.sessions, 1);
|
||||
const s = stmts.getSession.get("r1");
|
||||
assert.equal(s.source, "src_aaa");
|
||||
assert.equal(s.tool, "codex");
|
||||
assert.equal(s.name, "Remote work");
|
||||
assert.deepEqual(usage("r1"), [{ model: "gpt-a", i: 6, r: 4, o: 2 }]);
|
||||
// The local pass never sees the mirror, and leaves the row alone.
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("r1").source, "src_aaa");
|
||||
});
|
||||
|
||||
it("never writes over a session another source owns", async () => {
|
||||
writeRollout("dup", [meta("dup"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
writeMirrored(
|
||||
"dup",
|
||||
[meta("dup"), ctx("gpt-b"), tc([50, 0, 9], [50, 0, 9])],
|
||||
new Date("2026-09-29T02:30:00Z")
|
||||
);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z"), ...remote });
|
||||
assert.equal(stmts.getSession.get("dup").source, "local");
|
||||
assert.equal(stmts.getSession.get("dup").model, "gpt-a");
|
||||
});
|
||||
|
||||
it("keeps its own activity window, and only its own settle completes it", async () => {
|
||||
const t0 = Date.parse("2026-09-29T03:00:00Z");
|
||||
writeMirrored(
|
||||
"r2",
|
||||
[meta("r2"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||
new Date(t0 - 300_000)
|
||||
);
|
||||
await ingestCodexUsage({ now: t0, ...remote });
|
||||
assert.equal(stmts.getSession.get("r2").status, "active"); // 5 min < 10 min window
|
||||
settleActive(() => {}, t0 + 900_000); // the local settle: not its row
|
||||
assert.equal(stmts.getSession.get("r2").status, "active");
|
||||
settleActive(() => {}, t0 + 900_000, remote);
|
||||
assert.equal(stmts.getSession.get("r2").status, "completed");
|
||||
});
|
||||
});
|
||||
|
||||
it("never writes a session whose folder the user ignores", async () => {
|
||||
process.env.AGENTHUB_IGNORE_CWD = "/w";
|
||||
try {
|
||||
writeRollout("ig", [meta("ig"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("ig"), undefined);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_IGNORE_CWD;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("service tier", () => {
|
||||
const tier = (t) => ({
|
||||
timestamp: "2026-09-29T01:00:01.500Z",
|
||||
type: "event_msg",
|
||||
payload: { type: "thread_settings_applied", thread_settings: { service_tier: t } },
|
||||
});
|
||||
it("books fast/priority as speed fast and flex as service_tier flex", async () => {
|
||||
writeRollout("tier1", [
|
||||
meta("tier1"),
|
||||
ctx("gpt-a"),
|
||||
tc([10, 0, 1], [10, 0, 1]),
|
||||
tier("fast"),
|
||||
tc([30, 0, 3], [20, 0, 2]),
|
||||
tier("flex"),
|
||||
tc([60, 0, 6], [30, 0, 3]),
|
||||
tier("default"),
|
||||
tc([100, 0, 10], [40, 0, 4]),
|
||||
]);
|
||||
await ingestCodexUsage({ broadcast: () => {}, now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT speed, service_tier AS t, input_tokens AS i FROM token_usage WHERE session_id = 'tier1' ORDER BY i"
|
||||
)
|
||||
.all();
|
||||
assert.deepEqual(rows, [
|
||||
{ speed: "fast", t: "standard", i: 20 },
|
||||
{ speed: "standard", t: "flex", i: 30 },
|
||||
{ speed: "standard", t: "standard", i: 50 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file Tests for the AI-tool data scope (`?tool=` on cost / analytics / stats /
|
||||
* workflows), the per-tool cost split `by_tool`, the Prometheus cost metric, the
|
||||
* flex tier and OpenAI priority rates synced into the fast columns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cost-by-tool-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost } = require("../routes/pricing");
|
||||
const { toRow } = require("../lib/pricing-sync");
|
||||
|
||||
const M = 1_000_000;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function call(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get(`${base}${url}`, (res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () =>
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
text,
|
||||
body: res.headers["content-type"]?.includes("json") ? JSON.parse(text) : null,
|
||||
})
|
||||
);
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function session(id, tool) {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z', ?)"
|
||||
).run(id, id, tool);
|
||||
}
|
||||
const usage = (id, model, input) =>
|
||||
stmts.setTokenUsage.run(id, model, "standard", "global", "standard", input, 0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
// $1 / $2 per million input tokens.
|
||||
for (const [pattern, rate] of [
|
||||
["tooltest-claude%", 1],
|
||||
["tooltest-gpt%", 2],
|
||||
]) {
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES (?, ?, ?, 0, 0, 0)"
|
||||
).run(pattern, pattern, rate);
|
||||
}
|
||||
session("c1", "claude");
|
||||
usage("c1", "tooltest-claude-1", 3 * M);
|
||||
// Pre-compaction usage lives in the baseline columns; every cost path counts it.
|
||||
db.prepare("UPDATE token_usage SET baseline_input = ? WHERE session_id = 'c1'").run(1 * M);
|
||||
session("x1", "codex");
|
||||
usage("x1", "tooltest-gpt-1", 1 * M);
|
||||
});
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("?tool= and by_tool", () => {
|
||||
it("splits the total per tool, most expensive first", async () => {
|
||||
const r = await call("/api/pricing/cost");
|
||||
assert.equal(r.body.total_cost, 6);
|
||||
assert.deepEqual(r.body.by_tool, [
|
||||
{ tool: "claude", cost: 4 },
|
||||
{ tool: "codex", cost: 2 },
|
||||
]);
|
||||
});
|
||||
it("narrows cost, analytics, stats and workflows to one tool", async () => {
|
||||
const cost = await call("/api/pricing/cost?tool=codex");
|
||||
assert.equal(cost.body.total_cost, 2);
|
||||
const an = await call("/api/analytics?tool=codex");
|
||||
assert.equal(an.body.total_cost, 2);
|
||||
assert.equal(an.body.overview.total_sessions, 1);
|
||||
const st = await call("/api/stats?tool=codex");
|
||||
assert.equal(st.body.total_sessions, 1);
|
||||
const wf = await call("/api/workflows?tool=claude");
|
||||
assert.equal(wf.status, 200);
|
||||
});
|
||||
it("analytics total_cost equals /api/pricing/cost, baselines included", async () => {
|
||||
const an = await call("/api/analytics");
|
||||
const cost = await call("/api/pricing/cost");
|
||||
assert.equal(an.body.total_cost, cost.body.total_cost);
|
||||
});
|
||||
it("answers 400 for a malformed tool", async () => {
|
||||
for (const url of [
|
||||
"/api/pricing/cost?tool=a%20b",
|
||||
"/api/analytics?tool=%3Bdrop",
|
||||
"/api/stats?tool=a%2Fb",
|
||||
"/api/workflows?tool=a%27",
|
||||
]) {
|
||||
assert.equal((await call(url)).status, 400, url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub_cost_usd_total", () => {
|
||||
it("exposes cost per tool and model", async () => {
|
||||
const r = await call("/api/metrics");
|
||||
assert.match(r.text, /agenthub_cost_usd_total\{tool="claude",model="tooltest-claude-1"\} 4\n/);
|
||||
assert.match(r.text, /agenthub_cost_usd_total\{tool="codex",model="tooltest-gpt-1"\} 2\n/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiers", () => {
|
||||
it("prices the flex tier at half, like batch", () => {
|
||||
const rules = [{ model_pattern: "m%", input_per_mtok: 2 }];
|
||||
const row = {
|
||||
model: "m1",
|
||||
input_tokens: M,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
};
|
||||
assert.equal(calculateCost([row], rules).total_cost, 2);
|
||||
assert.equal(calculateCost([{ ...row, service_tier: "flex" }], rules).total_cost, 1);
|
||||
});
|
||||
it("syncs an OpenAI model's priority rates into the fast columns", () => {
|
||||
const row = toRow("gpt-5.6", {
|
||||
litellm_provider: "openai",
|
||||
input_cost_per_token: 4e-6,
|
||||
output_cost_per_token: 2e-5,
|
||||
input_cost_per_token_priority: 8e-6,
|
||||
output_cost_per_token_priority: 4e-5,
|
||||
});
|
||||
assert.equal(row.fastIn, 8);
|
||||
assert.equal(row.fastOut, 40);
|
||||
const plain = toRow("gpt-5.6", {
|
||||
litellm_provider: "openai",
|
||||
input_cost_per_token: 4e-6,
|
||||
output_cost_per_token: 2e-5,
|
||||
});
|
||||
assert.equal(plain.fastIn, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file Tests server/lib/cursor-usage.js — Cursor sessions from its agent
|
||||
* transcripts: a session created as `tool = 'cursor'` with its name, cwd (the
|
||||
* project slug walked back onto the filesystem) and no tokens, an unchanged
|
||||
* file never re-read, a quiet one settled to completed, a session the user
|
||||
* deleted staying deleted, and a non-cursor row with the same id never
|
||||
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
|
||||
* synthetic, in the shape Cursor writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const cursor = require("../lib/cursor-usage");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
|
||||
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
|
||||
fs.mkdirSync(PROJECT, { recursive: true });
|
||||
const slugOf = (p) => p.replace(/^\/+/, "").replace(/[^A-Za-z0-9]/g, "-");
|
||||
|
||||
function transcript(
|
||||
id,
|
||||
{ slug = slugOf(PROJECT), prompt = "Review the campaign code\nplease" } = {}
|
||||
) {
|
||||
const dir = path.join(process.env.AGENTHUB_CURSOR_DIR, "projects", slug, "agent-transcripts", id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${id}.jsonl`);
|
||||
const lines = [
|
||||
{
|
||||
role: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<timestamp>Thursday, Jun 11, 2026, 4:53 PM (UTC+7)</timestamp>\n<user_query>\n${prompt}\n</user_query>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ role: "assistant", message: { content: [{ type: "tool_use", name: "Read", input: {} }] } },
|
||||
{ type: "turn_ended", status: "success" },
|
||||
];
|
||||
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
function age(file, ms) {
|
||||
const t = new Date(Date.now() - ms);
|
||||
fs.utimesSync(file, t, t);
|
||||
}
|
||||
|
||||
const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
|
||||
db.exec(
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
|
||||
);
|
||||
});
|
||||
|
||||
describe("cursor usage", () => {
|
||||
it("walks a project slug back to an existing directory, else null", () => {
|
||||
assert.equal(cursor.cwdFromSlug(slugOf(PROJECT)), PROJECT);
|
||||
assert.equal(cursor.cwdFromSlug(slugOf(`${PROJECT}-gone`)), null);
|
||||
assert.equal(cursor.cwdFromSlug("empty-window"), null);
|
||||
assert.equal(cursor.cwdFromSlug(""), null);
|
||||
});
|
||||
|
||||
it("creates the session with its name and cwd, and no tokens", async () => {
|
||||
transcript(ID);
|
||||
assert.deepEqual(await cursor.ingestCursorUsage(), { files: 1, sessions: 1 });
|
||||
const s = session(ID);
|
||||
assert.equal(s.tool, "cursor");
|
||||
assert.equal(s.name, "Review the campaign code");
|
||||
assert.equal(s.cwd, PROJECT);
|
||||
assert.equal(s.model, null);
|
||||
assert.equal(s.status, "active");
|
||||
assert.equal(s.transcript_path, null);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM token_usage").get().c, 0);
|
||||
// Unchanged: never re-read.
|
||||
assert.deepEqual(await cursor.ingestCursorUsage(), { files: 0, sessions: 0 });
|
||||
});
|
||||
|
||||
it("settles a quiet session to completed, and reads an old one completed", async () => {
|
||||
const file = transcript(ID);
|
||||
await cursor.ingestCursorUsage();
|
||||
const seen = db.prepare("SELECT mtime_ms FROM cursor_transcripts").get().mtime_ms;
|
||||
assert.deepEqual(await cursor.ingestCursorUsage({ now: seen + 5 * 60_000 }), {
|
||||
files: 0,
|
||||
sessions: 1,
|
||||
});
|
||||
assert.equal(session(ID).status, "completed");
|
||||
assert.equal(session(ID).ended_at, new Date(seen).toISOString());
|
||||
|
||||
db.exec("DELETE FROM sessions; DELETE FROM cursor_transcripts;");
|
||||
age(file, 10 * 60_000);
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).status, "completed");
|
||||
});
|
||||
|
||||
it("keeps a deleted session deleted until Cursor writes again", async () => {
|
||||
const file = transcript(ID);
|
||||
age(file, 10 * 60_000);
|
||||
await cursor.ingestCursorUsage();
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID), undefined);
|
||||
fs.appendFileSync(file, JSON.stringify({ role: "user", message: { content: [] } }) + "\n");
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).tool, "cursor");
|
||||
});
|
||||
|
||||
it("never touches a session of another tool with the same id", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool) VALUES (?, 'mine', 'active', 'claude')"
|
||||
).run(ID);
|
||||
transcript(ID, { slug: "empty-window" });
|
||||
await cursor.ingestCursorUsage();
|
||||
const s = session(ID);
|
||||
assert.equal(s.tool, "claude");
|
||||
assert.equal(s.name, "mine");
|
||||
});
|
||||
|
||||
it("names a session with no prompt by its id, with no cwd", async () => {
|
||||
transcript(ID, { slug: "1778550192773", prompt: "" });
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
|
||||
assert.equal(session(ID).cwd, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file Runs every MCP tool (`mcp/server.js`) against a REAL in-test dashboard
|
||||
* (throwaway DB and CLAUDE_HOME), so a tool whose path or query drifts from
|
||||
* the actual API fails here rather than in a user's session. The MCP server is
|
||||
* spawned asynchronously because the dashboard lives in this process.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const TMP = path.join(os.tmpdir(), `agenthub-mcp-live-${Date.now()}-${process.pid}`);
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
let server;
|
||||
let child;
|
||||
let rpc;
|
||||
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
const url = `http://127.0.0.1:${server.address().port}`;
|
||||
child = spawn(process.execPath, [path.resolve(__dirname, "..", "..", "mcp", "server.js")], {
|
||||
env: { ...process.env, AGENTHUB_URL: url },
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
const waiting = new Map();
|
||||
readline.createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
waiting.get(msg.id)?.(msg);
|
||||
});
|
||||
let next = 1;
|
||||
rpc = (method, params) => {
|
||||
const id = next++;
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
return new Promise((resolve) => waiting.set(id, resolve));
|
||||
};
|
||||
await rpc("initialize", { protocolVersion: "2025-06-18" });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
child?.stdin.end();
|
||||
server?.close();
|
||||
db?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("every tool answers JSON from the real API", async () => {
|
||||
const { result } = await rpc("tools/list", {});
|
||||
const args = {
|
||||
get_session_cost: { session_id: "none" },
|
||||
get_lane_cost: { lane: 1 },
|
||||
get_session: { session_id: "none" },
|
||||
get_session_stats: { session_id: "none" },
|
||||
};
|
||||
for (const { name } of result.tools) {
|
||||
const res = await rpc("tools/call", { name, arguments: args[name] || {} });
|
||||
const out = res.result.content[0].text;
|
||||
if (name === "get_session" || name === "get_session_stats") {
|
||||
assert.match(out, /HTTP 404: Session not found/, name);
|
||||
} else if (name === "get_budget_status" && res.result.isError) {
|
||||
// Budgets come from another phase; until then the route is missing.
|
||||
assert.match(out, /Not available on this dashboard/);
|
||||
} else {
|
||||
assert.equal(res.result.isError, undefined, `${name}: ${out.slice(0, 200)}`);
|
||||
JSON.parse(out);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @file Tests for the read-only MCP server (`mcp/server.js`): spawns it as a
|
||||
* child over stdio against a fake in-test dashboard API and drives the real
|
||||
* JSON-RPC exchange — initialize, tools/list, tools/call — plus the error
|
||||
* paths: a route the dashboard lacks (404, or the production SPA page), an API
|
||||
* error, an unreachable dashboard, unknown tools/methods and bad JSON.
|
||||
* @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 http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const SERVER = path.resolve(__dirname, "..", "..", "mcp", "server.js");
|
||||
|
||||
let api;
|
||||
let apiUrl;
|
||||
const seen = [];
|
||||
|
||||
/** Start the MCP server against `url`; returns `rpc(msg)` and `close()`. */
|
||||
function startMcp(url) {
|
||||
const child = spawn(process.execPath, [SERVER], {
|
||||
env: { ...process.env, AGENTHUB_URL: url, DASHBOARD_TOKEN: "tok" },
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
const waiting = new Map();
|
||||
const unsolicited = [];
|
||||
readline.createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
const w = waiting.get(msg.id);
|
||||
if (w) {
|
||||
waiting.delete(msg.id);
|
||||
w(msg);
|
||||
} else unsolicited.push(msg);
|
||||
});
|
||||
let next = 1;
|
||||
return {
|
||||
unsolicited,
|
||||
rpc(method, params) {
|
||||
const id = next++;
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
return new Promise((resolve) => waiting.set(id, resolve));
|
||||
},
|
||||
raw(line) {
|
||||
child.stdin.write(line + "\n");
|
||||
},
|
||||
close() {
|
||||
child.stdin.end();
|
||||
return new Promise((resolve) => child.on("exit", resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const text = (res) => res.result.content[0].text;
|
||||
|
||||
describe("mcp/server.js", () => {
|
||||
let mcp;
|
||||
|
||||
before(async () => {
|
||||
api = http.createServer((req, res) => {
|
||||
seen.push({ url: req.url, auth: req.headers.authorization });
|
||||
const send = (status, body, type = "application/json") => {
|
||||
res.writeHead(status, { "Content-Type": type });
|
||||
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
||||
};
|
||||
const p = req.url.split("?")[0];
|
||||
if (p === "/api/stats") return send(200, { total_sessions: 3 });
|
||||
if (p === "/api/sessions/s%2F1") return send(200, { session: { id: "s/1" } });
|
||||
if (p === "/api/sessions/gone") {
|
||||
return send(404, { error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
if (p === "/api/pricing/cost") return send(200, { total_cost: 1.5, daily_costs: [] });
|
||||
if (p === "/api/analytics") return send(500, { error: { message: "boom" } });
|
||||
if (p === "/api/lanes") return send(200, "<!doctype html><html></html>", "text/html");
|
||||
return send(404, "Cannot GET " + p, "text/html");
|
||||
});
|
||||
await new Promise((r) => api.listen(0, "127.0.0.1", r));
|
||||
apiUrl = `http://127.0.0.1:${api.address().port}`;
|
||||
mcp = startMcp(apiUrl);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await mcp.close();
|
||||
api.close();
|
||||
});
|
||||
|
||||
it("initializes, echoing a supported protocol version", async () => {
|
||||
const res = await mcp.rpc("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0" },
|
||||
});
|
||||
assert.equal(res.result.protocolVersion, "2025-03-26");
|
||||
assert.deepEqual(res.result.capabilities, { tools: {} });
|
||||
assert.equal(res.result.serverInfo.name, "agenthub");
|
||||
// A notification gets no answer.
|
||||
mcp.raw(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
|
||||
const unknownVersion = await mcp.rpc("initialize", { protocolVersion: "1999-01-01" });
|
||||
assert.match(unknownVersion.result.protocolVersion, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.notEqual(unknownVersion.result.protocolVersion, "1999-01-01");
|
||||
});
|
||||
|
||||
it("lists ~15 read-only tools with object schemas", async () => {
|
||||
const res = await mcp.rpc("tools/list", {});
|
||||
const names = res.result.tools.map((t) => t.name);
|
||||
for (const n of [
|
||||
"get_stats",
|
||||
"get_total_cost",
|
||||
"get_session_cost",
|
||||
"list_sessions",
|
||||
"get_session",
|
||||
"get_session_stats",
|
||||
"get_analytics",
|
||||
"get_workflows",
|
||||
"list_alerts",
|
||||
"list_lanes",
|
||||
"get_lane_cost",
|
||||
"list_pricing",
|
||||
"get_health",
|
||||
"get_info",
|
||||
"get_budget_status",
|
||||
]) {
|
||||
assert.ok(names.includes(n), n);
|
||||
}
|
||||
for (const t of res.result.tools) {
|
||||
assert.equal(t.inputSchema.type, "object");
|
||||
assert.equal(t.annotations.readOnlyHint, true);
|
||||
}
|
||||
});
|
||||
|
||||
it("calls a tool: pretty JSON text, bearer token, query passed through", async () => {
|
||||
seen.length = 0;
|
||||
const res = await mcp.rpc("tools/call", {
|
||||
name: "get_total_cost",
|
||||
arguments: { tz_offset: -420, lane: 7, tool: "codex" },
|
||||
});
|
||||
assert.equal(res.result.isError, undefined);
|
||||
assert.deepEqual(JSON.parse(text(res)), { total_cost: 1.5, daily_costs: [] });
|
||||
assert.equal(seen[0].url, "/api/pricing/cost?tz_offset=-420&lane=7&tool=codex");
|
||||
assert.equal(seen[0].auth, "Bearer tok");
|
||||
});
|
||||
|
||||
it("defaults tz_offset to this machine's and encodes path ids", async () => {
|
||||
seen.length = 0;
|
||||
await mcp.rpc("tools/call", { name: "get_stats", arguments: {} });
|
||||
assert.equal(seen[0].url, `/api/stats?tz_offset=${new Date().getTimezoneOffset()}`);
|
||||
const res = await mcp.rpc("tools/call", {
|
||||
name: "get_session",
|
||||
arguments: { session_id: "s/1" },
|
||||
});
|
||||
assert.equal(JSON.parse(text(res)).session.id, "s/1");
|
||||
assert.equal(seen[1].url, "/api/sessions/s%2F1?events=0");
|
||||
});
|
||||
|
||||
it("reports a missing route as not available (404 and SPA page)", async () => {
|
||||
const budgets = await mcp.rpc("tools/call", { name: "get_budget_status", arguments: {} });
|
||||
assert.equal(budgets.result.isError, true);
|
||||
assert.match(text(budgets), /Not available on this dashboard/);
|
||||
const spa = await mcp.rpc("tools/call", { name: "list_lanes", arguments: {} });
|
||||
assert.match(text(spa), /Not available on this dashboard/);
|
||||
});
|
||||
|
||||
it("returns API errors and bad calls as isError results", async () => {
|
||||
const notFound = await mcp.rpc("tools/call", {
|
||||
name: "get_session_stats",
|
||||
arguments: { session_id: "gone" },
|
||||
});
|
||||
// JSON 404 from a real route is the API's own answer, not a missing route.
|
||||
const gone = await mcp.rpc("tools/call", {
|
||||
name: "get_session",
|
||||
arguments: { session_id: "gone" },
|
||||
});
|
||||
assert.match(text(gone), /HTTP 404: Session not found/);
|
||||
assert.equal(notFound.result.isError, true);
|
||||
const boom = await mcp.rpc("tools/call", { name: "get_analytics", arguments: {} });
|
||||
assert.equal(boom.result.isError, true);
|
||||
assert.match(text(boom), /HTTP 500: boom/);
|
||||
const missing = await mcp.rpc("tools/call", { name: "get_session_cost", arguments: {} });
|
||||
assert.match(text(missing), /Missing required argument\(s\): session_id/);
|
||||
const unknown = await mcp.rpc("tools/call", { name: "drop_tables", arguments: {} });
|
||||
assert.match(text(unknown), /Unknown tool/);
|
||||
});
|
||||
|
||||
it("answers unknown methods and bad JSON with JSON-RPC errors", async () => {
|
||||
const res = await mcp.rpc("resources/list", {});
|
||||
assert.equal(res.error.code, -32601);
|
||||
const before = mcp.unsolicited.length;
|
||||
mcp.raw("{not json");
|
||||
await mcp.rpc("ping", {}); // ordering barrier
|
||||
assert.equal(mcp.unsolicited[before].error.code, -32700);
|
||||
});
|
||||
|
||||
it("says so when the dashboard is down", async () => {
|
||||
const dead = http.createServer();
|
||||
await new Promise((r) => dead.listen(0, "127.0.0.1", r));
|
||||
const port = dead.address().port;
|
||||
await new Promise((r) => dead.close(r));
|
||||
const down = startMcp(`http://127.0.0.1:${port}`);
|
||||
const res = await down.rpc("tools/call", { name: "get_health", arguments: {} });
|
||||
assert.equal(res.result.isError, true);
|
||||
assert.match(text(res), /not reachable/);
|
||||
await down.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file Static checks for the Prometheus/Grafana bundle (`monitoring/`,
|
||||
* `docker-compose.monitoring.yml`): every YAML and dashboard JSON parses, the
|
||||
* compose file mounts paths that exist, and every `agenthub_*` metric a board
|
||||
* or recording rule queries is one `server/routes/metrics.js` exports — except
|
||||
* `agenthub_cost_usd_total`, which those panels mark as required from another
|
||||
* phase. Docker is never run.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const MON = path.join(ROOT, "monitoring");
|
||||
const readYaml = (p) => yaml.load(fs.readFileSync(path.join(ROOT, p), "utf8"));
|
||||
const BOARDS = fs
|
||||
.readdirSync(path.join(MON, "grafana", "dashboards"))
|
||||
.map((f) => path.join(MON, "grafana", "dashboards", f));
|
||||
|
||||
const exported = new Set(
|
||||
fs
|
||||
.readFileSync(path.join(ROOT, "server", "routes", "metrics.js"), "utf8")
|
||||
.match(/"agenthub_[a-z_]+"/g)
|
||||
.map((s) => s.slice(1, -1))
|
||||
);
|
||||
const OPTIONAL = new Set(["agenthub_cost_usd_total"]);
|
||||
const metricsIn = (expr) => expr.match(/\bagenthub_[a-z_]+\b/g) || [];
|
||||
|
||||
it("compose, Prometheus and provisioning YAML parse and point at real files", () => {
|
||||
const compose = readYaml("docker-compose.monitoring.yml");
|
||||
assert.deepEqual(Object.keys(compose.services).sort(), ["grafana", "prometheus"]);
|
||||
for (const svc of Object.values(compose.services)) {
|
||||
for (const v of svc.volumes) {
|
||||
const src = v.split(":")[0];
|
||||
if (src.startsWith("./")) assert.ok(fs.existsSync(path.join(ROOT, src)), src);
|
||||
}
|
||||
}
|
||||
const prom = readYaml("monitoring/prometheus/prometheus.yml");
|
||||
assert.equal(prom.scrape_configs[0].metrics_path, "/api/metrics");
|
||||
for (const f of prom.rule_files) assert.ok(fs.existsSync(path.join(MON, "prometheus", f)), f);
|
||||
readYaml("monitoring/grafana/provisioning/datasources/datasource.yml");
|
||||
readYaml("monitoring/grafana/provisioning/dashboards/provider.yml");
|
||||
});
|
||||
|
||||
it("recording rules query only exported metrics", () => {
|
||||
const rules = readYaml("monitoring/prometheus/agenthub-rules.yml");
|
||||
const all = rules.groups.flatMap((g) => g.rules);
|
||||
assert.ok(all.length > 0);
|
||||
for (const r of all) {
|
||||
assert.match(r.record, /^agenthub:/);
|
||||
for (const m of metricsIn(r.expr))
|
||||
assert.ok(exported.has(m) || OPTIONAL.has(m), `${r.record}: ${m}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("three boards: valid JSON, unique uid and panel ids, exported metrics only", () => {
|
||||
assert.equal(BOARDS.length, 3);
|
||||
const uids = new Set();
|
||||
let costPanels = 0;
|
||||
for (const file of BOARDS) {
|
||||
const b = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.ok(b.uid && !uids.has(b.uid), file);
|
||||
uids.add(b.uid);
|
||||
const ids = b.panels.map((p) => p.id);
|
||||
assert.equal(new Set(ids).size, ids.length, `${file}: duplicate panel id`);
|
||||
for (const p of b.panels) {
|
||||
assert.equal(p.datasource.uid, "${ds}");
|
||||
for (const t of p.targets) {
|
||||
for (const m of metricsIn(t.expr)) {
|
||||
assert.ok(exported.has(m) || OPTIONAL.has(m), `${b.uid} / ${p.title}: ${m}`);
|
||||
if (OPTIONAL.has(m)) {
|
||||
assert.match(p.description || "", /Requires agenthub_cost_usd_total/, p.title);
|
||||
costPanels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(costPanels >= 4, "cost panels present");
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file Tests server/lib/privacy.js — the secret patterns (each shape redacted,
|
||||
* look-alikes such as `max_tokens: 5` left alone), secret-named JSON keys, the
|
||||
* payload entry point being a byte-identical no-op while off or when nothing
|
||||
* matched, `trim_event_data` redacting before it trims, and the ignored-folder
|
||||
* list's path-boundary match and validation.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const privacy = require("../lib/privacy");
|
||||
const { trimEventData } = require("../lib/event-payload");
|
||||
|
||||
const R = privacy.REDACTED;
|
||||
|
||||
describe("redactText", () => {
|
||||
const cases = [
|
||||
["anthropic key", "key sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv here", `key ${R} here`],
|
||||
["openai key", "OPENAI sk-proj-AbCdEfGhIjKlMnOpQrStUvWx", `OPENAI ${R}`],
|
||||
["github token", "ghp_0123456789abcdefghijABCDEFGHIJ", R],
|
||||
["aws key id", "id AKIAABCDEFGHIJKLMNOP", `id ${R}`],
|
||||
["slack", "xoxb-1234567890-abcdef", R],
|
||||
[
|
||||
"bearer header",
|
||||
"curl -H 'Authorization: Bearer abc.def-ghi_jkl'",
|
||||
`curl -H 'Authorization: Bearer ${R}'`,
|
||||
],
|
||||
["password assignment", "mysql password=hunter22 -h db", `mysql password=${R} -h db`],
|
||||
["env token", "export GITHUB_TOKEN=abcd1234efgh", `export GITHUB_TOKEN=${R}`],
|
||||
["json-ish key", '{"api_key": "abcd1234"}', `{"api_key": ${R}}`],
|
||||
[
|
||||
"private key",
|
||||
"a\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA\n-----END OPENSSH PRIVATE KEY-----\nz",
|
||||
`a\n${R}\nz`,
|
||||
],
|
||||
];
|
||||
for (const [name, input, want] of cases) {
|
||||
it(`redacts ${name}`, () => assert.equal(privacy.redactText(input), want));
|
||||
}
|
||||
|
||||
it("leaves look-alikes alone", () => {
|
||||
for (const s of [
|
||||
"max_tokens: 4096",
|
||||
"token_count=5 input_tokens: 9",
|
||||
"the author wrote a secretive note",
|
||||
"sk-short",
|
||||
"Bearer of bad news",
|
||||
]) {
|
||||
assert.equal(privacy.redactText(s), s);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = privacy.redactText("password=hunter22 and ghp_0123456789abcdefghijABCDEFGHIJ");
|
||||
assert.equal(privacy.redactText(once), once);
|
||||
});
|
||||
|
||||
it("stays linear on a pathological run", () => {
|
||||
const t = Date.now();
|
||||
privacy.redactText("a-".repeat(200_000));
|
||||
assert.ok(Date.now() - t < 2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactValue", () => {
|
||||
it("redacts any string under a secret-named key, whatever it looks like", () => {
|
||||
const out = privacy.redactValue({
|
||||
tool_input: { env: { DB_PASSWORD: "x", API_KEY: "y", max_tokens: "5", authorization: "z" } },
|
||||
author: "me",
|
||||
});
|
||||
assert.deepEqual(out, {
|
||||
tool_input: { env: { DB_PASSWORD: R, API_KEY: R, max_tokens: "5", authorization: R } },
|
||||
author: "me",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactEventJson / trim_event_data", () => {
|
||||
let saved;
|
||||
beforeEach(() => {
|
||||
saved = process.env[privacy.REDACT_KEY];
|
||||
});
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env[privacy.REDACT_KEY];
|
||||
else process.env[privacy.REDACT_KEY] = saved;
|
||||
});
|
||||
const payload = JSON.stringify({
|
||||
session_id: "s",
|
||||
tool_input: { command: "curl -H 'Authorization: Bearer abcdefgh12345678' x" },
|
||||
});
|
||||
|
||||
it("is a byte-identical no-op while off", () => {
|
||||
delete process.env[privacy.REDACT_KEY];
|
||||
assert.equal(privacy.redactEventJson(payload), payload);
|
||||
assert.equal(trimEventData(payload), payload);
|
||||
});
|
||||
|
||||
it("redacts and counts when on, and leaves a clean payload byte-identical", () => {
|
||||
process.env[privacy.REDACT_KEY] = "1";
|
||||
const out = JSON.parse(trimEventData(payload));
|
||||
assert.equal(out.tool_input.command, `curl -H 'Authorization: Bearer ${R}' x`);
|
||||
assert.equal(out._redacted, 1);
|
||||
const clean = JSON.stringify({ session_id: "s", tool_input: { command: "ls" } });
|
||||
assert.equal(trimEventData(clean), clean);
|
||||
});
|
||||
|
||||
it("redacts a private key before the trim could cut it in two", () => {
|
||||
process.env[privacy.REDACT_KEY] = "1";
|
||||
const key = `-----BEGIN RSA PRIVATE KEY-----\n${"A".repeat(20_000)}\n-----END RSA PRIVATE KEY-----`;
|
||||
const json = JSON.stringify({ tool_name: "Read", tool_response: { file: { text: key } } });
|
||||
const out = trimEventData(json);
|
||||
assert.ok(!out.includes("AAAAAAAAAA"));
|
||||
assert.ok(out.includes(R));
|
||||
});
|
||||
|
||||
it("leaves summaries alone while off", () => {
|
||||
delete process.env[privacy.REDACT_KEY];
|
||||
assert.equal(privacy.redactSummary("password=hunter22"), "password=hunter22");
|
||||
process.env[privacy.REDACT_KEY] = "1";
|
||||
assert.equal(privacy.redactSummary("password=hunter22"), `password=${R}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignored folders", () => {
|
||||
const env = (list) => ({ [privacy.IGNORE_KEY]: list.join(path.delimiter) });
|
||||
|
||||
it("matches the folder and what lies under it, never a sibling sharing the prefix", () => {
|
||||
const e = env(["/work/secret", "/tmp/x/"]);
|
||||
assert.equal(privacy.isIgnoredCwd("/work/secret", e), true);
|
||||
assert.equal(privacy.isIgnoredCwd("/work/secret/app", e), true);
|
||||
assert.equal(privacy.isIgnoredCwd("/work/secret-two", e), false);
|
||||
assert.equal(privacy.isIgnoredCwd("/tmp/x/y", e), true);
|
||||
assert.equal(privacy.isIgnoredCwd("/work", e), false);
|
||||
assert.equal(privacy.isIgnoredCwd(undefined, e), false);
|
||||
assert.equal(privacy.isIgnoredCwd("/anything", {}), false);
|
||||
});
|
||||
|
||||
it("never treats a root or a relative entry as a folder to ignore", () => {
|
||||
assert.deepEqual(privacy.ignoredCwds(env(["/", "rel/dir", "/ok"])).length, 2);
|
||||
assert.equal(privacy.isIgnoredCwd("/home/u", env(["/"])), false);
|
||||
});
|
||||
|
||||
it("validates the Settings list", () => {
|
||||
assert.deepEqual(privacy.validateIgnoreList(["/a/b/", " /a/b ", "", "/c"]), ["/a/b", "/c"]);
|
||||
assert.throws(() => privacy.validateIgnoreList(["relative"]), { code: "EBADPATH" });
|
||||
assert.throws(() => privacy.validateIgnoreList(["/"]), { code: "EBADPATH" });
|
||||
assert.throws(() => privacy.validateIgnoreList([`/a${path.delimiter}/b`]), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
assert.throws(() => privacy.validateIgnoreList(["/a\nb"]), { code: "EBADPATH" });
|
||||
assert.throws(() => privacy.validateIgnoreList("/a"), { code: "EBADPATH" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @file Tests the push path and the privacy settings end to end:
|
||||
* `POST /api/hooks/ingest-batch` (off until a token exists, bearer token
|
||||
* checked, sessions tagged `push:<source>`, a session another source owns
|
||||
* refused, the sender's transcript paths dropped, batch limits) with the token
|
||||
* routes `/api/settings/push-token` (shown once, stored only as a hash,
|
||||
* rotate/revoke), and `/api/settings/privacy` (ignored folders dropping hooks,
|
||||
* opt-in secret redaction of stored payloads and summaries, validation, the
|
||||
* same-origin guard). DB, homes and the settings file live in a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-push-ingest-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
|
||||
delete process.env.AGENTHUB_PUSH_TOKEN_SHA256;
|
||||
delete process.env.AGENTHUB_REDACT_SECRETS;
|
||||
delete process.env.AGENTHUB_IGNORE_CWD;
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const express = require("express");
|
||||
|
||||
const { db, stmts } = require("../db");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
before(async () => {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use("/api/hooks", require("../routes/hooks"));
|
||||
app.use("/api/settings", require("../routes/settings"));
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
db.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function call(method, p, body, headers = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
const bearer = (t) => ({ Authorization: `Bearer ${t}` });
|
||||
const batch = (token, body) => call("POST", "/api/hooks/ingest-batch", body, bearer(token));
|
||||
const hook = (session_id, hook_type = "SessionStart", extra = {}) => ({
|
||||
hook_type,
|
||||
data: { session_id, cwd: "/remote/proj", ...extra },
|
||||
});
|
||||
|
||||
describe("push ingest", () => {
|
||||
let token;
|
||||
|
||||
it("is off (503) until a token exists", async () => {
|
||||
const r = await batch("anything", { source: "laptop", events: [hook("p0")] });
|
||||
assert.equal(r.status, 503);
|
||||
assert.equal(r.body.error.code, "EPUSHDISABLED");
|
||||
assert.deepEqual((await call("GET", "/api/settings/push-token")).body, {
|
||||
enabled: false,
|
||||
env_owned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a new token once and stores only its hash", async () => {
|
||||
const r = await call("POST", "/api/settings/push-token");
|
||||
assert.equal(r.status, 200);
|
||||
token = r.body.token;
|
||||
assert.match(token, /^ahp_[A-Za-z0-9_-]{40,}$/);
|
||||
const status = await call("GET", "/api/settings/push-token");
|
||||
assert.deepEqual(status.body, { enabled: true, env_owned: false });
|
||||
const file = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8");
|
||||
assert.ok(!file.includes(token));
|
||||
assert.match(file, /^AGENTHUB_PUSH_TOKEN_SHA256=[0-9a-f]{64}$/m);
|
||||
});
|
||||
|
||||
it("refuses a missing or wrong token", async () => {
|
||||
assert.equal(
|
||||
(await batch("ahp_wrong", { source: "laptop", events: [hook("p0")] })).status,
|
||||
401
|
||||
);
|
||||
const r = await call("POST", "/api/hooks/ingest-batch", { source: "laptop", events: [] });
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
|
||||
it("stores the batch tagged with its source and drops the sender's transcript paths", async () => {
|
||||
const r = await batch(token, {
|
||||
source: "laptop",
|
||||
events: [
|
||||
hook("p1", "SessionStart", { transcript_path: "/etc/passwd" }),
|
||||
hook("p1", "UserPromptSubmit", { prompt: "hello there" }),
|
||||
{ hook_type: "bad type!", data: {} },
|
||||
],
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.source, "push:laptop");
|
||||
assert.equal(r.body.stored, 2);
|
||||
assert.deepEqual(r.body.results[2], { ok: false, error: "INVALID_INPUT" });
|
||||
const s = stmts.getSession.get("p1");
|
||||
assert.equal(s.source, "push:laptop");
|
||||
assert.ok(!s.transcript_path);
|
||||
const data = db.prepare("SELECT data FROM events WHERE session_id = 'p1'").all();
|
||||
assert.ok(data.every((e) => !e.data.includes("/etc/passwd")));
|
||||
});
|
||||
|
||||
it("never touches a session another source owns", async () => {
|
||||
await call("POST", "/api/hooks/event", hook("local1"));
|
||||
assert.equal(stmts.getSession.get("local1").source, "local");
|
||||
const before = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'")
|
||||
.get().n;
|
||||
const r = await batch(token, { source: "laptop", events: [hook("local1", "Stop")] });
|
||||
assert.deepEqual(r.body.results[0], { ok: false, error: "EFOREIGN" });
|
||||
const after = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'")
|
||||
.get().n;
|
||||
assert.equal(after, before);
|
||||
// Nor one a different pushing machine created.
|
||||
const other = await batch(token, { source: "desk", events: [hook("p1", "Stop")] });
|
||||
assert.deepEqual(other.body.results[0], { ok: false, error: "EFOREIGN" });
|
||||
});
|
||||
|
||||
it("checks the source name and the batch size", async () => {
|
||||
assert.equal((await batch(token, { source: "../x", events: [hook("p2")] })).status, 400);
|
||||
assert.equal((await batch(token, { source: "laptop", events: [] })).status, 400);
|
||||
const many = Array.from({ length: 501 }, (_, i) => hook(`m${i}`));
|
||||
assert.equal((await batch(token, { source: "laptop", events: many })).status, 400);
|
||||
});
|
||||
|
||||
it("stops the old token on rotate, and everything on revoke", async () => {
|
||||
const r = await call("POST", "/api/settings/push-token");
|
||||
assert.notEqual(r.body.token, token);
|
||||
assert.equal((await batch(token, { source: "laptop", events: [hook("p3")] })).status, 401);
|
||||
assert.equal(
|
||||
(await batch(r.body.token, { source: "laptop", events: [hook("p3")] })).status,
|
||||
200
|
||||
);
|
||||
const off = await call("DELETE", "/api/settings/push-token");
|
||||
assert.deepEqual(off.body, { ok: true, enabled: false, env_owned: false });
|
||||
assert.equal(
|
||||
(await batch(r.body.token, { source: "laptop", events: [hook("p4")] })).status,
|
||||
503
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a token write from another site", async () => {
|
||||
const r = await call("POST", "/api/settings/push-token", undefined, {
|
||||
Origin: "https://evil.example",
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("privacy settings", () => {
|
||||
it("starts off", async () => {
|
||||
const r = await call("GET", "/api/settings/privacy");
|
||||
assert.deepEqual(r.body, {
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops every hook from an ignored folder, and keeps its siblings", async () => {
|
||||
const r = await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/private/work/"] });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.ignore_cwd, ["/private/work"]);
|
||||
const dropped = await call("POST", "/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "ig1", cwd: "/private/work/app" },
|
||||
});
|
||||
assert.deepEqual(dropped.body, { ok: true, ignored: true });
|
||||
assert.equal(stmts.getSession.get("ig1"), undefined);
|
||||
await call("POST", "/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "ig2", cwd: "/private/work-other" },
|
||||
});
|
||||
assert.ok(stmts.getSession.get("ig2"));
|
||||
});
|
||||
|
||||
it("redacts stored payloads and summaries once turned on", async () => {
|
||||
const r = await call("PUT", "/api/settings/privacy", { redact_secrets: true });
|
||||
assert.equal(r.body.redact_secrets, true);
|
||||
await call("POST", "/api/hooks/event", {
|
||||
hook_type: "PreToolUse",
|
||||
data: {
|
||||
session_id: "rd1",
|
||||
cwd: "/w",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "curl -H 'Authorization: Bearer abcdefgh12345678' https://x" },
|
||||
},
|
||||
});
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT summary, data FROM events WHERE session_id = 'rd1' AND event_type = 'PreToolUse'"
|
||||
)
|
||||
.all();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.ok(!rows[0].data.includes("abcdefgh12345678"));
|
||||
assert.ok(!String(rows[0].summary).includes("abcdefgh12345678"));
|
||||
assert.ok(rows[0].data.includes("[redacted]"));
|
||||
await call("PUT", "/api/settings/privacy", { redact_secrets: false, ignore_cwd: [] });
|
||||
});
|
||||
|
||||
it("refuses a bad list, a bad flag and another site", async () => {
|
||||
assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["rel"] })).status, 400);
|
||||
assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/"] })).status, 400);
|
||||
assert.equal(
|
||||
(await call("PUT", "/api/settings/privacy", { redact_secrets: "yes" })).status,
|
||||
400
|
||||
);
|
||||
const r = await call(
|
||||
"PUT",
|
||||
"/api/settings/privacy",
|
||||
{ redact_secrets: true },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @file The Query Explorer, GET /api/query: entity switch, filters (text, tool,
|
||||
* status, date range, cwd), the sort ALLOWLIST (a column or direction outside
|
||||
* it is a 400, never SQL), pagination, and the CSV/JSON exports — RFC 4180
|
||||
* quoting and the formula-injection guard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "query-explorer-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const { csvCell } = require("../routes/query");
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
async function get(url) {
|
||||
const res = await fetch(`${base}${url}`);
|
||||
const text = await res.text();
|
||||
let body = text;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
/* CSV */
|
||||
}
|
||||
return { status: res.status, headers: res.headers, body };
|
||||
}
|
||||
|
||||
function session(id, name, status, tool, cwd, startedAt) {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool, cwd, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(id, name, status, tool, cwd, startedAt, startedAt);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
session("q1", "=HYPERLINK(evil)", "completed", "claude", "/w/app", "2026-09-01T10:00:00.000Z");
|
||||
session("q2", 'says "hi", ok', "active", "codex", "/w/app/sub", "2026-09-05T10:00:00.000Z");
|
||||
session("q3", "100%_done", "error", "claude", "/w/apple", "2026-09-10T10:00:00.000Z");
|
||||
db.prepare(
|
||||
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES ('ag1', 'q1', 'main', 'main', 'completed', '2026-09-01T10:00:00.000Z')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('q2', 'PreToolUse', 'Bash', 'line1\nline2', '2026-09-05T11:00:00.000Z')"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ids = (r) => r.body.rows.map((x) => x.id);
|
||||
|
||||
describe("GET /api/query", () => {
|
||||
it("lists sessions newest first by default, paged", async () => {
|
||||
const r = await get("/api/query");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.entity, "sessions");
|
||||
assert.deepEqual(ids(r), ["q3", "q2", "q1"]);
|
||||
assert.equal(r.body.total, 3);
|
||||
const p = await get("/api/query?limit=1&offset=1");
|
||||
assert.deepEqual(ids(p), ["q2"]);
|
||||
assert.equal(p.body.total, 3);
|
||||
});
|
||||
|
||||
it("filters by text (wildcards literal), tool, status, range and cwd subtree", async () => {
|
||||
assert.deepEqual(ids(await get("/api/query?q=%25_")), ["q3"]);
|
||||
assert.deepEqual(ids(await get("/api/query?tool=codex")), ["q2"]);
|
||||
assert.deepEqual(ids(await get("/api/query?status=error")), ["q3"]);
|
||||
assert.deepEqual(ids(await get("/api/query?from=2026-09-02&to=2026-09-06")), ["q2"]);
|
||||
assert.deepEqual(ids(await get("/api/query?cwd=/w/app&sort=started_at&dir=asc")), ["q1", "q2"]);
|
||||
});
|
||||
|
||||
it("sorts only by an allowlisted column and direction", async () => {
|
||||
assert.deepEqual(ids(await get("/api/query?sort=name&dir=asc")), ["q3", "q1", "q2"]);
|
||||
for (const bad of [
|
||||
"sort=id;DROP TABLE sessions",
|
||||
"sort=summary",
|
||||
"dir=sideways",
|
||||
"entity=lanes",
|
||||
"status=bogus",
|
||||
"tool=../x",
|
||||
"from=2026-02-31",
|
||||
"format=xml",
|
||||
]) {
|
||||
const r = await get(`/api/query?${bad}`);
|
||||
assert.equal(r.status, 400, bad);
|
||||
}
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions").get().n, 3);
|
||||
});
|
||||
|
||||
it("queries events and agents with their own columns", async () => {
|
||||
const e = await get("/api/query?entity=events&q=Bash");
|
||||
assert.equal(e.body.total, 1);
|
||||
assert.equal(e.body.rows[0].tool, "codex");
|
||||
assert.equal((await get("/api/query?entity=events&status=active")).status, 400);
|
||||
const a = await get("/api/query?entity=agents&status=completed");
|
||||
assert.deepEqual(ids(a), ["ag1"]);
|
||||
});
|
||||
|
||||
it("exports CSV with quoting and the formula guard", async () => {
|
||||
const r = await get("/api/query?format=csv&sort=started_at&dir=asc");
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.headers.get("content-type"), /text\/csv/);
|
||||
assert.match(r.headers.get("content-disposition"), /attachment; filename="agenthub-sessions-/);
|
||||
assert.equal(r.headers.get("x-truncated"), "0");
|
||||
const lines = r.body.replace(/^\uFEFF/, "").split("\r\n");
|
||||
assert.equal(lines[0], "id,name,status,tool,model,cwd,started_at,ended_at");
|
||||
assert.ok(lines[1].startsWith("q1,'=HYPERLINK(evil),completed"));
|
||||
assert.ok(lines[2].startsWith('q2,"says ""hi"", ok",active'));
|
||||
const ev = await get("/api/query?entity=events&format=csv");
|
||||
assert.ok(ev.body.includes('"line1\nline2"'));
|
||||
});
|
||||
|
||||
it("exports JSON with the total and the cap", async () => {
|
||||
const r = await get("/api/query?format=json&tool=claude");
|
||||
assert.equal(r.body.total, 2);
|
||||
assert.equal(r.body.truncated, false);
|
||||
assert.equal(r.body.cap, 50_000);
|
||||
assert.equal(r.body.rows.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("csvCell", () => {
|
||||
it("guards every formula lead and leaves numbers and plain text alone", () => {
|
||||
for (const lead of ["=", "+", "-", "@", "\t"]) assert.equal(csvCell(`${lead}1`)[0], "'");
|
||||
assert.equal(csvCell(-5), "-5");
|
||||
assert.equal(csvCell(null), "");
|
||||
assert.equal(csvCell("plain"), "plain");
|
||||
assert.equal(csvCell("-1,2"), '"\'-1,2"');
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,22 @@ describe("remote-sync validateSourceInput", () => {
|
||||
assert.equal(v.remoteHome, "~/.claude");
|
||||
});
|
||||
|
||||
it("defaults Codex off and places its home beside Claude's (WSL included)", () => {
|
||||
const v = remoteSync.validateSourceInput({ label: "L", host: "h" });
|
||||
assert.equal(v.syncCodex, 0);
|
||||
assert.equal(v.remoteCodexHome, null);
|
||||
assert.equal(remoteSync.normalizedCodexHome({ remote_home: null }), "~/.codex");
|
||||
assert.equal(remoteSync.normalizedCodexHome({ remote_home: "wsl:~/.claude" }), "wsl:~/.codex");
|
||||
assert.equal(
|
||||
remoteSync.normalizedCodexHome({ remote_home: "~/.claude", remote_codex_home: "/opt/cx" }),
|
||||
"/opt/cx"
|
||||
);
|
||||
assert.equal(
|
||||
remoteSync.wslTarRemoteCmd("~/.codex", "sessions"),
|
||||
"wsl.exe -e sh -c 'tar -cC ~/.codex/sessions .'"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a config-alias host with no user", () => {
|
||||
const v = remoteSync.validateSourceInput({ label: "x", host: "mybox" });
|
||||
assert.equal(v.host, "mybox");
|
||||
@@ -412,6 +428,27 @@ describe("/api/remote-sources CRUD", () => {
|
||||
assert.equal(res.body.source.ssh_port, 22); // unchanged
|
||||
});
|
||||
|
||||
it("stores the Codex opt-in and its home, and a patch of one keeps the other", async () => {
|
||||
const res = await post("/api/remote-sources", {
|
||||
label: "Codex box",
|
||||
host: "son@codexbox",
|
||||
sync_codex: true,
|
||||
remote_codex_home: "~/.codex-work",
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.source.sync_codex, true);
|
||||
assert.equal(res.body.source.remote_codex_home, "~/.codex-work");
|
||||
const id = res.body.source.id;
|
||||
const off = await patch(`/api/remote-sources/${id}`, { sync_codex: false });
|
||||
assert.equal(off.body.source.sync_codex, false);
|
||||
assert.equal(off.body.source.remote_codex_home, "~/.codex-work");
|
||||
const bad = await patch(`/api/remote-sources/${id}`, { remote_codex_home: "~/x;rm -rf /" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "INVALID_REMOTE_CODEX_HOME");
|
||||
await del(`/api/remote-sources/${id}`);
|
||||
});
|
||||
|
||||
it("404s for an unknown id", async () => {
|
||||
const res = await patch("/api/remote-sources/src_nope", { label: "x" });
|
||||
assert.equal(res.status, 404);
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @file Scheduled reports: definition validation and CRUD behind the
|
||||
* same-origin guard, `lastSlot`/`reportWindow` in local time, a run writing
|
||||
* escaped HTML and JSON under <data dir>/reports/<id>/, the download route
|
||||
* reading only what a run recorded, and the scheduler claiming each slot once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "reports-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const reports = require("../lib/reports");
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
async function call(method, url, body, headers = {}) {
|
||||
const res = await fetch(`${base}${url}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed = text;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
/* a file */
|
||||
}
|
||||
return { status: res.status, headers: res.headers, body: parsed };
|
||||
}
|
||||
|
||||
const DEF = {
|
||||
name: "Weekly <b>&</b>",
|
||||
window_days: 7,
|
||||
frequency: "weekly",
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: -420,
|
||||
formats: ["html", "json"],
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('rep-model%', 'R', 1, 0, 0, 0)"
|
||||
).run();
|
||||
const day = new Date(Date.now() - 2 * 86_400_000).toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES ('rs', 'rs', 'completed', ?, ?, 'claude')"
|
||||
).run(day, day);
|
||||
stmts.setTokenUsage.run(
|
||||
"rs",
|
||||
"rep-model<x>",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
2e6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES ('rs', 'PreToolUse', '<script>', ?)"
|
||||
).run(day);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("schedule math", () => {
|
||||
const at = (iso) => Date.parse(iso);
|
||||
it("finds the latest daily and weekly slot in local time", () => {
|
||||
const daily = { frequency: "daily", hour: 8, weekday: 1, tz_offset: -420 };
|
||||
// 2026-10-05 is a Monday. 08:00 at UTC+7 = 01:00Z.
|
||||
assert.equal(
|
||||
new Date(reports.lastSlot(daily, at("2026-10-05T02:00:00Z"))).toISOString(),
|
||||
"2026-10-05T01:00:00.000Z"
|
||||
);
|
||||
assert.equal(
|
||||
new Date(reports.lastSlot(daily, at("2026-10-05T00:30:00Z"))).toISOString(),
|
||||
"2026-10-04T01:00:00.000Z"
|
||||
);
|
||||
const weekly = { ...daily, frequency: "weekly", weekday: 5 }; // Friday
|
||||
assert.equal(
|
||||
new Date(reports.lastSlot(weekly, at("2026-10-05T02:00:00Z"))).toISOString(),
|
||||
"2026-10-02T01:00:00.000Z"
|
||||
);
|
||||
});
|
||||
it("covers the window_days full local days before the run day", () => {
|
||||
const w = reports.reportWindow({ window_days: 7, tz_offset: -420 }, at("2026-10-04T18:00:00Z"));
|
||||
assert.deepEqual(w, { from: "2026-09-28", to: "2026-10-04" }); // 01:00 on the 5th locally
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/reports", () => {
|
||||
let id;
|
||||
it("validates and creates a definition", async () => {
|
||||
for (const bad of [
|
||||
{ ...DEF, name: "" },
|
||||
{ ...DEF, window_days: 0 },
|
||||
{ ...DEF, frequency: "hourly" },
|
||||
{ ...DEF, hour: 24 },
|
||||
{ ...DEF, formats: ["pdf"] },
|
||||
{ ...DEF, formats: [] },
|
||||
{ ...DEF, tz_offset: 9999 },
|
||||
]) {
|
||||
assert.equal((await call("POST", "/api/reports", bad)).status, 400, JSON.stringify(bad));
|
||||
}
|
||||
const r = await call("POST", "/api/reports", DEF);
|
||||
assert.equal(r.status, 201);
|
||||
id = r.body.report.id;
|
||||
assert.deepEqual(r.body.report.formats, ["html", "json"]);
|
||||
assert.equal(r.body.report.enabled, true);
|
||||
assert.ok(r.body.report.last_slot, "the slot already past is claimed");
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await call("POST", "/api/reports", DEF, { origin: "https://evil.example" });
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("updates part of a definition", async () => {
|
||||
const r = await call("PUT", `/api/reports/${id}`, { enabled: false });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.report.enabled, false);
|
||||
assert.equal(r.body.report.name, DEF.name);
|
||||
assert.equal((await call("PUT", `/api/reports/${id}`, { hour: -1 })).status, 400);
|
||||
assert.equal((await call("PUT", "/api/reports/999", { enabled: true })).status, 404);
|
||||
});
|
||||
|
||||
it("runs now, writing escaped HTML and JSON, and serves them by id", async () => {
|
||||
const r = await call("POST", `/api/reports/${id}/run`);
|
||||
assert.equal(r.status, 200);
|
||||
const run = r.body.run;
|
||||
assert.equal(run.status, "ok", run.error);
|
||||
assert.deepEqual(run.files, ["html", "json"]);
|
||||
assert.equal(run.trigger, "manual");
|
||||
|
||||
const json = await call("GET", `/api/reports/${id}/runs/${run.id}/json`);
|
||||
assert.equal(json.status, 200);
|
||||
assert.equal(json.body.sessions.total, 1);
|
||||
assert.equal(json.body.cost.total, 2);
|
||||
assert.deepEqual(json.body.cost.by_model, [{ model: "rep-model<x>", cost: 2 }]);
|
||||
assert.deepEqual(json.body.top_tools, [{ tool_name: "<script>", count: 1 }]);
|
||||
|
||||
const html = await call("GET", `/api/reports/${id}/runs/${run.id}/html`);
|
||||
assert.equal(html.status, 200);
|
||||
assert.match(html.headers.get("content-disposition"), /attachment/);
|
||||
assert.ok(!html.body.includes("<script>"));
|
||||
assert.ok(html.body.includes("<script>"));
|
||||
assert.ok(html.body.includes("Weekly <b>&</b>"));
|
||||
assert.ok(fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
|
||||
|
||||
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id}/pdf`)).status, 404);
|
||||
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id + 9}/json`)).status, 404);
|
||||
assert.equal((await call("GET", `/api/reports/${id}/runs/..%2F..%2Fx/json`)).status, 404);
|
||||
|
||||
const list = await call("GET", "/api/reports");
|
||||
assert.equal(list.body.reports[0].last_run.id, run.id);
|
||||
const runs = await call("GET", `/api/reports/${id}/runs`);
|
||||
assert.equal(runs.body.runs.length, 1);
|
||||
});
|
||||
|
||||
it("deletes a definition with its runs and files", async () => {
|
||||
const r = await call("DELETE", `/api/reports/${id}`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 0);
|
||||
assert.ok(!fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
|
||||
assert.equal((await call("DELETE", `/api/reports/${id}`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduler", () => {
|
||||
it("runs a due report once per slot", async () => {
|
||||
const r = await call("POST", "/api/reports", { ...DEF, frequency: "daily", formats: ["json"] });
|
||||
const id = r.body.report.id;
|
||||
const now = Date.now();
|
||||
assert.equal(reports.tick(now), 0, "the slot claimed at creation is not run");
|
||||
const tomorrow = now + 86_400_000;
|
||||
assert.equal(reports.tick(tomorrow), 1);
|
||||
assert.equal(reports.tick(tomorrow), 0);
|
||||
const runs = db.prepare("SELECT * FROM report_runs WHERE report_id = ?").all(id);
|
||||
assert.equal(runs.length, 1);
|
||||
assert.equal(runs[0].trigger, "schedule");
|
||||
});
|
||||
|
||||
it("survives Clear Data", async () => {
|
||||
await call("POST", "/api/settings/clear-data");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_definitions").get().n, 1);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file Route tests for /api/snapshots: capture freezes a session (events
|
||||
* oldest first, stats, cost), redaction replaces paths before anything is
|
||||
* stored, rows are immutable, writes are same-origin only, and snapshots
|
||||
* survive Clear Data.
|
||||
* @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 fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-snapshots-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
const CWD = "/work/secret-project";
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function request(method, p, body, headers = {}) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, started_at, transcript_path) VALUES (?, ?, 'completed', ?, ?, ?)"
|
||||
).run("s1", "Snap me", CWD, "2026-10-01T10:00:00.000Z", `${CWD}/t.jsonl`);
|
||||
db.prepare(
|
||||
"INSERT INTO agents (id, session_id, name, type) VALUES ('a1', 's1', 'main', 'main')"
|
||||
).run();
|
||||
const ev = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES ('s1', 'a1', ?, ?, ?, ?, ?)"
|
||||
);
|
||||
ev.run("SessionStart", null, "start", JSON.stringify({ cwd: CWD }), "2026-10-01T10:00:00.000Z");
|
||||
ev.run(
|
||||
"PreToolUse",
|
||||
"Read",
|
||||
`Read ${CWD}/src/a.js`,
|
||||
JSON.stringify({
|
||||
cwd: CWD,
|
||||
tool_input: { file_path: `${CWD}/src/a.js` },
|
||||
command: `ls ${CWD}/src`,
|
||||
}),
|
||||
"2026-10-01T10:00:01.000Z"
|
||||
);
|
||||
ev.run("Stop", null, "stop", "{}", "2026-10-01T10:00:02.000Z");
|
||||
db.prepare(
|
||||
"INSERT INTO token_usage (session_id, model, input_tokens, output_tokens) VALUES ('s1', 'claude-sonnet-4-5', 1000000, 0)"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await new Promise((r) => server.close(r));
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("/api/snapshots", () => {
|
||||
let plain;
|
||||
let redacted;
|
||||
|
||||
it("freezes a session under an unguessable token", async () => {
|
||||
const r = await request("POST", "/api/snapshots", { session_id: "s1" });
|
||||
assert.equal(r.status, 201);
|
||||
plain = r.body.snapshot;
|
||||
assert.match(plain.token, /^[A-Za-z0-9_-]{32}$/);
|
||||
const got = await request("GET", `/api/snapshots/${plain.token}`);
|
||||
assert.equal(got.status, 200);
|
||||
const p = got.body.payload;
|
||||
assert.equal(p.session.id, "s1");
|
||||
assert.equal(p.session.cwd, CWD);
|
||||
assert.deepEqual(
|
||||
p.events.map((e) => e.event_type),
|
||||
["SessionStart", "PreToolUse", "Stop"]
|
||||
);
|
||||
assert.equal(p.agents.length, 1);
|
||||
assert.equal(p.stats.total_events, 3);
|
||||
assert.ok(p.cost.total_cost > 0);
|
||||
assert.equal(p.redacted.file_paths, false);
|
||||
});
|
||||
|
||||
it("redacts paths at capture", async () => {
|
||||
const r = await request("POST", "/api/snapshots", {
|
||||
session_id: "s1",
|
||||
redact: { file_paths: true },
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
redacted = r.body.snapshot;
|
||||
const stored = db
|
||||
.prepare("SELECT payload FROM session_snapshots WHERE id = ?")
|
||||
.get(redacted.id).payload;
|
||||
assert.ok(!stored.includes(CWD), "no raw cwd in the stored row");
|
||||
const p = JSON.parse(stored);
|
||||
assert.equal(p.session.cwd, "[redacted]");
|
||||
assert.equal(p.session.transcript_path, "[redacted]");
|
||||
const read = JSON.parse(p.events[1].data);
|
||||
assert.equal(read.tool_input.file_path, "[redacted]");
|
||||
assert.equal(read.command, "ls <cwd>/src");
|
||||
assert.equal(p.events[1].summary, "Read <cwd>/src/a.js");
|
||||
});
|
||||
|
||||
it("lists without payloads, newest first", async () => {
|
||||
const r = await request("GET", "/api/snapshots");
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.snapshots.map((s) => s.id),
|
||||
[redacted.id, plain.id]
|
||||
);
|
||||
const [first] = r.body.snapshots;
|
||||
assert.equal(first.session_name, "Snap me");
|
||||
assert.equal(first.redacted, true);
|
||||
assert.equal(first.event_count, 3);
|
||||
assert.equal(first.payload, undefined);
|
||||
});
|
||||
|
||||
it("refuses an update in the database", () => {
|
||||
assert.throws(
|
||||
() => db.prepare("UPDATE session_snapshots SET payload = '{}' WHERE id = ?").run(plain.id),
|
||||
/immutable/
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 400 / 404 on bad input", async () => {
|
||||
assert.equal((await request("POST", "/api/snapshots", {})).status, 400);
|
||||
assert.equal((await request("POST", "/api/snapshots", { session_id: "nope" })).status, 404);
|
||||
assert.equal((await request("GET", "/api/snapshots/short")).status, 404);
|
||||
assert.equal((await request("DELETE", "/api/snapshots/99999")).status, 404);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const h = { Origin: "https://evil.example" };
|
||||
assert.equal((await request("POST", "/api/snapshots", { session_id: "s1" }, h)).status, 403);
|
||||
assert.equal((await request("DELETE", `/api/snapshots/${plain.id}`, undefined, h)).status, 403);
|
||||
});
|
||||
|
||||
it("survives Clear Data, and deletes", async () => {
|
||||
assert.equal((await request("POST", "/api/settings/clear-data", {})).status, 200);
|
||||
const got = await request("GET", `/api/snapshots/${plain.token}`);
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(got.body.payload.session.id, "s1");
|
||||
assert.equal((await request("DELETE", `/api/snapshots/${plain.id}`)).status, 200);
|
||||
assert.equal((await request("GET", `/api/snapshots/${plain.token}`)).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @file Tests for the Claude Code statusline (`statusline/statusline.js`): the
|
||||
* line formatter, today's cost from a fake dashboard (cache hit, cache of a
|
||||
* failure, the 1-second timeout), the script end to end on stdin, and
|
||||
* `agenthub statusline install|uninstall` against a temp HOME — never the
|
||||
* real ~/.claude/settings.json.
|
||||
* @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 fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const { format, contextPercent, todayCost, localDate } = require(
|
||||
path.join(ROOT, "statusline", "statusline.js")
|
||||
);
|
||||
const { isOurs, statuslineCommand } = require(path.join(ROOT, "statusline", "install.js"));
|
||||
|
||||
const plain = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-statusline-test-"));
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
/** Spawn a node script asynchronously (a fake server may live in this process). */
|
||||
function run(args, { env = {}, input = "" } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, args, { env: { ...process.env, ...env } });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
child.on("close", (code) => resolve({ code, out, err }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
describe("format", () => {
|
||||
it("renders model · branch · context bar · session $ · today $", () => {
|
||||
const line = format(
|
||||
{
|
||||
model: { id: "claude-opus-5-5", display_name: "Opus 5.5" },
|
||||
context_window: { used_percentage: 34 },
|
||||
cost: { total_cost_usd: 1.234 },
|
||||
},
|
||||
{ branch: "main", today: 45.678 }
|
||||
);
|
||||
assert.equal(plain(line), "Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68");
|
||||
});
|
||||
|
||||
it("omits whatever is missing", () => {
|
||||
assert.equal(plain(format({ model: { display_name: "Sonnet" } }, { today: null })), "Sonnet");
|
||||
assert.equal(format({}), "");
|
||||
assert.equal(format(null), "");
|
||||
assert.equal(plain(format({ cost: { total_cost_usd: 0 } })), "$0.00");
|
||||
});
|
||||
|
||||
it("computes context % from usage when used_percentage is absent, clamped", () => {
|
||||
const ctx = {
|
||||
context_window_size: 200_000,
|
||||
current_usage: { input_tokens: 10_000, cache_read_input_tokens: 40_000, output_tokens: 999 },
|
||||
};
|
||||
assert.equal(contextPercent(ctx), 25);
|
||||
assert.equal(contextPercent({}), null);
|
||||
assert.match(plain(format({ context_window: { used_percentage: 140 } })), /██████████ 100%/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("todayCost", () => {
|
||||
let api;
|
||||
let url;
|
||||
let hits = 0;
|
||||
let slow = false;
|
||||
let lastUrl = "";
|
||||
before(async () => {
|
||||
api = http.createServer((req, res) => {
|
||||
hits++;
|
||||
lastUrl = req.url;
|
||||
if (slow) return; // never answers — the timeout must fire
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
daily_costs: [
|
||||
{ date: localDate(), cost: 3.5 },
|
||||
{ date: "2000-01-01", cost: 9 },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
await new Promise((r) => api.listen(0, "127.0.0.1", r));
|
||||
url = `http://127.0.0.1:${api.address().port}`;
|
||||
});
|
||||
after(() => {
|
||||
api.closeAllConnections();
|
||||
api.close();
|
||||
});
|
||||
|
||||
it("reads today's row and caches it", async () => {
|
||||
const file = path.join(TMP, "cache-a.json");
|
||||
assert.equal(await todayCost({ url, file }), 3.5);
|
||||
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}`);
|
||||
assert.equal(await todayCost({ url, file }), 3.5);
|
||||
assert.equal(hits, 1);
|
||||
// A different dashboard URL is a cache miss.
|
||||
await todayCost({ url: url.replace("127.0.0.1", "localhost"), file });
|
||||
assert.equal(hits, 2);
|
||||
});
|
||||
|
||||
it("gives up after 1 s and caches the miss", async () => {
|
||||
slow = true;
|
||||
hits = 0;
|
||||
const file = path.join(TMP, "cache-b.json");
|
||||
const t0 = Date.now();
|
||||
assert.equal(await todayCost({ url, file }), null);
|
||||
assert.ok(Date.now() - t0 < 2_000);
|
||||
assert.equal(await todayCost({ url, file }), null);
|
||||
assert.equal(hits, 1);
|
||||
slow = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusline.js on stdin", () => {
|
||||
it("prints one line, and nothing (exit 0) on garbage", async () => {
|
||||
const env = { AGENTHUB_URL: "http://127.0.0.1:9", TMPDIR: TMP };
|
||||
const ok = await run([path.join(ROOT, "statusline", "statusline.js")], {
|
||||
env,
|
||||
input: JSON.stringify({
|
||||
model: { display_name: "Opus" },
|
||||
cwd: ROOT,
|
||||
cost: { total_cost_usd: 2 },
|
||||
}),
|
||||
});
|
||||
assert.equal(ok.code, 0);
|
||||
assert.match(plain(ok.out), /^Opus · \S+ · \$2\.00\n$/);
|
||||
const bad = await run([path.join(ROOT, "statusline", "statusline.js")], {
|
||||
env,
|
||||
input: "{nope",
|
||||
});
|
||||
assert.equal(bad.code, 0);
|
||||
assert.equal(bad.out, "\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub statusline install|uninstall", () => {
|
||||
const HOME = path.join(TMP, "home");
|
||||
const settings = path.join(HOME, ".claude", "settings.json");
|
||||
const env = { HOME, CLAUDE_HOME: "", NO_COLOR: "1" };
|
||||
const cli = (...a) => run([path.join(ROOT, "bin", "agenthub.js"), "statusline", ...a], { env });
|
||||
const read = () => JSON.parse(fs.readFileSync(settings, "utf8"));
|
||||
const backups = () =>
|
||||
fs.readdirSync(path.dirname(settings)).filter((f) => f.includes("agenthub-backup"));
|
||||
|
||||
it("installs into a fresh HOME, then is idempotent", async () => {
|
||||
const r = await cli("install");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(read().statusLine.command, statuslineCommand());
|
||||
assert.ok(isOurs(read().statusLine));
|
||||
assert.equal(backups().length, 0); // nothing to back up
|
||||
const again = await cli("install");
|
||||
assert.match(again.out, /already set/);
|
||||
assert.equal(backups().length, 0);
|
||||
});
|
||||
|
||||
it("refuses a foreign statusLine without --force, keeps other keys, backs up", async () => {
|
||||
fs.writeFileSync(
|
||||
settings,
|
||||
JSON.stringify({ model: "opus", statusLine: { type: "command", command: "~/my-line.sh" } })
|
||||
);
|
||||
const refused = await cli("install");
|
||||
assert.equal(refused.code, 1);
|
||||
assert.match(refused.err, /not AgentHub's/);
|
||||
assert.equal(read().statusLine.command, "~/my-line.sh");
|
||||
assert.equal((await cli("uninstall")).code, 1);
|
||||
assert.equal(read().statusLine.command, "~/my-line.sh");
|
||||
|
||||
const forced = await cli("install", "--force");
|
||||
assert.equal(forced.code, 0, forced.err);
|
||||
assert.ok(isOurs(read().statusLine));
|
||||
assert.equal(read().model, "opus");
|
||||
const [bak] = backups();
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(path.dirname(settings), bak))).statusLine.command,
|
||||
"~/my-line.sh"
|
||||
);
|
||||
});
|
||||
|
||||
it("uninstalls its own entry only", async () => {
|
||||
const r = await cli("uninstall");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(read().statusLine, undefined);
|
||||
assert.equal(read().model, "opus");
|
||||
assert.match((await cli("uninstall")).out, /No statusLine/);
|
||||
});
|
||||
|
||||
it("uses the stable launcher for a plugin-cache install", () => {
|
||||
const home = path.join(TMP, "launcher-home");
|
||||
fs.mkdirSync(path.join(home, ".local", "bin"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, ".local", "bin", "agenthub"), "#!/bin/sh\n");
|
||||
const root = path.join(home, ".claude", "plugins", "cache", "agenthub", "agenthub", "1.0.0");
|
||||
const cmd = statuslineCommand(root, home);
|
||||
assert.equal(cmd, `"${path.join(home, ".local", "bin", "agenthub")}" statusline`);
|
||||
assert.ok(isOurs({ command: cmd }));
|
||||
assert.equal(isOurs({ command: "bash ~/statusline.sh" }), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user