From cede986d388730a57d05dcc58da9d5fb7058e1ba Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 17:12:18 -0400 Subject: [PATCH] fix(browser): restore programmatic focus for CDP text insertion (#7041) * fix(browser): restore programmatic focus for CDP text insertion * test(browser): cover the DOM focus replay error path * fix(browser): serialize DOM.focus replay against pipelined Input.insertText * refactor(browser): consolidate pending-focus invalidation into one guard Collapse the three per-handler deletes (Page.bringToFront, Page.captureScreenshot, catch-all) of the pending DOM.focus replay into a single guarded delete keyed on 'not DOM.focus and not Input.insertText'. States the invariant in one place and removes the trap where a future early-returning handler forgets to clear the stale focus. Also clear the map on client teardown and shorten the field comment. Co-authored-by: Orca * test(browser): annotate mock webContents return type for portable declaration emit Co-authored-by: Orca * fix(browser): drop pending focus replay when the client disconnects mid-flight Co-authored-by: Orca * fix(browser): re-check active client after DOM.focus replay round-trip Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../browser/cdp-ws-proxy-focus-replay.test.ts | 394 ++++++++++++++++++ src/main/browser/cdp-ws-proxy-test-harness.ts | 118 ++++++ src/main/browser/cdp-ws-proxy.test.ts | 177 +++----- src/main/browser/cdp-ws-proxy.ts | 87 ++++ 4 files changed, 650 insertions(+), 126 deletions(-) create mode 100644 src/main/browser/cdp-ws-proxy-focus-replay.test.ts create mode 100644 src/main/browser/cdp-ws-proxy-test-harness.ts diff --git a/src/main/browser/cdp-ws-proxy-focus-replay.test.ts b/src/main/browser/cdp-ws-proxy-focus-replay.test.ts new file mode 100644 index 00000000000..71b00cd9ff6 --- /dev/null +++ b/src/main/browser/cdp-ws-proxy-focus-replay.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { CdpWsProxy } from './cdp-ws-proxy' +import { + connect, + createMockWebContents, + getSendCommandCalls, + getSendCommandMethods, + sendAndReceive, + type MockWebContents +} from './cdp-ws-proxy-test-harness' + +vi.mock('electron', () => ({ + webContents: { fromId: vi.fn() } +})) + +// Why: the proxy focuses the guest webContents natively before Input.insertText, +// which blurs any element a prior DOM.focus targeted. These tests pin the replay +// that re-applies that focus so text lands in the intended field. +describe('CdpWsProxy DOM.focus replay', () => { + let mock: MockWebContents + 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() + }) + + it('replays DOM.focus before Input.insertText in the root session', async () => { + const client = await connect(endpoint) + + const focusResponse = await sendAndReceive(client, { + id: 14, + method: 'DOM.focus', + params: { backendNodeId: 99 } + }) + const insertResponse = await sendAndReceive(client, { + id: 15, + method: 'Input.insertText', + params: { text: 'hello' } + }) + + expect(focusResponse.id).toBe(14) + expect(insertResponse.id).toBe(15) + expect(insertResponse.result).toEqual({}) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 99 }], + ['DOM.focus', { backendNodeId: 99 }], + ['Input.insertText', { text: 'hello' }] + ]) + client.close() + }) + + it('replays DOM.focus before Input.insertText for OOPIF sessions', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 16, + method: 'DOM.focus', + params: { backendNodeId: 123 }, + sessionId: 'oopif-session-123' + }) + const insertResponse = await sendAndReceive(client, { + id: 17, + method: 'Input.insertText', + params: { text: 'frame text' }, + sessionId: 'oopif-session-123' + }) + + expect(insertResponse.id).toBe(17) + expect(insertResponse.result).toEqual({}) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'], + ['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'], + ['Input.insertText', { text: 'frame text' }, 'oopif-session-123'] + ]) + client.close() + }) + + it('does not replay DOM.focus after adjacent eval traffic', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 18, + method: 'DOM.focus', + params: { backendNodeId: 44 } + }) + await sendAndReceive(client, { + id: 19, + method: 'Runtime.callFunctionOn', + params: { functionDeclaration: '() => document.activeElement?.id' } + }) + const insertResponse = await sendAndReceive(client, { + id: 20, + method: 'Input.insertText', + params: { text: 'after eval' } + }) + + expect(insertResponse.id).toBe(20) + expect(insertResponse.result).toEqual({}) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 44 }], + ['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }], + ['Input.insertText', { text: 'after eval' }] + ]) + client.close() + }) + + it('does not replay a failed DOM.focus on the next Input.insertText', async () => { + let domFocusAttempt = 0 + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + domFocusAttempt += 1 + if (domFocusAttempt === 1) { + throw new Error('Node not found') + } + } + return {} + }) + + const client = await connect(endpoint) + + const focusResponse = await sendAndReceive(client, { + id: 21, + method: 'DOM.focus', + params: { backendNodeId: 55 } + }) + const insertResponse = await sendAndReceive(client, { + id: 22, + method: 'Input.insertText', + params: { text: 'fallback' } + }) + + expect(focusResponse).toEqual({ + id: 21, + error: { code: -32000, message: 'Node not found' } + }) + expect(insertResponse.id).toBe(22) + expect(insertResponse.result).toEqual({}) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 55 }], + ['Input.insertText', { text: 'fallback' }] + ]) + client.close() + }) + + it('returns the replay error when the stored DOM.focus fails before Input.insertText', async () => { + let domFocusAttempt = 0 + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + domFocusAttempt += 1 + if (domFocusAttempt === 2) { + throw new Error('Focus target went stale') + } + } + return {} + }) + + const client = await connect(endpoint) + + const focusResponse = await sendAndReceive(client, { + id: 23, + method: 'DOM.focus', + params: { backendNodeId: 77 } + }) + const insertResponse = await sendAndReceive(client, { + id: 24, + method: 'Input.insertText', + params: { text: 'blocked' } + }) + + expect(focusResponse.id).toBe(23) + expect(focusResponse.result).toEqual({}) + expect(insertResponse).toEqual({ + id: 24, + error: { code: -32000, message: 'Focus target went stale' } + }) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 77 }], + ['DOM.focus', { backendNodeId: 77 }] + ]) + client.close() + }) + + it('still replays DOM.focus when Input.insertText is dispatched while DOM.focus is still in flight', async () => { + let resolveFocus: (v: Record) => void + const focusPromise = new Promise>((r) => { + resolveFocus = r + }) + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + return focusPromise + } + return {} + }) + + const client = await connect(endpoint) + const responses: Record[] = [] + client.on('message', (data) => { + responses.push(JSON.parse(data.toString())) + }) + + client.send(JSON.stringify({ id: 25, method: 'DOM.focus', params: { backendNodeId: 66 } })) + await new Promise((r) => setTimeout(r, 10)) + // Why: dispatch the next message before the in-flight DOM.focus sendCommand + // resolves, reproducing the pipelining race the fix closes. + client.send( + JSON.stringify({ id: 26, method: 'Input.insertText', params: { text: 'pipelined' } }) + ) + + await new Promise((r) => setTimeout(r, 20)) + resolveFocus!({}) + await new Promise((r) => setTimeout(r, 20)) + + expect(responses).toHaveLength(2) + const focusResponse = responses.find((r) => r.id === 25) + const insertResponse = responses.find((r) => r.id === 26) + expect(focusResponse?.result).toEqual({}) + expect(insertResponse?.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'DOM.focus', + 'Input.insertText' + ]) + client.close() + }) + + it('clears the pending DOM.focus replay when Page.bringToFront intervenes', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 27, + method: 'DOM.focus', + params: { backendNodeId: 88 } + }) + await sendAndReceive(client, { id: 28, method: 'Page.bringToFront', params: {} }) + const insertResponse = await sendAndReceive(client, { + id: 29, + method: 'Input.insertText', + params: { text: 'no replay' } + }) + + expect(insertResponse.id).toBe(29) + expect(insertResponse.result).toEqual({}) + // Why: both Page.bringToFront and Input.insertText natively call focus(), + // independent of the (now-cleared) DOM.focus replay. + expect(mock.webContents.focus).toHaveBeenCalledTimes(2) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'Input.insertText' + ]) + client.close() + }) + + it('clears the pending DOM.focus replay when Page.captureScreenshot intervenes', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 30, + method: 'DOM.focus', + params: { backendNodeId: 91 } + }) + await sendAndReceive(client, { id: 31, method: 'Page.captureScreenshot', params: {} }) + const insertResponse = await sendAndReceive(client, { + id: 32, + method: 'Input.insertText', + params: { text: 'no replay after screenshot' } + }) + + expect(insertResponse.id).toBe(32) + expect(insertResponse.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'Page.captureScreenshot', + 'Input.insertText' + ]) + client.close() + }) + + it('does not replay a pending DOM.focus across a client reconnect', async () => { + const first = await connect(endpoint) + await sendAndReceive(first, { id: 33, method: 'DOM.focus', params: { backendNodeId: 12 } }) + first.close() + + // Why: a new client connection replaces the previous one; the stale focus + // stored by the departed client must not leak into the new client's insert. + const second = await connect(endpoint) + const insertResponse = await sendAndReceive(second, { + id: 34, + method: 'Input.insertText', + params: { text: 'fresh client' } + }) + + expect(insertResponse.id).toBe(34) + expect(insertResponse.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'Input.insertText' + ]) + second.close() + }) + + it('does not replay or insert once the client disconnects mid-DOM.focus', async () => { + let resolveFocus: (v: Record) => void = () => {} + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + return new Promise>((resolve) => { + resolveFocus = resolve + }) + } + return {} + }) + + const client = await connect(endpoint) + client.send(JSON.stringify({ id: 35, method: 'DOM.focus', params: { backendNodeId: 7 } })) + await new Promise((r) => setTimeout(r, 10)) + // Pipeline the insert while DOM.focus is still in flight, then drop the client. + client.send(JSON.stringify({ id: 36, method: 'Input.insertText', params: { text: 'gone' } })) + await new Promise((r) => setTimeout(r, 10)) + client.close() + await new Promise((r) => setTimeout(r, 10)) + + // Resolve the in-flight DOM.focus only after the client is gone. + resolveFocus({}) + await new Promise((r) => setTimeout(r, 20)) + + // Why: a disconnected client's focus replay and insert must not reach the live page. + const methods = getSendCommandMethods(mock) + expect(methods.filter((m) => m === 'DOM.focus')).toHaveLength(1) + expect(methods).not.toContain('Input.insertText') + }) + + it('does not insert once the client disconnects during the DOM.focus replay', async () => { + let domFocusCalls = 0 + let resolveReplay: (v: Record) => void = () => {} + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + domFocusCalls += 1 + // Why: let the first DOM.focus resolve so a replay is queued, then hang the + // replay so the client can disconnect while it is in flight. + if (domFocusCalls === 2) { + return new Promise>((resolve) => { + resolveReplay = resolve + }) + } + } + return {} + }) + + const client = await connect(endpoint) + await sendAndReceive(client, { id: 37, method: 'DOM.focus', params: { backendNodeId: 9 } }) + client.send(JSON.stringify({ id: 38, method: 'Input.insertText', params: { text: 'late' } })) + await new Promise((r) => setTimeout(r, 10)) + client.close() + await new Promise((r) => setTimeout(r, 10)) + resolveReplay({}) + await new Promise((r) => setTimeout(r, 20)) + + expect(getSendCommandMethods(mock)).not.toContain('Input.insertText') + }) +}) diff --git a/src/main/browser/cdp-ws-proxy-test-harness.ts b/src/main/browser/cdp-ws-proxy-test-harness.ts new file mode 100644 index 00000000000..934c859c384 --- /dev/null +++ b/src/main/browser/cdp-ws-proxy-test-harness.ts @@ -0,0 +1,118 @@ +import { vi, type Mock } from 'vitest' +import WebSocket from 'ws' + +type DebuggerListener = (...args: unknown[]) => void + +type MockDebugger = { + isAttached: Mock<() => boolean> + attach: Mock<() => void> + detach: Mock<() => void> + sendCommand: Mock< + ( + method?: string, + params?: Record, + sessionId?: string + ) => Promise> + > + on: Mock<(event: string, handler: DebuggerListener) => void> + removeListener: Mock<(event: string, handler: DebuggerListener) => void> +} + +// Why: annotate the return explicitly so the exported inferred type stays nameable under +// composite declaration emit — otherwise the vi.fn() mocks leak @vitest/spy's Procedure +// and tsgo reports TS2883. +export type MockWebContents = { + webContents: { + debugger: MockDebugger + isDestroyed: () => boolean + focus: Mock<() => void> + printToPDF: Mock<() => Promise> + reload: Mock<() => void> + reloadIgnoringCache: Mock<() => void> + getTitle: Mock<() => string> + getURL: Mock<() => string> + } + listeners: Map + destroy: () => void + emit: (event: string, ...args: unknown[]) => void +} + +export function createMockWebContents(): MockWebContents { + const listeners = new Map() + let debuggerAttached = false + let destroyed = false + + const debuggerObj = { + isAttached: vi.fn(() => debuggerAttached), + attach: vi.fn(() => { + debuggerAttached = true + }), + detach: vi.fn(() => { + debuggerAttached = false + }), + sendCommand: vi.fn( + async (_method?: string, _params?: Record, _sessionId?: string) => ({}) + ), + 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: () => destroyed, + focus: vi.fn(), + printToPDF: vi.fn(async () => Buffer.from('%PDF-test')), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), + getTitle: vi.fn(() => 'Example'), + getURL: vi.fn(() => 'https://example.com') + }, + listeners, + destroy() { + destroyed = true + }, + emit(event: string, ...args: unknown[]) { + for (const handler of listeners.get(event) ?? []) { + handler(...args) + } + } + } +} + +export type SendCommandCall = [string, Record?, string?] + +export function connect(endpoint: string): Promise { + return new Promise((resolve) => { + const ws = new WebSocket(endpoint) + ws.on('open', () => resolve(ws)) + }) +} + +export 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)) + }) +} + +export function getSendCommandCalls(mock: MockWebContents): SendCommandCall[] { + return mock.webContents.debugger.sendCommand.mock.calls as unknown as SendCommandCall[] +} + +export function getSendCommandMethods(mock: MockWebContents): string[] { + return getSendCommandCalls(mock).map((call) => call[0]) +} diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index c5fa3361d96..60248d21b15 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -1,68 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import WebSocket from 'ws' import { CdpWsProxy } from './cdp-ws-proxy' +import { + connect, + createMockWebContents, + getSendCommandCalls, + getSendCommandMethods, + sendAndReceive, + type MockWebContents +} from './cdp-ws-proxy-test-harness' vi.mock('electron', () => ({ webContents: { fromId: vi.fn() } })) -type DebuggerListener = (...args: unknown[]) => void - -function createMockWebContents() { - const listeners = new Map() - let debuggerAttached = false - let destroyed = false - - const debuggerObj = { - isAttached: vi.fn(() => debuggerAttached), - attach: vi.fn(() => { - debuggerAttached = true - }), - detach: vi.fn(() => { - debuggerAttached = false - }), - sendCommand: vi.fn( - async (_method?: string, _params?: Record, _sessionId?: string) => ({}) - ), - 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: () => destroyed, - focus: vi.fn(), - printToPDF: vi.fn(async () => Buffer.from('%PDF-test')), - reload: vi.fn(), - reloadIgnoringCache: vi.fn(), - getTitle: vi.fn(() => 'Example'), - getURL: vi.fn(() => 'https://example.com') - }, - listeners, - destroy() { - destroyed = true - }, - emit(event: string, ...args: unknown[]) { - for (const handler of listeners.get(event) ?? []) { - handler(...args) - } - } - } -} - describe('CdpWsProxy', () => { - let mock: ReturnType + let mock: MockWebContents let proxy: CdpWsProxy let endpoint: string @@ -77,39 +30,6 @@ describe('CdpWsProxy', () => { 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)) - }) - } - - type SendCommandCall = [string, Record?, string?] - - function getSendCommandCalls(): SendCommandCall[] { - const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [ - string, - Record?, - string? - ][] - return calls - } - - function getSendCommandMethods(): string[] { - const calls = getSendCommandCalls() - return calls.map((call) => call[0]) - } - function expectPdfStreamHandle(response: Record): string { const result = response.result as Record expect(result.data).toBe('') @@ -141,7 +61,7 @@ describe('CdpWsProxy', () => { it('correlates CDP request/response IDs', async () => { mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' }) - const ws = connect() + const ws = connect(endpoint) const client = await ws const response = await sendAndReceive(client, { id: 42, @@ -157,7 +77,7 @@ describe('CdpWsProxy', () => { it('returns error response when sendCommand fails', async () => { mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found')) - const client = await connect() + const client = await connect(endpoint) const response = await sendAndReceive(client, { id: 7, method: 'DOM.describeNode', @@ -170,7 +90,7 @@ describe('CdpWsProxy', () => { }) it('returns an error instead of crashing when a command arrives after tab destruction', async () => { - const client = await connect() + const client = await connect(endpoint) mock.destroy() const response = await sendAndReceive(client, { @@ -207,7 +127,7 @@ describe('CdpWsProxy', () => { }) .mockResolvedValueOnce({ result: 'fast' }) - const client = await connect() + const client = await connect(endpoint) const responses: Record[] = [] client.on('message', (data) => { @@ -242,11 +162,11 @@ describe('CdpWsProxy', () => { ) .mockResolvedValueOnce({ result: 'new-client' }) - const firstClient = await connect() + const firstClient = await connect(endpoint) firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} })) await new Promise((resolve) => setTimeout(resolve, 10)) - const secondClient = await connect() + const secondClient = await connect(endpoint) const responses: Record[] = [] secondClient.on('message', (data) => { responses.push(JSON.parse(data.toString())) @@ -268,7 +188,7 @@ describe('CdpWsProxy', () => { it('forwards sessionId to sendCommand for OOPIF support', async () => { mock.webContents.debugger.sendCommand.mockResolvedValueOnce({}) - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 1, method: 'DOM.enable', @@ -287,7 +207,7 @@ describe('CdpWsProxy', () => { // ── Event forwarding ── it('forwards CDP events from debugger to client', async () => { - const client = await connect() + const client = await connect(endpoint) const eventPromise = new Promise>((resolve) => { client.on('message', (data) => resolve(JSON.parse(data.toString()))) @@ -302,7 +222,7 @@ describe('CdpWsProxy', () => { }) it('forwards sessionId in events when present', async () => { - const client = await connect() + const client = await connect(endpoint) const eventPromise = new Promise>((resolve) => { client.on('message', (data) => resolve(JSON.parse(data.toString()))) @@ -316,7 +236,7 @@ describe('CdpWsProxy', () => { }) it('does not focus the guest for Runtime.evaluate polling commands', async () => { - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 9, @@ -329,7 +249,7 @@ describe('CdpWsProxy', () => { }) it('still focuses the guest for Input.insertText', async () => { - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 10, @@ -338,11 +258,16 @@ describe('CdpWsProxy', () => { }) expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Input.insertText' + ]) client.close() }) it('primes lifecycle events for Page.navigate', async () => { - const client = await connect() + const client = await connect(endpoint) const response = await sendAndReceive(client, { id: 11, @@ -352,7 +277,7 @@ describe('CdpWsProxy', () => { expect(response.id).toBe(11) expect(response.result).toEqual({}) - expect(getSendCommandMethods()).toEqual([ + expect(getSendCommandMethods(mock)).toEqual([ 'Page.enable', 'Page.addScriptToEvaluateOnNewDocument', 'Network.enable', @@ -364,7 +289,7 @@ describe('CdpWsProxy', () => { }) it('primes lifecycle events for Page.reload and preserves response id', async () => { - const client = await connect() + const client = await connect(endpoint) const response = await sendAndReceive(client, { id: 12, @@ -373,7 +298,7 @@ describe('CdpWsProxy', () => { expect(response.id).toBe(12) expect(response.result).toEqual({}) - expect(getSendCommandMethods()).toEqual([ + expect(getSendCommandMethods(mock)).toEqual([ 'Page.enable', 'Page.addScriptToEvaluateOnNewDocument', 'Network.enable', @@ -381,12 +306,12 @@ describe('CdpWsProxy', () => { 'Page.setLifecycleEventsEnabled' ]) expect(mock.webContents.reload).toHaveBeenCalledTimes(1) - expect(getSendCommandMethods()).not.toContain('Page.reload') + expect(getSendCommandMethods(mock)).not.toContain('Page.reload') client.close() }) it('preserves explicit Page.navigate session during lifecycle priming', async () => { - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 14, @@ -395,7 +320,7 @@ describe('CdpWsProxy', () => { sessionId: 'iframe-session-123' }) - expect(getSendCommandCalls().slice(2)).toEqual([ + expect(getSendCommandCalls(mock).slice(2)).toEqual([ ['Network.enable', {}, 'iframe-session-123'], ['Page.enable', {}, 'iframe-session-123'], ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], @@ -405,7 +330,7 @@ describe('CdpWsProxy', () => { }) it('forwards explicit Page.reload session after lifecycle priming', async () => { - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 15, @@ -414,7 +339,7 @@ describe('CdpWsProxy', () => { sessionId: 'iframe-session-123' }) - expect(getSendCommandCalls().slice(2)).toEqual([ + expect(getSendCommandCalls(mock).slice(2)).toEqual([ ['Network.enable', {}, 'iframe-session-123'], ['Page.enable', {}, 'iframe-session-123'], ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], @@ -426,7 +351,7 @@ describe('CdpWsProxy', () => { }) it('rejects root Page.reload params that direct webContents reload cannot honor', async () => { - const client = await connect() + const client = await connect(endpoint) const response = await sendAndReceive(client, { id: 16, @@ -443,12 +368,12 @@ describe('CdpWsProxy', () => { }) expect(mock.webContents.reload).not.toHaveBeenCalled() expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() - expect(getSendCommandMethods()).not.toContain('Network.enable') + expect(getSendCommandMethods(mock)).not.toContain('Network.enable') client.close() }) it('still reloads when lifecycle priming stalls', async () => { - const client = await connect() + const client = await connect(endpoint) mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { if (method === 'Network.enable') { return new Promise(() => {}) @@ -467,7 +392,7 @@ describe('CdpWsProxy', () => { }) it('does not reload after the requesting client disconnects during priming', async () => { - const client = await connect() + const client = await connect(endpoint) mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { if (method === 'Network.enable') { return new Promise(() => {}) @@ -485,7 +410,7 @@ describe('CdpWsProxy', () => { }) it('forwards Runtime.evaluate without lifecycle priming', async () => { - const client = await connect() + const client = await connect(endpoint) const response = await sendAndReceive(client, { id: 13, @@ -495,7 +420,7 @@ describe('CdpWsProxy', () => { expect(response.id).toBe(13) expect(response.result).toEqual({}) - expect(getSendCommandMethods()).toEqual([ + expect(getSendCommandMethods(mock)).toEqual([ 'Page.enable', 'Page.addScriptToEvaluateOnNewDocument', 'Runtime.evaluate' @@ -504,7 +429,7 @@ describe('CdpWsProxy', () => { }) it('prints PDF data through native webContents printToPDF', async () => { - const client = await connect() + const client = await connect(endpoint) const response = await sendAndReceive(client, { id: 19, @@ -541,12 +466,12 @@ describe('CdpWsProxy', () => { pageRanges: '1-2', preferCSSPageSize: true }) - expect(getSendCommandMethods()).not.toContain('Page.printToPDF') + expect(getSendCommandMethods(mock)).not.toContain('Page.printToPDF') client.close() }) it('keeps default PDF margins for omitted sides', async () => { - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 20, @@ -570,7 +495,7 @@ describe('CdpWsProxy', () => { it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => { mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) - const client = await connect() + const client = await connect(endpoint) const printResponse = await sendAndReceive(client, { id: 21, @@ -619,7 +544,7 @@ describe('CdpWsProxy', () => { it('clears streamed PDF data when the client disconnects', async () => { mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) - const client = await connect() + const client = await connect(endpoint) const printResponse = await sendAndReceive(client, { id: 26, @@ -632,7 +557,7 @@ describe('CdpWsProxy', () => { client.close() await new Promise((resolve) => setTimeout(resolve, 10)) - const nextClient = await connect() + const nextClient = await connect(endpoint) const staleRead = await sendAndReceive(nextClient, { id: 27, method: 'IO.read', @@ -657,7 +582,7 @@ describe('CdpWsProxy', () => { const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams const createSpy = vi.spyOn(store, 'create') - const client = await connect() + const client = await connect(endpoint) client.send( JSON.stringify({ id: 30, @@ -681,7 +606,7 @@ describe('CdpWsProxy', () => { mock.webContents.debugger.sendCommand .mockResolvedValueOnce({ data: 'trace-data', eof: false }) .mockResolvedValueOnce({}) - const client = await connect() + const client = await connect(endpoint) const readResponse = await sendAndReceive(client, { id: 28, @@ -711,7 +636,7 @@ describe('CdpWsProxy', () => { // ── Cleanup ── it('detaches debugger and closes server on stop', async () => { - const client = await connect() + const client = await connect(endpoint) await proxy.stop() expect(mock.webContents.debugger.detach).toHaveBeenCalled() @@ -726,7 +651,7 @@ describe('CdpWsProxy', () => { }) it('detaches client websocket listeners after client close', async () => { - const client = await connect() + const client = await connect(endpoint) const serverClient = (proxy as unknown as { client: WebSocket | null }).client expect(serverClient).toBeTruthy() const offSpy = vi.spyOn(serverClient!, 'off') @@ -756,7 +681,7 @@ describe('CdpWsProxy', () => { }) ) - const client = await connect() + const client = await connect(endpoint) client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} })) await new Promise((r) => setTimeout(r, 10)) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 6c3a401d5b7..62e51342d26 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -10,6 +10,12 @@ import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron- const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 export class CdpWsProxy { + // Why: holds each session's last DOM.focus params to replay right before the next + // Input.insertText, countering the native webContents.focus() that would blur the target. + private pendingDomFocusBySession = new Map< + string | undefined, + Promise | undefined> + >() private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null @@ -104,6 +110,8 @@ export class CdpWsProxy { this.detachClientListeners?.() this.detachClientListeners = null this.client = null + // Why: a pending focus belongs to the departing client; never replay it for the next one. + this.pendingDomFocusBySession.clear() this.pdfStreams.clear() client?.close() } @@ -282,6 +290,12 @@ export class CdpWsProxy { ) return } + const effectiveSessionId = this.resolveDebuggerSessionId(msg.sessionId) + // Why: a stored focus is only valid for the immediately following Input.insertText; + // any other command may have moved DOM focus, so invalidate the replay in one place. + if (msg.method !== 'DOM.focus' && msg.method !== 'Input.insertText') { + this.pendingDomFocusBySession.delete(effectiveSessionId) + } if (msg.method === 'Page.bringToFront') { if (!this.webContents.isDestroyed()) { this.webContents.focus() @@ -289,6 +303,10 @@ export class CdpWsProxy { this.sendResult(clientId, {}, client) return } + if (msg.method === 'DOM.focus') { + this.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId) + return + } // Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests. if (msg.method === 'Page.captureScreenshot') { this.handleScreenshot(client, clientId, msg.params) @@ -325,6 +343,8 @@ export class CdpWsProxy { // is running. if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) { this.webContents.focus() + void this.forwardInsertText(client, clientId, msg.params ?? {}, effectiveSessionId) + return } // Why: agent-browser waits for network idle to detect navigation completion. // Electron webview CDP subscriptions silently lapse after cross-process swaps. @@ -467,6 +487,73 @@ export class CdpWsProxy { } } + // Why: this must stay synchronous up to the `.set()` call so the pending-focus + // entry exists before the event loop can dispatch a pipelined Input.insertText + // message, closing the race where the replay would otherwise be silently skipped. + private forwardDomFocus( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): void { + const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId) + this.pendingDomFocusBySession.set(effectiveSessionId, focused) + } + + private async sendDomFocus( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): Promise | undefined> { + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return undefined + } + try { + const result = await this.sendDebuggerCommand('DOM.focus', params, effectiveSessionId) + this.sendResult(clientId, result, client) + return { ...params } + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + return undefined + } + } + + private async forwardInsertText( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): Promise { + const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId) + this.pendingDomFocusBySession.delete(effectiveSessionId) + const pendingFocusParams = pendingFocus ? await pendingFocus : undefined + // Why: the client can disconnect while DOM.focus is in flight; don't replay its + // focus or forward its insert into the live page once it is no longer active. + if (!this.isActiveClient(client)) { + return + } + if (pendingFocusParams) { + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + await this.sendDebuggerCommand('DOM.focus', pendingFocusParams, effectiveSessionId) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + return + } + // Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished + // during it so its insert never lands in the live page. + if (!this.isActiveClient(client)) { + return + } + } + this.forwardCommand(client, clientId, 'Input.insertText', params, effectiveSessionId) + } + private async handlePrintToPdf( client: WebSocket, clientId: number,