Files
orca/src/main/native-chat/transcript-watch-error.test.ts
T
Neil 08d95b9979 test(native-chat): remove initial-snapshot recovery race in watch-error test (#17722)
Root cause: the test cleared the injected tail-reader failure *before*
writing the recovered transcript line. The capped rotation retry loop is
still firing at that point, so a retry drain could succeed against the
still-empty file, consume the pending initial drain, and emit an empty
initial snapshot (`[], false, 0, undefined, undefined`). The later manual
watch callback then took the append path, and `u-recovered` never reached
onInitialSnapshot -- producing the CI failure
`expected [ false, +0, ...(5) ] to deeply equal ArrayContaining{...}`.

That empty-snapshot-then-append sequence is correct product behavior, so
this is a test bug: write the content first, then clear the failure, so no
drain can ever observe a readable-but-empty transcript. The assertion now
checks the exact recovered snapshot instead of a flattened
arrayContaining, so an empty recovery snapshot fails loudly.
2026-08-31 13:22:20 -07:00

190 lines
6.9 KiB
TypeScript

import { EventEmitter } from 'node:events'
import type { FSWatcher } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as TranscriptTailReader from './transcript-tail-reader'
const { tailReaderState, watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({
tailReaderState: { failure: null as Error | null },
watchers: [] as (EventEmitter & { close: ReturnType<typeof vi.fn> })[],
watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[],
watchMock: vi.fn()
}))
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
watchMock.mockImplementation((_path, callback) => {
const watcher = Object.assign(new EventEmitter(), { close: vi.fn() })
watchers.push(watcher)
watchCallbacks.push(callback)
return watcher as unknown as FSWatcher
})
return { ...actual, watch: watchMock }
})
vi.mock('./transcript-tail-reader', async () => {
const actual = await vi.importActual<typeof TranscriptTailReader>('./transcript-tail-reader')
return {
...actual,
readNativeChatTranscriptTailFile: (
...args: Parameters<typeof actual.readNativeChatTranscriptTailFile>
) => {
if (tailReaderState.failure) {
return Promise.reject(tailReaderState.failure)
}
return actual.readNativeChatTranscriptTailFile(...args)
}
}
})
import { subscribeNativeChatTranscript } from './transcript-watch'
const roots: string[] = []
afterEach(async () => {
watchers.length = 0
watchCallbacks.length = 0
watchMock.mockClear()
tailReaderState.failure = null
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('native chat transcript watcher errors', () => {
it('handles a watcher error and rebinds after the directory is readable', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-error-'))
roots.push(root)
const filePath = join(root, 'transcript.jsonl')
await writeFile(filePath, '')
const subscription = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session',
filePath,
onAppend: () => {},
debounceMs: 0
})
expect(() => watchers[0]!.emit('error', new Error('EPERM'))).not.toThrow()
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2))
subscription.unsubscribe()
expect(watchers[0]!.close).toHaveBeenCalledOnce()
expect(watchers[1]!.close).toHaveBeenCalledOnce()
})
it('keeps retrying after the old recovery window and tails a recreated directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-gap-'))
roots.push(root)
const filePath = join(root, 'transcript.jsonl')
await writeFile(filePath, '')
const onInitialSnapshot = vi.fn()
const onAppend = vi.fn()
const subscription = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session',
filePath,
onInitialSnapshot,
onAppend,
initialLimit: 40,
debounceMs: 0
})
await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledOnce())
await rm(root, { recursive: true, force: true })
watchers[0]!.emit('error', new Error('EPERM'))
await new Promise((resolve) => setTimeout(resolve, 1_200))
expect(watchMock).toHaveBeenCalledTimes(1)
await mkdir(root, { recursive: true })
await writeFile(filePath, claudeLine('u-recreated', 'user', 'back'))
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2), { timeout: 2_000 })
await appendFile(filePath, claudeLine('a-recreated', 'assistant', 'reply'))
watchCallbacks[1]!('change', 'transcript.jsonl')
await vi.waitFor(() =>
expect(onAppend.mock.calls.flat(2)).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'a-recreated' })])
)
)
subscription.unsubscribe()
expect(watchers[1]!.close).toHaveBeenCalledOnce()
})
it('surfaces an error snapshot when the initial drain throws', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-error-'))
roots.push(root)
const filePath = join(root, 'transcript.jsonl')
await writeFile(filePath, '')
tailReaderState.failure = new Error('deterministic read failure')
const onInitialSnapshot = vi.fn()
const onAppend = vi.fn()
const subscription = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session',
filePath,
onInitialSnapshot,
onAppend,
initialLimit: 40,
debounceMs: 0
})
expect(subscription.watching).toBe(true)
await vi.waitFor(() =>
expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable')
)
// Surfaced once, not spammed by the capped rotation retry loop.
await new Promise((resolve) => setTimeout(resolve, 120))
expect(onInitialSnapshot).toHaveBeenCalledOnce()
subscription.unsubscribe()
})
it('still wins with a real initial snapshot once the transcript becomes readable', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-recover-'))
roots.push(root)
const filePath = join(root, 'transcript.jsonl')
await writeFile(filePath, '')
tailReaderState.failure = new Error('deterministic read failure')
const onInitialSnapshot = vi.fn()
const subscription = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session',
filePath,
onInitialSnapshot,
onAppend: () => {},
initialLimit: 40,
debounceMs: 0
})
await vi.waitFor(() =>
expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable')
)
// initialDrain stays true after the error, so a recovered read delivers the
// real snapshot instead of stranding the client on the error frame.
// The content must land before reads recover: the capped rotation retry is
// still firing, and any drain that succeeds against a still-empty file
// legitimately consumes the pending initial drain with an empty snapshot.
await writeFile(filePath, claudeLine('u-recovered', 'user', 'back'))
tailReaderState.failure = null
watchCallbacks[0]!('change', 'transcript.jsonl')
await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledTimes(2))
expect(onInitialSnapshot.mock.calls[1]![0]).toEqual([
expect.objectContaining({ id: 'u-recovered' })
])
subscription.unsubscribe()
})
})
function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string {
return `${JSON.stringify({
type: role,
uuid,
timestamp: '2026-06-01T10:00:00.000Z',
message: { role, content: role === 'user' ? text : [{ type: 'text', text }] }
})}\n`
}