test(automations): verify scroll-to-focus path in virtualized runs

- Implement scrollToIndex in virtualizer stub to move viewport window
- Optimize row-size estimation to use predicate instead of labels
- Test validates keyboard navigation scrolls rows into view before focus
This commit is contained in:
Jinjing
2026-09-15 21:41:00 -07:00
parent 6db175dc2c
commit 57e28aa941
4 changed files with 90 additions and 12 deletions
@@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AutomationRun } from '../../../../shared/automations-types'
import { AutomationRunHistory } from './AutomationRunHistory'
import { WORKSPACE_ID, makeRun, makeRunUsage, makeWorktree } from './automations-page-fixtures'
import { VIRTUALIZER_STUB_WINDOW_SIZE } from './virtualizer-test-stub'
vi.mock('@tanstack/react-virtual', async () => {
const { createVirtualizerStub } = await import('./virtualizer-test-stub')
@@ -163,6 +164,49 @@ describe('AutomationRunHistory virtualization', () => {
// The count above the table still speaks for the whole history, not the window.
expect(container.textContent).toContain('5000 runs')
})
it('scrolls a selected row below the fold into the window and then focuses it', async () => {
const runs = Array.from({ length: VIRTUALIZER_STUB_WINDOW_SIZE * 2 }, (_, index) =>
makeRun({ id: `run-${index}`, scheduledFor: FIRST + index })
)
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<AutomationRunHistory
runs={runs}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={vi.fn()}
/>
)
})
const belowFold = `run-${VIRTUALIZER_STUB_WINDOW_SIZE}`
expect(container.querySelector(`[data-automation-run-id="${belowFold}"]`)).toBeNull()
// Selection starts on the first row, so this many moves lands one row past the
// window — the case where focus has to wait for the scroll to mount the row.
for (let move = 0; move < VIRTUALIZER_STUB_WINDOW_SIZE; move += 1) {
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
)
})
}
const selected = container.querySelector<HTMLButtonElement>(
`[data-automation-run-id="${belowFold}"]`
)
expect(selected?.getAttribute('data-current')).toBe('true')
expect(document.activeElement).toBe(selected)
// The window moved rather than grew: the row it scrolled past is unmounted.
expect(container.querySelector('[data-automation-run-id="run-0"]')).toBeNull()
})
})
describe('AutomationRunHistory keyboard navigation', () => {
@@ -14,7 +14,7 @@ import {
formatAutomationTokens,
getAutomationUsageStatusLabel
} from './automation-usage-model'
import { automationRunOccurrenceLabel } from './automation-run-occurrences'
import { automationRunOccurrenceLabel, isAutomationRunFolded } from './automation-run-occurrences'
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
@@ -91,7 +91,9 @@ export function AutomationRunHistory({
const estimateRunRowSize = useCallback(
(index: number): number => {
const run = runs[index]
return run && automationRunOccurrenceLabel(run)
// The predicate, not the label: estimateSize is asked for unmounted indexes too,
// and building the label there would translate and format a date per run.
return run && isAutomationRunFolded(run)
? RUN_ROW_HEIGHT_PX + RUN_ROW_OCCURRENCE_LINE_PX
: RUN_ROW_HEIGHT_PX
},
@@ -13,12 +13,17 @@ import { translate } from '@/i18n/i18n'
type AutomationRunOccurrences = Pick<AutomationRun, 'occurrenceCount' | 'lastOccurrenceAt'>
/** The label's condition without its cost; row-size estimation asks it per history item. */
export function isAutomationRunFolded(run: AutomationRunOccurrences): boolean {
return (run.occurrenceCount ?? 1) > 1
}
/** Null for the single-occurrence rows, which is every row written before folding. */
export function automationRunOccurrenceLabel(run: AutomationRunOccurrences): string | null {
const count = run.occurrenceCount ?? 1
if (count <= 1) {
if (!isAutomationRunFolded(run)) {
return null
}
const count = run.occurrenceCount ?? 1
// Not named `count`: i18next reserves it for plural selection, which would send
// these keys looking for `_one`/`_other` variants the catalog does not carry.
// The label only renders above 1, so the plural is always right.
@@ -3,8 +3,16 @@
* that measurement straight to the virtualizer — so the real `useVirtualizer`
* renders no rows at all under test. This stub renders a bounded window instead,
* which is what the virtualization assertions are actually about.
*
* The window starts at index 0 and only moves when `scrollToIndex` names an index
* outside it, so a row below the fold stays unmounted until the component scrolls
* to it — the sequence a deferred-focus path depends on.
*/
import { useState } from 'react'
export const VIRTUALIZER_STUB_WINDOW_SIZE = 21
type VirtualizerStubOptions = {
count: number
estimateSize: (index: number) => number
@@ -19,9 +27,10 @@ type VirtualizerStub = {
}
export function createVirtualizerStub(
windowSize = 21
windowSize = VIRTUALIZER_STUB_WINDOW_SIZE
): (options: VirtualizerStubOptions) => VirtualizerStub {
return ({ count, estimateSize, getItemKey }) => {
const [windowStart, setWindowStart] = useState(0)
const sizes = Array.from({ length: count }, (_, index) => estimateSize(index))
let offset = 0
const starts = sizes.map((size) => {
@@ -32,14 +41,32 @@ export function createVirtualizerStub(
return {
getTotalSize: () => sizes.reduce((total, size) => total + size, 0),
getVirtualItems: () =>
Array.from({ length: Math.min(count, windowSize) }, (_, index) => ({
index,
key: getItemKey?.(index) ?? index,
start: starts[index] ?? 0,
size: sizes[index] ?? 0
})),
Array.from(
{ length: Math.max(0, Math.min(windowSize, count - windowStart)) },
(_, position) => {
const index = windowStart + position
return {
index,
key: getItemKey?.(index) ?? index,
start: starts[index] ?? 0,
size: sizes[index] ?? 0
}
}
),
measureElement: () => undefined,
scrollToIndex: () => undefined
// Scrolls the least the target allows, like `align: 'auto'`.
scrollToIndex: (index: number) => {
setWindowStart((current) => {
const lastStart = Math.max(0, count - windowSize)
if (index < current) {
return Math.min(index, lastStart)
}
if (index >= current + windowSize) {
return Math.min(index - windowSize + 1, lastStart)
}
return current
})
}
}
}
}