4aae61297f
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.
209 lines
7.7 KiB
JavaScript
209 lines
7.7 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|