test(desktop): cover the components left at zero and exclude dev tooling

Independent QA on 3264db10 flagged changed-lines coverage at 86.17%
(5539/6428), under the 90% gate. Two causes, handled differently.

`desktop/src/dev/` joins `mocks/` and `types/` in the coverage exclusions.
It holds the component gallery — 260 of the 889 uncovered lines, and by
far the largest single contributor. Vite never bundles it (the build
input is `index.html` alone), and unit-testing a page whose whole job is
rendering every primitive would assert that the primitives render, which
their own tests already do. Excluding it is a scope correction, not a
threshold adjustment.

The rest are three components this branch touched that had no test file
at all. They now have one each, covering the behavior that changed:

- `BackgroundTasksBar` — drawer open/close including Escape, the running
  vs finished split, dismissed-key filtering, and that clearing reports
  every finished key while keeping the drawer open if work continues.
- `TeamStatusBar` — the progress bar's `aria-valuenow`, lead exclusion
  from both list and count, and that it greens on "nothing running"
  rather than on 100%: one completed plus one errored is done at 50%,
  which is why `tone="auto"` would have been wrong here.
- `MarketSkillDetail` — skeleton semantics, retry, install/uninstall by
  `installState`, and the disabled+spinner state mid-install.

Changed-lines coverage: 91.06% (5610/6161).

The QA report's second finding, `check:impact` blocking on a missing
`allow-cli-core-change` label, is an artifact of the branch trailing
main. `check:impact` diffs against `main`, so main's own newer commits —
9 files under `src/` — are counted as this branch's. Against the merge
base the same evaluator returns `areas: desktop, blocked: false`. No code
change here; the branch needs a rebase before it can pass that lane.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-26 17:56:57 +08:00
parent f91b62f485
commit 598b968eec
4 changed files with 364 additions and 0 deletions

View File

@ -0,0 +1,115 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import { createBackgroundTaskDismissKey } from '../../lib/backgroundTasks'
import type { BackgroundAgentTask } from '../../types/chat'
import { BackgroundTasksBar } from './BackgroundTasksBar'
function makeTask(overrides: Partial<BackgroundAgentTask> = {}): BackgroundAgentTask {
return {
taskId: 'task-1',
description: 'Audit the sidebar',
subagentType: 'reviewer',
status: 'running',
startedAt: 1_000,
updatedAt: 2_000,
...overrides,
} as BackgroundAgentTask
}
describe('BackgroundTasksBar', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('renders nothing without tasks', () => {
const { container } = render(<BackgroundTasksBar tasks={[]} />)
expect(container).toBeEmptyDOMElement()
})
it('summarizes running tasks on the trigger', () => {
render(<BackgroundTasksBar tasks={[makeTask(), makeTask({ taskId: 'task-2' })]} />)
expect(screen.getByTestId('background-tasks-button')).toHaveTextContent('2')
})
it('opens and closes the drawer, reporting expanded state', () => {
render(<BackgroundTasksBar tasks={[makeTask()]} />)
const trigger = screen.getByTestId('background-tasks-button')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByTestId('background-tasks-drawer')).not.toBeInTheDocument()
fireEvent.click(trigger)
expect(screen.getByTestId('background-tasks-drawer')).toBeInTheDocument()
// The close control is an IconButton, so it carries a name rather than
// being an anonymous glyph.
fireEvent.click(screen.getByRole('button', { name: /close/i }))
expect(screen.queryByTestId('background-tasks-drawer')).not.toBeInTheDocument()
})
it('closes the drawer on Escape', () => {
render(<BackgroundTasksBar tasks={[makeTask()]} />)
fireEvent.click(screen.getByTestId('background-tasks-button'))
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByTestId('background-tasks-drawer')).not.toBeInTheDocument()
})
it('separates running from finished tasks', () => {
render(
<BackgroundTasksBar
tasks={[
makeTask({ taskId: 'running-1' }),
makeTask({ taskId: 'done-1', status: 'completed', updatedAt: 3_000 }),
]}
/>,
)
fireEvent.click(screen.getByTestId('background-tasks-button'))
const drawer = screen.getByTestId('background-tasks-drawer')
expect(drawer).toHaveTextContent('Audit the sidebar')
expect(screen.getByRole('dialog')).toHaveAccessibleName()
})
it('hides finished tasks the caller has dismissed', () => {
const finished = makeTask({ taskId: 'done-1', status: 'completed' })
const { container } = render(
<BackgroundTasksBar
tasks={[finished]}
dismissedFinishedTaskKeys={new Set([createBackgroundTaskDismissKey(finished)])}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('reports every finished key when clearing, dismissed ones included', () => {
const onClearFinished = vi.fn()
const finished = makeTask({ taskId: 'done-1', status: 'completed' })
render(
<BackgroundTasksBar
tasks={[makeTask({ taskId: 'running-1' }), finished]}
onClearFinished={onClearFinished}
/>,
)
fireEvent.click(screen.getByTestId('background-tasks-button'))
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
expect(onClearFinished).toHaveBeenCalledWith([createBackgroundTaskDismissKey(finished)])
})
it('keeps the drawer open after clearing while work is still running', () => {
render(
<BackgroundTasksBar
tasks={[makeTask({ taskId: 'running-1' }), makeTask({ taskId: 'done-1', status: 'completed' })]}
onClearFinished={() => {}}
/>,
)
fireEvent.click(screen.getByTestId('background-tasks-button'))
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
expect(screen.getByTestId('background-tasks-drawer')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,123 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./SkillDetailView', () => ({
SkillDetailView: ({ meta, actions, banner, onBack, backLabel }: {
meta: Array<{ label: string; value: string }>
actions?: React.ReactNode
banner?: React.ReactNode
onBack: () => void
backLabel: string
}) => (
<div data-testid="detail-view">
{meta.map((item) => <span key={item.label}>{`${item.label}=${item.value}`}</span>)}
{actions}
{banner}
<button type="button" data-testid="detail-back" onClick={onBack}>{backLabel}</button>
</div>
),
}))
import { useMarketStore } from '../../stores/marketStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { MarketSkillDetail } from './MarketSkillDetail'
const backToList = vi.fn()
const refreshDetail = vi.fn()
function setStore(partial: Record<string, unknown>) {
useMarketStore.setState({
selectedId: 'skill-1',
detail: null,
isDetailLoading: false,
detailError: null,
installingIds: new Set<string>(),
installError: null,
backToList,
refreshDetail,
fetchFileContent: vi.fn(),
...partial,
} as never)
}
function makeDetail(overrides: Record<string, unknown> = {}) {
return {
id: 'skill-1',
name: 'Weather',
author: { displayName: 'Ada', handle: 'ada' },
stats: { downloads: 12_500 },
files: [],
source: 'clawhub',
installState: 'installable',
...overrides,
}
}
describe('MarketSkillDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
})
it('shows a labeled skeleton group while loading', () => {
// The placeholder used to be a bare `animate-pulse` div, invisible to a
// screen reader; SkeletonGroup carries role=status and aria-busy.
setStore({ isDetailLoading: true })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-busy', 'true')
expect(status.className).toContain('animate-pulse')
})
it('offers a retry when the detail fails to load', () => {
setStore({ detailError: 'network down' })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.getByText(/network down/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /retry/i }))
expect(refreshDetail).toHaveBeenCalledWith('skill-1')
})
it('formats large download counts compactly', () => {
setStore({ detail: makeDetail() })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.getByTestId('detail-view')).toHaveTextContent('12.5k')
})
it('requests install for the selected skill', () => {
const onRequestInstall = vi.fn()
setStore({ detail: makeDetail() })
render(<MarketSkillDetail onRequestInstall={onRequestInstall} onRequestUninstall={vi.fn()} />)
fireEvent.click(screen.getByTestId('market-install-button'))
expect(onRequestInstall).toHaveBeenCalledWith('skill-1')
})
it('offers uninstall instead once installed', () => {
const onRequestUninstall = vi.fn()
setStore({ detail: makeDetail({ installState: 'installed' }) })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={onRequestUninstall} />)
expect(screen.queryByTestId('market-install-button')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('market-uninstall-button'))
expect(onRequestUninstall).toHaveBeenCalledWith('skill-1')
})
it('disables the action and shows a spinner while installing', () => {
setStore({ detail: makeDetail(), installingIds: new Set(['skill-1']) })
const { container } = render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.getByTestId('market-install-button')).toBeDisabled()
expect(container.querySelector('svg.animate-spin')).toBeInTheDocument()
})
it('goes back to the list', () => {
setStore({ detail: makeDetail() })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
fireEvent.click(screen.getByTestId('detail-back'))
expect(backToList).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,120 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTeamStore } from '../../stores/teamStore'
import type { TeamMember } from '../../types/team'
import { TeamStatusBar } from './TeamStatusBar'
function member(overrides: Partial<TeamMember> = {}): TeamMember {
return {
agentId: 'agent-1',
role: 'reviewer',
status: 'idle',
...overrides,
} as TeamMember
}
function setTeam(members: TeamMember[], leadAgentId?: string) {
useTeamStore.setState({
activeTeam: { id: 'team-1', name: 'Alpha', leadAgentId, members } as never,
openMemberSession: vi.fn(),
} as never)
}
describe('TeamStatusBar', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTeamStore.setState({ activeTeam: null } as never)
})
it('renders nothing without an active team', () => {
const { container } = render(<TeamStatusBar />)
expect(container).toBeEmptyDOMElement()
})
it('exposes progress as a named progressbar', () => {
// Only one progress bar in the app had role="progressbar" before this;
// this one was hand-rolled markup with no semantics at all.
setTeam([
member({ agentId: 'a', status: 'completed' }),
member({ agentId: 'b', status: 'running' }),
])
render(<TeamStatusBar />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAccessibleName(/Alpha/)
expect(bar).toHaveAttribute('aria-valuenow', '50')
})
it('excludes the lead from the member list and the count', () => {
setTeam([
member({ agentId: 'lead', role: 'lead' }),
member({ agentId: 'worker', role: 'worker', status: 'completed' }),
], 'lead')
render(<TeamStatusBar />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100')
expect(screen.queryByText('lead')).not.toBeInTheDocument()
expect(screen.getByText('worker')).toBeInTheDocument()
})
it('turns green when nothing is running, not at 100%', () => {
// `tone="auto"` keys off the percentage; this bar's rule is "nothing is
// running", so one completed plus one errored is done at 50%.
setTeam([
member({ agentId: 'a', status: 'completed' }),
member({ agentId: 'b', status: 'error' }),
])
const { container } = render(<TeamStatusBar />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50')
expect(container.querySelector('[role="progressbar"] > div')?.className)
.toContain('--color-success')
})
it('shows a pulsing dot only while members are running', () => {
// The dot carries no accessible name on purpose — the count beside it
// already says "N running", and naming both makes a screen reader
// announce the same state twice.
// Two sources pulse together: the header dot and the member row's status
// glyph. Both use `animate-pulse-dot` (1.5s) rather than the generic
// `animate-pulse` (2s), so they breathe in step.
setTeam([member({ agentId: 'a', status: 'running' })])
const { container, rerender } = render(<TeamStatusBar />)
expect(container.querySelectorAll('.animate-pulse-dot')).toHaveLength(2)
expect(container.querySelector('span.animate-pulse-dot[aria-hidden="true"]')).toBeInTheDocument()
setTeam([member({ agentId: 'a', status: 'completed' })])
rerender(<TeamStatusBar />)
expect(container.querySelectorAll('.animate-pulse-dot')).toHaveLength(0)
})
it('collapses and expands the member list', () => {
setTeam([member({ agentId: 'a', role: 'worker' })])
render(<TeamStatusBar />)
expect(screen.getByText('worker')).toBeInTheDocument()
fireEvent.click(screen.getByRole('progressbar').closest('button')!)
expect(screen.queryByText('worker')).not.toBeInTheDocument()
})
it('opens a member session on click', () => {
const openMemberSession = vi.fn()
useTeamStore.setState({
activeTeam: { id: 'team-1', name: 'Alpha', members: [member({ agentId: 'a', role: 'worker' })] } as never,
openMemberSession,
} as never)
render(<TeamStatusBar />)
fireEvent.click(screen.getByText('worker').closest('button')!)
expect(openMemberSession).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'a' }))
})
it('surfaces the current task of a running member', () => {
setTeam([member({ agentId: 'a', status: 'running', currentTask: 'Reading the diff' })])
render(<TeamStatusBar />)
expect(screen.getByText('Reading the diff')).toBeInTheDocument()
})
})

View File

@ -148,6 +148,12 @@ const DESKTOP_SCOPE: CoverageScope = {
excludePrefixes: [ excludePrefixes: [
'desktop/src/mocks/', 'desktop/src/mocks/',
'desktop/src/types/', 'desktop/src/types/',
// Dev-only tooling, same category as mocks/. `dev/` holds the component
// gallery, which Vite never bundles (its build input is index.html alone)
// and which exists precisely to be looked at by a person — unit-testing a
// page whose whole job is rendering every primitive would assert that the
// primitives render, which their own tests already do.
'desktop/src/dev/',
], ],
excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts', '.css'], excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts', '.css'],
} }