diff --git a/src/main/browser/browser-screencast-first-frame-timeout.test.ts b/src/main/browser/browser-screencast-first-frame-timeout.test.ts new file mode 100644 index 00000000000..63e92e0de27 --- /dev/null +++ b/src/main/browser/browser-screencast-first-frame-timeout.test.ts @@ -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(): Promise { + return new Promise(() => {}) +} + +function start( + webContents: ReturnType, + 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): 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 + }) +}) diff --git a/src/main/browser/browser-screencast-snapshot-capture.ts b/src/main/browser/browser-screencast-snapshot-capture.ts index b06ce8bb5cf..70c4d444983 100644 --- a/src/main/browser/browser-screencast-snapshot-capture.ts +++ b/src/main/browser/browser-screencast-snapshot-capture.ts @@ -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 diff --git a/src/main/browser/browser-screencast-stream.ts b/src/main/browser/browser-screencast-stream.ts index 237c94852e8..6456f4e3102 100644 --- a/src/main/browser/browser-screencast-stream.ts +++ b/src/main/browser/browser-screencast-stream.ts @@ -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 diff --git a/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts b/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts new file mode 100644 index 00000000000..c46ee63acd1 --- /dev/null +++ b/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts @@ -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((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 => { + 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((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])) + }) +}) diff --git a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts index 23ff4c88232..38a6f5ce489 100644 --- a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts +++ b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts @@ -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) diff --git a/src/main/runtime/runtime-browser-commands-browser-screencast.ts b/src/main/runtime/runtime-browser-commands-browser-screencast.ts index d77f2c13410..e57e5910092 100644 --- a/src/main/runtime/runtime-browser-commands-browser-screencast.ts +++ b/src/main/runtime/runtime-browser-commands-browser-screencast.ts @@ -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) } diff --git a/src/main/runtime/single-page-browser-commands-host-test-double.ts b/src/main/runtime/single-page-browser-commands-host-test-double.ts new file mode 100644 index 00000000000..dae6f73e02e --- /dev/null +++ b/src/main/runtime/single-page-browser-commands-host-test-double.ts @@ -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 +}