mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
feat: computer use via agent-browser CDP bridge (#856)
This commit is contained in:
+329
-1
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user