const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const { parseClaudeLine, parseCodexLine, parseGeminiLine, summarizeRecords, scanUsage } = require("../src/lib/tokenUsage.cjs"); test("parseClaudeLine extracts usage without reading message content", () => { const line = JSON.stringify({ timestamp: "2026-06-11T02:20:00.000Z", cwd: "/Users/me/Code/App", sessionId: "claude-session", message: { role: "assistant", content: "private conversation text", model: "claude-3-5-sonnet", usage: { input_tokens: 120, output_tokens: 30, cache_read_input_tokens: 50, cache_creation_input_tokens: 10 } } }); const record = parseClaudeLine(line, "/tmp/claude.jsonl"); assert.equal(record.provider, "Claude"); assert.equal(record.model, "claude-3-5-sonnet"); assert.equal(record.inputTokens, 120); assert.equal(record.outputTokens, 30); assert.equal(record.cachedTokens, 60); assert.equal(record.totalTokens, 210); assert.equal(record.contentPreview, undefined); }); test("parseCodexLine uses last_token_usage instead of cumulative total usage", () => { const line = JSON.stringify({ timestamp: "2026-06-11T03:10:00.000Z", type: "event_msg", payload: { info: { total_token_usage: { input_tokens: 1000, output_tokens: 300, cached_input_tokens: 200, reasoning_output_tokens: 40, total_tokens: 1300 }, last_token_usage: { input_tokens: 100, output_tokens: 30, cached_input_tokens: 20, reasoning_output_tokens: 4, total_tokens: 130 } } } }); const record = parseCodexLine(line, "/tmp/rollout.jsonl"); assert.equal(record.provider, "Codex"); assert.equal(record.inputTokens, 100); assert.equal(record.outputTokens, 30); assert.equal(record.cachedTokens, 20); assert.equal(record.reasoningTokens, 4); assert.equal(record.totalTokens, 154); }); test("parseGeminiLine extracts tokens from Gemini chat jsonl", () => { const line = JSON.stringify({ timestamp: "2026-06-11T04:00:00.000Z", type: "response", model: "gemini-2.5-pro", tokens: { input: 70, output: 20, cached: 5, thoughts: 3, tool: 2, total: 100 } }); const record = parseGeminiLine(line, "/tmp/session.jsonl"); assert.equal(record.provider, "Gemini"); assert.equal(record.model, "gemini-2.5-pro"); assert.equal(record.inputTokens, 70); assert.equal(record.outputTokens, 20); assert.equal(record.cachedTokens, 5); assert.equal(record.reasoningTokens, 3); assert.equal(record.toolTokens, 2); assert.equal(record.totalTokens, 100); }); test("summarizeRecords returns today, month, total, provider, trend and insight data", () => { const now = new Date("2026-06-11T12:00:00+08:00"); const records = [ record("Claude", "2026-06-11T01:00:00Z", 100, 20, 10), record("Codex", "2026-06-10T01:00:00Z", 80, 20, 0), record("Gemini", "2026-05-31T01:00:00Z", 50, 10, 0) ]; const summary = summarizeRecords(records, { now, timeZone: "Asia/Shanghai" }); assert.equal(summary.cards.todayTokens, 130); assert.equal(summary.cards.monthTokens, 230); assert.equal(summary.cards.totalTokens, 290); assert.equal(summary.providerTotals.Claude.totalTokens, 130); assert.equal(summary.providerTotals.Codex.totalTokens, 100); assert.equal(summary.providerTotals.Gemini.totalTokens, 60); assert.ok(summary.dailyTrend.length >= 2); assert.ok(summary.sourceRows.length >= 3); assert.ok(summary.insights.length >= 1); }); test("scanUsage reads only known local usage files and aggregates them", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "tokenlens-")); await fs.mkdir(path.join(homeDir, ".claude/projects/proj"), { recursive: true }); await fs.mkdir(path.join(homeDir, ".codex/sessions/2026/06/11"), { recursive: true }); await fs.mkdir(path.join(homeDir, ".gemini/tmp/me/chats"), { recursive: true }); await fs.writeFile( path.join(homeDir, ".claude/projects/proj/a.jsonl"), JSON.stringify({ timestamp: "2026-06-11T02:20:00.000Z", message: { model: "claude", usage: { input_tokens: 10, output_tokens: 5 } } }) + "\n" ); await fs.writeFile( path.join(homeDir, ".codex/sessions/2026/06/11/rollout.jsonl"), JSON.stringify({ timestamp: "2026-06-11T02:30:00.000Z", payload: { info: { last_token_usage: { input_tokens: 7, output_tokens: 3 } } } }) + "\n" ); await fs.writeFile( path.join(homeDir, ".gemini/tmp/me/chats/session.jsonl"), JSON.stringify({ timestamp: "2026-06-11T02:40:00.000Z", model: "gemini", tokens: { input: 8, output: 2, total: 10 } }) + "\n" ); const summary = await scanUsage({ homeDir, now: new Date("2026-06-11T12:00:00+08:00"), timeZone: "Asia/Shanghai" }); assert.equal(summary.records.length, 3); assert.equal(summary.cards.totalTokens, 35); assert.equal(summary.providerTotals.Claude.totalTokens, 15); assert.equal(summary.providerTotals.Codex.totalTokens, 10); assert.equal(summary.providerTotals.Gemini.totalTokens, 10); }); function record(provider, timestamp, inputTokens, outputTokens, cachedTokens) { return { provider, source: `${provider} source`, sourceFile: `/tmp/${provider}.jsonl`, timestamp, model: provider, inputTokens, outputTokens, cachedTokens, reasoningTokens: 0, toolTokens: 0, totalTokens: inputTokens + outputTokens + cachedTokens }; }