- aggregate: frozen days are dropped after deduplication, not before. A request straddling midnight whose best line sat on a day frozen since was counted again on the open day. On equal output the earliest line now wins (it was the first one read), so the choice no longer depends on which files a beat reads. - aggregate: unreadable directories, failed stats (ENOENT included) and stream errors are counted in `readErrors`; a missing root is still an empty scan. The beat then freezes no new day (nextState freeze: false), still pushes, and logs `read_errors=N` with no path. - limits: the response is read chunk by chunk and counted in bytes; past 256 KiB the stream is cancelled instead of being read in full and measured in characters. - state: a test now covers the chmodSync of a leftover temp file. Each fix was checked by mutation (13 mutations, 13 red). Refs #54 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1939 lines
80 KiB
JavaScript
1939 lines
80 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);
|
|
});
|
|
|
|
// #54: a request whose lines straddle midnight. The day freezes at D+2, so
|
|
// the beat that froze it saw every line of the request and gave it to the
|
|
// day of its best line. Later beats must make the same choice, not count
|
|
// the request again on the open day once the frozen line is filtered out.
|
|
// Toronto is UTC-4 here: 03:59Z is Oct 1 23:59, 04:01Z is Oct 2 00:01.
|
|
it("a request straddling midnight whose best line sits on a frozen day is not counted again on the open day", async () => {
|
|
writeTranscript(
|
|
"a/straddle.jsonl",
|
|
[
|
|
line({ ts: "2026-10-02T03:59:00Z", id: "msg_mid", req: "req_mid", output: 5 }),
|
|
line({ ts: "2026-10-02T04:01:00Z", id: "msg_mid", req: "req_mid", output: 5 }),
|
|
],
|
|
"2026-10-02T12:00:00Z",
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays: FROZEN });
|
|
expect(dayOf(r, "2026-10-02").models).toEqual({});
|
|
expect(r.openDays["2026-10-02"].models).toEqual({});
|
|
// The frozen day is served as it was counted, untouched.
|
|
expect(dayOf(r, "2026-10-01").models["claude-opus-5-5"].requests).toBe(7);
|
|
});
|
|
|
|
it("a request straddling midnight whose best line sits on the open day is counted once there", async () => {
|
|
writeTranscript(
|
|
"a/straddle.jsonl",
|
|
[
|
|
line({ ts: "2026-10-02T03:59:00Z", id: "msg_mid", req: "req_mid", output: 1 }),
|
|
line({ ts: "2026-10-02T04:01:00Z", id: "msg_mid", req: "req_mid", output: 5 }),
|
|
],
|
|
"2026-10-02T12:00:00Z",
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays: FROZEN });
|
|
expect(dayOf(r, "2026-10-02").models["claude-opus-5-5"]).toEqual({
|
|
input: 1,
|
|
output: 5,
|
|
cacheCreation: 0,
|
|
cacheRead: 0,
|
|
requests: 1,
|
|
});
|
|
});
|
|
|
|
it("on equal output the earliest line wins, whatever the order the files are read in", async () => {
|
|
// Sorted read order puts the later line first. The beat that freezes a day
|
|
// and the beats after it do not read the same files (the threshold moves),
|
|
// so the winner must not depend on the read order.
|
|
const later = writeTranscript(
|
|
"a/a-first.jsonl",
|
|
[line({ ts: "2026-10-02T04:01:00Z", id: "msg_tie", req: "req_tie", output: 5 })],
|
|
"2026-10-02T12:00:00Z",
|
|
);
|
|
const earlier = writeTranscript(
|
|
"a/b-second.jsonl",
|
|
[line({ ts: "2026-10-02T03:59:00Z", id: "msg_tie", req: "req_tie", output: 5 })],
|
|
"2026-10-02T12:00:00Z",
|
|
);
|
|
const opened = [];
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
openFile: (f, o) => {
|
|
opened.push(f);
|
|
return fs.createReadStream(f, o);
|
|
},
|
|
});
|
|
expect(opened).toEqual([later, earlier]);
|
|
expect(dayOf(r, "2026-10-01").models["claude-opus-5-5"].requests).toBe(1);
|
|
expect(dayOf(r, "2026-10-02").models).toEqual({});
|
|
});
|
|
});
|
|
|
|
// --- 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,
|
|
});
|
|
});
|
|
|
|
// #54: an unreadable transcript, directory or stat is counted, so that the
|
|
// agent freezes nothing on that beat.
|
|
it("counts a stream error as a read error", async () => {
|
|
writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
const bad = writeTranscript("p/bad.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
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(r.readErrors).toBe(1);
|
|
});
|
|
|
|
it("counts an opener that throws as a read error", async () => {
|
|
writeTranscript("p/bad.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
const r = await aggregate({
|
|
root: tmpDir,
|
|
today: "2026-10-04",
|
|
homedir: HOME,
|
|
openFile: () => {
|
|
throw Object.assign(new Error("denied"), { code: "EACCES" });
|
|
},
|
|
});
|
|
expect(r.readErrors).toBe(1);
|
|
});
|
|
|
|
it("counts an unreadable subdirectory as a read error, and skips its files", async () => {
|
|
writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z", output: 4 })], "2026-10-04T12:00:00Z");
|
|
writeTranscript("q/hidden.jsonl", [line({ ts: "2026-10-03T15:00:00Z", output: 9 })], "2026-10-04T12:00:00Z");
|
|
const badDir = path.join(tmpDir, "q");
|
|
// A spy rather than chmod 000: the CI runs as root, which reads anyway.
|
|
const realReaddir = fs.promises.readdir;
|
|
vi.spyOn(fs.promises, "readdir").mockImplementation((dir, opts) =>
|
|
dir === badDir
|
|
? Promise.reject(Object.assign(new Error("denied"), { code: "EACCES" }))
|
|
: realReaddir.call(fs.promises, dir, opts),
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
expect(r.readErrors).toBe(1);
|
|
expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"].output).toBe(4);
|
|
});
|
|
|
|
it("counts a failed stat as a read error, ENOENT included", async () => {
|
|
writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
const gone = writeTranscript("p/gone.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
const realStat = fs.promises.stat;
|
|
vi.spyOn(fs.promises, "stat").mockImplementation((file, opts) =>
|
|
file === gone
|
|
? Promise.reject(Object.assign(new Error("gone"), { code: "ENOENT" }))
|
|
: realStat.call(fs.promises, file, opts),
|
|
);
|
|
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
expect(r.readErrors).toBe(1);
|
|
});
|
|
|
|
it("a missing root is an empty scan, not a read error; a clean scan has none", async () => {
|
|
const missing = await aggregate({ root: path.join(tmpDir, "absent"), today: "2026-10-04", homedir: HOME });
|
|
expect(missing.readErrors).toBe(0);
|
|
writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z");
|
|
const clean = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
|
expect(clean.readErrors).toBe(0);
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|
|
|
|
describe("limits — the response size is counted in bytes, while reading (#54)", () => {
|
|
const MAX = 256 * 1024;
|
|
const limitsOpts = (fetch) => ({
|
|
env: {},
|
|
credentialsPath: "/unused",
|
|
readFile: () => JSON.stringify(goodCredentials()),
|
|
claudeCodeVersion: "2.1.283",
|
|
now: NOW,
|
|
fetch,
|
|
});
|
|
// A valid answer padded to exactly `bytes` UTF-8 bytes with `pad`.
|
|
function padded(bytes, pad) {
|
|
const base = JSON.stringify({ ...usageFixture(), pad: "" });
|
|
let room = bytes - Buffer.byteLength(base);
|
|
const unit = Buffer.byteLength(pad);
|
|
let fill = "a".repeat(room % unit);
|
|
fill += pad.repeat(Math.floor(room / unit));
|
|
const body = JSON.stringify({ ...usageFixture(), pad: fill });
|
|
expect(Buffer.byteLength(body)).toBe(bytes);
|
|
return body;
|
|
}
|
|
|
|
it("accepts a body of exactly 256 KiB", async () => {
|
|
const body = padded(MAX, "a");
|
|
const r = await fetchLimits(limitsOpts(makeFetch(() => new Response(body, { status: 200 }))));
|
|
expect(r.status).toBe("ok");
|
|
});
|
|
|
|
it("refuses 256 KiB + 1 byte of two-byte characters, fewer than 256 Ki characters", async () => {
|
|
const body = padded(MAX + 1, "\u00e9");
|
|
expect(body.length).toBeLessThan(MAX);
|
|
const r = await fetchLimits(limitsOpts(makeFetch(() => new Response(body, { status: 200 }))));
|
|
expect(r).toEqual({ status: "unavailable", detail: DETAILS.BAD_RESPONSE, fetchedAt: null, items: [] });
|
|
});
|
|
|
|
it("stops reading an endless body and cancels it", async () => {
|
|
let pulls = 0;
|
|
let cancelled = false;
|
|
const chunk = new Uint8Array(64 * 1024).fill(0x20);
|
|
const stream = new ReadableStream({
|
|
pull(controller) {
|
|
pulls += 1;
|
|
controller.enqueue(chunk);
|
|
},
|
|
cancel() {
|
|
cancelled = true;
|
|
},
|
|
});
|
|
const r = await fetchLimits(limitsOpts(makeFetch(() => new Response(stream, { status: 200 }))));
|
|
expect(r).toEqual({ status: "unavailable", detail: DETAILS.BAD_RESPONSE, fetchedAt: null, items: [] });
|
|
expect(cancelled).toBe(true);
|
|
expect(pulls).toBeLessThan(10); // 5 chunks cross the ceiling
|
|
});
|
|
});
|
|
|
|
// --- 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("with freeze: false, keeps the frozen days (pruned) and freezes no new one", () => {
|
|
const state = {
|
|
...emptyState(),
|
|
frozenDays: {
|
|
"2026-08-29": { models: {}, projects: {} }, // pruned all the same
|
|
"2026-09-20": { models: {}, projects: {} },
|
|
},
|
|
};
|
|
const openDays = {
|
|
"2026-10-01": { models: { m: { input: 1 } }, projects: {} },
|
|
"2026-10-03": { models: {}, projects: {} },
|
|
};
|
|
const ok = { status: "ok", fetchedAt: "2026-10-04T16:00:00.000Z", items: [{ kind: "session" }] };
|
|
const s = nextState(state, { openDays, today: "2026-10-04", limits: ok, freeze: false });
|
|
expect(Object.keys(s.frozenDays)).toEqual(["2026-09-20"]);
|
|
expect(s.lastGoodLimits).toEqual({ fetchedAt: ok.fetchedAt, items: ok.items });
|
|
});
|
|
|
|
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("a temp file left at mode 644 by a killed run still ends in a mode 600 cache", () => {
|
|
// writeFileSync only applies `mode` when it creates the file (#54).
|
|
const file = path.join(tmpDir, "state", "state.json");
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, "leftover");
|
|
fs.chmodSync(tmp, 0o644); // explicit: the umask could already give 600
|
|
expect(fs.statSync(tmp).mode & 0o777).toBe(0o644);
|
|
saveState(file, emptyState());
|
|
expect(fs.existsSync(tmp)).toBe(false);
|
|
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
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 beat with a read error freezes no day, still pushes, logs a count only; the next clean beat freezes", async () => {
|
|
const { home, stateFile } = makeHome({ credentials: undefined });
|
|
const projectDir = path.join(home, ".claude", "projects", "-home-tester-claude-code-alpha");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
const write = (name, lines) => {
|
|
const file = path.join(projectDir, name);
|
|
fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
|
|
setMtime(file, "2026-10-04T15:30:00Z");
|
|
return file;
|
|
};
|
|
write("good.jsonl", [line({ ts: "2026-10-01T15:00:00Z", output: 3 })]);
|
|
const bad = write("secret-name.jsonl", [line({ ts: "2026-10-01T15:00:00Z", output: 8 })]);
|
|
const env = agentEnv({ CLAUDE_USAGE_LIMITS: "off" });
|
|
|
|
const fetch1 = routedFetch();
|
|
const r1 = await runAgent({
|
|
home, fetch: fetch1, env,
|
|
openFile: (f, o) => fs.createReadStream(f === bad ? path.join(projectDir, "missing.jsonl") : f, o),
|
|
});
|
|
expect(r1.code).toBe(EXIT_OK);
|
|
expect(pushCalls(fetch1)).toHaveLength(1);
|
|
expect(loadState(stateFile).frozenDays).toEqual({});
|
|
expect(r1.stderr).toContain("read_errors=1");
|
|
expect(r1.stderr).not.toContain("secret-name");
|
|
expect(r1.stderr).not.toContain(home);
|
|
expect(r1.stdout).not.toContain("secret-name");
|
|
|
|
const fetch2 = routedFetch();
|
|
const r2 = await runAgent({ home, fetch: fetch2, env, now: NOW + 15 * 60 * 1000 });
|
|
expect(r2.code).toBe(EXIT_OK);
|
|
expect(r2.stderr).not.toContain("read_errors");
|
|
const frozen = loadState(stateFile).frozenDays;
|
|
expect(Object.keys(frozen)).toHaveLength(28);
|
|
expect(frozen["2026-10-01"].models["claude-opus-5-5"].output).toBe(11);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|