mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* feat(cli): open a browser tab in a split pane with `tab create --split` `orca tab create` could only add a tab to an existing pane, so an agent had no way to put a page beside its terminal. `--split <right|left|down|up>` creates a new pane next to the calling terminal (ORCA_TAB_ID), falling back to the active pane, and opens the page there without taking focus. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNtbMA9iVj9T4rBUjn5u6W * refactor(browser): share one type for the tab-create IPC payload The preload listener, its callback, the preload API contract and main's sender each declared the payload separately, and the listener copy had already drifted from the new split fields. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(browser): open the CLI split pane and its tab in one step Creating an empty group and then filling it fails when main owns the layout, because the window's group doesn't exist until main's frame lands. createBrowserTab's split option carries both to whichever owner draws them, so the rollback for a half-made pane goes away. SSH hosts already cannot choose a new tab's group, so their CLI's split is stripped like targetGroupId. * fix(browser): split in the caller's workspace and refuse splits that can't happen A CLI run outside its workspace's directory sends no workspace, so the renderer fell back to the active one and missed the caller's terminal. The caller's tab now picks the workspace when none was named. An SSH host's split and an empty --split= both turned into a plain tab reported as success; both are refused instead. --------- Co-authored-by: Kalvin Mizzi <kalvin@mizzi.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
921 lines
27 KiB
TypeScript
921 lines
27 KiB
TypeScript
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const callMock = vi.fn()
|
|
|
|
vi.mock('./runtime-client', () => {
|
|
class RuntimeClient {
|
|
call = callMock
|
|
getCliStatus = vi.fn()
|
|
openOrca = vi.fn()
|
|
}
|
|
|
|
class RuntimeClientError extends Error {
|
|
readonly code: string
|
|
|
|
constructor(code: string, message: string) {
|
|
super(message)
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
class RuntimeRpcFailureError extends RuntimeClientError {
|
|
readonly response: unknown
|
|
|
|
constructor(response: unknown) {
|
|
super('runtime_error', 'runtime_error')
|
|
this.response = response
|
|
}
|
|
}
|
|
|
|
return {
|
|
RuntimeClient,
|
|
RuntimeClientError,
|
|
RuntimeRpcFailureError
|
|
}
|
|
})
|
|
|
|
import { main } from './index'
|
|
import { RuntimeClientError } from './runtime-client'
|
|
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
|
|
|
describe('orca cli browser page targeting', () => {
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('passes explicit page ids to snapshot without resolving the current worktree', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_snapshot', {
|
|
browserPageId: 'page-1',
|
|
snapshot: 'tree',
|
|
refs: [],
|
|
url: 'https://example.com',
|
|
title: 'Example'
|
|
})
|
|
)
|
|
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('sends snapshot flags to the host and prints delta changes against the last tree', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_snapshot', {
|
|
browserPageId: 'page-1',
|
|
snapshot: {
|
|
kind: 'delta',
|
|
revision: 3,
|
|
baseRevision: 2,
|
|
treeChange: { startLine: 4, deleteCount: 2, lines: ['- button "Save" [ref=e9]'] }
|
|
},
|
|
url: 'https://example.com',
|
|
title: 'Example'
|
|
})
|
|
)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'snapshot',
|
|
'--page',
|
|
'page-1',
|
|
'--interactive',
|
|
'--depth',
|
|
'3',
|
|
'--selector',
|
|
'form',
|
|
'--delta'
|
|
],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('browser.snapshot', {
|
|
page: 'page-1',
|
|
interactive: true,
|
|
depth: 3,
|
|
selector: 'form',
|
|
delta: true
|
|
})
|
|
expect(log).toHaveBeenCalledWith(
|
|
[
|
|
'page: page-1',
|
|
'Example — https://example.com',
|
|
'revision 3: changes to revision 2; replace lines 5-6 with:',
|
|
'- button "Save" [ref=e9]'
|
|
].join('\n')
|
|
)
|
|
})
|
|
|
|
it('saves screenshots where the CLI runs and prints the path instead of base64', async () => {
|
|
const screenshotDir = mkdtempSync(join(tmpdir(), 'orca-browser-shot-'))
|
|
vi.stubEnv('ORCA_BROWSER_SCREENSHOT_TMPDIR', screenshotDir)
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_shot', { data: Buffer.from('png-bytes').toString('base64'), format: 'png' }),
|
|
okFixture('req_unchanged', { data: '', format: 'png', changed: false })
|
|
)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
try {
|
|
await main(['screenshot', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree')
|
|
await main(['screenshot', '--page', 'page-1', '--if-changed'], '/tmp/not-an-orca-worktree')
|
|
|
|
const saved = JSON.parse(String(log.mock.calls[0][0])).result
|
|
expect(saved.data).toBeUndefined()
|
|
expect(saved.path).toBe(join(screenshotDir, 'req_shot-screenshot.png'))
|
|
expect(readFileSync(saved.path, 'utf8')).toBe('png-bytes')
|
|
expect(callMock).toHaveBeenLastCalledWith('browser.screenshot', {
|
|
page: 'page-1',
|
|
ifChanged: true
|
|
})
|
|
expect(log).toHaveBeenLastCalledWith(
|
|
'Screenshot unchanged since the last --if-changed capture; nothing saved'
|
|
)
|
|
} finally {
|
|
rmSync(screenshotDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('resolves current worktree only when --page is combined with --worktree current', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
|
okFixture('req_snapshot', {
|
|
browserPageId: 'page-1',
|
|
snapshot: 'tree',
|
|
refs: [],
|
|
url: 'https://example.com',
|
|
title: 'Example'
|
|
})
|
|
)
|
|
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: 'id:repo::/tmp/repo/feature'
|
|
})
|
|
})
|
|
|
|
it('passes page-targeted tab switches through without auto-scoping to the current worktree', async () => {
|
|
queueFixtures(callMock, okFixture('req_switch', { switched: 2, browserPageId: 'page-2' }))
|
|
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'
|
|
})
|
|
expect(callMock.mock.calls[0][1]).not.toHaveProperty('focus')
|
|
})
|
|
|
|
it('still resolves the current worktree when tab switch --page is combined with --worktree current', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
|
okFixture('req_switch', { switched: 2, browserPageId: 'page-2' })
|
|
)
|
|
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: 'id:repo::/tmp/repo/feature'
|
|
})
|
|
})
|
|
|
|
it('passes focus: true through to browser.tabSwitch when --focus is set', async () => {
|
|
queueFixtures(callMock, okFixture('req_switch', { switched: 1, browserPageId: 'page-1' }))
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['tab', 'switch', '--page', 'page-1', '--focus', '--json'], '/tmp/repo/feature/src')
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabSwitch', {
|
|
index: undefined,
|
|
page: 'page-1',
|
|
focus: true
|
|
})
|
|
})
|
|
|
|
it('passes explicit profile ids to tab create', async () => {
|
|
queueFixtures(callMock, okFixture('req_create', { browserPageId: 'page-3' }))
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'tab',
|
|
'create',
|
|
'--url',
|
|
'https://example.com',
|
|
'--profile',
|
|
'work',
|
|
'--worktree',
|
|
'all',
|
|
'--json'
|
|
],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'browser.tabCreate',
|
|
{
|
|
url: 'https://example.com',
|
|
worktree: undefined,
|
|
profileId: 'work'
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
})
|
|
|
|
it('asks for a split beside the calling terminal tab with --split', async () => {
|
|
vi.stubEnv('ORCA_TAB_ID', 'terminal-tab-1')
|
|
queueFixtures(callMock, okFixture('req_create', { browserPageId: 'page-4' }))
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['tab', 'create', '--url', 'https://example.com', '--split', 'right', '--worktree', 'all'],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'browser.tabCreate',
|
|
{
|
|
url: 'https://example.com',
|
|
worktree: undefined,
|
|
profileId: undefined,
|
|
split: 'right',
|
|
splitFromTabId: 'terminal-tab-1'
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('leaves the caller tab out of a plain tab create', async () => {
|
|
vi.stubEnv('ORCA_TAB_ID', 'terminal-tab-1')
|
|
queueFixtures(callMock, okFixture('req_create', { browserPageId: 'page-5' }))
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['tab', 'create', '--worktree', 'all'], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock.mock.calls[0][1]).not.toHaveProperty('split')
|
|
expect(callMock.mock.calls[0][1]).not.toHaveProperty('splitFromTabId')
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('rejects an unknown --split direction before calling the runtime', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
await main(['tab', 'create', '--split', 'sideways'], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(errorSpy).toHaveBeenCalledWith('--split must be right, left, down, or up')
|
|
})
|
|
|
|
it('rejects --split with no direction instead of opening a plain tab', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
await main(['tab', 'create', '--split='], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(errorSpy).toHaveBeenCalledWith('Missing value for --split')
|
|
})
|
|
|
|
it('opens browser-launch URLs on the client hosting the current worktree', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
|
okFixture('req_open_url', { browserPageId: 'page-local' })
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['open-url', '--url', 'https://example.com/login', '--json'], '/tmp/repo/feature')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'browser.openUrl',
|
|
{
|
|
url: 'https://example.com/login',
|
|
worktree: 'id:repo::/tmp/repo/feature'
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
})
|
|
|
|
it('creates a tab in the calling folder workspace, not the parent sharing its path (#33618)', async () => {
|
|
const parent = buildWorktree('/tmp/folder', '')
|
|
const workspace = { ...parent, id: `${parent.id}::workspace:ws-2` }
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([parent, workspace]),
|
|
okFixture('req_tab_create', { browserPageId: 'page-1' })
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
vi.stubEnv('ORCA_WORKTREE_ID', workspace.id)
|
|
|
|
await main(['tab', 'create', '--url', 'https://example.com', '--json'], '/tmp/folder')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'browser.tabCreate',
|
|
{ url: 'https://example.com', worktree: `id:${workspace.id}` },
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
})
|
|
|
|
it('passes tab profile updates through by page id', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_set_profile', {
|
|
browserPageId: 'page-2',
|
|
profileId: 'work',
|
|
profileLabel: 'Work'
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['tab', 'profile', 'set', '--page', 'page-2', '--profile', 'work', '--json'],
|
|
'/tmp/repo/feature/src'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabSetProfile', {
|
|
page: 'page-2',
|
|
profileId: 'work'
|
|
})
|
|
})
|
|
|
|
it('shows tab profile labels in text mode when requested', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_list', {
|
|
tabs: [
|
|
{
|
|
browserPageId: 'page-1',
|
|
index: 0,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
active: true,
|
|
profileId: 'default',
|
|
profileLabel: 'Default'
|
|
},
|
|
{
|
|
browserPageId: 'page-2',
|
|
index: 1,
|
|
url: 'https://mail.example.com',
|
|
title: 'Mail',
|
|
active: false,
|
|
profileId: 'work',
|
|
profileLabel: 'Work'
|
|
}
|
|
]
|
|
})
|
|
)
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['tab', 'list', '--show-profile', '--worktree', 'all'], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabList', { worktree: undefined })
|
|
expect(logSpy).toHaveBeenCalledWith(
|
|
'* [0] page-1 Example — https://example.com [Default]\n' +
|
|
' [1] page-2 Mail — https://mail.example.com [Work]'
|
|
)
|
|
})
|
|
|
|
it('shows a single tab by page id', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_tab_show', {
|
|
tab: {
|
|
browserPageId: 'page-1',
|
|
index: 0,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
active: true,
|
|
worktreeId: 'repo::/tmp/repo/feature',
|
|
profileId: 'work',
|
|
profileLabel: 'Work'
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['tab', 'show', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabShow', { page: 'page-1' })
|
|
})
|
|
|
|
it('resolves the current tab in a worktree', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_tab_current', {
|
|
tab: {
|
|
browserPageId: 'page-2',
|
|
index: 1,
|
|
url: 'https://mail.example.com',
|
|
title: 'Mail',
|
|
active: true,
|
|
worktreeId: 'repo::/tmp/repo/feature',
|
|
profileId: 'default',
|
|
profileLabel: 'Default'
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['tab', 'current', '--worktree', 'all', '--json'], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
|
|
})
|
|
})
|
|
|
|
describe('orca cli browser identity', () => {
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('gets the host identity only after capability negotiation', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('status', { capabilities: ['browser.identity.v1'] }),
|
|
okFixture('identity', {
|
|
identity: {
|
|
state: 'valid',
|
|
appliedMode: 'clean',
|
|
configuredMode: 'native',
|
|
explicitSelection: true,
|
|
migrationNoticePending: false,
|
|
restartRequired: true
|
|
},
|
|
migrationNotice: null
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['browser', 'identity', 'get', '--json'], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(1, 'status.get')
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.identity.get')
|
|
})
|
|
|
|
it('sets the host identity through the runtime writer', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('status', { capabilities: ['browser.identity.v1'] }),
|
|
okFixture('identity', {
|
|
ok: true,
|
|
identity: {
|
|
state: 'valid',
|
|
appliedMode: 'clean',
|
|
configuredMode: 'native',
|
|
explicitSelection: true,
|
|
migrationNoticePending: false,
|
|
restartRequired: true
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['browser', 'identity', 'set', '--mode', 'native', '--json'],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.identity.set', { mode: 'native' })
|
|
})
|
|
|
|
it('refuses an older runtime instead of guessing at identity support', async () => {
|
|
queueFixtures(callMock, okFixture('status', { capabilities: [] }))
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
await main(['browser', 'identity', 'get'], '/tmp/not-an-orca-worktree')
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(error).toHaveBeenCalledWith(expect.stringContaining('Update or restart Orca'))
|
|
})
|
|
})
|
|
|
|
describe('orca cli browser profile management', () => {
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('shows the profile bound to a tab', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_profile_show', {
|
|
browserPageId: 'page-2',
|
|
worktreeId: 'repo::/tmp/repo/feature',
|
|
profileId: 'work',
|
|
profileLabel: 'Work'
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['tab', 'profile', 'show', '--page', 'page-2', '--json'],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabProfileShow', { page: 'page-2' })
|
|
})
|
|
|
|
it('switches a tab back to the default profile', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_profile_default', {
|
|
browserPageId: 'page-2',
|
|
profileId: 'default',
|
|
profileLabel: 'Default'
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['tab', 'profile', 'use-default', '--page', 'page-2', '--json'],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabSetProfile', {
|
|
page: 'page-2',
|
|
profileId: 'default'
|
|
})
|
|
})
|
|
|
|
it('clones a tab into a different profile', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_profile_clone', {
|
|
browserPageId: 'page-9',
|
|
sourceBrowserPageId: 'page-2',
|
|
profileId: 'work',
|
|
profileLabel: 'Work'
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['tab', 'profile', 'clone', '--page', 'page-2', '--profile', 'work', '--json'],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('browser.tabProfileClone', {
|
|
page: 'page-2',
|
|
profileId: 'work'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('orca cli browser tab profiles', () => {
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
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 cookies', () => {
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
process.exitCode = undefined
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('passes a finite non-negative cookie expiry through as a number', async () => {
|
|
queueFixtures(callMock, okFixture('req_cookie', { success: true }))
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'cookie',
|
|
'set',
|
|
'--name',
|
|
'sid',
|
|
'--value',
|
|
'x',
|
|
'--expires',
|
|
'0',
|
|
'--worktree',
|
|
'all',
|
|
'--json'
|
|
],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('browser.cookie.set', {
|
|
name: 'sid',
|
|
value: 'x',
|
|
expires: 0,
|
|
worktree: undefined
|
|
})
|
|
})
|
|
|
|
it.each(['not-a-number', 'Infinity', '-1'])(
|
|
'rejects invalid cookie expiry value %s before RPC dispatch',
|
|
async (expires) => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main(
|
|
['cookie', 'set', '--name', 'sid', '--value', 'x', '--expires', expires],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(errorSpy.mock.calls.flat().join('\n')).toContain(`Invalid --expires value: ${expires}`)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
}
|
|
)
|
|
|
|
it('rejects --expires without a value before RPC dispatch', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main(
|
|
['cookie', 'set', '--name', 'sid', '--value', 'x', '--expires'],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing value for --expires.')
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
})
|
|
|
|
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 () => {
|
|
queueFixtures(callMock, okFixture('req_wait', { ok: true }))
|
|
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 () => {
|
|
queueFixtures(callMock, okFixture('req_wait', { ok: true }))
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['wait', '--selector', '#ready', '--timeout', '45000', '--worktree', 'all', '--json'],
|
|
'/tmp/not-an-orca-worktree'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'browser.wait',
|
|
{
|
|
selector: '#ready',
|
|
timeout: 45000,
|
|
text: undefined,
|
|
url: undefined,
|
|
load: undefined,
|
|
fn: undefined,
|
|
state: undefined,
|
|
worktree: undefined
|
|
},
|
|
{ timeoutMs: 75000 }
|
|
)
|
|
})
|
|
|
|
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 () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_viewport', {
|
|
width: 375,
|
|
height: 812,
|
|
deviceScaleFactor: 2,
|
|
mobile: true
|
|
})
|
|
)
|
|
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
|
|
})
|
|
})
|
|
})
|