mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
test(x): cover useTurn and useAgentRunTranscript hook behavior
The follower's join protocol was already unit-tested, but the hooks grew stateful behavior of their own: useTurn's reset-on-turnId-change vs keep-while-disabled, the snapshotFailed signal and feed-event recovery, and useAgentRunTranscript's legacy runs:fetch fallback and loading/error derivation. Tests drive the real turn-feed singleton through a stubbed window.ipc preload surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
876bc35e9e
commit
51b11101f0
2 changed files with 225 additions and 0 deletions
|
|
@ -0,0 +1,77 @@
|
|||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { completedTurnLog } from '@/lib/session-chat/test-fixtures'
|
||||
import { useAgentRunTranscript } from './use-agent-run-transcript'
|
||||
|
||||
// Same preload stub as use-turn.test.tsx: the hook rides useTurn (real
|
||||
// turn-feed singleton) plus a runs:fetch legacy fallback.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.resolve({ success: true })
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers = {}
|
||||
})
|
||||
|
||||
describe('useAgentRunTranscript', () => {
|
||||
it('renders a turn-backed transcript', async () => {
|
||||
handlers['sessions:getTurn'] = async () => ({
|
||||
turnId: 'run-1',
|
||||
events: completedTurnLog('run-1', 's1', 'do the task', 'task done'),
|
||||
})
|
||||
const { result } = renderHook(() => useAgentRunTranscript('run-1'))
|
||||
|
||||
await waitFor(() => expect(result.current.transcript).not.toBeNull())
|
||||
expect(result.current.transcript?.id).toBe('run-1')
|
||||
expect(result.current.transcript?.summary).toBe('task done')
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to legacy runs:fetch when the id is not a turn', async () => {
|
||||
handlers['sessions:getTurn'] = async () => {
|
||||
throw new Error('turn not found: legacy id')
|
||||
}
|
||||
handlers['runs:fetch'] = async (args) => ({
|
||||
id: (args as { runId: string }).runId,
|
||||
createdAt: '2026-07-01T00:00:00Z',
|
||||
subUseCase: 'cron',
|
||||
log: [],
|
||||
})
|
||||
const { result } = renderHook(() => useAgentRunTranscript('legacy-run'))
|
||||
|
||||
await waitFor(() => expect(result.current.transcript).not.toBeNull())
|
||||
expect(result.current.transcript?.id).toBe('legacy-run')
|
||||
expect(result.current.transcript?.trigger).toBe('cron')
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces an error when both the turn and legacy paths fail', async () => {
|
||||
handlers['sessions:getTurn'] = async () => {
|
||||
throw new Error('turn not found')
|
||||
}
|
||||
handlers['runs:fetch'] = async () => {
|
||||
throw new Error('run not found either')
|
||||
}
|
||||
const { result } = renderHook(() => useAgentRunTranscript('ghost-run'))
|
||||
|
||||
await waitFor(() => expect(result.current.error).not.toBeNull())
|
||||
expect(result.current.error).toMatch(/run not found either/)
|
||||
expect(result.current.transcript).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('is idle without a run id', () => {
|
||||
const { result } = renderHook(() => useAgentRunTranscript(null))
|
||||
expect(result.current.transcript).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
148
apps/x/apps/renderer/src/hooks/use-turn.test.tsx
Normal file
148
apps/x/apps/renderer/src/hooks/use-turn.test.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TurnBusEvent } from '@x/shared/src/turns.js'
|
||||
import {
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
requested,
|
||||
turnCompleted,
|
||||
user,
|
||||
type TEvent,
|
||||
} from '@/lib/session-chat/test-fixtures'
|
||||
import { useTurn } from './use-turn'
|
||||
|
||||
// The hook wires the real turn-feed singleton and window.ipc, so the tests
|
||||
// stub the preload surface: `on` captures the feed listener (the feed
|
||||
// attaches lazily on first subscribe), `invoke` routes by channel through a
|
||||
// per-test handler map.
|
||||
let feedListener: ((event: TurnBusEvent) => void) | null = null
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: (channel: string, handler: (event: TurnBusEvent) => void) => {
|
||||
if (channel === 'turns:events') feedListener = handler
|
||||
return () => undefined
|
||||
},
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.resolve({ success: true })
|
||||
},
|
||||
}
|
||||
|
||||
function emit(event: TurnBusEvent): void {
|
||||
act(() => feedListener?.(event))
|
||||
}
|
||||
|
||||
function durable(turnId: string, event: TEvent, offset: number): TurnBusEvent {
|
||||
return { turnId, sessionId: null, event, offset }
|
||||
}
|
||||
|
||||
function serveTurns(turns: Record<string, TEvent[]>): void {
|
||||
handlers['sessions:getTurn'] = async (args) => {
|
||||
const { turnId } = args as { turnId: string }
|
||||
const events = turns[turnId]
|
||||
if (!events) throw new Error(`turn not found: ${turnId}`)
|
||||
return { turnId, events: [...events] }
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers = {}
|
||||
})
|
||||
|
||||
describe('useTurn', () => {
|
||||
it('fetches the snapshot and applies contiguous feed events', async () => {
|
||||
const T = 'turn-live'
|
||||
const full: TEvent[] = [
|
||||
created(T, 's1', user('go')),
|
||||
requested(T, 0),
|
||||
completed(T, 0, assistantText('done')),
|
||||
turnCompleted(T, 'done'),
|
||||
]
|
||||
serveTurns({ [T]: full.slice(0, 2) })
|
||||
const { result } = renderHook(() => useTurn(T))
|
||||
|
||||
await waitFor(() => expect(result.current.state).not.toBeNull())
|
||||
expect(result.current.state?.terminal).toBeUndefined()
|
||||
|
||||
emit(durable(T, full[2], 3))
|
||||
emit(durable(T, full[3], 4))
|
||||
expect(result.current.state?.terminal?.type).toBe('turn_completed')
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('resets state when turnId changes instead of showing the stale turn', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
serveTurns({ 'turn-a': completedTurnLog('turn-a', 's1', 'qa', 'aa') })
|
||||
const base = handlers['sessions:getTurn']
|
||||
handlers['sessions:getTurn'] = async (args) => {
|
||||
if ((args as { turnId: string }).turnId === 'turn-b') {
|
||||
await gate
|
||||
return { turnId: 'turn-b', events: completedTurnLog('turn-b', 's1', 'qb', 'ab') }
|
||||
}
|
||||
return base(args)
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(({ id }) => useTurn(id), {
|
||||
initialProps: { id: 'turn-a' },
|
||||
})
|
||||
await waitFor(() => expect(result.current.state).not.toBeNull())
|
||||
|
||||
rerender({ id: 'turn-b' })
|
||||
// turn-b's snapshot is gated: the hook must show nothing, not turn-a.
|
||||
expect(result.current.state).toBeNull()
|
||||
|
||||
release()
|
||||
await waitFor(() =>
|
||||
expect(result.current.state?.definition.turnId).toBe('turn-b'),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the last state while disabled and refetches on re-enable', async () => {
|
||||
const getTurn = vi.fn(async () => ({
|
||||
turnId: 'turn-a',
|
||||
events: completedTurnLog('turn-a', 's1', 'q', 'a'),
|
||||
}))
|
||||
handlers['sessions:getTurn'] = getTurn
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useTurn('turn-a', { enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.state).not.toBeNull())
|
||||
const fetches = getTurn.mock.calls.length
|
||||
|
||||
rerender({ enabled: false })
|
||||
expect(result.current.state).not.toBeNull() // kept while hidden
|
||||
|
||||
rerender({ enabled: true })
|
||||
await waitFor(() =>
|
||||
expect(getTurn.mock.calls.length).toBeGreaterThan(fetches),
|
||||
)
|
||||
expect(result.current.state).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports snapshotFailed after retries and recovers via a feed event', async () => {
|
||||
const T = 'turn-late'
|
||||
const full = completedTurnLog(T, 's1', 'q', 'a')
|
||||
let available = false
|
||||
handlers['sessions:getTurn'] = async () => {
|
||||
if (!available) throw new Error('not created yet')
|
||||
return { turnId: T, events: [...full] }
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useTurn(T, { maxRetries: 0 }))
|
||||
await waitFor(() => expect(result.current.snapshotFailed).toBe(true))
|
||||
expect(result.current.state).toBeNull()
|
||||
|
||||
available = true
|
||||
emit(durable(T, full[full.length - 1], full.length))
|
||||
await waitFor(() => expect(result.current.state).not.toBeNull())
|
||||
expect(result.current.snapshotFailed).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue