Files
orca/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts
T
OrcaWinandm4air aeffeb4cfb refactor(pty): no ipc/pty.ts barrel (#27798)
* refactor(pty): no ipc/pty.ts barrel

* test(orcad): clarify preflight-order comment

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 23:45:07 -07:00

466 lines
17 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { onMock, spawnMock, trackMock, classifyErrorMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { registerPtyHandlers } from './pty/register-handlers'
import { setLocalPtyProvider } from './pty/provider/registry'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
vi.mock('../wsl', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
describe('registerPtyHandlers', () => {
const {
handlers,
mainWindow,
mainWindowIpcEvent,
createMockProc,
installObservableDaemonTestProvider
} = setupPtyIpcSuite()
describe('agent_started telemetry', () => {
// Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event.
// Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event.
it('emits agent_started after a successful spawn when telemetry is supplied', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
telemetry: {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
})
expect(trackMock).toHaveBeenCalledWith('agent_started', {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
})
})
it('does not emit agent_started when telemetry is omitted (bare-shell tab)', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(trackMock).not.toHaveBeenCalled()
})
it('drops the event when any telemetry field is outside its closed enum', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
telemetry: {
agent_kind: 'claude-code',
launch_source: 'not_a_real_surface',
request_kind: 'new'
}
})
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
})
it('does not emit agent_started when provider.spawn throws', async () => {
// Why: agent_started fires only on confirmed launch — inject a throwing provider to hit the catch path with no race against the real LocalPtyProvider.
setLocalPtyProvider({
spawn: vi.fn(async () => {
throw new Error('spawn boom')
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
classifyErrorMock.mockReturnValue({ error_class: 'unknown' })
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'claude',
telemetry: {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
})
).rejects.toThrow(/spawn boom/)
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
})
})
describe('serializeBuffer dispatch', () => {
type SerializeListener = (
_event: unknown,
args: {
requestId?: string
snapshot?: { data?: unknown; cols?: unknown; rows?: unknown; lastTitle?: unknown } | null
}
) => void
type SerializeController = {
serializeBuffer: (
ptyId: string,
opts?: { scrollbackRows?: number }
) => Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null>
}
function setup(): { listener: SerializeListener; controller: SerializeController } {
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const onCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:serializeBuffer:response'
)
if (!onCall) {
throw new Error('expected pty:serializeBuffer:response listener registration')
}
const listener = onCall[1] as SerializeListener
const controller = runtime.setPtyController.mock.calls[0]?.[0] as SerializeController
return { listener, controller }
}
function getSentRequestIds(): string[] {
return mainWindow.webContents.send.mock.calls
.filter((call: unknown[]) => call[0] === 'pty:serializeBuffer:request')
.map((call: unknown[]) => (call[1] as { requestId: string }).requestId)
}
it('registers exactly one persistent listener regardless of concurrent in-flight requests', async () => {
const { listener, controller } = setup()
const inflight = [
controller.serializeBuffer('pty-1'),
controller.serializeBuffer('pty-2'),
controller.serializeBuffer('pty-3'),
controller.serializeBuffer('pty-4'),
controller.serializeBuffer('pty-5'),
controller.serializeBuffer('pty-6'),
controller.serializeBuffer('pty-7'),
controller.serializeBuffer('pty-8'),
controller.serializeBuffer('pty-9'),
controller.serializeBuffer('pty-10'),
controller.serializeBuffer('pty-11'),
controller.serializeBuffer('pty-12')
]
// Why: the bug registered one listener per request, so 12 concurrent calls would trip Node's MaxListeners.
const responseChannelRegistrations = onMock.mock.calls.filter(
(call: unknown[]) => call[0] === 'pty:serializeBuffer:response'
)
expect(responseChannelRegistrations.length).toBe(1)
// Drain the in-flight requests so the test doesn't leak timers.
for (const requestId of getSentRequestIds()) {
listener(mainWindowIpcEvent, { requestId, snapshot: null })
}
await Promise.all(inflight)
})
it('routes each response to the originating request via requestId', async () => {
const { listener, controller } = setup()
const a = controller.serializeBuffer('pty-a')
const b = controller.serializeBuffer('pty-b')
const ids = getSentRequestIds()
const requestIdA = ids[0]
const requestIdB = ids[1]
listener(mainWindowIpcEvent, {
requestId: requestIdB,
snapshot: { data: 'B-data', cols: 80, rows: 24 }
})
listener(mainWindowIpcEvent, {
requestId: requestIdA,
snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' }
})
await expect(b).resolves.toEqual({ data: 'B-data', cols: 80, rows: 24 })
await expect(a).resolves.toEqual({
data: 'A-data',
cols: 100,
rows: 30,
lastTitle: 'A-title'
})
})
it('ignores responses with unknown requestId without affecting pending requests', async () => {
const { listener, controller } = setup()
const pending = controller.serializeBuffer('pty-1')
const realRequestId = getSentRequestIds()[0]
listener(mainWindowIpcEvent, {
requestId: 'not-a-real-id',
snapshot: { data: 'irrelevant', cols: 1, rows: 1 }
})
listener(mainWindowIpcEvent, { requestId: undefined, snapshot: null })
let resolved = false
void pending.then(() => {
resolved = true
})
await new Promise((r) => setTimeout(r, 0))
expect(resolved).toBe(false)
listener(mainWindowIpcEvent, {
requestId: realRequestId,
snapshot: { data: 'ok', cols: 80, rows: 24 }
})
await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 })
})
it('resolves to null and removes the entry when the 750ms timeout fires', async () => {
vi.useFakeTimers()
try {
const { controller } = setup()
const pending = controller.serializeBuffer('pty-stuck')
vi.advanceTimersByTime(750)
await expect(pending).resolves.toBeNull()
} finally {
vi.useRealTimers()
}
})
it('resolves to null when the response snapshot is malformed', async () => {
const { listener, controller } = setup()
const pending = controller.serializeBuffer('pty-bad')
const requestId = getSentRequestIds()[0]
listener(mainWindowIpcEvent, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } })
await expect(pending).resolves.toBeNull()
})
})
describe('provider buffer snapshot dispatch', () => {
it('exposes daemon history when no renderer pane is mounted', async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
serializeProviderBuffer(ptyId: string, opts?: { scrollbackRows?: number }): Promise<unknown>
}
await expect(
controller.serializeProviderBuffer('daemon-restored', { scrollbackRows: 5000 })
).resolves.toEqual({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-restored', {
scrollbackRows: 5000
})
})
})
describe('main buffer snapshot dispatch', () => {
it('returns a hidden-output recovery snapshot with clamped scrollback', async () => {
const runtime = {
setPtyController: vi.fn(),
getPtyOutputSequence: vi.fn(() => 42),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 42,
source: 'headless'
})
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const result = await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: 'pty-1',
opts: { scrollbackRows: 999_999 }
})
expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', {
scrollbackRows: 50_000
})
// Why pendingDeliveryStartSeq === seq: pending delivery queue is empty, so low-seq live chunks must not be dropped against the snapshot baseline.
expect(result).toEqual({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 42,
pendingDeliveryStartSeq: 42,
source: 'headless'
})
})
it('uses the complete provider model after daemon stream thinning', async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue({
data: 'complete daemon scrollback\r\n',
cols: 100,
rows: 30,
seq: 900,
source: 'headless'
})
const runtime = {
setPtyController: vi.fn(),
getPtyOutputSequence: vi.fn(() => 640),
notePtyDataGap: vi.fn(),
onPtyExit: vi.fn(),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'kept tail only\r\n',
cols: 100,
rows: 30,
seq: 640,
source: 'headless'
})
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
provider.emitDataGap('daemon-pty', 512)
const result = await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: 'daemon-pty',
opts: { scrollbackRows: 5000 }
})
expect(runtime.notePtyDataGap).toHaveBeenCalledWith('daemon-pty', 512)
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', {
scrollbackRows: 5000
})
expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled()
expect(result).toEqual({
data: 'complete daemon scrollback\r\n',
cols: 100,
rows: 30,
seq: 900,
source: 'headless',
// Bytes between main's absolute seq and the daemon snapshot may still be queued on the stream socket and must dedupe on arrival.
pendingDeliveryStartSeq: 640
})
provider.emitExit('daemon-pty')
})
it("never paints main's incomplete tail when a required provider snapshot is unavailable", async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue(null)
const runtime = {
setPtyController: vi.fn(),
getPtyOutputSequence: vi.fn(() => 640),
notePtyDataGap: vi.fn(),
onPtyExit: vi.fn(),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'kept tail only\r\n',
cols: 100,
rows: 30,
seq: 640,
source: 'headless'
})
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
provider.emitDataGap('daemon-pty', 512)
const result = await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: 'daemon-pty',
opts: { scrollbackRows: 5000 }
})
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', {
scrollbackRows: 5000
})
expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled()
expect(result).toBeNull()
provider.emitExit('daemon-pty')
})
it('reports where the undelivered pending backlog starts alongside the snapshot', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
preAllocateHandleForPty: vi.fn(() => null),
getPtyOutputSequence: vi.fn(() => 2_472),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 100,
rows: 30,
seq: 2_472,
source: 'headless'
})
}
try {
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
// Starved pending entry: bytes ingested but not yet flushed to the renderer — they can still arrive after the snapshot.
mockProc.emitData('frame-bytes')
const result = (await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: spawnResult.id
})) as { pendingDeliveryStartSeq?: number }
expect(result.pendingDeliveryStartSeq).toBe(2_472 - 'frame-bytes'.length)
} finally {
vi.useRealTimers()
}
})
})
})