mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-01 20:35:09 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
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>
768 lines
34 KiB
TypeScript
768 lines
34 KiB
TypeScript
// @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 },
|
||
])
|
||
})
|
||
})
|