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 <help@stably.ai>

* test(browser): annotate mock webContents return type for portable declaration emit

Co-authored-by: Orca <help@stably.ai>

* fix(browser): drop pending focus replay when the client disconnects mid-flight

Co-authored-by: Orca <help@stably.ai>

* fix(browser): re-check active client after DOM.focus replay round-trip

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Rod Boev
2026-07-03 14:12:18 -07:00
committed by GitHub
co-authored by Orca Neil
parent 3614cbc40c
commit cede986d38
4 changed files with 650 additions and 126 deletions
@@ -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<string, unknown>) => void
const focusPromise = new Promise<Record<string, unknown>>((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<string, unknown>[] = []
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<string, unknown>) => void = () => {}
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
return new Promise<Record<string, unknown>>((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<string, unknown>) => 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<Record<string, unknown>>((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')
})
})
@@ -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<string, unknown>,
sessionId?: string
) => Promise<Record<string, unknown>>
>
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<Buffer>>
reload: Mock<() => void>
reloadIgnoringCache: Mock<() => void>
getTitle: Mock<() => string>
getURL: Mock<() => string>
}
listeners: Map<string, DebuggerListener[]>
destroy: () => void
emit: (event: string, ...args: unknown[]) => void
}
export function createMockWebContents(): MockWebContents {
const listeners = new Map<string, DebuggerListener[]>()
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<string, unknown>, _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, unknown>?, string?]
export function connect(endpoint: string): Promise<WebSocket> {
return new Promise((resolve) => {
const ws = new WebSocket(endpoint)
ws.on('open', () => resolve(ws))
})
}
export 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))
})
}
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])
}
+51 -126
View File
@@ -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<string, DebuggerListener[]>()
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<string, unknown>, _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<typeof createMockWebContents>
let mock: MockWebContents
let proxy: CdpWsProxy
let endpoint: string
@@ -77,39 +30,6 @@ describe('CdpWsProxy', () => {
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))
})
}
type SendCommandCall = [string, Record<string, unknown>?, string?]
function getSendCommandCalls(): SendCommandCall[] {
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
string,
Record<string, unknown>?,
string?
][]
return calls
}
function getSendCommandMethods(): string[] {
const calls = getSendCommandCalls()
return calls.map((call) => call[0])
}
function expectPdfStreamHandle(response: Record<string, unknown>): string {
const result = response.result as Record<string, unknown>
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<string, unknown>[] = []
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<string, unknown>[] = []
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<Record<string, unknown>>((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<Record<string, unknown>>((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))
+87
View File
@@ -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<Record<string, unknown> | 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<string, unknown>,
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<string, unknown>,
effectiveSessionId?: string
): Promise<Record<string, unknown> | 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<string, unknown>,
effectiveSessionId?: string
): Promise<void> {
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,