Files
orca/src/main/browser/agent-browser-bridge.test.ts
T

2172 lines
73 KiB
TypeScript

/* eslint-disable max-lines */
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } =
vi.hoisted(() => ({
execFileMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
existsSyncMock: vi.fn(() => false),
readFileSyncMock: vi.fn(() => Buffer.from('')),
stdinWrites: [] as string[]
}))
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
accessSync: vi.fn(),
chmodSync: vi.fn(),
constants: { X_OK: 1 }
}))
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
vi.mock('electron', () => {
return {
app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false },
webContents: { fromId: webContentsFromIdMock }
}
})
const { CdpWsProxyMock } = vi.hoisted(() => {
const instances: unknown[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MockClass = vi.fn().mockImplementation(function (this: any, _wc: unknown) {
this._wc = _wc
this.start = vi.fn(async () => 'ws://127.0.0.1:9222')
this.stop = vi.fn(async () => {})
this.getPort = vi.fn(() => 9222)
instances.push(this)
})
return { CdpWsProxyMock: Object.assign(MockClass, { instances }) }
})
vi.mock('./cdp-ws-proxy', () => ({
CdpWsProxy: CdpWsProxyMock
}))
vi.mock('./cdp-bridge', () => ({
BrowserError: class BrowserError extends Error {
code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
}))
import {
AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES,
AgentBrowserBridge
} from './agent-browser-bridge'
import type { BrowserManager } from './browser-manager'
import {
CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS,
CLIPBOARD_TEXT_WRITE_MAX_BYTES,
CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
} from '../../shared/clipboard-text'
// Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId
// inside a try/catch. Override the private method to inject our mock.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(AgentBrowserBridge.prototype as any).getWebContents = function (id: number) {
return webContentsFromIdMock(id) ?? null
}
function mockBrowserManager(
tabs: Map<string, number> = new Map([['tab-1', 100]]),
worktrees: Map<string, string> = new Map(),
overrides: Partial<BrowserManager> = {}
): BrowserManager {
return {
getWebContentsIdByTabId: () => tabs,
getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId),
getGuestWebContentsId: vi.fn(() => null),
getBrowserPageLoadError: vi.fn(() => null),
getBrowserPageCertificateFailure: vi.fn(() => null),
unregisterGuest: vi.fn(),
ensureWebviewVisible: vi.fn(async () => () => {}),
acquireAutomationVisibility: vi.fn(async () => () => {}),
...overrides
} as unknown as BrowserManager
}
function mockWebContents(id: number, url = 'https://example.com', title = 'Example') {
return {
id,
getURL: () => url,
getTitle: () => title,
isDestroyed: () => false,
invalidate: vi.fn(),
focus: vi.fn(),
debugger: {
isAttached: vi.fn(() => true),
attach: vi.fn(),
detach: vi.fn(),
sendCommand: vi.fn(),
on: vi.fn(),
removeListener: vi.fn()
}
}
}
function succeedWith(data: unknown): void {
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, JSON.stringify({ success: true, data }), '')
return {
stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) }
}
})
}
function succeedForContentEditable(data: unknown = { ok: true }): void {
execFileMock.mockImplementation((_bin: string, args: string[], _opts: unknown, cb: Function) => {
const result =
args.includes('get') && args.includes('attr') && args.includes('contenteditable')
? { value: 'true' }
: data
cb(null, JSON.stringify({ success: true, data: result }), '')
return {
stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) }
}
})
}
function failWith(error: string): void {
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, JSON.stringify({ success: false, error }), '')
})
}
class TestEvent {
type: string
bubbles: boolean
constructor(type: string, init?: { bubbles?: boolean }) {
this.type = type
this.bubbles = init?.bubbles ?? false
}
}
type FillEvalNode = {
tagName: string
getAttribute: (name: string) => string | null
matches: (selector: string) => boolean
querySelector?: (selector: string) => FillEvalNode | null
dispatchEvent: (event: TestEvent) => boolean
value: string
}
function matchesFillEvalSelector(node: FillEvalNode, selector: string): boolean {
return selector.split(',').some((candidate) => {
const trimmed = candidate.trim()
if (trimmed === 'textarea') {
return node.tagName === 'TEXTAREA'
}
if (!trimmed.startsWith('input') || node.tagName !== 'INPUT') {
return false
}
const excludedTypes = [...trimmed.matchAll(/:not\(\[type='([^']+)'\]\)/g)].map((match) =>
match[1].toLowerCase()
)
const inputType = node.getAttribute('type')?.toLowerCase() ?? ''
return !excludedTypes.includes(inputType)
})
}
function createFillEvalNode(options: {
tagName: string
role?: string
ariaControls?: string
descendant?: FillEvalNode | null
descendants?: FillEvalNode[]
type?: string
}) {
const events: TestEvent[] = []
let value = ''
const proto = {
get value() {
return value
},
set value(next: string) {
value = next
}
}
const node = Object.create(proto) as FillEvalNode
node.tagName = options.tagName
node.getAttribute = (name: string) => {
if (name === 'role') {
return options.role ?? null
}
if (name === 'aria-controls') {
return options.ariaControls ?? null
}
if (name === 'type') {
return options.type ?? null
}
return null
}
node.matches = vi.fn((selector: string) => matchesFillEvalSelector(node, selector))
const descendants = options.descendants ?? (options.descendant ? [options.descendant] : [])
node.querySelector = vi.fn(
(selector: string) => descendants.find((descendant) => descendant.matches(selector)) ?? null
)
node.dispatchEvent = vi.fn((event: TestEvent) => {
events.push(event)
return true
})
return {
node,
events,
get value() {
return value
}
}
}
function runFillEvalExpressions(
expressions: string[],
document: { activeElement: unknown; getElementById: (id: string) => unknown },
windowObject: Record<string, unknown> = {}
): void {
for (const expression of expressions) {
new Function('document', 'Event', 'window', `return (${expression})`)(
document,
TestEvent,
windowObject
)
}
}
function createContentEditableEvalEnvironment(initialText: string) {
const editor = {
tagName: 'DIV',
isContentEditable: true,
textContent: initialText,
matches: vi.fn(() => false),
getAttribute: vi.fn((name: string) => (name === 'contenteditable' ? 'true' : null)),
dispatchEvent: vi.fn()
}
let selected = false
const selection = {
selectAllChildren: vi.fn(() => {
selected = true
})
}
const execCommand = vi.fn((command: string, _showUi: boolean, value: string) => {
// Chromium treats an empty insertText as a successful no-op; deletion is
// required to clear a selected contenteditable through the input pipeline.
if (command === 'delete') {
if (!selected) {
return false
}
editor.textContent = ''
} else if (value.length > 0) {
editor.textContent = selected ? value : editor.textContent + value
}
selected = false
return true
})
return {
editor,
execCommand,
document: {
activeElement: editor,
body: {},
getElementById: () => null,
execCommand
},
windowObject: { getSelection: () => selection }
}
}
const CDP_DISCOVERY_FAILURE =
'Auto-launch failed: All CDP discovery methods failed: connect ECONNREFUSED 127.0.0.1:9222; WebSocket connect failed'
describe('AgentBrowserBridge', () => {
let bridge: AgentBrowserBridge
beforeEach(() => {
vi.clearAllMocks()
stdinWrites.length = 0
CdpWsProxyMock.instances.length = 0
existsSyncMock.mockReturnValue(false)
readFileSyncMock.mockReturnValue(Buffer.from(''))
const wc = mockWebContents(100)
webContentsFromIdMock.mockReturnValue(wc)
bridge = new AgentBrowserBridge(mockBrowserManager())
bridge.setActiveTab(100)
})
// ── Session naming ──
it('uses browserPageId as session name', async () => {
succeedWith({ snapshot: '...' })
await bridge.snapshot()
const args = execFileMock.mock.calls[0][1] as string[]
expect(args).toContain('--session')
expect(args[args.indexOf('--session') + 1]).toBe('orca-tab-tab-1')
})
// ── --cdp first-use only ──
it('passes --cdp only on first command for a session', async () => {
succeedWith({ snapshot: '...' })
await bridge.snapshot()
// Why: calls[0] is stale-session 'close'; find the snapshot call
const snapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect(snapshotCall![1]).toContain('--cdp')
const cdpIdx = (snapshotCall![1] as string[]).indexOf('--cdp')
expect((snapshotCall![1] as string[])[cdpIdx + 1]).toBe('9222')
succeedWith({ clicked: '@e1' })
await bridge.click('@e1')
const clickCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('click')
)
expect(clickCall![1]).not.toContain('--cdp')
})
it('continues when stale agent-browser session close hangs during session creation', async () => {
vi.useFakeTimers()
try {
const closeKill = vi.fn()
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
return { kill: closeKill }
}
if (args.includes('snapshot')) {
cb(null, JSON.stringify({ success: true, data: { snapshot: 'ready' } }), '')
return { kill: vi.fn() }
}
throw new Error(`unexpected agent-browser args ${args.join(' ')}`)
}
)
const promise = bridge.snapshot()
let settled = false
void promise.finally(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(3_000)
await Promise.resolve()
expect(settled).toBe(true)
await expect(promise).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'ready' })
expect(closeKill).toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
// ── --json always appended ──
it('always appends --json to commands', async () => {
succeedWith({ snapshot: '...' })
await bridge.snapshot()
const snapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect((snapshotCall![1] as string[]).at(-1)).toBe('--json')
})
// ── Output translation ──
it('translates success response to result', async () => {
succeedWith({ snapshot: 'tree output' })
const result = await bridge.snapshot()
expect(result).toEqual({ browserPageId: 'tab-1', snapshot: 'tree output' })
})
it('routes snapshot to an explicit browser page id without changing the active tab', async () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
b.setActiveTab(1)
succeedWith({ snapshot: 'tree output' })
const result = await b.snapshot(undefined, 'tab-b')
const snapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect(snapshotCall).toBeTruthy()
expect(snapshotCall![1]).toContain('--session')
expect(
(snapshotCall![1] as string[])[(snapshotCall![1] as string[]).indexOf('--session') + 1]
).toBe('orca-tab-tab-b')
expect(result).toEqual({ browserPageId: 'tab-b', snapshot: 'tree output' })
expect(b.getActiveWebContentsId()).toBe(1)
})
it('translates error response to BrowserError', async () => {
failWith('Element not found')
await expect(bridge.click('@e1')).rejects.toThrow('Element not found')
})
it('keeps CDP discovery failures generic while the tab session is still live', async () => {
failWith(CDP_DISCOVERY_FAILURE)
await expect(bridge.snapshot()).rejects.toMatchObject({
code: 'browser_error',
message: CDP_DISCOVERY_FAILURE
})
})
it('maps in-flight CDP discovery failures to tab not found after the session disappears', async () => {
let releaseSnapshot: (() => void) | null = null
const activeChild = { kill: vi.fn() }
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('snapshot')) {
releaseSnapshot = () => {
cb(null, JSON.stringify({ success: false, error: CDP_DISCOVERY_FAILURE }), '')
}
return activeChild
}
cb(null, JSON.stringify({ success: true, data: null }), '')
return { kill: vi.fn() }
}
)
const snapshotPromise = bridge.snapshot()
await vi.waitFor(() => {
expect(releaseSnapshot).not.toBeNull()
})
// Why: this reproduces the teardown race where the tab close path has
// already removed the bridge session before agent-browser reports that
// its CDP proxy disappeared.
;(bridge as unknown as { sessions: Map<string, unknown> }).sessions.delete('orca-tab-tab-1')
releaseSnapshot!()
await expect(snapshotPromise).rejects.toMatchObject({
code: 'browser_tab_not_found',
message: 'Browser page tab-1 is no longer available'
})
})
it('maps target disappearance during session creation to tab not found', async () => {
const wc = mockWebContents(100)
webContentsFromIdMock.mockImplementationOnce(() => wc).mockImplementationOnce(() => null)
await expect(bridge.snapshot(undefined, 'tab-1')).rejects.toMatchObject({
code: 'browser_tab_not_found',
message: 'Browser page tab-1 is no longer available'
})
})
it('handles malformed JSON from agent-browser', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, 'not json at all', '')
}
)
await expect(bridge.snapshot()).rejects.toThrow()
})
// ── exec passthrough ──
it('strips --cdp and --session from exec commands', async () => {
succeedWith({ output: 'ok' })
await bridge.exec(
'dblclick @e3 --cdp ws://evil --session hijack --cdp=ws://evil-equals --session=hijack-equals'
)
// Why: find the actual exec call (contains 'dblclick'), not the stale-session close
const execCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('dblclick')
)
const args = execCall![1] as string[]
// The bridge's own --session and --cdp (for session init) are expected.
// Verify the user-injected ones were stripped, including --flag=value forms.
expect(args.join(' ')).not.toContain('ws://evil')
expect(args.join(' ')).not.toContain('ws://evil-equals')
expect(args.join(' ')).not.toContain('hijack')
expect(args.join(' ')).not.toContain('hijack-equals')
expect(args).toContain('dblclick')
expect(args).toContain('@e3')
})
// ── Worktree filtering ──
describe('worktree filtering', () => {
it('returns all tabs when no worktreeId', () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
const result = b.tabList()
expect(result.tabs).toHaveLength(2)
})
it('returns only matching worktree tabs', () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-2']
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
const result = b.tabList('wt-1')
expect(result.tabs).toHaveLength(1)
expect(result.tabs[0].browserPageId).toBe('tab-a')
expect(result.tabs[0].url).toBe('https://a.com')
})
it('surfaces the browser-manager load error on each listed tab', () => {
const tabs = new Map([['tab-a', 1]])
const wc1 = mockWebContents(1, 'chrome-error://chromewebdata/', '')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : null))
const loadError = {
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/'
}
const certificateFailure = {
challengeId: 'challenge-1',
browserPageId: 'tab-a',
errorCode: -202,
error: 'ERR_CERT_AUTHORITY_INVALID',
origin: 'https://localhost:3443',
displayHost: 'localhost:3443',
canProceed: true,
observedAt: 123
}
const b = new AgentBrowserBridge(
mockBrowserManager(tabs, new Map(), {
getBrowserPageLoadError: vi.fn((tabId: string) => (tabId === 'tab-a' ? loadError : null)),
getBrowserPageCertificateFailure: vi.fn((tabId: string) =>
tabId === 'tab-a' ? certificateFailure : null
)
})
)
// Why: an agent driving the browser must see the structured cert failure,
// not just chrome-error:// from getURL().
expect(b.tabList().tabs[0]).toMatchObject({
url: 'https://localhost:3443/',
loadError,
certificateFailure
})
})
it('does not mutate active-tab routing when tab-list infers the first live tab', () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
const result = b.tabList()
expect(result.tabs).toMatchObject([
{ browserPageId: 'tab-a', active: true },
{ browserPageId: 'tab-b', active: false }
])
expect(b.getActiveWebContentsId()).toBeNull()
})
it('unregisters stale tab-list entries when their WebContents is gone', () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 2 ? wc2 : null))
const unregisterGuest = vi.fn()
const b = new AgentBrowserBridge(mockBrowserManager(tabs, new Map(), { unregisterGuest }))
expect(b.tabList().tabs).toMatchObject([{ browserPageId: 'tab-b', active: true }])
expect(unregisterGuest).toHaveBeenCalledWith('tab-a')
})
})
// ── Tab switch ──
it('throws on out-of-range tab index', async () => {
await expect(bridge.tabSwitch(99)).rejects.toThrow('Tab index 99 out of range')
})
// ── No tab error ──
it('throws browser_no_tab when no tabs registered', async () => {
const b = new AgentBrowserBridge(mockBrowserManager(new Map()))
await expect(b.snapshot()).rejects.toThrow('No browser tab open')
})
it('uses the runtime mobile tap path when a nearby DOM target is handled', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation(async (method: string) => {
if (method === 'Runtime.evaluate') {
return { result: { value: { x: 12, y: 34, adjusted: true, handled: true } } }
}
return {}
})
webContentsFromIdMock.mockReturnValue(wc)
const result = await bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18)
expect(result).toEqual({
clicked: { x: 12, y: 34, button: 'left', adjusted: true, handled: true }
})
expect(wc.debugger.sendCommand).toHaveBeenCalledWith(
'Runtime.evaluate',
expect.objectContaining({ returnByValue: true, silent: true })
)
expect(
wc.debugger.sendCommand.mock.calls.some((call) => call[0] === 'Input.dispatchMouseEvent')
).toBe(false)
})
it('falls back to CDP mouse events when runtime does not handle a mobile tap', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation(async (method: string) => {
if (method === 'Runtime.evaluate') {
return { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } }
}
return {}
})
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18)).resolves.toEqual({
clicked: { x: 10, y: 20, button: 'left', adjusted: false, handled: false }
})
const mouseCalls = wc.debugger.sendCommand.mock.calls.filter(
(call) => call[0] === 'Input.dispatchMouseEvent'
)
expect(mouseCalls).toHaveLength(2)
expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', x: 10, y: 20 })
expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 10, y: 20 })
})
it('passes mobile click modifiers through to CDP mouse events', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation(async (method: string) => {
if (method === 'Runtime.evaluate') {
return { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } }
}
return {}
})
webContentsFromIdMock.mockReturnValue(wc)
await bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18, ['cmd', 'shift'])
const mouseCalls = wc.debugger.sendCommand.mock.calls.filter(
(call) => call[0] === 'Input.dispatchMouseEvent'
)
expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', modifiers: 12 })
expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', modifiers: 12 })
})
it('keeps adjusted mobile tap coordinates but uses CDP for modifier clicks', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation(async (method: string) => {
if (method === 'Runtime.evaluate') {
return { result: { value: { x: 12, y: 34, adjusted: true, handled: false } } }
}
return {}
})
webContentsFromIdMock.mockReturnValue(wc)
await expect(
bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18, ['cmd'])
).resolves.toEqual({
clicked: { x: 12, y: 34, button: 'left', adjusted: true, handled: false }
})
const evaluateCall = wc.debugger.sendCommand.mock.calls.find(
(call) => call[0] === 'Runtime.evaluate'
)
expect((evaluateCall?.[1] as { expression?: string } | undefined)?.expression).toContain(
'const allowDomActivation = false'
)
const mouseCalls = wc.debugger.sendCommand.mock.calls.filter(
(call) => call[0] === 'Input.dispatchMouseEvent'
)
expect(mouseCalls).toHaveLength(2)
expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', x: 12, y: 34, modifiers: 4 })
expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 12, y: 34, modifiers: 4 })
})
it('drops empty command queues after direct CDP commands finish', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockResolvedValue({})
webContentsFromIdMock.mockReturnValue(wc)
await bridge.mouseClick(10, 20, 'right', undefined, 'tab-1')
expect(
(bridge as unknown as { commandQueues: Map<string, unknown[]> }).commandQueues.size
).toBe(0)
expect((bridge as unknown as { processingQueues: Set<string> }).processingQueues.size).toBe(0)
})
// ── Command queue serialization ──
it('serializes concurrent commands per session', async () => {
const commandCalls: string[][] = []
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
commandCalls.push(args)
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
const [r1, r2] = await Promise.all([bridge.snapshot(), bridge.click('@e1')])
expect(r1).toEqual({ browserPageId: 'tab-1', ok: true })
expect(r2).toEqual({ ok: true })
// Why: close runs first (stale session cleanup), then commands execute sequentially
const snapshotIdx = commandCalls.findIndex((a) => a.includes('snapshot'))
const clickIdx = commandCalls.findIndex((a) => a.includes('click'))
expect(snapshotIdx).toBeLessThan(clickIdx)
})
it('acquires an automation visibility lease while running snapshot commands', async () => {
const lifecycleEvents: string[] = []
const restore = vi.fn(() => {
lifecycleEvents.push('restore-100')
})
const acquireAutomationVisibility = vi.fn(async (webContentsId: number) => {
lifecycleEvents.push(`acquire-${webContentsId}`)
return restore
})
const b = new AgentBrowserBridge(
mockBrowserManager(undefined, undefined, {
acquireAutomationVisibility
})
)
b.setActiveTab(100)
let releaseSnapshot: (() => void) | null = null
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
if (args.includes('snapshot')) {
lifecycleEvents.push('command-snapshot')
releaseSnapshot = () => {
cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '')
}
return
}
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
const snapshot = b.snapshot()
await vi.waitFor(() => {
expect(releaseSnapshot).not.toBeNull()
})
expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot'])
expect(restore).not.toHaveBeenCalled()
releaseSnapshot!()
await expect(snapshot).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'tree' })
expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot', 'restore-100'])
})
it('re-resolves the page after automation visibility re-registers the webview', async () => {
const tabs = new Map([['tab-1', 100]])
const wc100 = mockWebContents(100)
const wc200 = mockWebContents(200, 'https://example.com/reloaded', 'Reloaded')
webContentsFromIdMock.mockImplementation((id: number) => {
if (id === 100) {
return wc100
}
if (id === 200) {
return wc200
}
return null
})
const acquireAutomationVisibility = vi.fn(async () => {
tabs.set('tab-1', 200)
return vi.fn()
})
const b = new AgentBrowserBridge(
mockBrowserManager(tabs, undefined, {
acquireAutomationVisibility
})
)
b.setActiveTab(100)
succeedWith({ snapshot: 'tree' })
await expect(b.snapshot()).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'tree' })
expect(acquireAutomationVisibility).toHaveBeenCalledWith(100)
const createdProxyIds = CdpWsProxyMock.instances.map(
(instance) => (instance as { _wc?: { id?: number } })._wc?.id
)
expect(createdProxyIds).toEqual([100, 200])
})
it('preserves intercept routes when automation visibility re-registers the webview', async () => {
const tabs = new Map([['tab-1', 100]])
const wc100 = mockWebContents(100)
const wc200 = mockWebContents(200, 'https://example.com/reloaded', 'Reloaded')
webContentsFromIdMock.mockImplementation((id: number) => {
if (id === 100) {
return wc100
}
if (id === 200) {
return wc200
}
return null
})
let reregisterOnVisibility = false
const acquireAutomationVisibility = vi.fn(async () => {
if (reregisterOnVisibility) {
tabs.set('tab-1', 200)
}
return vi.fn()
})
const b = new AgentBrowserBridge(
mockBrowserManager(tabs, undefined, {
acquireAutomationVisibility
})
)
b.setActiveTab(100)
const commandCalls: string[][] = []
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
commandCalls.push(args)
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
await b.interceptEnable(['https://old.example/**'])
reregisterOnVisibility = true
await expect(b.snapshot()).resolves.toEqual({ browserPageId: 'tab-1', ok: true })
const routeCalls = commandCalls.filter(
(args) => args.includes('network') && args.includes('route')
)
expect(routeCalls).toHaveLength(2)
expect(routeCalls.at(-1)).toContain('https://old.example/**')
})
it('clears stale sessions after direct CDP visibility re-registration', async () => {
const tabs = new Map([['tab-1', 100]])
const wc100 = mockWebContents(100)
const wc200 = mockWebContents(200, 'https://example.com/reloaded', 'Reloaded')
wc200.debugger.sendCommand.mockResolvedValue({})
webContentsFromIdMock.mockImplementation((id: number) => {
if (id === 100) {
return wc100
}
if (id === 200) {
return wc200
}
return null
})
let reregisterOnVisibility = false
const acquireAutomationVisibility = vi.fn(async () => {
if (reregisterOnVisibility) {
tabs.set('tab-1', 200)
}
return vi.fn()
})
const b = new AgentBrowserBridge(
mockBrowserManager(tabs, undefined, {
acquireAutomationVisibility
})
)
b.setActiveTab(100)
succeedWith({ snapshot: 'before' })
await b.snapshot()
reregisterOnVisibility = true
await expect(b.mouseClick(10, 20, 'right', undefined, 'tab-1')).resolves.toEqual({
clicked: { x: 10, y: 20, button: 'right', adjusted: false, handled: false }
})
succeedWith({ snapshot: 'after' })
await expect(b.snapshot()).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'after' })
const createdProxyIds = CdpWsProxyMock.instances.map(
(instance) => (instance as { _wc?: { id?: number } })._wc?.id
)
expect(createdProxyIds).toEqual([100, 200])
})
it('clears reload fallback timer after the load event settles', async () => {
vi.useFakeTimers()
try {
succeedWith(null)
const wc = {
...mockWebContents(100, 'https://reloaded.example', 'Reloaded'),
reload: vi.fn(),
on: vi.fn(),
removeListener: vi.fn()
}
webContentsFromIdMock.mockReturnValue(wc)
const result = bridge.reload()
await vi.waitFor(() => {
expect(wc.on).toHaveBeenCalledWith('did-finish-load', expect.any(Function))
})
const finishListener = wc.on.mock.calls.find(
([event]) => event === 'did-finish-load'
)?.[1] as (() => void) | undefined
const failListener = wc.on.mock.calls.find(([event]) => event === 'did-fail-load')?.[1] as
| (() => void)
| undefined
expect(finishListener).toBeDefined()
expect(failListener).toBeDefined()
expect(vi.getTimerCount()).toBe(1)
finishListener!()
await expect(result).resolves.toEqual({
url: 'https://reloaded.example',
title: 'Reloaded'
})
expect(wc.removeListener).toHaveBeenCalledWith('did-finish-load', finishListener)
expect(wc.removeListener).toHaveBeenCalledWith('did-fail-load', failListener)
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(10_000)
expect(wc.removeListener).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('serializes screenshot visibility prep across sessions', async () => {
vi.useFakeTimers()
try {
const tabs = new Map([
['tab-1', 1],
['tab-2', 2]
])
const worktrees = new Map([
['tab-1', 'wt-1'],
['tab-2', 'wt-2']
])
const lifecycleEvents: string[] = []
const acquireAutomationVisibilityMock = vi.fn(async (webContentsId: number) => {
lifecycleEvents.push(`acquire-${webContentsId}`)
return () => {
lifecycleEvents.push(`restore-${webContentsId}`)
}
})
const wc1 = mockWebContents(1)
const wc2 = mockWebContents(2)
webContentsFromIdMock.mockImplementation((id: number) =>
id === 1 ? wc1 : id === 2 ? wc2 : null
)
existsSyncMock.mockReturnValue(true)
const screenshotBytes = Buffer.from('serialized-screenshot')
readFileSyncMock.mockReturnValue(screenshotBytes)
const b = new AgentBrowserBridge(
mockBrowserManager(tabs, worktrees, {
acquireAutomationVisibility: acquireAutomationVisibilityMock
})
)
b.setActiveTab(1, 'wt-1')
b.setActiveTab(2, 'wt-2')
let releaseFirstScreenshot: (() => void) | null = null
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
if (args.includes('screenshot')) {
const sessionName = args[args.indexOf('--session') + 1]
lifecycleEvents.push(`command-${sessionName}`)
if (sessionName === 'orca-tab-tab-1' && !releaseFirstScreenshot) {
releaseFirstScreenshot = () => {
cb(null, JSON.stringify({ success: true, data: { path: '/tmp/tab-1.png' } }), '')
}
return
}
cb(
null,
JSON.stringify({ success: true, data: { path: `/tmp/${sessionName}.png` } }),
''
)
return
}
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
const first = b.screenshot('png', 'wt-1')
const second = b.screenshot('png', 'wt-2')
await Promise.resolve()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(300)
expect(lifecycleEvents).toContain('acquire-1')
expect(lifecycleEvents).toContain('command-orca-tab-tab-1')
expect(lifecycleEvents).not.toContain('acquire-2')
expect(releaseFirstScreenshot).not.toBeNull()
releaseFirstScreenshot!()
await expect(first).resolves.toEqual({
data: screenshotBytes.toString('base64'),
format: 'png'
})
await Promise.resolve()
await Promise.resolve()
expect(lifecycleEvents.indexOf('restore-1')).toBeLessThan(
lifecycleEvents.indexOf('acquire-2')
)
await vi.advanceTimersByTimeAsync(300)
await expect(second).resolves.toEqual({
data: screenshotBytes.toString('base64'),
format: 'png'
})
} finally {
vi.useRealTimers()
}
})
it('captures full-page screenshots directly through CDP using CSS layout bounds', async () => {
vi.useFakeTimers()
try {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation((method: string) => {
if (method === 'Page.getLayoutMetrics') {
return Promise.resolve({
cssContentSize: { width: 600.2, height: 900.4 },
contentSize: { width: 1200.4, height: 1800.8 }
})
}
if (method === 'Page.captureScreenshot') {
return Promise.resolve({ data: 'full-cdp-shot' })
}
return Promise.resolve({})
})
webContentsFromIdMock.mockReturnValue(wc)
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, JSON.stringify({ success: true, data: null }), '')
}
)
const screenshotPromise = bridge.fullPageScreenshot('png')
await vi.advanceTimersByTimeAsync(500)
await expect(screenshotPromise).resolves.toEqual({
data: 'full-cdp-shot',
format: 'png'
})
expect(wc.debugger.sendCommand).toHaveBeenNthCalledWith(1, 'Page.getLayoutMetrics', {})
expect(wc.debugger.sendCommand).toHaveBeenNthCalledWith(2, 'Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: true,
clip: { x: 0, y: 0, width: 601, height: 901, scale: 1 }
})
const screenshotCall = execFileMock.mock.calls.find((call: unknown[]) =>
(call[1] as string[]).includes('screenshot')
)
expect(screenshotCall).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
// ── Timeout escalation ──
it('destroys session after 3 consecutive timeouts', async () => {
const killedError = Object.assign(new Error('timeout'), { killed: true })
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(killedError, '', '')
}
)
for (let i = 0; i < 3; i++) {
await expect(bridge.snapshot()).rejects.toThrow('timed out')
}
// Session is destroyed — next command should re-create it (new --cdp flag)
succeedWith({ snapshot: 'fresh' })
await bridge.snapshot()
const lastArgs = execFileMock.mock.calls.at(-1)![1] as string[]
expect(lastArgs).toContain('--cdp')
})
it('waits for pending session destruction before recreating the same session', async () => {
succeedWith({ snapshot: 'initial' })
await bridge.snapshot()
execFileMock.mockClear()
const commandCalls: string[][] = []
let releaseDestroyClose: (() => void) | null = null
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
commandCalls.push(args)
if (args.includes('close')) {
if (!releaseDestroyClose) {
releaseDestroyClose = () => {
cb(null, JSON.stringify({ success: true, data: null }), '')
}
return
}
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
if (args.includes('snapshot')) {
cb(null, JSON.stringify({ success: true, data: { snapshot: 'after-destroy' } }), '')
return
}
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
const destroyPromise = (
bridge as unknown as { destroySession: (name: string) => Promise<void> }
).destroySession('orca-tab-tab-1')
const nextSnapshot = bridge.snapshot()
await Promise.resolve()
await Promise.resolve()
expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(1)
expect(commandCalls.some((args) => args.includes('snapshot'))).toBe(false)
expect(releaseDestroyClose).not.toBeNull()
releaseDestroyClose!()
await destroyPromise
await expect(nextSnapshot).resolves.toEqual({
browserPageId: 'tab-1',
snapshot: 'after-destroy'
})
expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(2)
})
it('tears down a session that finishes creating after destruction starts', async () => {
const commandCalls: string[][] = []
let releaseStaleClose: (() => void) | null = null
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
commandCalls.push(args)
if (args.includes('close') && !releaseStaleClose) {
releaseStaleClose = () => {
cb(null, JSON.stringify({ success: true, data: null }), '')
}
return { kill: vi.fn() }
}
cb(null, JSON.stringify({ success: true, data: null }), '')
return { kill: vi.fn() }
}
)
const ensurePromise = (
bridge as unknown as {
ensureSession: (
sessionName: string,
browserPageId: string,
webContentsId: number
) => Promise<void>
}
).ensureSession('orca-tab-tab-1', 'tab-1', 100)
await vi.waitFor(() => {
expect(releaseStaleClose).not.toBeNull()
})
expect(CdpWsProxyMock.instances).toHaveLength(0)
const destroyPromise = (
bridge as unknown as { destroySession: (name: string) => Promise<void> }
).destroySession('orca-tab-tab-1')
releaseStaleClose!()
await ensurePromise
await destroyPromise
const sessions = (bridge as unknown as { sessions: Map<string, unknown> }).sessions
const proxy = CdpWsProxyMock.instances[0] as { stop: ReturnType<typeof vi.fn> }
expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(2)
expect(sessions.size).toBe(0)
expect(proxy.stop).toHaveBeenCalledTimes(1)
})
it('cancels the command already running when a session is destroyed', async () => {
succeedWith({ snapshot: 'initial' })
await bridge.snapshot()
execFileMock.mockClear()
const killedError = Object.assign(new Error('killed'), { killed: true })
let resolveRunningCommand: (() => void) | null = null
const activeChild = {
kill: vi.fn(() => {
resolveRunningCommand?.()
})
}
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('snapshot')) {
resolveRunningCommand = () => cb(killedError, '', '')
return activeChild
}
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return { kill: vi.fn() }
}
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
return { kill: vi.fn() }
}
)
const runningSnapshot = bridge.snapshot()
await vi.waitFor(() => {
expect(resolveRunningCommand).not.toBeNull()
})
const destroyPromise = (
bridge as unknown as { destroySession: (name: string) => Promise<void> }
).destroySession('orca-tab-tab-1')
expect(activeChild.kill).toHaveBeenCalledTimes(1)
await expect(runningSnapshot).rejects.toMatchObject({
code: 'browser_tab_closed',
message: 'Tab was closed while command was running'
})
await destroyPromise
})
// ── Process swap ──
it('destroys session on process swap and re-inits with --cdp', async () => {
const tabs = new Map([['tab-1', 100]])
const mgr = mockBrowserManager(tabs)
const b = new AgentBrowserBridge(mgr)
b.setActiveTab(100)
succeedWith({ snapshot: 'tree' })
await b.snapshot()
// Why: calls[0] is the stale-session 'close'; find the snapshot call with --cdp
const firstSnapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect(firstSnapshotCall![1]).toContain('--cdp')
// Simulate process swap: update tab mapping + notify bridge
tabs.set('tab-1', 200)
const newWc = mockWebContents(200)
webContentsFromIdMock.mockReturnValue(newWc)
succeedWith(null) // for the 'close' command in destroySession
await b.onProcessSwap('tab-1', 200)
// Next command should re-init with --cdp since session was destroyed
succeedWith({ snapshot: 'new tree' })
await b.snapshot()
const snapshotCalls = execFileMock.mock.calls.filter((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect(snapshotCalls.length).toBeGreaterThanOrEqual(2)
const lastSnapshotArgs = snapshotCalls.at(-1)![1] as string[]
// After process swap + session destroy, the new session must re-init with --cdp
expect(lastSnapshotArgs).toContain('--cdp')
})
it('does not replay stale intercept routes after process swap when the first command disables routing', async () => {
const tabs = new Map([['tab-1', 100]])
const mgr = mockBrowserManager(tabs)
const b = new AgentBrowserBridge(mgr)
b.setActiveTab(100)
succeedWith({ ok: true })
await b.interceptEnable(['https://old.example/**'])
tabs.set('tab-1', 200)
webContentsFromIdMock.mockReturnValue(mockWebContents(200))
succeedWith(null)
await b.onProcessSwap('tab-1', 200)
const commandCalls: string[][] = []
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
commandCalls.push(args)
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
await b.interceptDisable()
const routeCalls = commandCalls.filter(
(args) => args.includes('network') && args.includes('route')
)
expect(routeCalls).toHaveLength(0)
const unrouteCall = commandCalls.find(
(args) => args.includes('network') && args.includes('unroute')
)
expect(unrouteCall).toBeDefined()
expect(unrouteCall).toContain('--cdp')
})
it('does not replay stale intercept routes after process swap when the first command enables a new route', async () => {
const tabs = new Map([['tab-1', 100]])
const mgr = mockBrowserManager(tabs)
const b = new AgentBrowserBridge(mgr)
b.setActiveTab(100)
succeedWith({ ok: true })
await b.interceptEnable(['https://old.example/**'])
tabs.set('tab-1', 200)
webContentsFromIdMock.mockReturnValue(mockWebContents(200))
succeedWith(null)
await b.onProcessSwap('tab-1', 200)
const commandCalls: string[][] = []
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
commandCalls.push(args)
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
await b.interceptEnable(['https://new.example/**'])
const routeCalls = commandCalls.filter(
(args) => args.includes('network') && args.includes('route')
)
expect(routeCalls).toHaveLength(1)
expect(routeCalls[0]).toContain('https://new.example/**')
expect(routeCalls[0]).not.toContain('https://old.example/**')
expect(routeCalls[0]).toContain('--cdp')
})
it('clears pending intercept restore state when a swapped tab closes before reuse', async () => {
const tabs = new Map([['tab-1', 100]])
const mgr = mockBrowserManager(tabs)
const b = new AgentBrowserBridge(mgr)
b.setActiveTab(100)
succeedWith({ ok: true })
await b.interceptEnable(['https://old.example/**'])
tabs.set('tab-1', 200)
webContentsFromIdMock.mockReturnValue(mockWebContents(200))
succeedWith(null)
await b.onProcessSwap('tab-1', 200)
expect(
(b as unknown as { pendingInterceptRestore: Map<string, string[]> }).pendingInterceptRestore
.size
).toBe(1)
await b.onTabClosed(200)
expect(
(b as unknown as { pendingInterceptRestore: Map<string, string[]> }).pendingInterceptRestore
.size
).toBe(0)
})
// ── Tab close clears active ──
it('clears activeWebContentsId on tab close', async () => {
succeedWith({ snapshot: 'tree' })
await bridge.snapshot()
await bridge.onTabClosed(100)
expect(bridge.getActiveWebContentsId()).toBeNull()
})
it('closes the named agent-browser session when a tab closes', async () => {
succeedWith({ snapshot: 'tree' })
await bridge.snapshot()
execFileMock.mockClear()
succeedWith(null)
await bridge.onTabClosed(100)
const closeCall = execFileMock.mock.calls.find((call: unknown[]) =>
(call[1] as string[]).includes('close')
)
expect(closeCall).toBeTruthy()
expect(closeCall![1]).toEqual(['--session', 'orca-tab-tab-1', 'close'])
})
it('repairs per-worktree active routing when the active tab closes', async () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-1']
])
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 2 ? wc2 : null))
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
b.setActiveTab(1, 'wt-1')
await b.onTabClosed(1)
expect(b.getActiveWebContentsId()).toBe(2)
expect(b.tabList('wt-1').tabs).toMatchObject([{ browserPageId: 'tab-b', active: true }])
})
it('repairs per-worktree active routing when an active tab swaps processes', async () => {
const tabs = new Map([['tab-a', 200]])
const worktrees = new Map([['tab-a', 'wt-1']])
const wc = mockWebContents(200, 'https://a.com', 'A')
webContentsFromIdMock.mockImplementation((id: number) => (id === 200 ? wc : null))
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
b.setActiveTab(100, 'wt-1')
await b.onProcessSwap('tab-a', 200, 100)
expect(b.getActiveWebContentsId()).toBe(200)
expect(b.tabList('wt-1').tabs).toMatchObject([{ browserPageId: 'tab-a', active: true }])
})
// ── tabSwitch success ──
it('switches active tab and returns switched index', async () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const wc1 = mockWebContents(1)
const wc2 = mockWebContents(2)
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
b.setActiveTab(1)
const result = await b.tabSwitch(1)
expect(result).toEqual({ switched: 1, browserPageId: 'tab-b' })
expect(b.getActiveWebContentsId()).toBe(2)
})
it('switches tabs by explicit browser page id', async () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const wc1 = mockWebContents(1)
const wc2 = mockWebContents(2)
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
b.setActiveTab(1)
const result = await b.tabSwitch(undefined, undefined, 'tab-b')
expect(result).toEqual({ switched: 1, browserPageId: 'tab-b' })
expect(b.getActiveWebContentsId()).toBe(2)
})
it('updates the owning worktree active tab when switching by browser page id', async () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-1']
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
b.setActiveTab(2, 'wt-1')
await expect(b.tabSwitch(undefined, undefined, 'tab-a')).resolves.toEqual({
switched: 0,
browserPageId: 'tab-a'
})
expect(b.tabList('wt-1').tabs).toMatchObject([
{ browserPageId: 'tab-a', active: true },
{ browserPageId: 'tab-b', active: false }
])
})
it('queues tabSwitch behind in-flight commands on the current session', async () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-1']
])
const wc1 = mockWebContents(1)
const wc2 = mockWebContents(2)
webContentsFromIdMock.mockImplementation((id: number) =>
id === 1 ? wc1 : id === 2 ? wc2 : null
)
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
b.setActiveTab(1, 'wt-1')
let releaseSnapshot: (() => void) | null = null
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
if (args.includes('snapshot')) {
releaseSnapshot = () => {
cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '')
}
return
}
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
const snapshot = b.snapshot('wt-1')
const switched = b.tabSwitch(1, 'wt-1')
await Promise.resolve()
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(b.getActiveWebContentsId()).toBe(1)
expect(releaseSnapshot).not.toBeNull()
releaseSnapshot!()
await expect(snapshot).resolves.toEqual({ browserPageId: 'tab-a', snapshot: 'tree' })
await expect(switched).resolves.toEqual({ switched: 1, browserPageId: 'tab-b' })
expect(b.getActiveWebContentsId()).toBe(2)
})
// ── goto command ──
it('passes url to goto command', async () => {
succeedWith({ url: 'https://example.com', title: 'Example' })
await bridge.goto('https://example.com')
const args = execFileMock.mock.calls.at(-1)![1] as string[]
expect(args).toContain('goto')
expect(args).toContain('https://example.com')
})
it('rejects oversized browser clipboard writes before spawning agent-browser', async () => {
const secret = 'browser-clipboard-secret'
succeedWith({ ok: true })
await expect(
bridge.clipboardWrite(secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1))
).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)
expect(execFileMock).not.toHaveBeenCalled()
})
it('rejects browser clipboard writes that exceed the safe agent-browser argument size', async () => {
succeedWith({ ok: true })
await expect(
bridge.clipboardWrite('x'.repeat(AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + 1))
).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)
expect(execFileMock).not.toHaveBeenCalled()
})
it('builds valid fill eval JavaScript for multiline values', async () => {
succeedWith({ ok: true })
await bridge.fill('@textarea', "line one\nline two with 'quote' and \\ slash")
const evalCall = execFileMock.mock.calls.find((call: unknown[]) =>
(call[1] as string[]).includes('eval')
)
expect(evalCall).toBeDefined()
const args = evalCall![1] as string[]
const expression = args[args.indexOf('eval') + 1]
expect(() => new Function(expression)).not.toThrow()
})
it('replaces contenteditable text through the browser editing pipeline', async () => {
succeedForContentEditable()
const environment = createContentEditableEvalEnvironment('existing text')
await bridge.fill('@editor', 'replacement text')
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
expect(environment.editor.textContent).toBe('replacement text')
expect(environment.execCommand).toHaveBeenCalledWith('insertText', false, 'replacement text')
expect(environment.editor.dispatchEvent).not.toHaveBeenCalled()
})
it('clears selected contenteditable text with a browser delete command', async () => {
succeedForContentEditable()
const environment = createContentEditableEvalEnvironment('existing text')
const result = await bridge.clear('@editor')
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
expect(environment.editor.textContent).toBe('')
expect(environment.execCommand).toHaveBeenCalledWith('delete', false, '')
expect(environment.editor.dispatchEvent).not.toHaveBeenCalled()
expect(result).toEqual({ cleared: '@editor' })
})
it('fails contenteditable fill when the browser editing command is unavailable', async () => {
succeedForContentEditable()
const environment = createContentEditableEvalEnvironment('existing text')
environment.execCommand.mockReturnValue(false)
await bridge.fill('@editor', 'replacement text')
expect(() =>
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
).toThrow('Browser rich-text editing command failed')
expect(environment.editor.textContent).toBe('existing text')
expect(environment.editor.dispatchEvent).not.toHaveBeenCalled()
})
it('inserts paste-sized contenteditable text as one stdin editing transaction', async () => {
const firstChunk = 'x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES)
succeedForContentEditable()
const environment = createContentEditableEvalEnvironment('existing text')
await bridge.fill('@editor', `${firstChunk}tail`)
const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) =>
(call[1] as string[]).includes('eval')
)
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
expect(evalCalls).toHaveLength(1)
expect(evalCalls[0][1]).toContain('--stdin')
expect(stdinWrites).toHaveLength(1)
expect(environment.editor.textContent).toBe(`${firstChunk}tail`)
})
it('uses target-aware agent-browser fill when clearing a non-rich target', async () => {
succeedWith({ filled: '@disabled' })
const result = await bridge.clear('@disabled')
const commandArgs = execFileMock.mock.calls.map((call: unknown[]) => call[1] as string[])
expect(commandArgs.some((args) => args.includes('eval'))).toBe(false)
expect(commandArgs.some((args) => args.includes('fill') && args.includes('@disabled'))).toBe(
true
)
expect(result).toEqual({ cleared: '@disabled' })
})
it('routes focused spinbutton wrappers to editable descendants before filling', async () => {
succeedWith({ ok: true })
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const input = createFillEvalNode({ tagName: 'INPUT' })
const wrapper = createFillEvalNode({
tagName: 'DIV',
role: 'spinbutton',
descendant: input.node
})
runFillEvalExpressions(expressions, {
activeElement: wrapper.node,
getElementById: () => null
})
expect(input.value).toBe('200')
expect(wrapper.value).toBe('')
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
expect(wrapper.events).toHaveLength(0)
})
it('routes aria-controlled spinbutton wrappers to editable inputs before filling', async () => {
succeedWith({ ok: true })
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const input = createFillEvalNode({ tagName: 'INPUT' })
const wrapper = createFillEvalNode({
tagName: 'DIV',
role: 'spinbutton',
ariaControls: 'target-id'
})
runFillEvalExpressions(expressions, {
activeElement: wrapper.node,
getElementById: (id: string) => (id === 'target-id' ? input.node : null)
})
expect(input.value).toBe('200')
expect(wrapper.value).toBe('')
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
expect(wrapper.events).toHaveLength(0)
})
it('routes aria-controlled spinbutton containers to editable descendants before filling', async () => {
succeedWith({ ok: true })
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const input = createFillEvalNode({ tagName: 'INPUT' })
const controlled = createFillEvalNode({ tagName: 'DIV', descendant: input.node })
const wrapper = createFillEvalNode({
tagName: 'DIV',
role: 'spinbutton',
ariaControls: 'target-id'
})
runFillEvalExpressions(expressions, {
activeElement: wrapper.node,
getElementById: (id: string) => (id === 'target-id' ? controlled.node : null)
})
expect(input.value).toBe('200')
expect(controlled.value).toBe('')
expect(wrapper.value).toBe('')
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
expect(controlled.events).toHaveLength(0)
expect(wrapper.events).toHaveLength(0)
})
it('skips non-text spinbutton descendant inputs before filling', async () => {
succeedWith({ ok: true })
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const hiddenInput = createFillEvalNode({ tagName: 'INPUT', type: 'hidden' })
const numberInput = createFillEvalNode({ tagName: 'INPUT', type: 'number' })
const wrapper = createFillEvalNode({
tagName: 'DIV',
role: 'spinbutton',
descendants: [hiddenInput.node, numberInput.node]
})
runFillEvalExpressions(expressions, {
activeElement: wrapper.node,
getElementById: () => null
})
expect(numberInput.value).toBe('200')
expect(hiddenInput.value).toBe('')
expect(wrapper.value).toBe('')
expect(numberInput.events.map((event) => event.type)).toEqual(['input', 'change'])
expect(hiddenInput.events).toHaveLength(0)
expect(wrapper.events).toHaveLength(0)
})
it('keeps plain focused inputs as fill targets', async () => {
succeedWith({ ok: true })
await bridge.fill('@input', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const input = createFillEvalNode({ tagName: 'INPUT' })
runFillEvalExpressions(expressions, {
activeElement: input.node,
getElementById: () => null
})
expect(input.value).toBe('200')
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
})
it('chunks large agent-browser fill values before eval transport', async () => {
const text = ['x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'tail'].join('')
succeedWith({ ok: true })
await bridge.fill('@textarea', text)
const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) =>
(call[1] as string[]).includes('eval')
)
const appendExpressions = evalCalls.slice(1, -1).map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
expect(appendExpressions).toHaveLength(2)
expect(appendExpressions.some((expression) => expression.includes(text))).toBe(false)
expect(appendExpressions[0]).toContain('x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES))
expect(appendExpressions[1]).toContain('tail')
})
it.each([
['fill', (b: AgentBrowserBridge, text: string) => b.fill('@textarea', text)],
['type', (b: AgentBrowserBridge, text: string) => b.type(text)],
['keyboard insert', (b: AgentBrowserBridge, text: string) => b.keyboardInsertText(text)]
])('yields before spawning agent-browser for accepted large %s text', async (_name, run) => {
vi.useFakeTimers()
try {
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
succeedWith({ ok: true })
const pending = run(bridge, text)
await Promise.resolve()
expect(execFileMock).not.toHaveBeenCalled()
await vi.runOnlyPendingTimersAsync()
await pending
expect(execFileMock).toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('chunks large agent-browser type text before keyboard transport', async () => {
const text = ['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz'].join('')
succeedWith({ typed: true })
await bridge.type(text)
const typeCalls = execFileMock.mock.calls.filter((call: unknown[]) => {
const args = call[1] as string[]
return args.includes('keyboard') && args.includes('type')
})
const chunks = typeCalls.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('type') + 1]
})
expect(chunks).toEqual(['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz'])
})
it('chunks large agent-browser keyboard insert text before transport', async () => {
const text = ['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq'].join('')
succeedWith({ inserted: true })
await bridge.keyboardInsertText(text)
const insertTextCalls = execFileMock.mock.calls.filter((call: unknown[]) => {
const args = call[1] as string[]
return args.includes('keyboard') && args.includes('inserttext')
})
const chunks = insertTextCalls.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('inserttext') + 1]
})
expect(chunks).toEqual(['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq'])
})
// ── Cross-worktree text-injection guard ──
describe('scoped target for text-mutating commands', () => {
function twoWorktreeBridge(): AgentBrowserBridge {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-2']
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
return new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
}
function sessionNamesUsed(): string[] {
return execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('--session'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('--session') + 1]
})
}
it.each([
['inserttext', (b: AgentBrowserBridge) => b.keyboardInsertText('x', undefined, undefined)],
['type', (b: AgentBrowserBridge) => b.type('x', undefined, undefined)],
['fill', (b: AgentBrowserBridge) => b.fill('@input', 'x', undefined, undefined)]
])(
'refuses %s when worktrees are ambiguous instead of routing to the global active tab',
async (_name, run) => {
const b = twoWorktreeBridge()
// Why: simulates the user viewing worktree B's tab, which sets the global
// active webContents — the bug would route the agent's text there.
b.onTabChanged(2, 'wt-2')
succeedWith({ inserted: true })
await expect(run(b)).rejects.toMatchObject({
code: 'browser_target_ambiguous'
})
// Must not have dispatched the command to worktree B's session.
expect(sessionNamesUsed()).not.toContain('orca-tab-tab-b')
}
)
it('auto-scopes inserttext to the lone worktree that has a live tab', async () => {
const tabs = new Map([['tab-a', 1]])
const worktrees = new Map([['tab-a', 'wt-1']])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
webContentsFromIdMock.mockReturnValue(wc1)
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
succeedWith({ inserted: true })
await b.keyboardInsertText('x', undefined, undefined)
expect(sessionNamesUsed()).toContain('orca-tab-tab-a')
})
it('throws browser_no_tab for inserttext when no live tab exists', async () => {
const b = new AgentBrowserBridge(mockBrowserManager(new Map()))
await expect(b.keyboardInsertText('x', undefined, undefined)).rejects.toMatchObject({
code: 'browser_no_tab'
})
})
it('keeps read-only snapshot on the lenient global active-tab fallback', async () => {
const b = twoWorktreeBridge()
// Why: read/navigation commands intentionally keep the global fallback so
// discovery still works without a worktree; only text writes are guarded.
b.onTabChanged(2, 'wt-2')
succeedWith({ snapshot: 'tree' })
await b.snapshot(undefined, undefined)
expect(sessionNamesUsed()).toContain('orca-tab-tab-b')
})
})
// ── Cookie command arg building ──
it('builds cookie set args with all options', async () => {
succeedWith({ success: true })
await bridge.cookieSet({
name: 'sid',
value: 'abc',
domain: '.example.com',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Lax',
expires: 1700000000
})
const args = execFileMock.mock.calls.at(-1)![1] as string[]
expect(args).toContain('cookies')
expect(args).toContain('set')
expect(args).toContain('sid')
expect(args).toContain('abc')
expect(args).toContain('--domain')
expect(args).toContain('.example.com')
expect(args).toContain('--path')
expect(args).toContain('/')
expect(args).toContain('--secure')
expect(args).toContain('--httpOnly')
expect(args).toContain('--sameSite')
expect(args).toContain('Lax')
expect(args).toContain('--expires')
expect(args).toContain('1700000000')
})
// ── Viewport command arg building ──
it('applies viewport emulation through CDP so mobile mode is preserved', async () => {
const wc = mockWebContents(100)
webContentsFromIdMock.mockReturnValue(wc)
await bridge.setViewport(375, 812, 2, true)
expect(wc.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', {
width: 375,
height: 812,
deviceScaleFactor: 2,
mobile: true
})
expect(wc.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setVisibleSize', {
width: 375,
height: 812
})
const viewportCall = execFileMock.mock.calls.find((call: unknown[]) =>
(call[1] as string[]).includes('viewport')
)
expect(viewportCall).toBeUndefined()
})
it('normalizes selector wait state=visible to the default supported semantics', async () => {
succeedWith({ selector: 'h1', waited: 'selector' })
await bridge.wait({ selector: 'h1', state: 'visible' })
const args = execFileMock.mock.calls.at(-1)![1] as string[]
expect(args).toContain('wait')
expect(args).toContain('h1')
expect(args).not.toContain('--state')
})
it('enforces conditional wait timeouts at the bridge layer', async () => {
succeedWith({ selector: '#ready', waited: 'selector' })
await bridge.wait({ selector: '#ready', timeout: 1200 })
const args = execFileMock.mock.calls.at(-1)![1] as string[]
const options = execFileMock.mock.calls.at(-1)![2] as { timeout?: number; env?: unknown }
expect(args).toContain('wait')
expect(args).toContain('#ready')
expect(options.timeout).toBe(2200)
expect(options.env).toBe(process.env)
})
it('returns browser_timeout for timed conditional waits without recycling the session', async () => {
const killedError = Object.assign(new Error('timeout'), { killed: true })
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('wait')) {
cb(killedError, '', '')
return
}
cb(null, JSON.stringify({ success: true, data: { snapshot: 'fresh' } }), '')
}
)
for (let i = 0; i < 3; i++) {
await expect(bridge.wait({ selector: '.missing', timeout: 1200 })).rejects.toThrow(
'Timed out waiting for browser condition after 1200ms.'
)
}
await bridge.snapshot()
expect(CdpWsProxyMock.instances).toHaveLength(1)
})
// ── Stderr passthrough on non-timeout errors ──
it('passes stderr through as error message on execFile failure', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(new Error('exit code 1'), '', 'daemon crashed: segfault')
}
)
await expect(bridge.snapshot()).rejects.toThrow('daemon crashed: segfault')
})
it('falls back to error.message when stderr is empty', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(new Error('Command failed'), '', '')
}
)
await expect(bridge.snapshot()).rejects.toThrow('Command failed')
})
// ── Malformed JSON returns BrowserError ──
it('returns browser_error with truncated output for malformed JSON', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, 'Error: not json output', '')
}
)
await expect(bridge.snapshot()).rejects.toThrow('Unexpected output from agent-browser')
})
// ── destroyAllSessions ──
it('destroys all active sessions', async () => {
succeedWith({ snapshot: 'tree' })
await bridge.snapshot()
// Should have one session now
succeedWith(null) // for the 'close' call
await bridge.destroyAllSessions()
// Next command should re-create session with --cdp
succeedWith({ snapshot: 'fresh' })
await bridge.snapshot()
const snapshotCalls = execFileMock.mock.calls.filter((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
const lastSnapshotArgs = snapshotCalls.at(-1)![1] as string[]
expect(lastSnapshotArgs).toContain('--cdp')
})
})