From 6db175dc2cff9c2b24f73bc5597a3fe2e32da5bd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:26:20 -0700 Subject: [PATCH] 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 --- .../automations/AutomationRunHistory.test.tsx | 199 ++++++++++++++- .../automations/AutomationRunsTable.test.tsx | 234 +++++++++++++++++- .../automations/automations-page-fixtures.ts | 42 +++- 3 files changed, 468 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx index 5bf966af452..fff54b458d7 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx @@ -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> + onOpenRun?: (run: AutomationRun) => void + }): void { + act(() => { + root.render( + + ) + }) + } + + function rows(): NodeListOf { + return container.querySelectorAll('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 { + 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( + + ) + }) + + 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( + + ) + }) + // 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( + + ) + }) + + await pressEnter() + + expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(replacement) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationRunsTable.test.tsx b/src/renderer/src/components/automations/AutomationRunsTable.test.tsx index 25a1bd3e214..3ef348977b7 100644 --- a/src/renderer/src/components/automations/AutomationRunsTable.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunsTable.test.tsx @@ -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 { + return container.querySelectorAll('[data-testid="automation-runs-row"]') + } + + it('fills every column of a row from the entry it stands for', () => { + render( + {}} + 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( + {}} + 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( + {}} + onOpenRun={onOpenRun} + /> + ) + + act(() => rows()[3].click()) + + expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(rendered[3]) + }) + + it('shows the spinner only until the first page arrives', () => { + render( + {}} + 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( + {}} + onOpenRun={() => {}} + /> + ) + + expect(container.textContent).not.toContain('Loading runs') + expect(rows()).toHaveLength(3) + }) + + it('distinguishes an empty history from one still loading', () => { + render( + {}} + 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( + {}} + /> + ) + ) + } + + function scroller(): HTMLElement { + const element = container.querySelector('.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) + }) +}) diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 16c28c28145..a5a3175e467 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -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 { } } +export function makeRunUsage(overrides: Partial = {}): 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 { @@ -179,14 +202,27 @@ function makeProjectHostSetup(): ProjectHostSetup { } } -function makeWorktree(): Worktree { +export function makeWorktree(overrides: Partial = {}): 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 = {