codeburn/tests/cli-status-menubar.test.ts

1524 lines
65 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync, readdirSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter as pathDelimiter, join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import { CACHE_SCHEMA_VERSION } from '../src/models.js'
import { STATUS_SNAPSHOT_RENDER_VERSION } from '../src/status-snapshot-semantic.js'
import { noonTz, utcDaysAgo } from './fixtures/clock.js'
// Derive the revision tags rather than hard-coding them: a snapshot revision
// bump on one branch would otherwise leave another branch's assertion pinned
// to a stale literal that still auto-merges cleanly.
const CURRENT_RENDER_TAG = `:render-${STATUS_SNAPSHOT_RENDER_VERSION}:`
const PREVIOUS_RENDER_TAG = `:render-${STATUS_SNAPSHOT_RENDER_VERSION - 1}:`
// Each distinct query gets its own `status-snapshot.<queryKeyHash>.json` file
// rather than one shared fixed path — these tests
// don't know the hash up front, so they locate whatever landed by pattern.
const SNAPSHOT_FILE_RE = /^status-snapshot\.[0-9a-f]+\.json$/
function findSnapshotFiles(cacheDir: string): string[] {
if (!existsSync(cacheDir)) return []
return readdirSync(cacheDir).filter(f => SNAPSHOT_FILE_RE.test(f)).map(f => join(cacheDir, f))
}
function stripSessionCountBasis(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) stripSessionCountBasis(item)
return
}
if (value && typeof value === 'object') {
const rec = value as Record<string, unknown>
delete rec.sessionCountBasis
for (const child of Object.values(rec)) stripSessionCountBasis(child)
}
}
/** Plant a same-package v5 snapshot whose payload still has costs/totals but
* predates sessionCountBasis. The live corpus is left unchanged. */
async function plantRender5MissingBasis(snapshotPath: string): Promise<string> {
const record = JSON.parse(await readFile(snapshotPath, 'utf-8')) as { semanticKey: string, payload: unknown }
record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-5:')
stripSessionCountBasis(record.payload)
await writeFile(snapshotPath, JSON.stringify(record))
return record.semanticKey
}
// Every case here spawns the real CLI and does genuine multi-provider parse
// work; the 5s default is fine on a dev laptop and not on a shared 2-core
// runner, where individual cases have been observed needing 6-8s. On
// windows-latest a single spawn has been measured between 1.4s and 12s (run
// 35474746411), so a two-spawn case reached 23.9s under the old 30s ceiling.
// runCli caps each CLI at 30s itself, so a real hang still fails here with the
// child's stderr rather than riding this number.
vi.setConfig({ testTimeout: 60_000 })
// Fixtures stamped relative to now query "today"; the fixed-date ones pass TZ: 'UTC'.
const CLI_TZ = noonTz()
function runCli(args: string[], home: string, extraEnv: Record<string, string | undefined> = {}) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
HOME: home, USERPROFILE: home,
TZ: CLI_TZ,
...extraEnv,
},
encoding: 'utf-8',
timeout: 30_000,
})
}
function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'do the thing' },
})
}
function assistantLine(sessionId: string, timestamp: string, messageId: string, model = 'claude-sonnet-4-5'): string {
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model,
content: [
{ type: 'text', text: 'done' },
{ type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } },
],
usage: { input_tokens: 500, output_tokens: 50 },
},
})
}
describe('codeburn status --format menubar-json', () => {
it('returns valid MenubarPayload with expected top-level fields', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts4 = new Date(base.getTime() + 180_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
assistantLine('s1', ts2, 'msg-1'),
userLine('s1', ts3),
assistantLine('s1', ts4, 'msg-2'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as Record<string, unknown>
expect(payload).toHaveProperty('generated')
expect(payload).toHaveProperty('current')
expect(payload).toHaveProperty('optimize')
expect(payload).toHaveProperty('history')
const current = payload['current'] as Record<string, unknown>
expect(current['cost']).toBeGreaterThan(0)
expect(current['calls']).toBe(2)
expect(current['sessions']).toBe(1)
expect(current).toHaveProperty('oneShotRate')
expect(current).toHaveProperty('topActivities')
expect(current).toHaveProperty('topModels')
expect(current).toHaveProperty('providers')
const history = payload['history'] as { daily: unknown[] }
expect(Array.isArray(history.daily)).toBe(true)
// The consent-gated telemetry aggregate the desktop app and the tray both
// send verbatim. Bucketed and name-only, so it must never echo a cost.
const snapshot = payload['telemetrySnapshot'] as Record<string, unknown>
expect(snapshot).toBeTruthy()
expect(snapshot['schema']).toBe(2)
// The same period label the payload shows, date suffix and all: the event
// it rides on is already stamped with the calendar day.
expect(snapshot['period']).toBe(current['label'])
expect(snapshot['costBucket']).toBe('<1')
expect((snapshot['models'] as Array<{ name: string; tasks: unknown[] }>)[0]!.tasks.length).toBeGreaterThan(0)
expect(JSON.stringify(snapshot)).not.toContain('myapp')
expect(JSON.stringify(snapshot)).not.toContain(String(current['cost']))
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reports calls for a zero-cost provider so the menubar can show its tab', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-zero-cost-provider-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const userAt = new Date(now.getTime() - 60_000)
const ts1 = userAt.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = now.toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('zero-cost', ts1),
assistantLine('zero-cost', ts2, 'msg-zero-cost', 'definitely-unpriced-model'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
'--no-timeline',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { providerDetails: Array<{ id: string; cost: number; calls: number; hasUsage: boolean }> }
}
expect(payload.current.providerDetails.find(p => p.id === 'claude')).toMatchObject({
id: 'claude',
cost: 0,
calls: 1,
hasUsage: true,
})
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('omits history.timeline with --no-timeline, includes it by default', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-tl-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const base = new Date(Date.now() - 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const withTimeline = runCli(['status', '--format', 'menubar-json', '--period', 'today', '--no-optimize'], home)
expect(withTimeline.status, `stderr: ${withTimeline.stderr}`).toBe(0)
const withHistory = (JSON.parse(withTimeline.stdout) as { history: Record<string, unknown> }).history
expect(withHistory).toHaveProperty('timeline')
const noTimeline = runCli(['status', '--format', 'menubar-json', '--period', 'today', '--no-optimize', '--no-timeline'], home)
expect(noTimeline.status, `stderr: ${noTimeline.stderr}`).toBe(0)
const noHistory = (JSON.parse(noTimeline.stdout) as { history: Record<string, unknown> }).history
expect(noHistory).not.toHaveProperty('timeline')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters menubar payloads to a selected review day with --day', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-day-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('before', '2026-04-09T23:58:00Z'),
assistantLine('before', '2026-04-09T23:59:00Z', 'msg-before'),
userLine('selected', '2026-04-10T11:59:00Z'),
assistantLine('selected', '2026-04-10T12:00:00Z', 'msg-selected'),
userLine('after', '2026-04-11T00:00:00Z'),
assistantLine('after', '2026-04-11T00:01:00Z', 'msg-after'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--no-optimize',
], home, { TZ: 'UTC' })
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: {
label: string
calls: number
sessions: number
topProjects: Array<{ sessions: number; sessionDetails: Array<{ date: string }> }>
}
}
expect(payload.current.label).toBe('Day (2026-04-10)')
expect(payload.current.calls).toBe(1)
expect(payload.current.sessions).toBe(1)
expect(payload.current.topProjects).toHaveLength(1)
expect(payload.current.topProjects[0]?.sessions).toBe(1)
expect(payload.current.topProjects[0]?.sessionDetails.map(s => s.date)).toEqual(['2026-04-10'])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters the whole menubar payload to a selected Claude config source', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
const slug = 'shared-app'
const day = utcDaysAgo(30)
await mkdir(join(work, 'projects', slug), { recursive: true })
await mkdir(join(personal, 'projects', slug), { recursive: true })
await writeFile(
join(work, 'projects', slug, 'work.jsonl'),
[
userLine('work', `${day}T11:59:00Z`),
assistantLine('work', `${day}T12:00:00Z`, 'msg-work'),
].join('\n'),
)
await writeFile(
join(personal, 'projects', slug, 'personal.jsonl'),
[
userLine('personal', `${day}T12:59:00Z`),
assistantLine('personal', `${day}T13:00:00Z`, 'msg-personal'),
].join('\n'),
)
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter), TZ: 'UTC' }
const allResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--no-optimize',
], home, env)
expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0)
const allPayload = JSON.parse(allResult.stdout) as {
current: { calls: number; sessions: number }
claudeConfigs?: { selectedId: string | null; options: Array<{ id: string; label: string; path: string }> }
}
expect(allPayload.current.calls).toBe(2)
expect(allPayload.current.sessions).toBe(2)
expect(allPayload.claudeConfigs?.selectedId).toBeNull()
expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work'])
const workSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-work')!.id
const workResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--claude-config-source', workSourceId,
'--no-optimize',
], home, env)
expect(workResult.status, `stderr: ${workResult.stderr}`).toBe(0)
const workPayload = JSON.parse(workResult.stdout) as {
current: { calls: number; sessions: number; providers: Record<string, number> }
history: { daily: Array<{ calls: number }> }
claudeConfigs?: { selectedId: string | null }
}
expect(workPayload.claudeConfigs?.selectedId).toBe(workSourceId)
expect(workPayload.current.calls).toBe(1)
expect(workPayload.current.sessions).toBe(1)
expect(Object.keys(workPayload.current.providers)).toEqual(['claude'])
expect(workPayload.history.daily.reduce((sum, d) => sum + d.calls, 0)).toBe(1)
const invalidResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--claude-config-source', 'claude-config:missing',
'--no-optimize',
], home, env)
expect(invalidResult.status, `stderr: ${invalidResult.stderr}`).toBe(0)
const invalidPayload = JSON.parse(invalidResult.stdout) as {
current: { calls: number }
claudeConfigs?: { selectedId: string | null }
}
expect(invalidPayload.current.calls).toBe(2)
expect(invalidPayload.claudeConfigs?.selectedId).toBeNull()
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('keeps idle Claude config options visible for the selected period', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-idle-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
await writeFile(
join(work, 'projects', 'app', 'work.jsonl'),
[
userLine('work', '2026-04-10T11:59:00Z'),
assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'),
].join('\n'),
)
await writeFile(
join(personal, 'projects', 'app', 'personal.jsonl'),
[
userLine('personal', '2026-04-09T11:59:00Z'),
assistantLine('personal', '2026-04-09T12:00:00Z', 'msg-personal'),
].join('\n'),
)
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter), TZ: 'UTC' }
const allResult = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--no-optimize',
], home, env)
expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0)
const allPayload = JSON.parse(allResult.stdout) as {
current: { calls: number }
claudeConfigs?: { options: Array<{ id: string; label: string }> }
}
expect(allPayload.current.calls).toBe(1)
expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work'])
const personalSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-personal')!.id
const personalResult = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--claude-config-source', personalSourceId,
'--no-optimize',
], home, env)
expect(personalResult.status, `stderr: ${personalResult.stderr}`).toBe(0)
const personalPayload = JSON.parse(personalResult.stdout) as {
current: { calls: number; sessions: number; providers: Record<string, number> }
claudeConfigs?: { selectedId: string | null }
}
expect(personalPayload.claudeConfigs?.selectedId).toBe(personalSourceId)
expect(personalPayload.current.calls).toBe(0)
expect(personalPayload.current.sessions).toBe(0)
expect(personalPayload.current.providers).toEqual({ claude: 0 })
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('invalidates the snapshot when ordered Claude config topology changes', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-topology-'))
try {
// The two roots deliberately share a basename. Their user-visible labels
// are disambiguated by configured order ("profile 1" / "profile 2"),
// while their transcript paths and contents stay unchanged.
const firstRoot = join(home, 'account-a', 'profile')
const secondRoot = join(home, 'account-b', 'profile')
await mkdir(join(firstRoot, 'projects', 'first'), { recursive: true })
await mkdir(join(secondRoot, 'projects', 'second'), { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(firstRoot, 'projects', 'first', 'first.jsonl'),
[userLine('first', ts(0)), assistantLine('first', ts(60_000), 'msg-first')].join('\n'),
)
await writeFile(
join(secondRoot, 'projects', 'second', 'second.jsonl'),
[userLine('second', ts(120_000)), assistantLine('second', ts(180_000), 'msg-second')].join('\n'),
)
const configDir = join(home, '.config', 'codeburn')
const configPath = join(configDir, 'config.json')
await mkdir(configDir, { recursive: true })
await writeFile(configPath, JSON.stringify({ claudeConfigDirs: [firstRoot, secondRoot] }))
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
// Empty env values select config.json instead of runCli's default single
// CLAUDE_CONFIG_DIR, and remain identical across both invocations.
const env = { CLAUDE_CONFIG_DIR: '', CLAUDE_CONFIG_DIRS: '' }
const before = runCli(args, home, env)
expect(before.status, `stderr: ${before.stderr}`).toBe(0)
const beforePayload = JSON.parse(before.stdout) as {
claudeConfigs?: { options: Array<{ label: string; path: string }> }
}
expect(Object.fromEntries(beforePayload.claudeConfigs!.options.map(option => [option.path, option.label]))).toEqual({
[firstRoot]: 'profile 1',
[secondRoot]: 'profile 2',
})
// Only config order changes. The broken fingerprint sorted transcript
// paths and ignored source labels, so it served the first snapshot.
await writeFile(configPath, JSON.stringify({ claudeConfigDirs: [secondRoot, firstRoot] }))
const after = runCli(args, home, env)
expect(after.status, `stderr: ${after.stderr}`).toBe(0)
const afterPayload = JSON.parse(after.stdout) as {
claudeConfigs?: { options: Array<{ label: string; path: string }> }
}
expect(Object.fromEntries(afterPayload.claudeConfigs!.options.map(option => [option.path, option.label]))).toEqual({
[firstRoot]: 'profile 2',
[secondRoot]: 'profile 1',
})
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects --claude-config-source combined with a non-Claude --provider', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cfg-provider-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) }
const result = runCli([
'status', '--format', 'menubar-json', '--period', 'all',
'--provider', 'codex', '--claude-config-source', 'claude-config:whatever',
'--no-optimize',
], home, env)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('--claude-config-source cannot be combined with --provider codex')
// 'claude' is allowed (a config scopes Claude usage).
const okClaude = runCli([
'status', '--format', 'menubar-json', '--period', 'all',
'--provider', 'claude', '--no-optimize',
], home, env)
expect(okClaude.status, `stderr: ${okClaude.stderr}`).toBe(0)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('surfaces Claude Desktop sessions as their own bucket so config sum equals All', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-desktop-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
const day = utcDaysAgo(30)
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
await writeFile(join(work, 'projects', 'app', 'w.jsonl'),
[userLine('w', `${day}T11:59:00Z`), assistantLine('w', `${day}T12:00:00Z`, 'mw')].join('\n'))
await writeFile(join(personal, 'projects', 'app', 'p.jsonl'),
[userLine('p', `${day}T12:59:00Z`), assistantLine('p', `${day}T13:00:00Z`, 'mp')].join('\n'))
// A fake Claude Desktop sessions tree.
const desktop = join(home, 'desktop-sessions')
const dProj = join(desktop, 'appid', 'ws', 'local_s1', '.claude', 'projects', 'space')
await mkdir(dProj, { recursive: true })
await writeFile(join(dProj, 'd.jsonl'),
[userLine('d', `${day}T13:59:00Z`), assistantLine('d', `${day}T14:00:00Z`, 'md')].join('\n'))
const env = {
CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter),
CODEBURN_DESKTOP_SESSIONS_DIR: desktop,
TZ: 'UTC',
}
const result = runCli([
'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--no-optimize',
], home, env)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { calls: number }
claudeConfigs?: { options: Array<{ id: string; label: string }> }
}
// 3 sessions total: work + personal + desktop.
expect(payload.current.calls).toBe(3)
// The selector lists all three, including a Claude Desktop bucket.
const labels = payload.claudeConfigs?.options.map(o => o.label).sort()
expect(labels).toContain('Claude Desktop')
expect(labels).toHaveLength(3)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('includes LingTai TUI usage and activity categories in menubar payloads', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-lingtai-'))
try {
const lingtaiHome = join(home, '.lingtai')
const agentDir = join(lingtaiHome, 'agent')
await mkdir(join(agentDir, 'logs'), { recursive: true })
await writeFile(join(agentDir, '.agent.json'), JSON.stringify({
agent_id: 'agent-1',
agent_name: 'LingTai Agent',
llm: { model: 'gpt-4o' },
}))
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(agentDir, 'logs', 'token_ledger.jsonl'),
[
JSON.stringify({ source: 'main', ts: ts1, input: 1000, cached: 100, output: 100, model: 'gpt-4o' }),
JSON.stringify({ source: 'tc_wake', ts: ts2, input: 2000, cached: 500, output: 200, model: 'gpt-4o' }),
JSON.stringify({ source: 'summarize_apriori', ts: ts3, input: 1500, cached: 300, output: 150, model: 'gpt-4o' }),
].join('\n') + '\n',
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--period', 'today',
'--provider', 'lingtai-tui',
'--no-optimize',
], home, {
LINGTAI_HOME: lingtaiHome,
LINGTAI_TUI_GLOBAL_DIR: join(home, '.lingtai-tui'),
})
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: {
cost: number
calls: number
providers: Record<string, number>
topActivities: Array<{ name: string; turns: number }>
}
}
expect(payload.current.cost).toBeGreaterThan(0)
expect(payload.current.calls).toBe(3)
expect(payload.current.providers['lingtai tui']).toBeGreaterThan(0)
expect(payload.current.topActivities.map(a => a.name).sort()).toEqual([
'Conversation',
'Delegation',
'Planning',
])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
assistantLine('s1', ts2, 'msg-1'),
].join('\n'),
)
const combinedResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
'--combined-details',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0)
const payload = JSON.parse(combinedResult.stdout) as {
current: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
}
history: {
daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }>
}
combined?: {
perDevice: Array<{
id: string
local: boolean
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
}>
combined: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
deviceCount: number
reachableCount: number
}
}
combinedDevices?: Array<{
id: string
local: boolean
payload?: { current: { cost: number } }
}>
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
const local = payload.combined!.perDevice[0]!
const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0)
const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0)
const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens
expect(local).toMatchObject({
id: 'local',
local: true,
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
})
expect(payload.combined!.combined).toEqual({
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
deviceCount: 1,
reachableCount: 1,
})
expect(payload.combinedDevices).toHaveLength(1)
expect(payload.combinedDevices![0]).toMatchObject({
id: 'local',
local: true,
payload: { current: { cost: payload.current.cost } },
})
const localResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'local',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0)
expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it.each([
['--provider', ['--provider', 'claude']],
['--project', ['--project', 'x']],
['--exclude', ['--exclude', 'y']],
])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
...filterArgs,
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects invalid menubar-json scope values', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'remote',
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('unknown scope "remote"')
} finally {
await rm(home, { recursive: true, force: true })
}
})
// Four CLI spawns, the most in this file, against a windows-latest tail of
// ~12s per spawn: the file-wide 60s is not enough headroom for this one.
it('serves a repeat identical query from the status snapshot, debounces a fresh change, then reflects it once settled', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-snapshot-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const firstPayload = JSON.parse(first.stdout) as { current: { calls: number } }
expect(firstPayload.current.calls).toBe(1)
// The persisted snapshot carries cost/usage aggregates and project
// paths, so it must land group/world-unreadable regardless of umask.
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
// Windows has no POSIX mode bits: NTFS reports 0o666 whatever mode open()
// was handed, so owner-only permission is only assertable on POSIX.
if (process.platform !== 'win32') expect(statSync(snapshotFiles[0]!).mode & 0o777).toBe(0o600)
// Identical query against an unchanged corpus: served from the
// snapshot, byte-identical to the first call.
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
expect(second.stdout).toBe(first.stdout)
// New session activity moves the corpus fingerprint. A call made right
// after — still well inside the (large, forced) settle window — must
// debounce: the freshly-touched file may still be mid-write, so it
// keeps serving the last SETTLED snapshot rather than recomputing on
// every tick of a burst.
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1'),
userLine('s1', ts(120_000)), assistantLine('s1', ts(180_000), 'msg-2'),
].join('\n'),
)
const debounced = runCli(args, home, { CODEBURN_STATUS_SNAPSHOT_SETTLE_MS: '60000' })
expect(debounced.status, `stderr: ${debounced.stderr}`).toBe(0)
expect(debounced.stdout).toBe(first.stdout)
// Once the change is treated as settled (forcing the window to 0), the
// very next call must reflect it — the debounce only ever delays
// picking up a real update, it never masks one permanently.
const settled = runCli(args, home, { CODEBURN_STATUS_SNAPSHOT_SETTLE_MS: '0' })
expect(settled.status, `stderr: ${settled.stderr}`).toBe(0)
const settledPayload = JSON.parse(settled.stdout) as { current: { calls: number } }
expect(settledPayload.current.calls).toBe(2)
} finally {
await rm(home, { recursive: true, force: true })
}
}, 90_000)
it('rejects a provider payload cached under the previous render contract', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-provider-render-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as {
semanticKey: string
payload: { current: { providerDetails: unknown[] } }
}
record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-2:')
record.payload.current.providerDetails = [
{ id: 'legacy-idle', label: 'Legacy Idle', cost: 0 },
]
await writeFile(snapshotFiles[0]!, JSON.stringify(record))
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const payload = JSON.parse(second.stdout) as {
current: { providerDetails: Array<{ id: string; calls: number; hasUsage: boolean }> }
}
expect(payload.current.providerDetails.map(provider => provider.id)).not.toContain('legacy-idle')
expect(payload.current.providerDetails.find(provider => provider.id === 'claude')).toMatchObject({
calls: 1,
hasUsage: true,
})
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('carries per-provider cache read through the parse path', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cache-read-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
JSON.stringify({
type: 'assistant',
sessionId: 's1',
timestamp: ts2,
message: {
id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'done' }],
usage: { input_tokens: 500, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 400 },
},
}),
].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const result = runCli(args, home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> }
}
expect(payload.current.providerDetails.find(provider => provider.id === 'claude')?.cacheReadTokens).toBe(400)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('recomputes a snapshot from the previous render revision, then reuses it stably', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cache-render-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
JSON.stringify({
type: 'assistant',
sessionId: 's1',
timestamp: ts2,
message: {
id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'done' }],
usage: { input_tokens: 500, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 400 },
},
}),
].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
// First run writes a snapshot under the current semantic key.
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as {
semanticKey: string
payload: { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } }
}
// Downgrade to the PREVIOUS render revision and strip the cache field,
// simulating a warm snapshot from the branch that took it: it must be
// rejected rather than served as if it had this data.
record.semanticKey = record.semanticKey.replace(/:render-\d+:/, PREVIOUS_RENDER_TAG)
for (const row of record.payload.current.providerDetails) delete row.cacheReadTokens
await writeFile(snapshotFiles[0]!, JSON.stringify(record))
// Recompute: the previous-revision record is rejected and rebuilt with
// cache data.
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const rebuilt = JSON.parse(second.stdout) as {
current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> }
}
expect(rebuilt.current.providerDetails.find(provider => provider.id === 'claude')?.cacheReadTokens).toBe(400)
// Stable reuse: the recomputed current-revision snapshot is served
// as-is. Inject a sentinel into the on-disk payload (keeping the current
// key and fingerprint) and confirm the next run returns it rather than
// recomputing.
const files = findSnapshotFiles(join(home, '.cache', 'codeburn'))
const fresh = JSON.parse(await readFile(files[0]!, 'utf-8')) as {
semanticKey: string
payload: { sentinel?: string }
}
expect(fresh.semanticKey).toContain(CURRENT_RENDER_TAG)
fresh.payload.sentinel = 'reused-current'
await writeFile(files[0]!, JSON.stringify(fresh))
const third = runCli(args, home)
expect(third.status, `stderr: ${third.stderr}`).toBe(0)
expect(JSON.parse(third.stdout)).toHaveProperty('sentinel', 'reused-current')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects a pre-count-basis render-5 snapshot on an empty unchanged corpus', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-legacy-basis-empty-'))
try {
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const original = JSON.parse(first.stdout) as { current: { sessions: number, sessionCountBasis?: string, cost: number, calls: number } }
expect(original.current.sessionCountBasis).toBe('identity')
expect(original.current.sessions).toBe(0)
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
const plantedKey = await plantRender5MissingBasis(snapshotFiles[0]!)
expect(plantedKey).toContain(':render-5:')
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const payload = JSON.parse(second.stdout) as { current: { sessions: number, sessionCountBasis?: string, cost: number, calls: number } }
expect(payload.current.sessionCountBasis).toBe('identity')
expect(payload.current.sessions).toBe(0)
expect(payload.current.cost).toBe(original.current.cost)
expect(payload.current.calls).toBe(original.current.calls)
const rewritten = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as { semanticKey: string }
expect(rewritten.semanticKey).toContain(CURRENT_RENDER_TAG)
expect(rewritten.semanticKey).not.toContain(':render-5:')
const third = runCli(args, home)
expect(third.status, `stderr: ${third.stderr}`).toBe(0)
expect(third.stdout).toBe(second.stdout)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects a pre-count-basis render-5 snapshot and preserves nonempty costs', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-legacy-basis-live-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const original = JSON.parse(first.stdout) as {
current: {
cost: number
calls: number
sessions: number
sessionCountBasis?: string
topProjects: Array<{ cost: number, sessions: number, sessionCountBasis?: string }>
}
}
expect(original.current.calls).toBe(1)
expect(original.current.sessions).toBe(1)
expect(original.current.sessionCountBasis).toBe('partial')
expect(original.current.cost).toBeGreaterThan(0)
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
await plantRender5MissingBasis(snapshotFiles[0]!)
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const payload = JSON.parse(second.stdout) as {
current: {
cost: number
calls: number
sessions: number
sessionCountBasis?: string
topProjects: Array<{ cost: number, sessions: number, sessionCountBasis?: string }>
}
}
expect(payload.current.cost).toBe(original.current.cost)
expect(payload.current.calls).toBe(original.current.calls)
expect(payload.current.sessions).toBe(original.current.sessions)
expect(payload.current.sessionCountBasis).toBe(original.current.sessionCountBasis)
expect(payload.current.topProjects[0]).toMatchObject({
cost: original.current.topProjects[0]!.cost,
sessions: original.current.topProjects[0]!.sessions,
sessionCountBasis: original.current.topProjects[0]!.sessionCountBasis,
})
const third = runCli(args, home)
expect(third.status, `stderr: ${third.stderr}`).toBe(0)
expect(third.stdout).toBe(second.stdout)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('keeps unknown retained accounting partial after rejecting a render-5 snapshot', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-legacy-basis-partial-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--no-optimize']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const original = JSON.parse(first.stdout) as { current: { cost: number, calls: number, sessions: number, sessionCountBasis?: string } }
expect(original.current.sessionCountBasis).toBe('partial')
expect(original.current.sessions).toBe(1)
const cacheDir = join(home, '.cache', 'codeburn')
const dailyFiles = readdirSync(cacheDir).filter(f => /^daily-cache\.v\d+\.json$/.test(f))
expect(dailyFiles).toHaveLength(1)
const dailyPath = join(cacheDir, dailyFiles[0]!)
const daily = JSON.parse(await readFile(dailyPath, 'utf-8')) as {
version: number
days: Array<Record<string, unknown>>
}
const carriedDate = new Date(todayUtcMidnight - 10 * 24 * 3600_000).toISOString().slice(0, 10)
daily.days.push({
date: carriedDate,
cost: 100,
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: 100, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 10, cost: 100, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
providers: {
claude: {
calls: 40, cost: 100, savingsUSD: 0, sessions: 3,
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
projects: { 'proj-x': { cost: 100, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
},
},
projects: { 'proj-x': { cost: 100, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
carried: true,
})
await writeFile(dailyPath, JSON.stringify(daily))
const snapshotFiles = findSnapshotFiles(cacheDir)
expect(snapshotFiles).toHaveLength(1)
await plantRender5MissingBasis(snapshotFiles[0]!)
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const payload = JSON.parse(second.stdout) as { current: { cost: number, calls: number, sessions: number, sessionCountBasis?: string } }
expect(payload.current.sessionCountBasis).toBe('partial')
expect(payload.current.sessions).toBe(3)
expect(payload.current.cost).toBeCloseTo(original.current.cost + 100, 5)
expect(payload.current.calls).toBe(original.current.calls + 40)
const dailyAfter = readdirSync(cacheDir).filter(f => /^daily-cache\.v\d+\.json$/.test(f))
expect(dailyAfter).toEqual(dailyFiles)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('recomputes over a pre-change snapshot so topModels carry token counts, then reuses the fresh record without re-invalidating', async () => {
// The per-model token counts changed the payload's rendering semantics
// without changing the envelope. A snapshot written by the pre-change
// binary carries the same corpus fingerprint and query key, so a record
// that predates the fields must be rejected by the semantic key (one real
// recompute), after which the fresh record is served as-is — the third
// identical call must neither rebuild nor rewrite the snapshot.
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-token-render-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const seeded = JSON.parse(first.stdout) as { current: { topModels: Array<Record<string, unknown>> } }
expect(seeded.current.topModels[0]?.inputTokens).toBe(500)
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as {
semanticKey: string
payload: { current: { topModels: unknown[] } }
}
// Rewind the record to the pre-change contract: the IMMEDIATELY previous
// render revision, topModels rows without any token counts. Deriving the
// tag keeps the plant meaningful — a hard-coded older literal would also
// be rejected by revisions this change did not introduce, so the case
// would stop proving that THIS bump is what invalidates the record.
record.semanticKey = record.semanticKey.replace(/:render-\d+:/, PREVIOUS_RENDER_TAG)
record.payload.current.topModels = [
{ name: 'Legacy Snapshot Model', cost: 9.99, calls: 4, savingsUSD: 0, savingsBaselineModel: '' },
]
await writeFile(snapshotFiles[0]!, JSON.stringify(record))
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const payload = JSON.parse(second.stdout) as {
current: { topModels: Array<{ name: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }> }
}
expect(payload.current.topModels.map(model => model.name)).not.toContain('Legacy Snapshot Model')
expect(payload.current.topModels[0]).toMatchObject({
inputTokens: 500,
outputTokens: 50,
// The helper's usage carries no cache traffic: a KNOWN zero, which the
// pre-change record could not have expressed at all.
cacheReadTokens: 0,
cacheWriteTokens: 0,
})
const freshRecord = await readFile(snapshotFiles[0]!, 'utf-8')
expect(JSON.parse(freshRecord).semanticKey).toContain(CURRENT_RENDER_TAG)
const third = runCli(args, home)
expect(third.status, `stderr: ${third.stderr}`).toBe(0)
expect(JSON.parse(third.stdout)).toEqual(payload)
// No repeated invalidation: a warm record is served, not rebuilt or
// rewritten (loadStatusSnapshot only persists settle-window bookkeeping
// on a corpus mismatch, and saveStatusSnapshot only runs on a miss).
expect(await readFile(snapshotFiles[0]!, 'utf-8')).toBe(freshRecord)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reprices from an updated live LiteLLM cache instead of serving a stale snapshot', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-pricing-gen-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const cacheDir = join(home, '.cache', 'codeburn')
await mkdir(cacheDir, { recursive: true })
const liveCachePath = join(cacheDir, 'litellm-pricing.json')
const writeLivePricing = (timestamp: number, inputCost: number, outputCost: number) =>
writeFile(liveCachePath, JSON.stringify({
version: CACHE_SCHEMA_VERSION,
timestamp,
data: { 'claude-sonnet-4-5': { inputCostPerToken: inputCost, outputCostPerToken: outputCost, cacheWriteCostPerToken: inputCost * 1.25, cacheReadCostPerToken: inputCost * 0.1, webSearchCostPerRequest: 0.01, fastMultiplier: 1 } },
}))
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'claude', '--no-optimize']
await writeLivePricing(Date.now(), 1e-6, 5e-6)
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const firstCost = (JSON.parse(first.stdout) as { current: { cost: number } }).current.cost
// Same session corpus, no config/currency change — only the live
// LiteLLM cache's price and timestamp move. Before the fix, the
// persisted status snapshot has no way to observe this and would keep
// serving `firstCost` indefinitely.
await writeLivePricing(Date.now() + 1000, 2e-6, 10e-6)
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const secondCost = (JSON.parse(second.stdout) as { current: { cost: number } }).current.cost
expect(secondCost).toBeCloseTo(firstCost * 2, 5)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('invalidates the snapshot when a model is newly marked flat-rate', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-flat-rate-gen-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
const model = 'zz-status-snapshot-flat-rate'
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1', model)].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'claude', '--no-optimize']
const before = runCli(args, home)
expect(before.status, `stderr: ${before.stderr}`).toBe(0)
const beforePayload = JSON.parse(before.stdout) as { current: { unpricedModels: Array<{ model: string }> } }
expect(beforePayload.current.unpricedModels.map(row => row.model)).toContain(model)
// This is render/daily-cache config, not a session-file change. It must
// still invalidate the fully-rendered status snapshot.
const configDir = join(home, '.config', 'codeburn')
await mkdir(configDir, { recursive: true })
await writeFile(join(configDir, 'config.json'), JSON.stringify({ flatRateModels: [model] }))
const after = runCli(args, home)
expect(after.status, `stderr: ${after.stderr}`).toBe(0)
const afterPayload = JSON.parse(after.stdout) as { current: { unpricedModels: Array<{ model: string }> } }
expect(afterPayload.current.unpricedModels.map(row => row.model)).not.toContain(model)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('invalidates the snapshot when the gateway totals opt-in flips', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-gateway-optin-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const cacheDir = join(home, '.cache', 'codeburn')
expect(runCli(args, home).status).toBe(0)
expect(findSnapshotFiles(cacheDir)).toHaveLength(1)
// An identical query reuses the record rather than writing a second one.
expect(runCli(args, home).status).toBe(0)
expect(findSnapshotFiles(cacheDir)).toHaveLength(1)
// The opt-in moves the headline without touching a session file or any
// pricing config, so it has to be part of the snapshot's query key.
expect(runCli(['gateway-totals', 'include'], home).status).toBe(0)
expect(runCli(args, home).status).toBe(0)
expect(findSnapshotFiles(cacheDir)).toHaveLength(2)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('never persists or serves the default optimize-enabled payload from the status snapshot', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-optimize-cache-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'claude']
const before = runCli(args, home)
expect(before.status, `stderr: ${before.stderr}`).toBe(0)
const findingsBefore = (JSON.parse(before.stdout) as { optimize: { findingCount: number } }).optimize.findingCount
const cacheDir = join(home, '.cache', 'codeburn')
expect(findSnapshotFiles(cacheDir)).toEqual([])
// Mutable, non-fingerprinted optimize input: an unused custom agent
// definition. The session corpus is untouched, so a corpus-fingerprint-
// keyed snapshot would never notice this changed.
const agentsDir = join(home, '.claude', 'agents')
await mkdir(agentsDir, { recursive: true })
await writeFile(join(agentsDir, 'ghost-agent.md'), '# never invoked this period')
const after = runCli(args, home)
expect(after.status, `stderr: ${after.stderr}`).toBe(0)
const findingsAfter = (JSON.parse(after.stdout) as { optimize: { findingCount: number } }).optimize.findingCount
expect(findingsAfter).toBe(findingsBefore + 1)
expect(findSnapshotFiles(cacheDir)).toEqual([])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reflects appended session activity on the very next optimize-path call', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-optimize-miss-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
// Same provider for both calls: the only delta between the two
// invocations is the appended session line, so a changed answer can
// only come from the corpus change being picked up, never from a
// queryKey change.
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'claude']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const firstPayload = JSON.parse(first.stdout) as { current: { calls: number } }
expect(firstPayload.current.calls).toBe(1)
// The optimize path never reads the disk snapshot, so the new session
// line shows up immediately, with no settle window to wait out.
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1'),
userLine('s1', ts(120_000)), assistantLine('s1', ts(180_000), 'msg-2'),
].join('\n'),
)
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const secondPayload = JSON.parse(second.stdout) as { current: { calls: number } }
expect(secondPayload.current.calls).toBe(2)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('still emits a valid combined menubar payload when the remotes store is corrupt', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-corrupt-remotes-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const base = new Date(now.getTime() - 2 * 3600_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n'))
// Corrupt the remotes store the combined path reads. The menubar must
// still get a valid payload (combined degrades to local-only).
const sharingDir = join(home, '.config', 'codeburn', 'sharing')
await mkdir(sharingDir, { recursive: true })
await writeFile(join(sharingDir, 'remote-devices.json'), '{ this is : not valid json ]')
const result = runCli([
'status', '--format', 'menubar-json', '--scope', 'combined',
'--period', 'today', '--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { cost: number }
combined?: { perDevice: unknown[]; combined: { deviceCount: number; reachableCount: number } }
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
expect(payload.combined!.combined.deviceCount).toBe(1)
expect(payload.combined!.combined.reachableCount).toBe(1)
} finally {
await rm(home, { recursive: true, force: true })
}
})
})