From 57e28aa9415e6b0f830d059ecd75f87af8ea960a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:41:00 -0700 Subject: [PATCH] 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 --- .../automations/AutomationRunHistory.test.tsx | 44 +++++++++++++++++++ .../automations/AutomationRunHistory.tsx | 6 ++- .../automations/automation-run-occurrences.ts | 9 +++- .../automations/virtualizer-test-stub.ts | 43 ++++++++++++++---- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx index fff54b458d7..56d3e3b8ec2 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx @@ -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( + + ) + }) + + 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( + `[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', () => { diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index dc67495aaf5..3f5a7eb2ee1 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -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 }, diff --git a/src/renderer/src/components/automations/automation-run-occurrences.ts b/src/renderer/src/components/automations/automation-run-occurrences.ts index 71af9043eb8..fc90937853f 100644 --- a/src/renderer/src/components/automations/automation-run-occurrences.ts +++ b/src/renderer/src/components/automations/automation-run-occurrences.ts @@ -13,12 +13,17 @@ import { translate } from '@/i18n/i18n' type AutomationRunOccurrences = Pick +/** 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. diff --git a/src/renderer/src/components/automations/virtualizer-test-stub.ts b/src/renderer/src/components/automations/virtualizer-test-stub.ts index bcb0ace80f6..43eed088ecd 100644 --- a/src/renderer/src/components/automations/virtualizer-test-stub.ts +++ b/src/renderer/src/components/automations/virtualizer-test-stub.ts @@ -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 + }) + } } } }