// @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() 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>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), 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() 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() 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() await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all')) }) it('boots to today when no default period is persisted', async () => { localStorage.removeItem('codeburn.defaultPeriod') render() await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all')) }) it('switches sections with command-number shortcuts', async () => { render() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() // 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() 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() 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 = {} 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() // 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() // 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(() => {}) : Promise.resolve(usd)) primePolledMemo(overviewMemoKey('claude', '30days', null, null), eur) render() // 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() 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 & Pick): 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> 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> 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 }, ]) }) })