fix(browser): keep the desktop drawing while a phone streams a browser tab (#26284)

While the desktop was in the screen saver or minimized, a paired phone opening a browser tab spun forever: the throttled main window stopped compositing, so the guest's captures hung. Each page stream now holds the existing renderer-throttle lease for its lifetime, released by the stream's own cleanup on every exit. A 10 s no-frame deadline reports the existing timeout error so a viewer never spins forever.
This commit is contained in:
Jinwoo Hong
2026-10-07 17:04:09 -04:00
committed by GitHub
parent e05c69fe8f
commit 7b054e6494
7 changed files with 387 additions and 55 deletions
@@ -0,0 +1,120 @@
/**
* A hidden, throttled embedder stops compositing: capturePage never settles and no live frame
* comes. The stream says so once, 10 s after it started, and only if no frame arrived by then.
*/
import { Buffer } from 'node:buffer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startBrowserScreencast } from './browser-screencast-stream'
import type { BrowserScreencastOptions } from './browser-screencast-stream-types'
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
function never<T>(): Promise<T> {
return new Promise<T>(() => {})
}
function start(
webContents: ReturnType<typeof createMockScreencastWebContents>,
options: BrowserScreencastOptions
) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream touches only the debugger, isDestroyed and capturePage the doubles provide.
return startBrowserScreencast(webContents as never, options)
}
function liveFrame(webContents: ReturnType<typeof createMockScreencastWebContents>): void {
webContents.debugger.emit('message', {}, 'Page.screencastFrame', {
data: Buffer.from('live').toString('base64'),
sessionId: 1,
metadata: { deviceWidth: 390, deviceHeight: 844, pageScaleFactor: 1 }
})
}
function startOptions(viewport: boolean) {
return {
format: 'jpeg' as const,
quality: 70,
maxWidth: 1440,
maxHeight: 1200,
everyNthFrame: 2,
minFrameIntervalMs: 0,
...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}),
onFrame: vi.fn(() => true),
onError: vi.fn()
}
}
describe('browser screencast first-frame deadline', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reports no frame at exactly 10 s when the captures hang', async () => {
const webContents = Object.assign(createMockScreencastWebContents(), {
capturePage: vi.fn(() => never())
})
webContents.debugger.sendCommand.mockImplementation(async (method: string) =>
method === 'Page.captureScreenshot' ? never() : {}
)
const options = startOptions(true)
const session = await start(webContents, options)
await vi.advanceTimersByTimeAsync(9_999)
expect(options.onError).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(options.onError).toHaveBeenCalledExactlyOnceWith('Browser stream timed out.')
session.stop()
// The stop queues behind the capture: capturePage's 10 s bound, then the fallback's 8 s.
await vi.advanceTimersByTimeAsync(8_000)
await session.done
})
it.each(['live', 'snapshot'] as const)('stays quiet once a %s frame arrives', async (kind) => {
const webContents = Object.assign(createMockScreencastWebContents(), {
capturePage: vi.fn(() => never())
})
webContents.debugger.sendCommand.mockImplementation(async (method: string) =>
method === 'Page.captureScreenshot' ? { data: Buffer.from('frame').toString('base64') } : {}
)
const options = startOptions(kind === 'live')
const session = await start(webContents, options)
if (kind === 'live') {
liveFrame(webContents)
}
await vi.advanceTimersByTimeAsync(30_000)
expect(options.onFrame).toHaveBeenCalled()
expect(options.onError).not.toHaveBeenCalled()
session.stop()
await session.done
})
it('drops a capture that settles after the limit', async () => {
let settleCapture!: (image: unknown) => void
const webContents = Object.assign(createMockScreencastWebContents(), {
capturePage: vi.fn(
() =>
new Promise((resolve) => {
settleCapture = resolve
})
)
})
const options = startOptions(true)
const session = await start(webContents, options)
await vi.advanceTimersByTimeAsync(10_000)
settleCapture({
getSize: () => ({ width: 390, height: 844 }),
toJPEG: () => Buffer.from('late'),
toPNG: () => Buffer.from('late')
})
await vi.advanceTimersByTimeAsync(1_000)
expect(options.onFrame).not.toHaveBeenCalled()
session.stop()
await session.done
})
})
@@ -7,6 +7,10 @@ import type {
PendingScreencastFrame
} from './browser-screencast-stream-types'
import { positiveInteger, scaleSnapshotToFit } from './browser-screencast-viewport-fit'
import { withTimeout } from '../../shared/promise-timeout-fallback'
// Why: a hung capture would also wedge the viewport updates and stop queued behind it.
const SNAPSHOT_CAPTURE_TIMEOUT_MS = 10_000
type BrowserScreencastSnapshotCaptureDeps = {
webContents: WebContents
@@ -76,19 +80,19 @@ export function createBrowserScreencastSnapshotCapture(
}
if (viewportWidth && viewportHeight && typeof webContents.capturePage === 'function') {
try {
// Why: CDP captureScreenshot can tile BrowserView surfaces under
// mobile emulation; Electron captures the actual visible viewport.
const nativeImage = await webContents.capturePage({
x: 0,
y: 0,
width: viewportWidth,
height: viewportHeight
})
const capture = scaleSnapshotToFit(nativeImage, options)
const buffer =
options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality)
if (buffer.byteLength > 0) {
image = new Uint8Array(buffer)
// Why: captures the real viewport (CDP can tile it) but never settles once compositing stops.
const nativeImage = await withTimeout(
webContents.capturePage({ x: 0, y: 0, width: viewportWidth, height: viewportHeight }),
SNAPSHOT_CAPTURE_TIMEOUT_MS,
null
)
if (nativeImage) {
const capture = scaleSnapshotToFit(nativeImage, options)
const buffer =
options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality)
if (buffer.byteLength > 0) {
image = new Uint8Array(buffer)
}
}
} catch {
image = null
+13 -2
View File
@@ -13,6 +13,9 @@ import type {
BrowserScreencastViewport
} from './browser-screencast-stream-types'
// Why: long enough for a slow first paint, short enough that a frameless viewer is told soon.
const NO_FRAME_TIMEOUT_MS = 10_000
export async function startBrowserScreencast(
webContents: WebContents,
options: BrowserScreencastOptions
@@ -108,8 +111,9 @@ export async function startBrowserScreencast(
}
const handleDetach = (): void => {
options.onError?.('Browser debugger detached while streaming.')
// Why: finish first so an owner that stops the stream on this error finds it already closed.
finish()
options.onError?.('Browser debugger detached while streaming.')
}
dbg.on('message', handleMessage as never)
@@ -120,6 +124,13 @@ export async function startBrowserScreencast(
await deviceMetrics.apply()
await startScreencast()
pendingUpdate = snapshotCapture.emitSnapshotFrame(true)
// Why: a page whose embedder stopped compositing never yields a frame; the owner ends on this.
const noFrameTimer = setTimeout(() => {
if (!closed && !stopping && framePacer.getSeq() === 0) {
options.onError?.('Browser stream timed out.')
}
}, NO_FRAME_TIMEOUT_MS)
void done.then(() => clearTimeout(noFrameTimer))
} catch (error) {
if (deviceMetrics.isOverridden()) {
await deviceMetrics.clear().catch(() => {})
@@ -188,7 +199,7 @@ export async function startBrowserScreencast(
return pendingUpdate
},
stop: () => {
if (closed) {
if (closed || stopping) {
return
}
stopping = true
@@ -0,0 +1,179 @@
/**
* A remote browser stream keeps the desktop window drawing for exactly as long as it lives: guest
* frames come from the embedder's compositor, which a throttled hidden window stops running.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserScreencastOptions } from '../browser/browser-screencast-stream-types'
import { createScreencastHarness } from './browser-screencast-subscriber-test-harness'
import { RuntimeBrowserCommands } from './orca-runtime-browser'
import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double'
const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({
webContentsFromId: vi.fn(),
startBrowserScreencast: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { on: vi.fn(), removeListener: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
webContents: { fromId: webContentsFromId }
}))
vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast }))
type PageStream = { options: BrowserScreencastOptions; close: () => void }
function createRig() {
const { runtime } = createScreencastHarness()
const setBackgroundThrottling = vi.fn()
const window = { webContents: { isDestroyed: () => false, setBackgroundThrottling } }
Object.assign(runtime, {
browserCommands: new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost(window))
})
const pageStreams: PageStream[] = []
// Models Chromium's asynchronous teardown: a held stop leaves the stream open until `close()`.
const stopControl = { hold: false }
startBrowserScreencast.mockImplementation(
async (_guest: unknown, options: BrowserScreencastOptions) => {
let close!: () => void
const done = new Promise<void>((resolve) => {
close = resolve
})
pageStreams.push({ options, close })
return {
stop: () => {
if (!stopControl.hold) {
close()
}
},
done,
updateViewport: vi.fn(async () => {}),
updateFrameBudget: vi.fn(async () => {})
}
}
)
const subscribe = (connectionId: string, signal?: AbortSignal) => {
const emit = vi.fn()
const done = runtime.browserScreencast(
{ worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' },
{ connectionId, clientKind: 'mobile', sendBinary: vi.fn(() => true), signal, emit }
)
const ready = async (): Promise<string> => {
await vi.waitFor(() =>
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'ready' }))
)
return emit.mock.calls.find(([event]) => event.type === 'ready')?.[0].subscriptionId
}
const eventTypes = (): string[] => emit.mock.calls.map(([event]) => event.type)
return { done, ready, eventTypes }
}
return {
runtime,
subscribe,
pageStreams,
stopControl,
throttleCalls: () => setBackgroundThrottling.mock.calls.map(([allowed]) => allowed)
}
}
describe('remote browser screencast renderer throttle lease', () => {
beforeEach(() => {
webContentsFromId.mockReset()
webContentsFromId.mockReturnValue({ isDestroyed: () => false })
startBrowserScreencast.mockReset()
})
it('lifts the window throttle before the stream starts capturing', async () => {
const rig = createRig()
let callsAtStart: boolean[] = []
const started = startBrowserScreencast.getMockImplementation()
startBrowserScreencast.mockImplementation(async (...args: unknown[]) => {
callsAtStart = rig.throttleCalls()
return started?.(...args)
})
const phone = rig.subscribe('conn-phone')
await phone.ready()
expect(callsAtStart).toEqual([false])
})
// Unsubscribe, connection close, ghost eviction and a desktop page close all end through the
// page stream's `done`, so one of them stands for all.
it('restores it when the stream ends', async () => {
const rig = createRig()
const phone = rig.subscribe('conn-phone')
const subscriptionId = await phone.ready()
rig.runtime.cleanupSubscription(subscriptionId)
await phone.done
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
})
it('restores it when the stream fails to start', async () => {
const rig = createRig()
startBrowserScreencast.mockRejectedValue(new Error('Could not attach debugger.'))
const phone = rig.subscribe('conn-phone')
await expect(phone.done).rejects.toThrow('Could not attach debugger.')
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
})
it('ends the stream on an error, then restores it', async () => {
const rig = createRig()
const phone = rig.subscribe('conn-phone')
await phone.ready()
rig.pageStreams[0].options.onError?.('Browser stream timed out.')
await phone.done
expect(phone.eventTypes()).toEqual(['ready', 'error', 'end'])
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
})
it('starts a fresh stream for a viewer that joins while the errored one tears down', async () => {
const rig = createRig()
const phone = rig.subscribe('conn-phone')
await phone.ready()
rig.stopControl.hold = true
rig.pageStreams[0].options.onError?.('Browser stream timed out.')
const guestLookups = webContentsFromId.mock.calls.length
const tablet = rig.subscribe('conn-tablet')
// Past its guest lookup, the joiner reaches the page record with no further await.
await vi.waitFor(() =>
expect(webContentsFromId.mock.calls.length).toBeGreaterThan(guestLookups)
)
await new Promise((resolve) => setImmediate(resolve))
rig.pageStreams[0].close()
await tablet.ready()
expect(startBrowserScreencast).toHaveBeenCalledTimes(2)
expect(tablet.eventTypes()).toEqual(['ready'])
await phone.done
})
it('restores it when the subscription is aborted before ready', async () => {
const rig = createRig()
let releaseStart!: () => void
const started = startBrowserScreencast.getMockImplementation()
startBrowserScreencast.mockImplementation(async (...args: unknown[]) => {
await new Promise<void>((resolve) => {
releaseStart = resolve
})
return started?.(...args)
})
const abort = new AbortController()
const phone = rig.subscribe('conn-phone', abort.signal)
await vi.waitFor(() => expect(releaseStart).toBeTypeOf('function'))
abort.abort()
releaseStart()
await phone.done
expect(phone.eventTypes()).not.toContain('ready')
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
})
})
@@ -1,9 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits'
import { BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT } from './browser-screencast-ghost-subscriber-eviction'
import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import type { RuntimeBrowserCommands } from './orca-runtime-browser'
import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double'
const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({
webContentsFromId: vi.fn(),
@@ -24,32 +23,6 @@ function deferred() {
return { promise, resolve }
}
function createCommandsHost(): RuntimeBrowserCommandHost {
const runtimeBrowserPages = new RuntimeBrowserPageRegistry()
const bridge = {
getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])),
getActivePageId: vi.fn(() => 'page-1'),
tabList: vi.fn(() => ({
tabs: [
{
browserPageId: 'page-1',
index: 0,
url: 'about:blank',
title: 'Browser',
active: true
}
]
}))
} as unknown as AgentBrowserBridge
return {
resolveWorktreeSelector: async () => ({ id: 'wt-1' }),
getAgentBrowserBridge: () => bridge,
getRuntimeBrowserPageRegistry: () => runtimeBrowserPages,
getAvailableAuthoritativeWindow: vi.fn(() => null),
getOffscreenBrowserBackend: vi.fn(() => null)
} as unknown as RuntimeBrowserCommandHost
}
describe('RuntimeBrowserCommands screencast fanout', () => {
beforeEach(() => {
webContentsFromId.mockReset()
@@ -63,7 +36,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const firstSend = vi.fn(() => false)
const secondSend = vi.fn(() => true)
const first = await commands.browserScreencast(
@@ -112,7 +85,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const sized = await commands.browserScreencast(
{
worktree: 'id:wt-1',
@@ -160,7 +133,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
updateFrameBudget: vi.fn(async () => {})
}
})
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const creatorSend = vi.fn(() => true)
await commands.browserScreencast(
{
@@ -210,7 +183,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
updateViewport: vi.fn(async () => {}),
updateFrameBudget
})
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const desktop = await commands.browserScreencast(
{
worktree: 'id:wt-1',
@@ -271,7 +244,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
updateViewport: vi.fn(async () => {})
})
const sendBinary = vi.fn(() => true)
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const started = await commands.browserScreencast(
{ worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' },
{ sendBinary }
@@ -316,7 +289,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const survivorSend = vi.fn(() => true)
const survivor: Subscription = await commands.browserScreencast(
{
@@ -374,7 +347,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
done: done.promise,
updateViewport: vi.fn(async () => {})
})
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const only: Subscription = await commands.browserScreencast(
{ worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' },
{ sendBinary: sendUntilQuit(1) }
@@ -394,7 +367,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
done: done.promise,
updateViewport: vi.fn(async () => {})
})
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
let sends = 0
// Why: this is the backpressure shape — a link that drains one frame per window must never
// be mistaken for a socket that is gone.
@@ -422,7 +395,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
done: done.promise,
updateViewport: vi.fn(async () => {})
})
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
let gateOpen = false
const joinerSend = vi.fn(() => gateOpen)
const joiner: Subscription = await commands.browserScreencast(
@@ -451,7 +424,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const ghostSend = vi.fn(() => true)
const ghost: Subscription = await commands.browserScreencast(
{
@@ -504,7 +477,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
done: done.promise,
updateViewport: vi.fn(async () => {})
})
const commands = new RuntimeBrowserCommands(createCommandsHost())
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const anonymousFirst = vi.fn(() => true)
const anonymousSecond = vi.fn(() => true)
const identified = vi.fn(() => true)
@@ -17,6 +17,7 @@ import type { BrowserEvalResult, BrowserScreencastResult } from '../../shared/ru
import { BrowserError } from '../browser/browser-error'
import { randomUUID } from 'node:crypto'
import { startBrowserScreencast } from '../browser/browser-screencast-stream'
import { rendererPublicationThrottle } from '../window/renderer-publication-throttle'
import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission'
import {
INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY,
@@ -69,6 +70,9 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
viewportOwnerSubscriptionId: null,
appliedBudget: budget
} as ActiveBrowserScreencastPage
// Why: guest frames come from the window's compositor, which a throttled hidden window stops.
const win = this.host.getAvailableAuthoritativeWindow()
const releaseThrottle = win ? rendererPublicationThrottle.acquire(win.webContents) : () => {}
record.started = startBrowserScreencast(guest, {
format: params.format,
...budget,
@@ -103,6 +107,12 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
for (const subscriber of record.subscribers.values()) {
subscriber.emit?.({ type: 'error', message })
}
// Why: every stream error is terminal; marking it stopping makes a newcomer start a fresh
// stream instead of joining this one and waiting on frames that will never come.
if (record.session) {
record.stopping = true
record.session.stop()
}
}
})
active = record
@@ -113,6 +123,7 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
return session.done
})
.finally(() => {
releaseThrottle()
if (this.activeScreencastsByPageId.get(browserPageId) === record) {
this.activeScreencastsByPageId.delete(browserPageId)
}
@@ -0,0 +1,34 @@
import { vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
// The part of the authoritative window a browser stream touches: its renderer's throttle.
export type AuthoritativeWindowDouble = {
webContents: { isDestroyed: () => boolean; setBackgroundThrottling: (allowed: boolean) => void }
}
/** A browser command host whose worktree `wt-1` has one registered page, `page-1`. */
export function createSinglePageBrowserCommandsHost(
window: AuthoritativeWindowDouble | null = null
): RuntimeBrowserCommandHost {
const runtimeBrowserPages = new RuntimeBrowserPageRegistry()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page commands read only these bridge members.
const bridge = {
getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])),
getActivePageId: vi.fn(() => 'page-1'),
tabList: vi.fn(() => ({
tabs: [
{ browserPageId: 'page-1', index: 0, url: 'about:blank', title: 'Browser', active: true }
]
}))
} as unknown as AgentBrowserBridge
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page commands read only these host members.
return {
resolveWorktreeSelector: async () => ({ id: 'wt-1' }),
getAgentBrowserBridge: () => bridge,
getRuntimeBrowserPageRegistry: () => runtimeBrowserPages,
getAvailableAuthoritativeWindow: vi.fn(() => window),
getOffscreenBrowserBackend: vi.fn(() => null)
} as unknown as RuntimeBrowserCommandHost
}