orca/src/main/plugins/plugin-host-process.test.ts

136 lines
3.8 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const processMocks = vi.hoisted(() => ({ fork: vi.fn() }))
vi.mock('node:child_process', () => ({ fork: processMocks.fork }))
import { startPluginWorker } from './plugin-host-process'
class FakeChild extends EventEmitter {
connected = true
stdout = new PassThrough()
stderr = new PassThrough()
send = vi.fn()
kill = vi.fn()
}
function start(child: FakeChild, options: { eventTimeoutMs?: number } = {}) {
processMocks.fork.mockReturnValue(child)
return startPluginWorker({
pluginId: 'orca-samples.demo',
rootDir: '/plugin',
mainEntry: 'worker.js',
entryPath: '/host.js',
grantedCapabilities: [],
executeHostCall: async () => ({ ok: true, value: null }),
log: vi.fn(),
...options
})
}
beforeEach(() => {
processMocks.fork.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
describe('startPluginWorker', () => {
it('does not inherit Orca execArgv', async () => {
const child = new FakeChild()
const pending = start(child)
child.emit('message', { type: 'ready', commands: [] })
await pending
expect(processMocks.fork).toHaveBeenCalledWith(
'/host.js',
[],
expect.objectContaining({ execArgv: [] })
)
})
it('replays an exit that happened before handle registration', async () => {
const child = new FakeChild()
const pending = start(child)
child.emit('message', { type: 'ready', commands: ['run'] })
const handle = await pending
child.emit('exit', 23)
const onExit = vi.fn()
handle.onExit(onExit)
expect(onExit).toHaveBeenCalledOnce()
expect(onExit).toHaveBeenCalledWith(23)
})
it('kills a live worker that disconnects its IPC channel', async () => {
const child = new FakeChild()
const pending = start(child)
child.emit('message', { type: 'ready', commands: ['run'] })
const handle = await pending
const command = handle.invokeCommand('run')
child.connected = false
child.emit('disconnect')
await expect(command).rejects.toThrow('disconnected')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('counts delivered events as in flight until their acknowledgement', async () => {
const child = new FakeChild()
const pending = start(child)
child.emit('message', { type: 'ready', commands: [] })
const handle = await pending
handle.deliverEvent('worktree.created', {
worktreeId: 'worktree-1',
path: '/repo',
branch: 'feature'
})
expect(handle.inFlightCount()).toBe(1)
child.emit('message', { type: 'eventAck', eventId: 0 })
expect(handle.inFlightCount()).toBe(0)
})
it('kills a worker whose event handler never acknowledges completion', async () => {
vi.useFakeTimers()
const child = new FakeChild()
const pending = start(child, { eventTimeoutMs: 25 })
child.emit('message', { type: 'ready', commands: [] })
const handle = await pending
handle.deliverEvent('worktree.created', {
worktreeId: 'worktree-1',
path: '/repo',
branch: 'feature'
})
await vi.advanceTimersByTimeAsync(25)
expect(handle.inFlightCount()).toBe(0)
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills a worker that exceeds the pending event cap', async () => {
const child = new FakeChild()
const pending = start(child)
child.emit('message', { type: 'ready', commands: [] })
const handle = await pending
for (let index = 0; index < 65; index += 1) {
handle.deliverEvent('agent.status.changed', {
worktreeId: null,
paneKey: `pane-${index}`,
state: 'working',
receivedAt: Date.now()
})
}
expect(handle.inFlightCount()).toBe(64)
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
})