feat(cli): add tab profile lifecycle commands (#1397)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Nikolatesla-lj <Nikolatesla-lj@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
Nikolatesla-lj 2026-05-05 14:01:15 +08:00 committed by GitHub
parent f90da6ea37
commit 9bf339eeb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 320 additions and 1 deletions

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -128,6 +129,151 @@ describe('orca cli browser page targeting', () => {
})
})
describe('orca cli browser tab profiles', () => {
beforeEach(() => {
callMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('lists browser tab profiles', async () => {
queueFixtures(
callMock,
okFixture('req_profiles', {
profiles: [
{ id: 'default', scope: 'default', label: 'Default', partition: 'persist:orca-browser' },
{
id: 'work',
scope: 'isolated',
label: 'Work',
partition: 'persist:orca-browser-session-work'
}
]
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'profile', 'list', '--json'], '/tmp/not-an-orca-worktree')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.profileList')
})
it('reports an empty browser tab profile list with a friendly message', async () => {
queueFixtures(callMock, okFixture('req_profiles', { profiles: [] }))
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'profile', 'list'], '/tmp/not-an-orca-worktree')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.profileList')
expect(logSpy).toHaveBeenCalledWith('No browser profiles found.')
})
it('creates isolated browser tab profiles by default', async () => {
queueFixtures(
callMock,
okFixture('req_profile_create', {
profile: {
id: 'work',
scope: 'isolated',
label: 'Work',
partition: 'persist:orca-browser-session-work'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['tab', 'profile', 'create', '--label', 'Work', '--json'],
'/tmp/not-an-orca-worktree'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.profileCreate', {
label: 'Work',
scope: 'isolated'
})
})
it('forwards --scope imported through to the runtime', async () => {
queueFixtures(
callMock,
okFixture('req_profile_create', {
profile: {
id: 'imp',
scope: 'imported',
label: 'From Chrome',
partition: 'persist:orca-browser-session-imp'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['tab', 'profile', 'create', '--label', 'From Chrome', '--scope', 'imported', '--json'],
'/tmp/not-an-orca-worktree'
)
expect(callMock).toHaveBeenCalledWith('browser.profileCreate', {
label: 'From Chrome',
scope: 'imported'
})
})
it('rejects unknown --scope values instead of silently defaulting to isolated', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['tab', 'profile', 'create', '--label', 'Work', '--scope', 'isloated'],
'/tmp/not-an-orca-worktree'
)
expect(callMock).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalledWith('--scope must be "isolated" or "imported"')
})
it('surfaces a runtime error if the registry refuses to create a profile', async () => {
queueFixtures(callMock, okFixture('req_profile_create', { profile: null }))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['tab', 'profile', 'create', '--label', 'Bogus'], '/tmp/not-an-orca-worktree')
expect(callMock).toHaveBeenCalledTimes(1)
expect(errorSpy).toHaveBeenCalledWith(
'Failed to create browser profile (label=Bogus, scope=isolated)'
)
})
it('deletes browser tab profiles by id', async () => {
queueFixtures(callMock, okFixture('req_profile_delete', { deleted: true, profileId: 'work' }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['tab', 'profile', 'delete', '--profile', 'work', '--json'],
'/tmp/not-an-orca-worktree'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.profileDelete', { profileId: 'work' })
})
it('reports a not-deleted profile in text mode without throwing', async () => {
queueFixtures(
callMock,
okFixture('req_profile_delete', { deleted: false, profileId: 'default' })
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'profile', 'delete', '--profile', 'default'], '/tmp/not-an-orca-worktree')
expect(callMock).toHaveBeenCalledWith('browser.profileDelete', { profileId: 'default' })
expect(logSpy).toHaveBeenCalledWith('Profile default was not deleted')
})
})
describe('orca cli browser waits and viewport flags', () => {
beforeEach(() => {
callMock.mockReset()

View File

@ -7,6 +7,7 @@ import { TERMINAL_HANDLERS } from './handlers/terminal'
import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav'
import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact'
import { BROWSER_TAB_HANDLERS } from './handlers/browser-tab'
import { BROWSER_PROFILE_HANDLERS } from './handlers/browser-profile'
import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie'
import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture'
import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
@ -32,6 +33,7 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_NAV_HANDLERS,
BROWSER_INTERACT_HANDLERS,
BROWSER_TAB_HANDLERS,
BROWSER_PROFILE_HANDLERS,
BROWSER_COOKIE_HANDLERS,
BROWSER_CAPTURE_HANDLERS,
BROWSER_ENV_HANDLERS,

View File

@ -1,4 +1,5 @@
import type {
BrowserProfileListResult,
BrowserScreenshotResult,
BrowserSnapshotResult,
BrowserTabListResult,
@ -247,3 +248,16 @@ export function formatTabList(result: BrowserTabListResult): string {
})
.join('\n')
}
export function formatBrowserProfileList(result: BrowserProfileListResult): string {
if (result.profiles.length === 0) {
return 'No browser profiles found.'
}
return result.profiles
.map((profile) => {
const marker = profile.scope === 'default' ? '* ' : ' '
const source = profile.source?.browserFamily ?? 'none'
return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}`
})
.join('\n')
}

View File

@ -0,0 +1,61 @@
import type {
BrowserProfileCreateResult,
BrowserProfileDeleteResult,
BrowserProfileListResult
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
import { formatBrowserProfileList, printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
function parseScopeFlag(flags: Map<string, string | boolean>): 'isolated' | 'imported' {
const raw = getOptionalStringFlag(flags, 'scope')
if (raw === undefined || raw === 'isolated') {
return 'isolated'
}
if (raw === 'imported') {
return 'imported'
}
throw new RuntimeClientError('invalid_argument', '--scope must be "isolated" or "imported"')
}
export const BROWSER_PROFILE_HANDLERS: Record<string, CommandHandler> = {
'tab profile list': async ({ client, json }) => {
const result = await client.call<BrowserProfileListResult>('browser.profileList')
printResult(result, json, formatBrowserProfileList)
},
'tab profile create': async ({ flags, client, json }) => {
const label = getRequiredStringFlag(flags, 'label')
const scope = parseScopeFlag(flags)
const result = await client.call<BrowserProfileCreateResult>('browser.profileCreate', {
label,
scope
})
if (result.result.profile === null) {
// Why: registry refuses non-isolated/imported scopes; we already validated
// the scope client-side, so a null here means a server-side rejection we
// shouldn't silently report as success.
throw new RuntimeClientError(
'runtime_error',
`Failed to create browser profile (label=${label}, scope=${scope})`
)
}
printResult(
result,
json,
(value) =>
`Created profile ${value.profile?.id ?? 'unknown'} (${value.profile?.label ?? label})`
)
},
'tab profile delete': async ({ flags, client, json }) => {
const profileId = getRequiredStringFlag(flags, 'profile')
const result = await client.call<BrowserProfileDeleteResult>('browser.profileDelete', {
profileId
})
printResult(result, json, (value) =>
value.deleted
? `Deleted profile ${value.profileId}`
: `Profile ${value.profileId} was not deleted`
)
}
}

View File

@ -59,6 +59,9 @@ Orchestration:
Browser Automation:
tab create Create a new browser tab (navigates to --url)
tab list List open browser tabs
tab profile list List browser session profiles
tab profile create Create a browser session profile
tab profile delete Delete a browser session profile
tab switch Switch the active browser tab by --index or --page
tab close Close a browser tab by --index/--page or the current tab
snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)
@ -183,6 +186,7 @@ Browser Options:
--amount <pixels> Scroll distance in pixels (default: viewport height)
--index <n> Tab index (from \`tab list\`)
--page <id> Stable browser page id (preferred for concurrent workflows)
--profile <id> Browser profile id (see \`orca tab profile list\`)
--format <png|jpeg> Screenshot image format
--from <ref> Drag source element ref
--to <ref> Drag target element ref
@ -202,6 +206,8 @@ Examples:
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
$ orca terminal send --terminal term_123 --text "hi" --enter
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json
$ orca tab profile list
$ orca tab profile create --label Work
$ orca tab create --url https://example.com
$ orca snapshot
$ orca click --element e3
@ -306,6 +312,7 @@ export function formatFlagHelp(flag: string): string {
amount: '--amount <pixels> Scroll distance in pixels',
index: '--index <n> Tab index to switch to',
page: '--page <id> Stable browser page id from `orca tab list --json`',
profile: '--profile <id> Browser profile id',
format: '--format <png|jpeg> Screenshot image format'
}

View File

@ -169,6 +169,24 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [
usage: 'orca tab create [--url <url>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree']
},
{
path: ['tab', 'profile', 'list'],
summary: 'List browser session profiles available to browser tabs',
usage: 'orca tab profile list [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['tab', 'profile', 'create'],
summary: 'Create a browser session profile for browser tabs',
usage: 'orca tab profile create --label <name> [--scope <isolated|imported>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope']
},
{
path: ['tab', 'profile', 'delete'],
summary: 'Delete a browser session profile used by browser tabs',
usage: 'orca tab profile delete --profile <id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'profile']
},
{
path: ['tab', 'close'],
summary: 'Close a browser tab',

View File

@ -57,6 +57,9 @@ import type {
BrowserEvalResult,
BrowserTabListResult,
BrowserTabSwitchResult,
BrowserProfileCreateResult,
BrowserProfileDeleteResult,
BrowserProfileListResult,
BrowserHoverResult,
BrowserDragResult,
BrowserUploadResult,
@ -82,6 +85,7 @@ import type {
import { BrowserWindow, ipcMain } from 'electron'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { BrowserError } from '../browser/cdp-bridge'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { waitForTabRegistration } from '../ipc/browser'
import { getPRForBranch } from '../github/client'
import {
@ -4484,6 +4488,26 @@ export class OrcaRuntimeService {
return { browserPageId }
}
async browserProfileList(): Promise<BrowserProfileListResult> {
return { profiles: browserSessionRegistry.listProfiles() }
}
async browserProfileCreate(params: {
label: string
scope: 'isolated' | 'imported'
}): Promise<BrowserProfileCreateResult> {
return {
profile: browserSessionRegistry.createProfile(params.scope, params.label)
}
}
async browserProfileDelete(params: { profileId: string }): Promise<BrowserProfileDeleteResult> {
return {
deleted: await browserSessionRegistry.deleteProfile(params.profileId),
profileId: params.profileId
}
}
async browserTabClose(params: {
index?: number
page?: string

View File

@ -16,6 +16,8 @@ import {
KeyboardInsert,
Keypress,
LimitParam,
ProfileCreate,
ProfileDelete,
Screenshot,
Scroll,
Select,
@ -105,6 +107,21 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
params: TabClose,
handler: async (params, { runtime }) => runtime.browserTabClose(params)
}),
defineMethod({
name: 'browser.profileList',
params: null,
handler: async (_params, { runtime }) => runtime.browserProfileList()
}),
defineMethod({
name: 'browser.profileCreate',
params: ProfileCreate,
handler: async (params, { runtime }) => runtime.browserProfileCreate(params)
}),
defineMethod({
name: 'browser.profileDelete',
params: ProfileDelete,
handler: async (params, { runtime }) => runtime.browserProfileDelete(params)
}),
defineMethod({
name: 'browser.hover',
params: Element,

View File

@ -153,6 +153,23 @@ export const Exec = BrowserTarget.extend({
command: requiredString('Missing required --command')
})
export const ProfileCreate = z.object({
label: requiredString('Missing required --label'),
scope: z
.unknown()
.transform((value) => {
if (value === 'imported') {
return 'imported'
}
return 'isolated'
})
.pipe(z.enum(['isolated', 'imported']))
})
export const ProfileDelete = z.object({
profileId: requiredString('Missing required --profile')
})
export const Get = BrowserTarget.extend({
what: requiredString('Missing required --what'),
selector: OptionalString

View File

@ -1,6 +1,6 @@
/* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */
import type { TerminalPaneLayoutNode } from './types'
import type { GitWorktreeInfo, Repo } from './types'
import type { BrowserSessionProfile, GitWorktreeInfo, Repo } from './types'
export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable'
@ -270,6 +270,19 @@ export type BrowserTabSwitchResult = {
browserPageId: string
}
export type BrowserProfileListResult = {
profiles: BrowserSessionProfile[]
}
export type BrowserProfileCreateResult = {
profile: BrowserSessionProfile | null
}
export type BrowserProfileDeleteResult = {
deleted: boolean
profileId: string
}
export type BrowserHoverResult = {
hovered: string
}