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
+ })
+ }
}
}
}