diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index c0f518b8e..b1fddb8bb 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -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() diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 5b245c992..6dd41e3a5 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -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 { BROWSER_NAV_HANDLERS, BROWSER_INTERACT_HANDLERS, BROWSER_TAB_HANDLERS, + BROWSER_PROFILE_HANDLERS, BROWSER_COOKIE_HANDLERS, BROWSER_CAPTURE_HANDLERS, BROWSER_ENV_HANDLERS, diff --git a/src/cli/format.ts b/src/cli/format.ts index b1d1ac432..aacad3fbc 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -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') +} diff --git a/src/cli/handlers/browser-profile.ts b/src/cli/handlers/browser-profile.ts new file mode 100644 index 000000000..0e4eefac2 --- /dev/null +++ b/src/cli/handlers/browser-profile.ts @@ -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): '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 = { + 'tab profile list': async ({ client, json }) => { + const result = await client.call('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('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('browser.profileDelete', { + profileId + }) + printResult(result, json, (value) => + value.deleted + ? `Deleted profile ${value.profileId}` + : `Profile ${value.profileId} was not deleted` + ) + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 2e8f6ddb9..2ad77f9db 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 Scroll distance in pixels (default: viewport height) --index Tab index (from \`tab list\`) --page Stable browser page id (preferred for concurrent workflows) + --profile Browser profile id (see \`orca tab profile list\`) --format Screenshot image format --from Drag source element ref --to 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 Scroll distance in pixels', index: '--index Tab index to switch to', page: '--page Stable browser page id from `orca tab list --json`', + profile: '--profile Browser profile id', format: '--format Screenshot image format' } diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 8a1e02893..9466a18fc 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -169,6 +169,24 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca tab create [--url ] [--worktree ] [--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 [--scope ] [--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 [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'profile'] + }, { path: ['tab', 'close'], summary: 'Close a browser tab', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index be4210e28..af0f05b99 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { + return { profiles: browserSessionRegistry.listProfiles() } + } + + async browserProfileCreate(params: { + label: string + scope: 'isolated' | 'imported' + }): Promise { + return { + profile: browserSessionRegistry.createProfile(params.scope, params.label) + } + } + + async browserProfileDelete(params: { profileId: string }): Promise { + return { + deleted: await browserSessionRegistry.deleteProfile(params.profileId), + profileId: params.profileId + } + } + async browserTabClose(params: { index?: number page?: string diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 5d2721373..f68ea7971 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 3a8e3cf0a..42693a19d 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -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 diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 95c6cf54f..9d222b78b 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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 }