Pure, dependency-free module for the claude-usage agent. It walks
~/.claude/projects/**/*.jsonl (subagent transcripts included), keeps
assistant lines with message.usage, drops <synthetic> and truncated
lines, deduplicates on (message.id, requestId) across all files at the
largest output_tokens, and buckets by America/Toronto day computed as
strings only (DST-safe).
Returns { openDays, days, projects, claudeCodeVersion }: the recomputed
non-frozen days, the merged 30-day series (at most 12 models per day,
the rest folded into "other"), the top 8 projects by work tokens plus
"(autres)", and the version of the newest line. Files whose mtime day
is before the day after the most recent frozen day are never opened.
Resolves #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
633 lines
23 KiB
JavaScript
633 lines
23 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");
|
|
});
|
|
|
|
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", () => {
|
|
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)/,
|
|
);
|
|
});
|
|
});
|