orca/src/main/browser/cdp-ws-proxy.test.ts

312 lines
8.8 KiB
TypeScript
Raw Normal View History

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()
})
})