tests/usage.test.js (5724 bytes)
1 import assert from 'node:assert/strict'; 2 import fs from 'node:fs'; 3 import os from 'node:os'; 4 import path from 'node:path'; 5 import { afterEach, beforeEach, test } from 'node:test'; 6 import fc from 'fast-check'; 7 import * as usage from '../src/usage.js'; 8 9 let dir; // the folder with the transcripts of the test 10 11 beforeEach(() => { 12 process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-usage-')); 13 dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-transcripts-')); 14 }); 15 16 afterEach(() => { 17 fs.rmSync(dir, { recursive: true, force: true }); 18 }); 19 20 /** One answer of the model, as Claude Code writes it into a transcript. */ 21 function answer({ id = 'msg_1', model = 'claude-opus-5', cwd = 'C:\\work', at = '2026-09-22T10:00:00.000Z', input = 0, output = 0, read = 0, w5m = 0, w1h = 0 } = {}) { 22 return JSON.stringify({ 23 type: 'assistant', 24 cwd, 25 sessionId: 's1', 26 timestamp: at, 27 message: { 28 id, 29 model, 30 usage: { 31 input_tokens: input, 32 output_tokens: output, 33 cache_read_input_tokens: read, 34 cache_creation_input_tokens: w5m + w1h, 35 cache_creation: { ephemeral_5m_input_tokens: w5m, ephemeral_1h_input_tokens: w1h }, 36 }, 37 }, 38 }); 39 } 40 41 const write = (name, lines) => fs.writeFileSync(path.join(dir, name), `${lines.join('\n')}\n`); 42 43 test('the price of a row follows the price list', () => { 44 // 1M input, 1M output, 1M cache read, 1M cache write with the 5-minute lifetime, on Opus 5. 45 const row = { model: 'claude-opus-5', input: 1e6, output: 1e6, read: 1e6, write5m: 1e6, write1h: 0 }; 46 assert.equal(usage.cost(row), 5 + 25 + 0.5 + 5 * 1.25); 47 // The 1-hour lifetime costs 2 times the input price. 48 assert.equal(usage.cost({ model: 'claude-opus-5', write1h: 1e6 }), 10); 49 // A date at the end of the name names the same model. 50 assert.equal(usage.cost({ model: 'claude-haiku-4-5-20251001', output: 1e6 }), 5); 51 // An unknown model costs nothing, and the page can say so. 52 assert.equal(usage.cost({ model: 'gpt-9', output: 1e6 }), 0); 53 assert.equal(usage.priced('gpt-9'), false); 54 assert.equal(usage.priced('claude-opus-5'), true); 55 }); 56 57 test('the price of a sum is the sum of the prices, for every model', () => { 58 const models = fc.constantFrom(...Object.keys(usage.PRICES)); 59 const count = fc.integer({ min: 0, max: 5_000_000 }); 60 fc.assert( 61 fc.property(models, count, count, count, count, count, (model, input, output, read, write5m, write1h) => { 62 const one = { model, input, output, read, write5m, write1h }; 63 const parts = [ 64 { model, input, output: 0, read: 0, write5m: 0, write1h: 0 }, 65 { model, input: 0, output, read: 0, write5m: 0, write1h: 0 }, 66 { model, input: 0, output: 0, read, write5m: 0, write1h: 0 }, 67 { model, input: 0, output: 0, read: 0, write5m, write1h }, 68 ]; 69 const sum = parts.reduce((n, p) => n + usage.cost(p), 0); 70 assert.ok(Math.abs(usage.cost(one) - sum) < 1e-9); 71 }), 72 ); 73 }); 74 75 test('a scan adds the tokens up for each day, project, and model', () => { 76 write('a.jsonl', [ 77 answer({ id: 'm1', output: 100, read: 1000 }), 78 answer({ id: 'm1', output: 100, read: 1000 }), // the same answer twice: it counts once 79 answer({ id: 'm2', model: 'claude-sonnet-5', output: 50 }), 80 answer({ id: 'm3', at: '2026-09-21T10:00:00.000Z', output: 7 }), 81 answer({ id: 'm4', cwd: 'C:\\other', output: 3 }), 82 '{"type":"user","message":{"content":"no tokens here"}}', 83 'half a line that a crash cut', 84 ]); 85 const { rows, files, read } = usage.scan({ dir }); 86 assert.deepEqual([files, read], [1, 1]); 87 const find = (day, project, model) => rows.find((r) => r.day === day && r.project === project && r.model === model); 88 assert.deepEqual( 89 [find('2026-09-22', 'C:\\work', 'claude-opus-5').output, find('2026-09-22', 'C:\\work', 'claude-opus-5').messages], 90 [100, 1], 91 ); 92 assert.equal(find('2026-09-22', 'C:\\work', 'claude-sonnet-5').output, 50); 93 assert.equal(find('2026-09-21', 'C:\\work', 'claude-opus-5').output, 7); 94 assert.equal(find('2026-09-22', 'C:\\other', 'claude-opus-5').output, 3); 95 assert.equal(rows.length, 4); 96 }); 97 98 test('the next scan reads only the new lines', () => { 99 write('a.jsonl', [answer({ id: 'm1', output: 10 })]); 100 usage.scan({ dir }); 101 assert.equal(usage.scan({ dir }).read, 0); // nothing new: no file is read again 102 fs.appendFileSync(path.join(dir, 'a.jsonl'), `${answer({ id: 'm2', output: 5 })}\n`); 103 const second = usage.scan({ dir }); 104 assert.equal(second.read, 1); 105 const row = second.rows.find((r) => r.model === 'claude-opus-5'); 106 assert.deepEqual([row.output, row.messages], [15, 2]); // the tokens of both scans 107 }); 108 109 test('byProject and since group the rows the way the page shows them', () => { 110 const today = new Date().toISOString().slice(0, 10); 111 const old = new Date(Date.now() - 40 * 86400000).toISOString().slice(0, 10); 112 const rows = [ 113 { day: today, project: 'p1', model: 'claude-opus-5', output: 1e6, messages: 2, input: 0, read: 0, write5m: 0, write1h: 0 }, 114 { day: today, project: 'p1', model: 'claude-sonnet-5', output: 1e6, messages: 1, input: 0, read: 0, write5m: 0, write1h: 0 }, 115 { day: old, project: 'p2', model: 'claude-opus-5', output: 2e6, messages: 5, input: 0, read: 0, write5m: 0, write1h: 0 }, 116 ]; 117 assert.equal(usage.since(rows, 30).length, 2); 118 assert.equal(usage.since(rows, 0).length, 3); 119 const [first, second] = usage.byProject(rows); 120 assert.deepEqual([first.project, first.cost], ['p2', 50]); // the most expensive project first 121 assert.deepEqual([second.project, second.cost, second.messages], ['p1', 35, 3]); 122 assert.deepEqual(Object.keys(second.models).sort(), ['claude-opus-5', 'claude-sonnet-5']); 123 });