mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
refactor(runtime): compose typed browser command owners (#27576)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
+32
-41
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+97
-19
@@ -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
|
||||
})
|
||||
})
|
||||
+103
-50
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
-62
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-30
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-16
@@ -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)
|
||||
}
|
||||
+49
-105
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user