mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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])
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user