mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 00:02:24 +00:00
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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user