Refresh automation run history before opening a run (#32788)

* Refresh automation run history before navigating to a run

- Prevent stale cached runs from opening or reporting missing history
- Show loading and preserve dashboard navigation during refresh

* Update run history inputs after layout commit

* Resolve workspace run links on their host and preserve cancellation

* fix test
This commit is contained in:
Jinjing
2026-10-23 14:46:11 -07:00
committed by GitHub
parent ffd784286e
commit b9700e48fa
12 changed files with 563 additions and 65 deletions
@@ -0,0 +1,275 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { AutomationRun } from '../../../../shared/automations-types'
import {
api,
installAutomationsPageHarness,
mocks,
renderPage,
runtimeHost,
scopedList,
settleHostQueries
} from './automations-page-test-harness'
import { makeAutomation, makeRun } from './automations-page-fixtures'
vi.mock('@tanstack/react-virtual', async () => {
const { createVirtualizerStub } = await import('./virtualizer-test-stub')
return { useVirtualizer: createVirtualizerStub() }
})
installAutomationsPageHarness()
async function loadCachedHistory() {
const automation = makeAutomation()
const oldRun = makeRun({ id: 'run-26', runNumber: 26 })
api.automations.list.mockResolvedValue([automation])
scopedList([automation])
api.automations.listRuns.mockResolvedValue([oldRun])
mocks.state.selectedAutomationId = automation.id
mocks.state.setPendingAutomationRunNavigation = mocks.setPendingRunNavigation
mocks.setPendingRunNavigation.mockImplementation((navigation) => {
mocks.state.pendingAutomationRunNavigation = navigation
})
const page = await renderPage()
await settleHostQueries()
return { ...page, automation, oldRun }
}
async function openDashboardRun(
container: HTMLElement,
newRun: AutomationRun,
oldRun: AutomationRun
) {
const fresh = Promise.withResolvers<AutomationRun[]>()
api.automations.listRuns.mockImplementation((args?: { limit?: number }) =>
args?.limit ? Promise.resolve([newRun, oldRun]) : fresh.promise
)
await act(async () => within(container).getByRole('button', { name: 'Runs' }).click())
await act(async () => {
within(container)
.getByRole('button', { name: new RegExp(newRun.title) })
.click()
})
return fresh
}
describe('AutomationsPage run navigation', () => {
it('opens the automation overview without rereading its cached history', async () => {
const { container, rerender, automation, oldRun } = await loadCachedHistory()
const fresh = Promise.withResolvers<AutomationRun[]>()
api.automations.listRuns.mockClear()
api.automations.listRuns.mockReturnValue(fresh.promise)
mocks.state.pendingAutomationRunNavigation = {
automationId: automation.id,
runId: null
}
await rerender()
await settleHostQueries()
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
expect(within(container).getByTestId('detail-pane')).toBeTruthy()
expect(mocks.detailPane?.selectedRuns).toEqual([oldRun])
expect(api.automations.listRuns).not.toHaveBeenCalled()
expect(mocks.toastMessage).not.toHaveBeenCalled()
await act(async () => fresh.resolve([oldRun]))
})
it('opens a new dashboard run after refreshing an already-cached automation history', async () => {
const { container, oldRun } = await loadCachedHistory()
await act(async () => within(container).getByRole('button', { name: 'Nightly' }).click())
expect(within(container).getByTestId('detail-run-count').textContent).toBe('1')
expect(mocks.detailPane?.selectedRuns.map((run) => run.id)).toEqual([oldRun.id])
const breadcrumb = within(container).getByRole('navigation', {
name: 'Automations breadcrumb'
})
await act(async () => within(breadcrumb).getByRole('button', { name: 'Automations' }).click())
const newRun = makeRun({
id: 'run-27',
runNumber: 27,
title: 'Nightly run 27',
outputSnapshot: {
format: 'plain_text',
content: 'Fresh run output',
capturedAt: 20,
truncated: false
}
})
const fresh = await openDashboardRun(container, newRun, oldRun)
expect(mocks.toastMessage).not.toHaveBeenCalled()
expect(mocks.state.pendingAutomationRunNavigation).toMatchObject({ runId: newRun.id })
expect(within(container).getByRole('status').textContent).toContain('Loading runs')
expect(within(container).queryByText('Fresh run output')).toBeNull()
await act(async () => fresh.resolve([newRun, oldRun]))
expect(mocks.toastMessage).not.toHaveBeenCalled()
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
expect(within(container).queryByRole('status')).toBeNull()
expect(within(container).getByText('Fresh run output')).toBeTruthy()
expect(within(container).getByRole('button', { name: 'Back to runs' })).toBeTruthy()
})
it('keeps the runs dashboard open when the user backs out of a pending read', async () => {
const { container, oldRun } = await loadCachedHistory()
const newRun = makeRun({ id: 'run-27', title: 'Nightly run 27' })
const fresh = await openDashboardRun(container, newRun, oldRun)
const breadcrumb = within(container).getByRole('navigation', {
name: 'Automations breadcrumb'
})
await act(async () => within(breadcrumb).getByRole('button', { name: 'Runs' }).click())
await act(async () => fresh.resolve([newRun, oldRun]))
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
expect(within(container).getByRole('textbox', { name: 'Search runs…' })).toBeTruthy()
expect(container.querySelector('[aria-label="Run context"]')).toBeNull()
expect(mocks.toastMessage).not.toHaveBeenCalled()
})
it('shows loading for a workspace run link and cancels it with Escape', async () => {
const { container, rerender, automation, oldRun } = await loadCachedHistory()
const fresh = Promise.withResolvers<AutomationRun[]>()
api.automations.listRuns.mockReturnValue(fresh.promise)
mocks.state.pendingAutomationRunNavigation = {
automationId: automation.id,
runId: oldRun.id
}
await rerender()
expect(within(container).getByRole('status').textContent).toContain('Loading runs')
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
)
})
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
await act(async () => fresh.resolve([oldRun]))
expect(within(container).getByRole('textbox', { name: 'Search runs…' })).toBeTruthy()
expect(container.querySelector('[aria-label="Run context"]')).toBeNull()
expect(mocks.toastMessage).not.toHaveBeenCalled()
})
it('returns to automation details when cancelling a workspace run link from there', async () => {
const { container, rerender, automation, oldRun } = await loadCachedHistory()
await act(async () => within(container).getByRole('button', { name: 'Nightly' }).click())
const fresh = Promise.withResolvers<AutomationRun[]>()
api.automations.listRuns.mockReturnValue(fresh.promise)
mocks.state.pendingAutomationRunNavigation = {
automationId: automation.id,
runId: oldRun.id
}
await rerender()
expect(within(container).getByRole('status')).toBeTruthy()
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
)
})
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
await act(async () => fresh.resolve([oldRun]))
expect(within(container).getByTestId('detail-pane')).toBeTruthy()
expect(container.querySelector('[aria-label="Run context"]')).toBeNull()
expect(mocks.toastMessage).not.toHaveBeenCalled()
})
it('reports a missing run only after the fresh history confirms its absence', async () => {
const { rerender, automation, oldRun } = await loadCachedHistory()
const fresh = Promise.withResolvers<AutomationRun[]>()
api.automations.listRuns.mockReturnValue(fresh.promise)
mocks.state.pendingAutomationRunNavigation = {
automationId: automation.id,
runId: 'pruned-run'
}
await rerender()
expect(mocks.toastMessage).not.toHaveBeenCalled()
expect(mocks.setPendingRunNavigation).not.toHaveBeenCalled()
await act(async () => fresh.resolve([oldRun]))
expect(mocks.toastMessage).toHaveBeenCalledWith('Run history no longer available.')
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
})
it('shows the history error when opening a run from the dashboard fails', async () => {
const { container, oldRun } = await loadCachedHistory()
const newRun = makeRun({ id: 'run-27', title: 'Nightly run 27' })
const fresh = await openDashboardRun(container, newRun, oldRun)
await act(async () => fresh.reject(new Error('Host disconnected')))
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
expect(mocks.toastMessage).not.toHaveBeenCalled()
expect(container.querySelector('[data-testid="detail-pane"]')).not.toBeNull()
expect(mocks.detailPane?.selectedRunsNotice?.message).toContain('Host disconnected')
})
it.each(['all', 'desktop'] as const)(
'resolves a workspace run link on its host with colliding ids under the %s filter',
async (filter) => {
const remoteRun = makeRun({
id: 'run-26',
outputSnapshot: {
format: 'plain_text',
content: 'Output from the selected remote host',
capturedAt: 20,
truncated: false
}
})
runtimeHost([makeAutomation({ name: 'Remote nightly' })], [remoteRun])
if (filter === 'desktop') {
mocks.state.automationHostFilter = {
kind: 'host',
host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }
}
}
mocks.setAutomationHostFilter.mockImplementation((next) => {
mocks.state.automationHostFilter = next
})
const { container, rerender, automation } = await loadCachedHistory()
const fresh = Promise.withResolvers<{ runs: AutomationRun[] }>()
const originalRpc = mocks.callRuntimeRpc.getMockImplementation()
mocks.callRuntimeRpc.mockImplementation(
(target: unknown, method: unknown, ...args: unknown[]) => {
if (
method === 'automation.runs' &&
typeof target === 'object' &&
target !== null &&
'kind' in target &&
target.kind === 'environment' &&
'environmentId' in target &&
target.environmentId === 'gpu'
) {
return fresh.promise
}
return originalRpc?.(target, method, ...args)
}
)
await act(async () => {
mocks.state.pendingAutomationRunNavigation = {
automationId: automation.id,
runId: remoteRun.id,
hostId: 'runtime:gpu'
}
})
await rerender()
await rerender()
expect(mocks.toastMessage).not.toHaveBeenCalled()
expect(mocks.state.pendingAutomationRunNavigation).not.toBeNull()
await act(async () => fresh.resolve({ runs: [remoteRun] }))
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
expect(container.textContent).toContain('Output from the selected remote host')
expect(mocks.toastMessage).not.toHaveBeenCalled()
}
)
})
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react'
import { translate } from '@/i18n/i18n'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { AutomationEditorDialog } from './AutomationEditorDialog'
import { AutomationsDetailPane } from './AutomationsDetailPane'
import { AutomationsPageSkeleton } from './AutomationsPageSkeleton'
@@ -116,18 +117,21 @@ export function AutomationsPageSurface({
setPageView('run')
}
const showAutomationsList = (): void => {
setPendingAutomationRunNavigation(null)
setPageView('automations')
setSelectedAutomationRunPageId(null)
setIsDetailOpen(false)
setActivePaneTab('overview')
}
const showRunsDashboard = (): void => {
setPendingAutomationRunNavigation(null)
setPageView('runs')
setSelectedAutomationRunPageId(null)
setIsDetailOpen(false)
setActivePaneTab('overview')
}
const showAutomationDetails = (): void => {
setPendingAutomationRunNavigation(null)
setPageView('automations')
setSelectedAutomationRunPageId(null)
setIsDetailOpen(true)
@@ -228,6 +232,17 @@ export function AutomationsPageSurface({
setPendingAutomationRunNavigation={setPendingAutomationRunNavigation}
setIsDetailOpen={setIsDetailOpen}
/>
) : pageView === 'run' && store.pendingAutomationRunNavigation?.runId ? (
<div
role="status"
className="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
>
<LoadingSpinner className="size-4" />
{translate(
'auto.components.automations.AutomationRunsDashboard.loading',
'Loading runs…'
)}
</div>
) : pageView === 'run' && selectedAutomationRunPage ? (
<AutomationRunDetailsPage
automation={selected}
@@ -134,6 +134,7 @@ describe('shouldHandleAutomationDetailEscapeKey', () => {
function makeEvent(
overrides: Partial<{
key: string
defaultPrevented: boolean
altKey: boolean
ctrlKey: boolean
metaKey: boolean
@@ -169,6 +170,10 @@ describe('shouldHandleAutomationDetailEscapeKey', () => {
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ key: 'ArrowLeft' }))).toBe(false)
})
it('ignores Escape already consumed while returning from a run', () => {
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ defaultPrevented: true }))).toBe(false)
})
it('ignores modified or composing Escape keys', () => {
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ metaKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ ctrlKey: true }))).toBe(false)
@@ -46,6 +46,7 @@ export function shouldHandleAutomationDetailTabArrowKey(event: {
export function shouldHandleAutomationDetailEscapeKey(event: {
key: string
defaultPrevented?: boolean
altKey: boolean
ctrlKey: boolean
metaKey: boolean
@@ -54,6 +55,7 @@ export function shouldHandleAutomationDetailEscapeKey(event: {
target?: EventTarget | null
}): boolean {
if (
event.defaultPrevented ||
event.key !== 'Escape' ||
Boolean(event.nativeEvent?.isComposing) ||
event.altKey ||
@@ -6,7 +6,7 @@ import type {
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -52,7 +52,7 @@ export function getAutomationTargetFromHostId(
}
export function getAutomationAuthorityTarget(
authority: AutomationAuthorityRef
authority: StableAutomationAuthorityRef
): AutomationHostTarget {
return authority.kind === 'runtime'
? { kind: 'environment', environmentId: authority.environmentId }
@@ -65,6 +65,7 @@ export type ListPanelProps = {
selectExternalKey: (entryKey: string | null) => void
onOpenDetail: () => void
onRefresh: () => void
onOpenRuns: () => void
runNow: (row: AutomationListRow) => void
openEditDialog: (row: AutomationListRow) => void
toggleAutomation: (row: AutomationListRow) => void
@@ -199,6 +200,7 @@ vi.mock('./AutomationsListPanel', () => ({
return (
<div data-testid="list-panel">
<button aria-label="Refresh automations" onClick={props.onRefresh} />
<button onClick={props.onOpenRuns}>Runs</button>
{props.sortedListItems.map((item) =>
item.kind === 'local' ? (
<button
@@ -1,7 +1,12 @@
import { useEffect, useMemo } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { getAutomationHostTargetKey, getAutomationTargetFromHostId } from './automation-host-client'
import {
getAutomationAuthorityTarget,
getAutomationHostTargetKey,
getAutomationTargetFromHostId
} from './automation-host-client'
import { ALL_AUTOMATION_HOSTS_FILTER } from '../../../../shared/automation-host-filter'
import { useAppStore } from '@/store'
import {
getAutomationRunOwnerEnvironmentId,
@@ -42,7 +47,6 @@ export function useAutomationRunPageState({
isLoading,
automationHostTargetKey,
automations,
selectedAutomationRuns,
setSelectedAutomationRunPageId,
setActivePaneTab,
rerunRunIdsInFlight,
@@ -50,22 +54,30 @@ export function useAutomationRunPageState({
selectExternalKey,
pageView,
setPageView,
setRunPageOrigin,
isDetailOpen,
setIsDetailOpen
} = local
const { selected, selectedRow } = list
const { selectedRuns, selectedAutomationRunPage } = setup
const {
selected,
selectedRow,
visibleRows,
externalAutomationEntries,
hostCatalog,
selectAutomationRow
} = list
const { selectedRuns, selectedRunsReady, selectedRunsNotice, selectedAutomationRunPage } = setup
useEffect(() => {
if (!isDetailOpen || pendingAutomationRunNavigation) {
return
}
const hasSelectedLocal = selectedRow?.key
? list.visibleRows.some((row) => row.key === selectedRow.key)
? visibleRows.some((row) => row.key === selectedRow.key)
: selectedId !== null && selectedRow !== null
const hasSelectedExternal =
selectedExternalKey !== null &&
list.externalAutomationEntries.some((entry) => entry.key === selectedExternalKey)
externalAutomationEntries.some((entry) => entry.key === selectedExternalKey)
if (hasSelectedLocal || hasSelectedExternal) {
return
}
@@ -74,8 +86,8 @@ export function useAutomationRunPageState({
setActivePaneTab('overview')
}, [
isDetailOpen,
list.externalAutomationEntries,
list.visibleRows,
externalAutomationEntries,
visibleRows,
pendingAutomationRunNavigation,
selectedExternalKey,
selectedId,
@@ -85,10 +97,17 @@ export function useAutomationRunPageState({
setSelectedAutomationRunPageId
])
useEffect(() => {
if (!pendingAutomationRunNavigation || isLoading) {
if (!pendingAutomationRunNavigation) {
return
}
const pending = pendingAutomationRunNavigation
if (pending.runId && pageView !== 'run') {
setRunPageOrigin(pageView === 'automations' && isDetailOpen ? 'automation' : 'runs')
setPageView('run')
}
if (isLoading) {
return
}
const pendingTargetKey = getAutomationHostTargetKey(
getAutomationTargetFromHostId(pending.hostId)
)
@@ -116,8 +135,21 @@ export function useAutomationRunPageState({
)
return
}
if (selectedId !== pending.automationId) {
setSelectedId(pending.automationId)
const pendingRow = visibleRows.find(
(row) =>
row.automation.id === pending.automationId &&
(!row.catalogRef ||
getAutomationHostTargetKey(getAutomationAuthorityTarget(row.catalogRef.authority)) ===
pendingTargetKey)
)
if (!pendingRow) {
if (hostCatalog.resolution.effective.kind !== 'all') {
hostCatalog.selectHost(ALL_AUTOMATION_HOSTS_FILTER)
}
return
}
if (selectedId !== pending.automationId || selectedRow?.key !== pendingRow.key) {
selectAutomationRow(pendingRow.key)
setIsDetailOpen(true)
return
}
@@ -129,20 +161,15 @@ export function useAutomationRunPageState({
setPageView('automations')
return
}
if (
selectedAutomationRuns.notice &&
selectedAutomationRuns.automationId === pending.automationId
) {
if (!selectedRunsReady) {
return
}
if (selectedRunsNotice) {
setIsDetailOpen(true)
setActivePaneTab('runs')
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
if (pageView === 'run') {
setPageView('runs')
}
return
}
if (selectedAutomationRuns.automationId !== pending.automationId) {
setPageView('automations')
return
}
setIsDetailOpen(true)
@@ -169,18 +196,24 @@ export function useAutomationRunPageState({
automations,
automationHostTargetKey,
isLoading,
isDetailOpen,
hostCatalog,
selectAutomationRow,
visibleRows,
pendingAutomationRunNavigation,
pageView,
selectExternalKey,
selectedAutomationRuns.automationId,
selectedAutomationRuns.notice,
selectedExternalKey,
selectedId,
selectedRow,
selectedRuns,
selectedRunsReady,
selectedRunsNotice,
setActivePaneTab,
setIsDetailOpen,
setPendingAutomationRunNavigation,
setPageView,
setRunPageOrigin,
setSelectedAutomationRunPageId,
setSelectedId
])
@@ -11,7 +11,7 @@ export function useAutomationsPageEscape({
store: AutomationsPageStoreState
local: AutomationsPageLocalState
}): void {
const { activeModal, closeAutomationsPage } = store
const { activeModal, closeAutomationsPage, setPendingAutomationRunNavigation } = store
const {
createOpen,
deleteTarget,
@@ -65,6 +65,7 @@ export function useAutomationsPageEscape({
if (pageView === 'run') {
event.preventDefault()
setPendingAutomationRunNavigation(null)
setSelectedAutomationRunPageId(null)
setPageView(runPageOrigin === 'automation' ? 'automations' : 'runs')
setIsDetailOpen(runPageOrigin === 'automation')
@@ -108,6 +109,7 @@ export function useAutomationsPageEscape({
setActivePaneTab,
setIsDetailOpen,
setSelectedAutomationRunPageId,
setPendingAutomationRunNavigation,
setPageView
])
}
@@ -36,6 +36,8 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
automationId: null,
rowKey: null,
ownerKey: null,
navigation: null,
reloadToken: -1,
runs: [],
notice: null
})
@@ -26,13 +26,15 @@ export function useAutomationsPageSetupState({
local: AutomationsPageLocalState
list: AutomationsPageListState
}) {
const { repos, settings, projectHostSetups, worktreesByRepo } = store
const { repos, settings, projectHostSetups, worktreesByRepo, pendingAutomationRunNavigation } =
store
const {
automationYamlHooksByRepoKey,
automationHookCheckPromisesRef,
setAutomationYamlHooksByRepoKey,
setupDecisionTouchedRef,
selectedAutomationRuns,
runHistoryReloadToken,
draft,
automationHostTargetKey,
selectedAutomationRunPageId
@@ -160,7 +162,12 @@ export function useAutomationsPageSetupState({
selectedAutomationRuns.rowKey === selectedRow.key &&
selectedAutomationRuns.ownerKey ===
capturedAutomationOwnerKey(capturedAutomationOwner(capturedAutomationOwners, selectedRow.key))
const selectedRunsNotice = selectedRunsMatchSelection ? selectedAutomationRuns.notice : null
const selectedRunsReady =
selectedRunsMatchSelection &&
selectedAutomationRuns.reloadToken === runHistoryReloadToken &&
(!pendingAutomationRunNavigation ||
selectedAutomationRuns.navigation === pendingAutomationRunNavigation)
const selectedRunsNotice = selectedRunsReady ? selectedAutomationRuns.notice : null
const selectedRuns = useMemo(
() =>
selected && selectedRunsMatchSelection
@@ -187,6 +194,7 @@ export function useAutomationsPageSetupState({
getDraftSetupDecisionDefaultSignature,
markSetupDecisionTouched,
selectedRuns,
selectedRunsReady,
selectedRunsNotice,
selectedAutomationRunPage,
worktrees,
@@ -15,6 +15,7 @@ import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref
import { ownerKey } from '../../../../shared/automation-owner-key'
import { makeAutomation, makeRun } from './automations-page-fixtures'
import { automationListRowKey, type AutomationListRow } from './automation-list-row-identity'
import * as automationHostClient from './automation-host-client'
import {
useSelectedAutomationRunHistory,
type SelectedAutomationRunHistoryInput,
@@ -102,6 +103,8 @@ describe('useSelectedAutomationRunHistory', () => {
automationId: 'a-1',
rowKey: DESKTOP_ROW.key,
ownerKey: 'uncaptured',
navigation: null,
reloadToken: 0,
runs: [],
notice: { message: 'web-01 is not connected', recovery: 'reconnect', severity: 'failure' }
}
@@ -133,7 +136,15 @@ describe('useSelectedAutomationRunHistory', () => {
await render(input)
expect(settled(input)).toEqual([
{ automationId: 'a-1', rowKey: DESKTOP_ROW.key, ownerKey: 'uncaptured', runs, notice: null }
{
automationId: 'a-1',
rowKey: DESKTOP_ROW.key,
ownerKey: 'uncaptured',
navigation: null,
reloadToken: 0,
runs,
notice: null
}
])
})
@@ -155,6 +166,8 @@ describe('useSelectedAutomationRunHistory', () => {
automationId: 'a-1',
rowKey: DESKTOP_ROW.key,
ownerKey: 'uncaptured',
navigation: null,
reloadToken: 1,
runs: [],
notice: null
})
@@ -180,14 +193,120 @@ describe('useSelectedAutomationRunHistory', () => {
expect(settled(input).at(-1)?.ownerKey).toBe(ownerKey(owner))
})
it('keeps the requested overview host for a row without a captured owner', async () => {
const runs = [makeRun()]
const listRuns = vi
.spyOn(automationHostClient, 'listAutomationRunsForTarget')
.mockResolvedValueOnce(runs)
mocks.dispatch.mockImplementationOnce(
async (_context: unknown, _row: unknown, legacy: () => Promise<AutomationRun[]>) => ({
ok: true,
value: await legacy()
})
)
const input = makeInput({
navigation: { automationId: 'a-1', runId: null, hostId: 'runtime:gpu' }
})
try {
await render(input)
expect(listRuns).toHaveBeenCalledWith({ kind: 'environment', environmentId: 'gpu' }, 'a-1')
expect(settled(input).at(-1)?.runs).toEqual(runs)
} finally {
listRuns.mockRestore()
}
})
it('clears the pane when nothing is selected', async () => {
const input = makeInput({ selected: null })
await render(input)
expect(settled(input)).toEqual([
{ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null }
{
automationId: null,
rowKey: null,
ownerKey: null,
navigation: null,
reloadToken: 0,
runs: [],
notice: null
}
])
expect(mocks.dispatch).not.toHaveBeenCalled()
})
it('refreshes each navigation and keeps its answer when navigation is consumed', async () => {
mocks.dispatch.mockResolvedValue({ ok: true, value: [] })
const input = makeInput()
const rerender = await render(input)
const navigation = { automationId: 'a-1', runId: 'run-27' }
const runs = [makeRun({ id: navigation.runId })]
mocks.dispatch.mockResolvedValue({ ok: true, value: runs })
await rerender({ ...input, navigation })
expect(settled(input).at(-1)?.runs).toEqual(runs)
expect(settled(input).at(-1)?.navigation).toBe(navigation)
expect(mocks.dispatch).toHaveBeenCalledTimes(2)
await rerender(input)
expect(mocks.dispatch).toHaveBeenCalledTimes(2)
const repeatedNavigation = { ...navigation }
await rerender({ ...input, navigation: repeatedNavigation })
expect(mocks.dispatch).toHaveBeenCalledTimes(3)
expect(settled(input).at(-1)?.navigation).toBe(repeatedNavigation)
})
it('ignores a previous navigation that finishes after a newer navigation', async () => {
const obsolete = Promise.withResolvers<{ ok: true; value: AutomationRun[] }>()
const current = Promise.withResolvers<{ ok: true; value: AutomationRun[] }>()
mocks.dispatch.mockReturnValueOnce(obsolete.promise).mockReturnValueOnce(current.promise)
const firstNavigation = { automationId: 'a-1', runId: 'run-26' }
const input = makeInput({ navigation: firstNavigation })
const rerender = await render(input)
const secondNavigation = { automationId: 'a-1', runId: 'run-27' }
await rerender({ ...input, navigation: secondNavigation })
await act(async () => {
current.resolve({ ok: true, value: [makeRun({ id: secondNavigation.runId })] })
})
await act(async () => {
obsolete.resolve({ ok: true, value: [makeRun({ id: firstNavigation.runId })] })
})
expect(settled(input)).toHaveLength(1)
expect(settled(input)[0]?.navigation).toBe(secondNavigation)
expect(settled(input)[0]?.runs[0]?.id).toBe('run-27')
})
it('refreshes a reselected row after an intervening read was cancelled', async () => {
const obsolete = Promise.withResolvers<{ ok: true; value: AutomationRun[] }>()
const oldRun = makeRun({ id: 'run-26' })
const freshRun = makeRun({ id: 'run-27' })
mocks.dispatch
.mockResolvedValueOnce({ ok: true, value: [oldRun] })
.mockReturnValueOnce(obsolete.promise)
.mockResolvedValueOnce({ ok: true, value: [freshRun, oldRun] })
const input = makeInput()
const rerender = await render(input)
await rerender({
...input,
selected: {
...DESKTOP_ROW,
key: automationListRowKey('host:desktop|self', 'a-2'),
automation: makeAutomation({ id: 'a-2' })
}
})
await rerender(input)
await act(async () => {
obsolete.resolve({ ok: true, value: [makeRun({ automationId: 'a-2' })] })
})
expect(mocks.dispatch).toHaveBeenCalledTimes(3)
expect(settled(input).at(-1)?.runs).toEqual([freshRun, oldRun])
})
})
@@ -1,14 +1,6 @@
/**
* Fetches only the selected automation's history, through its captured owner.
*
* The refetch policy is a string key rather than the objects the fetch reads:
* the dispatch context is rebuilt on every cache reprojection, and keying an
* effect on it re-asks the same host for the same history in a loop. What
* actually decides the request is the row, the owner it was listed under, and
* the host a navigation named — so those, and only those, are the key.
*/
/** Reads selected history through its captured owner, with a fresh answer for each run navigation. */
import { useEffect, useMemo, useRef } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { AutomationRun } from '../../../../shared/automations-types'
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner'
@@ -19,6 +11,8 @@ import {
dispatchAutomationRunHistory
} from './automation-row-action-dispatch'
import {
getAutomationAuthorityTarget,
getAutomationHostTargetKey,
getAutomationTargetFromHostId,
listAutomationRunsForTarget,
type AutomationHostTarget
@@ -26,6 +20,7 @@ import {
export type SelectedAutomationRunNavigation = {
automationId: string
runId: string | null
hostId?: string | null
} | null
@@ -35,6 +30,9 @@ export type SelectedAutomationRunHistoryOutcome = {
rowKey: string | null
/** Which incarnation answered; a re-pairing makes the same row's answer stale. */
ownerKey: string | null
/** The exact navigation this read answered, including repeated clicks on the same run. */
navigation: SelectedAutomationRunNavigation
reloadToken: number
runs: AutomationRun[]
/**
* Non-null when the host refused or failed the read. The runs are then unknown
@@ -62,20 +60,19 @@ function navigationHostId(
}
export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHistoryInput): void {
const inputRef = useRef(input)
inputRef.current = input
const settledRequestRef = useRef<{
fetchKey: string
reloadToken: number
authority: AutomationAuthorityRef
} | null>(null)
const automationId = input.selected?.automation.id ?? null
const rowKey = input.selected?.key ?? null
const reloadToken = input.reloadToken
const fetchKey =
automationId && rowKey
? [
rowKey,
capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)),
navigationHostId(input.navigation, automationId) ?? ''
].join('|')
: ''
const navigation =
input.navigation?.runId && input.navigation.automationId === automationId
? input.navigation
: null
const captured = input.selected
? capturedAutomationOwner(input.context.capturedOwners, input.selected.key).owner
: null
@@ -84,40 +81,78 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
() => (authority?.kind === 'runtime' ? authority : { kind: 'desktop' }),
[authority]
)
const navigationHost = automationId ? navigationHostId(input.navigation, automationId) : null
const target = captured
? getAutomationAuthorityTarget(captured.authority)
: navigationHost
? getAutomationTargetFromHostId(navigationHost)
: input.selected
? (input.legacyTarget(input.selected) ?? { kind: 'local' as const })
: { kind: 'local' as const }
const fetchKey =
automationId && rowKey
? [
rowKey,
capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)),
getAutomationHostTargetKey(target)
].join('|')
: ''
const inputRef = useRef({ ...input, target })
useLayoutEffect(() => {
inputRef.current = { ...input, target }
})
useEffect(() => {
const { selected, context, legacyTarget, navigation, onSettled } = inputRef.current
const { selected, context, onSettled, target: requestTarget } = inputRef.current
if (!selected || !automationId || !rowKey) {
onSettled({ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null })
settledRequestRef.current = null
onSettled({
automationId: null,
rowKey: null,
ownerKey: null,
navigation: null,
reloadToken,
runs: [],
notice: null
})
return
}
const settled = settledRequestRef.current
if (
!navigation &&
settled?.fetchKey === fetchKey &&
settled.reloadToken === reloadToken &&
settled.authority === rowAuthority
) {
return
}
settledRequestRef.current = null
let cancelled = false
const owner = capturedAutomationOwnerKey(
capturedAutomationOwner(context.capturedOwners, rowKey)
)
const navigationHost = navigationHostId(navigation, automationId)
const target = navigationHost
? getAutomationTargetFromHostId(navigationHost)
: (legacyTarget(selected) ?? { kind: 'local' })
void dispatchAutomationRunHistory(
context,
{ rowKey, automationId },
() => listAutomationRunsForTarget(target, automationId),
() => listAutomationRunsForTarget(requestTarget, automationId),
rowAuthority
).then((result) => {
if (cancelled) {
return
}
// A refused or failed read still settles: leaving the previous automation's
// ID in place strands anything waiting on this one and shows its runs as zero.
onSettled(
result.ok
? { automationId, rowKey, ownerKey: owner, runs: result.value, notice: null }
: { automationId, rowKey, ownerKey: owner, runs: [], notice: result.notice }
)
settledRequestRef.current = { fetchKey, reloadToken, authority: rowAuthority }
onSettled({
automationId,
rowKey,
ownerKey: owner,
navigation,
reloadToken,
runs: result.ok ? result.value : [],
notice: result.ok ? null : result.notice
})
})
return () => {
cancelled = true
}
}, [automationId, rowAuthority, rowKey, fetchKey, reloadToken])
}, [automationId, rowAuthority, rowKey, fetchKey, reloadToken, navigation])
}