mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-03 21:35:13 +00:00
The seeded 'today' session used a fixed noon timestamp; under the suite's TZ=UTC pin any run between 00:00 and ~12:30 UTC placed it in the future, so the provider-filtered durable path (today-range ends at now) dropped it and the parity assertion failed. Seed timestamps proportionally between start-of-today and now so they are always today and always in the past.
267 lines
12 KiB
TypeScript
267 lines
12 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
import { mkdir, rm, writeFile } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
|
|
import { getDateRange } from '../src/cli-date.js'
|
|
import { loadPricing } from '../src/models.js'
|
|
import {
|
|
buildMenubarPayloadForRange,
|
|
buildDurablePeriod,
|
|
buildPeriodData,
|
|
getDailyCacheConfigHash,
|
|
} from '../src/usage-aggregator.js'
|
|
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
|
|
import { renderOverview } from '../src/overview.js'
|
|
import type { DateRange } from '../src/types.js'
|
|
|
|
// The point of #755: Claude deletes transcripts after ~30 days, so a day that
|
|
// can no longer be re-derived from session files exists ONLY in the durable
|
|
// daily cache. The bug this suite guards: the CLI report / overview / TUI
|
|
// live-parsed the surviving files and dropped those carried days, so their
|
|
// totals fell short of the menubar (which unions the cache). Every surface now
|
|
// routes totals through the ONE shared builder (buildDurablePeriod), so the CLI
|
|
// report totals must equal the menubar payload totals EXACTLY — carried day
|
|
// included — across all-provider, provider-filtered, custom-range, and lifetime
|
|
// queries, and in the plain live regime with no carried days at all.
|
|
|
|
const ROOT = join(tmpdir(), `codeburn-durable-totals-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
|
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
|
|
let savedEnv: Record<string, string | undefined>
|
|
|
|
const CARRIED_COST = 100
|
|
|
|
function daysAgoStr(n: number): string {
|
|
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
/** A day the cache holds but no session file can reproduce (sources aged out). */
|
|
function carriedDay(date: string): DailyEntry {
|
|
return {
|
|
date,
|
|
cost: CARRIED_COST,
|
|
savingsUSD: 0,
|
|
calls: 40,
|
|
sessions: 3,
|
|
inputTokens: 5000,
|
|
outputTokens: 2000,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
editTurns: 4,
|
|
oneShotTurns: 2,
|
|
models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
|
categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
|
providers: {
|
|
claude: {
|
|
calls: 40, cost: CARRIED_COST, savingsUSD: 0, sessions: 3,
|
|
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
editTurns: 4, oneShotTurns: 2,
|
|
models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
|
categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
|
projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
|
|
},
|
|
},
|
|
projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
|
|
carried: true,
|
|
}
|
|
}
|
|
|
|
/** Write a cache whose only historical day is carried (no source files exist). */
|
|
async function seedCarriedCache(): Promise<string> {
|
|
const day = daysAgoStr(10)
|
|
const cache: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
savingsConfigHash: getDailyCacheConfigHash(),
|
|
tzKey: currentTzKey(),
|
|
lastComputedDate: daysAgoStr(1),
|
|
days: [carriedDay(day)],
|
|
complete: true,
|
|
}
|
|
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
|
|
return day
|
|
}
|
|
|
|
/** Seed a real, priced Claude session dated TODAY (the live, surviving half). */
|
|
async function seedLiveTodaySession(): Promise<void> {
|
|
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
|
|
await mkdir(projectDir, { recursive: true })
|
|
const now = new Date()
|
|
// Timestamps must be today AND already in the past: the provider-filtered
|
|
// durable path parses a today-range ending at `now`, so a fixed clock time
|
|
// (e.g. noon under the suite's TZ=UTC pin) sits in the future for any run
|
|
// before that hour and gets range-filtered out, failing parity flakily.
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const at = (f: number) => new Date(todayStart.getTime() + (now.getTime() - todayStart.getTime()) * f)
|
|
const ts = at(0.4).toISOString()
|
|
const ts2 = at(0.6).toISOString()
|
|
const line = (id: string, t: string): string => JSON.stringify({
|
|
type: 'assistant',
|
|
timestamp: t,
|
|
sessionId: 's-today',
|
|
message: {
|
|
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
|
|
content: [],
|
|
usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
|
|
},
|
|
})
|
|
await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', ts), line('m2', ts2)].join('\n') + '\n', 'utf-8')
|
|
}
|
|
|
|
/** Live-only headline over the surviving files for the range (no cache union). */
|
|
async function liveOnly(range: DateRange): Promise<{ cost: number; calls: number }> {
|
|
clearSessionCache()
|
|
const projects = filterProjectsByName(await parseAllSessions(range, 'all'), [], [])
|
|
const data = buildPeriodData('live', projects)
|
|
return { cost: data.cost, calls: data.calls }
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await loadPricing()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
|
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
|
|
await mkdir(join(ROOT, 'cache'), { recursive: true })
|
|
process.env['HOME'] = join(ROOT, 'home')
|
|
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
|
|
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
|
|
delete process.env['CLAUDE_CONFIG_DIRS']
|
|
delete process.env['CODEX_HOME']
|
|
clearSessionCache()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearSessionCache()
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k]
|
|
else process.env[k] = savedEnv[k]
|
|
}
|
|
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
|
|
})
|
|
|
|
/** The full report-vs-menubar equality for one resolved range + provider. */
|
|
async function assertParity(range: DateRange, provider: string): Promise<{ menubarCost: number; carried: number }> {
|
|
clearSessionCache()
|
|
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider, optimize: false, timeline: false })
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider })
|
|
// The report / overview / TUI headline IS durable.data; the menubar payload's
|
|
// current IS the same builder. They must agree bit-for-bit.
|
|
expect(menubar.current.cost).toBe(durable.data.cost)
|
|
expect(menubar.current.calls).toBe(durable.data.calls)
|
|
expect(menubar.current.sessions).toBe(durable.data.sessions)
|
|
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
|
|
expect(menubar.current.outputTokens).toBe(durable.data.outputTokens)
|
|
return { menubarCost: menubar.current.cost, carried: durable.carriedCostUSD }
|
|
}
|
|
|
|
describe('CLI totals ↔ menubar parity through the durable daily cache', () => {
|
|
it('counts the carried day equally on both paths for all-provider, custom-range, and lifetime', async () => {
|
|
await seedCarriedCache()
|
|
await seedLiveTodaySession()
|
|
|
|
const custom: DateRange = { start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), end: new Date() }
|
|
for (const range of [getDateRange('all').range, getDateRange('lifetime').range, custom]) {
|
|
const { menubarCost, carried } = await assertParity(range, 'all')
|
|
// The carried day is genuinely in the total: it equals the surviving-file
|
|
// parse PLUS the $100 carried day, and strictly exceeds the live-only view.
|
|
const live = await liveOnly(range)
|
|
expect(carried).toBeCloseTo(CARRIED_COST, 6)
|
|
expect(menubarCost).toBeGreaterThan(live.cost)
|
|
expect(menubarCost).toBeCloseTo(live.cost + CARRIED_COST, 6)
|
|
}
|
|
})
|
|
|
|
it('resolves provider filters identically on both paths, slicing the carried day per provider', async () => {
|
|
await seedCarriedCache()
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
// The corpus is entirely Claude, so the claude slice equals the all total.
|
|
const claude = await assertParity(range, 'claude')
|
|
clearSessionCache()
|
|
const all = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
|
|
expect(claude.menubarCost).toBeCloseTo(all.data.cost, 6)
|
|
expect(claude.carried).toBeCloseTo(CARRIED_COST, 6)
|
|
|
|
// A provider with no data is zero on both paths (no carried leak).
|
|
clearSessionCache()
|
|
const codexMenubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'codex', optimize: false, timeline: false })
|
|
clearSessionCache()
|
|
const codexDurable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'codex' })
|
|
expect(codexMenubar.current.cost).toBe(codexDurable.data.cost)
|
|
expect(codexDurable.data.cost).toBe(0)
|
|
expect(codexDurable.carriedCostUSD).toBe(0)
|
|
})
|
|
|
|
it('holds the equality in the plain live regime with no carried days', async () => {
|
|
// No cache seeded: the only data is today's surviving session.
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
const { menubarCost, carried } = await assertParity(range, 'all')
|
|
const live = await liveOnly(range)
|
|
expect(carried).toBe(0)
|
|
expect(menubarCost).toBeGreaterThan(0)
|
|
expect(menubarCost).toBeCloseTo(live.cost, 6)
|
|
})
|
|
})
|
|
|
|
describe('terminal overview carried-day footnote', () => {
|
|
it('appends the preserved-cost footnote exactly when carried > 0', async () => {
|
|
await seedCarriedCache()
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' })
|
|
expect(durable.carriedCostUSD).toBeGreaterThan(0)
|
|
const withCarried = renderOverview(durable.liveProjects, {
|
|
label: 'Last 6 months',
|
|
color: false,
|
|
durable: {
|
|
cost: durable.data.cost,
|
|
savingsUSD: durable.data.savingsUSD,
|
|
calls: durable.data.calls,
|
|
sessions: durable.data.sessions,
|
|
inputTokens: durable.data.inputTokens,
|
|
outputTokens: durable.data.outputTokens,
|
|
cacheReadTokens: durable.data.cacheReadTokens,
|
|
cacheWriteTokens: durable.data.cacheWriteTokens,
|
|
days: durable.days,
|
|
carriedCostUSD: durable.carriedCostUSD,
|
|
},
|
|
})
|
|
expect(withCarried).toContain('preserved from expired session logs')
|
|
})
|
|
|
|
it('omits the footnote when nothing was carried', async () => {
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' })
|
|
expect(durable.carriedCostUSD).toBe(0)
|
|
const noCarried = renderOverview(durable.liveProjects, {
|
|
label: 'Last 6 months',
|
|
color: false,
|
|
durable: {
|
|
cost: durable.data.cost,
|
|
savingsUSD: durable.data.savingsUSD,
|
|
calls: durable.data.calls,
|
|
sessions: durable.data.sessions,
|
|
inputTokens: durable.data.inputTokens,
|
|
outputTokens: durable.data.outputTokens,
|
|
cacheReadTokens: durable.data.cacheReadTokens,
|
|
cacheWriteTokens: durable.data.cacheWriteTokens,
|
|
days: durable.days,
|
|
carriedCostUSD: durable.carriedCostUSD,
|
|
},
|
|
})
|
|
expect(noCarried).not.toContain('preserved from expired session logs')
|
|
})
|
|
})
|