codeburn/app/renderer/App.test.tsx
Resham Joshi a9d4471d17
Some checks are pending
CI / semgrep (push) Waiting to run
payload: spend by pull request and by branch; desktop Pull requests tab (#788)
Part 1 (src): expose two add-only, optional aggregations on the menubar
payload's `current`, computed on the unscoped all-provider path from the
surviving-session parse (carried history cannot contribute, as expected).

- current.pullRequests: aggregateByPr rows (top 20 by cost) plus the
  multi-link-safe distinctCost/distinctSessions. Rows are by-reference, so
  they are never summed.
- current.byBranch: new aggregateByBranch, per-branch spend (top 15 by cost)
  that carries each session's last-seen git branch forward across its turns.
  The cache stores a turn's branch only when it changes, so the parser now
  resolves the branch at reconstruction (before the date slice) and records
  SessionSummary.everHadBranch from the full transcript. That lets the report
  keep a branch-bearing session's pre-branch spend in an explicit null row
  even when the range clipped the anchor turn, while a provider that never
  captures a branch still contributes nothing.

Part 2 (app): new "Pull requests" sidebar entry between Sessions and Spend
(shortcuts reflow to number 8 for Plans). It renders the pullRequests rows as
a refined table with the PR label linking out via openExternal, cost,
sessions, calls and an active-date span, plus a footnote stating the distinct
total and the by-reference attribution. A quiet explanatory line shows when no
PR links exist, never a fake table.

Tests: aggregateByBranch unit tests (carry-forward, null bucket, clipped
anchor, by-reference session counts) and the Pull requests component tests
(table, external link, footnote, both empty states). Nav-reflow assertions in
Sidebar and App tests updated.

Co-authored-by: reviewer <review@local>
2026-07-20 14:36:53 -07:00

768 lines
34 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) => 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('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 },
])
})
})