codeburn/app/renderer/App.test.tsx
Marc Reynolds 61581edb18
feat(dashboard): add combined-device scope to desktop app and menubar badge
Fixes #795: the desktop Dashboard showed only local-device cost while the
menubar and web GUI aggregated across paired devices.

Desktop app:
- Add a Local/Combined Scope setting (Settings > General), mirroring the
  macOS menubar. Combined passes `--scope combined` to the CLI, forces the
  provider filter to all, and persists to localStorage.
- Overview hero shows the paired-device aggregate (cost/calls/sessions) with
  a "Combined · N devices" caption and per-device breakdown when Combined is
  selected; detailed panels remain local (the combined payload carries totals
  only).

Menubar:
- The badge figure now reflects Combined scope instead of always showing the
  local total: refreshMenubarBadge fetches the combined payload for the badge
  period and the badge renders the cross-device aggregate, falling back to
  local when no combined payload is available.
2026-07-31 15:12:29 -06:00

787 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { App, overviewMemoKey, topCategoryByModel, usageSnapshotProps } from './App'
import { sanitizeProps } from '../electron/telemetry'
import { __resetPolledMemo, hasPolledMemo, primePolledMemo } from './hooks/usePolled'
import { setActiveCurrency } from './lib/format'
import type { DateRange, MenubarPayload, ModelReportRow, OptimizeJsonReport, SpendFlow } from './lib/types'
const stored = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => stored.set(key, value),
removeItem: (key: string) => stored.delete(key),
clear: () => stored.clear(),
})
const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
getModels: vi.fn(),
getSessions: vi.fn(),
getCompareModels: vi.fn(),
getCompare: vi.fn(),
getQuota: vi.fn(),
getPlans: vi.fn(),
getActReport: vi.fn(),
getYield: vi.fn(),
getDevices: vi.fn(),
getDevicesScan: vi.fn(),
getIdentity: vi.fn(),
cliStatus: vi.fn(),
getPriceOverrides: vi.fn(),
getAliases: vi.fn(),
setCurrency: vi.fn(),
resetCurrency: vi.fn(),
}))
vi.mock('./lib/ipc', async orig => {
const actual = await orig<typeof import('./lib/ipc')>()
return { ...actual, codeburn: mocks }
})
function dateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
}
function overviewPayload(): MenubarPayload {
const now = new Date()
return {
generated: now.toISOString(),
current: {
label: 'Last 30 days',
cost: 12.34,
calls: 12,
sessions: 2,
oneShotRate: null,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 0,
codexCredits: 0,
topActivities: [],
topModels: [],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: { claude: 10, codex: 2 },
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [],
skills: [],
subagents: [],
mcpServers: [],
},
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: {
daily: [
{
date: dateKey(now),
cost: 12.34,
savingsUSD: 0,
calls: 12,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
},
],
},
}
}
const CONFIG_A = 'claude-config:aaaa000011112222'
const CONFIG_B = 'claude-desktop:bbbb000011112222'
function withConfigs(payload: MenubarPayload): MenubarPayload {
return {
...payload,
claudeConfigs: {
selectedId: null,
options: [
{ id: CONFIG_A, label: 'Default Claude', path: '/Users/x/.claude' },
{ id: CONFIG_B, label: 'Claude Desktop', path: '/Users/x/Library/Application Support/Claude' },
],
},
}
}
function installDefaultMocks() {
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.getOverview.mockResolvedValue(overviewPayload())
mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] })
mocks.getOptimizeReport.mockResolvedValue({
period: { label: 'Last 30 days', start: null, end: null },
summary: {
healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0,
sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0,
potentialSavingsPercent: 0, costRateUSD: 0,
},
findings: [],
})
mocks.getModels.mockResolvedValue([])
mocks.getSessions.mockResolvedValue([])
mocks.getCompareModels.mockResolvedValue([])
mocks.getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
])
mocks.getPlans.mockResolvedValue({})
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
mocks.getYield.mockResolvedValue({
period: { label: 'Last 30 days', start: '', end: '' },
summary: {
productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
total: { costUSD: 0, sessions: 0 },
productiveToRevertedCostRatio: null,
},
details: [],
})
mocks.getIdentity.mockResolvedValue({ name: 'CodeBurn Mac', fingerprint: 'AA:BB:CC' })
mocks.getDevicesScan.mockResolvedValue({ found: [] })
mocks.getDevices.mockResolvedValue({
perDevice: [],
combined: {
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
deviceCount: 1,
reachableCount: 1,
},
})
mocks.getPriceOverrides.mockResolvedValue({ overrides: [] })
mocks.getAliases.mockResolvedValue([])
mocks.setCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
mocks.resetCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
}
describe('App shortcuts', () => {
beforeEach(() => {
installDefaultMocks()
localStorage.clear()
// Pin the boot period so the provider/config tests below are independent of
// the app-wide default ('today'); tests that exercise the default set it.
localStorage.setItem('codeburn.defaultPeriod', '30days')
document.documentElement.removeAttribute('data-theme')
})
it('applies the persisted theme on app boot before Settings mounts', async () => {
localStorage.setItem('codeburn.theme', 'dark')
render(<App />)
await waitFor(() => expect(document.documentElement).toHaveAttribute('data-theme', 'dark'))
expect(screen.queryByRole('heading', { name: 'General' })).not.toBeInTheDocument()
})
it('boots with the persisted default period from Settings', async () => {
localStorage.setItem('codeburn.defaultPeriod', 'week')
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all'))
})
it('boots to today when no default period is persisted', async () => {
localStorage.removeItem('codeburn.defaultPeriod')
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
})
it('switches sections with command-number shortcuts', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', metaKey: true })
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
})
it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.getByText('⌘1-8')).toBeInTheDocument()
expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0)
expect(screen.getByText('⌘R')).toBeInTheDocument()
expect(screen.queryByText('Command')).not.toBeInTheDocument()
expect(screen.queryByText('Export view')).not.toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', metaKey: true })
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '3', metaKey: true })
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '5', metaKey: true })
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '6', metaKey: true })
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '7', metaKey: true })
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '8', metaKey: true })
expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: ',', metaKey: true })
expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0)
expect(screen.queryByText('Back')).not.toBeInTheDocument()
const overviewCalls = mocks.getOverview.mock.calls.length
fireEvent.keyDown(document, { key: 'r', metaKey: true })
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
})
it('re-polls visible section data when period or provider changes', async () => {
render(<App />)
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByText('Today'))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all')
expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'all')
})
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('today', 'claude')
expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'claude')
})
})
it('drives combined-scope overview fetches and persists the Scope setting', async () => {
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
fireEvent.keyDown(document, { key: ',', metaKey: true })
fireEvent.click(await screen.findByLabelText('Scope'))
fireEvent.click(await screen.findByRole('option', { name: 'Combined' }))
// Combined scope forces provider='all' and passes --scope combined (6th arg).
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
expect(localStorage.getItem('codeburn.scope')).toBe('combined')
})
it('boots in combined scope from the persisted Scope setting', async () => {
localStorage.setItem('codeburn.scope', 'combined')
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
})
it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => {
// grok's display name is "Grok Build"; the picker must show the label but
// send the internal id `grok` as --provider (which assertProvider accepts).
const payload = overviewPayload()
payload.current.providers = { 'grok build': 5, claude: 10 }
payload.current.providerDetails = [
{ id: 'grok', label: 'Grok Build', cost: 5 },
{ id: 'claude', label: 'Claude', cost: 10 },
]
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Grok Build' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok'))
})
it('lists a detected provider with no spend this period and sorts it last', async () => {
// Hermes has usage only outside the current period: the CLI still emits it
// as a detected provider (cost 0), so the picker must show it, at the bottom.
const payload = overviewPayload()
payload.current.providers = { claude: 10, hermes: 0 }
payload.current.providerDetails = [
{ id: 'claude', label: 'Claude', cost: 10 },
{ id: 'hermes', label: 'Hermes', cost: 0 },
]
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByText('All providers'))
const claudeOption = await screen.findByRole('option', { name: 'Claude' })
const hermesOption = screen.getByRole('option', { name: 'Hermes' })
const options = screen.getAllByRole('option')
// Zero-cost Hermes appears, and sorts after the provider that has spend.
expect(options.indexOf(hermesOption)).toBeGreaterThan(options.indexOf(claudeOption))
fireEvent.click(hermesOption)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'hermes'))
})
it('hides the Claude config picker when the payload carries no claudeConfigs', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Claude config source' })).not.toBeInTheDocument()
})
it('shows the config picker, re-fetches overview with the flag, and persists the choice', async () => {
mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
render(<App />)
const trigger = await screen.findByRole('button', { name: 'Claude config source' })
fireEvent.click(trigger)
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBe(CONFIG_A)
})
it('resets a non-Claude provider filter to all when a config is selected', async () => {
const payload = withConfigs(overviewPayload())
payload.current.providers = { claude: 10, codex: 2 }
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))
fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
// The Claude-incompatible provider filter must never reach the CLI with the flag.
expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
})
it('clears the config scope when a non-Claude provider is picked afterwards', async () => {
const payload = withConfigs(overviewPayload())
payload.current.providers = { claude: 10, codex: 2 }
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))
// The incompatible combination must never reach the CLI.
expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
})
it('boots with the persisted config source and clears it via All Claude configs', async () => {
localStorage.setItem('codeburn.claudeConfigSource', CONFIG_A)
mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
fireEvent.click(await screen.findByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'All Claude configs' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
})
it('applies a calendar range to overview and visible section polls', async () => {
render(<App />)
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Choose date range' }))
const to = new Date()
const from = new Date(to.getFullYear(), to.getMonth(), to.getDate() - 2)
const fromLabel = from.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const toLabel = to.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const range = { from: dateKey(from), to: dateKey(to) }
fireEvent.mouseDown(screen.getByRole('button', { name: fromLabel }))
fireEvent.mouseEnter(screen.getByRole('button', { name: toLabel }))
fireEvent.mouseUp(screen.getByRole('button', { name: toLabel }))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', range)
expect(mocks.getSpendFlow).toHaveBeenCalledWith('30days', 'all', range)
})
expect(screen.getByRole('button', { name: // })).toBeInTheDocument()
expect(screen.getByText('30D')).not.toHaveClass('on')
})
it('shows no daily budget banner when none is configured', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
})
it('shows no banner when today spend is under 80% of the budget', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 100 }))
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
})
it('warns when today spend reaches 80% of the daily budget', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 14 }))
render(<App />)
// 12.34 / 14 = 88.1% → warning band
expect(await screen.findByText("Today's spend is at 88% of your daily budget")).toBeInTheDocument()
})
it('alerts and dismisses for the rest of the day when the budget is exceeded', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 10 }))
render(<App />)
expect(await screen.findByText('Daily budget exceeded: $12.34 of $10.00')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
expect(localStorage.getItem('codeburn.dailyBudget.dismissed')).toBe(dateKey(new Date()))
})
it('evaluates a token budget only on the all-providers view', async () => {
const payload = overviewPayload()
payload.history.daily[0]!.inputTokens = 60_000
payload.history.daily[0]!.outputTokens = 40_000
mocks.getOverview.mockResolvedValue(payload)
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'tokens', value: 90_000 }))
render(<App />)
expect(await screen.findByText('Daily budget exceeded: 100K of 90K')).toBeInTheDocument()
// A specific-provider filter zeroes history.daily token fields, so the token
// cap can no longer be evaluated: the banner must disappear.
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
})
})
describe('provider prefetch storm', () => {
const PROVIDERS = [
'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid',
'hermes', 'zcode', 'cursor', 'kiro', 'codewhale', 'openrouter',
]
function manyProviderPayload(): MenubarPayload {
const base = overviewPayload()
const providers: Record<string, number> = {}
PROVIDERS.forEach((id, i) => { providers[id] = PROVIDERS.length - i })
return { ...base, current: { ...base.current, providers } }
}
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.getOverview.mockResolvedValue(manyProviderPayload())
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
mocks.getYield.mockResolvedValue({
period: { label: 'Last 30 days', start: '', end: '' },
summary: {
productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
total: { costUSD: 0, sessions: 0 },
productiveToRevertedCostRatio: null,
},
details: [],
})
localStorage.clear()
// Pin the cadence to 30s so the fake-timer soak math below is independent of
// the app-wide default (bumped to 60s for energy).
localStorage.setItem('codeburn.refreshInterval', '30s')
// Pin the boot period so these prefetch assertions are independent of the
// app-wide default ('today').
localStorage.setItem('codeburn.defaultPeriod', '30days')
__resetPolledMemo()
})
// With MEMO_MAX too small (< providers + base keys) the base overview key
// LRU-evicts between polls, blanking the overview and re-arming the prefetch
// every 30s cycle: 12 redundant full-history parses forever. This asserts the
// fix — each provider is prefetched EXACTLY ONCE total across three cycles.
it('prefetches each detected provider exactly once across 3 poll cycles', async () => {
vi.useFakeTimers()
try {
render(<App />)
// Let the mount overview resolve so `ready` flips and the prefetch arms.
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
// Three full 30s poll cycles plus the prefetch start delay and staggered
// per-provider warms (12 × 400ms). A re-arming storm would re-spawn some
// providers on cycles 2 and 3; the once-per-key guard must prevent it.
await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3 + 12_000) })
for (const id of PROVIDERS) {
const spawns = mocks.getOverview.mock.calls.filter(
// Prefetch warms carry the background-priority flag (5th arg).
c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined && c[4] === true,
)
expect(spawns.length, `prefetch spawns for ${id}`).toBe(1)
}
// Sanity: the active 'all' view was polled every cycle (not prefetch-gated).
const allPolls = mocks.getOverview.mock.calls.filter(c => c[1] === 'all')
expect(allPolls.length).toBeGreaterThanOrEqual(3)
// The memo must be sized to hold every warmed provider so none LRU-evict
// between polls — the eviction that (in the real app) blanked the base
// overview key and re-armed the prefetch. Under the old fixed cap of 8,
// 7 of these 12 keys would have been evicted by soak's end.
for (const id of PROVIDERS) {
expect(hasPolledMemo(overviewMemoKey(id, '30days', null, null)), `warm key ${id}`).toBe(true)
}
} finally {
vi.useRealTimers()
}
})
})
describe('energy: hidden-window polling', () => {
beforeEach(() => {
installDefaultMocks()
localStorage.clear()
localStorage.setItem('codeburn.refreshInterval', '30s') // pin cadence for the soak math
__resetPolledMemo()
})
// The whole app's data flows through usePolled, which is the ONLY driver of CLI
// spawns (each codeburn.getX → IPC → spawnCli). This measures that a hidden
// window issues ZERO new interval spawns, and that visibility resumes them —
// the unit-level stand-in for the packaged visible-vs-hidden sample.
it('issues zero new interval spawns while hidden and resumes when visible', async () => {
vi.useFakeTimers()
try {
setVisibility('visible')
render(<App />)
// Boot + three visible 30s cadences: the overview section's yield poll (a
// pure usePolled interval, never prefetched) fires each cadence.
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3) })
const visibleYield = mocks.getYield.mock.calls.length
expect(visibleYield).toBeGreaterThan(1) // polling while visible
// Hidden for five cadences: not a single new spawn on any poller.
setVisibility('hidden')
const atHideYield = mocks.getYield.mock.calls.length
const atHideOverview = mocks.getOverview.mock.calls.length
await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 5) })
expect(mocks.getYield.mock.calls.length).toBe(atHideYield)
expect(mocks.getOverview.mock.calls.length).toBe(atHideOverview)
// Back to visible: the stale-by-a-cadence polls catch up immediately.
setVisibility('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
expect(mocks.getYield.mock.calls.length).toBeGreaterThan(atHideYield)
expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(atHideOverview)
} finally {
setVisibility('visible')
vi.useRealTimers()
localStorage.clear()
}
})
})
describe('currency correctness', () => {
const USD = { code: 'USD', symbol: '$', rate: 1 }
const EUR = { code: 'EUR', symbol: '€', rate: 0.9 }
beforeEach(() => {
installDefaultMocks()
// Reset the module-level display currency so a prior test never bleeds in.
setActiveCurrency(USD)
localStorage.clear()
// Pin the boot period so the memo keys below match the app's boot fetch,
// independent of the app-wide default ('today').
localStorage.setItem('codeburn.defaultPeriod', '30days')
__resetPolledMemo()
})
it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => {
const usd = { ...overviewPayload(), currency: USD }
// A stale EUR payload cached for `claude`, as if warmed before a currency
// change. The claude fetch is left pending so `switching` stays true and the
// memo-served EUR payload is what's on screen during the assertion window.
const eur = { ...overviewPayload(), currency: EUR }
mocks.getOverview.mockImplementation((_period: string, provider: string) =>
provider === 'claude' ? new Promise<MenubarPayload>(() => {}) : Promise.resolve(usd))
primePolledMemo(overviewMemoKey('claude', '30days', null, null), eur)
render(<App />)
// Boot on the USD ('all') view.
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/€/)).not.toBeInTheDocument()
// Switch to claude: usePolled paints the memoized EUR payload (switching) while
// its fresh fetch hangs. The currency effect must NOT apply that stale EUR.
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
expect(screen.queryByText(/€/)).not.toBeInTheDocument()
})
it('clears the instant-switch memo and force-refreshes when currency is reset', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
// A warmed entry (as the prefetcher would leave one) that must be purged so a
// later switch can't repaint a payload computed under the old currency.
primePolledMemo('sentinel-warmed-key', { stale: true })
expect(hasPolledMemo('sentinel-warmed-key')).toBe(true)
fireEvent.keyDown(document, { key: ',', metaKey: true })
const overviewCalls = mocks.getOverview.mock.calls.length
fireEvent.click(await screen.findByRole('button', { name: 'Reset to USD' }))
await waitFor(() => expect(mocks.resetCurrency).toHaveBeenCalled())
// Memo purged and the active view force-refreshed so the new currency lands fast.
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
expect(hasPolledMemo('sentinel-warmed-key')).toBe(false)
})
})
describe('usage_snapshot telemetry props', () => {
// The renderer builds these props; the main-process sanitizer (sanitizeProps)
// is the last gate before the wire. Test the composition, which is what ships.
function enrichedPayload(): MenubarPayload {
const p = overviewPayload()
p.current.cost = 42
p.current.topModels = [
{ name: 'claude-opus-4-8', cost: 30, savingsUSD: 0, savingsBaselineModel: '', calls: 400 },
{ name: 'M'.repeat(80), cost: 0.5, savingsUSD: 0, savingsBaselineModel: '', calls: 2 },
]
p.current.topActivities = [
{ name: 'coding', cost: 20, savingsUSD: 0, turns: 100, oneShotRate: 0.6123 },
{ name: 'debugging', cost: 10, savingsUSD: 0, turns: 40, oneShotRate: null },
]
p.current.mcpServers = [
{ name: 'context7', calls: 5 },
{ name: 'S'.repeat(80), calls: 250 },
{ name: 'shadcn', calls: 1500 },
]
p.current.skills = [
{ name: 'graphify', turns: 3, cost: 0 },
{ name: 'council', turns: 150, cost: 0 },
]
// A path-like project name that MUST NEVER reach telemetry: the snapshot never
// reads topProjects, and this guards against a future field accidentally doing so.
p.current.topProjects = [{
name: '/Users/torukmakto/secret-client/private-repo',
cost: 42, savingsUSD: 0, sessions: 1, avgCostPerSession: 42, sessionDetails: [],
}]
return p
}
it('includes MCP servers and skills as names + bucketed usage', () => {
const props = sanitizeProps(usageSnapshotProps(enrichedPayload()))
const mcp = props.mcpServers as Array<{ name: string; callBucket: string }>
expect(mcp.map(m => [m.name.slice(0, 8), m.callBucket])).toEqual([
['context7', '1-10'],
['SSSSSSSS', '100-1k'],
['shadcn', '1k+'],
])
const skills = props.skills as Array<{ name: string; callBucket: string }>
// Skills are measured in turns; buckets mirror the count scale.
expect(skills).toEqual([
{ name: 'graphify', callBucket: '1-10' },
{ name: 'council', callBucket: '100-1k' },
])
})
it('truncates over-long names at the 64-char sanitizer cap', () => {
const props = sanitizeProps(usageSnapshotProps(enrichedPayload()))
const mcp = props.mcpServers as Array<{ name: string }>
const models = props.models as Array<{ name: string }>
expect(mcp[1]!.name.length).toBe(64)
expect(models[1]!.name.length).toBe(64)
})
it('never leaks a filesystem path or project name', () => {
const serialized = JSON.stringify(sanitizeProps(usageSnapshotProps(enrichedPayload())))
expect(serialized).not.toContain('/Users/')
expect(serialized).not.toContain('secret-client')
expect(serialized).not.toContain('private-repo')
})
function modelRow(over: Partial<ModelReportRow> & Pick<ModelReportRow, 'model' | 'modelDisplayName'>): ModelReportRow {
return {
provider: 'claude', providerDisplayName: 'Claude', category: null,
inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, totalTokens: 0,
costUSD: 0, savingsUSD: 0, savingsBaselineModel: '', calls: 0, credits: null, ...over,
}
}
it('caps providers at 8, sorted by cost descending, bucketed', () => {
const p = enrichedPayload()
p.current.providers = {
claude: 500, codex: 40, gemini: 5, cursor: 0.5, antigravity: 300,
copilot: 20, windsurf: 2, amp: 0.1, cline: 0.02,
}
const props = sanitizeProps(usageSnapshotProps(p))
const providers = props.providers as Array<{ name: string; costBucket: string }>
expect(providers).toHaveLength(8)
expect(providers).toEqual([
{ name: 'claude', costBucket: '200-1k' },
{ name: 'antigravity', costBucket: '200-1k' },
{ name: 'codex', costBucket: '10-50' },
{ name: 'copilot', costBucket: '10-50' },
{ name: 'gemini', costBucket: '1-10' },
{ name: 'windsurf', costBucket: '1-10' },
{ name: 'cursor', costBucket: '<1' },
{ name: 'amp', costBucket: '<1' },
])
})
it('crosses each top model with its dominant task category when the report joins', () => {
// The overview model name is the display/short name; for Claude that equals
// modelDisplayName, which is how the by-model report row joins back.
const rows = [
modelRow({ model: 'claude-opus-4-20260101', modelDisplayName: 'claude-opus-4-8', topCategory: 'coding' }),
]
const props = sanitizeProps(usageSnapshotProps(enrichedPayload(), topCategoryByModel(rows)))
const models = props.models as Array<Record<string, unknown>>
expect(models[0]).toEqual({ name: 'claude-opus-4-8', costBucket: '10-50', topCategory: 'coding' })
// A model the report has no category for carries name + costBucket only, never a fabricated cross.
expect(Object.keys(models[1]!).sort()).toEqual(['costBucket', 'name'])
})
it('still emits a valid snapshot without topCategory when the by-model fetch fails', () => {
// The graceful-degradation path: usageSnapshotProps is called with no category map.
const props = sanitizeProps(usageSnapshotProps(enrichedPayload()))
const models = props.models as Array<Record<string, unknown>>
for (const m of models) expect(Object.keys(m).sort()).toEqual(['costBucket', 'name'])
// Everything else the snapshot carries is intact.
expect((props.mcpServers as unknown[]).length).toBe(3)
expect((props.skills as unknown[]).length).toBe(2)
expect(props.categories).toEqual([
{ name: 'coding', oneShotRate: 0.61 },
{ name: 'debugging', oneShotRate: -1 },
])
})
})