mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
311 lines
8.8 KiB
TypeScript
311 lines
8.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import WebSocket from 'ws'
|
|
import { CdpWsProxy } from './cdp-ws-proxy'
|
|
|
|
vi.mock('electron', () => ({
|
|
webContents: { fromId: vi.fn() }
|
|
}))
|
|
|
|
type DebuggerListener = (...args: unknown[]) => void
|
|
|
|
function createMockWebContents() {
|
|
const listeners = new Map<string, DebuggerListener[]>()
|
|
|
|
const debuggerObj = {
|
|
isAttached: vi.fn(() => false),
|
|
attach: vi.fn(),
|
|
detach: vi.fn(),
|
|
sendCommand: vi.fn(async () => ({})),
|
|
on: vi.fn((event: string, handler: DebuggerListener) => {
|
|
const arr = listeners.get(event) ?? []
|
|
arr.push(handler)
|
|
listeners.set(event, arr)
|
|
}),
|
|
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
|
|
const arr = listeners.get(event) ?? []
|
|
listeners.set(
|
|
event,
|
|
arr.filter((h) => h !== handler)
|
|
)
|
|
})
|
|
}
|
|
|
|
return {
|
|
webContents: {
|
|
debugger: debuggerObj,
|
|
isDestroyed: () => false,
|
|
focus: vi.fn(),
|
|
getTitle: vi.fn(() => 'Example'),
|
|
getURL: vi.fn(() => 'https://example.com')
|
|
},
|
|
listeners,
|
|
emit(event: string, ...args: unknown[]) {
|
|
for (const handler of listeners.get(event) ?? []) {
|
|
handler(...args)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('CdpWsProxy', () => {
|
|
let mock: ReturnType<typeof createMockWebContents>
|
|
let proxy: CdpWsProxy
|
|
let endpoint: string
|
|
|
|
beforeEach(async () => {
|
|
mock = createMockWebContents()
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
proxy = new CdpWsProxy(mock.webContents as any)
|
|
endpoint = await proxy.start()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await proxy.stop()
|
|
})
|
|
|
|
function connect(): Promise<WebSocket> {
|
|
return new Promise((resolve) => {
|
|
const ws = new WebSocket(endpoint)
|
|
ws.on('open', () => resolve(ws))
|
|
})
|
|
}
|
|
|
|
function sendAndReceive(
|
|
ws: WebSocket,
|
|
msg: Record<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
return new Promise((resolve) => {
|
|
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
|
|
ws.send(JSON.stringify(msg))
|
|
})
|
|
}
|
|
|
|
it('starts on a random port and returns ws:// URL', () => {
|
|
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
|
expect(proxy.getPort()).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('attaches debugger on start', () => {
|
|
expect(mock.webContents.debugger.attach).toHaveBeenCalledWith('1.3')
|
|
})
|
|
|
|
// ── CDP message ID correlation ──
|
|
|
|
it('correlates CDP request/response IDs', async () => {
|
|
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' })
|
|
|
|
const ws = connect()
|
|
const client = await ws
|
|
const response = await sendAndReceive(client, {
|
|
id: 42,
|
|
method: 'Accessibility.getFullAXTree',
|
|
params: {}
|
|
})
|
|
|
|
expect(response.id).toBe(42)
|
|
expect(response.result).toEqual({ tree: 'nodes' })
|
|
client.close()
|
|
})
|
|
|
|
it('returns error response when sendCommand fails', async () => {
|
|
mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found'))
|
|
|
|
const client = await connect()
|
|
const response = await sendAndReceive(client, {
|
|
id: 7,
|
|
method: 'DOM.describeNode',
|
|
params: { nodeId: 999 }
|
|
})
|
|
|
|
expect(response.id).toBe(7)
|
|
expect(response.error).toEqual({ code: -32000, message: 'Node not found' })
|
|
client.close()
|
|
})
|
|
|
|
// ── Concurrent requests get correct responses ──
|
|
|
|
it('handles concurrent requests with correct correlation', async () => {
|
|
let resolveFirst: (v: unknown) => void
|
|
const firstPromise = new Promise((r) => {
|
|
resolveFirst = r
|
|
})
|
|
|
|
mock.webContents.debugger.sendCommand
|
|
.mockImplementationOnce(async () => {
|
|
await firstPromise
|
|
return { result: 'slow' }
|
|
})
|
|
.mockResolvedValueOnce({ result: 'fast' })
|
|
|
|
const client = await connect()
|
|
|
|
const responses: Record<string, unknown>[] = []
|
|
client.on('message', (data) => {
|
|
responses.push(JSON.parse(data.toString()))
|
|
})
|
|
|
|
client.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
client.send(JSON.stringify({ id: 2, method: 'Page.enable', params: {} }))
|
|
|
|
await new Promise((r) => setTimeout(r, 20))
|
|
resolveFirst!(undefined)
|
|
await new Promise((r) => setTimeout(r, 20))
|
|
|
|
expect(responses).toHaveLength(2)
|
|
const resp1 = responses.find((r) => r.id === 1)
|
|
const resp2 = responses.find((r) => r.id === 2)
|
|
expect(resp1?.result).toEqual({ result: 'slow' })
|
|
expect(resp2?.result).toEqual({ result: 'fast' })
|
|
|
|
client.close()
|
|
})
|
|
|
|
it('does not deliver a late response from a closed client to a newer websocket', async () => {
|
|
let resolveSlowCommand: ((value: { result: string }) => void) | null = null
|
|
mock.webContents.debugger.sendCommand
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveSlowCommand = resolve
|
|
})
|
|
)
|
|
.mockResolvedValueOnce({ result: 'new-client' })
|
|
|
|
const firstClient = await connect()
|
|
firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
|
|
const secondClient = await connect()
|
|
const responses: Record<string, unknown>[] = []
|
|
secondClient.on('message', (data) => {
|
|
responses.push(JSON.parse(data.toString()))
|
|
})
|
|
|
|
secondClient.send(JSON.stringify({ id: 2, method: 'Page.enable', params: {} }))
|
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
|
|
resolveSlowCommand!({ result: 'old-client' })
|
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
|
|
expect(responses).toEqual([{ id: 2, result: { result: 'new-client' } }])
|
|
|
|
secondClient.close()
|
|
})
|
|
|
|
// ── sessionId envelope translation ──
|
|
|
|
it('forwards sessionId to sendCommand for OOPIF support', async () => {
|
|
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
|
|
|
|
const client = await connect()
|
|
await sendAndReceive(client, {
|
|
id: 1,
|
|
method: 'DOM.enable',
|
|
params: {},
|
|
sessionId: 'oopif-session-123'
|
|
})
|
|
|
|
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith(
|
|
'DOM.enable',
|
|
{},
|
|
'oopif-session-123'
|
|
)
|
|
client.close()
|
|
})
|
|
|
|
// ── Event forwarding ──
|
|
|
|
it('forwards CDP events from debugger to client', async () => {
|
|
const client = await connect()
|
|
|
|
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
|
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
|
})
|
|
|
|
mock.emit('message', {}, 'Console.messageAdded', { entry: { text: 'hello' } })
|
|
|
|
const event = await eventPromise
|
|
expect(event.method).toBe('Console.messageAdded')
|
|
expect(event.params).toEqual({ entry: { text: 'hello' } })
|
|
client.close()
|
|
})
|
|
|
|
it('forwards sessionId in events when present', async () => {
|
|
const client = await connect()
|
|
|
|
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
|
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
|
})
|
|
|
|
mock.emit('message', {}, 'DOM.nodeInserted', { node: {} }, 'iframe-session-456')
|
|
|
|
const event = await eventPromise
|
|
expect(event.sessionId).toBe('iframe-session-456')
|
|
client.close()
|
|
})
|
|
|
|
it('does not focus the guest for Runtime.evaluate polling commands', async () => {
|
|
const client = await connect()
|
|
|
|
await sendAndReceive(client, {
|
|
id: 9,
|
|
method: 'Runtime.evaluate',
|
|
params: { expression: 'document.readyState' }
|
|
})
|
|
|
|
expect(mock.webContents.focus).not.toHaveBeenCalled()
|
|
client.close()
|
|
})
|
|
|
|
it('still focuses the guest for Input.insertText', async () => {
|
|
const client = await connect()
|
|
|
|
await sendAndReceive(client, {
|
|
id: 10,
|
|
method: 'Input.insertText',
|
|
params: { text: 'hello' }
|
|
})
|
|
|
|
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
|
client.close()
|
|
})
|
|
|
|
// ── Page.frameNavigated interception ──
|
|
|
|
// ── Cleanup ──
|
|
|
|
it('detaches debugger and closes server on stop', async () => {
|
|
const client = await connect()
|
|
await proxy.stop()
|
|
|
|
expect(mock.webContents.debugger.detach).toHaveBeenCalled()
|
|
expect(proxy.getPort()).toBeGreaterThan(0) // port stays set but server is closed
|
|
|
|
await new Promise<void>((resolve) => {
|
|
client.on('close', () => resolve())
|
|
if (client.readyState === WebSocket.CLOSED) {
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('rejects inflight requests on stop', async () => {
|
|
let resolveCommand: (v: unknown) => void
|
|
mock.webContents.debugger.sendCommand.mockImplementation(
|
|
() =>
|
|
new Promise((r) => {
|
|
resolveCommand = r as (v: unknown) => void
|
|
})
|
|
)
|
|
|
|
const client = await connect()
|
|
client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }))
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
await proxy.stop()
|
|
|
|
resolveCommand!({})
|
|
client.close()
|
|
})
|
|
})
|