mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
type StreamRecord = {
|
|
connectionId: string | undefined
|
|
emit: (response: string) => void
|
|
settled: boolean
|
|
signal: AbortSignal
|
|
subscriptionId: string
|
|
}
|
|
|
|
const { dispatchMode, handlers, listeners, streams, unaryConnections } = vi.hoisted(() => ({
|
|
// Most real streaming handlers return once set up and keep streaming until their signal aborts.
|
|
dispatchMode: { settleOnSetup: false },
|
|
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
|
listeners: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
|
streams: [] as StreamRecord[],
|
|
unaryConnections: [] as (string | undefined)[]
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
BrowserWindow: { fromWebContents: vi.fn() },
|
|
ipcMain: {
|
|
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
|
handlers.set(channel, handler)
|
|
}),
|
|
on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
|
listeners.set(channel, handler)
|
|
}),
|
|
removeAllListeners: vi.fn(),
|
|
removeHandler: vi.fn()
|
|
}
|
|
}))
|
|
|
|
vi.mock('../runtime/rpc/dispatcher', () => ({
|
|
RpcDispatcher: class {
|
|
dispatch(_request: unknown, options: { connectionId?: string }): Promise<unknown> {
|
|
unaryConnections.push(options.connectionId)
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
}
|
|
|
|
dispatchStreaming(
|
|
request: { id: string },
|
|
emit: (response: string) => void,
|
|
options: { connectionId?: string; signal: AbortSignal }
|
|
): Promise<void> {
|
|
const record: StreamRecord = {
|
|
connectionId: options.connectionId,
|
|
emit,
|
|
settled: false,
|
|
signal: options.signal,
|
|
subscriptionId: request.id
|
|
}
|
|
streams.push(record)
|
|
if (dispatchMode.settleOnSetup) {
|
|
record.settled = true
|
|
return Promise.resolve()
|
|
}
|
|
return new Promise<void>((resolve) => {
|
|
options.signal.addEventListener('abort', () => {
|
|
record.settled = true
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}))
|
|
|
|
import { registerRuntimeHandlers } from './runtime'
|
|
|
|
type SenderHarness = {
|
|
destroy: () => void
|
|
emitDidNavigate: () => void
|
|
emitRenderProcessGone: () => void
|
|
listenerCount: (eventName: string) => number
|
|
sender: {
|
|
id: number
|
|
isDestroyed: () => boolean
|
|
mainFrame: object
|
|
on: (eventName: string, callback: () => void) => void
|
|
once: (eventName: string, callback: () => void) => void
|
|
send: ReturnType<typeof vi.fn>
|
|
}
|
|
}
|
|
|
|
function createSender(id: number): SenderHarness {
|
|
const senderListeners = new Map<string, (() => void)[]>()
|
|
let destroyed = false
|
|
const add = (eventName: string, callback: () => void): void => {
|
|
const callbacks = senderListeners.get(eventName) ?? []
|
|
callbacks.push(callback)
|
|
senderListeners.set(eventName, callbacks)
|
|
}
|
|
const fire = (eventName: string): void => {
|
|
for (const callback of senderListeners.get(eventName) ?? []) {
|
|
callback()
|
|
}
|
|
}
|
|
const mainFrame = {}
|
|
return {
|
|
destroy: () => {
|
|
destroyed = true
|
|
fire('destroyed')
|
|
},
|
|
emitDidNavigate: () => fire('did-navigate'),
|
|
emitRenderProcessGone: () => fire('render-process-gone'),
|
|
listenerCount: (eventName) => (senderListeners.get(eventName) ?? []).length,
|
|
sender: {
|
|
id,
|
|
isDestroyed: () => destroyed,
|
|
mainFrame,
|
|
on: add,
|
|
once: (eventName, callback) => {
|
|
const wrapped = (): void => {
|
|
const callbacks = senderListeners.get(eventName) ?? []
|
|
senderListeners.set(
|
|
eventName,
|
|
callbacks.filter((candidate) => candidate !== wrapped)
|
|
)
|
|
callback()
|
|
}
|
|
add(eventName, wrapped)
|
|
},
|
|
send: vi.fn()
|
|
}
|
|
}
|
|
}
|
|
|
|
async function call(sender: SenderHarness['sender']): Promise<void> {
|
|
const handler = handlers.get('runtime:call')
|
|
if (!handler) {
|
|
throw new Error('runtime:call handler not registered')
|
|
}
|
|
await handler({ sender, senderFrame: sender.mainFrame }, { method: 'agentSession.hold' })
|
|
}
|
|
|
|
function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void {
|
|
const handler = handlers.get('runtime:subscribe')
|
|
if (!handler) {
|
|
throw new Error('runtime:subscribe handler not registered')
|
|
}
|
|
handler(
|
|
{ sender, senderFrame: sender.mainFrame },
|
|
{ subscriptionId, method: 'agentSession.watch' }
|
|
)
|
|
}
|
|
|
|
function streamFor(subscriptionId: string): StreamRecord {
|
|
const record = streams.findLast((entry) => entry.subscriptionId === subscriptionId)
|
|
if (!record) {
|
|
throw new Error(`no stream dispatched for ${subscriptionId}`)
|
|
}
|
|
return record
|
|
}
|
|
|
|
const FRAME = JSON.stringify({ ok: true, result: { seq: 1 } })
|
|
|
|
describe('runtime:subscribe renderer lifecycle cleanup', () => {
|
|
beforeEach(() => {
|
|
dispatchMode.settleOnSetup = false
|
|
handlers.clear()
|
|
listeners.clear()
|
|
streams.length = 0
|
|
unaryConnections.length = 0
|
|
registerRuntimeHandlers({ cleanupSubscriptionsForConnection: vi.fn() } as never)
|
|
})
|
|
|
|
it('aborts a live stream once its sender commits a navigation', () => {
|
|
const harness = createSender(1)
|
|
subscribe(harness.sender, 'sub-reload')
|
|
const stream = streamFor('sub-reload')
|
|
|
|
stream.emit(FRAME)
|
|
expect(harness.sender.send).toHaveBeenCalledTimes(1)
|
|
expect(harness.sender.send).toHaveBeenCalledWith('runtime:subscription:sub-reload', {
|
|
ok: true,
|
|
result: { seq: 1 }
|
|
})
|
|
|
|
harness.emitDidNavigate()
|
|
expect(stream.signal.aborted).toBe(true)
|
|
expect(stream.settled).toBe(true)
|
|
|
|
stream.emit(FRAME)
|
|
expect(harness.sender.send).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('aborts a live stream when the renderer process dies without destruction', () => {
|
|
const harness = createSender(2)
|
|
subscribe(harness.sender, 'sub-crash')
|
|
const stream = streamFor('sub-crash')
|
|
|
|
harness.emitRenderProcessGone()
|
|
expect(stream.signal.aborted).toBe(true)
|
|
|
|
stream.emit(FRAME)
|
|
expect(harness.sender.send).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('still aborts on sender destruction', () => {
|
|
const harness = createSender(3)
|
|
subscribe(harness.sender, 'sub-destroyed')
|
|
const stream = streamFor('sub-destroyed')
|
|
|
|
harness.destroy()
|
|
expect(stream.signal.aborted).toBe(true)
|
|
|
|
stream.emit(FRAME)
|
|
expect(harness.sender.send).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('scopes navigation cleanup to the navigating sender', () => {
|
|
const navigating = createSender(4)
|
|
const surviving = createSender(5)
|
|
subscribe(navigating.sender, 'sub-navigating')
|
|
subscribe(surviving.sender, 'sub-surviving')
|
|
|
|
navigating.emitDidNavigate()
|
|
|
|
expect(streamFor('sub-navigating').signal.aborted).toBe(true)
|
|
expect(streamFor('sub-surviving').signal.aborted).toBe(false)
|
|
streamFor('sub-surviving').emit(FRAME)
|
|
expect(surviving.sender.send).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('streams to a fresh post-reload subscription while the orphan stays dead', async () => {
|
|
const harness = createSender(6)
|
|
subscribe(harness.sender, 'sub-old')
|
|
const orphan = streamFor('sub-old')
|
|
|
|
harness.emitDidNavigate()
|
|
expect(orphan.signal.aborted).toBe(true)
|
|
// The settled stream's cleanup runs off the dispatch promise.
|
|
await Promise.resolve()
|
|
|
|
subscribe(harness.sender, 'sub-new')
|
|
const fresh = streamFor('sub-new')
|
|
expect(fresh.signal.aborted).toBe(false)
|
|
|
|
fresh.emit(FRAME)
|
|
orphan.emit(FRAME)
|
|
expect(harness.sender.send).toHaveBeenCalledTimes(1)
|
|
expect(harness.sender.send).toHaveBeenCalledWith('runtime:subscription:sub-new', {
|
|
ok: true,
|
|
result: { seq: 1 }
|
|
})
|
|
|
|
// Lifecycle listeners are registered once per sender, not once per subscription.
|
|
expect(harness.listenerCount('did-navigate')).toBe(1)
|
|
expect(harness.listenerCount('render-process-gone')).toBe(1)
|
|
})
|
|
|
|
it('aborts every stream of a sender with several live subscriptions', () => {
|
|
const harness = createSender(7)
|
|
subscribe(harness.sender, 'sub-a')
|
|
subscribe(harness.sender, 'sub-b')
|
|
|
|
harness.emitDidNavigate()
|
|
|
|
expect(streamFor('sub-a').signal.aborted).toBe(true)
|
|
expect(streamFor('sub-b').signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('keeps explicit unsubscribe working', () => {
|
|
const harness = createSender(8)
|
|
subscribe(harness.sender, 'sub-explicit')
|
|
const stream = streamFor('sub-explicit')
|
|
|
|
const unsubscribe = listeners.get('runtime:unsubscribe')
|
|
if (!unsubscribe) {
|
|
throw new Error('runtime:unsubscribe listener not registered')
|
|
}
|
|
unsubscribe({ sender: harness.sender }, { subscriptionId: 'sub-explicit' })
|
|
|
|
expect(stream.signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('aborts a stream whose handler returned right after setup, on explicit unsubscribe', async () => {
|
|
dispatchMode.settleOnSetup = true
|
|
const harness = createSender(12)
|
|
subscribe(harness.sender, 'sub-returned')
|
|
const stream = streamFor('sub-returned')
|
|
// Let the settled dispatch run whatever it chains before the renderer leaves.
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(stream.settled).toBe(true)
|
|
expect(stream.signal.aborted).toBe(false)
|
|
|
|
const unsubscribe = listeners.get('runtime:unsubscribe')
|
|
if (!unsubscribe) {
|
|
throw new Error('runtime:unsubscribe listener not registered')
|
|
}
|
|
unsubscribe({ sender: harness.sender }, { subscriptionId: 'sub-returned' })
|
|
|
|
expect(stream.signal.aborted).toBe(true)
|
|
stream.emit(FRAME)
|
|
expect(harness.sender.send).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('aborts a stream whose handler returned right after setup, when its sender navigates', async () => {
|
|
dispatchMode.settleOnSetup = true
|
|
const harness = createSender(13)
|
|
subscribe(harness.sender, 'sub-returned-nav')
|
|
const stream = streamFor('sub-returned-nav')
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
harness.emitDidNavigate()
|
|
|
|
expect(stream.signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('scopes colliding subscription ids and unsubscribe to their sender', () => {
|
|
const firstHarness = createSender(9)
|
|
const secondHarness = createSender(10)
|
|
subscribe(firstHarness.sender, 'sub-collision')
|
|
const first = streams.at(-1)!
|
|
subscribe(secondHarness.sender, 'sub-collision')
|
|
const second = streams.at(-1)!
|
|
|
|
expect(first.signal.aborted).toBe(false)
|
|
expect(second.signal.aborted).toBe(false)
|
|
|
|
const unsubscribe = listeners.get('runtime:unsubscribe')
|
|
if (!unsubscribe) {
|
|
throw new Error('runtime:unsubscribe listener not registered')
|
|
}
|
|
unsubscribe({ sender: firstHarness.sender }, { subscriptionId: 'sub-collision' })
|
|
|
|
expect(first.signal.aborted).toBe(true)
|
|
expect(second.signal.aborted).toBe(false)
|
|
})
|
|
|
|
it('rotates unary document ownership before sweeping navigation replacements', async () => {
|
|
const cleanupSubscriptionsForConnection = vi.fn()
|
|
handlers.clear()
|
|
listeners.clear()
|
|
streams.length = 0
|
|
unaryConnections.length = 0
|
|
registerRuntimeHandlers({ cleanupSubscriptionsForConnection } as never)
|
|
const harness = createSender(11)
|
|
|
|
await call(harness.sender)
|
|
const retired = unaryConnections[0]
|
|
harness.emitDidNavigate()
|
|
await call(harness.sender)
|
|
const replacement = unaryConnections[1]
|
|
|
|
expect(retired).toMatch(/^desktop-renderer:11:/)
|
|
expect(replacement).toMatch(/^desktop-renderer:11:/)
|
|
expect(replacement).not.toBe(retired)
|
|
expect(cleanupSubscriptionsForConnection).toHaveBeenCalledWith(retired)
|
|
expect(cleanupSubscriptionsForConnection).not.toHaveBeenCalledWith(replacement)
|
|
|
|
harness.emitRenderProcessGone()
|
|
expect(cleanupSubscriptionsForConnection).toHaveBeenCalledWith(replacement)
|
|
})
|
|
})
|