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: /claude-code//... -> ", () => { 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"); }); 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: /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-/-claude-code-

/... ->

", () => { 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 , 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(""); 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", () => { const 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 } }, }, "2026-09-30": { models: {}, projects: {} }, }; it("opens no file whose mtime day is before the threshold (spy on file opening)", async () => { // Threshold = day after the most recent frozen day = 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)).toHaveLength(28); expect(r.projects).toEqual([{ name: "alpha", work: 21 + 1 + 3, cacheRead: 7, requests: 8 }]); }); 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)/, ); }); });