// @vitest-environment jsdom import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types' import { Settings } from './Settings' const mocks = vi.hoisted(() => ({ getIdentity: vi.fn<() => Promise>(), getDevices: vi.fn<(period: string) => Promise>(), getDevicesScan: vi.fn<() => Promise>(), getShareStatus: vi.fn<() => Promise>(), getQuota: vi.fn<(force?: boolean) => Promise>(), getPlans: vi.fn<(period: string) => Promise>(), getOverview: vi.fn<(period: string, provider: string) => Promise>(), getAliases: vi.fn<() => Promise>(), getPriceOverrides: vi.fn<() => Promise>(), setPriceOverride: vi.fn<(model: string, rates: PriceRates) => Promise>(), removePriceOverride: vi.fn<(model: string) => Promise>(), setCurrency: vi.fn<(code: string) => Promise>(), resetCurrency: vi.fn<() => Promise>(), addAlias: vi.fn<(from: string, to: string) => Promise>(), removeAlias: vi.fn<(from: string) => Promise>(), removeDevice: vi.fn<(name: string) => Promise>(), setPlan: vi.fn<(id: string, provider: string) => Promise>(), resetPlan: vi.fn<(provider: string) => Promise>(), chooseDirectory: vi.fn<() => Promise>(), exportData: vi.fn<(format: string, provider: string, path: string) => Promise>(), })) vi.mock('../lib/ipc', async orig => { const actual = await orig() return { ...actual, codeburn: mocks } }) const identity: Identity = { name: 'Toruk MacBook Pro', fingerprint: 'AA:11:22:33:44:55:66:77' } const actionOk: ActionResult = { ok: true, stdout: 'updated', stderr: '', code: 0 } const devices: CombinedUsage = { perDevice: [ { id: 'local', name: 'Toruk MacBook Pro', local: true, cost: 120.1, calls: 100, sessions: 10, inputTokens: 1, outputTokens: 2, cacheCreateTokens: 3, cacheReadTokens: 4, totalTokens: 10 }, { id: 'mini', name: 'toruk-mini', local: false, cost: 41.2, calls: 680, sessions: 34, inputTokens: 11, outputTokens: 12, cacheCreateTokens: 13, cacheReadTokens: 14, totalTokens: 50 }, ], combined: { cost: 161.3, calls: 780, sessions: 44, inputTokens: 12, outputTokens: 14, cacheCreateTokens: 16, cacheReadTokens: 18, totalTokens: 60, deviceCount: 2, reachableCount: 2 }, } const scan: DeviceScanResult = { found: [{ name: 'Mac Studio', host: 'mac-studio.local', port: 9732, fingerprint: '7F:2A:19:88:55:44:33:C4', code: 'pair-1', paired: false }] } const overview = { current: { providers: { claude: 12.34, codex: 4.5 } } } as unknown as MenubarPayload const quotaProviders: QuotaProvider[] = [ { provider: 'claude', connection: 'connected', primary: null, details: [], planLabel: 'Max 20x', footerLines: [] }, { provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] }, ] const stored = new Map() vi.stubGlobal('localStorage', { getItem: (key: string) => stored.get(key) ?? null, setItem: (key: string, value: string) => stored.set(key, value), clear: () => stored.clear(), }) describe('Settings', () => { beforeEach(() => { Object.values(mocks).forEach(mock => mock.mockReset()) mocks.getIdentity.mockResolvedValue(identity) mocks.getDevices.mockResolvedValue(devices) mocks.getDevicesScan.mockResolvedValue(scan) mocks.getShareStatus.mockResolvedValue({ sharing: true, name: 'Toruk MacBook Pro', port: 9732, always: false, peers: 1, pending: [] }) mocks.getQuota.mockResolvedValue(quotaProviders) mocks.getPlans.mockResolvedValue({ currency: 'EUR', today: { cost: 0, savings: 0, calls: 0 }, month: { cost: 0, savings: 0, calls: 0 }, plans: { claude: { id: 'claude-max', provider: 'claude', budget: 200, spent: 48, percentUsed: 24, status: 'under', projectedMonthEnd: 120, daysUntilReset: 19, periodStart: '2026-07-01', periodEnd: '2026-08-01' } } }) mocks.getOverview.mockResolvedValue(overview) mocks.getAliases.mockResolvedValue([{ from: 'proxy-opus', to: 'claude-opus-4-6' }]) mocks.getPriceOverrides.mockResolvedValue({ overrides: [{ model: 'local/llama', inputPerM: 0.2, outputPerM: 0.6, cacheReadPerM: 0.05 }], configPath: '/home/user/.config/codeburn/config.json' }) mocks.setPriceOverride.mockResolvedValue(actionOk) mocks.removePriceOverride.mockResolvedValue(actionOk) mocks.setCurrency.mockResolvedValue(actionOk) mocks.resetCurrency.mockResolvedValue(actionOk) mocks.addAlias.mockResolvedValue(actionOk) mocks.removeAlias.mockResolvedValue(actionOk) mocks.removeDevice.mockResolvedValue(actionOk) mocks.setPlan.mockResolvedValue(actionOk) mocks.resetPlan.mockResolvedValue(actionOk) mocks.chooseDirectory.mockResolvedValue('/Users/toruk/Exports') mocks.exportData.mockResolvedValue(actionOk) localStorage.clear() document.documentElement.removeAttribute('data-theme') }) it('switches panes from the rail and renders the completed Plans pane', async () => { const user = userEvent.setup() render() expect(screen.getByRole('heading', { name: 'General' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Plans' })) expect(screen.getByRole('heading', { name: 'Plans' })).toBeInTheDocument() expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0) }) it('shows current currency and sends currency changes to the CLI', async () => { const user = userEvent.setup() render() const currency = await screen.findByLabelText('Currency') expect(currency).toHaveTextContent('EUR') await user.click(currency) expect(screen.getByRole('option', { name: 'CZK' })).toBeInTheDocument() await user.click(screen.getByRole('option', { name: 'CNY' })) expect(mocks.setCurrency).toHaveBeenCalledWith('CNY') expect(await screen.findByText('Updated')).toBeInTheDocument() }) it('persists theme choices and applies forced themes to the root', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Dark' })) expect(document.documentElement).toHaveAttribute('data-theme', 'dark') expect(localStorage.getItem('codeburn.theme')).toBe('dark') await user.click(screen.getByRole('button', { name: 'System' })) expect(document.documentElement).not.toHaveAttribute('data-theme') }) it('shows the active Claude config as a read-only line in General when multiple configs exist', async () => { render() expect(await screen.findByText('Claude config')).toBeInTheDocument() expect(screen.getByText('Claude Desktop')).toBeInTheDocument() expect(screen.getByText('Applies to the overview data. Manage config folders with the codeburn CLI.')).toBeInTheDocument() }) it('omits the Claude config line when no multi-config selector is present', async () => { render() expect(await screen.findByRole('heading', { name: 'General' })).toBeInTheDocument() expect(screen.queryByText('Claude config')).not.toBeInTheDocument() }) it('stores a positive daily budget from General', async () => { const user = userEvent.setup() render() await user.click(screen.getByLabelText('Daily budget')) await user.click(screen.getByRole('option', { name: 'USD amount' })) await user.type(screen.getByLabelText('Daily budget amount'), '25') expect(JSON.parse(localStorage.getItem('codeburn.dailyBudget')!)).toEqual({ kind: 'usd', value: 25 }) }) it('rejects a non-positive daily budget without persisting it', async () => { const user = userEvent.setup() render() await user.click(screen.getByLabelText('Daily budget')) await user.click(screen.getByRole('option', { name: 'Tokens' })) await user.type(screen.getByLabelText('Daily budget amount'), '-5') expect(screen.getByText('Enter a positive number.')).toBeInTheDocument() expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy() }) it('reflects the current scope and reports a change through onScopeChange', async () => { const user = userEvent.setup() const onScopeChange = vi.fn() render() const scope = screen.getByLabelText('Scope') expect(scope).toHaveTextContent('Local') await user.click(scope) await user.click(screen.getByRole('option', { name: 'Combined' })) expect(onScopeChange).toHaveBeenCalledWith('combined') }) it('lists providers from the real overview payload', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Providers' })) expect(await screen.findByText('Claude')).toBeInTheDocument() expect(screen.getByText('Detected · $12.34')).toBeInTheDocument() expect(screen.getByText('Codex')).toBeInTheDocument() expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all') }) it('keys provider logos on the internal id from providerDetails', async () => { mocks.getOverview.mockResolvedValue({ current: { providers: { 'grok build': 8.1, apex: 2.2 }, providerDetails: [ { id: 'grok', label: 'Grok Build', cost: 8.1 }, { id: 'apex', label: 'Apex', cost: 2.2 }, ], }, } as unknown as MenubarPayload) const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('button', { name: 'Providers' })) // grok has a themed mark, keyed on the internal id, so a real image renders. const grokRow = (await screen.findByText('Grok Build')).closest('.set-prov-head')! expect(grokRow.querySelector('img.provider-logo')).toBeInTheDocument() expect(grokRow.querySelector('.provider-mono')).toBeNull() // an unknown provider still renders a monogram badge, never nothing. const apexRow = screen.getByText('Apex').closest('.set-prov-head')! expect(apexRow.querySelector('span.provider-mono')).toHaveTextContent('A') expect(container.querySelector('.set-prov-head img[src="grok build"]')).toBeNull() }) it('lists, adds, and removes model aliases through the action bridge', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Model aliases' })) expect(await screen.findByText('proxy-opus')).toBeInTheDocument() expect(screen.getByText('claude-opus-4-6')).toBeInTheDocument() await user.type(screen.getByLabelText('Unrecognized model'), 'proxy-sonnet') await user.type(screen.getByLabelText('Priced model'), 'claude-sonnet-4-5') await user.click(screen.getByRole('button', { name: 'Add' })) expect(mocks.addAlias).toHaveBeenCalledWith('proxy-sonnet', 'claude-sonnet-4-5') await user.click(screen.getByRole('button', { name: 'Remove' })) expect(mocks.removeAlias).toHaveBeenCalledWith('proxy-opus') }) it('lists, adds, and removes price overrides through the action bridge', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Pricing' })) expect(await screen.findByText('local/llama')).toBeInTheDocument() expect(screen.getByText('in 0.2 · out 0.6 · read 0.05')).toBeInTheDocument() await user.type(screen.getByLabelText('Override model'), 'ollama/qwen') await user.type(screen.getByLabelText('Input rate'), '0.1') await user.type(screen.getByLabelText('Output rate'), '0.4') await user.click(screen.getByRole('button', { name: 'Add' })) // Only the two filled rates are sent; cache fields stay out of the payload. expect(mocks.setPriceOverride).toHaveBeenCalledWith('ollama/qwen', { input: 0.1, output: 0.4 }) await user.click(screen.getByRole('button', { name: 'Remove' })) await user.click(screen.getByRole('button', { name: 'Confirm' })) expect(mocks.removePriceOverride).toHaveBeenCalledWith('local/llama') }) it('rejects an invalid price rate client-side without calling the bridge', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Pricing' })) await user.type(screen.getByLabelText('Override model'), 'ollama/qwen') await user.type(screen.getByLabelText('Input rate'), '0') await user.type(screen.getByLabelText('Output rate'), '0.4') await user.click(screen.getByRole('button', { name: 'Add' })) expect(screen.getByText('Rates must be positive numbers (USD per 1M tokens).')).toBeInTheDocument() expect(mocks.setPriceOverride).not.toHaveBeenCalled() }) it('lists, removes, and adds plans through the action bridge', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Plans' })) expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0) expect(screen.getByText('$200.00/month · claude · 24% used')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Remove' })) await user.click(screen.getByRole('button', { name: 'Confirm' })) expect(mocks.resetPlan).toHaveBeenCalledWith('claude') await user.click(screen.getByLabelText('Add a plan')) await user.click(screen.getByRole('option', { name: 'Cursor Pro' })) await user.click(screen.getByRole('button', { name: 'Add' })) expect(mocks.setPlan).toHaveBeenCalledWith('cursor-pro', 'cursor') }) it('shows detected subscriptions with an auto-detected tier and a disconnected hint', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Plans' })) expect(await screen.findByText('Detected subscriptions')).toBeInTheDocument() expect(screen.getByText('Max 20x')).toBeInTheDocument() expect(screen.getByText('Not connected. Log in with the Codex CLI.')).toBeInTheDocument() expect(mocks.getQuota).toHaveBeenCalledWith(false) }) it('expands the DetectedRow Connect affordance and forces a keychain refresh', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Plans' })) await screen.findByText('Detected subscriptions') await user.click(screen.getByRole('button', { name: 'Connect' })) expect(screen.getByText('codex login')).toBeInTheDocument() mocks.getQuota.mockClear() await user.click(screen.getByRole('button', { name: 'Refresh' })) await waitFor(() => expect(mocks.getQuota).toHaveBeenCalledWith(true)) }) it('offers only non-OAuth budget presets; Claude and Codex are excluded', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Plans' })) await user.click(await screen.findByLabelText('Add a plan')) expect(screen.getByRole('option', { name: 'Cursor Pro' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'SuperGrok' })).toBeInTheDocument() expect(screen.queryByRole('option', { name: 'Claude Pro' })).not.toBeInTheDocument() expect(screen.queryByRole('option', { name: 'Claude Max 20x' })).not.toBeInTheDocument() expect(screen.queryByRole('option', { name: 'Claude Max 5x' })).not.toBeInTheDocument() }) it('still lists a configured Claude manual plan with Remove and a superseded note', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Plans' })) expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0) expect(screen.getByText('superseded by the detected subscription')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument() }) it('chooses an export folder and exports the selected format and provider', async () => { const user = userEvent.setup() render() await user.click(screen.getAllByRole('button', { name: 'Export' }).at(-1)!) await user.click(screen.getByRole('button', { name: 'Choose folder…' })) expect(mocks.chooseDirectory).toHaveBeenCalledOnce() expect(await screen.findByText('/Users/toruk/Exports')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'JSON' })) await user.click(screen.getByLabelText('Provider')) await user.click(screen.getByRole('option', { name: 'Claude' })) await user.click(screen.getAllByRole('button', { name: 'Export' }).at(-1)!) expect(mocks.exportData).toHaveBeenCalledWith('json', 'claude', '/Users/toruk/Exports') expect(await screen.findByText('Exported to /Users/toruk/Exports')).toBeInTheDocument() }) it('renders real device status and removes paired devices without fake pairing controls', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Devices' })) expect(await screen.findByText('Toruk MacBook Pro')).toBeInTheDocument() expect(screen.getByText('Local device name: Toruk MacBook Pro')).toBeInTheDocument() expect(await screen.findByText('Mac Studio')).toBeInTheDocument() expect(screen.getByText('fingerprint 7F:2A:…:C4')).toBeInTheDocument() expect(await screen.findByText('toruk-mini')).toBeInTheDocument() expect(screen.getByText('34 sessions · $41.20 this month')).toBeInTheDocument() expect(screen.getByText('Visible')).toBeInTheDocument() expect(screen.getByText(/Pairing is interactive/)).toBeInTheDocument() expect(screen.queryByText('Approve')).not.toBeInTheDocument() expect(screen.queryByText('Pull now')).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Remove' })) await user.click(screen.getByRole('button', { name: 'Confirm' })) expect(mocks.removeDevice).toHaveBeenCalledWith('toruk-mini') expect(screen.getByText('Combined view active · 2 devices')).toBeInTheDocument() }) it('excludes already-paired scans and renders empty device states', async () => { const user = userEvent.setup() mocks.getDevicesScan.mockResolvedValue({ found: [{ ...scan.found[0]!, paired: true }] }) mocks.getDevices.mockResolvedValue({ perDevice: [devices.perDevice[0]!], combined: { ...devices.combined, deviceCount: 1, reachableCount: 1 } }) render() await user.click(screen.getByRole('button', { name: 'Devices' })) expect(await screen.findByText('No nearby devices found.')).toBeInTheDocument() expect(screen.getByText('No paired devices yet.')).toBeInTheDocument() expect(screen.queryByText('Mac Studio')).not.toBeInTheDocument() }) it('renders not-found and permission states for device reads', async () => { const user = userEvent.setup() mocks.getIdentity.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) mocks.getDevicesScan.mockRejectedValue({ kind: 'nonzero', message: 'Cursor permission denied: Full Disk Access required' }) mocks.getDevices.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) render() await user.click(screen.getByRole('button', { name: 'Devices' })) await waitFor(() => expect(screen.getAllByText('Locate the codeburn CLI')).toHaveLength(2)) expect(screen.getByText('permission denied; grant Full Disk Access')).toHaveStyle({ color: 'var(--warn)' }) }) })