limits.js reads the Claude Code OAuth credentials read-only and calls the pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no retry, never a refresh); detail codes come from a fixed enum only. state.js freezes every Toronto day before yesterday, empty ones included, prunes past 35 days, keeps the last good limits, and writes atomically in mode 600. push-claude-usage.js writes the cache before pushing, refuses a non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and prints no token. run-claude-usage.sh takes a non-blocking flock so a beat that overlaps the previous one exits 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1684 lines
69 KiB
JavaScript
1684 lines
69 KiB
JavaScript
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const {
|
|
aggregate,
|
|
mapProject,
|
|
torontoDay,
|
|
shiftDay,
|
|
windowDays,
|
|
capModels,
|
|
WINDOW_DAYS,
|
|
MAX_MODELS_PER_DAY,
|
|
OTHER_MODEL,
|
|
OTHER_PROJECTS,
|
|
} = require("../agent/claude-usage/aggregate.js");
|
|
|
|
// Coverage for the pure aggregation of the claude-usage agent (issue #49).
|
|
//
|
|
// Every transcript here is synthetic (__tests__/fixtures/claude-usage/ or
|
|
// written inline): no real ~/.claude content ever enters the repo. Fixtures
|
|
// are copied into a temp dir and their mtimes set with utimesSync, so the
|
|
// scan threshold is exercised on explicit instants, never on the wall clock.
|
|
|
|
const FIXTURES = path.join(__dirname, "fixtures", "claude-usage", "projects");
|
|
const HOME = "/home/tester";
|
|
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-usage-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function setMtime(file, iso) {
|
|
const t = new Date(iso);
|
|
fs.utimesSync(file, t, t);
|
|
}
|
|
|
|
// Write a transcript under tmpDir. `lines` are objects (serialized) or raw
|
|
// strings (written verbatim, for broken lines).
|
|
function writeTranscript(rel, lines, mtimeIso) {
|
|
const file = path.join(tmpDir, rel);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const body = lines.map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n");
|
|
fs.writeFileSync(file, `${body}\n`);
|
|
if (mtimeIso) setMtime(file, mtimeIso);
|
|
return file;
|
|
}
|
|
|
|
let seq = 0;
|
|
function line({
|
|
ts,
|
|
model = "claude-opus-5-5",
|
|
cwd = `${HOME}/claude-code/alpha`,
|
|
id,
|
|
req,
|
|
input = 1,
|
|
output = 1,
|
|
cacheCreation = 0,
|
|
cacheRead = 0,
|
|
version = "2.1.283",
|
|
type = "assistant",
|
|
}) {
|
|
seq += 1;
|
|
return {
|
|
type,
|
|
message: {
|
|
id: id || `msg_${seq}`,
|
|
model,
|
|
usage: {
|
|
input_tokens: input,
|
|
output_tokens: output,
|
|
cache_creation_input_tokens: cacheCreation,
|
|
cache_read_input_tokens: cacheRead,
|
|
},
|
|
},
|
|
requestId: req || `req_${seq}`,
|
|
cwd,
|
|
timestamp: ts,
|
|
version,
|
|
};
|
|
}
|
|
|
|
function dayOf(result, date) {
|
|
return result.days.find((d) => d.date === date);
|
|
}
|
|
|
|
function sumWork(projects) {
|
|
return projects.reduce((s, p) => s + p.work, 0);
|
|
}
|
|
|
|
// --- Dates ------------------------------------------------------------------
|
|
|
|
describe("Toronto dates", () => {
|
|
it("23:59 and 00:01 Toronto time fall on two different days (EDT)", () => {
|
|
expect(torontoDay(Date.parse("2026-10-03T03:59:00Z"))).toBe("2026-10-02");
|
|
expect(torontoDay(Date.parse("2026-10-03T04:01:00Z"))).toBe("2026-10-03");
|
|
});
|
|
|
|
it("splits days correctly on both sides of the 2026-11-01 DST change", () => {
|
|
// Midnight Oct 31 -> Nov 1 is still EDT (UTC-4).
|
|
expect(torontoDay(Date.parse("2026-11-01T03:59:00Z"))).toBe("2026-10-31");
|
|
expect(torontoDay(Date.parse("2026-11-01T04:01:00Z"))).toBe("2026-11-01");
|
|
// Midnight Nov 1 -> Nov 2 is EST (UTC-5): a fixed -4 offset would be wrong.
|
|
expect(torontoDay(Date.parse("2026-11-02T04:30:00Z"))).toBe("2026-11-01");
|
|
expect(torontoDay(Date.parse("2026-11-02T04:59:00Z"))).toBe("2026-11-01");
|
|
expect(torontoDay(Date.parse("2026-11-02T05:01:00Z"))).toBe("2026-11-02");
|
|
});
|
|
|
|
it("shifts dates as calendar strings across month, year and DST boundaries", () => {
|
|
expect(shiftDay("2026-11-02", -1)).toBe("2026-11-01");
|
|
expect(shiftDay("2026-11-01", 1)).toBe("2026-11-02");
|
|
expect(shiftDay("2026-03-01", -1)).toBe("2026-02-28");
|
|
expect(shiftDay("2027-01-01", -1)).toBe("2026-12-31");
|
|
expect(shiftDay("2027-03-14", 1)).toBe("2027-03-15");
|
|
});
|
|
|
|
it.each(["2026-11-15", "2027-03-20", "2026-10-04", "2027-01-05"])(
|
|
"windowDays(%s) is 30 consecutive unique dates ending today",
|
|
(today) => {
|
|
const days = windowDays(today);
|
|
expect(days).toHaveLength(WINDOW_DAYS);
|
|
expect(new Set(days).size).toBe(WINDOW_DAYS);
|
|
expect(days[days.length - 1]).toBe(today);
|
|
for (let i = 1; i < days.length; i++) expect(shiftDay(days[i - 1], 1)).toBe(days[i]);
|
|
},
|
|
);
|
|
|
|
it("aggregate puts 23:59 and 00:01 lines on two days around 2026-11-01", async () => {
|
|
writeTranscript(
|
|
"p/s.jsonl",
|
|
[
|
|
line({ ts: "2026-11-01T03:59:00Z", output: 1 }), // Oct 31 23:59 EDT
|
|
line({ ts: "2026-11-01T04:01:00Z", output: 10 }), // Nov 1 00:01 EDT
|
|
line({ ts: "2026-11-02T04:59:00Z", output: 100 }), // Nov 1 23:59 EST
|
|
line({ ts: "2026-11-02T05:01:00Z", output: 1000 }), // Nov 2 00:01 EST
|
|
],
|
|
"2026-11-02T12:00:00Z",
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-11-02", homedir: HOME });
|
|
expect(dayOf(r, "2026-10-31").models["claude-opus-5-5"].output).toBe(1);
|
|
expect(dayOf(r, "2026-11-01").models["claude-opus-5-5"].output).toBe(110);
|
|
expect(dayOf(r, "2026-11-02").models["claude-opus-5-5"].output).toBe(1000);
|
|
expect(dayOf(r, "2026-11-01").models["claude-opus-5-5"].requests).toBe(2);
|
|
});
|
|
|
|
it("rejects a malformed today", async () => {
|
|
await expect(aggregate({ root: tmpDir, today: "2026-13-01" })).rejects.toThrow(/today/);
|
|
await expect(aggregate({ root: tmpDir, today: "04/10/2026" })).rejects.toThrow(/today/);
|
|
});
|
|
|
|
it("defaults today to the current Toronto day", async () => {
|
|
const r = await aggregate({ root: tmpDir, homedir: HOME });
|
|
expect(r.days[r.days.length - 1].date).toBe(torontoDay(Date.now()));
|
|
});
|
|
});
|
|
|
|
// --- Project mapping ----------------------------------------------------------
|
|
|
|
describe("mapProject", () => {
|
|
it("rule 1: <home>/claude-code/<a>/... -> <a>", () => {
|
|
expect(mapProject(`${HOME}/claude-code/simpl-resultat`, HOME)).toBe("simpl-resultat");
|
|
expect(mapProject(`${HOME}/claude-code/simpl-resultat/src/lib`, HOME)).toBe(
|
|
"simpl-resultat",
|
|
);
|
|
expect(
|
|
mapProject(`${HOME}/claude-code/vps-health-api/.claude/worktrees/agent-x`, HOME),
|
|
).toBe("vps-health-api");
|
|
// A doubled slash never yields an empty project name.
|
|
expect(mapProject(`${HOME}/claude-code//simpl-liste/app`, HOME)).toBe("simpl-liste");
|
|
expect(mapProject(`${HOME}//claude-code`, HOME)).toBe("(monorepo)");
|
|
});
|
|
|
|
it("rule 1: .spikes and .archive keep their second segment", () => {
|
|
expect(mapProject(`${HOME}/claude-code/.spikes/idea/sub`, HOME)).toBe(".spikes/idea");
|
|
expect(mapProject(`${HOME}/claude-code/.archive/medic`, HOME)).toBe(".archive/medic");
|
|
});
|
|
|
|
it("rule 2: <home>/claude-code exactly -> (monorepo)", () => {
|
|
expect(mapProject(`${HOME}/claude-code`, HOME)).toBe("(monorepo)");
|
|
expect(mapProject(`${HOME}/claude-code/`, HOME)).toBe("(monorepo)");
|
|
});
|
|
|
|
it("rule 3: /tmp/spike-defenseur-auto-* -> defenseurs", () => {
|
|
expect(mapProject("/tmp/spike-defenseur-auto-20260927", HOME)).toBe("defenseurs");
|
|
expect(mapProject("/tmp/spike-defenseur-auto-abc/repo", HOME)).toBe("defenseurs");
|
|
});
|
|
|
|
it("rule 4: /tmp/claude-<uid>/<home slug>-claude-code-<p>/... -> <p>", () => {
|
|
expect(
|
|
mapProject("/tmp/claude-1000/-home-tester-claude-code-vps-health-api/sess/scratchpad", HOME),
|
|
).toBe("vps-health-api");
|
|
expect(mapProject("/tmp/claude-1000/-home-tester-claude-code-roadtrip", HOME)).toBe(
|
|
"roadtrip",
|
|
);
|
|
});
|
|
|
|
it("rule 5: anything else -> (autre)", () => {
|
|
expect(mapProject("/home/tester/other", HOME)).toBe("(autre)");
|
|
expect(mapProject("/home/tester/claude-codex/x", HOME)).toBe("(autre)");
|
|
expect(mapProject("/tmp/claude-1000/-home-other-claude-code-x", HOME)).toBe("(autre)");
|
|
expect(mapProject("/tmp/spike-other", HOME)).toBe("(autre)");
|
|
expect(mapProject(undefined, HOME)).toBe("(autre)");
|
|
expect(mapProject("", HOME)).toBe("(autre)");
|
|
});
|
|
|
|
it("uses the injected homedir, not the real one", () => {
|
|
expect(mapProject("/home/max/claude-code/alpha", "/home/max")).toBe("alpha");
|
|
expect(mapProject("/home/max/claude-code/alpha", HOME)).toBe("(autre)");
|
|
});
|
|
});
|
|
|
|
// --- Fixture scan ---------------------------------------------------------------
|
|
|
|
describe("aggregate on the fixture tree", () => {
|
|
async function runFixture(extra = {}) {
|
|
fs.cpSync(FIXTURES, tmpDir, { recursive: true });
|
|
const session = path.join(tmpDir, "-home-tester-claude-code-alpha", "sess-1.jsonl");
|
|
const sub = path.join(
|
|
tmpDir,
|
|
"-home-tester-claude-code-alpha",
|
|
"sess-1",
|
|
"subagents",
|
|
"agent-a.jsonl",
|
|
);
|
|
setMtime(session, "2026-10-04T12:00:00Z");
|
|
setMtime(sub, "2026-10-04T12:00:00Z");
|
|
return aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, ...extra });
|
|
}
|
|
|
|
it("counts a request duplicated across session and subagents/ once, at its largest output", async () => {
|
|
const r = await runFixture();
|
|
expect(dayOf(r, "2026-10-03").models).toEqual({
|
|
"claude-opus-5-5": {
|
|
input: 10,
|
|
output: 2048,
|
|
cacheCreation: 100,
|
|
cacheRead: 1000,
|
|
requests: 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("ignores <synthetic>, user lines and truncated JSON without throwing", async () => {
|
|
const r = await runFixture();
|
|
const all = r.days.flatMap((d) => Object.keys(d.models));
|
|
expect(all).not.toContain("<synthetic>");
|
|
const outputs = r.days.flatMap((d) => Object.values(d.models).map((m) => m.output));
|
|
expect(outputs).not.toContain(7777); // synthetic
|
|
expect(outputs).not.toContain(999); // user line carrying a usage object
|
|
expect(dayOf(r, "2026-10-04").models).toEqual({
|
|
"claude-sonnet-5": { input: 5, output: 50, cacheCreation: 0, cacheRead: 200, requests: 1 },
|
|
});
|
|
expect(dayOf(r, "2026-10-02").models["claude-opus-5-5"].requests).toBe(1);
|
|
});
|
|
|
|
it("aggregates projects with work = input + output + cacheCreation", async () => {
|
|
const r = await runFixture();
|
|
expect(r.projects).toEqual([
|
|
{ name: "alpha", work: 10 + 2048 + 100 + 5 + 50, cacheRead: 1200, requests: 2 },
|
|
{ name: ".spikes/idea", work: 33, cacheRead: 0, requests: 1 },
|
|
]);
|
|
});
|
|
|
|
it("reports claudeCodeVersion from the most recent timestamp", async () => {
|
|
const r = await runFixture();
|
|
expect(r.claudeCodeVersion).toBe("2.1.283");
|
|
});
|
|
|
|
it("returns every window day as open when nothing is frozen", async () => {
|
|
const r = await runFixture();
|
|
expect(Object.keys(r.openDays)).toEqual(windowDays("2026-10-04"));
|
|
expect(r.openDays["2026-10-03"].projects).toEqual({
|
|
alpha: { work: 2158, cacheRead: 1000, requests: 1 },
|
|
});
|
|
expect(r.openDays["2026-09-20"]).toEqual({ models: {}, projects: {} });
|
|
});
|
|
});
|
|
|
|
// --- Series -------------------------------------------------------------------
|
|
|
|
describe("30-day series", () => {
|
|
it("always has 30 entries, empty days included, newest = today", async () => {
|
|
const r = await aggregate({ root: path.join(tmpDir, "missing"), today: "2026-10-04", homedir: HOME });
|
|
expect(r.days).toHaveLength(30);
|
|
expect(r.days[29].date).toBe("2026-10-04");
|
|
expect(r.days[0].date).toBe("2026-09-05");
|
|
for (const d of r.days) expect(d.models).toEqual({});
|
|
expect(r.projects).toEqual([]);
|
|
expect(r.claudeCodeVersion).toBeNull();
|
|
});
|
|
|
|
it("drops lines outside the window (older than 30 days, or after today)", async () => {
|
|
writeTranscript(
|
|
"p/s.jsonl",
|
|
[
|
|
line({ ts: "2026-09-05T04:00:00Z", output: 1 }), // Sep 5 00:00 Toronto, first day
|
|
line({ ts: "2026-09-05T03:59:00Z", output: 2 }), // Sep 4, outside
|
|
line({ ts: "2026-10-05T04:01:00Z", output: 4 }), // Oct 5, after today
|
|
line({ ts: "not a date", output: 8 }),
|
|
],
|
|
"2026-10-04T12:00:00Z",
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
const total = r.days.reduce(
|
|
(s, d) => s + Object.values(d.models).reduce((t, m) => t + m.output, 0),
|
|
0,
|
|
);
|
|
expect(total).toBe(1);
|
|
expect(dayOf(r, "2026-09-05").models["claude-opus-5-5"].output).toBe(1);
|
|
});
|
|
|
|
it("keeps 30 entries when the window straddles the 2026-11-01 DST change", async () => {
|
|
const r = await aggregate({ root: tmpDir, today: "2026-11-15", homedir: HOME });
|
|
expect(r.days.map((d) => d.date)).toEqual(windowDays("2026-11-15"));
|
|
expect(r.days.filter((d) => d.date === "2026-11-01")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// --- Top projects ---------------------------------------------------------------
|
|
|
|
describe("top 8 projects + (autres)", () => {
|
|
it("keeps the top 8 by work and aggregates the rest; sums match the window total", async () => {
|
|
const lines = [];
|
|
for (let i = 1; i <= 11; i++) {
|
|
lines.push(
|
|
line({
|
|
ts: "2026-10-03T15:00:00Z",
|
|
cwd: `${HOME}/claude-code/p${String(i).padStart(2, "0")}`,
|
|
input: i * 100,
|
|
output: i,
|
|
cacheCreation: 7,
|
|
cacheRead: 3,
|
|
}),
|
|
);
|
|
}
|
|
writeTranscript("p/s.jsonl", lines, "2026-10-04T12:00:00Z");
|
|
const frozenDays = {
|
|
"2026-10-01": {
|
|
models: { "claude-opus-5-5": { input: 50, output: 0, cacheCreation: 0, cacheRead: 9, requests: 1 } },
|
|
projects: { p01: { work: 50, cacheRead: 9, requests: 1 } },
|
|
},
|
|
};
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays });
|
|
|
|
const expectedTotal = 50 + [...Array(11)].reduce((s, _, k) => s + (k + 1) * 101 + 7, 0);
|
|
expect(r.projects).toHaveLength(9);
|
|
expect(r.projects[8].name).toBe(OTHER_PROJECTS);
|
|
expect(r.projects.slice(0, 8).map((p) => p.name)).toEqual([
|
|
"p11", "p10", "p09", "p08", "p07", "p06", "p05", "p04",
|
|
]);
|
|
// p01 = 101 + 7 + 50 frozen, p02 = 209, p03 = 310 -> (autres)
|
|
expect(r.projects[8]).toEqual({
|
|
name: OTHER_PROJECTS,
|
|
work: 158 + 209 + 310,
|
|
cacheRead: 3 * 3 + 9,
|
|
requests: 4,
|
|
});
|
|
expect(sumWork(r.projects)).toBe(expectedTotal);
|
|
const seriesWork = r.days.reduce(
|
|
(s, d) =>
|
|
s + Object.values(d.models).reduce((t, m) => t + m.input + m.output + m.cacheCreation, 0),
|
|
0,
|
|
);
|
|
expect(sumWork(r.projects)).toBe(seriesWork);
|
|
});
|
|
|
|
it("omits (autres) when 8 projects or fewer have volume", async () => {
|
|
const lines = [];
|
|
for (let i = 1; i <= 8; i++) {
|
|
lines.push(line({ ts: "2026-10-03T15:00:00Z", cwd: `${HOME}/claude-code/q${i}`, input: i }));
|
|
}
|
|
writeTranscript("p/s.jsonl", lines, "2026-10-04T12:00:00Z");
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
expect(r.projects).toHaveLength(8);
|
|
expect(r.projects.map((p) => p.name)).not.toContain(OTHER_PROJECTS);
|
|
});
|
|
});
|
|
|
|
// --- Model cap ------------------------------------------------------------------
|
|
|
|
describe("at most 12 models per day", () => {
|
|
it("merges the least consuming models into `other`, totals preserved", async () => {
|
|
const lines = [];
|
|
for (let i = 1; i <= 14; i++) {
|
|
lines.push(
|
|
line({
|
|
ts: "2026-10-03T15:00:00Z",
|
|
model: `model-${String(i).padStart(2, "0")}`,
|
|
input: i * 10,
|
|
cacheRead: 1,
|
|
}),
|
|
);
|
|
}
|
|
writeTranscript("p/s.jsonl", lines, "2026-10-04T12:00:00Z");
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
const models = dayOf(r, "2026-10-03").models;
|
|
expect(Object.keys(models)).toHaveLength(MAX_MODELS_PER_DAY);
|
|
expect(models[OTHER_MODEL]).toEqual({
|
|
input: 10 + 20 + 30,
|
|
output: 3,
|
|
cacheCreation: 0,
|
|
cacheRead: 3,
|
|
requests: 3,
|
|
});
|
|
expect(models["model-03"]).toBeUndefined();
|
|
expect(models["model-04"]).toBeDefined();
|
|
// The open day keeps the raw truth: the cap is applied to the series only.
|
|
expect(Object.keys(r.openDays["2026-10-03"].models)).toHaveLength(14);
|
|
});
|
|
|
|
it("is idempotent and leaves a small day untouched", () => {
|
|
const small = { a: { input: 1, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 } };
|
|
expect(capModels(small)).toBe(small);
|
|
const big = {};
|
|
for (let i = 0; i < 20; i++) {
|
|
big[`m${i}`] = { input: i, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 };
|
|
}
|
|
const once = capModels(big);
|
|
expect(Object.keys(once)).toHaveLength(12);
|
|
expect(capModels(once)).toBe(once);
|
|
expect(Object.values(once).reduce((s, m) => s + m.requests, 0)).toBe(20);
|
|
});
|
|
});
|
|
|
|
// --- Frozen days and the scan threshold ----------------------------------------
|
|
|
|
describe("frozen days", () => {
|
|
// What the agent hands over: a contiguous frozen run from the window start
|
|
// through 2026-10-01, empty days included.
|
|
const FROZEN = {};
|
|
for (const day of windowDays("2026-10-04")) {
|
|
if (day > "2026-10-01") break;
|
|
FROZEN[day] = { models: {}, projects: {} };
|
|
}
|
|
FROZEN["2026-10-01"] = {
|
|
models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } },
|
|
projects: { alpha: { work: 21, cacheRead: 7, requests: 7 } },
|
|
};
|
|
|
|
it("opens no file whose mtime day is before the threshold (spy on file opening)", async () => {
|
|
// Threshold = oldest open day = day after the frozen run = 2026-10-02.
|
|
const old = writeTranscript(
|
|
"a/old.jsonl",
|
|
[line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened
|
|
"2026-09-20T12:00:00Z",
|
|
);
|
|
const justBefore = writeTranscript(
|
|
"a/sub/just-before.jsonl",
|
|
[line({ ts: "2026-10-02T15:00:00Z", output: 5 })],
|
|
"2026-10-02T03:30:00Z", // Oct 1 23:30 Toronto
|
|
);
|
|
const atThreshold = writeTranscript(
|
|
"a/at-threshold.jsonl",
|
|
[line({ ts: "2026-10-02T03:00:00Z", output: 1 })], // Oct 1 23:00 -> frozen, ignored
|
|
"2026-10-02T04:30:00Z", // Oct 2 00:30 Toronto
|
|
);
|
|
const recent = writeTranscript(
|
|
"b/subagents/recent.jsonl",
|
|
[line({ ts: "2026-10-03T15:00:00Z", output: 100 })],
|
|
"2026-10-04T12:00:00Z",
|
|
);
|
|
|
|
const opened = [];
|
|
const openFile = (file, options) => {
|
|
opened.push(file);
|
|
return fs.createReadStream(file, options);
|
|
};
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
frozenDays: FROZEN,
|
|
openFile,
|
|
});
|
|
|
|
expect(opened.sort()).toEqual([atThreshold, recent].sort());
|
|
expect(opened).not.toContain(old);
|
|
expect(opened).not.toContain(justBefore);
|
|
expect(dayOf(r, "2026-10-02").models).toEqual({});
|
|
expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"].output).toBe(100);
|
|
});
|
|
|
|
it("the default opener goes through fs.createReadStream, and skips old files too", async () => {
|
|
const old = writeTranscript(
|
|
"a/old.jsonl",
|
|
[line({ ts: "2026-10-02T15:00:00Z" })],
|
|
"2026-09-20T12:00:00Z",
|
|
);
|
|
const recent = writeTranscript(
|
|
"a/recent.jsonl",
|
|
[line({ ts: "2026-10-03T15:00:00Z" })],
|
|
"2026-10-04T12:00:00Z",
|
|
);
|
|
const spy = vi.spyOn(fs, "createReadStream");
|
|
await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays: FROZEN });
|
|
const paths = spy.mock.calls.map((c) => c[0]);
|
|
expect(paths).toContain(recent);
|
|
expect(paths).not.toContain(old);
|
|
});
|
|
|
|
it("without frozen days, still skips files last written before the window", async () => {
|
|
const ancient = writeTranscript(
|
|
"a/ancient.jsonl",
|
|
[line({ ts: "2026-08-01T15:00:00Z" })],
|
|
"2026-08-01T16:00:00Z",
|
|
);
|
|
const inWindow = writeTranscript(
|
|
"a/in-window.jsonl",
|
|
[line({ ts: "2026-09-05T15:00:00Z" })],
|
|
"2026-09-05T16:00:00Z",
|
|
);
|
|
const opened = [];
|
|
await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
openFile: (f, o) => {
|
|
opened.push(f);
|
|
return fs.createReadStream(f, o);
|
|
},
|
|
});
|
|
expect(opened).toEqual([inWindow]);
|
|
expect(opened).not.toContain(ancient);
|
|
});
|
|
|
|
it("serves frozen days verbatim, never recomputes them, and leaves them out of openDays", async () => {
|
|
writeTranscript(
|
|
"a/recent.jsonl",
|
|
[
|
|
line({ ts: "2026-10-01T15:00:00Z", output: 999 }), // frozen day: ignored
|
|
line({ ts: "2026-10-03T15:00:00Z", output: 3 }),
|
|
],
|
|
"2026-10-04T12:00:00Z",
|
|
);
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
frozenDays: FROZEN,
|
|
});
|
|
expect(dayOf(r, "2026-10-01").models).toEqual(FROZEN["2026-10-01"].models);
|
|
expect(dayOf(r, "2026-09-30").models).toEqual({});
|
|
expect(r.openDays["2026-10-01"]).toBeUndefined();
|
|
expect(r.openDays["2026-09-30"]).toBeUndefined();
|
|
expect(Object.keys(r.openDays)).toEqual(["2026-10-02", "2026-10-03", "2026-10-04"]);
|
|
expect(r.projects).toEqual([{ name: "alpha", work: 21 + 1 + 3, cacheRead: 7, requests: 8 }]);
|
|
});
|
|
|
|
it("a frozen day in the future never hides an open day's files", async () => {
|
|
const file = writeTranscript(
|
|
"a/open.jsonl",
|
|
[line({ ts: "2026-10-03T15:00:00Z", output: 3 })],
|
|
"2026-10-03T16:00:00Z",
|
|
);
|
|
const opened = [];
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
frozenDays: { "2026-11-15": { models: {}, projects: {} } },
|
|
openFile: (f, o) => {
|
|
opened.push(f);
|
|
return fs.createReadStream(f, o);
|
|
},
|
|
});
|
|
expect(opened).toEqual([file]);
|
|
expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3);
|
|
});
|
|
|
|
it("a gap in the frozen run keeps the open day before the gap scanned", async () => {
|
|
// 10-03 open, 10-04 (today) frozen: the oldest open day sets the threshold.
|
|
const file = writeTranscript(
|
|
"a/open.jsonl",
|
|
[line({ ts: "2026-10-03T15:00:00Z", output: 3 })],
|
|
"2026-10-03T16:00:00Z",
|
|
);
|
|
const frozenDays = {};
|
|
for (const day of windowDays("2026-10-04")) {
|
|
if (day !== "2026-10-03") frozenDays[day] = { models: {}, projects: {} };
|
|
}
|
|
const opened = [];
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
frozenDays,
|
|
openFile: (f, o) => {
|
|
opened.push(f);
|
|
return fs.createReadStream(f, o);
|
|
},
|
|
});
|
|
expect(opened).toEqual([file]);
|
|
expect(Object.keys(r.openDays)).toEqual(["2026-10-03"]);
|
|
expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3);
|
|
});
|
|
|
|
it("every day frozen: no file is opened", async () => {
|
|
writeTranscript("a/x.jsonl", [line({ ts: "2026-10-04T15:00:00Z" })], "2026-10-04T16:00:00Z");
|
|
const frozenDays = {};
|
|
for (const day of windowDays("2026-10-04")) frozenDays[day] = { models: {}, projects: {} };
|
|
const opened = [];
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
frozenDays,
|
|
openFile: (f, o) => {
|
|
opened.push(f);
|
|
return fs.createReadStream(f, o);
|
|
},
|
|
});
|
|
expect(opened).toEqual([]);
|
|
expect(Object.keys(r.openDays)).toEqual([]);
|
|
expect(r.days).toHaveLength(WINDOW_DAYS);
|
|
});
|
|
|
|
it("re-reads frozen counters defensively (bad numbers -> 0, junk ignored)", async () => {
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
frozenDays: {
|
|
"2026-10-01": {
|
|
models: {
|
|
"claude-opus-5-5": { input: -1, output: 1.5, cacheCreation: "9", cacheRead: 4, requests: 1 },
|
|
"Bad Key!": { input: 2, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 },
|
|
junk: null,
|
|
},
|
|
projects: { alpha: { work: Number.MAX_SAFE_INTEGER + 2, cacheRead: 1, requests: 1 } },
|
|
},
|
|
"not-a-day": { models: { x: { input: 1 } } },
|
|
},
|
|
});
|
|
expect(dayOf(r, "2026-10-01").models).toEqual({
|
|
"claude-opus-5-5": { input: 0, output: 0, cacheCreation: 0, cacheRead: 4, requests: 1 },
|
|
other: { input: 2, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 },
|
|
});
|
|
expect(r.projects).toEqual([{ name: "alpha", work: 0, cacheRead: 1, requests: 1 }]);
|
|
expect(Object.keys(r.openDays)).toHaveLength(29);
|
|
});
|
|
});
|
|
|
|
// --- Robustness -----------------------------------------------------------------
|
|
|
|
describe("robustness", () => {
|
|
it("counts a line missing its identifiers as its own request", async () => {
|
|
const a = line({ ts: "2026-10-03T15:00:00Z", output: 2 });
|
|
delete a.requestId;
|
|
const b = JSON.parse(JSON.stringify(a));
|
|
writeTranscript("p/s.jsonl", [a, b], "2026-10-04T12:00:00Z");
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"].requests).toBe(2);
|
|
});
|
|
|
|
it("ignores non-.jsonl files and survives an unreadable transcript", async () => {
|
|
writeTranscript("p/notes.txt", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
writeTranscript(
|
|
"p/good.jsonl",
|
|
[line({ ts: "2026-10-03T15:00:00Z", output: 4 })],
|
|
"2026-10-04T12:00:00Z",
|
|
);
|
|
const bad = writeTranscript("p/bad.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
// The bad transcript's stream fails with ENOENT, as when Claude Code
|
|
// removes a file between the directory walk and the open.
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
openFile: (f, o) =>
|
|
fs.createReadStream(f === bad ? path.join(tmpDir, "does-not-exist") : f, o),
|
|
});
|
|
expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"]).toEqual({
|
|
input: 1,
|
|
output: 4,
|
|
cacheCreation: 0,
|
|
cacheRead: 0,
|
|
requests: 1,
|
|
});
|
|
});
|
|
|
|
it("folds a model name outside the server key pattern into `other`", async () => {
|
|
writeTranscript(
|
|
"p/s.jsonl",
|
|
[line({ ts: "2026-10-03T15:00:00Z", model: "Weird Model/1" })],
|
|
"2026-10-04T12:00:00Z",
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
expect(Object.keys(dayOf(r, "2026-10-03").models)).toEqual([OTHER_MODEL]);
|
|
});
|
|
|
|
it("reads no network module and writes nothing", () => {
|
|
const src = fs.readFileSync(
|
|
path.join(__dirname, "..", "agent", "claude-usage", "aggregate.js"),
|
|
"utf-8",
|
|
);
|
|
const requires = [...src.matchAll(/require\("([^"]+)"\)/g)].map((m) => m[1]).sort();
|
|
expect(requires).toEqual(["node:fs", "node:os", "node:path", "node:readline"]);
|
|
expect(src).not.toMatch(
|
|
/fs\.(?:promises\.)?(?:write|append|unlink|rename|mkdir|rm|copy|cp|symlink|chmod|chown|utimes|truncate|createWriteStream)/,
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- Names that collide with Object.prototype ------------------------------------
|
|
|
|
describe("prototype-shaped names", () => {
|
|
const PROTO_KEYS = ["work", "cacheRead", "requests", "input", "output", "cacheCreation"];
|
|
|
|
function prototypeIsClean() {
|
|
for (const k of PROTO_KEYS) {
|
|
expect(Object.prototype.hasOwnProperty.call(Object.prototype, k)).toBe(false);
|
|
expect(Object.prototype.hasOwnProperty.call(Object, k)).toBe(false);
|
|
}
|
|
}
|
|
|
|
it("projects named __proto__ and constructor are counted, Object.prototype untouched", async () => {
|
|
writeTranscript(
|
|
"a/x.jsonl",
|
|
[
|
|
line({ ts: "2026-10-03T15:00:00Z", cwd: `${HOME}/claude-code/__proto__`, output: 5 }),
|
|
line({ ts: "2026-10-03T16:00:00Z", cwd: `${HOME}/claude-code/constructor`, output: 7 }),
|
|
line({ ts: "2026-10-03T17:00:00Z", model: "constructor", output: 2 }),
|
|
],
|
|
"2026-10-04T12:00:00Z",
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
prototypeIsClean();
|
|
const byName = Object.fromEntries(r.projects.map((p) => [p.name, p]));
|
|
expect(Object.keys(byName).sort()).toEqual(["__proto__", "alpha", "constructor"].sort());
|
|
expect(Object.getOwnPropertyDescriptor(byName, "__proto__").value.work).toBe(1 + 5);
|
|
expect(byName.constructor.work).toBe(1 + 7);
|
|
expect(dayOf(r, "2026-10-03").models.constructor.output).toBe(2);
|
|
expect(JSON.parse(JSON.stringify(r.days)).length).toBe(WINDOW_DAYS);
|
|
});
|
|
|
|
it("a __proto__ key in a frozen day stays a plain project", async () => {
|
|
const frozenDays = JSON.parse(
|
|
'{"2026-10-01":{"models":{},"projects":{"__proto__":{"work":4,"cacheRead":0,"requests":1},"constructor":{"work":6,"cacheRead":0,"requests":1}}}}',
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays });
|
|
prototypeIsClean();
|
|
expect(r.projects.map((p) => [p.name, p.work]).sort()).toEqual(
|
|
[
|
|
["__proto__", 4],
|
|
["constructor", 6],
|
|
].sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// The agent around the aggregation (issue #50): plan limits, local cache,
|
|
// snapshot assembly and delivery, cron wrapper.
|
|
//
|
|
// Nothing here touches the real ~/.claude or ~/.local, nor the network: every
|
|
// path (home, credentials, state dir, transcripts root) and `fetch` itself are
|
|
// injected, and the spawned processes run with HOME pointed at a temp dir.
|
|
// The two tokens below are checked, fragment by fragment, against every output
|
|
// that would reach `logger -t claude-usage`, as in agent.test.js.
|
|
// ============================================================================
|
|
|
|
const { execFile, spawn } = require("node:child_process");
|
|
const { promisify } = require("node:util");
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const {
|
|
fetchLimits,
|
|
parseUsage,
|
|
USAGE_URL,
|
|
DETAILS,
|
|
} = require("../agent/claude-usage/limits.js");
|
|
const {
|
|
loadState,
|
|
saveState,
|
|
nextState,
|
|
limitsForSnapshot,
|
|
emptyState,
|
|
stateFilePath,
|
|
defaultStateDir,
|
|
RETENTION_DAYS,
|
|
} = require("../agent/claude-usage/state.js");
|
|
const {
|
|
main: agentMain,
|
|
readConfig: agentReadConfig,
|
|
MAX_BODY_BYTES,
|
|
EXIT_OK,
|
|
EXIT_CONFIG,
|
|
EXIT_PUSH,
|
|
} = require("../agent/claude-usage/push-claude-usage.js");
|
|
const { sanitizeClaudeUsage } = require("../claude-usage.js");
|
|
|
|
const AGENT_DIR = path.join(__dirname, "..", "agent", "claude-usage");
|
|
const PUSH_PATH = path.join(AGENT_DIR, "push-claude-usage.js");
|
|
const WRAPPER_PATH = path.join(AGENT_DIR, "run-claude-usage.sh");
|
|
const USAGE_FIXTURE = path.join(__dirname, "fixtures", "claude-usage", "oauth-usage.json");
|
|
|
|
// Mixed letters and digits, no run of five digits, so neither a port nor a
|
|
// timestamp can ever look like a leak.
|
|
const OAUTH_TOKEN = "sk9ant8oat2q7w4e1r6t3y5u";
|
|
const INGEST_TOKEN = "c4u9g2i7n3g8e5s1t6k0z";
|
|
const API_URL = "https://health.example.test";
|
|
const NOW = Date.parse("2026-10-04T16:00:00.000Z"); // Toronto 2026-10-04 12:00
|
|
const FUTURE = NOW + 3600 * 1000;
|
|
const LIMIT_STATUS_ENUM = ["ok", "token_expired", "http_error", "unavailable", "disabled"];
|
|
const DETAIL_ENUM = Object.values(DETAILS);
|
|
|
|
function usageFixture() {
|
|
return JSON.parse(fs.readFileSync(USAGE_FIXTURE, "utf-8"));
|
|
}
|
|
|
|
function fragments(token, min = 5) {
|
|
const out = [];
|
|
for (let len = min; len <= token.length; len++) {
|
|
for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function expectNoLeak(text) {
|
|
const haystack = String(text);
|
|
for (const token of [OAUTH_TOKEN, INGEST_TOKEN]) {
|
|
const leaked = fragments(token).filter((f) => haystack.includes(f));
|
|
const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), "");
|
|
expect(leaked.length, `token leaked — longest fragment found: "${worst}"`).toBe(0);
|
|
}
|
|
}
|
|
|
|
function expectDetailInEnum(limits) {
|
|
expect(LIMIT_STATUS_ENUM).toContain(limits.status);
|
|
if (limits.detail !== undefined) {
|
|
const ok = DETAIL_ENUM.includes(limits.detail) || /^http_[1-5]\d\d$/.test(limits.detail);
|
|
expect(ok, `detail outside the enum: ${limits.detail}`).toBe(true);
|
|
}
|
|
}
|
|
|
|
// A fetch double that records every call. `handler(url, init)` returns a
|
|
// Response (or a promise of one) or throws.
|
|
function makeFetch(handler) {
|
|
const calls = [];
|
|
const fn = async (url, init = {}) => {
|
|
calls.push({ url: String(url), init });
|
|
return handler(String(url), init);
|
|
};
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
function jsonResponse(body, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
// Anthropic answers with `usageBody`, the health API with `pushStatus`.
|
|
function routedFetch({ usage = () => jsonResponse(usageFixture()), push = () => new Response(null, { status: 204 }) } = {}) {
|
|
return makeFetch((url, init) => (url === USAGE_URL ? usage(url, init) : push(url, init)));
|
|
}
|
|
|
|
// A fake home: <tmp>/home with .claude/projects (optionally the fixture tree)
|
|
// and .claude/.credentials.json (optionally).
|
|
function makeHome({ fixtures = false, credentials } = {}) {
|
|
const home = path.join(tmpDir, "home");
|
|
fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true });
|
|
if (fixtures) {
|
|
fs.cpSync(FIXTURES, path.join(home, ".claude", "projects"), { recursive: true });
|
|
}
|
|
const credsPath = path.join(home, ".claude", ".credentials.json");
|
|
if (credentials !== undefined) {
|
|
fs.writeFileSync(
|
|
credsPath,
|
|
typeof credentials === "string" ? credentials : JSON.stringify(credentials),
|
|
{ mode: 0o600 },
|
|
);
|
|
}
|
|
return { home, credsPath, stateFile: stateFilePath(defaultStateDir(home)) };
|
|
}
|
|
|
|
function goodCredentials(expiresAt = FUTURE) {
|
|
return {
|
|
claudeAiOauth: {
|
|
accessToken: OAUTH_TOKEN,
|
|
refreshToken: "rt9single8use7only6",
|
|
expiresAt,
|
|
scopes: ["user:inference"],
|
|
},
|
|
};
|
|
}
|
|
|
|
function agentEnv(extra = {}) {
|
|
return { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN, ...extra };
|
|
}
|
|
|
|
// Runs main() in process with everything injected; collects the output.
|
|
async function runAgent({ home, fetch, env = agentEnv(), argv = [], now = NOW, ...deps }) {
|
|
const out = [];
|
|
const err = [];
|
|
const code = await agentMain(argv, env, {
|
|
home,
|
|
fetch,
|
|
now,
|
|
log: (l) => out.push(l),
|
|
logError: (l) => err.push(l),
|
|
...deps,
|
|
});
|
|
return { code, stdout: out.join("\n"), stderr: err.join("\n") };
|
|
}
|
|
|
|
function pushCalls(fetch) {
|
|
return fetch.calls.filter((c) => c.url !== USAGE_URL);
|
|
}
|
|
|
|
// --- limits: parsing ----------------------------------------------------------
|
|
|
|
describe("limits — the endpoint's answer", () => {
|
|
const limitsOpts = (extra = {}) => ({
|
|
env: {},
|
|
credentialsPath: "/unused",
|
|
readFile: () => JSON.stringify(goodCredentials()),
|
|
claudeCodeVersion: "2.1.283",
|
|
now: NOW,
|
|
...extra,
|
|
});
|
|
|
|
it("maps the anonymized limits[] to items, weekly_scoped carrying model Fable", async () => {
|
|
const fetch = makeFetch(() => jsonResponse(usageFixture()));
|
|
const r = await fetchLimits(limitsOpts({ fetch }));
|
|
expect(r).toEqual({
|
|
status: "ok",
|
|
fetchedAt: new Date(NOW).toISOString(),
|
|
items: [
|
|
{ kind: "session", group: "session", percent: 1, severity: "normal", resetsAt: "2026-09-27T21:30:00Z", model: null },
|
|
{ kind: "weekly_all", group: "weekly", percent: 50, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: null },
|
|
{ kind: "weekly_scoped", group: "weekly", percent: 63, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: "Fable" },
|
|
],
|
|
});
|
|
expect(fetch.calls).toHaveLength(1);
|
|
});
|
|
|
|
it.each([
|
|
["absent", (b) => delete b.limits],
|
|
["empty", (b) => (b.limits = [])],
|
|
["made of junk entries", (b) => (b.limits = [null, 3, "x", { percent: 9 }])],
|
|
])("falls back to five_hour / seven_day when limits[] is %s", (_label, mutate) => {
|
|
const body = usageFixture();
|
|
mutate(body);
|
|
expect(parseUsage(body)).toEqual([
|
|
{ kind: "session", group: "session", percent: 1, resetsAt: "2026-09-27T21:30:00.512345+00:00", model: null },
|
|
{ kind: "weekly_all", group: "weekly", percent: 50, resetsAt: "2026-10-01T07:00:00.512345+00:00", model: null },
|
|
]);
|
|
});
|
|
|
|
it("an all-null answer is unavailable/bad_response", async () => {
|
|
const body = Object.fromEntries(Object.keys(usageFixture()).map((k) => [k, null]));
|
|
const fetch = makeFetch(() => jsonResponse(body));
|
|
const r = await fetchLimits(limitsOpts({ fetch }));
|
|
expect(r).toEqual({ status: "unavailable", detail: "bad_response", fetchedAt: null, items: [] });
|
|
});
|
|
|
|
it("rounds and clamps percent to an integer 0-100, drops a non-number, normalizes resetsAt", () => {
|
|
const items = parseUsage({
|
|
limits: [
|
|
{ kind: "a", group: "g", percent: 62.6, resets_at: 1790000000 },
|
|
{ kind: "b", group: "g", percent: 130, resets_at: "not a date" },
|
|
{ kind: "c", group: "g", percent: -3, resets_at: "Thu, 01 Oct 2026 07:00:00 GMT" },
|
|
{ kind: "d", percent: "50", scope: { model: { display_name: 42 } } },
|
|
],
|
|
});
|
|
expect(items.map((i) => i.percent)).toEqual([63, 100, 0, undefined]);
|
|
expect("percent" in items[3]).toBe(false);
|
|
expect(items[3].group).toBe("d");
|
|
expect(items[3].model).toBeNull();
|
|
expect(items[0].resetsAt).toBe(new Date(1790000000 * 1000).toISOString());
|
|
expect(items[1].resetsAt).toBeNull();
|
|
expect(items[2].resetsAt).toBe("2026-10-01T07:00:00.000Z");
|
|
});
|
|
|
|
it("keeps at most 12 items and no upstream key outside the whitelist", () => {
|
|
const limits = Array.from({ length: 20 }, (_, i) => ({
|
|
kind: `k${i}`, group: "g", percent: 1, severity: "normal", resets_at: null,
|
|
scope: null, is_active: true, __proto__x: "junk", token: OAUTH_TOKEN,
|
|
}));
|
|
const items = parseUsage({ limits });
|
|
expect(items).toHaveLength(12);
|
|
for (const it of items) {
|
|
expect(Object.keys(it).sort()).toEqual(["group", "kind", "model", "percent", "resetsAt", "severity"]);
|
|
}
|
|
expectNoLeak(JSON.stringify(items));
|
|
});
|
|
});
|
|
|
|
// --- limits: when no call is made ----------------------------------------------
|
|
|
|
describe("limits — cases that never call the endpoint", () => {
|
|
it.each([
|
|
["CLAUDE_USAGE_LIMITS=off", { env: { CLAUDE_USAGE_LIMITS: "off" } }, { status: "disabled" }],
|
|
["CLAUDE_USAGE_LIMITS=OFF", { env: { CLAUDE_USAGE_LIMITS: " OFF " } }, { status: "disabled" }],
|
|
["an expired token", { creds: JSON.stringify(goodCredentials(NOW - 1)) }, { status: "token_expired" }],
|
|
["truncated credentials (rewrite in progress)", { creds: '{"claudeAiOauth":{"accessToken":"sk9ant8o' }, { status: "unavailable", detail: "parse_error" }],
|
|
["absent credentials", { missing: true }, { status: "unavailable", detail: "no_credentials" }],
|
|
["credentials without an access token", { creds: '{"claudeAiOauth":{}}' }, { status: "unavailable", detail: "no_credentials" }],
|
|
["an unreadable credentials file", { unreadable: true }, { status: "unavailable", detail: "parse_error" }],
|
|
["no Claude Code version", { version: null }, { status: "unavailable", detail: "no_version" }],
|
|
])("%s", async (_label, c, expected) => {
|
|
const fetch = makeFetch(() => jsonResponse(usageFixture()));
|
|
const readFile = () => {
|
|
if (c.missing) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
if (c.unreadable) throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
return c.creds || JSON.stringify(goodCredentials());
|
|
};
|
|
const r = await fetchLimits({
|
|
env: c.env || {},
|
|
credentialsPath: "/unused",
|
|
readFile,
|
|
fetch,
|
|
claudeCodeVersion: c.version === null ? null : "2.1.283",
|
|
now: NOW,
|
|
});
|
|
expect(fetch.calls).toHaveLength(0);
|
|
expect(r).toEqual({ ...expected, fetchedAt: null, items: [] });
|
|
expectNoLeak(JSON.stringify(r));
|
|
});
|
|
|
|
it("CLAUDE_USAGE_LIMITS=off does not even read the credentials", async () => {
|
|
const readFile = vi.fn(() => JSON.stringify(goodCredentials()));
|
|
const fetch = makeFetch(() => jsonResponse(usageFixture()));
|
|
await fetchLimits({ env: { CLAUDE_USAGE_LIMITS: "off" }, readFile, fetch, claudeCodeVersion: "2.1.283" });
|
|
expect(readFile).not.toHaveBeenCalled();
|
|
expect(fetch.calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// --- limits: the request and its failures --------------------------------------
|
|
|
|
describe("limits — the request", () => {
|
|
const base = (fetch, env = {}) => ({
|
|
env,
|
|
credentialsPath: "/unused",
|
|
readFile: () => JSON.stringify(goodCredentials()),
|
|
fetch,
|
|
claudeCodeVersion: "2.1.283",
|
|
now: NOW,
|
|
});
|
|
|
|
it("sends the OAuth bearer to the pinned Anthropic URL only, redirects refused, whatever the environment says", async () => {
|
|
expect(USAGE_URL).toBe("https://api.anthropic.com/api/oauth/usage");
|
|
const fetch = makeFetch(() => jsonResponse(usageFixture()));
|
|
await fetchLimits(
|
|
base(fetch, {
|
|
ANTHROPIC_BASE_URL: "https://evil.example",
|
|
ANTHROPIC_API_URL: "https://evil.example",
|
|
CLAUDE_USAGE_API_URL: "https://evil.example",
|
|
CLAUDE_USAGE_LIMITS_URL: "https://evil.example",
|
|
CLAUDE_USAGE_LIMITS: "on",
|
|
}),
|
|
);
|
|
expect(fetch.calls).toHaveLength(1);
|
|
const { url, init } = fetch.calls[0];
|
|
expect(url).toBe("https://api.anthropic.com/api/oauth/usage");
|
|
expect(init.method).toBe("GET");
|
|
expect(init.redirect).toBe("error");
|
|
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
expect(init.headers).toMatchObject({
|
|
Authorization: `Bearer ${OAUTH_TOKEN}`,
|
|
"anthropic-beta": "oauth-2025-04-20",
|
|
"User-Agent": "claude-code/2.1.283",
|
|
});
|
|
});
|
|
|
|
it("limits.js reads no environment variable but CLAUDE_USAGE_LIMITS, and writes the URL once", () => {
|
|
const src = fs.readFileSync(path.join(AGENT_DIR, "limits.js"), "utf-8");
|
|
const code = src
|
|
.split("\n")
|
|
.filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
|
|
.join("\n");
|
|
expect([...code.matchAll(/\benv\.([A-Za-z_]+)/g)].map((m) => m[1])).toEqual(["CLAUDE_USAGE_LIMITS"]);
|
|
// process.env appears once, as the default of the injectable `env`.
|
|
expect(code.match(/process\.env/g)).toEqual(["process.env"]);
|
|
expect(code).toContain("env = process.env,");
|
|
expect(code.match(/https:\/\//g)).toHaveLength(1);
|
|
expect(code).not.toMatch(/refresh/i);
|
|
});
|
|
|
|
it("a 429 is http_error/http_429, and the agent then serves the last good items with their fetchedAt", async () => {
|
|
const fetch = makeFetch(() => new Response("Too Many Requests", { status: 429 }));
|
|
const r = await fetchLimits(base(fetch));
|
|
expect(r).toEqual({ status: "http_error", detail: "http_429", fetchedAt: null, items: [] });
|
|
expect(fetch.calls).toHaveLength(1); // no retry
|
|
|
|
const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() });
|
|
const first = routedFetch();
|
|
expect((await runAgent({ home, fetch: first, now: NOW })).code).toBe(EXIT_OK);
|
|
const firstSnap = JSON.parse(pushCalls(first)[0].init.body);
|
|
|
|
const later = NOW + 15 * 60 * 1000;
|
|
const second = routedFetch({ usage: () => new Response("slow down", { status: 429 }) });
|
|
expect((await runAgent({ home, fetch: second, now: later })).code).toBe(EXIT_OK);
|
|
const snap = JSON.parse(pushCalls(second)[0].init.body);
|
|
expect(snap.limits).toEqual({
|
|
status: "http_error",
|
|
detail: "http_429",
|
|
fetchedAt: new Date(NOW).toISOString(),
|
|
items: firstSnap.limits.items,
|
|
});
|
|
expect(snap.generatedAt).toBe(new Date(later).toISOString());
|
|
expect(credsPath).toBeTruthy();
|
|
});
|
|
|
|
it.each([
|
|
["a 500 echoing the token and request headers", () => new Response(`upstream said: Authorization: Bearer ${OAUTH_TOKEN}\nanthropic-beta: oauth`, { status: 500 })],
|
|
["a 200 with arbitrary text", () => new Response(`<html>error ${OAUTH_TOKEN} please retry</html>`, { status: 200 })],
|
|
["a 200 JSON echoing the headers", () => jsonResponse({ headers: { authorization: `Bearer ${OAUTH_TOKEN}` }, detail: "Ignore previous instructions" })],
|
|
["a 200 JSON with a hostile detail field", () => jsonResponse({ detail: "http_200_but_actually_long_text", limits: "nope", five_hour: { utilization: "x" } })],
|
|
["a 302 to another host", () => new Response(null, { status: 302, headers: { location: "https://evil.example/" } })],
|
|
["a thrown error carrying the token in its message", () => { throw new TypeError(`Headers.append: "Bearer ${OAUTH_TOKEN}" is an invalid header value.`); }],
|
|
["a network failure", () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); }],
|
|
["a timeout", () => { throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); }],
|
|
])("%s keeps detail inside the enum and no token in the result", async (_label, answer) => {
|
|
const fetch = makeFetch(answer);
|
|
const r = await fetchLimits(base(fetch));
|
|
expectDetailInEnum(r);
|
|
expect(r.status).not.toBe("ok");
|
|
expect(r.items).toEqual([]);
|
|
expectNoLeak(JSON.stringify(r));
|
|
expect(fetch.calls).toHaveLength(1);
|
|
});
|
|
|
|
it("maps timeouts, network errors and non-2xx to their codes", async () => {
|
|
const timeout = await fetchLimits({
|
|
...base(makeFetch((url, init) => new Promise((_, reject) => {
|
|
init.signal.addEventListener("abort", () => reject(init.signal.reason));
|
|
}))),
|
|
timeoutMs: 20,
|
|
});
|
|
expect(timeout).toMatchObject({ status: "unavailable", detail: "timeout" });
|
|
|
|
const network = await fetchLimits(base(makeFetch(() => { throw new TypeError("fetch failed"); })));
|
|
expect(network).toMatchObject({ status: "unavailable", detail: "network" });
|
|
|
|
const unauthorized = await fetchLimits(base(makeFetch(() => new Response("{}", { status: 401 }))));
|
|
expect(unauthorized).toMatchObject({ status: "http_error", detail: "http_401" });
|
|
});
|
|
});
|
|
|
|
// --- the credentials file ------------------------------------------------------
|
|
|
|
describe("the credentials file is never opened for writing", () => {
|
|
it("a full run only ever reads it, through fs.readFileSync without a flag", async () => {
|
|
const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() });
|
|
const before = fs.readFileSync(credsPath, "utf-8");
|
|
const beforeStat = fs.statSync(credsPath);
|
|
|
|
const writers = [
|
|
"writeFileSync", "writeFile", "appendFileSync", "appendFile", "createWriteStream",
|
|
"renameSync", "rename", "copyFileSync", "copyFile", "cpSync", "rmSync", "rm",
|
|
"unlinkSync", "unlink", "truncateSync", "truncate", "chmodSync", "chmod",
|
|
"chownSync", "utimesSync", "symlinkSync", "linkSync", "openSync", "open",
|
|
];
|
|
const spies = writers.map((name) => vi.spyOn(fs, name));
|
|
const promiseWriters = ["writeFile", "appendFile", "open", "rename", "copyFile", "rm", "unlink", "truncate", "chmod"];
|
|
const promiseSpies = promiseWriters.map((name) => vi.spyOn(fs.promises, name));
|
|
const readSpy = vi.spyOn(fs, "readFileSync");
|
|
|
|
const fetch = routedFetch();
|
|
const { code } = await runAgent({ home, fetch });
|
|
expect(code).toBe(EXIT_OK);
|
|
expect(fetch.calls.filter((c) => c.url === USAGE_URL)).toHaveLength(1);
|
|
|
|
const credReads = readSpy.mock.calls.filter((c) => c[0] === credsPath);
|
|
expect(credReads).toHaveLength(1);
|
|
expect(credReads[0][1]).toBe("utf-8");
|
|
|
|
for (const spy of [...spies, ...promiseSpies]) {
|
|
for (const args of spy.mock.calls) {
|
|
const touches = args.some((a) => String(a) === credsPath || String(a).startsWith(`${credsPath}.`));
|
|
expect(touches, `${spy.getMockName()} touched the credentials file`).toBe(false);
|
|
}
|
|
}
|
|
// The state file, on the other hand, was written: the spies were live.
|
|
expect(spies[0].mock.calls.length + spies[6].mock.calls.length).toBeGreaterThan(0);
|
|
|
|
expect(fs.readFileSync(credsPath, "utf-8")).toBe(before);
|
|
expect(fs.statSync(credsPath).mtimeMs).toBe(beforeStat.mtimeMs);
|
|
});
|
|
});
|
|
|
|
// --- state -----------------------------------------------------------------------
|
|
|
|
describe("state — local cache", () => {
|
|
it.each([
|
|
["missing", null],
|
|
["truncated", '{"version":1,"frozenDays":{"2026-09-'],
|
|
["not an object", "[1,2,3]"],
|
|
["binary junk", "\u0000\u0001ÿ"],
|
|
])("a %s state.json gives the empty state", (_label, content) => {
|
|
const file = path.join(tmpDir, "state.json");
|
|
if (content !== null) fs.writeFileSync(file, content);
|
|
expect(loadState(file)).toEqual(emptyState());
|
|
});
|
|
|
|
it("keeps only well-formed frozen days and last good limits on reload", () => {
|
|
const file = path.join(tmpDir, "state.json");
|
|
fs.writeFileSync(
|
|
file,
|
|
JSON.stringify({
|
|
frozenDays: {
|
|
"2026-10-01": { models: { m: { input: 1 } }, projects: {} },
|
|
"2026-02-30": { models: {}, projects: {} },
|
|
"not-a-day": { models: {}, projects: {} },
|
|
"2026-09-30": "junk",
|
|
"2026-09-29": { models: [], projects: null },
|
|
},
|
|
lastGoodLimits: { fetchedAt: "garbage", items: [] },
|
|
}),
|
|
);
|
|
const s = loadState(file);
|
|
expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-29", "2026-10-01"]);
|
|
expect(s.frozenDays["2026-09-29"]).toEqual({ models: {}, projects: {} });
|
|
expect(s.lastGoodLimits).toBeNull();
|
|
});
|
|
|
|
it("freezes every open day strictly before yesterday, empty ones included; yesterday and today stay open", () => {
|
|
const openDays = {
|
|
"2026-09-05": { models: {}, projects: {} },
|
|
"2026-10-01": { models: { m: { input: 1 } }, projects: { p: { work: 1 } } },
|
|
"2026-10-02": { models: {}, projects: {} },
|
|
"2026-10-03": { models: { m: { input: 2 } }, projects: {} },
|
|
"2026-10-04": { models: {}, projects: {} },
|
|
};
|
|
const s = nextState(emptyState(), { openDays, today: "2026-10-04", limits: { status: "disabled" } });
|
|
expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-05", "2026-10-01", "2026-10-02"]);
|
|
expect(s.frozenDays["2026-10-02"]).toEqual({ models: {}, projects: {} });
|
|
expect(s.frozenDays["2026-10-01"]).toEqual(openDays["2026-10-01"]);
|
|
});
|
|
|
|
it(`prunes frozen days older than ${RETENTION_DAYS} days`, () => {
|
|
const state = {
|
|
...emptyState(),
|
|
frozenDays: {
|
|
"2026-08-29": { models: {}, projects: {} }, // today - 36
|
|
"2026-08-30": { models: {}, projects: {} }, // today - 35
|
|
"2026-09-20": { models: {}, projects: {} },
|
|
},
|
|
};
|
|
const s = nextState(state, { openDays: {}, today: "2026-10-04", limits: { status: "disabled" } });
|
|
expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-08-30", "2026-09-20"]);
|
|
});
|
|
|
|
it("updates lastGoodLimits on `ok` only", () => {
|
|
const ok = { status: "ok", fetchedAt: "2026-10-04T16:00:00.000Z", items: [{ kind: "session" }] };
|
|
const s1 = nextState(emptyState(), { openDays: {}, today: "2026-10-04", limits: ok });
|
|
expect(s1.lastGoodLimits).toEqual({ fetchedAt: ok.fetchedAt, items: ok.items });
|
|
for (const failed of [
|
|
{ status: "http_error", detail: "http_429", fetchedAt: null, items: [] },
|
|
{ status: "token_expired", fetchedAt: null, items: [] },
|
|
{ status: "unavailable", detail: "timeout", fetchedAt: null, items: [] },
|
|
{ status: "disabled", fetchedAt: null, items: [] },
|
|
]) {
|
|
const s2 = nextState(s1, { openDays: {}, today: "2026-10-04", limits: failed });
|
|
expect(s2.lastGoodLimits).toEqual(s1.lastGoodLimits);
|
|
expect(limitsForSnapshot(failed, s2)).toEqual({
|
|
status: failed.status,
|
|
...(failed.detail ? { detail: failed.detail } : {}),
|
|
fetchedAt: ok.fetchedAt,
|
|
items: ok.items,
|
|
});
|
|
}
|
|
expect(limitsForSnapshot({ status: "token_expired", fetchedAt: null, items: [] }, emptyState())).toEqual({
|
|
status: "token_expired",
|
|
fetchedAt: null,
|
|
items: [],
|
|
});
|
|
});
|
|
|
|
it("writes atomically: mode 600, temp file suffixed with the pid in the same directory, then rename", () => {
|
|
const file = path.join(tmpDir, "deep", "state", "state.json");
|
|
const writeSpy = vi.spyOn(fs, "writeFileSync");
|
|
const renameSpy = vi.spyOn(fs, "renameSync");
|
|
saveState(file, { ...emptyState(), frozenDays: { "2026-10-01": { models: {}, projects: {} } } });
|
|
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
expect(writeSpy.mock.calls.map((c) => c[0])).toEqual([tmp]);
|
|
expect(renameSpy.mock.calls).toEqual([[tmp, file]]);
|
|
expect(fs.existsSync(tmp)).toBe(false);
|
|
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
expect(loadState(file).frozenDays).toEqual({ "2026-10-01": { models: {}, projects: {} } });
|
|
});
|
|
|
|
it("the default state dir is ~/.local/state/maximus-claude-usage", () => {
|
|
expect(stateFilePath(defaultStateDir("/home/u"))).toBe("/home/u/.local/state/maximus-claude-usage/state.json");
|
|
});
|
|
});
|
|
|
|
// --- push-claude-usage: one beat ----------------------------------------------
|
|
|
|
describe("push-claude-usage — one beat", () => {
|
|
it("two consecutive beats around an empty day: the empty day is frozen, the second beat opens no file before the threshold", async () => {
|
|
const { home, stateFile } = makeHome({ credentials: undefined });
|
|
const root = path.join(home, ".claude", "projects");
|
|
const projectDir = path.join(root, "-home-tester-claude-code-alpha");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
const write = (name, lines, mtimeIso) => {
|
|
const file = path.join(projectDir, name);
|
|
fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
|
|
setMtime(file, mtimeIso);
|
|
return file;
|
|
};
|
|
// Lines on Oct 1 (Toronto 23:00), file last touched Oct 2 00:30 Toronto:
|
|
// Oct 2 itself has no line at all. Unfrozen, that empty day would hold the
|
|
// threshold on Oct 2 and this file would be re-read on every beat.
|
|
const old = write("old.jsonl", [line({ ts: "2026-10-02T03:00:00Z", output: 40 })], "2026-10-02T04:30:00Z");
|
|
const recent = write(
|
|
"recent.jsonl",
|
|
[line({ ts: "2026-10-03T15:00:00Z", output: 7 }), line({ ts: "2026-10-04T15:00:00Z", output: 9 })],
|
|
"2026-10-04T15:30:00Z",
|
|
);
|
|
const env = agentEnv({ CLAUDE_USAGE_LIMITS: "off" });
|
|
|
|
const firstOpened = [];
|
|
const fetch1 = routedFetch();
|
|
const r1 = await runAgent({
|
|
home, fetch: fetch1, env,
|
|
openFile: (f, o) => { firstOpened.push(f); return fs.createReadStream(f, o); },
|
|
});
|
|
expect(r1.code).toBe(EXIT_OK);
|
|
expect(firstOpened.sort()).toEqual([old, recent].sort());
|
|
|
|
const frozen = JSON.parse(fs.readFileSync(stateFile, "utf-8")).frozenDays;
|
|
expect(frozen["2026-10-02"]).toEqual({ models: {}, projects: {} });
|
|
expect(frozen["2026-10-01"].models["claude-opus-5-5"].output).toBe(40);
|
|
expect(frozen["2026-10-03"]).toBeUndefined(); // yesterday stays open
|
|
expect(frozen["2026-10-04"]).toBeUndefined();
|
|
expect(Object.keys(frozen)).toHaveLength(28); // the window minus yesterday and today
|
|
|
|
const secondOpened = [];
|
|
const fetch2 = routedFetch();
|
|
const r2 = await runAgent({
|
|
home, fetch: fetch2, env, now: NOW + 15 * 60 * 1000,
|
|
openFile: (f, o) => { secondOpened.push(f); return fs.createReadStream(f, o); },
|
|
});
|
|
expect(r2.code).toBe(EXIT_OK);
|
|
expect(secondOpened).toEqual([recent]);
|
|
|
|
const snap1 = JSON.parse(pushCalls(fetch1)[0].init.body);
|
|
const snap2 = JSON.parse(pushCalls(fetch2)[0].init.body);
|
|
expect(snap2.days).toEqual(snap1.days); // frozen days served as they were counted
|
|
expect(snap2.projects).toEqual(snap1.projects);
|
|
});
|
|
|
|
it("a corrupt state.json gives a full beat without failing, and is rewritten", async () => {
|
|
const { home, stateFile } = makeHome({ fixtures: true });
|
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
fs.writeFileSync(stateFile, '{"frozenDays":{"2026-10-0');
|
|
const opened = [];
|
|
const fetch = routedFetch();
|
|
const r = await runAgent({
|
|
home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }),
|
|
openFile: (f, o) => { opened.push(f); return fs.createReadStream(f, o); },
|
|
});
|
|
expect(r.code).toBe(EXIT_OK);
|
|
expect(opened).toHaveLength(2); // the whole fixture tree
|
|
expect(Object.keys(loadState(stateFile).frozenDays).length).toBeGreaterThan(0);
|
|
expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
it("writes the cache before the push: a failed push still leaves the frozen days on disk", async () => {
|
|
const { home, stateFile } = makeHome({ fixtures: true });
|
|
const fetch = routedFetch({ push: () => new Response("nope", { status: 503 }) });
|
|
const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) });
|
|
expect(r.code).toBe(EXIT_PUSH);
|
|
expect(r.stderr).toContain("push failed: HTTP 503");
|
|
expect(Object.keys(loadState(stateFile).frozenDays).length).toBe(28);
|
|
});
|
|
|
|
it("the snapshot built on the fixtures goes through sanitizeClaudeUsage unchanged", async () => {
|
|
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
|
|
const fetch = routedFetch();
|
|
const r = await runAgent({ home, fetch });
|
|
expect(r.code).toBe(EXIT_OK);
|
|
const sent = JSON.parse(pushCalls(fetch)[0].init.body);
|
|
|
|
expect(sent.schemaVersion).toBe(1);
|
|
expect(sent.timeZone).toBe("America/Toronto");
|
|
expect(sent.claudeCodeVersion).toBe("2.1.283");
|
|
expect(sent.days).toHaveLength(30);
|
|
expect(sent.days[29].date).toBe("2026-10-04");
|
|
expect(sent.limits.status).toBe("ok");
|
|
expect(sent.limits.items.find((i) => i.kind === "weekly_scoped").model).toBe("Fable");
|
|
expect(sent.projects.length).toBeGreaterThan(0);
|
|
|
|
const { snapshot, error } = sanitizeClaudeUsage(sent);
|
|
expect(error).toBeUndefined();
|
|
expect(snapshot).toEqual(sent);
|
|
});
|
|
|
|
it.each([
|
|
["ok limits", { credentials: goodCredentials() }, {}],
|
|
["expired token", { credentials: goodCredentials(NOW - 1) }, {}],
|
|
["no credentials", {}, {}],
|
|
["limits off", { credentials: goodCredentials() }, { CLAUDE_USAGE_LIMITS: "off" }],
|
|
])("with %s, the snapshot still round-trips through the sanitizer", async (_label, homeOpts, envExtra) => {
|
|
const { home } = makeHome({ fixtures: true, ...homeOpts });
|
|
const fetch = routedFetch();
|
|
expect((await runAgent({ home, fetch, env: agentEnv(envExtra) })).code).toBe(EXIT_OK);
|
|
const sent = JSON.parse(pushCalls(fetch)[0].init.body);
|
|
expect(sanitizeClaudeUsage(sent).snapshot).toEqual(sent);
|
|
});
|
|
|
|
it("sends the OAuth bearer only to api.anthropic.com and the ingest token only to the health API", async () => {
|
|
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
|
|
const fetch = routedFetch();
|
|
await runAgent({ home, fetch });
|
|
expect(fetch.calls.map((c) => c.url)).toEqual([USAGE_URL, `${API_URL}/claude-usage`]);
|
|
|
|
const [usage, push] = fetch.calls;
|
|
expect(usage.init.headers.Authorization).toBe(`Bearer ${OAUTH_TOKEN}`);
|
|
expect(JSON.stringify(usage)).not.toContain(INGEST_TOKEN);
|
|
expect(push.init.headers.Authorization).toBe(`Bearer ${INGEST_TOKEN}`);
|
|
expect(push.init.method).toBe("POST");
|
|
expect(push.init.redirect).toBe("error");
|
|
expect(push.init.signal).toBeInstanceOf(AbortSignal);
|
|
expect(JSON.stringify(push.init.headers)).not.toContain(OAUTH_TOKEN);
|
|
expect(push.init.body).not.toContain(OAUTH_TOKEN);
|
|
});
|
|
|
|
it("neither token appears in the snapshot nor in the output, on success or on hostile failures", async () => {
|
|
const scenarios = [
|
|
routedFetch(),
|
|
routedFetch({ push: () => new Response(`denied ${INGEST_TOKEN} ${OAUTH_TOKEN}`, { status: 401 }) }),
|
|
routedFetch({ push: () => { throw new TypeError(`Headers.append: "Bearer ${INGEST_TOKEN}" is an invalid header value.`); } }),
|
|
routedFetch({ usage: () => new Response(`Authorization: Bearer ${OAUTH_TOKEN}`, { status: 500 }) }),
|
|
routedFetch({ push: () => { throw Object.assign(new Error(`connect to ${INGEST_TOKEN}`), { code: `E${INGEST_TOKEN}` }); } }),
|
|
];
|
|
for (const fetch of scenarios) {
|
|
fs.rmSync(path.join(tmpDir, "home"), { recursive: true, force: true });
|
|
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
|
|
const r = await runAgent({ home, fetch });
|
|
expectNoLeak(`${r.stdout}\n${r.stderr}`);
|
|
expectNoLeak(pushCalls(fetch)[0].init.body);
|
|
expectNoLeak(fs.readFileSync(stateFilePath(defaultStateDir(home)), "utf-8"));
|
|
}
|
|
});
|
|
|
|
it("the real process output contains no fragment of either token", async () => {
|
|
const { home } = makeHome({ fixtures: true, credentials: goodCredentials(Date.now() - 1000) });
|
|
// Expired credentials (no Anthropic call), and a push target that refuses
|
|
// the connection: a real failing run, nothing leaves the machine.
|
|
const result = await execFileAsync(process.execPath, [PUSH_PATH], {
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HOME: home,
|
|
CLAUDE_USAGE_API_URL: "https://127.0.0.1:1",
|
|
CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN,
|
|
NO_PROXY: "*",
|
|
no_proxy: "*",
|
|
},
|
|
}).catch((e) => e);
|
|
expect(result.code).toBe(EXIT_PUSH);
|
|
expect(result.stderr).toMatch(/claude-usage: push failed: request error \(code=[A-Za-z0-9_]+\) limits=token_expired/);
|
|
expectNoLeak(`${result.stdout}${result.stderr}`);
|
|
});
|
|
|
|
it.each([
|
|
["http:", { CLAUDE_USAGE_API_URL: "http://health.example.test" }, "must be https://"],
|
|
["unset URL", { CLAUDE_USAGE_API_URL: "" }, "CLAUDE_USAGE_API_URL is not set"],
|
|
["garbage URL", { CLAUDE_USAGE_API_URL: "::not a url::" }, "not a valid URL"],
|
|
["unset token", { CLAUDE_USAGE_INGEST_TOKEN: "" }, "CLAUDE_USAGE_INGEST_TOKEN is not set"],
|
|
["token with a trailing newline", { CLAUDE_USAGE_INGEST_TOKEN: `${INGEST_TOKEN}\n` }, "invalid character"],
|
|
])("configuration error (%s) exits 1 before any scan or call", async (_label, envExtra, message) => {
|
|
const { home, stateFile } = makeHome({ fixtures: true, credentials: goodCredentials() });
|
|
const fetch = routedFetch();
|
|
const openFile = vi.fn();
|
|
const r = await runAgent({ home, fetch, env: agentEnv(envExtra), openFile });
|
|
expect(r.code).toBe(EXIT_CONFIG);
|
|
expect(r.stderr).toContain(message);
|
|
expect(fetch.calls).toHaveLength(0);
|
|
expect(openFile).not.toHaveBeenCalled();
|
|
expect(fs.existsSync(stateFile)).toBe(false);
|
|
expectNoLeak(`${r.stdout}${r.stderr}`);
|
|
});
|
|
|
|
it("readConfig appends /claude-usage to the base URL", () => {
|
|
expect(agentReadConfig(agentEnv({ CLAUDE_USAGE_API_URL: "https://h.example/" })).config.target).toBe(
|
|
"https://h.example/claude-usage",
|
|
);
|
|
});
|
|
|
|
it(`a snapshot over ${MAX_BODY_BYTES} bytes logs payload_too_large, exits 2, sends nothing`, async () => {
|
|
const { home } = makeHome();
|
|
const projectDir = path.join(home, ".claude", "projects", "big");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
// 30 days x 12 models with 64-character names and maximal counters: about
|
|
// 70 KB once serialized, every value still inside the contract.
|
|
const big = Number.MAX_SAFE_INTEGER;
|
|
const lines = [];
|
|
for (const date of windowDays("2026-10-04")) {
|
|
for (let m = 0; m < 12; m++) {
|
|
lines.push(
|
|
line({
|
|
ts: `${date}T15:00:00Z`,
|
|
model: `m${String(m).padStart(2, "0")}-${"x".repeat(60)}`,
|
|
input: big, output: big, cacheCreation: big, cacheRead: big,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
const file = path.join(projectDir, "s.jsonl");
|
|
fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
|
|
setMtime(file, "2026-10-04T15:30:00Z");
|
|
|
|
const fetch = routedFetch();
|
|
const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) });
|
|
expect(r.code).toBe(EXIT_PUSH);
|
|
expect(r.stderr).toMatch(/payload_too_large bytes=\d+ max=65536/);
|
|
expect(Number(/bytes=(\d+)/.exec(r.stderr)[1])).toBeGreaterThan(MAX_BODY_BYTES);
|
|
expect(fetch.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("one push attempt only: a failure is not retried", async () => {
|
|
const { home } = makeHome({ fixtures: true });
|
|
const fetch = routedFetch({ push: () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }); } });
|
|
const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) });
|
|
expect(r.code).toBe(EXIT_PUSH);
|
|
expect(r.stderr).toContain("push failed: request error (code=ECONNRESET)");
|
|
expect(pushCalls(fetch)).toHaveLength(1);
|
|
});
|
|
|
|
it("a 2xx answer exits 0 with a one-line summary", async () => {
|
|
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
|
|
const r = await runAgent({ home, fetch: routedFetch() });
|
|
expect(r.code).toBe(EXIT_OK);
|
|
expect(r.stdout).toMatch(/^claude-usage: ok status=204 limits=ok bytes=\d+$/);
|
|
expect(r.stderr).toBe("");
|
|
});
|
|
|
|
it("--dry-run prints the snapshot, needs no configuration, pushes nothing and leaves the cache alone", async () => {
|
|
const { home, stateFile } = makeHome({ fixtures: true });
|
|
const fetch = routedFetch();
|
|
const r = await runAgent({ home, fetch, env: { CLAUDE_USAGE_LIMITS: "off" }, argv: ["--dry-run"] });
|
|
expect(r.code).toBe(EXIT_OK);
|
|
const snap = JSON.parse(r.stdout);
|
|
expect(snap.days).toHaveLength(30);
|
|
expect(snap.limits).toEqual({ status: "disabled", fetchedAt: null, items: [] });
|
|
expect(fetch.calls).toHaveLength(0);
|
|
expect(fs.existsSync(stateFile)).toBe(false);
|
|
});
|
|
|
|
it("the agent modules load nothing but node built-ins and their siblings", () => {
|
|
for (const name of ["limits.js", "state.js", "push-claude-usage.js"]) {
|
|
const src = fs.readFileSync(path.join(AGENT_DIR, name), "utf-8");
|
|
for (const [, mod] of src.matchAll(/require\("([^"]+)"\)/g)) {
|
|
expect(mod.startsWith("node:") || mod.startsWith("./"), `${name} requires ${mod}`).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- run-claude-usage.sh ----------------------------------------------------------
|
|
|
|
describe("run-claude-usage.sh", () => {
|
|
function writeEnvFile(home, lines, mode = 0o600) {
|
|
const file = path.join(home, ".config", "maximus-claude-usage.env");
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode });
|
|
fs.chmodSync(file, mode);
|
|
return file;
|
|
}
|
|
|
|
// A stand-in for node: records that it ran, then holds the lock a while.
|
|
function writeStubNode(home, seconds) {
|
|
const stub = path.join(home, "stub-node");
|
|
fs.writeFileSync(stub, `#!/bin/sh\necho run >> "$HOME/runs"\nsleep ${seconds}\n`, { mode: 0o755 });
|
|
return stub;
|
|
}
|
|
|
|
function runs(home) {
|
|
const file = path.join(home, "runs");
|
|
return fs.existsSync(file) ? fs.readFileSync(file, "utf-8").trim().split("\n").length : 0;
|
|
}
|
|
|
|
function wrapperEnv(home) {
|
|
return { PATH: process.env.PATH, HOME: home };
|
|
}
|
|
|
|
test("two runs in parallel: the second exits 0 without working", async () => {
|
|
const { home } = makeHome();
|
|
const stub = writeStubNode(home, 2);
|
|
writeEnvFile(home, [
|
|
`CLAUDE_USAGE_API_URL=${API_URL}`,
|
|
`CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`,
|
|
`NODE_BIN=${stub}`,
|
|
]);
|
|
|
|
const first = spawn(WRAPPER_PATH, [], { env: wrapperEnv(home), stdio: "ignore" });
|
|
const firstDone = new Promise((resolve) => first.on("exit", (code) => resolve(code)));
|
|
const deadline = Date.now() + 5000;
|
|
while (runs(home) === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25));
|
|
expect(runs(home)).toBe(1);
|
|
|
|
const started = Date.now();
|
|
const second = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) });
|
|
expect(Date.now() - started).toBeLessThan(1500);
|
|
expect(second.stdout).toContain("claude-usage: a run is already in progress, skipping");
|
|
expect(second.stderr).toBe("");
|
|
expect(runs(home)).toBe(1);
|
|
|
|
expect(await firstDone).toBe(0);
|
|
// The lock is released with the first run: the next beat works again.
|
|
const stub0 = writeStubNode(home, 0);
|
|
expect(stub0).toBe(stub);
|
|
await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) });
|
|
expect(runs(home)).toBe(2);
|
|
expect(fs.existsSync(path.join(home, ".local", "state", "maximus-claude-usage", "run.lock"))).toBe(true);
|
|
}, 15000);
|
|
|
|
test("a dry run through the wrapper prints a valid snapshot and no token", async () => {
|
|
const { home } = makeHome({ fixtures: true });
|
|
writeEnvFile(home, [
|
|
`CLAUDE_USAGE_API_URL=${API_URL}`,
|
|
`CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`,
|
|
"CLAUDE_USAGE_LIMITS=off",
|
|
`NODE_BIN=${process.execPath}`,
|
|
]);
|
|
const result = await execFileAsync(WRAPPER_PATH, ["--dry-run"], { env: wrapperEnv(home) });
|
|
const snap = JSON.parse(result.stdout);
|
|
expect(snap.limits.status).toBe("disabled"); // CLAUDE_USAGE_LIMITS was exported
|
|
expect(sanitizeClaudeUsage(snap).snapshot).toEqual(snap);
|
|
expect(result.stderr).toBe("");
|
|
expectNoLeak(`${result.stdout}${result.stderr}`);
|
|
});
|
|
|
|
test.each([
|
|
[["CLAUDE_USAGE_INGEST_TOKEN"], "CLAUDE_USAGE_API_URL is not set"],
|
|
[["CLAUDE_USAGE_API_URL"], "CLAUDE_USAGE_INGEST_TOKEN is not set"],
|
|
])("stops when a variable is missing (%j)", async (present, message) => {
|
|
const { home } = makeHome();
|
|
const values = { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN };
|
|
writeEnvFile(home, present.map((k) => `${k}=${values[k]}`));
|
|
const result = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toContain(message);
|
|
expectNoLeak(`${result.stdout}${result.stderr}`);
|
|
});
|
|
|
|
test("stops when the env file is absent, and warns on a mode other than 600", async () => {
|
|
const { home } = makeHome();
|
|
const missing = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e);
|
|
expect(missing.code).toBe(1);
|
|
expect(missing.stderr).toContain("claude-usage: env file not found:");
|
|
|
|
const stub = writeStubNode(home, 0);
|
|
writeEnvFile(
|
|
home,
|
|
[`CLAUDE_USAGE_API_URL=${API_URL}`, `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, `NODE_BIN=${stub}`],
|
|
0o644,
|
|
);
|
|
const loose = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) });
|
|
expect(loose.stderr).toContain("is mode 644, expected 600");
|
|
expect(runs(home)).toBe(1);
|
|
expectNoLeak(`${loose.stdout}${loose.stderr}`);
|
|
});
|
|
|
|
test("never enables shell tracing — `set -x` would echo the token into syslog", () => {
|
|
const script = fs.readFileSync(WRAPPER_PATH, "utf-8");
|
|
const traced = script
|
|
.split("\n")
|
|
.filter((l) => !l.trim().startsWith("#"))
|
|
.filter((l) => /\bset\b[^#\n]*-[a-z]*x/.test(l));
|
|
expect(traced).toEqual([]);
|
|
});
|
|
|
|
test("is executable, and takes a non-blocking flock under ~/.local/state", () => {
|
|
expect(fs.statSync(WRAPPER_PATH).mode & 0o111).toBeGreaterThan(0);
|
|
const script = fs.readFileSync(WRAPPER_PATH, "utf-8");
|
|
expect(script).toContain('STATE_DIR="$HOME/.local/state/maximus-claude-usage"');
|
|
expect(script).toMatch(/flock -n 9/);
|
|
});
|
|
});
|