test(automations): add coverage for virtualized run table

- Tests verify row content renders spend, tokens, and workspace labels correctly
- Keyboard navigation guards prevent operations during failed host reads
- Load-more pagination triggers at scroll end and respects page boundaries
- New fixtures support flexible automation run and usage test scenarios
This commit is contained in:
Jinjing
2026-09-15 17:26:20 -07:00
parent 6632a185dd
commit 6db175dc2c
3 changed files with 468 additions and 7 deletions
@@ -13,7 +13,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AutomationRun } from '../../../../shared/automations-types'
import { AutomationRunHistory } from './AutomationRunHistory'
import { makeRun } from './automations-page-fixtures'
import { WORKSPACE_ID, makeRun, makeRunUsage, makeWorktree } from './automations-page-fixtures'
vi.mock('@tanstack/react-virtual', async () => {
const { createVirtualizerStub } = await import('./virtualizer-test-stub')
@@ -270,3 +270,200 @@ describe('AutomationRunHistory keyboard navigation', () => {
expect(onOpenRun).toHaveBeenCalledWith(run2)
})
})
describe('AutomationRunHistory row content', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
roots.push(root)
})
function renderHistory(props: {
runs: AutomationRun[]
automationId?: string
worktreeMap?: ReadonlyMap<string, ReturnType<typeof makeWorktree>>
onOpenRun?: (run: AutomationRun) => void
}): void {
act(() => {
root.render(
<AutomationRunHistory
runs={props.runs}
automationId={props.automationId ?? 'a-1'}
worktreeMap={props.worktreeMap ?? new Map()}
onOpenRun={props.onOpenRun ?? vi.fn()}
/>
)
})
}
function rows(): NodeListOf<HTMLButtonElement> {
return container.querySelectorAll<HTMLButtonElement>('button[data-automation-run-id]')
}
it('reports spend and tokens a host actually measured', () => {
renderHistory({
runs: [
makeRun({
usage: makeRunUsage({ estimatedCostUsd: 1.5, totalTokens: 12_345 })
})
]
})
expect(rows()[0].textContent).toContain('$1.50')
expect(rows()[0].textContent).toContain('12k')
})
it('says n/a rather than zero when usage is unavailable', () => {
renderHistory({ runs: [makeRun({ usage: null })] })
// A run whose usage nobody could read has not been measured at $0.00.
expect(rows()[0].textContent).toContain('n/a')
expect(rows()[0].textContent).not.toContain('$0.00')
})
it('names the workspace a run is still attached to', () => {
renderHistory({
runs: [makeRun({ workspaceId: WORKSPACE_ID })],
worktreeMap: new Map([[WORKSPACE_ID, makeWorktree({ displayName: 'nightly-check' })]])
})
expect(rows()[0].textContent).toContain('nightly-check')
})
it('keeps the remembered name of a workspace that is gone, and says it is gone', () => {
renderHistory({
runs: [makeRun({ workspaceId: WORKSPACE_ID, workspaceDisplayName: 'nightly-check' })],
worktreeMap: new Map()
})
expect(rows()[0].textContent).toContain('nightly-check')
expect(rows()[0].textContent).toContain('no longer available')
})
it('counts the whole history but only the completed runs as completed', () => {
renderHistory({
runs: [
makeRun({ id: 'run-1', status: 'completed' }),
makeRun({ id: 'run-2', status: 'dispatch_failed' }),
makeRun({ id: 'run-3', status: 'completed' })
]
})
expect(container.textContent).toContain('3 runs · 2 completed')
})
it('says "1 run" rather than "1 runs"', () => {
renderHistory({ runs: [makeRun()] })
expect(container.textContent).toContain('1 run · 1 completed')
})
it('opens and selects the clicked run', () => {
const onOpenRun = vi.fn()
const second = makeRun({ id: 'run-2' })
renderHistory({ runs: [makeRun({ id: 'run-1' }), second], onOpenRun })
act(() => rows()[1].click())
expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(second)
expect(rows()[1].getAttribute('data-current')).toBe('true')
expect(rows()[0].getAttribute('data-current')).toBe('false')
})
it('drops a selection that belonged to the automation before this one', () => {
const runs = [makeRun({ id: 'run-1' }), makeRun({ id: 'run-2' })]
renderHistory({ runs, automationId: 'a-1' })
act(() => rows()[1].click())
expect(rows()[1].getAttribute('data-current')).toBe('true')
// Same row IDs, different automation: carrying the old selection over would
// highlight a row the user never picked.
renderHistory({ runs, automationId: 'a-2' })
expect(rows()[0].getAttribute('data-current')).toBe('true')
expect(rows()[1].getAttribute('data-current')).toBe('false')
})
})
describe('AutomationRunHistory keyboard navigation guards', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
roots.push(root)
})
async function pressEnter(): Promise<void> {
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
)
})
}
it('opens nothing while rows are on screen under an unanswered read', async () => {
const onOpenRun = vi.fn()
await act(async () => {
root.render(
<AutomationRunHistory
runs={[makeRun({ id: 'run-1' })]}
automationId="a-1"
worktreeMap={new Map()}
notice={{
message: 'web-01 is not connected',
recovery: 'reconnect',
severity: 'failure'
}}
onOpenRun={onOpenRun}
/>
)
})
await pressEnter()
// The notice says these rows are not the host's answer, so Enter must not act
// on them however many of them are still painted.
expect(onOpenRun).not.toHaveBeenCalled()
})
it('follows the runs it was last given, not the ones it mounted with', async () => {
const onOpenRun = vi.fn()
const replacement = makeRun({ id: 'run-9', scheduledFor: LATEST })
await act(async () => {
root.render(
<AutomationRunHistory
runs={[makeRun({ id: 'run-1' })]}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={onOpenRun}
/>
)
})
// The listener subscribes once and reads the current runs through a ref; a
// refreshed history has to reach it without a resubscribe.
await act(async () => {
root.render(
<AutomationRunHistory
runs={[replacement]}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={onOpenRun}
/>
)
})
await pressEnter()
expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(replacement)
})
})
@@ -12,13 +12,16 @@ vi.mock('@tanstack/react-virtual', async () => {
return { useVirtualizer: createVirtualizerStub() }
})
function entries(count: number): AutomationRunsDashboardEntry[] {
function entries(
count: number,
overrides: { hostLabel?: string; scope?: AutomationRunsDashboardEntry['scope'] } = {}
): AutomationRunsDashboardEntry[] {
const automation = { id: 'automation', name: 'Daily check' } as Automation
const row = {
key: 'row',
automation,
catalogRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } },
hostLabel: 'Local Mac',
hostLabel: overrides.hostLabel ?? 'Local Mac',
usageSummary: null
} as const
return Array.from({ length: count }, (_, index) => ({
@@ -34,10 +37,23 @@ function entries(count: number): AutomationRunsDashboardEntry[] {
trigger: 'scheduled',
status: 'completed'
} as AutomationRun,
scope: 'local'
scope: overrides.scope ?? 'local'
}))
}
/** The load-more guard reads the scroller's geometry, which happy-dom leaves at 0. */
function scrollTo(
scroller: HTMLElement,
geometry: { scrollTop: number; scrollHeight: number; clientHeight: number }
): void {
for (const [property, value] of Object.entries(geometry)) {
Object.defineProperty(scroller, property, { value, configurable: true })
}
act(() => {
scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
})
}
describe('AutomationRunsTable virtualization', () => {
let container: HTMLDivElement
let root: Root
@@ -71,3 +87,215 @@ describe('AutomationRunsTable virtualization', () => {
expect(mountedRows).toHaveLength(21)
})
})
describe('AutomationRunsTable rows', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function render(node: React.JSX.Element): void {
act(() => root.render(node))
}
function rows(): NodeListOf<HTMLButtonElement> {
return container.querySelectorAll<HTMLButtonElement>('[data-testid="automation-runs-row"]')
}
it('fills every column of a row from the entry it stands for', () => {
render(
<AutomationRunsTable
entries={entries(1)}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
const row = rows()[0]
expect(row.textContent).toContain('Daily check')
expect(row.textContent).toContain('Run 0')
expect(row.textContent).toContain('Local Mac')
expect(row.textContent).toContain('scheduled')
expect(row.textContent).toContain('Done')
})
it('names the scope when the row carries no host label', () => {
render(
<AutomationRunsTable
entries={entries(1, { hostLabel: '', scope: 'remote' })}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
// An unlabeled host still has to say where the run happened.
expect(rows()[0].textContent).toContain('Remote')
})
it('opens the entry belonging to the clicked row, not the first one', () => {
const onOpenRun = vi.fn()
const rendered = entries(5)
render(
<AutomationRunsTable
entries={rendered}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={onOpenRun}
/>
)
act(() => rows()[3].click())
expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(rendered[3])
})
it('shows the spinner only until the first page arrives', () => {
render(
<AutomationRunsTable
entries={[]}
loading={true}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
expect(container.textContent).toContain('Loading runs')
expect(rows()).toHaveLength(0)
// A refresh over rows already on screen must not blank them back to a spinner.
render(
<AutomationRunsTable
entries={entries(3)}
loading={true}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
expect(container.textContent).not.toContain('Loading runs')
expect(rows()).toHaveLength(3)
})
it('distinguishes an empty history from one still loading', () => {
render(
<AutomationRunsTable
entries={[]}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
expect(container.textContent).toContain('No runs yet')
expect(container.textContent).not.toContain('Loading runs')
})
})
describe('AutomationRunsTable load more', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderTable(props: {
loading: boolean
hasMore: boolean
onLoadMore: () => void
}): void {
act(() =>
root.render(
<AutomationRunsTable
entries={entries(40)}
loading={props.loading}
hasMore={props.hasMore}
onLoadMore={props.onLoadMore}
onOpenRun={() => {}}
/>
)
)
}
function scroller(): HTMLElement {
const element = container.querySelector<HTMLElement>('.scrollbar-sleek')
if (!element) {
throw new Error('runs table has no scroll container')
}
return element
}
it('asks for the next page once the scroll reaches the end', () => {
const onLoadMore = vi.fn()
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), { scrollTop: 1760, scrollHeight: 2360, clientHeight: 600 })
expect(onLoadMore).toHaveBeenCalledTimes(1)
})
it('stays quiet while the scroll is still far from the end', () => {
const onLoadMore = vi.fn()
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), { scrollTop: 0, scrollHeight: 2360, clientHeight: 600 })
expect(onLoadMore).not.toHaveBeenCalled()
})
it('stays quiet when the host has no further pages', () => {
const onLoadMore = vi.fn()
renderTable({ loading: false, hasMore: false, onLoadMore })
scrollTo(scroller(), { scrollTop: 1760, scrollHeight: 2360, clientHeight: 600 })
expect(onLoadMore).not.toHaveBeenCalled()
})
it('asks once per page, not once per scroll event the same page fires', () => {
const onLoadMore = vi.fn()
const geometry = { scrollTop: 1760, scrollHeight: 2360, clientHeight: 600 }
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), geometry)
// Scroll momentum keeps firing before the request settles; a second ask would
// fetch the same cursor twice.
renderTable({ loading: true, hasMore: true, onLoadMore })
scrollTo(scroller(), geometry)
scrollTo(scroller(), geometry)
expect(onLoadMore).toHaveBeenCalledTimes(1)
// Once the page settles the next stretch of scrolling may ask again.
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), geometry)
expect(onLoadMore).toHaveBeenCalledTimes(2)
})
})
@@ -10,6 +10,7 @@
import type {
Automation,
AutomationRun,
AutomationRunUsage,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
@@ -94,6 +95,28 @@ export function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
}
}
export function makeRunUsage(overrides: Partial<AutomationRunUsage> = {}): AutomationRunUsage {
return {
status: 'known',
provider: 'claude',
model: 'claude-opus-5',
inputTokens: 1_000,
outputTokens: 500,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: 1_500,
estimatedCostUsd: 0.25,
estimatedCostSource: 'api_equivalent',
providerSessionId: 'session-1',
attribution: 'provider_session_time_window',
collectedAt: 10,
unavailableReason: null,
unavailableMessage: null,
...overrides
}
}
export function makeExternalManager(
overrides: Partial<ExternalAutomationManager> = {}
): ExternalAutomationManager {
@@ -179,14 +202,27 @@ function makeProjectHostSetup(): ProjectHostSetup {
}
}
function makeWorktree(): Worktree {
export function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: WORKSPACE_ID,
repoId: REPO_ID,
displayName: 'main',
path: '/repos/orca',
branch: 'main'
} as Worktree
branch: 'main',
head: 'abc123',
isBare: false,
isMainWorktree: true,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
export type AutomationsPageStoreFixtures = {