refactor(runtime): compose typed browser command owners (#27576)

This commit is contained in:
Neil
2026-10-14 01:02:56 -07:00
committed by GitHub
parent 230b39c279
commit b84d371a9f
59 changed files with 1911 additions and 1551 deletions
+10 -2
View File
@@ -27,7 +27,7 @@ describe('restart-survival E2E routing', () => {
// Every file below carries behavior the restart spec is the only test that exercises end to end.
it.each([
'src/main/runtime/orca-runtime.ts',
'src/main/runtime/orca-runtime-browser.ts',
'src/main/runtime/browser/browser-commands.ts',
'src/main/runtime/client-hosted-page-reconciliation-window.ts',
'src/main/runtime/runtime-browser-client-page-adoption.ts',
'src/main/runtime/runtime-browser-client-page-recovery.ts',
@@ -44,6 +44,9 @@ describe('restart-survival E2E routing', () => {
'src/shared/browser-client-host-protocol.ts'
])('routes %s', (path) => {
expect(restartSurvivalRoute.matches(path)).toBe(true)
if (path === 'src/main/runtime/browser/browser-commands.ts') {
expect(existsSync(join(projectDir, path))).toBe(true)
}
})
// The pattern is deliberately not "anything under src": routing every PR at a two-Electron spec
@@ -53,7 +56,12 @@ describe('restart-survival E2E routing', () => {
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
'src/main/terminal/pty-manager.ts',
// The status/types entries name whole files, not a suffix any longer name may end with.
'src/shared/computer-use-runtime-types.ts'
'src/shared/computer-use-runtime-types.ts',
'src/main/runtime/browser/browser-commands.test.ts',
'src/main/runtime/browser/browser-commands.test-fixture.ts',
'src/main/runtime/browser/browser-commands.fixture.ts',
'src/main/runtime/browser/browser-commands-extra.ts',
'src/main/runtime/browser/browser-navigation.ts'
])('does not route %s', (path) => {
expect(restartSurvivalRoute.matches(path)).toBe(false)
})
+4 -3
View File
@@ -393,9 +393,10 @@ export const PR_E2E_SOURCE_ROUTES = [
specs: ['tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test(
file
)
(file === 'src/main/runtime/browser/browser-commands.ts' ||
/^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime\.ts|\/runtime-(?:status|types)\.ts)/.test(
file
))
}
]
-14
View File
@@ -135,20 +135,6 @@ src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts
src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts
src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts
src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts
src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts
src/main/runtime/runtime-browser-commands-browser-clear.ts
src/main/runtime/runtime-browser-commands-browser-click.ts
src/main/runtime/runtime-browser-commands-browser-command-target-params.ts
src/main/runtime/runtime-browser-commands-browser-network-log.ts
src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts
src/main/runtime/runtime-browser-commands-browser-screencast.ts
src/main/runtime/runtime-browser-commands-browser-set-headers.ts
src/main/runtime/runtime-browser-commands-browser-tab-close.ts
src/main/runtime/runtime-browser-commands-browser-tab-create.ts
src/main/runtime/runtime-browser-commands-browser-tab-list.ts
src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts
src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts
src/main/runtime/runtime-browser-commands-state.ts
src/main/runtime/runtime-file-command-host.ts
src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts
src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { RuntimeBrowserScreencastController } from '../../../src/main/runtime/runtime-browser-screencast-controller'
import type { RuntimeBrowserCommands } from '../../../src/main/runtime/orca-runtime-browser'
import type { BrowserScreencastResult } from '../../../src/shared/runtime-types'
import type { RpcBinarySender } from '../../../src/main/runtime/rpc/rpc-binary-sender'
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
import type { RpcResponse } from './types'
@@ -14,7 +14,7 @@ describe('relay browser cancellation resource budget', () => {
let stopped = false
let frameSends = 0
let frameBytes = 0
let sendBinary: (bytes: Uint8Array) => boolean | void = () => false
let sendBinary: RpcBinarySender = () => false
let hostRun: Promise<void> | undefined
const methods: string[] = []
const cleanup = (id: string): void => {
@@ -23,24 +23,36 @@ describe('relay browser cancellation resource budget', () => {
void release?.()
}
const host = new RuntimeBrowserScreencastController({
getCommands: () =>
({
browserScreencast: async (_params, stream) => {
sendBinary = stream.sendBinary
return {
getCommands: () => ({
browserScreencast: async (_params, stream) => {
sendBinary = stream.sendBinary
return {
subscriptionId: 'server-stream',
ready: {
type: 'ready',
subscriptionId: 'server-stream',
ready: { type: 'ready', subscriptionId: 'server-stream', browserPageId: 'page' },
session: {
done: done.promise,
stop: () => {
stopped = true
done.resolve()
}
browserPageId: 'page',
format: 'jpeg',
tab: {
browserPageId: 'page',
index: 0,
url: 'about:blank',
title: 'Browser',
active: true
}
},
session: {
done: done.promise,
stop: () => {
stopped = true
done.resolve()
},
flushPendingFrame: () => {}
}
updateViewport: async () => {}
},
flushPendingFrame: () => {}
}
}) as RuntimeBrowserCommands,
}
}),
registerSubscriptionCleanup: (id, release) => subscriptions.set(id, release),
cleanupSubscription: cleanup,
getDriver: () => ({ kind: 'idle' }),
@@ -1,5 +1,5 @@
import { mkdtempSync } from 'node:fs'
import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser'
import { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+1 -1
View File
@@ -1,4 +1,4 @@
import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser'
import { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
/** The desktop factory. Importing this file is what pulls in the Chromium browser cluster. */
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RunProcessModule from '@orca/process-host'
import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts'
const { spawnProcessMock } = vi.hoisted(() => ({
spawnProcessMock: vi.fn<(spec: ProcessSpec) => ReturnType<typeof RunProcessModule.spawnProcess>>()
@@ -5,10 +5,8 @@ import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { z } from 'zod'
import { BrowserError } from '../browser/browser-error'
import type {
RuntimeBrowserCommandHost,
RuntimeBrowserCommands
} from '../runtime/orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts'
import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types'
import { readRuntimeMetadata } from '../runtime/runtime-metadata'
@@ -1,8 +1,6 @@
import { z } from 'zod'
import type {
RuntimeBrowserCommandHost,
RuntimeBrowserCommands
} from '../runtime/orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts'
import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
import { BrowserError } from '../browser/browser-error'
import {
ExternalChromiumBrowserSession,
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts'
import { BrowserError } from '../browser/browser-error'
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
import type { ExternalChromiumBrowserSession } from './external-chromium-browser-session'
+2 -4
View File
@@ -8,10 +8,8 @@ import {
setRuntimeBrowserCommandsFactory,
setRuntimeBrowserUnavailableCause
} from '../runtime/runtime-browser-commands-factory'
import type {
RuntimeBrowserCommandHost,
RuntimeBrowserCommands
} from '../runtime/orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts'
import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() }))
afterEach(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { BrowserError } from '../browser/browser-error'
import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types'
import type { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser'
import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
import {
resolveOrcadBrowserProvider,
@@ -12,18 +12,13 @@ vi.mock('electron', () => ({
webContents: { fromId: () => ({ isDestroyed: () => false }) }
}))
import { RuntimeBrowserCommandsWithBrowserSetHeaders } from './runtime-browser-commands-browser-set-headers'
import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params'
import { RuntimeBrowserDialogs } from './browser/browser-dialogs'
import { settleBrowserDialogOnLiveScreencast } from './browser-dialog-settlement'
import type { ActiveBrowserScreencastPage } from './browser/browser-screencast-contracts'
import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types'
const PAGE_ID = 'page-1'
class DialogCommandsUnderTest extends RuntimeBrowserCommandsWithBrowserSetHeaders {
registerLiveScreencast(browserPageId: string, record: ActiveBrowserScreencastPage): void {
this.activeScreencastsByPageId.set(browserPageId, record)
}
}
function streamingPage(session: BrowserScreencastSession): ActiveBrowserScreencastPage {
return {
format: 'jpeg',
@@ -48,11 +43,19 @@ function createCommands(settleDialog: (accept: boolean, promptText?: string) =>
dialogAccept: vi.fn(async () => ({ via: 'bridge' })),
dialogDismiss: vi.fn(async () => ({ via: 'bridge' }))
}
const host = { getAgentBrowserBridge: () => bridge }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dialog path reads only getAgentBrowserBridge off the host.
const commands = new DialogCommandsUnderTest(host as never)
const activeScreencastsByPageId = new Map<string, ActiveBrowserScreencastPage>()
const commands = new RuntimeBrowserDialogs(
{
resolveBrowserCommandTarget: async (params) => ({ browserPageId: params.page }),
requireAgentBrowserBridge: () => bridge
},
{
settleDialog: (pageId, accept, promptText) =>
settleBrowserDialogOnLiveScreencast(activeScreencastsByPageId, pageId, accept, promptText)
}
)
const registerStream = (): void => {
commands.registerLiveScreencast(
activeScreencastsByPageId.set(
PAGE_ID,
streamingPage({
stop: () => {},
@@ -1,4 +1,4 @@
import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params'
import type { ActiveBrowserScreencastPage } from './browser/browser-screencast-contracts'
/**
* What `browser.dialogAccept` and `browser.dialogDismiss` answer, on either path.
@@ -5,7 +5,7 @@
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 { RuntimeBrowserCommands } from './browser/browser-commands'
import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double'
const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({
@@ -1,4 +1,4 @@
import type { ActiveBrowserScreencastSubscriber } from './runtime-browser-commands-browser-command-target-params'
import type { ActiveBrowserScreencastSubscriber } from './browser/browser-screencast-contracts'
import { recordScreencastSubscriberSend } from './browser-screencast-ghost-subscriber-eviction'
import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission'
@@ -194,7 +194,7 @@ describe('browser.tabCreate caller navigation', () => {
beforeAll(async () => {
// Why: constructing the browser commands is what pulls the Chromium cluster in, so production
// installs this at the Electron entry and a Node host leaves the RPCs rejecting.
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
return () => setRuntimeBrowserCommandsFactory(null)
})
@@ -3,7 +3,7 @@
* the folder-aware resolver, so `id:folder:…` failed with selector_not_found on the server path.
*/
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
vi.mock('electron', () => ({
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
@@ -40,7 +40,7 @@ function createHost(overrides: Record<string, unknown>): RuntimeBrowserCommandHo
describe('browser.tabCreate on a folder workspace', () => {
it('creates a server-hosted page for the folder workspace', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const createTab = vi.fn(async () => ({ browserPageId: 'page-folder' }))
const host = createHost({
getOffscreenBrowserBackend: vi.fn(() => ({ createTab }))
@@ -2,7 +2,7 @@
// hand-rolls its own bookkeeping is how a client-placed page once lost its targetGroupId.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import {
BROWSER_TAB_CREATE_PLACEMENT_KINDS,
@@ -350,7 +350,7 @@ describe('browser tab-create placement census', () => {
})
it('moves a user-created offscreen tab into the clicked split group', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const markHeadlessBrowserSessionTabActive = vi.fn()
const commands = new RuntimeBrowserCommands(
createCommandHost({
@@ -387,7 +387,7 @@ describe('browser tab-create placement census', () => {
})
it('leaves renderer tab focus to the create IPC instead of the session-tab marker', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const webContents = { send: vi.fn() }
webContents.send = vi.fn((_channel: string, data: { requestId: string }) => {
const handler = ipcMainOnMock.mock.calls.find(
@@ -424,7 +424,7 @@ describe('browser tab-create placement census', () => {
})
it('does not focus the host renderer for a create addressed to the caller', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const webContents = { send: vi.fn() }
webContents.send = vi.fn((_channel: string, data: { requestId: string }) => {
const handler = ipcMainOnMock.mock.calls.find(
@@ -457,7 +457,7 @@ describe('browser tab-create placement census', () => {
})
it('still places a caller-addressed client page in the clicked split group', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const markHeadlessBrowserSessionTabActive = vi.fn()
const commands = new RuntimeBrowserCommands(
createCommandHost({
@@ -623,7 +623,7 @@ describe('browser tab-switch placement census', () => {
}
it('marks a focused client switch active and leaves an unfocused one alone', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const markHeadlessBrowserSessionTabActive = vi.fn()
const notifyHeadlessBrowserSessionTabsChanged = vi.fn()
const { host, registry } = createClientSwitchHost({
@@ -651,7 +651,7 @@ describe('browser tab-switch placement census', () => {
})
it('marks a focused bridge switch active and leaves an unfocused one alone', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const markHeadlessBrowserSessionTabActive = vi.fn()
const bridge = {
getRegisteredTabs: vi.fn(() => new Map([['page-server', 100]])),
@@ -0,0 +1,80 @@
import type {
BrowserScreenshotResult,
BrowserCaptureStartResult,
BrowserCaptureStopResult,
BrowserConsoleResult,
BrowserPdfResult,
BrowserEvalResult
} from '../../../shared/runtime-types'
import type { BrowserCommandTargetParams } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
export class RuntimeBrowserCapture {
constructor(
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget'
>
) {}
async browserScreenshot(
params: {
format?: 'png' | 'jpeg'
} & BrowserCommandTargetParams
): Promise<BrowserScreenshotResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.screenshot(params.format, target.worktreeId, target.browserPageId)
}
async browserPdf(params: BrowserCommandTargetParams): Promise<BrowserPdfResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId)
}
async browserFullScreenshot(
params: {
format?: 'png' | 'jpeg'
} & BrowserCommandTargetParams
): Promise<BrowserScreenshotResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.fullPageScreenshot(params.format, target.worktreeId, target.browserPageId)
}
async browserCaptureStart(
params: BrowserCommandTargetParams
): Promise<BrowserCaptureStartResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.captureStart(target.worktreeId, target.browserPageId)
}
async browserCaptureStop(params: BrowserCommandTargetParams): Promise<BrowserCaptureStopResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.captureStop(target.worktreeId, target.browserPageId)
}
async browserConsoleLog(
params: { limit?: number } & BrowserCommandTargetParams
): Promise<BrowserConsoleResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.consoleLog(params.limit, target.worktreeId, target.browserPageId)
}
async browserEval(
params: { expression: string } & BrowserCommandTargetParams
): Promise<BrowserEvalResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.evaluate(params.expression, target.worktreeId, target.browserPageId)
}
}
@@ -0,0 +1,57 @@
import type { AgentBrowserBridge } from '../../browser/agent-browser-bridge'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { BrowserNetworkExecutionHost } from '../../../shared/browser-client-host-protocol'
import type { BrowserHostLeaseRegistry } from '../browser-host-lease-registry'
import type { RuntimeBrowserPageRegistry } from '../runtime-browser-page-registry'
import type { BrowserWindow } from 'electron'
import type { BrowserBackend } from '../../browser/browser-backend'
import type { BrowserSessionTabSelectionOptions } from '../browser-tab-create-publication'
export type BrowserCommandTargetParams = {
worktree?: string
page?: string
}
export type ResolvedBrowserCommandTarget = {
worktreeId?: string
browserPageId?: string
}
export type ResolvedBrowserPageWebContents = {
browserPageId: string
webContents: Electron.WebContents
}
export type RuntimeBrowserCommandHost = {
getAgentBrowserBridge(): AgentBrowserBridge | null
resolveWorktreeSelector(selector: string): Promise<{
id: string
repoId?: string
hostId?: ExecutionHostId
}>
resolveBrowserWorkspace(selector: string): Promise<{
id: string
repoId?: string
hostId?: ExecutionHostId
}>
resolveBrowserNetworkExecutionHost(worktree?: {
id: string
repoId?: string
hostId?: ExecutionHostId
}): BrowserNetworkExecutionHost | Promise<BrowserNetworkExecutionHost>
getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry
getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry
getAuthoritativeWindow(): BrowserWindow
getAvailableAuthoritativeWindow(): BrowserWindow | null
// Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing.
getOffscreenBrowserBackend(): BrowserBackend | null
// Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal.
markHeadlessBrowserSessionTabActive?(
worktreeId: string | undefined,
browserPageId: string,
options?: BrowserSessionTabSelectionOptions
): void
notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void
/** True when a runtime-owned session row for that page existed and was retired. */
retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void
}
@@ -1,26 +1,30 @@
// @ts-nocheck -- mechanically split class members.
import type {
BrowserCommandTargetParams,
ResolvedBrowserCommandTarget,
ResolvedBrowserPageWebContents,
RuntimeBrowserCommandHost
} from './runtime-browser-commands-browser-command-target-params'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { BrowserError } from '../browser/browser-error'
import type { AgentBrowserBridge } from '../../browser/agent-browser-bridge'
import { BrowserError } from '../../browser/browser-error'
import { webContents } from 'electron'
import {
waitForTabRegistration,
waitForWorktreeTabRegistration
} from '../ipc/browser-tab-registration-wait'
import type { BrowserSnapshotResult } from '../../shared/runtime-types'
import { RuntimeBrowserCommandsState } from './runtime-browser-commands-state'
} from '../../ipc/browser-tab-registration-wait'
import type { BrowserSnapshotResult } from '../../../shared/runtime-types'
import type {
BrowserCommandTargetParams,
RuntimeBrowserCommandHost,
ResolvedBrowserCommandTarget,
ResolvedBrowserPageWebContents
} from './browser-command-contracts'
export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends RuntimeBrowserCommandsState {
constructor(private readonly host: RuntimeBrowserCommandHost) {
super()
}
export class RuntimeBrowserCommandTargets {
constructor(
private readonly host: Pick<
RuntimeBrowserCommandHost,
| 'getAgentBrowserBridge'
| 'resolveWorktreeSelector'
| 'getAuthoritativeWindow'
| 'retireRuntimeOwnedBrowserSessionTab'
>
) {}
protected requireAgentBrowserBridge(): AgentBrowserBridge {
requireAgentBrowserBridge(): AgentBrowserBridge {
const bridge = this.host.getAgentBrowserBridge()
if (!bridge) {
throw new BrowserError('browser_no_tab', 'No browser session is active')
@@ -28,25 +32,15 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
return bridge
}
/**
* Retires a session row for a page nothing here can close any more.
*
* A client-hosted page whose runtime record is gone -- released as unrecoverable, or created by a
* build that kept its records in memory only -- still leaves a row every paired device can see,
* and every close path below is keyed on a live guest this runtime does not have. Without this
* the row's X fails closed and the ghost outlives the browser it named.
*/
private retireGhostBrowserSessionRow(
worktreeId: string | undefined,
browserPageId: string
): boolean {
// A retained session row can outlive the page its host was able to close.
retireGhostBrowserSessionRow(worktreeId: string | undefined, browserPageId: string): boolean {
return (
worktreeId !== undefined &&
this.host.retireRuntimeOwnedBrowserSessionTab?.(worktreeId, browserPageId) === true
)
}
protected hasLiveRegisteredBrowserTab(
private hasLiveRegisteredBrowserTab(
bridge: AgentBrowserBridge,
worktreeId: string | undefined
): boolean {
@@ -59,7 +53,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
return false
}
protected hasLiveRegisteredBrowserPage(
private hasLiveRegisteredBrowserPage(
bridge: AgentBrowserBridge,
worktreeId: string | undefined,
browserPageId: string
@@ -73,7 +67,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
}
// Why: the CLI sends selectors (e.g. "path:/...") but the bridge keys tabs by "repoId::path"; resolve to that store-compatible id.
private async resolveBrowserWorktreeId(selector?: string): Promise<string | undefined> {
async resolveBrowserWorktreeId(selector?: string): Promise<string | undefined> {
if (!selector) {
// Why: after restart, webviews mount only when the pane is visible; activate the view so persisted tabs become operable via registerGuest.
const bridge = this.host.getAgentBrowserBridge()
@@ -100,7 +94,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
return worktreeId
}
protected async resolveBrowserCommandTarget(
async resolveBrowserCommandTarget(
params: BrowserCommandTargetParams
): Promise<ResolvedBrowserCommandTarget> {
const browserPageId =
@@ -129,7 +123,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
}
}
protected resolveBrowserPageWebContents(
resolveBrowserPageWebContents(
worktreeId: string | undefined,
browserPageId: string | undefined
): ResolvedBrowserPageWebContents {
@@ -164,7 +158,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
await waitForWorktreeTabRegistration(worktreeId)
}
protected async ensureBrowserPageActive(
async ensureBrowserPageActive(
worktreeId: string | undefined,
browserPageId: string
): Promise<void> {
@@ -176,8 +170,8 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
await waitForTabRegistration(browserPageId)
}
// Why: helper-driven clicks can bypass Electron navigation events; push authoritative URL/title updates after automation.
private notifyRendererNavigation(browserPageId: string, url: string, title: string): void {
// Why: automation clicks can bypass Electron navigation events; push authoritative URL/title updates after automation.
notifyRendererNavigation(browserPageId: string, url: string, title: string): void {
try {
const win = this.host.getAuthoritativeWindow()
win.webContents.send('browser:navigation-update', { browserPageId, url, title })
@@ -187,10 +181,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime
}
// Why: carry worktreeId (not a global setActiveWorktree) so one agent's --focus can't steal the screen from another agent's parallel worktree.
private notifyRendererBrowserPaneFocus(
worktreeId: string | undefined,
browserPageId: string
): void {
notifyRendererBrowserPaneFocus(worktreeId: string | undefined, browserPageId: string): void {
try {
const win = this.host.getAuthoritativeWindow()
win.webContents.send('browser:pane-focus', {
@@ -0,0 +1,202 @@
import type { RuntimeBrowserCommandHost } from './browser-command-contracts'
import { RuntimeBrowserCommandTargets } from './browser-command-targets'
import { RuntimeBrowserTabCatalog } from './browser-tab-catalog'
import { RuntimeBrowserNavigation } from './browser-navigation'
import { RuntimeBrowserElementActions } from './browser-element-actions'
import { RuntimeBrowserPointerInput } from './browser-pointer-input'
import { RuntimeBrowserCapture } from './browser-capture'
import { RuntimeBrowserNetwork } from './browser-network'
import { RuntimeBrowserStorage } from './browser-storage'
import { RuntimeBrowserTabQueries } from './browser-tab-queries'
import { RuntimeBrowserTabCreation } from './browser-tab-creation'
import { RuntimeBrowserTabClose } from './browser-tab-close'
import { RuntimeBrowserProfiles } from './browser-profiles'
import { RuntimeBrowserScreencasts } from './browser-screencasts'
import { RuntimeBrowserDialogs } from './browser-dialogs'
export class RuntimeBrowserCommands {
readonly browserSnapshot: RuntimeBrowserCommandTargets['browserSnapshot']
readonly browserClick: RuntimeBrowserNavigation['browserClick']
readonly browserGoto: RuntimeBrowserNavigation['browserGoto']
readonly browserBack: RuntimeBrowserNavigation['browserBack']
readonly browserReload: RuntimeBrowserNavigation['browserReload']
readonly browserFill: RuntimeBrowserElementActions['browserFill']
readonly browserType: RuntimeBrowserElementActions['browserType']
readonly browserSelect: RuntimeBrowserElementActions['browserSelect']
readonly browserScroll: RuntimeBrowserElementActions['browserScroll']
readonly browserHover: RuntimeBrowserElementActions['browserHover']
readonly browserDrag: RuntimeBrowserElementActions['browserDrag']
readonly browserUpload: RuntimeBrowserElementActions['browserUpload']
readonly browserWait: RuntimeBrowserElementActions['browserWait']
readonly browserCheck: RuntimeBrowserElementActions['browserCheck']
readonly browserFocus: RuntimeBrowserElementActions['browserFocus']
readonly browserClear: RuntimeBrowserElementActions['browserClear']
readonly browserSelectAll: RuntimeBrowserElementActions['browserSelectAll']
readonly browserKeypress: RuntimeBrowserElementActions['browserKeypress']
readonly browserDblclick: RuntimeBrowserElementActions['browserDblclick']
readonly browserScrollIntoView: RuntimeBrowserElementActions['browserScrollIntoView']
readonly browserGet: RuntimeBrowserElementActions['browserGet']
readonly browserIs: RuntimeBrowserElementActions['browserIs']
readonly browserFind: RuntimeBrowserElementActions['browserFind']
readonly browserHighlight: RuntimeBrowserElementActions['browserHighlight']
readonly browserExec: RuntimeBrowserElementActions['browserExec']
readonly browserKeyboardInsertText: RuntimeBrowserPointerInput['browserKeyboardInsertText']
readonly browserMouseMove: RuntimeBrowserPointerInput['browserMouseMove']
readonly browserMouseDown: RuntimeBrowserPointerInput['browserMouseDown']
readonly browserMouseClick: RuntimeBrowserPointerInput['browserMouseClick']
readonly browserMouseUp: RuntimeBrowserPointerInput['browserMouseUp']
readonly browserMouseWheel: RuntimeBrowserPointerInput['browserMouseWheel']
readonly browserScreenshot: RuntimeBrowserCapture['browserScreenshot']
readonly browserPdf: RuntimeBrowserCapture['browserPdf']
readonly browserFullScreenshot: RuntimeBrowserCapture['browserFullScreenshot']
readonly browserCaptureStart: RuntimeBrowserCapture['browserCaptureStart']
readonly browserCaptureStop: RuntimeBrowserCapture['browserCaptureStop']
readonly browserConsoleLog: RuntimeBrowserCapture['browserConsoleLog']
readonly browserEval: RuntimeBrowserCapture['browserEval']
readonly browserNetworkLog: RuntimeBrowserNetwork['browserNetworkLog']
readonly browserForward: RuntimeBrowserNavigation['browserForward']
readonly browserCookieGet: RuntimeBrowserNetwork['browserCookieGet']
readonly browserCookieSet: RuntimeBrowserNetwork['browserCookieSet']
readonly browserCookieDelete: RuntimeBrowserNetwork['browserCookieDelete']
readonly browserSetViewport: RuntimeBrowserNetwork['browserSetViewport']
readonly browserSetGeolocation: RuntimeBrowserNetwork['browserSetGeolocation']
readonly browserInterceptEnable: RuntimeBrowserNetwork['browserInterceptEnable']
readonly browserInterceptDisable: RuntimeBrowserNetwork['browserInterceptDisable']
readonly browserInterceptList: RuntimeBrowserNetwork['browserInterceptList']
readonly browserSetDevice: RuntimeBrowserNetwork['browserSetDevice']
readonly browserSetOffline: RuntimeBrowserNetwork['browserSetOffline']
readonly browserSetHeaders: RuntimeBrowserNetwork['browserSetHeaders']
readonly browserSetCredentials: RuntimeBrowserNetwork['browserSetCredentials']
readonly browserSetMedia: RuntimeBrowserNetwork['browserSetMedia']
readonly browserDownload: RuntimeBrowserNetwork['browserDownload']
readonly browserClipboardRead: RuntimeBrowserStorage['browserClipboardRead']
readonly browserClipboardWrite: RuntimeBrowserStorage['browserClipboardWrite']
readonly browserStorageLocalGet: RuntimeBrowserStorage['browserStorageLocalGet']
readonly browserStorageLocalSet: RuntimeBrowserStorage['browserStorageLocalSet']
readonly browserStorageLocalClear: RuntimeBrowserStorage['browserStorageLocalClear']
readonly browserStorageSessionGet: RuntimeBrowserStorage['browserStorageSessionGet']
readonly browserStorageSessionSet: RuntimeBrowserStorage['browserStorageSessionSet']
readonly browserStorageSessionClear: RuntimeBrowserStorage['browserStorageSessionClear']
readonly browserTabList: RuntimeBrowserTabQueries['browserTabList']
readonly browserProceedCertificate: RuntimeBrowserTabQueries['browserProceedCertificate']
readonly browserTabShow: RuntimeBrowserTabQueries['browserTabShow']
readonly browserTabCurrent: RuntimeBrowserTabQueries['browserTabCurrent']
readonly browserTabSwitch: RuntimeBrowserTabQueries['browserTabSwitch']
readonly browserTabCreate: RuntimeBrowserTabCreation['browserTabCreate']
readonly browserOpenUrlOnClient: RuntimeBrowserTabCreation['browserOpenUrlOnClient']
readonly browserTabClose: RuntimeBrowserTabClose['browserTabClose']
readonly browserTabSetProfile: RuntimeBrowserProfiles['browserTabSetProfile']
readonly browserTabProfileShow: RuntimeBrowserProfiles['browserTabProfileShow']
readonly browserTabProfileClone: RuntimeBrowserProfiles['browserTabProfileClone']
readonly browserProfileList: RuntimeBrowserProfiles['browserProfileList']
readonly browserProfileCreate: RuntimeBrowserProfiles['browserProfileCreate']
readonly browserProfileDelete: RuntimeBrowserProfiles['browserProfileDelete']
readonly browserScreencast: RuntimeBrowserScreencasts['browserScreencast']
readonly browserDialogAccept: RuntimeBrowserDialogs['browserDialogAccept']
readonly browserDialogDismiss: RuntimeBrowserDialogs['browserDialogDismiss']
readonly browserProfileDetectBrowsers: RuntimeBrowserProfiles['browserProfileDetectBrowsers']
readonly browserProfileImportFromBrowser: RuntimeBrowserProfiles['browserProfileImportFromBrowser']
readonly browserProfileClearDefaultCookies: RuntimeBrowserProfiles['browserProfileClearDefaultCookies']
constructor(host: RuntimeBrowserCommandHost) {
const targets = new RuntimeBrowserCommandTargets(host)
const catalog = new RuntimeBrowserTabCatalog(host, targets)
const navigation = new RuntimeBrowserNavigation(host, targets)
const elements = new RuntimeBrowserElementActions(targets)
const pointer = new RuntimeBrowserPointerInput(targets)
const capture = new RuntimeBrowserCapture(targets)
const network = new RuntimeBrowserNetwork(targets)
const storage = new RuntimeBrowserStorage(targets)
const queries = new RuntimeBrowserTabQueries(host, targets, catalog)
const creation = new RuntimeBrowserTabCreation(host, targets)
const close = new RuntimeBrowserTabClose(queries, host, targets, catalog)
const profiles = new RuntimeBrowserProfiles(host, targets, catalog)
const screencasts = new RuntimeBrowserScreencasts(host, targets, catalog)
const dialogs = new RuntimeBrowserDialogs(targets, screencasts)
this.browserProfileDetectBrowsers = profiles.browserProfileDetectBrowsers.bind(profiles)
this.browserProfileImportFromBrowser = profiles.browserProfileImportFromBrowser.bind(profiles)
this.browserProfileClearDefaultCookies =
profiles.browserProfileClearDefaultCookies.bind(profiles)
this.browserSnapshot = targets.browserSnapshot.bind(targets)
this.browserClick = navigation.browserClick.bind(navigation)
this.browserGoto = navigation.browserGoto.bind(navigation)
this.browserBack = navigation.browserBack.bind(navigation)
this.browserReload = navigation.browserReload.bind(navigation)
this.browserFill = elements.browserFill.bind(elements)
this.browserType = elements.browserType.bind(elements)
this.browserSelect = elements.browserSelect.bind(elements)
this.browserScroll = elements.browserScroll.bind(elements)
this.browserHover = elements.browserHover.bind(elements)
this.browserDrag = elements.browserDrag.bind(elements)
this.browserUpload = elements.browserUpload.bind(elements)
this.browserWait = elements.browserWait.bind(elements)
this.browserCheck = elements.browserCheck.bind(elements)
this.browserFocus = elements.browserFocus.bind(elements)
this.browserClear = elements.browserClear.bind(elements)
this.browserSelectAll = elements.browserSelectAll.bind(elements)
this.browserKeypress = elements.browserKeypress.bind(elements)
this.browserDblclick = elements.browserDblclick.bind(elements)
this.browserScrollIntoView = elements.browserScrollIntoView.bind(elements)
this.browserGet = elements.browserGet.bind(elements)
this.browserIs = elements.browserIs.bind(elements)
this.browserFind = elements.browserFind.bind(elements)
this.browserHighlight = elements.browserHighlight.bind(elements)
this.browserExec = elements.browserExec.bind(elements)
this.browserKeyboardInsertText = pointer.browserKeyboardInsertText.bind(pointer)
this.browserMouseMove = pointer.browserMouseMove.bind(pointer)
this.browserMouseDown = pointer.browserMouseDown.bind(pointer)
this.browserMouseClick = pointer.browserMouseClick.bind(pointer)
this.browserMouseUp = pointer.browserMouseUp.bind(pointer)
this.browserMouseWheel = pointer.browserMouseWheel.bind(pointer)
this.browserScreenshot = capture.browserScreenshot.bind(capture)
this.browserPdf = capture.browserPdf.bind(capture)
this.browserFullScreenshot = capture.browserFullScreenshot.bind(capture)
this.browserCaptureStart = capture.browserCaptureStart.bind(capture)
this.browserCaptureStop = capture.browserCaptureStop.bind(capture)
this.browserConsoleLog = capture.browserConsoleLog.bind(capture)
this.browserEval = capture.browserEval.bind(capture)
this.browserNetworkLog = network.browserNetworkLog.bind(network)
this.browserForward = navigation.browserForward.bind(navigation)
this.browserCookieGet = network.browserCookieGet.bind(network)
this.browserCookieSet = network.browserCookieSet.bind(network)
this.browserCookieDelete = network.browserCookieDelete.bind(network)
this.browserSetViewport = network.browserSetViewport.bind(network)
this.browserSetGeolocation = network.browserSetGeolocation.bind(network)
this.browserInterceptEnable = network.browserInterceptEnable.bind(network)
this.browserInterceptDisable = network.browserInterceptDisable.bind(network)
this.browserInterceptList = network.browserInterceptList.bind(network)
this.browserSetDevice = network.browserSetDevice.bind(network)
this.browserSetOffline = network.browserSetOffline.bind(network)
this.browserSetHeaders = network.browserSetHeaders.bind(network)
this.browserSetCredentials = network.browserSetCredentials.bind(network)
this.browserSetMedia = network.browserSetMedia.bind(network)
this.browserDownload = network.browserDownload.bind(network)
this.browserClipboardRead = storage.browserClipboardRead.bind(storage)
this.browserClipboardWrite = storage.browserClipboardWrite.bind(storage)
this.browserStorageLocalGet = storage.browserStorageLocalGet.bind(storage)
this.browserStorageLocalSet = storage.browserStorageLocalSet.bind(storage)
this.browserStorageLocalClear = storage.browserStorageLocalClear.bind(storage)
this.browserStorageSessionGet = storage.browserStorageSessionGet.bind(storage)
this.browserStorageSessionSet = storage.browserStorageSessionSet.bind(storage)
this.browserStorageSessionClear = storage.browserStorageSessionClear.bind(storage)
this.browserTabList = queries.browserTabList.bind(queries)
this.browserProceedCertificate = queries.browserProceedCertificate.bind(queries)
this.browserTabShow = queries.browserTabShow.bind(queries)
this.browserTabCurrent = queries.browserTabCurrent.bind(queries)
this.browserTabSwitch = queries.browserTabSwitch.bind(queries)
this.browserTabCreate = creation.browserTabCreate.bind(creation)
this.browserOpenUrlOnClient = creation.browserOpenUrlOnClient.bind(creation)
this.browserTabClose = close.browserTabClose.bind(close)
this.browserTabSetProfile = profiles.browserTabSetProfile.bind(profiles)
this.browserTabProfileShow = profiles.browserTabProfileShow.bind(profiles)
this.browserTabProfileClone = profiles.browserTabProfileClone.bind(profiles)
this.browserProfileList = profiles.browserProfileList.bind(profiles)
this.browserProfileCreate = profiles.browserProfileCreate.bind(profiles)
this.browserProfileDelete = profiles.browserProfileDelete.bind(profiles)
this.browserScreencast = screencasts.browserScreencast.bind(screencasts)
this.browserDialogAccept = dialogs.browserDialogAccept.bind(dialogs)
this.browserDialogDismiss = dialogs.browserDialogDismiss.bind(dialogs)
}
}
@@ -0,0 +1,37 @@
import type { AgentBrowserBridge } from '../../browser/agent-browser-bridge'
import type { RuntimeBrowserScreencasts } from './browser-screencasts'
import { browserDialogSettledResult, type BrowserDialogResult } from '../browser-dialog-settlement'
import type { BrowserCommandTargetParams } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
type BrowserDialogTargets = Pick<RuntimeBrowserCommandTargets, 'resolveBrowserCommandTarget'> & {
requireAgentBrowserBridge(): Pick<AgentBrowserBridge, 'dialogAccept' | 'dialogDismiss'>
}
export class RuntimeBrowserDialogs {
constructor(
private readonly targets: BrowserDialogTargets,
private readonly screencasts: Pick<RuntimeBrowserScreencasts, 'settleDialog'>
) {}
async browserDialogAccept(
params: { text?: string } & BrowserCommandTargetParams
): Promise<BrowserDialogResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
if (!(await this.screencasts.settleDialog(target.browserPageId, true, params.text))) {
await this.targets
.requireAgentBrowserBridge()
.dialogAccept(params.text, target.worktreeId, target.browserPageId)
}
return browserDialogSettledResult(true)
}
async browserDialogDismiss(params: BrowserCommandTargetParams): Promise<BrowserDialogResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
if (!(await this.screencasts.settleDialog(target.browserPageId, false))) {
await this.targets
.requireAgentBrowserBridge()
.dialogDismiss(target.worktreeId, target.browserPageId)
}
return browserDialogSettledResult(false)
}
}
@@ -0,0 +1,237 @@
import type {
BrowserFillResult,
BrowserScrollResult,
BrowserSelectResult,
BrowserTypeResult,
BrowserCheckResult,
BrowserDragResult,
BrowserFocusResult,
BrowserHoverResult,
BrowserUploadResult,
BrowserWaitResult,
BrowserClearResult,
BrowserKeypressResult,
BrowserSelectAllResult
} from '../../../shared/runtime-types'
import type { BrowserCommandTargetParams } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
export class RuntimeBrowserElementActions {
constructor(
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget'
>
) {}
async browserFill(
params: {
element: string
value: string
} & BrowserCommandTargetParams
): Promise<BrowserFillResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.fill(params.element, params.value, target.worktreeId, target.browserPageId)
}
async browserType(
params: { input: string } & BrowserCommandTargetParams
): Promise<BrowserTypeResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.type(params.input, target.worktreeId, target.browserPageId)
}
async browserSelect(
params: {
element: string
value: string
} & BrowserCommandTargetParams
): Promise<BrowserSelectResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.select(params.element, params.value, target.worktreeId, target.browserPageId)
}
async browserScroll(
params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams
): Promise<BrowserScrollResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.scroll(params.direction, params.amount, target.worktreeId, target.browserPageId)
}
async browserHover(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserHoverResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.hover(params.element, target.worktreeId, target.browserPageId)
}
async browserDrag(
params: {
from: string
to: string
} & BrowserCommandTargetParams
): Promise<BrowserDragResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.drag(params.from, params.to, target.worktreeId, target.browserPageId)
}
async browserUpload(
params: { element: string; files: string[] } & BrowserCommandTargetParams
): Promise<BrowserUploadResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.upload(params.element, params.files, target.worktreeId, target.browserPageId)
}
async browserWait(
params: {
selector?: string
timeout?: number
text?: string
url?: string
load?: string
fn?: string
state?: string
} & BrowserCommandTargetParams
): Promise<BrowserWaitResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
const { worktree: _, page: __, ...options } = params
return this.targets
.requireAgentBrowserBridge()
.wait(options, target.worktreeId, target.browserPageId)
}
async browserCheck(
params: { element: string; checked: boolean } & BrowserCommandTargetParams
): Promise<BrowserCheckResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.check(params.element, params.checked, target.worktreeId, target.browserPageId)
}
async browserFocus(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserFocusResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.focus(params.element, target.worktreeId, target.browserPageId)
}
async browserClear(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserClearResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.clear(params.element, target.worktreeId, target.browserPageId)
}
async browserSelectAll(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserSelectAllResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.selectAll(params.element, target.worktreeId, target.browserPageId)
}
async browserKeypress(
params: { key: string } & BrowserCommandTargetParams
): Promise<BrowserKeypressResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.keypress(params.key, target.worktreeId, target.browserPageId)
}
async browserDblclick(
params: { element: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.dblclick(params.element, target.worktreeId, target.browserPageId)
}
async browserScrollIntoView(
params: { element: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.scrollIntoView(params.element, target.worktreeId, target.browserPageId)
}
async browserGet(
params: {
what: string
selector?: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.get(params.what, params.selector, target.worktreeId, target.browserPageId)
}
async browserIs(
params: { what: string; selector: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.is(params.what, params.selector, target.worktreeId, target.browserPageId)
}
async browserFind(
params: {
locator: string
value: string
action: string
text?: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.find(
params.locator,
params.value,
params.action,
params.text,
target.worktreeId,
target.browserPageId
)
}
async browserHighlight(
params: { selector: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.highlight(params.selector, target.worktreeId, target.browserPageId)
}
async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.exec(params.command, target.worktreeId, target.browserPageId)
}
}
@@ -0,0 +1,81 @@
import type {
BrowserBackResult,
BrowserClickResult,
BrowserGotoResult,
BrowserReloadResult
} from '../../../shared/runtime-types'
import type {
BrowserCommandTargetParams,
RuntimeBrowserCommandHost
} from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
export class RuntimeBrowserNavigation {
constructor(
private readonly host: Pick<
RuntimeBrowserCommandHost,
'getAvailableAuthoritativeWindow' | 'notifyHeadlessBrowserSessionTabsChanged'
>,
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' | 'notifyRendererNavigation'
>
) {}
async browserClick(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserClickResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
const bridge = this.targets.requireAgentBrowserBridge()
const result = await bridge.click(params.element, target.worktreeId, target.browserPageId)
// Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page.
const page = bridge.getPageInfo(target.worktreeId, target.browserPageId)
if (page) {
this.targets.notifyRendererNavigation(page.browserPageId, page.url, page.title)
}
return result
}
async browserGoto(
params: { url: string } & BrowserCommandTargetParams
): Promise<BrowserGotoResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
const bridge = this.targets.requireAgentBrowserBridge()
const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId)
const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
if (pageId) {
this.targets.notifyRendererNavigation(pageId, result.url, result.title)
}
if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) {
this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId)
}
return result
}
async browserBack(params: BrowserCommandTargetParams): Promise<BrowserBackResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
const bridge = this.targets.requireAgentBrowserBridge()
const result = await bridge.back(target.worktreeId, target.browserPageId)
const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
if (pageId) {
this.targets.notifyRendererNavigation(pageId, result.url, result.title)
}
return result
}
async browserReload(params: BrowserCommandTargetParams): Promise<BrowserReloadResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
const bridge = this.targets.requireAgentBrowserBridge()
const result = await bridge.reload(target.worktreeId, target.browserPageId)
const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
if (pageId) {
this.targets.notifyRendererNavigation(pageId, result.url, result.title)
}
return result
}
async browserForward(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId)
}
}
+198
View File
@@ -0,0 +1,198 @@
import type {
BrowserNetworkLogResult,
BrowserCookieDeleteResult,
BrowserCookieGetResult,
BrowserCookieSetResult,
BrowserGeolocationResult,
BrowserInterceptDisableResult,
BrowserInterceptEnableResult,
BrowserViewportResult
} from '../../../shared/runtime-types'
import type { BrowserCommandTargetParams } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
export class RuntimeBrowserNetwork {
constructor(
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget'
>
) {}
async browserNetworkLog(
params: { limit?: number } & BrowserCommandTargetParams
): Promise<BrowserNetworkLogResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.networkLog(params.limit, target.worktreeId, target.browserPageId)
}
async browserCookieGet(
params: { url?: string } & BrowserCommandTargetParams
): Promise<BrowserCookieGetResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.cookieGet(params.url, target.worktreeId, target.browserPageId)
}
async browserCookieSet(
params: {
name: string
value: string
domain?: string
path?: string
secure?: boolean
httpOnly?: boolean
sameSite?: string
expires?: number
} & BrowserCommandTargetParams
): Promise<BrowserCookieSetResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.cookieSet(params, target.worktreeId, target.browserPageId)
}
async browserCookieDelete(
params: {
name: string
domain?: string
url?: string
} & BrowserCommandTargetParams
): Promise<BrowserCookieDeleteResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.cookieDelete(params.name, params.domain, params.url, target.worktreeId, target.browserPageId)
}
async browserSetViewport(
params: {
width: number
height: number
deviceScaleFactor?: number
mobile?: boolean
} & BrowserCommandTargetParams
): Promise<BrowserViewportResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.setViewport(
params.width,
params.height,
params.deviceScaleFactor,
params.mobile,
target.worktreeId,
target.browserPageId
)
}
async browserSetGeolocation(
params: {
latitude: number
longitude: number
accuracy?: number
} & BrowserCommandTargetParams
): Promise<BrowserGeolocationResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.setGeolocation(
params.latitude,
params.longitude,
params.accuracy,
target.worktreeId,
target.browserPageId
)
}
async browserInterceptEnable(
params: {
patterns?: string[]
} & BrowserCommandTargetParams
): Promise<BrowserInterceptEnableResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.interceptEnable(params.patterns, target.worktreeId, target.browserPageId)
}
async browserInterceptDisable(
params: BrowserCommandTargetParams
): Promise<BrowserInterceptDisableResult> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.interceptDisable(target.worktreeId, target.browserPageId)
}
async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.interceptList(target.worktreeId, target.browserPageId)
}
async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.setDevice(params.name, target.worktreeId, target.browserPageId)
}
async browserSetOffline(
params: { state?: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.setOffline(params.state, target.worktreeId, target.browserPageId)
}
async browserSetHeaders(
params: { headers: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.setHeaders(params.headers, target.worktreeId, target.browserPageId)
}
async browserSetCredentials(
params: {
user: string
pass: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.setCredentials(params.user, params.pass, target.worktreeId, target.browserPageId)
}
async browserSetMedia(
params: {
colorScheme?: string
reducedMotion?: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.setMedia(params.colorScheme, params.reducedMotion, target.worktreeId, target.browserPageId)
}
async browserDownload(
params: {
selector: string
path: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.download(params.selector, params.path, target.worktreeId, target.browserPageId)
}
}
@@ -0,0 +1,81 @@
import type { BrowserCommandTargetParams } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
import { clampOptionalNumber } from './browser-screencast-budget'
export class RuntimeBrowserPointerInput {
constructor(
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget'
>
) {}
async browserKeyboardInsertText(
params: { text: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.keyboardInsertText(params.text, target.worktreeId, target.browserPageId)
}
async browserMouseMove(
params: { x: number; y: number } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.mouseMove(params.x, params.y, target.worktreeId, target.browserPageId)
}
async browserMouseDown(
params: { button?: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.mouseDown(params.button, target.worktreeId, target.browserPageId)
}
async browserMouseClick(
params: {
x: number
y: number
button?: string
radius?: number
modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[]
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.mouseClick(
params.x,
params.y,
params.button,
target.worktreeId,
target.browserPageId,
clampOptionalNumber(params.radius, 0, 64),
params.modifiers
)
}
async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.mouseUp(params.button, target.worktreeId, target.browserPageId)
}
async browserMouseWheel(
params: {
dy: number
dx?: number
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.mouseWheel(params.dy, params.dx, target.worktreeId, target.browserPageId)
}
}
@@ -1,6 +1,4 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserTabCreate } from './runtime-browser-commands-browser-tab-create'
import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params'
import { createBrowserTabInRenderer } from './browser-tab-renderer-create'
import type {
BrowserDetectProfilesResult,
BrowserProfileCreateResult,
@@ -8,25 +6,47 @@ import type {
BrowserProfileListResult,
BrowserTabProfileCloneResult,
BrowserTabProfileShowResult,
BrowserTabSetProfileResult
} from '../../shared/runtime-types'
import { BrowserError } from '../browser/browser-error'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { browserManager } from '../browser/browser-manager'
BrowserTabSetProfileResult,
BrowserProfileClearDefaultCookiesResult,
BrowserProfileImportFromBrowserResult
} from '../../../shared/runtime-types'
import { BrowserError } from '../../browser/browser-error'
import { browserSessionRegistry } from '../../browser/browser-session-registry'
import { browserManager } from '../../browser/browser-manager'
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import { waitForTabRegistration } from '../ipc/browser-tab-registration-wait'
import { detectInstalledBrowsers } from '../browser/browser-cookie-import'
import { waitForTabRegistration } from '../../ipc/browser-tab-registration-wait'
import {
detectInstalledBrowsers,
importCookiesFromBrowser,
selectBrowserProfile
} from '../../browser/browser-cookie-import'
import type {
BrowserCommandTargetParams,
RuntimeBrowserCommandHost
} from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog'
export class RuntimeBrowserProfiles {
constructor(
private readonly host: Pick<RuntimeBrowserCommandHost, 'getAuthoritativeWindow'>,
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget'
>,
private readonly catalog: Pick<RuntimeBrowserTabCatalog, 'describeBrowserTab'>
) {}
export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrowserCommandsWithBrowserTabCreate {
async browserTabSetProfile(
params: {
profileId: string
} & BrowserCommandTargetParams
): Promise<BrowserTabSetProfileResult> {
const target = await this.resolveBrowserCommandTarget(params)
const target = await this.targets.resolveBrowserCommandTarget(params)
const browserPageId =
target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
target.browserPageId ??
this.targets.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
if (!browserPageId) {
throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree')
}
@@ -101,8 +121,8 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows
page: string
worktree?: string
}): Promise<BrowserTabProfileShowResult> {
const target = await this.resolveBrowserCommandTarget(params)
const tab = this.describeBrowserTab(params.page, target.worktreeId)
const target = await this.targets.resolveBrowserCommandTarget(params)
const tab = this.catalog.describeBrowserTab(params.page, target.worktreeId)
return {
browserPageId: tab.browserPageId,
worktreeId: tab.worktreeId ?? null,
@@ -116,13 +136,14 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows
profileId: string
} & BrowserCommandTargetParams
): Promise<BrowserTabProfileCloneResult> {
const target = await this.resolveBrowserCommandTarget(params)
const target = await this.targets.resolveBrowserCommandTarget(params)
const sourceBrowserPageId =
target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
target.browserPageId ??
this.targets.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
if (!sourceBrowserPageId) {
throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree')
}
const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId)
const sourceTab = this.catalog.describeBrowserTab(sourceBrowserPageId, target.worktreeId)
const profile = browserSessionRegistry.getProfile(params.profileId)
if (!profile) {
throw new BrowserError(
@@ -130,7 +151,8 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows
`Browser profile ${params.profileId} was not found`
)
}
const created = await this.createBrowserTabInRenderer(
const created = await createBrowserTabInRenderer(
this.host,
sourceTab.url,
sourceTab.worktreeId ?? target.worktreeId,
profile.id,
@@ -181,4 +203,60 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows
}))
}
}
async browserProfileImportFromBrowser(params: {
profileId: string
browserFamily: string
browserProfile?: string
supportsPartitionSkippedCookies?: true
}): Promise<BrowserProfileImportFromBrowserResult> {
const profile = browserSessionRegistry.getProfile(params.profileId)
if (!profile) {
return { ok: false, reason: 'Session profile not found.' }
}
if (
params.browserProfile &&
(/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..'))
) {
return { ok: false, reason: 'Invalid browser profile name.' }
}
const browsers = detectInstalledBrowsers()
let browser = browsers.find((candidate) => candidate.family === params.browserFamily)
if (!browser) {
return { ok: false, reason: 'Browser not found on this system.' }
}
if (params.browserProfile && params.browserProfile !== browser.selectedProfile) {
const reselected = selectBrowserProfile(browser, params.browserProfile)
if (!reselected) {
return {
ok: false,
reason: `No cookies database found for profile "${params.browserProfile}".`
}
}
browser = reselected
}
const result = await importCookiesFromBrowser(browser, profile.partition, {
canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true
})
if (!result.ok) {
return result
}
const profileName =
browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ??
browser.selectedProfile
browserSessionRegistry.updateProfileSource(params.profileId, {
browserFamily: browser.family,
profileName,
importedAt: Date.now()
})
return { ...result, profileId: params.profileId }
}
async browserProfileClearDefaultCookies(): Promise<BrowserProfileClearDefaultCookiesResult> {
return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() }
}
}
@@ -0,0 +1,88 @@
import type {
BrowserScreencastFrameBudget,
BrowserScreencastSession,
BrowserScreencastViewport
} from '../../browser/browser-screencast-stream-types'
import {
browserScreencastFrameBudgetsEqual,
mergeBrowserScreencastFrameBudgets
} from '../../browser/browser-screencast-frame-budget'
import type {
BrowserScreencastParams,
ActiveBrowserScreencastPage
} from './browser-screencast-contracts'
export async function applySharedScreencastFrameBudget(
active: Pick<ActiveBrowserScreencastPage, 'subscribers' | 'appliedBudget'>,
session: BrowserScreencastSession
): Promise<void> {
const merged = mergeBrowserScreencastFrameBudgets(
Array.from(active.subscribers.values(), (subscriber) => subscriber.budget)
)
if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) {
return
}
active.appliedBudget = merged
await session.updateFrameBudget(merged)
}
export function normalizeScreencastViewport(
params: BrowserScreencastParams
): BrowserScreencastViewport {
return {
viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840),
viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160),
deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4),
mobile: params.mobile === true
}
}
export function normalizeScreencastFrameBudget(
params: BrowserScreencastParams
): BrowserScreencastFrameBudget {
return {
quality: clampInteger(params.quality, 10, 100, 70),
maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440),
maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200),
everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2),
minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0)
}
}
export function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean {
return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined
}
export function clampInteger(
value: number | undefined,
min: number,
max: number,
fallback: number
): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback
}
return Math.min(max, Math.max(min, Math.round(value)))
}
export function clampOptionalInteger(
value: number | undefined,
min: number,
max: number
): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.min(max, Math.max(min, Math.round(value)))
}
export function clampOptionalNumber(
value: number | undefined,
min: number,
max: number
): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.min(max, Math.max(min, value))
}
@@ -0,0 +1,57 @@
import type { BrowserScreencastResult } from '../../../shared/runtime-types'
import type {
BrowserScreencastFrameBudget,
BrowserScreencastSession,
BrowserScreencastViewport
} from '../../browser/browser-screencast-stream-types'
import type { ScreencastSubscriberDeliveryState } from '../browser-screencast-ghost-subscriber-eviction'
import type { RpcBinarySender } from '../rpc/rpc-binary-sender'
import type { BrowserCommandTargetParams } from './browser-command-contracts'
export type BrowserScreencastParams = {
format: 'jpeg' | 'png'
quality?: number
maxWidth?: number
maxHeight?: number
viewportWidth?: number
viewportHeight?: number
deviceScaleFactor?: number
mobile?: boolean
everyNthFrame?: number
minFrameIntervalMs?: number
} & BrowserCommandTargetParams
export type BrowserScreencastStartResult = {
subscriptionId: string
ready: Extract<BrowserScreencastResult, { type: 'ready' }>
// The frame budget and the page's dialog belong to the shared page, not to one subscriber's handle.
session: Omit<BrowserScreencastSession, 'updateFrameBudget' | 'settleDialog'>
// Why: callers gate frames until they have emitted `ready`, and the snapshot captured
// for a joining subscriber lands inside that window. This replays it once the gate opens.
flushPendingFrame: () => void
}
export type ActiveBrowserScreencastSubscriber = {
sendBinary: RpcBinarySender
emit?: (event: BrowserScreencastResult) => void
done: Promise<void>
resolveDone: () => void
viewport: BrowserScreencastViewport
budget: BrowserScreencastFrameBudget
pendingFrame: Uint8Array<ArrayBufferLike> | null
pendingFrameRetry: ReturnType<typeof setTimeout> | null
// Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new
// socket never reuses the old id, so a reconnecting device would stack a second subscription.
pairedDeviceId?: string
delivery: ScreencastSubscriberDeliveryState
}
export type ActiveBrowserScreencastPage = {
format: 'jpeg' | 'png'
session: BrowserScreencastSession | null
started: Promise<BrowserScreencastSession>
stopping: boolean
subscribers: Map<string, ActiveBrowserScreencastSubscriber>
viewportOwnerSubscriptionId: string | null
appliedBudget: BrowserScreencastFrameBudget
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebContents } from 'electron'
import type {
BrowserScreencastOptions,
BrowserScreencastSession
} from '../../browser/browser-screencast-stream-types'
import {
createSinglePageBrowserCommandsHost,
createStreamedGuest
} from '../single-page-browser-commands-host-test-double'
import { RuntimeBrowserCommands } from './browser-commands'
const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({
webContentsFromId: vi.fn(),
startBrowserScreencast:
vi.fn<
(guest: WebContents, options: BrowserScreencastOptions) => Promise<BrowserScreencastSession>
>()
}))
vi.mock('electron', () => ({
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
webContents: { fromId: webContentsFromId }
}))
vi.mock('../../browser/browser-screencast-stream', () => ({ startBrowserScreencast }))
function createSession() {
let close!: () => void
const done = new Promise<void>((resolve) => {
close = resolve
})
return {
stop: vi.fn(() => close()),
done,
updateViewport: async () => {},
updateFrameBudget: async () => {},
settleDialog: async () => false
}
}
const params = { page: 'page-1', format: 'jpeg' } as const
describe('screencast ownership', () => {
beforeEach(() => {
webContentsFromId.mockReset()
webContentsFromId.mockReturnValue(createStreamedGuest())
startBrowserScreencast.mockReset()
})
it('keeps same-page streams isolated between runtime instances', async () => {
const firstSession = createSession()
const secondSession = createSession()
startBrowserScreencast.mockResolvedValueOnce(firstSession).mockResolvedValueOnce(secondSession)
const firstCommands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const secondCommands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const firstSend = vi.fn(() => true)
const secondSend = vi.fn(() => true)
const first = await firstCommands.browserScreencast(params, { sendBinary: firstSend })
const second = await secondCommands.browserScreencast(params, { sendBinary: secondSend })
const frame = new Uint8Array([1, 2, 3])
expect(startBrowserScreencast).toHaveBeenCalledTimes(2)
startBrowserScreencast.mock.calls[0]?.[1].onFrame(frame)
expect(firstSend).toHaveBeenCalledOnce()
expect(secondSend).not.toHaveBeenCalled()
first.session.stop()
await first.session.done
expect(firstSession.stop).toHaveBeenCalledOnce()
expect(secondSession.stop).not.toHaveBeenCalled()
startBrowserScreencast.mock.calls[1]?.[1].onFrame(frame)
expect(secondSend).toHaveBeenCalledOnce()
second.session.stop()
await second.session.done
})
it('settles dialogs through the stream owned by the command instance', async () => {
const session = createSession()
const settleDialog = vi.spyOn(session, 'settleDialog').mockResolvedValue(true)
startBrowserScreencast.mockResolvedValue(session)
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const stream = await commands.browserScreencast(params, { sendBinary: () => true })
expect(await commands.browserDialogAccept({ page: params.page, text: 'Ada' })).toEqual({
accepted: true
})
expect(settleDialog).toHaveBeenCalledWith(true, 'Ada')
expect(await commands.browserDialogDismiss({ page: params.page })).toEqual({ accepted: false })
expect(settleDialog).toHaveBeenLastCalledWith(false, undefined)
stream.session.stop()
await stream.session.done
})
it('accepts startup callbacks before the stream promise is returned', async () => {
const session = createSession()
const startupFrame = new Uint8Array([4, 5, 6])
const send = vi.fn(() => true)
startBrowserScreencast.mockImplementation((_guest, options) => {
expect(options.onFrame(startupFrame)).toBe(true)
options.onError?.('startup notification')
expect(send).not.toHaveBeenCalled()
return Promise.resolve(session)
})
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
const stream = await commands.browserScreencast(params, { sendBinary: send })
expect(session.stop).not.toHaveBeenCalled()
startBrowserScreencast.mock.calls[0]?.[1].onFrame(startupFrame)
expect(send).toHaveBeenCalledWith(startupFrame, { dropWhenBacklogged: true })
stream.session.stop()
await stream.session.done
})
})
@@ -1,36 +1,51 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserClick } from './runtime-browser-commands-browser-click'
import type {
ActiveBrowserScreencastPage,
ActiveBrowserScreencastSubscriber,
BrowserCommandTargetParams,
BrowserScreencastParams,
BrowserScreencastStartResult
} from './runtime-browser-commands-browser-command-target-params'
import {
applySharedScreencastFrameBudget,
hasScreencastViewportSize,
normalizeScreencastFrameBudget,
normalizeScreencastViewport
} from './runtime-browser-commands-browser-command-target-params'
import type { BrowserEvalResult, BrowserScreencastResult } from '../../shared/runtime-types'
import { BrowserError } from '../browser/browser-error'
import { settleBrowserDialogOnLiveScreencast } from '../browser-dialog-settlement'
import type { BrowserScreencastResult } from '../../../shared/runtime-types'
import { BrowserError } from '../../browser/browser-error'
import { randomUUID } from 'node:crypto'
import { mintBrowserScreencastSubscriptionId } from './browser-screencast-subscription-id'
import { startBrowserScreencast } from '../browser/browser-screencast-stream'
import { rendererPublicationThrottle } from '../window/renderer-publication-throttle'
import { startBrowserScreencast } from '../../browser/browser-screencast-stream'
import { rendererPublicationThrottle } from '../../window/renderer-publication-throttle'
import { mintBrowserScreencastSubscriptionId } from '../browser-screencast-subscription-id'
import {
cancelScreencastSubscriberFrameRetry,
deliverScreencastSubscriberFrame
} from './browser-screencast-subscriber-frame-delivery'
} from '../browser-screencast-subscriber-frame-delivery'
import {
INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY,
screencastSubscriberIsGhost
} from './browser-screencast-ghost-subscriber-eviction'
import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types'
import type { RpcBinarySender } from './rpc/rpc-binary-sender'
} from '../browser-screencast-ghost-subscriber-eviction'
import type { BrowserScreencastSession } from '../../browser/browser-screencast-stream-types'
import type { RpcBinarySender } from '../rpc/rpc-binary-sender'
import type { RuntimeBrowserCommandHost } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog'
import {
applySharedScreencastFrameBudget,
normalizeScreencastViewport,
normalizeScreencastFrameBudget,
hasScreencastViewportSize
} from './browser-screencast-budget'
import type {
BrowserScreencastParams,
BrowserScreencastStartResult,
ActiveBrowserScreencastSubscriber,
ActiveBrowserScreencastPage
} from './browser-screencast-contracts'
export class RuntimeBrowserScreencasts {
private readonly activeScreencastsByPageId = new Map<string, ActiveBrowserScreencastPage>()
constructor(
private readonly host: Pick<RuntimeBrowserCommandHost, 'getAvailableAuthoritativeWindow'>,
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'resolveBrowserCommandTarget' | 'resolveBrowserPageWebContents'
>,
private readonly catalog: Pick<
RuntimeBrowserTabCatalog,
'resolveClientHostedBrowserPage' | 'describeBrowserTab'
>
) {}
export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserCommandsWithBrowserClick {
async browserScreencast(
params: BrowserScreencastParams,
stream: {
@@ -39,14 +54,14 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
pairedDeviceId?: string
}
): Promise<BrowserScreencastStartResult> {
if (await this.resolveClientHostedBrowserPage(params)) {
if (await this.catalog.resolveClientHostedBrowserPage(params)) {
throw new BrowserError(
'browser_error',
'Client-hosted browser pages do not support server screencast.'
)
}
const target = await this.resolveBrowserCommandTarget(params)
const { browserPageId, webContents: guest } = this.resolveBrowserPageWebContents(
const target = await this.targets.resolveBrowserCommandTarget(params)
const { browserPageId, webContents: guest } = this.targets.resolveBrowserPageWebContents(
target.worktreeId,
target.browserPageId
)
@@ -66,26 +81,26 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
if (!active) {
createdPageStream = true
const subscribers = new Map<string, ActiveBrowserScreencastSubscriber>()
const record = {
const pendingRecord: Omit<ActiveBrowserScreencastPage, 'started'> = {
format: params.format,
session: null,
stopping: false,
subscribers,
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) : () => {}
// Why: on the active tab the guest is the widget a cover hides, and its attach-time unthrottle can predate it.
const releaseGuestThrottle = rendererPublicationThrottle.acquire(guest)
record.started = startBrowserScreencast(guest, {
const started = startBrowserScreencast(guest, {
format: params.format,
...budget,
...viewport,
onFrame: (bytes) => {
const ghosts: string[] = []
for (const [subscriptionId, subscriber] of record.subscribers) {
for (const [subscriptionId, subscriber] of pendingRecord.subscribers) {
// A slow viewer drops this frame without stalling every other viewer.
deliverScreencastSubscriberFrame(subscriber, bytes)
if (screencastSubscriberIsGhost(subscriber.delivery)) {
@@ -95,29 +110,30 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
// Evicting after the fan-out keeps a teardown that stops the session from cutting the
// remaining viewers out of this frame.
for (const subscriptionId of ghosts) {
if (record.session) {
this.leaveScreencastSubscriber(record, subscriptionId, record.session)
if (pendingRecord.session) {
this.leaveScreencastSubscriber(pendingRecord, subscriptionId, pendingRecord.session)
}
}
return true
},
onEvent: (event) => {
for (const subscriber of record.subscribers.values()) {
for (const subscriber of pendingRecord.subscribers.values()) {
subscriber.emit?.(event)
}
},
onError: (message) => {
for (const subscriber of record.subscribers.values()) {
for (const subscriber of pendingRecord.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()
if (pendingRecord.session) {
pendingRecord.stopping = true
pendingRecord.session.stop()
}
}
})
const record: ActiveBrowserScreencastPage = Object.assign(pendingRecord, { started })
active = record
this.activeScreencastsByPageId.set(browserPageId, record)
void record.started
@@ -131,11 +147,11 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
if (this.activeScreencastsByPageId.get(browserPageId) === record) {
this.activeScreencastsByPageId.delete(browserPageId)
}
for (const subscriber of record.subscribers.values()) {
for (const subscriber of pendingRecord.subscribers.values()) {
cancelScreencastSubscriberFrameRetry(subscriber)
subscriber.resolveDone()
}
record.subscribers.clear()
pendingRecord.subscribers.clear()
})
.catch(() => {})
}
@@ -204,19 +220,56 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
subscriptionId,
browserPageId,
format: active.format,
tab: this.describeBrowserTab(browserPageId, target.worktreeId)
tab: this.catalog.describeBrowserTab(browserPageId, target.worktreeId)
}
}
}
async browserEval(
params: { expression: string } & BrowserCommandTargetParams
): Promise<BrowserEvalResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().evaluate(
params.expression,
target.worktreeId,
target.browserPageId
// Explicit stops, ghost eviction and reconnect replacement must share the same teardown.
private leaveScreencastSubscriber(
active: Pick<
ActiveBrowserScreencastPage,
'subscribers' | 'viewportOwnerSubscriptionId' | 'stopping' | 'appliedBudget'
>,
subscriptionId: string,
session: BrowserScreencastSession
): void {
const subscriber = active.subscribers.get(subscriptionId)
if (!subscriber) {
return
}
active.subscribers.delete(subscriptionId)
cancelScreencastSubscriberFrameRetry(subscriber)
subscriber.resolveDone()
if (active.viewportOwnerSubscriptionId === subscriptionId) {
const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) =>
hasScreencastViewportSize(candidate.viewport)
)
active.viewportOwnerSubscriptionId = fallback?.[0] ?? null
if (fallback) {
void session.updateViewport(fallback[1].viewport).catch(() => {})
}
}
if (active.subscribers.size === 0) {
active.stopping = true
session.stop()
return
}
// Why: a departed subscriber's caps would otherwise pin the shared stream for
// the rest of its life, long after the client that asked for them is gone.
void applySharedScreencastFrameBudget(active, session).catch(() => {})
}
settleDialog(
browserPageId: string | undefined,
accept: boolean,
promptText?: string
): Promise<boolean> {
return settleBrowserDialogOnLiveScreencast(
this.activeScreencastsByPageId,
browserPageId,
accept,
promptText
)
}
}
@@ -0,0 +1,83 @@
import type { BrowserCommandTargetParams } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
export class RuntimeBrowserStorage {
constructor(
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget'
>
) {}
async browserClipboardRead(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.clipboardRead(target.worktreeId, target.browserPageId)
}
async browserClipboardWrite(
params: { text: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.clipboardWrite(params.text, target.worktreeId, target.browserPageId)
}
async browserStorageLocalGet(
params: { key: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.storageLocalGet(params.key, target.worktreeId, target.browserPageId)
}
async browserStorageLocalSet(
params: {
key: string
value: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.storageLocalSet(params.key, params.value, target.worktreeId, target.browserPageId)
}
async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.storageLocalClear(target.worktreeId, target.browserPageId)
}
async browserStorageSessionGet(
params: { key: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.storageSessionGet(params.key, target.worktreeId, target.browserPageId)
}
async browserStorageSessionSet(
params: {
key: string
value: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.storageSessionSet(params.key, params.value, target.worktreeId, target.browserPageId)
}
async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.targets.resolveBrowserCommandTarget(params)
return this.targets
.requireAgentBrowserBridge()
.storageSessionClear(target.worktreeId, target.browserPageId)
}
}
@@ -1,16 +1,24 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserTabClose } from './runtime-browser-commands-browser-tab-close'
import type { RuntimeBrowserClientPage } from './runtime-browser-page-registry'
import type { BrowserTabListResult } from '../../shared/runtime-types'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { BrowserError } from '../browser/browser-error'
import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params'
import { browserManager } from '../browser/browser-manager'
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import type { RuntimeBrowserClientPage } from '../runtime-browser-page-registry'
import type { BrowserTabListResult } from '../../../shared/runtime-types'
import { browserSessionRegistry } from '../../browser/browser-session-registry'
import { BrowserError } from '../../browser/browser-error'
import { browserManager } from '../../browser/browser-manager'
import type {
BrowserCommandTargetParams,
RuntimeBrowserCommandHost
} from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBrowserCommandsWithBrowserTabClose {
protected listLogicalBrowserTabs(
export class RuntimeBrowserTabCatalog {
constructor(
private readonly host: Pick<
RuntimeBrowserCommandHost,
'getAgentBrowserBridge' | 'resolveBrowserWorkspace' | 'getRuntimeBrowserPageRegistry'
>,
private readonly targets: Pick<RuntimeBrowserCommandTargets, 'requireAgentBrowserBridge'>
) {}
listLogicalBrowserTabs(
worktreeId: string | undefined,
clientPages: readonly RuntimeBrowserClientPage[]
): BrowserTabListResult['tabs'] {
@@ -47,7 +55,7 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro
return tabs.map((tab, index) => ({ ...tab, index }))
}
protected async assertClientPageWorkspace(
async assertClientPageWorkspace(
page: RuntimeBrowserClientPage,
selector: string | undefined
): Promise<void> {
@@ -63,7 +71,7 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro
}
}
protected async resolveClientHostedBrowserPage(
async resolveClientHostedBrowserPage(
params: BrowserCommandTargetParams
): Promise<RuntimeBrowserClientPage | undefined> {
const pages = this.host.getRuntimeBrowserPageRegistry()
@@ -80,12 +88,13 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro
return pages.listPages(workspaceId).find((page) => page.active)
}
protected describeBrowserTab(
describeBrowserTab(
browserPageId: string,
explicitWorktreeId?: string
): BrowserTabListResult['tabs'][number] {
const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId)
const tab = this.requireAgentBrowserBridge()
const tab = this.targets
.requireAgentBrowserBridge()
.tabList(worktreeId)
.tabs.find((entry) => entry.browserPageId === browserPageId)
if (!tab) {
@@ -98,51 +107,18 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro
return this.enrichBrowserTabInfo(tab)
}
protected async createBrowserTabInRenderer(
url: string,
worktreeId: string | undefined,
profileId: string | undefined,
sessionPartition: string | undefined,
activate?: boolean,
requestedPageId?: string
): Promise<{ browserPageId: string }> {
const win = this.host.getAuthoritativeWindow()
const requestId = randomUUID()
const browserPageId = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('browser:tabCreateReply', handler)
reject(new Error('Tab creation timed out'))
}, 10_000)
const handler = (
event: Electron.IpcMainEvent,
reply: { requestId: string; browserPageId?: string; error?: string }
): void => {
if (event.sender !== win.webContents || reply.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('browser:tabCreateReply', handler)
if (reply.error) {
reject(new Error(reply.error))
} else {
resolve(reply.browserPageId!)
}
}
ipcMain.on('browser:tabCreateReply', handler)
win.webContents.send('browser:requestTabCreate', {
requestId,
url,
worktreeId,
...(requestedPageId ? { browserPageId: requestedPageId } : {}),
// Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance.
sessionProfileId: profileId,
sessionPartition,
activate
})
})
return { browserPageId }
private enrichBrowserTabInfo(
tab: BrowserTabListResult['tabs'][number]
): BrowserTabListResult['tabs'][number] {
const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId)
const profile =
browserSessionRegistry.getProfile(rawProfileId ?? 'default') ??
browserSessionRegistry.getDefaultProfile()
return {
...tab,
worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null,
profileId: profile.id,
profileLabel: profile.label
}
}
}
@@ -1,14 +1,38 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser } from './runtime-browser-commands-browser-profile-import-from-browser'
import { closeRuntimeBrowserClientPage } from './runtime-browser-client-page-creation'
import { BrowserError } from '../browser/browser-error'
import type { RuntimeBrowserTabQueries } from './browser-tab-queries'
import { closeRuntimeBrowserClientPage } from '../runtime-browser-client-page-creation'
import { BrowserError } from '../../browser/browser-error'
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import type { BrowserTabListResult } from '../../shared/runtime-types'
import { browserManager } from '../browser/browser-manager'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import type { RuntimeBrowserCommandHost } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog'
export class RuntimeBrowserTabClose {
constructor(
private readonly queries: Pick<
RuntimeBrowserTabQueries,
'browserTabList' | 'browserTabCurrent'
>,
private readonly host: Pick<
RuntimeBrowserCommandHost,
| 'getRuntimeBrowserPageRegistry'
| 'resolveBrowserWorkspace'
| 'getBrowserHostLeaseRegistry'
| 'retireRuntimeOwnedBrowserSessionTab'
| 'notifyHeadlessBrowserSessionTabsChanged'
| 'getAgentBrowserBridge'
| 'resolveWorktreeSelector'
| 'getAvailableAuthoritativeWindow'
| 'getOffscreenBrowserBackend'
| 'getAuthoritativeWindow'
>,
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'resolveBrowserWorktreeId' | 'retireGhostBrowserSessionRow'
>,
private readonly catalog: Pick<RuntimeBrowserTabCatalog, 'assertClientPageWorkspace'>
) {}
export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser {
async browserTabClose(params: {
index?: number
page?: string
@@ -17,7 +41,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom
const pages = this.host.getRuntimeBrowserPageRegistry()
let clientPage = params.page ? pages.getPage(params.page) : undefined
if (clientPage) {
await this.assertClientPageWorkspace(clientPage, params.worktree)
await this.catalog.assertClientPageWorkspace(clientPage, params.worktree)
} else if (!params.page) {
const workspaceId = params.worktree
? (await this.host.resolveBrowserWorkspace(params.worktree)).id
@@ -25,8 +49,8 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom
if (pages.listPages(workspaceId).length > 0) {
const tab =
params.index !== undefined
? (await this.browserTabList({ worktree: params.worktree })).tabs[params.index]
: (await this.browserTabCurrent({ worktree: params.worktree })).tab
? (await this.queries.browserTabList({ worktree: params.worktree })).tabs[params.index]
: (await this.queries.browserTabCurrent({ worktree: params.worktree })).tab
clientPage = tab ? pages.getPage(tab.browserPageId) : undefined
}
}
@@ -63,7 +87,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom
if (
namedPageId &&
params.worktree &&
this.retireGhostBrowserSessionRow(
this.targets.retireGhostBrowserSessionRow(
(await this.host.resolveWorktreeSelector(params.worktree)).id,
namedPageId
)
@@ -76,7 +100,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom
? params.worktree
? (await this.host.resolveWorktreeSelector(params.worktree)).id
: undefined
: await this.resolveBrowserWorktreeId(params.worktree)
: await this.targets.resolveBrowserWorktreeId(params.worktree)
let tabId: string | null = null
if (namedPageId !== null) {
@@ -108,7 +132,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom
return { closed: false }
}
if (explicitPage && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) {
if (this.retireGhostBrowserSessionRow(worktreeId, resolvedTabId)) {
if (this.targets.retireGhostBrowserSessionRow(worktreeId, resolvedTabId)) {
return { closed: true }
}
const scope = worktreeId ? ' in this worktree' : ''
@@ -131,7 +155,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom
}
if (!authoritativeWindow && tabId && !bridge.getRegisteredTabs(worktreeId).has(tabId)) {
if (this.retireGhostBrowserSessionRow(worktreeId, tabId)) {
if (this.targets.retireGhostBrowserSessionRow(worktreeId, tabId)) {
return { closed: true }
}
const scope = worktreeId ? ' in this worktree' : ''
@@ -172,19 +196,4 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom
return { closed: true }
}
protected enrichBrowserTabInfo(
tab: BrowserTabListResult['tabs'][number]
): BrowserTabListResult['tabs'][number] {
const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId)
const profile =
browserSessionRegistry.getProfile(rawProfileId ?? 'default') ??
browserSessionRegistry.getDefaultProfile()
return {
...tab,
worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null,
profileId: profile.id,
profileLabel: profile.label
}
}
}
@@ -1,24 +1,45 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserSetHeaders } from './runtime-browser-commands-browser-set-headers'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement'
import { createBrowserTabInRenderer } from './browser-tab-renderer-create'
import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation'
import type { BrowserPageCreationPlacement } from '../../../shared/browser-client-host-placement'
import {
publishCreatedBrowserSessionTab,
resolveBrowserTabCreateFocus
} from './browser-tab-create-publication'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { BrowserError } from '../browser/browser-error'
} from '../browser-tab-create-publication'
import { browserSessionRegistry } from '../../browser/browser-session-registry'
import { BrowserError } from '../../browser/browser-error'
import { randomUUID } from 'node:crypto'
import {
createRuntimeBrowserClientPage,
navigateRuntimeBrowserClientPage
} from './runtime-browser-client-page-creation'
import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route'
import { waitForTabRegistration } from '../ipc/browser-tab-registration-wait'
import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol'
import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection'
} from '../runtime-browser-client-page-creation'
import { browserNetworkExecutionHostKey } from '../../browser/browser-network-execution-route'
import { waitForTabRegistration } from '../../ipc/browser-tab-registration-wait'
import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../shared/browser-client-automation-protocol'
import { BROWSER_HOST_WEBVIEW_CAPABILITY } from '../browser-host-capability-selection'
import type { RuntimeBrowserCommandHost } from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
export class RuntimeBrowserTabCreation {
constructor(
private readonly host: Pick<
RuntimeBrowserCommandHost,
| 'getAgentBrowserBridge'
| 'resolveBrowserWorkspace'
| 'resolveBrowserNetworkExecutionHost'
| 'getBrowserHostLeaseRegistry'
| 'getRuntimeBrowserPageRegistry'
| 'getAvailableAuthoritativeWindow'
| 'getOffscreenBrowserBackend'
| 'getAuthoritativeWindow'
| 'markHeadlessBrowserSessionTabActive'
| 'notifyHeadlessBrowserSessionTabsChanged'
>,
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
'requireAgentBrowserBridge' | 'notifyRendererNavigation'
>
) {}
export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCommandsWithBrowserSetHeaders {
async browserTabCreate(
params: {
url?: string
@@ -134,7 +155,8 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo
})
return { browserPageId: created.browserPageId }
}
const { browserPageId } = await this.createBrowserTabInRenderer(
const { browserPageId } = await createBrowserTabInRenderer(
this.host,
url,
worktreeId,
params.profileId,
@@ -152,7 +174,7 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo
}
}
const bridge = this.requireAgentBrowserBridge()
const bridge = this.targets.requireAgentBrowserBridge()
publishCreatedBrowserSessionTab(this.host, {
placementKind: 'renderer',
browserPageId,
@@ -166,7 +188,7 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo
if (url && url !== 'about:blank') {
const navigate = async (): Promise<void> => {
const result = await bridge.goto(url, worktreeId, browserPageId)
this.notifyRendererNavigation(browserPageId, result.url, result.title)
this.targets.notifyRendererNavigation(browserPageId, result.url, result.title)
if (!this.host.getAvailableAuthoritativeWindow() && worktreeId) {
this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId)
}
@@ -1,24 +1,43 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserScreencast } from './runtime-browser-commands-browser-screencast'
import type {
BrowserCheckResult,
BrowserDragResult,
BrowserFocusResult,
BrowserHoverResult,
BrowserTabCurrentResult,
BrowserTabListResult,
BrowserTabShowResult,
BrowserTabSwitchResult,
BrowserUploadResult,
BrowserWaitResult
} from '../../shared/runtime-types'
import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params'
import type { BrowserCertificateProceedResult } from '../../shared/browser-workspace-types'
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
import { BrowserError } from '../browser/browser-error'
import { publishSwitchedBrowserSessionTab } from './browser-tab-create-publication'
BrowserTabSwitchResult
} from '../../../shared/runtime-types'
import type { BrowserCertificateProceedResult } from '../../../shared/browser-workspace-types'
import { browserCertificateTrustController, browserManager } from '../../browser/browser-manager'
import { BrowserError } from '../../browser/browser-error'
import { publishSwitchedBrowserSessionTab } from '../browser-tab-create-publication'
import type {
BrowserCommandTargetParams,
RuntimeBrowserCommandHost
} from './browser-command-contracts'
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog'
export class RuntimeBrowserTabQueries {
constructor(
private readonly host: Pick<
RuntimeBrowserCommandHost,
| 'getAgentBrowserBridge'
| 'resolveBrowserWorkspace'
| 'getRuntimeBrowserPageRegistry'
| 'markHeadlessBrowserSessionTabActive'
| 'notifyHeadlessBrowserSessionTabsChanged'
>,
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
| 'requireAgentBrowserBridge'
| 'resolveBrowserCommandTarget'
| 'resolveBrowserWorktreeId'
| 'notifyRendererBrowserPaneFocus'
>,
private readonly catalog: Pick<
RuntimeBrowserTabCatalog,
'listLogicalBrowserTabs' | 'assertClientPageWorkspace' | 'describeBrowserTab'
>
) {}
export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserCommandsWithBrowserScreencast {
async browserTabList(params: { worktree?: string }): Promise<BrowserTabListResult> {
const workspaceId = params.worktree
? (await this.host.resolveBrowserWorkspace(params.worktree)).id
@@ -27,20 +46,20 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm
let bridgeWorktreeId = workspaceId
if (this.host.getAgentBrowserBridge()) {
try {
bridgeWorktreeId = await this.resolveBrowserWorktreeId(params.worktree)
bridgeWorktreeId = await this.targets.resolveBrowserWorktreeId(params.worktree)
} catch (error) {
if (clientPages.length === 0) {
throw error
}
}
}
return { tabs: this.listLogicalBrowserTabs(bridgeWorktreeId, clientPages) }
return { tabs: this.catalog.listLogicalBrowserTabs(bridgeWorktreeId, clientPages) }
}
async browserProceedCertificate(
params: { challengeId: string } & BrowserCommandTargetParams
): Promise<BrowserCertificateProceedResult> {
const target = await this.resolveBrowserCommandTarget(params)
const target = await this.targets.resolveBrowserCommandTarget(params)
if (!target.browserPageId) {
return { ok: false, reason: 'missing' }
}
@@ -50,18 +69,20 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm
async browserTabShow(params: { page: string; worktree?: string }): Promise<BrowserTabShowResult> {
const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(params.page)
if (clientPage) {
await this.assertClientPageWorkspace(clientPage, params.worktree)
const tab = this.listLogicalBrowserTabs(
clientPage.workspaceId,
this.host.getRuntimeBrowserPageRegistry().listPages(clientPage.workspaceId)
).find((candidate) => candidate.browserPageId === clientPage.browserPageId)
await this.catalog.assertClientPageWorkspace(clientPage, params.worktree)
const tab = this.catalog
.listLogicalBrowserTabs(
clientPage.workspaceId,
this.host.getRuntimeBrowserPageRegistry().listPages(clientPage.workspaceId)
)
.find((candidate) => candidate.browserPageId === clientPage.browserPageId)
if (!tab) {
throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`)
}
return { tab }
}
const target = await this.resolveBrowserCommandTarget(params)
return { tab: this.describeBrowserTab(params.page, target.worktreeId) }
const target = await this.targets.resolveBrowserCommandTarget(params)
return { tab: this.catalog.describeBrowserTab(params.page, target.worktreeId) }
}
async browserTabCurrent(params: { worktree?: string }): Promise<BrowserTabCurrentResult> {
@@ -103,7 +124,7 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm
})
return { switched: switchedIndex, browserPageId: clientPage.browserPageId }
}
const bridge = this.requireAgentBrowserBridge()
const bridge = this.targets.requireAgentBrowserBridge()
const worktreeId =
typeof selected.worktreeId === 'string'
? selected.worktreeId
@@ -125,85 +146,8 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm
focus: params.focus
})
if (params.focus) {
this.notifyRendererBrowserPaneFocus(focusWorktreeId, result.browserPageId)
this.targets.notifyRendererBrowserPaneFocus(focusWorktreeId, result.browserPageId)
}
return { ...result, switched: switchedIndex }
}
async browserHover(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserHoverResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().hover(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserDrag(
params: {
from: string
to: string
} & BrowserCommandTargetParams
): Promise<BrowserDragResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().drag(
params.from,
params.to,
target.worktreeId,
target.browserPageId
)
}
async browserUpload(
params: { element: string; files: string[] } & BrowserCommandTargetParams
): Promise<BrowserUploadResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().upload(
params.element,
params.files,
target.worktreeId,
target.browserPageId
)
}
async browserWait(
params: {
selector?: string
timeout?: number
text?: string
url?: string
load?: string
fn?: string
state?: string
} & BrowserCommandTargetParams
): Promise<BrowserWaitResult> {
const target = await this.resolveBrowserCommandTarget(params)
const { worktree: _, page: __, ...options } = params
return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId)
}
async browserCheck(
params: { element: string; checked: boolean } & BrowserCommandTargetParams
): Promise<BrowserCheckResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().check(
params.element,
params.checked,
target.worktreeId,
target.browserPageId
)
}
async browserFocus(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserFocusResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().focus(
params.element,
target.worktreeId,
target.browserPageId
)
}
}
@@ -0,0 +1,52 @@
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import type { RuntimeBrowserCommandHost } from './browser-command-contracts'
export async function createBrowserTabInRenderer(
host: Pick<RuntimeBrowserCommandHost, 'getAuthoritativeWindow'>,
url: string,
worktreeId: string | undefined,
profileId: string | undefined,
sessionPartition: string | undefined,
activate?: boolean,
requestedPageId?: string
): Promise<{ browserPageId: string }> {
const win = host.getAuthoritativeWindow()
const requestId = randomUUID()
const browserPageId = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('browser:tabCreateReply', handler)
reject(new Error('Tab creation timed out'))
}, 10_000)
const handler = (
event: Electron.IpcMainEvent,
reply: { requestId: string; browserPageId?: string; error?: string }
): void => {
if (event.sender !== win.webContents || reply.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('browser:tabCreateReply', handler)
if (reply.error) {
reject(new Error(reply.error))
} else {
resolve(reply.browserPageId!)
}
}
ipcMain.on('browser:tabCreateReply', handler)
win.webContents.send('browser:requestTabCreate', {
requestId,
url,
worktreeId,
...(requestedPageId ? { browserPageId: requestedPageId } : {}),
// Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance.
sessionProfileId: profileId,
sessionPartition,
activate
})
})
return { browserPageId }
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
const { startBrowserScreencastMock, browserSessionRegistryMock } = vi.hoisted(() => ({
@@ -129,7 +129,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('creates an explicitly client-placed page without a server renderer or offscreen backend', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const createClientPage = vi.fn(async () => ({
kind: 'client' as const,
browserHostClientId: 'host-a',
@@ -197,7 +197,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('moves a user-created client page into the clicked split group', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const markHeadlessBrowserSessionTabActive = vi.fn()
const host = createHost({
resolveBrowserNetworkExecutionHost: vi.fn(async () => ({
@@ -254,7 +254,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('keeps a background client page out of the workspace current-page slot', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
const commands = new RuntimeBrowserCommands(
createHost({
@@ -298,7 +298,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('publishes the proven client page before navigation and scopes it to the worktree', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const createProof = deferred<{
kind: 'client'
browserHostClientId: string
@@ -378,7 +378,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('retains a folder-scoped logical client page when navigation fails', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
const resolveBrowserWorkspace = vi.fn(async () => ({
id: 'folder:folder-1',
@@ -449,7 +449,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('lists, shows, and resolves the current client page on a browserless host', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
publishClientPage(registry, { browserPageId: 'page-client', workspaceId: 'folder:folder-1' })
const commands = new RuntimeBrowserCommands(
@@ -483,7 +483,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('switches logical client pages without invoking the server bridge', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
publishClientPage(registry, { browserPageId: 'page-a', active: true })
publishClientPage(registry, { browserPageId: 'page-b', active: false })
@@ -506,7 +506,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('reports one global active client page across independently active workspaces', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
publishClientPage(registry, { browserPageId: 'page-a', workspaceId: 'wt-1' })
publishClientPage(registry, { browserPageId: 'page-b', workspaceId: 'wt-2' })
@@ -532,7 +532,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('makes a switched server tab globally active without clearing other workspace scopes', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
publishClientPage(registry, { browserPageId: 'page-client-a', workspaceId: 'wt-1' })
publishClientPage(registry, { browserPageId: 'page-client-b', workspaceId: 'wt-2' })
@@ -571,7 +571,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('closes the exact client page and excludes it from server screencast', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
publishClientPage(registry, { browserPageId: 'page-client' })
const issueClientPageCommand = vi.fn(() => ({
@@ -624,7 +624,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('closes a retained client page whose host is gone without commanding it', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const registry = new RuntimeBrowserPageRegistry()
publishClientPage(registry, { browserPageId: 'page-client' })
const issueClientPageCommand = vi.fn()
@@ -663,7 +663,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => {
})
it('does not fall back to a server page after explicit client placement fails', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const offscreenCreate = vi.fn()
const send = vi.fn()
const commands = new RuntimeBrowserCommands(
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import { RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT } from './client-hosted-browser-page-persistence'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
@@ -19,7 +19,7 @@ vi.mock('electron', () => ({
*/
describe('browserTabClose on a session row with no runtime page', () => {
it('retires the runtime-owned session row on a registry miss', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const retireRuntimeOwnedBrowserSessionTab = vi.fn(() => true)
const commands = new RuntimeBrowserCommands(createHost({ retireRuntimeOwnedBrowserSessionTab }))
@@ -30,7 +30,7 @@ describe('browserTabClose on a session row with no runtime page', () => {
})
it('still fails closed when no session row names that page either', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const commands = new RuntimeBrowserCommands(
createHost({ retireRuntimeOwnedBrowserSessionTab: vi.fn(() => false) })
)
@@ -41,7 +41,7 @@ describe('browserTabClose on a session row with no runtime page', () => {
})
it('retires the row even on a runtime with no browser session at all', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const retireRuntimeOwnedBrowserSessionTab = vi.fn(() => true)
const commands = new RuntimeBrowserCommands(
createHost({
@@ -57,7 +57,7 @@ describe('browserTabClose on a session row with no runtime page', () => {
})
it('closes a rehydrated row with no host to ask, and leaves nothing to restore', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const pages = new RuntimeBrowserPageRegistry()
pages.publishClientPage({
browserPageId: 'page-restored',
@@ -88,7 +88,7 @@ describe('browserTabClose on a session row with no runtime page', () => {
})
it('never steals a close from a page the bridge still holds', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const retireRuntimeOwnedBrowserSessionTab = vi.fn(() => true)
const closeTab = vi.fn(async () => {})
const commands = new RuntimeBrowserCommands(
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
const {
@@ -105,7 +105,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => {
})
it('closes a headless tab via the offscreen backend without a renderer round-trip', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false })
const closeTab = vi.fn(async () => {})
const retireRuntimeOwnedBrowserSessionTab = vi.fn()
@@ -142,7 +142,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => {
})
it('closes the active headless tab on an implicit close', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false })
const closeTab = vi.fn(async () => {})
const bridge = {
@@ -165,7 +165,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => {
})
it('reports not-closed when no headless tab can be resolved', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const closeTab = vi.fn(async () => {})
const bridge = {
getRegisteredTabs: vi.fn(() => new Map()),
@@ -187,7 +187,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => {
})
it('forwards an unresolved worktree to the bridge unchanged for keyboard inserttext', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const keyboardInsertText = vi.fn().mockResolvedValue({ inserted: true })
const bridge = {
getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])),
@@ -76,7 +76,7 @@ function connectLegacyChannel() {
}
async function subscribe(sendBinary: RpcBinarySender) {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
let resolveDone!: () => void
const done = new Promise<void>((resolve) => {
resolveDone = resolve
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
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 { RuntimeBrowserCommands } from './orca-runtime-browser'
import type { RuntimeBrowserCommands } from './browser/browser-commands'
import {
createSinglePageBrowserCommandsHost,
createStreamedGuest
@@ -34,7 +34,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
})
it('restores the surviving subscriber viewport without stopping its shared stream', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
@@ -83,7 +83,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
})
it('keeps viewport authority with sized subscribers when a sizeless viewer joins', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
@@ -118,7 +118,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
})
it('replays the joiner snapshot that its pre-ready gate refused', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const snapshot = new Uint8Array([9, 9, 9])
let onFrame: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void = () => true
@@ -177,7 +177,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
})
it('drives the shared stream at the most constrained subscriber and releases it on leave', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const updateFrameBudget = vi.fn(async () => {})
startBrowserScreencast.mockResolvedValue({
@@ -239,7 +239,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
})
it('admits shared frames through the paired-runtime size guard', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
startBrowserScreencast.mockResolvedValue({
stop: vi.fn(() => done.resolve()),
@@ -287,7 +287,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
}
it('evicts a departed subscriber and hands its viewport to the survivor', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
@@ -342,7 +342,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
})
it('stops the shared stream when the evicted subscriber was the last one', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
startBrowserScreencast.mockResolvedValue({
@@ -362,7 +362,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
})
it('keeps a subscriber whose refusals are broken by a delivery', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
startBrowserScreencast.mockResolvedValue({
@@ -390,7 +390,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
})
it('never evicts on the refusals a joiner pre-ready gate produced', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
startBrowserScreencast.mockResolvedValue({
@@ -422,7 +422,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
})
it('replaces the older subscription when the same paired device reattaches', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
const updateViewport = vi.fn(async () => {})
@@ -472,7 +472,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
})
it('leaves other devices and unidentified callers attached', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const done = deferred()
const stop = vi.fn(() => done.resolve())
startBrowserScreencast.mockResolvedValue({
@@ -19,7 +19,7 @@ vi.mock('electron', () => ({
vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast }))
async function subscribe(sendBinary: RpcBinarySender) {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
let resolveDone!: () => void
const done = new Promise<void>((resolve) => {
resolveDone = resolve
+20 -20
View File
@@ -1,7 +1,7 @@
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 type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
const {
@@ -139,7 +139,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('creates profiles with the requested scope and label', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const profile = {
id: 'profile-google',
scope: 'isolated',
@@ -157,7 +157,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('waits for explicit worktree browser registration after requesting a hidden mount', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const send = vi.fn()
const bridge = {
getRegisteredTabs: vi.fn(() => new Map()),
@@ -178,7 +178,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('re-wakes an explicit worktree when the only registered browser tab is stale', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true })
const send = vi.fn()
const bridge = {
@@ -200,7 +200,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('waits for any browser registration after requesting a hidden mount without worktree scope', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const send = vi.fn()
const bridge = {
getRegisteredTabs: vi.fn(() => new Map()),
@@ -221,7 +221,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('creates the first explicit-worktree browser tab without waiting for an existing registration', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const webContents = { send: vi.fn() }
const send = vi.fn((channel: string, data: { requestId: string }) => {
expect(channel).toBe('browser:requestTabCreate')
@@ -280,7 +280,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('sends the resolved isolated profile partition when creating a renderer tab', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const webContents = { send: vi.fn() }
const send = vi.fn((channel: string, data: { requestId: string }) => {
expect(channel).toBe('browser:requestTabCreate')
@@ -327,7 +327,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('returns the renderer page identity before navigation settles', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const navigation = deferred<{ title: string; url: string }>()
const webContents = { send: vi.fn() }
webContents.send = vi.fn((_channel: string, data: { requestId: string }) => {
@@ -377,7 +377,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('rejects unknown explicit profile ids before requesting a renderer tab', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const send = vi.fn()
const commands = new RuntimeBrowserCommands(
createHost({
@@ -398,7 +398,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('sends the resolved partition when switching a renderer tab profile', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const webContents = { send: vi.fn() }
const send = vi.fn((channel: string, data: { requestId: string }) => {
expect(channel).toBe('browser:requestTabSetProfile')
@@ -440,7 +440,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('wakes the requested page instead of the first worktree tab for page-scoped commands', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true })
const send = vi.fn()
const snapshot = vi.fn(() => ({
@@ -480,7 +480,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('wakes the requested page before showing page-scoped tab metadata', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true })
const send = vi.fn()
const bridge = {
@@ -517,7 +517,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('wakes the requested page before showing page-scoped profile metadata', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true })
const send = vi.fn()
const bridge = {
@@ -557,7 +557,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('closes the requested renderer page without waiting for guest registration', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true })
const send = vi.fn((channel: string, data: { requestId?: string }) => {
if (channel !== 'browser:requestTabClose') {
@@ -594,7 +594,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('lets the renderer close an acknowledged page whose guest never registered', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const send = vi.fn((channel: string, data: { requestId?: string }) => {
if (channel !== 'browser:requestTabClose') {
return
@@ -630,7 +630,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
})
it('fans one page screencast out to multiple subscribers', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({
isDestroyed: () => false,
setBackgroundThrottling: vi.fn(),
@@ -683,7 +683,7 @@ describe('RuntimeBrowserCommands browser screencast', () => {
}, 10_000)
it('admits screencast frames through the paired-runtime size guard', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({
isDestroyed: () => false,
setBackgroundThrottling: vi.fn(),
@@ -725,7 +725,7 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => {
})
it('routes tab creation to the offscreen backend when no renderer window exists', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const createTab = vi.fn(async () => ({ browserPageId: 'page-offscreen' }))
const setActiveTab = vi.fn()
const bridge = {
@@ -755,7 +755,7 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => {
})
it('publishes a headless session snapshot after explicit navigation', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false })
const notifyHeadlessBrowserSessionTabsChanged = vi.fn()
const bridge = {
@@ -783,7 +783,7 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => {
})
it('rejects tab creation when neither a renderer nor an offscreen backend is available', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const { RuntimeBrowserCommands } = await import('./browser/browser-commands')
const commands = new RuntimeBrowserCommands(
createHost({
getAvailableAuthoritativeWindow: vi.fn(() => null),
-5
View File
@@ -1,5 +0,0 @@
import { RuntimeBrowserCommandsWithListLogicalBrowserTabs } from './runtime-browser-commands-list-logical-browser-tabs'
export class RuntimeBrowserCommands extends RuntimeBrowserCommandsWithListLogicalBrowserTabs {}
export type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params'
export type { RuntimeBrowserCommandHost } from './runtime-browser-commands-browser-command-target-params'
@@ -2,7 +2,7 @@
import './setup.spec'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { RuntimeBrowserCommands } from '../orca-runtime-browser'
import { RuntimeBrowserCommands } from '../browser/browser-commands'
import {
setRuntimeBrowserCommandsFactory,
setRuntimeBrowserUnavailableCause
@@ -1,218 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserTabList } from './runtime-browser-commands-browser-tab-list'
import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params'
import type {
BrowserCaptureStartResult,
BrowserCaptureStopResult,
BrowserClearResult,
BrowserConsoleResult,
BrowserCookieDeleteResult,
BrowserCookieGetResult,
BrowserCookieSetResult,
BrowserGeolocationResult,
BrowserInterceptDisableResult,
BrowserInterceptEnableResult,
BrowserKeypressResult,
BrowserPdfResult,
BrowserScreenshotResult,
BrowserSelectAllResult,
BrowserViewportResult
} from '../../shared/runtime-types'
export class RuntimeBrowserCommandsWithBrowserClear extends RuntimeBrowserCommandsWithBrowserTabList {
async browserClear(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserClearResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().clear(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserSelectAll(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserSelectAllResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().selectAll(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserKeypress(
params: { key: string } & BrowserCommandTargetParams
): Promise<BrowserKeypressResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().keypress(
params.key,
target.worktreeId,
target.browserPageId
)
}
async browserPdf(params: BrowserCommandTargetParams): Promise<BrowserPdfResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId)
}
async browserFullScreenshot(
params: {
format?: 'png' | 'jpeg'
} & BrowserCommandTargetParams
): Promise<BrowserScreenshotResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().fullPageScreenshot(
params.format,
target.worktreeId,
target.browserPageId
)
}
// ── Cookie management ──
async browserCookieGet(
params: { url?: string } & BrowserCommandTargetParams
): Promise<BrowserCookieGetResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().cookieGet(
params.url,
target.worktreeId,
target.browserPageId
)
}
async browserCookieSet(
params: {
name: string
value: string
domain?: string
path?: string
secure?: boolean
httpOnly?: boolean
sameSite?: string
expires?: number
} & BrowserCommandTargetParams
): Promise<BrowserCookieSetResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().cookieSet(
params,
target.worktreeId,
target.browserPageId
)
}
async browserCookieDelete(
params: {
name: string
domain?: string
url?: string
} & BrowserCommandTargetParams
): Promise<BrowserCookieDeleteResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().cookieDelete(
params.name,
params.domain,
params.url,
target.worktreeId,
target.browserPageId
)
}
// ── Viewport ──
async browserSetViewport(
params: {
width: number
height: number
deviceScaleFactor?: number
mobile?: boolean
} & BrowserCommandTargetParams
): Promise<BrowserViewportResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setViewport(
params.width,
params.height,
params.deviceScaleFactor,
params.mobile,
target.worktreeId,
target.browserPageId
)
}
// ── Geolocation ──
async browserSetGeolocation(
params: {
latitude: number
longitude: number
accuracy?: number
} & BrowserCommandTargetParams
): Promise<BrowserGeolocationResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setGeolocation(
params.latitude,
params.longitude,
params.accuracy,
target.worktreeId,
target.browserPageId
)
}
// ── Request interception ──
async browserInterceptEnable(
params: {
patterns?: string[]
} & BrowserCommandTargetParams
): Promise<BrowserInterceptEnableResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().interceptEnable(
params.patterns,
target.worktreeId,
target.browserPageId
)
}
async browserInterceptDisable(
params: BrowserCommandTargetParams
): Promise<BrowserInterceptDisableResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().interceptDisable(
target.worktreeId,
target.browserPageId
)
}
async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId)
}
// ── Console/network capture ──
async browserCaptureStart(
params: BrowserCommandTargetParams
): Promise<BrowserCaptureStartResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId)
}
async browserCaptureStop(params: BrowserCommandTargetParams): Promise<BrowserCaptureStopResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId)
}
async browserConsoleLog(
params: { limit?: number } & BrowserCommandTargetParams
): Promise<BrowserConsoleResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().consoleLog(
params.limit,
target.worktreeId,
target.browserPageId
)
}
}
@@ -1,176 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithActiveScreencastsByPageId } from './runtime-browser-commands-active-screencasts-by-page-id'
import type {
ActiveBrowserScreencastPage,
BrowserCommandTargetParams
} from './runtime-browser-commands-browser-command-target-params'
import {
applySharedScreencastFrameBudget,
hasScreencastViewportSize
} from './runtime-browser-commands-browser-command-target-params'
import type {
BrowserBackResult,
BrowserClickResult,
BrowserFillResult,
BrowserGotoResult,
BrowserReloadResult,
BrowserScreenshotResult,
BrowserScrollResult,
BrowserSelectResult,
BrowserTypeResult
} from '../../shared/runtime-types'
import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types'
import { cancelScreencastSubscriberFrameRetry } from './browser-screencast-subscriber-frame-delivery'
export class RuntimeBrowserCommandsWithBrowserClick extends RuntimeBrowserCommandsWithActiveScreencastsByPageId {
async browserClick(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserClickResult> {
const target = await this.resolveBrowserCommandTarget(params)
const bridge = this.requireAgentBrowserBridge()
const result = await bridge.click(params.element, target.worktreeId, target.browserPageId)
// Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page.
const page = bridge.getPageInfo(target.worktreeId, target.browserPageId)
if (page) {
this.notifyRendererNavigation(page.browserPageId, page.url, page.title)
}
return result
}
async browserGoto(
params: { url: string } & BrowserCommandTargetParams
): Promise<BrowserGotoResult> {
const target = await this.resolveBrowserCommandTarget(params)
const bridge = this.requireAgentBrowserBridge()
const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId)
const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
if (pageId) {
this.notifyRendererNavigation(pageId, result.url, result.title)
}
if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) {
this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId)
}
return result
}
async browserFill(
params: {
element: string
value: string
} & BrowserCommandTargetParams
): Promise<BrowserFillResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().fill(
params.element,
params.value,
target.worktreeId,
target.browserPageId
)
}
async browserType(
params: { input: string } & BrowserCommandTargetParams
): Promise<BrowserTypeResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().type(
params.input,
target.worktreeId,
target.browserPageId
)
}
async browserSelect(
params: {
element: string
value: string
} & BrowserCommandTargetParams
): Promise<BrowserSelectResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().select(
params.element,
params.value,
target.worktreeId,
target.browserPageId
)
}
async browserScroll(
params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams
): Promise<BrowserScrollResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().scroll(
params.direction,
params.amount,
target.worktreeId,
target.browserPageId
)
}
async browserBack(params: BrowserCommandTargetParams): Promise<BrowserBackResult> {
const target = await this.resolveBrowserCommandTarget(params)
const bridge = this.requireAgentBrowserBridge()
const result = await bridge.back(target.worktreeId, target.browserPageId)
const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
if (pageId) {
this.notifyRendererNavigation(pageId, result.url, result.title)
}
return result
}
async browserReload(params: BrowserCommandTargetParams): Promise<BrowserReloadResult> {
const target = await this.resolveBrowserCommandTarget(params)
const bridge = this.requireAgentBrowserBridge()
const result = await bridge.reload(target.worktreeId, target.browserPageId)
const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
if (pageId) {
this.notifyRendererNavigation(pageId, result.url, result.title)
}
return result
}
async browserScreenshot(
params: {
format?: 'png' | 'jpeg'
} & BrowserCommandTargetParams
): Promise<BrowserScreenshotResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().screenshot(
params.format,
target.worktreeId,
target.browserPageId
)
}
// The single leave path: an explicit stop, a ghost eviction and a same-device replacement all
// unwind through here, so viewport hand-off, budget release and stream teardown cannot drift.
private leaveScreencastSubscriber(
active: ActiveBrowserScreencastPage,
subscriptionId: string,
session: BrowserScreencastSession
): void {
const subscriber = active.subscribers.get(subscriptionId)
if (!subscriber) {
return
}
active.subscribers.delete(subscriptionId)
cancelScreencastSubscriberFrameRetry(subscriber)
subscriber.resolveDone()
if (active.viewportOwnerSubscriptionId === subscriptionId) {
const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) =>
hasScreencastViewportSize(candidate.viewport)
)
active.viewportOwnerSubscriptionId = fallback?.[0] ?? null
if (fallback) {
void session.updateViewport(fallback[1].viewport).catch(() => {})
}
}
if (active.subscribers.size === 0) {
active.stopping = true
session.stop()
return
}
// Why: a departed subscriber's caps would otherwise pin the shared stream for
// the rest of its life, long after the client that asked for them is gone.
void applySharedScreencastFrameBudget(active, session).catch(() => {})
}
}
@@ -1,193 +0,0 @@
// @ts-nocheck -- mechanically split declarations.
import type { BrowserScreencastResult } from '../../shared/runtime-types'
import type {
BrowserScreencastFrameBudget,
BrowserScreencastSession,
BrowserScreencastViewport
} from '../browser/browser-screencast-stream-types'
import type { ScreencastSubscriberDeliveryState } from './browser-screencast-ghost-subscriber-eviction'
import {
browserScreencastFrameBudgetsEqual,
mergeBrowserScreencastFrameBudgets
} from '../browser/browser-screencast-frame-budget'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry'
import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import type { BrowserWindow } from 'electron'
import type { BrowserBackend } from '../browser/browser-backend'
import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication'
import type { RpcBinarySender } from './rpc/rpc-binary-sender'
export type BrowserCommandTargetParams = {
worktree?: string
page?: string
}
export type ResolvedBrowserCommandTarget = {
worktreeId?: string
browserPageId?: string
}
export type ResolvedBrowserPageWebContents = {
browserPageId: string
webContents: Electron.WebContents
}
export type BrowserScreencastParams = {
format: 'jpeg' | 'png'
quality?: number
maxWidth?: number
maxHeight?: number
viewportWidth?: number
viewportHeight?: number
deviceScaleFactor?: number
mobile?: boolean
everyNthFrame?: number
minFrameIntervalMs?: number
} & BrowserCommandTargetParams
export type BrowserScreencastStartResult = {
subscriptionId: string
ready: Extract<BrowserScreencastResult, { type: 'ready' }>
// The frame budget and the page's dialog belong to the shared page, not to one subscriber's handle.
session: Omit<BrowserScreencastSession, 'updateFrameBudget' | 'settleDialog'>
// Why: callers gate frames until they have emitted `ready`, and the snapshot captured
// for a joining subscriber lands inside that window. This replays it once the gate opens.
flushPendingFrame: () => void
}
export type ActiveBrowserScreencastSubscriber = {
sendBinary: RpcBinarySender
emit?: (event: BrowserScreencastResult) => void
done: Promise<void>
resolveDone: () => void
viewport: BrowserScreencastViewport
budget: BrowserScreencastFrameBudget
pendingFrame: Uint8Array<ArrayBufferLike> | null
pendingFrameRetry: ReturnType<typeof setTimeout> | null
// Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new
// socket never reuses the old id, so a reconnecting device would stack a second subscription.
pairedDeviceId?: string
delivery: ScreencastSubscriberDeliveryState
}
export type ActiveBrowserScreencastPage = {
format: 'jpeg' | 'png'
session: BrowserScreencastSession | null
started: Promise<BrowserScreencastSession>
stopping: boolean
subscribers: Map<string, ActiveBrowserScreencastSubscriber>
viewportOwnerSubscriptionId: string | null
appliedBudget: BrowserScreencastFrameBudget
}
export async function applySharedScreencastFrameBudget(
active: ActiveBrowserScreencastPage,
session: BrowserScreencastSession
): Promise<void> {
const merged = mergeBrowserScreencastFrameBudgets(
Array.from(active.subscribers.values(), (subscriber) => subscriber.budget)
)
if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) {
return
}
active.appliedBudget = merged
await session.updateFrameBudget(merged)
}
export function normalizeScreencastViewport(
params: BrowserScreencastParams
): BrowserScreencastViewport {
return {
viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840),
viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160),
deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4),
mobile: params.mobile === true
}
}
export function normalizeScreencastFrameBudget(
params: BrowserScreencastParams
): BrowserScreencastFrameBudget {
return {
quality: clampInteger(params.quality, 10, 100, 70),
maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440),
maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200),
everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2),
minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0)
}
}
export function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean {
return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined
}
export function clampInteger(
value: number | undefined,
min: number,
max: number,
fallback: number
): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback
}
return Math.min(max, Math.max(min, Math.round(value)))
}
export function clampOptionalInteger(
value: number | undefined,
min: number,
max: number
): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.min(max, Math.max(min, Math.round(value)))
}
export function clampOptionalNumber(
value: number | undefined,
min: number,
max: number
): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.min(max, Math.max(min, value))
}
export type RuntimeBrowserCommandHost = {
getAgentBrowserBridge(): AgentBrowserBridge | null
resolveWorktreeSelector(selector: string): Promise<{
id: string
repoId?: string
hostId?: ExecutionHostId
}>
resolveBrowserWorkspace(selector: string): Promise<{
id: string
repoId?: string
hostId?: ExecutionHostId
}>
resolveBrowserNetworkExecutionHost(worktree?: {
id: string
repoId?: string
hostId?: ExecutionHostId
}): BrowserNetworkExecutionHost | Promise<BrowserNetworkExecutionHost>
getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry
getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry
getAuthoritativeWindow(): BrowserWindow
getAvailableAuthoritativeWindow(): BrowserWindow | null
// Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing.
getOffscreenBrowserBackend(): BrowserBackend | null
// Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal.
markHeadlessBrowserSessionTabActive?(
worktreeId: string | undefined,
browserPageId: string,
options?: BrowserSessionTabSelectionOptions
): void
notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void
/** True when a runtime-owned session row for that page existed and was retired. */
retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void
}
@@ -1,200 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserClear } from './runtime-browser-commands-browser-clear'
import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params'
import { clampOptionalNumber } from './runtime-browser-commands-browser-command-target-params'
import type { BrowserNetworkLogResult } from '../../shared/runtime-types'
export class RuntimeBrowserCommandsWithBrowserNetworkLog extends RuntimeBrowserCommandsWithBrowserClear {
async browserNetworkLog(
params: { limit?: number } & BrowserCommandTargetParams
): Promise<BrowserNetworkLogResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().networkLog(
params.limit,
target.worktreeId,
target.browserPageId
)
}
// ── Additional core commands ──
async browserDblclick(
params: { element: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().dblclick(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserForward(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId)
}
async browserScrollIntoView(
params: { element: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().scrollIntoView(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserGet(
params: {
what: string
selector?: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().get(
params.what,
params.selector,
target.worktreeId,
target.browserPageId
)
}
async browserIs(
params: { what: string; selector: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().is(
params.what,
params.selector,
target.worktreeId,
target.browserPageId
)
}
// ── Keyboard insert text ──
async browserKeyboardInsertText(
params: { text: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().keyboardInsertText(
params.text,
target.worktreeId,
target.browserPageId
)
}
// ── Mouse commands ──
async browserMouseMove(
params: { x: number; y: number } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().mouseMove(
params.x,
params.y,
target.worktreeId,
target.browserPageId
)
}
async browserMouseDown(
params: { button?: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().mouseDown(
params.button,
target.worktreeId,
target.browserPageId
)
}
async browserMouseClick(
params: {
x: number
y: number
button?: string
radius?: number
modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[]
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().mouseClick(
params.x,
params.y,
params.button,
target.worktreeId,
target.browserPageId,
clampOptionalNumber(params.radius, 0, 64),
params.modifiers
)
}
async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().mouseUp(
params.button,
target.worktreeId,
target.browserPageId
)
}
async browserMouseWheel(
params: {
dy: number
dx?: number
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().mouseWheel(
params.dy,
params.dx,
target.worktreeId,
target.browserPageId
)
}
// ── Find (semantic locators) ──
async browserFind(
params: {
locator: string
value: string
action: string
text?: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().find(
params.locator,
params.value,
params.action,
params.text,
target.worktreeId,
target.browserPageId
)
}
// ── Set commands ──
async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setDevice(
params.name,
target.worktreeId,
target.browserPageId
)
}
async browserSetOffline(
params: { state?: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setOffline(
params.state,
target.worktreeId,
target.browserPageId
)
}
}
@@ -1,70 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserTabSetProfile } from './runtime-browser-commands-browser-tab-set-profile'
import type {
BrowserProfileClearDefaultCookiesResult,
BrowserProfileImportFromBrowserResult
} from '../../shared/runtime-types'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import {
detectInstalledBrowsers,
importCookiesFromBrowser,
selectBrowserProfile
} from '../browser/browser-cookie-import'
export class RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser extends RuntimeBrowserCommandsWithBrowserTabSetProfile {
async browserProfileImportFromBrowser(params: {
profileId: string
browserFamily: string
browserProfile?: string
supportsPartitionSkippedCookies?: true
}): Promise<BrowserProfileImportFromBrowserResult> {
const profile = browserSessionRegistry.getProfile(params.profileId)
if (!profile) {
return { ok: false, reason: 'Session profile not found.' }
}
if (
params.browserProfile &&
(/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..'))
) {
return { ok: false, reason: 'Invalid browser profile name.' }
}
const browsers = detectInstalledBrowsers()
let browser = browsers.find((candidate) => candidate.family === params.browserFamily)
if (!browser) {
return { ok: false, reason: 'Browser not found on this system.' }
}
if (params.browserProfile && params.browserProfile !== browser.selectedProfile) {
const reselected = selectBrowserProfile(browser, params.browserProfile)
if (!reselected) {
return {
ok: false,
reason: `No cookies database found for profile "${params.browserProfile}".`
}
}
browser = reselected
}
const result = await importCookiesFromBrowser(browser, profile.partition, {
canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true
})
if (!result.ok) {
return result
}
const profileName =
browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ??
browser.selectedProfile
browserSessionRegistry.updateProfileSource(params.profileId, {
browserFamily: browser.family,
profileName,
importedAt: Date.now()
})
return { ...result, profileId: params.profileId }
}
async browserProfileClearDefaultCookies(): Promise<BrowserProfileClearDefaultCookiesResult> {
return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() }
}
}
@@ -1,217 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeBrowserCommandsWithBrowserNetworkLog } from './runtime-browser-commands-browser-network-log'
import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params'
import {
browserDialogSettledResult,
settleBrowserDialogOnLiveScreencast,
type BrowserDialogResult
} from './browser-dialog-settlement'
export class RuntimeBrowserCommandsWithBrowserSetHeaders extends RuntimeBrowserCommandsWithBrowserNetworkLog {
async browserSetHeaders(
params: { headers: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setHeaders(
params.headers,
target.worktreeId,
target.browserPageId
)
}
async browserSetCredentials(
params: {
user: string
pass: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setCredentials(
params.user,
params.pass,
target.worktreeId,
target.browserPageId
)
}
async browserSetMedia(
params: {
colorScheme?: string
reducedMotion?: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setMedia(
params.colorScheme,
params.reducedMotion,
target.worktreeId,
target.browserPageId
)
}
// ── Clipboard commands ──
async browserClipboardRead(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId)
}
async browserClipboardWrite(
params: { text: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().clipboardWrite(
params.text,
target.worktreeId,
target.browserPageId
)
}
// ── Dialog commands ──
async browserDialogAccept(
params: { text?: string } & BrowserCommandTargetParams
): Promise<BrowserDialogResult> {
const target = await this.resolveBrowserCommandTarget(params)
if (
!(await settleBrowserDialogOnLiveScreencast(
this.activeScreencastsByPageId,
target.browserPageId,
true,
params.text
))
) {
await this.requireAgentBrowserBridge().dialogAccept(
params.text,
target.worktreeId,
target.browserPageId
)
}
return browserDialogSettledResult(true)
}
async browserDialogDismiss(params: BrowserCommandTargetParams): Promise<BrowserDialogResult> {
const target = await this.resolveBrowserCommandTarget(params)
if (
!(await settleBrowserDialogOnLiveScreencast(
this.activeScreencastsByPageId,
target.browserPageId,
false
))
) {
await this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId)
}
return browserDialogSettledResult(false)
}
// ── Storage commands ──
async browserStorageLocalGet(
params: { key: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageLocalGet(
params.key,
target.worktreeId,
target.browserPageId
)
}
async browserStorageLocalSet(
params: {
key: string
value: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageLocalSet(
params.key,
params.value,
target.worktreeId,
target.browserPageId
)
}
async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageLocalClear(
target.worktreeId,
target.browserPageId
)
}
async browserStorageSessionGet(
params: { key: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageSessionGet(
params.key,
target.worktreeId,
target.browserPageId
)
}
async browserStorageSessionSet(
params: {
key: string
value: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageSessionSet(
params.key,
params.value,
target.worktreeId,
target.browserPageId
)
}
async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageSessionClear(
target.worktreeId,
target.browserPageId
)
}
// ── Download command ──
async browserDownload(
params: {
selector: string
path: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().download(
params.selector,
params.path,
target.worktreeId,
target.browserPageId
)
}
// ── Highlight command ──
async browserHighlight(
params: { selector: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().highlight(
params.selector,
target.worktreeId,
target.browserPageId
)
}
// ── New: exec passthrough + tab lifecycle ──
async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().exec(
params.command,
target.worktreeId,
target.browserPageId
)
}
}
@@ -3,12 +3,13 @@ import {
BROWSER_UNAVAILABLE_ERROR_CODE,
type RuntimeBrowserUnavailableReason
} from '../../shared/runtime-types'
import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import type { RuntimeBrowserCommands } from './browser/browser-commands'
/**
* How `OrcaRuntimeService` obtains its browser-automation commands.
*
* Why a factory rather than a direct import: `orca-runtime-browser.ts` reaches the
* Why a factory rather than a direct import: `browser/browser-commands.ts` reaches the
* whole Chromium cluster — `BrowserWindow`, `session`, `webContents`, cookie jars —
* 15 modules that a Node host cannot load at all. Importing the class for its *type*
* is free; constructing it is what drags the cluster in.
@@ -1,6 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params'
export class RuntimeBrowserCommandsState {
protected readonly activeScreencastsByPageId = new Map<string, ActiveBrowserScreencastPage>()
}
@@ -5,11 +5,11 @@ import {
screencastSubscriberDrivesAsMobile,
type BrowserScreencastSubscriber
} from './browser-screencast-driver-scope'
import type { RuntimeBrowserCommands } from './orca-runtime-browser'
import type { RuntimeBrowserCommands } from './browser/browser-commands'
import type { RpcBinarySender } from './rpc/rpc-binary-sender'
type RuntimeBrowserScreencastControllerDeps = {
getCommands: () => RuntimeBrowserCommands
getCommands: () => Pick<RuntimeBrowserCommands, 'browserScreencast'>
registerSubscriptionCleanup: (
subscriptionId: string,
cleanup: () => void | Promise<void>,
@@ -1,5 +1,6 @@
import type { BrowserScreencastResult } from '../../shared/runtime-types'
import type { RuntimeBrowserCommands, RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import type { RuntimeBrowserCommands } from './browser/browser-commands'
import { RuntimeEmulatorCommands } from './orca-runtime-emulator'
import { RuntimeBrowserScreencastController } from './runtime-browser-screencast-controller'
import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory'
@@ -1,6 +1,6 @@
import { vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts'
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import type { RendererPublicationThrottleTarget } from '../window/renderer-publication-throttle'