codeburn/app/renderer/components/Sidebar.test.tsx
Resham Joshi ea78d36ccf
Some checks are pending
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
CI / semgrep (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Windows menu bar and Capacity Dock as a Plugins card, plus desktop fixes (#1507)
* feat(windows): configure the menu bar and Capacity Dock as a Plugins card

Windows now configures its tray companion from a card on the Plugins page, the
same place macOS does, instead of two switches in the sidebar corner.

- WindowsCompanionCard mirrors the macOS MenuBarCard: Install/Reinstall,
  Update, Open (show the tray popover), Settings (opens the desktop Menu bar
  settings pane), Quit (stop without uninstalling), Uninstall, plus the
  Capacity Dock toggle, a Running dot, the version, and an info popup.
- MenubarCompanion.status() now reports installed/running/version/outdated/
  canInstall, and new discrete install/open/quit/uninstall actions back the
  card. uninstall only forgets the tray once the CLI removal succeeds, and a
  cancelled elevation prompt is treated as a no-op rather than a failure.
- isNewerVersion sorts a dev build below any release, so a staged release still
  lights Update and a dev build never does.
- codeburn menubar --uninstall reads the product code from the install marker
  or the uninstall registry and runs msiexec /x.
- Remove the bespoke sidebar CompanionSwitches and their dead CSS.
- A dev-only CODEBURN_MENUBAR_RESOURCES override lets the tray install run
  without packaging; the CLI still validates the staged path in full.

* feat(desktop): fold the Capacity Dock settings into the Menu bar pane

One tray rail entry instead of two: MenuBarPane and CapacityDockPane render
together in the Menu bar pane with a clear gap between the two cards, so the
card's Settings button opens both. Drops the now-unreachable 'dock' pane value.

* fix(desktop): stop the Spend cost-flow Sankey from re-rendering forever

The label-fit effect measured the already-truncated text, so a capped label
read as short, un-capped, overflowed the cap, and re-capped without end
(Maximum update depth exceeded). It now estimates each label from its full
name via data-name, and caps only tighten per layout, so the measure, cap and
remeasure cycle is guaranteed to settle instead of oscillating.

* feat(desktop): bundle Inter and JetBrains Mono for one UI on every OS

The app used each platform's system font (SF Pro on macOS, Segoe UI on
Windows), so the two looked different. Bundle Inter for text and JetBrains
Mono for figures and lead the --sans and --mono stacks with them, keeping the
per-locale CJK fallbacks, so Windows and macOS render the same faces.

* desktop: gate tray installed state on support; drop strings and channels the sidebar switches left behind
2026-09-19 18:43:27 -07:00

147 lines
6.3 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from './Sidebar'
// The preload bridge is read once at module load, so the switches need it mocked rather than
// assigned onto `window` after the fact.
const bridge = vi.hoisted(() => ({
companionStatus: vi.fn(),
openExternal: vi.fn(),
}))
vi.mock('../lib/ipc', () => ({ codeburn: bridge, normalizeCliError: (err: unknown) => err }))
// This jsdom setup ships no Storage, and the sidebar's collapsed state is read
// from one at first render.
const store = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => { store.set(key, value) },
removeItem: (key: string) => { store.delete(key) },
clear: () => store.clear(),
})
function setPlatform(platform: string): void {
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
}
describe('Sidebar', () => {
beforeEach(() => {
bridge.companionStatus.mockResolvedValue({ supported: false, menuBar: false, sidebar: false, store: false })
})
afterEach(() => {
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
store.clear()
vi.clearAllMocks()
})
it.each([
['darwin', '⌘'],
['win32', 'Ctrl+'],
] as const)('renders every nav item in its group with %s keycaps', (platform, mod) => {
setPlatform(platform)
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
const labels = [...container.querySelectorAll('.ni')].map(item => item.textContent)
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Models', 'Optimize', 'Compare', 'Compare periods', 'Plans', 'Plugins', 'Settings'])
const tip = (label: string) => [...container.querySelectorAll('.ni')].find(item => item.textContent === label)?.getAttribute('title')
expect(tip('Sessions')).toBe(`Sessions ${mod}2`)
expect(tip('Pull requests')).toBe(`Pull requests ${mod}3`)
expect(tip('Compare')).toBe(`Compare ${mod}7`)
expect(tip('Plans')).toBe(`Plans ${mod}8`)
})
it('calls onNavigate with the section id when a nav item is clicked', () => {
const onNavigate = vi.fn()
render(<Sidebar active="overview" onNavigate={onNavigate} />)
fireEvent.click(screen.getByRole('button', { name: /Spend/ }))
expect(onNavigate).toHaveBeenCalledWith('spend')
})
it('marks the active item with the "on" class', () => {
render(<Sidebar active="models" onNavigate={() => {}} />)
expect(screen.getByRole('button', { name: /Models/ })).toHaveClass('on')
expect(screen.getByRole('button', { name: /Overview/ })).not.toHaveClass('on')
})
it('renders the wordmark as animated text with no flame image', () => {
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
const mark = container.querySelector('.app b')
expect(mark).toHaveClass('flame-text')
expect(mark).toHaveTextContent('CodeBurn')
expect(container.querySelector('.app img')).toBeNull()
})
it('groups the nav under muted section labels', () => {
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
expect([...container.querySelectorAll('.grp-label')].map(el => el.textContent)).toEqual(['Usage', 'Insight', 'Account'])
})
it('keeps the shortcut on the row tooltip rather than a chip in the row', () => {
setPlatform('darwin')
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
expect(container.querySelector('.ni .k')).toBeNull()
const spend = [...container.querySelectorAll('.ni')].find(item => item.textContent === 'Spend')
expect(spend).toHaveAttribute('title', 'Spend ⌘4')
expect(spend).toHaveAttribute('data-tip', 'Spend ⌘4')
})
it('carries About and the version in the corner, with the links in the modal', async () => {
setPlatform('darwin')
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
const about = screen.getByRole('link', { name: /About/ })
expect(about).toHaveTextContent(/^Aboutv\d+\.\d+\.\d+$/)
expect(container.querySelector('.foot .social')).toBeNull()
fireEvent.click(about)
expect(await screen.findByRole('link', { name: /GitHub/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /LinkedIn/ })).toBeInTheDocument()
})
it('collapses to a rail, remembers it, and still navigates by icon', () => {
const onNavigate = vi.fn()
const { container, unmount } = render(<Sidebar active="overview" onNavigate={onNavigate} />)
const nav = container.querySelector('.sb')
expect(nav).not.toHaveClass('collapsed')
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(nav).toHaveClass('collapsed')
expect(localStorage.getItem('codeburn.sidebarCollapsed')).toBe('1')
// The label never leaves the DOM, so the row keeps its name on the rail.
fireEvent.click(screen.getByRole('button', { name: /Spend/ }))
expect(onNavigate).toHaveBeenCalledWith('spend')
unmount()
render(<Sidebar active="overview" onNavigate={() => {}} />)
expect(document.querySelector('.sb')).toHaveClass('collapsed')
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument()
})
it.each([
['darwin', { metaKey: true }],
['win32', { ctrlKey: true }],
] as const)('toggles the rail with the %s modifier chord and B', (platform, chord) => {
setPlatform(platform)
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
const nav = container.querySelector('.sb')
fireEvent.keyDown(window, { key: 'b', ...chord })
expect(nav).toHaveClass('collapsed')
fireEvent.keyDown(window, { key: 'b', ...chord })
expect(nav).not.toHaveClass('collapsed')
})
// The Menu bar and Capacity Dock switches moved out of the sidebar corner into a card on the
// Plugins page, so the sidebar no longer renders any switch. See WindowsCompanionCard.test.tsx.
it('renders no companion switch in the corner on Windows', async () => {
setPlatform('win32')
render(<Sidebar active="overview" onNavigate={() => {}} />)
expect(screen.getByRole('link', { name: /About/ })).toBeInTheDocument()
expect(screen.queryByRole('switch')).toBeNull()
})
})