feat: computer use via agent-browser CDP bridge (#856)

This commit is contained in:
Jinwoo Hong
2026-04-20 20:56:14 -07:00
committed by GitHub
parent 22ae50e0ae
commit 7a9bc4ef6d
35 changed files with 13560 additions and 43 deletions
+329 -1
View File
@@ -1,3 +1,5 @@
/* oxlint-disable max-lines -- Why: CLI parsing behavior is exercised end-to-end
in one file so command and flag interactions stay visible in a single suite. */
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -35,7 +37,24 @@ vi.mock('./runtime-client', () => {
}
})
import { buildCurrentWorktreeSelector, main, normalizeWorktreeSelector } from './index'
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
main,
normalizeWorktreeSelector
} from './index'
import { RuntimeClientError } from './runtime-client'
describe('COMMAND_SPECS collision check', () => {
it('has no duplicate command paths', () => {
const seen = new Set<string>()
for (const spec of COMMAND_SPECS) {
const key = spec.path.join(' ')
expect(seen.has(key), `Duplicate COMMAND_SPECS path: "${key}"`).toBe(false)
seen.add(key)
}
})
})
describe('orca cli worktree awareness', () => {
beforeEach(() => {
@@ -303,3 +322,312 @@ describe('orca cli worktree awareness', () => {
})
})
})
describe('orca cli browser page targeting', () => {
beforeEach(() => {
callMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('passes explicit page ids to snapshot without resolving the current worktree', async () => {
callMock.mockResolvedValueOnce({
id: 'req_snapshot',
ok: true,
result: {
browserPageId: 'page-1',
snapshot: 'tree',
refs: [],
url: 'https://example.com',
title: 'Example'
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['snapshot', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.snapshot', {
page: 'page-1'
})
})
it('resolves current worktree only when --page is combined with --worktree current', async () => {
callMock
.mockResolvedValueOnce({
id: 'req_list',
ok: true,
result: {
worktrees: [
{
id: 'repo::/tmp/repo/feature',
repoId: 'repo',
path: '/tmp/repo/feature',
branch: 'feature/foo',
linkedIssue: null,
git: {
path: '/tmp/repo/feature',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
}
],
totalCount: 1,
truncated: false
},
_meta: {
runtimeId: 'runtime-1'
}
})
.mockResolvedValueOnce({
id: 'req_snapshot',
ok: true,
result: {
browserPageId: 'page-1',
snapshot: 'tree',
refs: [],
url: 'https://example.com',
title: 'Example'
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['snapshot', '--page', 'page-1', '--worktree', 'current', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.snapshot', {
page: 'page-1',
worktree: `path:${path.resolve('/tmp/repo/feature')}`
})
})
it('passes page-targeted tab switches through without auto-scoping to the current worktree', async () => {
callMock.mockResolvedValueOnce({
id: 'req_switch',
ok: true,
result: {
switched: 2,
browserPageId: 'page-2'
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'switch', '--page', 'page-2', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabSwitch', {
index: undefined,
page: 'page-2'
})
})
it('still resolves the current worktree when tab switch --page is combined with --worktree current', async () => {
callMock
.mockResolvedValueOnce({
id: 'req_list',
ok: true,
result: {
worktrees: [
{
id: 'repo::/tmp/repo/feature',
repoId: 'repo',
path: '/tmp/repo/feature',
branch: 'feature/foo',
linkedIssue: null,
git: {
path: '/tmp/repo/feature',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
}
],
totalCount: 1,
truncated: false
},
_meta: {
runtimeId: 'runtime-1'
}
})
.mockResolvedValueOnce({
id: 'req_switch',
ok: true,
result: {
switched: 2,
browserPageId: 'page-2'
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['tab', 'switch', '--page', 'page-2', '--worktree', 'current', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.tabSwitch', {
index: undefined,
page: 'page-2',
worktree: `path:${path.resolve('/tmp/repo/feature')}`
})
})
})
describe('orca cli browser waits and viewport flags', () => {
beforeEach(() => {
callMock.mockReset()
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
})
it('gives selector waits an explicit RPC timeout budget', async () => {
callMock.mockResolvedValueOnce({
id: 'req_wait',
ok: true,
result: { ok: true },
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['wait', '--selector', '#ready', '--worktree', 'all', '--json'],
'/tmp/not-an-orca-worktree'
)
expect(callMock).toHaveBeenCalledWith(
'browser.wait',
{
selector: '#ready',
timeout: undefined,
text: undefined,
url: undefined,
load: undefined,
fn: undefined,
state: undefined,
worktree: undefined
},
{ timeoutMs: 60_000 }
)
})
it('extends selector wait RPC timeout when the user passes --timeout', async () => {
callMock.mockResolvedValueOnce({
id: 'req_wait',
ok: true,
result: { ok: true },
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['wait', '--selector', '#ready', '--timeout', '12000', '--worktree', 'all', '--json'],
'/tmp/not-an-orca-worktree'
)
expect(callMock).toHaveBeenCalledWith(
'browser.wait',
{
selector: '#ready',
timeout: 12000,
text: undefined,
url: undefined,
load: undefined,
fn: undefined,
state: undefined,
worktree: undefined
},
{ timeoutMs: 17000 }
)
})
it('does not tell users Orca is down for a generic runtime timeout', async () => {
callMock.mockRejectedValueOnce(
new RuntimeClientError(
'runtime_timeout',
'Timed out waiting for the Orca runtime to respond.'
)
)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['wait', '--selector', '#ready', '--worktree', 'all'], '/tmp/not-an-orca-worktree')
expect(errorSpy).toHaveBeenCalledWith('Timed out waiting for the Orca runtime to respond.')
})
it('passes the mobile viewport flag through to browser.viewport', async () => {
callMock.mockResolvedValueOnce({
id: 'req_viewport',
ok: true,
result: {
width: 375,
height: 812,
deviceScaleFactor: 2,
mobile: true
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'viewport',
'--width',
'375',
'--height',
'812',
'--scale',
'2',
'--mobile',
'--worktree',
'all',
'--json'
],
'/tmp/not-an-orca-worktree'
)
expect(callMock).toHaveBeenCalledWith('browser.viewport', {
width: 375,
height: 812,
deviceScaleFactor: 2,
mobile: true,
worktree: undefined
})
})
})
+1496 -12
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -58,7 +58,10 @@ export class RuntimeClient {
private readonly userDataPath: string
private readonly requestTimeoutMs: number
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 15000) {
// Why: browser commands trigger first-time session init (agent-browser connect +
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
// being so large that genuine hangs go unnoticed.
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) {
this.userDataPath = userDataPath
this.requestTimeoutMs = requestTimeoutMs
}
@@ -383,6 +386,12 @@ export function getDefaultUserDataPath(
platform: NodeJS.Platform = process.platform,
homeDir = homedir()
): string {
// Why: in dev mode, the Electron app writes runtime metadata to `orca-dev`
// instead of `orca` to avoid clobbering the production app's metadata. The
// CLI needs to find the same metadata file, so respect this env var override.
if (process.env.ORCA_USER_DATA_PATH) {
return process.env.ORCA_USER_DATA_PATH
}
if (platform === 'darwin') {
return join(homeDir, 'Library', 'Application Support', 'orca')
}