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() 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 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 { return new Promise((resolve) => { const ws = new WebSocket(endpoint) ws.on('open', () => resolve(ws)) }) } function sendAndReceive( ws: WebSocket, msg: Record ): Promise> { 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[] = [] 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[] = [] 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>((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>((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((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() }) })