mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(browser): keep CDP reload from closing Orca browser tabs
Fix the per-tab CDP proxy reload path so raw Page.reload keeps the Orca browser tab alive. Lifecycle priming is bounded and root reloads use the same safer webContents reload behavior as Orca's own browser reload path.
This commit is contained in:
@@ -21,7 +21,9 @@ function createMockWebContents() {
|
||||
detach: vi.fn(() => {
|
||||
debuggerAttached = false
|
||||
}),
|
||||
sendCommand: vi.fn(async () => ({})),
|
||||
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)
|
||||
@@ -41,6 +43,8 @@ function createMockWebContents() {
|
||||
debugger: debuggerObj,
|
||||
isDestroyed: () => destroyed,
|
||||
focus: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
reloadIgnoringCache: vi.fn(),
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
getURL: vi.fn(() => 'https://example.com')
|
||||
},
|
||||
@@ -89,6 +93,22 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
it('starts on a random port and returns ws:// URL', () => {
|
||||
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect(proxy.getPort()).toBeGreaterThan(0)
|
||||
@@ -311,6 +331,168 @@ describe('CdpWsProxy', () => {
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.navigate', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 11,
|
||||
method: 'Page.navigate',
|
||||
params: { url: 'https://example.com/next' }
|
||||
})
|
||||
|
||||
expect(response.id).toBe(11)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
'Page.enable',
|
||||
'Page.setLifecycleEventsEnabled',
|
||||
'Page.navigate'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.reload and preserves response id', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 12,
|
||||
method: 'Page.reload'
|
||||
})
|
||||
|
||||
expect(response.id).toBe(12)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
'Page.enable',
|
||||
'Page.setLifecycleEventsEnabled'
|
||||
])
|
||||
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandMethods()).not.toContain('Page.reload')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 14,
|
||||
method: 'Page.navigate',
|
||||
params: { url: 'https://example.com/frame' },
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123']
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('forwards explicit Page.reload session after lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 15,
|
||||
method: 'Page.reload',
|
||||
params: { ignoreCache: true },
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
['Page.reload', { ignoreCache: true }, 'iframe-session-123']
|
||||
])
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 16,
|
||||
method: 'Page.reload',
|
||||
params: { loaderId: 'stale-loader' }
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
id: 16,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads'
|
||||
}
|
||||
})
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
expect(getSendCommandMethods()).not.toContain('Network.enable')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('still reloads when lifecycle priming stalls', async () => {
|
||||
const client = await connect()
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const responsePromise = sendAndReceive(client, {
|
||||
id: 17,
|
||||
method: 'Page.reload'
|
||||
})
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({ id: 17, result: {} })
|
||||
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not reload after the requesting client disconnects during priming', async () => {
|
||||
const client = await connect()
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
client.send(JSON.stringify({ id: 18, method: 'Page.reload' }))
|
||||
client.close()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100))
|
||||
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards Runtime.evaluate without lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 13,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression: 'document.readyState' }
|
||||
})
|
||||
|
||||
expect(response.id).toBe(13)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Runtime.evaluate'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
// ── Page.frameNavigated interception ──
|
||||
|
||||
// ── Cleanup ──
|
||||
|
||||
@@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
|
||||
|
||||
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
|
||||
|
||||
export class CdpWsProxy {
|
||||
private httpServer: Server | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
@@ -298,13 +300,39 @@ export class CdpWsProxy {
|
||||
}
|
||||
// Why: agent-browser waits for network idle to detect navigation completion.
|
||||
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
|
||||
// Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031).
|
||||
if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) {
|
||||
void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {})
|
||||
void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
|
||||
return
|
||||
}
|
||||
// Why: CDP Page.reload can destroy Electron webview targets during process swaps.
|
||||
// Use the same direct webContents reload path as Orca's own browser.reload.
|
||||
if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) {
|
||||
void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId)
|
||||
}
|
||||
|
||||
private resolveDebuggerSessionId(msgSessionId?: string): string | undefined {
|
||||
return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
|
||||
}
|
||||
|
||||
private isActiveClient(client: WebSocket): boolean {
|
||||
return this.client === client && client.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
private sendDebuggerCommand(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
sessionId?: string
|
||||
): Promise<unknown> {
|
||||
const command = sessionId
|
||||
? this.webContents.debugger.sendCommand(method, params, sessionId)
|
||||
: this.webContents.debugger.sendCommand(method, params)
|
||||
return Promise.resolve(command)
|
||||
}
|
||||
|
||||
private forwardCommand(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
@@ -316,10 +344,9 @@ export class CdpWsProxy {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
const sessionId =
|
||||
msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
|
||||
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
|
||||
try {
|
||||
Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId))
|
||||
this.sendDebuggerCommand(method, params, sessionId)
|
||||
.then((result) => {
|
||||
this.sendResult(clientId, result, client)
|
||||
})
|
||||
@@ -331,21 +358,85 @@ export class CdpWsProxy {
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateWithLifecycleEnsured(
|
||||
private async navigateWithLifecycle(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
msgSessionId?: string
|
||||
): Promise<void> {
|
||||
await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId))
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId)
|
||||
}
|
||||
|
||||
private async reloadWithLifecycle(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
msgSessionId?: string
|
||||
): Promise<void> {
|
||||
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
|
||||
const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params)
|
||||
if (unsupportedParam) {
|
||||
this.sendError(
|
||||
clientId,
|
||||
`Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`,
|
||||
client
|
||||
)
|
||||
return
|
||||
}
|
||||
await this.primePageLifecycle(sessionId)
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
if (sessionId) {
|
||||
this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId)
|
||||
return
|
||||
}
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dbg = this.webContents.debugger
|
||||
if (params.ignoreCache === true) {
|
||||
this.webContents.reloadIgnoringCache()
|
||||
} else {
|
||||
this.webContents.reload()
|
||||
}
|
||||
this.sendResult(clientId, {}, client)
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
}
|
||||
}
|
||||
|
||||
private getUnsupportedRootReloadParam(params: Record<string, unknown>): string | null {
|
||||
return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
|
||||
}
|
||||
|
||||
private async primePageLifecycle(sessionId?: string): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const priming = (async (): Promise<void> => {
|
||||
// Why: without Network.enable, agent-browser never sees network idle → goto times out.
|
||||
await dbg.sendCommand('Network.enable', {})
|
||||
await dbg.sendCommand('Page.enable', {})
|
||||
await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true })
|
||||
} catch {
|
||||
/* best-effort */
|
||||
await this.sendDebuggerCommand('Network.enable', {}, sessionId)
|
||||
await this.sendDebuggerCommand('Page.enable', {}, sessionId)
|
||||
await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId)
|
||||
})().catch(() => {})
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
priming,
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS)
|
||||
timeout.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
this.forwardCommand(client, clientId, 'Page.navigate', params)
|
||||
}
|
||||
|
||||
private handleScreenshot(
|
||||
|
||||
Reference in New Issue
Block a user