Files
orca/src/cli/index.test.ts
T
BorjaandClaude Opus 4.8 0cf4f6010e Add --workspace-status flag to worktree set CLI (#3332)
The `worktree.set` RPC already accepts `workspaceStatus`, but the CLI
handler never forwarded it, so the board status could not be changed
from the command line. Pass the flag through, document it in the spec
and root help, and add a CLI test mirroring the existing --comment case.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:41:10 -07:00

2190 lines
62 KiB
TypeScript

/* eslint-disable max-lines -- Why: CLI parser tests share one mocked runtime client and fixture queue; splitting this file would duplicate setup and make command coverage harder to audit. */
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn()
}))
vi.mock('./runtime-client', () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
const effectivePairingCode =
remotePairingCode === undefined
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
: remotePairingCode
const effectiveEnvironment =
environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector
if (effectivePairingCode && effectiveEnvironment) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --pairing-code or --environment, not both.'
)
}
this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment)
}
}
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,
serveOrcaApp: serveOrcaAppMock,
getDefaultUserDataPath: getDefaultUserDataPathMock
}
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
main,
normalizeWorktreeSelector
} from './index'
import { GLOBAL_FLAGS } from './args'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
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)
}
})
it('allows every flag documented in command usage strings', () => {
const flagPattern = /--([a-zA-Z0-9-]+)/g
for (const spec of COMMAND_SPECS) {
const allowed = new Set([...GLOBAL_FLAGS, ...spec.allowedFlags])
for (const match of spec.usage.matchAll(flagPattern)) {
const flag = match[1]
expect(
allowed.has(flag),
`Documented flag --${flag} is not allowed for command: ${spec.path.join(' ')}`
).toBe(true)
}
}
})
})
describe('orca cli worktree awareness', () => {
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
const originalPairingCode = process.env.ORCA_PAIRING_CODE
const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
const originalEnvironment = process.env.ORCA_ENVIRONMENT
beforeEach(() => {
callMock.mockReset()
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_USER_DATA_PATH
serveOrcaAppMock.mockReset()
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
listEnvironmentsMock.mockReset()
addEnvironmentFromPairingCodeMock.mockReturnValue({
id: 'env-1',
name: 'desk',
createdAt: 100,
updatedAt: 100,
lastUsedAt: null,
runtimeId: null,
endpoints: [
{
id: 'ws-env-1',
kind: 'websocket',
label: 'WebSocket',
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'pk'
}
],
preferredEndpointId: 'ws-env-1'
})
listEnvironmentsMock.mockReturnValue([])
})
afterEach(() => {
vi.restoreAllMocks()
if (originalTerminalHandle === undefined) {
delete process.env.ORCA_TERMINAL_HANDLE
} else {
process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle
}
if (originalUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
}
if (originalPairingCode === undefined) {
delete process.env.ORCA_PAIRING_CODE
} else {
process.env.ORCA_PAIRING_CODE = originalPairingCode
}
if (originalRemotePairing === undefined) {
delete process.env.ORCA_REMOTE_PAIRING
} else {
process.env.ORCA_REMOTE_PAIRING = originalRemotePairing
}
if (originalEnvironment === undefined) {
delete process.env.ORCA_ENVIRONMENT
} else {
process.env.ORCA_ENVIRONMENT = originalEnvironment
}
})
it('builds the current worktree selector from cwd', () => {
expect(buildCurrentWorktreeSelector('/tmp/repo/feature')).toBe(
`path:${path.resolve('/tmp/repo/feature')}`
)
})
it('normalizes active/current worktree selectors to cwd', () => {
const resolved = path.resolve('/tmp/repo/feature')
expect(normalizeWorktreeSelector('active', '/tmp/repo/feature')).toBe(`path:${resolved}`)
expect(normalizeWorktreeSelector('current', '/tmp/repo/feature')).toBe(`path:${resolved}`)
expect(normalizeWorktreeSelector('branch:feature/foo', '/tmp/repo/feature')).toBe(
'branch:feature/foo'
)
})
it('shows the enclosing worktree for `worktree current`', async () => {
queueFixtures(
callMock,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main'),
buildWorktree('/tmp/repo/feature', 'feature/foo'),
buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'duplicate-repo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'current is a local cwd shortcut and cannot be resolved against a remote runtime.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('uses cwd when active is passed to worktree.set', async () => {
queueFixtures(
callMock,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main', 'aaa'),
buildWorktree('/tmp/repo/feature', 'feature/foo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature',
comment: 'hello'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'set', '--worktree', 'active', '--comment', 'hello', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
worktree: 'id:repo::/tmp/repo/feature',
displayName: undefined,
linkedIssue: undefined,
comment: 'hello',
parentWorktree: undefined,
noParent: false
})
})
it('passes parent lineage through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_set_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: 'repo::/tmp/repo/parent',
childWorktreeIds: [],
lineage: {
worktreeId: 'repo::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'id:repo::/tmp/repo/parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: 'id:repo::/tmp/repo/parent',
noParent: false
})
})
it('resolves current for explicit parent-worktree on set', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent')]),
okFixture('req_set_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: 'repo::/tmp/repo/parent',
childWorktreeIds: [],
lineage: null
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'current',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: 'id:repo::/tmp/repo/parent',
noParent: false
})
})
it('rejects contradictory parent flags on worktree.set before resolving selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'current',
'--no-parent',
'--json'
],
'/tmp/not-managed'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --parent-worktree or --no-parent, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects bare parent-worktree on worktree.set', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--parent-worktree', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing required --parent-worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes parent removal through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_clear_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--no-parent', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
workspaceStatus: undefined,
parentWorktree: undefined,
noParent: true
})
})
it('passes workspace status through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_set_status', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
workspaceStatus: 'in-review'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--workspace-status',
'in-review',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
workspaceStatus: 'in-review',
parentWorktree: undefined,
noParent: false
})
})
it('passes explicit activation through worktree.create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--activate', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: true,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined
})
})
it('passes an explicit parent through worktree.create without cwd inference', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: {
...buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
parentWorktreeId: 'repo-1::/tmp/repo/parent',
lineage: {
worktreeId: 'repo-1::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
},
lineage: {
worktreeId: 'repo-1::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'id:repo-1::/tmp/repo/parent',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: 'id:repo-1::/tmp/repo/parent',
noParent: false,
callerTerminalHandle: undefined
})
})
it('resolves current for explicit parent-worktree on create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'current',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: 'id:repo-1::/tmp/repo/parent',
noParent: false,
callerTerminalHandle: undefined
})
})
it('rejects contradictory parent flags on worktree.create before resolving selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'current',
'--no-parent',
'--json'
],
'/tmp/not-managed'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --parent-worktree or --no-parent, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects bare parent-worktree on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing required --parent-worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes no-parent through worktree.create and skips cwd inference', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent', '--json'],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
noParent: true,
callerTerminalHandle: undefined
})
})
it('passes caller terminal handle through worktree.create with cwd fallback', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_parent'
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledTimes(2)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: 'term_parent'
})
})
it('starts a foreground headless server through `serve`', async () => {
serveOrcaAppMock.mockResolvedValue(0)
process.env.ORCA_ENVIRONMENT = 'stale-env'
await main(
['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: '6768',
pairingAddress: '100.64.1.20',
noPairing: true,
mobilePairing: false
})
})
it('starts a foreground headless server with mobile pairing enabled', async () => {
serveOrcaAppMock.mockResolvedValue(0)
await main(
['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: null,
pairingAddress: '100.64.1.20',
noPairing: false,
mobilePairing: true
})
})
it('rejects contradictory serve pairing flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --mobile-pairing or --no-pairing, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects invalid serve ports before launching the app', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Invalid --port value: not-a-port'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects value-less serve ports before launching the app', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--port', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing value for --port.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()])
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['environment', 'list', '--json'], '/tmp/repo')
expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'],
'/tmp/repo'
)
expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
name: 'desk',
pairingCode: 'orca://pair#abc'
})
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
it('resolves repo.add paths against the invoking cli cwd', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: path.resolve('/tmp/repo/apps/web'),
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: path.resolve('/tmp/repo/apps/web')
})
})
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote repo add requires --path to be an absolute path on the remote server.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends remote repo.add absolute paths unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: '/srv/orca/web',
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: '/srv/orca/web'
})
})
it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])(
'sends remote repo.add server absolute path %s unchanged',
async (serverPath) => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: serverPath,
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: serverPath
})
}
)
it('opts into setup and activation when worktree.create runs hooks', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--run-hooks', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: true,
activate: true,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined
})
})
it('passes explicit focus through terminal.create', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'RUNNER'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'RUNNER',
'--focus',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: undefined,
title: 'RUNNER',
focus: true
})
})
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex',
'--command',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex',
'--command',
'codex',
'--focus',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: true,
rendererBacked: true,
activate: true
})
})
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex exec'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex exec',
'--command',
'codex exec summarize',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex exec'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex exec',
'--command',
'codex -m gpt-5 --sandbox workspace-write exec summarize',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex review commands after long options', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex review'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex review',
'--command',
'codex --model=gpt-5 --sandbox=workspace-write review',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
title: 'Codex review',
focus: false
})
})
it('does not force the visible terminal path for Codex help commands', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex help'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex help',
'--command',
'codex --help',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
title: 'Codex help',
focus: false
})
})
it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex prompt'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex prompt',
'--command',
'codex -m gpt-5 "fix the flaky test"',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Claude'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Claude',
'--command',
'claude',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude',
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps Claude print commands on the background terminal path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Claude print'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Claude print',
'--command',
'claude -p "summarize"',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
title: 'Claude print',
focus: false
})
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
okFixture('req_show', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
})
it('formats group orchestration sends in text mode', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
callMock.mockResolvedValueOnce({
id: 'req_send',
ok: true,
result: {
messages: [{ id: 'msg_1' }, { id: 'msg_2' }],
recipients: 2
},
_meta: {
runtimeId: 'runtime-1'
}
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['orchestration', 'send', '--to', '@all', '--subject', 'hello'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.send', {
from: 'term_sender',
to: '@all',
subject: 'hello',
body: undefined,
type: undefined,
priority: undefined,
threadId: undefined,
payload: undefined,
devMode: false
})
expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients')
})
it('passes all reset scope explicitly for no-flag orchestration reset', async () => {
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset: 'all' }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['orchestration', 'reset'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
all: true,
tasks: undefined,
messages: undefined
})
})
it.each([
{
args: ['orchestration', 'reset', '--all'],
params: { all: true, tasks: undefined, messages: undefined },
reset: 'all'
},
{
args: ['orchestration', 'reset', '--tasks'],
params: { all: undefined, tasks: true, messages: undefined },
reset: 'tasks'
},
{
args: ['orchestration', 'reset', '--messages'],
params: { all: undefined, tasks: undefined, messages: true },
reset: 'messages'
},
{
args: ['orchestration', 'reset', '--tasks', '--messages'],
params: { all: undefined, tasks: true, messages: true },
reset: 'tasks'
},
{
args: ['orchestration', 'reset', '--all', '--tasks'],
params: { all: true, tasks: true, messages: undefined },
reset: 'all'
}
])('passes explicit reset flags through for $args', async ({ args, params, reset }) => {
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(args, '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.reset', params)
})
it('rejects unknown task-update status with an enum-aware error', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['orchestration', 'task-update', '--id', 'task_x', '--status', 'complete'],
'/tmp/repo'
)
const output = [...errSpy.mock.calls, ...logSpy.mock.calls]
.flat()
.map((v) => (typeof v === 'string' ? v : JSON.stringify(v)))
.join('\n')
expect(output).toContain("invalid status 'complete'")
expect(output).toContain('pending, ready, dispatched, completed, failed, blocked')
expect(callMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
// Reset exitCode so subsequent tests don't inherit the failure.
process.exitCode = priorExitCode
errSpy.mockRestore()
})
it('passes the caller terminal handle through orchestration task-create', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
callMock.mockResolvedValueOnce({
id: 'req_task_create',
ok: true,
result: {
task: { id: 'task_1', status: 'ready' }
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['orchestration', 'task-create', '--spec', 'spawn child workspace'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.taskCreate', {
spec: 'spawn child workspace',
deps: undefined,
parent: undefined,
callerTerminalHandle: 'term_creator'
})
})
it('passes dev mode to injected orchestration dispatches', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev'
callMock.mockResolvedValueOnce({
id: 'req_dispatch',
ok: true,
result: {
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' }
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_worker', '--inject'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('orchestration.dispatch', {
task: 'task_1',
to: 'term_worker',
from: 'term_sender',
inject: true,
devMode: true
})
})
it('uses the resolved enclosing worktree for terminal consumers', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
okFixture('req_term', { terminals: [], totalCount: 0, truncated: false })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', {
worktree: 'id:repo::/tmp/repo/feature',
limit: undefined
})
})
it('rejects implicit remote terminal create instead of resolving from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/client/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote terminal create requires --worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/srv/orca/feature',
title: 'Server terminal'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'id:repo-1::/srv/orca/feature',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
})
it('exits nonzero when terminal wait returns an unsatisfied blocked result', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
callMock.mockResolvedValueOnce({
id: 'req_terminal_wait',
ok: true,
result: {
wait: {
handle: 'term_worker',
condition: 'tui-idle',
satisfied: false,
status: 'running',
exitCode: null,
blockedReason: 'codex-cwd-prompt'
}
},
_meta: {
runtimeId: 'runtime-1'
}
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['terminal', 'wait', '--terminal', 'term_worker', '--for', 'tui-idle'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'terminal.wait',
{
terminal: 'term_worker',
for: 'tui-idle',
timeoutMs: undefined
},
{
timeoutMs: 300000
}
)
expect(logSpy.mock.calls.flat().join('\n')).toContain('blockedReason: codex-cwd-prompt')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('does not force remote Codex terminal creates through a local renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/srv/orca/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'id:repo-1::/srv/orca/feature',
'--command',
'codex',
'--title',
'Codex',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
title: 'Codex',
focus: false
})
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
queueFixtures(
callMock,
okFixture('req_tab_current', {
tab: {
browserPageId: 'page-1',
index: 0,
url: 'https://example.com',
title: 'Example',
active: true,
worktreeId: 'repo-1::/srv/orca/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
})
it('creates an automation for the enclosing worktree by default', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_automation_create', {
automation: {
id: 'auto-1',
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: 'repo-1::/tmp/repo/feature',
baseBranch: null,
timezone: 'America/Toronto',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1,
enabled: true,
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
baseBranch: undefined,
reuseSession: undefined,
timezone: undefined,
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
})
})
it('rejects invalid automation --day values before calling the runtime', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Weekly review',
'--trigger',
'weekly',
'--day',
'7',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day must be an integer from 0 to 6'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'day on daily preset',
args: ['--trigger', 'daily', '--day', '2'],
message: '--day can only be used with the weekly automation preset'
},
{
name: 'time on custom cron',
args: ['--trigger', '0 9 * * *', '--time', '10:30'],
message: '--time can only be used with preset automation triggers'
},
{
name: 'time on hourly preset',
args: ['--trigger', 'hourly', '--time', '10:30'],
message: '--time cannot be used with the hourly automation preset'
}
])('rejects automation schedule modifier mismatch: $name', async ({ args, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
...args,
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'create',
args: [
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--time',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
]
},
{
name: 'edit',
args: ['automations', 'edit', 'auto-1', '--trigger', 'daily', '--time', '--json']
}
])('rejects bare automation --time on $name', async ({ args }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(args, '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--time must use HH:MM format'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation edit schedule modifiers without a schedule flag', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'edit', 'auto-1', '--day', '7', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day requires --trigger or --schedule'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation create with both repo and workspace targets', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--repo',
'id:repo-1',
'--workspace',
'id:repo-1::/tmp/repo/feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --repo or --workspace, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes automation session reuse flags through create and edit', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_create', { automation: { id: 'auto-1', name: 'Daily review' } }),
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--workspace',
'current',
'--reuse-session',
'--json'
],
'/tmp/repo/feature/src'
)
await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(
2,
'automation.create',
expect.objectContaining({
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
reuseSession: true
})
)
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.update', {
id: 'auto-1',
updates: expect.objectContaining({ reuseSession: false })
})
})
it('rejects conflicting automation session reuse flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['automations', 'edit', 'auto-1', '--reuse-session', '--fresh-session', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --reuse-session or --fresh-session, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation edit with both repo and workspace targets', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'edit',
'auto-1',
'--repo',
'id:repo-1',
'--workspace',
'id:repo-1::/tmp/repo/feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --repo or --workspace, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{ flag: 'enabled', value: 'false', message: '--enabled does not take a value' },
{ flag: 'disabled', value: 'false', message: '--disabled does not take a value' }
])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--repo',
'id:repo-1',
`--${flag}`,
value,
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('resolves explicit automation create workspace active from cwd', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--workspace',
'active',
'--json'
],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
baseBranch: undefined,
timezone: undefined,
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
})
})
it('resolves explicit automation edit workspace current from cwd', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['automations', 'edit', 'auto-1', '--workspace', 'current', '--enabled', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', {
id: 'auto-1',
updates: {
name: undefined,
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: undefined,
baseBranch: undefined,
reuseSession: undefined,
timezone: undefined,
enabled: true,
missedRunGraceMinutes: undefined
}
})
})
it('passes positional automation ids to edit, remove, run, and show', async () => {
queueFixtures(
callMock,
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }),
okFixture('req_remove', { removed: true, id: 'auto-1' }),
okFixture('req_run', {
run: {
id: 'run-1',
automationId: 'auto-1',
title: 'Paused run 1',
status: 'pending',
trigger: 'manual',
scheduledFor: 1,
workspaceId: null,
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
outputSnapshot: null,
usage: null,
error: null,
startedAt: null,
dispatchedAt: null,
createdAt: 1
}
}),
okFixture('req_show', { automation: { id: 'auto-1', name: 'Paused' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['automations', 'edit', 'auto-1', '--disabled', '--json'], '/tmp/repo')
await main(['automations', 'remove', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', {
id: 'auto-1',
updates: {
name: undefined,
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: undefined,
workspaceMode: undefined,
baseBranch: undefined,
timezone: undefined,
enabled: false,
missedRunGraceMinutes: undefined
}
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' })
})
it('rejects ambiguous positional and flag automation ids before dispatch', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'show', 'auto-1', '--id', 'auto-2', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'Pass --id either positionally or as a flag, not both.'
}
})
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})