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:
parent
f90da6ea37
commit
9bf339eeb9
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue