mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* feat(cli): add `orca account add` / `account list` for headless hosts The desktop "Add account" UI is disabled when the renderer drives a remote runtime (isRemoteAccountScope === kind:'environment'), so a headless server reached from a remote desktop/web client has no way to register managed Claude accounts. Add a host-local CLI path that reuses the existing capture logic: - ClaudeAccountService.addAccountFromConfigDir(): register a managed account by capturing credentials from an already-authenticated CLAUDE_CONFIG_DIR instead of spawning the interactive browser login (extracted persist/rollback helpers shared with the existing add flow) - RPC accounts.addClaudeFromConfigDir, bridged via OrcaRuntime; rejected for mobile device tokens (host-local only) - `orca account add` runs `claude login` in the user's own terminal into a temp CLAUDE_CONFIG_DIR, then registers it via the local runtime; `orca account list` lists managed accounts Switching (select) already works from a remote client; only adding was blocked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cli): support Codex in `orca account add` / `account list` Mirror the Claude headless-account CLI for Codex: - CodexAccountService.addAccountFromHome(): register a managed Codex account by importing auth.json from an already-authenticated CODEX_HOME, reusing a shared persist helper extracted from doAddAccount (no interactive login spawned here) - RPC accounts.addCodexFromHome + OrcaRuntime.addCodexAccountFromHome bridge, rejected for mobile device tokens (host-local only) - `orca account add --agent claude|codex` (default claude); `orca account list` now renders both Claude and Codex managed-account blocks Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover headless account-add capture paths (Claude + Codex) - ClaudeAccountService.addAccountFromConfigDir: registers a managed account by capturing an authenticated CLAUDE_CONFIG_DIR; rejects and rolls back when the dir has no .credentials.json - CodexAccountService.addAccountFromHome: imports auth.json from an authenticated CODEX_HOME into a managed account; rejects when auth.json is missing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review on headless account-add flows - CLI login spawn uses a shell on Windows so `.cmd` agent shims resolve without ENOENT (args are fixed literals, no injection risk) - Claude capture skips the `.credentials.json` precheck on macOS, where creds live in the Keychain and captureAuthFromConfigDir reads them - Claude add rollback is best-effort: a failed rematerialization no longer skips managed-auth cleanup or masks the original add error - Codex persist restores the prior account/selection if a post-write sync or rate-limit refresh fails, so a failure can't leave a dangling managed account - Codex sync passes the account's selection target (correct runtime for WSL) - Add JSDoc to the new public service methods and CLI functions Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): harden headless account capture * fix(cli): correct account command flag surface and interrupt cleanup - `account` commands no longer accept or advertise the browser `--page` flag; `supportsBrowserPageFlag` allow-listed them by omission, so `orca account list --page x` was silently accepted and `--help` rendered a browser-only option - account specs declare GLOBAL_FLAGS, so `--help`/`--json` render in the Options block like every other command - `--agent` on `account add` documents the account provider instead of the terminal TUI-agent meaning inherited from the shared flag table - a SIGINT/SIGTERM during the interactive login now removes the temp login dir (and restores the macOS Keychain item) before exiting 130; Node terminates without unwinding `finally`, which stranded live OAuth credentials on disk * perf(cli): stop `account list` forcing a provider usage refresh `accounts.list` awaited refreshAccountsForMobile(), which runs fetchAll({ force: true }) — bypassing both the poll throttle and the per-provider Retry-After gate — then O(N) serial per-account round trips. `orca account list` renders only emails and the active ids, so all of that work was discarded. The RPC now takes `refreshUsage` (default true, so mobile and web keep the forced lane) and the CLI opts out. Older hosts declare `params: null` and ignore the field, so a newer CLI degrades to the previous behavior rather than failing. Also documents on `account list` that `--environment` does not retarget it, matching the host-local behavior of shouldIgnoreRemoteSelection. * fix(cli): survive repeated and hangup signals during account add withInterruptCleanup latched cleanup behind a boolean, so a second signal got an already-resolved promise and its process.exit fired while the first cleanup was still inside a Keychain call (3s each) — the temp dir's OAuth credentials and the swapped macOS Keychain item both survived. Memoize the cleanup promise so every signal awaits the same run, and register with `on` instead of `once` so a second Ctrl-C cannot fall through to Node's terminate-immediately default mid-cleanup. Handle SIGHUP too. This flow exists for headless/SSH hosts, where the most likely interrupt is the connection dropping, which hangs up the login's terminal and previously ran no cleanup at all. Warn when the interrupt lands after sign-in completed: the runtime finishes the add independently of this process, so exiting 130 silently would tell the user it was cancelled when the account may exist. Reject a valueless `--agent`; the parser turns it into boolean true, which silently ran a full OAuth login for Claude when the user asked for another provider. Also lock two behaviors the refactor changed but left uncovered: a WSL Codex add must sync the WSL runtime lane rather than the default host lane, and rename the account-spec help test to describe the Options block it actually asserts rather than the usage string it never reads. * fix(build): bundle the main modules the account CLI imports electron-vite cleans out/main and emits only its declared entries, and `build:desktop` runs it after `build:cli`, so the tsc-emitted copies of `claude-accounts/keychain`, `codex-cli/command` and `win32-utils` were deleted before packaging. Both `orca account add` and `orca account list` then died at require time with "Cannot find module '../../main/claude-accounts/keychain'" — reproduced against a real `--serve` host. `agent-hooks/managed-agent-hook-controls` already carried an entry for exactly this reason; these three were missing. Adds a parity test so any future CLI import of a `src/main` module fails in CI rather than at a user's shell after packaging. * test: cover the desktop add-path behavior this PR changes Both changes ride in the persist/rollback helpers the existing GUI add flow shares with the new headless path, and neither had coverage: - Claude: rollbackAddAccount now guards forceMaterializeCurrentSelection- ForRollback, so a rejecting rematerialization no longer replaces the real add error nor skips safeRemoveManagedAuth. Asserts the original error surfaces and the throwaway auth dir is gone. - Codex: the desktop add now passes the account's selection target to syncForCurrentSelection, matching reauthenticate and select. Asserts the host target alongside the existing WSL assertion. Both fail when the corresponding change is reverted. * fix(cli): close the remaining account-add interrupt and preflight gaps The round-1 interrupt fix detached the signal handlers before running the finally-path cleanup, so the very window it was meant to protect — the two serial 3s `security` calls plus rmSync on the success/error path — was still covered only by Node's terminate-immediately default. Both review lanes reproduced it independently. Await cleanup first, detach in a nested finally, and stop a cleanup failure from replacing the error that actually explains why the add failed. Do not burn the interactive login when the runtime is unreachable. The RuntimeClient is lazily constructed and the first call was the registration RPC itself, so "Requires the Orca runtime to be running" was discovered only after the user completed a full OAuth round trip. Preflight with the now-cheap `accounts.list { refreshUsage: false }`. Reject `--environment` / `--pairing-code` on `account add`. shouldIgnoreRemoteSelection pins account commands to the local runtime, so `orca account add --environment homelab` silently registered the account on the laptop instead of the headless host it names. Survive a daemon that cannot spawn `claude`. `allowFailure` is honored in onClose but not onError, and unlike the GUI flow nothing has run `claude` in the daemon before this point — so a launchd/systemd daemon with a minimal PATH hard-failed an add the user had already signed in for, even though identity resolves fine from the config dir's oauthAccount. Also align the `--agent` help description with the global flag column. * fix(cli): reject runtime selectors on `account list` too `orca account list --environment homelab` was accepted and silently listed the LOCAL machine's accounts, because shouldIgnoreRemoteSelection pins account commands to the local runtime. Documenting that in --help does not reach someone who already typed the flag, and answering with the wrong host's accounts is the specific wrong answer they would act on. `account add` already errors; this makes the new command group internally consistent. The other groups in shouldIgnoreRemoteSelection keep their existing silent-ignore behavior — changing those is not this PR's job. * test: harden account-add signal tests and cover cleanup failure - Identify the handler under test by set difference instead of `process.listeners(sig).at(-1)`. Vitest installs its own once-wrapped SIGINT teardown, so the positional lookup could grab the wrong listener; the helper also asserts exactly one new listener was added. - Mock rmSync while keeping the real implementation by default, so the temp-dir assertions elsewhere stay honest. - Cover that a cleanup failure in the `finally` does not replace the error explaining why the add failed. Fails when that guard is removed. Completes the review loop's final round; the loop died on an API error before it could commit this, and its `import()` type annotation would have failed oxlint. * fix(cli): harden interactive account add * test(cli): make account cancellation coverage portable * fix(cli): preserve merged skills runtime modules --------- Co-authored-by: Dominik <marketing@gavaplast.sk> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
4947 lines
145 KiB
TypeScript
4947 lines
145 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 'node:path'
|
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
callMock,
|
|
runtimeClientConstructorMock,
|
|
serveOrcaAppMock,
|
|
getDefaultUserDataPathMock,
|
|
addEnvironmentFromPairingCodeMock,
|
|
listEnvironmentsMock,
|
|
spawnMock
|
|
} = vi.hoisted(() => ({
|
|
callMock: vi.fn(),
|
|
runtimeClientConstructorMock: vi.fn(),
|
|
serveOrcaAppMock: vi.fn(),
|
|
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
|
addEnvironmentFromPairingCodeMock: vi.fn(),
|
|
listEnvironmentsMock: vi.fn(),
|
|
spawnMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./runtime-client', async () => {
|
|
// Why: re-export the REAL error classes rather than redefining them. format.ts
|
|
// narrows with `instanceof` against ./runtime/types, so a look-alike class
|
|
// here would make every CLI error fall through to the generic `runtime_error`
|
|
// shape — mirroring the barrel keeps the mock faithful to production.
|
|
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
|
|
|
|
class RuntimeClient {
|
|
readonly isRemote: boolean
|
|
call = callMock
|
|
getCliStatus = vi.fn()
|
|
openOrca = vi.fn()
|
|
|
|
constructor(
|
|
_userDataPath?: string,
|
|
_requestTimeoutMs?: number,
|
|
remotePairingCode?: string | null,
|
|
environmentSelector?: string | null
|
|
) {
|
|
runtimeClientConstructorMock()
|
|
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)
|
|
}
|
|
}
|
|
|
|
return {
|
|
RuntimeClient,
|
|
RuntimeClientError,
|
|
RuntimeRpcFailureError,
|
|
serveOrcaApp: serveOrcaAppMock,
|
|
getDefaultUserDataPath: getDefaultUserDataPathMock
|
|
}
|
|
})
|
|
|
|
vi.mock('./runtime/environments', () => ({
|
|
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
|
listEnvironments: listEnvironmentsMock,
|
|
removeEnvironment: vi.fn(),
|
|
resolveEnvironment: vi.fn()
|
|
}))
|
|
|
|
vi.mock('child_process', async () => {
|
|
const { EventEmitter } = await import('node:events')
|
|
return {
|
|
spawn: spawnMock.mockImplementation(() => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stdin: {
|
|
write: vi.fn(),
|
|
end: vi.fn()
|
|
},
|
|
kill: vi.fn()
|
|
})
|
|
process.nextTick(() => {
|
|
child.emit('exit', 0, null)
|
|
child.emit('close', 0, null)
|
|
})
|
|
return child
|
|
})
|
|
}
|
|
})
|
|
|
|
import {
|
|
buildCurrentWorktreeSelector,
|
|
COMMAND_SPECS,
|
|
main,
|
|
normalizeWorktreeSelector
|
|
} from './index'
|
|
import { GLOBAL_FLAGS, specPaths } from './args'
|
|
import { RuntimeRpcFailureError } from './runtime-client'
|
|
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
|
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
|
|
|
|
describe('COMMAND_SPECS collision check', () => {
|
|
it('has no duplicate command or alias paths', () => {
|
|
// Why: first-match resolution would silently shadow duplicate aliases.
|
|
const seen = new Set<string>()
|
|
for (const spec of COMMAND_SPECS) {
|
|
for (const path of specPaths(spec)) {
|
|
const key = path.join(' ')
|
|
expect(seen.has(key), `Duplicate command/alias 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('command aliases dispatch to the canonical handler', () => {
|
|
let logSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
callMock.mockReset()
|
|
// Why: restore console.log so a downstream describe's vi.spyOn starts from a
|
|
// clean spy — otherwise this block's --json output leaks into its calls[0].
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it('runs `worktree remove` as the canonical `worktree rm` (the incident)', async () => {
|
|
queueFixtures(callMock, okFixture('req', { removed: true }))
|
|
|
|
await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'worktree.rm',
|
|
expect.objectContaining({ worktree: 'id:wt-1', force: true })
|
|
)
|
|
})
|
|
|
|
it('runs `worktree delete` as the canonical `worktree rm`', async () => {
|
|
queueFixtures(callMock, okFixture('req', { removed: true }))
|
|
|
|
await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'worktree.rm',
|
|
expect.objectContaining({ worktree: 'id:wt-1' })
|
|
)
|
|
})
|
|
|
|
it('still runs `terminal focus` after the handler de-duplication', async () => {
|
|
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
|
|
|
|
await main(['terminal', 'focus', '--terminal', 'term_abc', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'terminal.focus',
|
|
expect.objectContaining({ navigation: 'host' })
|
|
)
|
|
})
|
|
|
|
it('serves `agent-context --json` without contacting the runtime', async () => {
|
|
runtimeClientConstructorMock.mockClear()
|
|
await main(['agent-context', '--json'], '/tmp/repo')
|
|
|
|
// Why: pure local read — proves the SSH/offline property (no RPC).
|
|
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
const schema = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]))
|
|
expect(schema.schemaVersion).toBe(1)
|
|
const rm = schema.commands.find(
|
|
(command: { command: string }) => command.command === 'worktree rm'
|
|
)
|
|
expect(rm.aliases).toContainEqual(['worktree', 'remove'])
|
|
})
|
|
|
|
it('keeps `agent-context` local when remote environment variables are set', async () => {
|
|
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
|
|
vi.stubEnv('ORCA_ENVIRONMENT', 'stale-environment')
|
|
try {
|
|
await main(['agent-context', '--json'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).not.toBe(1)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.unstubAllEnvs()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('unknown command surfaces a suggestion', () => {
|
|
let errorSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
errorSpy.mockRestore()
|
|
process.exitCode = 0
|
|
})
|
|
|
|
it('prints did-you-mean for a near-miss command and exits non-zero', async () => {
|
|
await main(['worktree', 'remov'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('Unknown command: worktree remov')
|
|
expect(stderr).toContain('orca worktree')
|
|
})
|
|
|
|
it('reports a mistyped pre-command flag without swallowing the command', async () => {
|
|
await main(['--jso', 'worktree', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('Unknown flag --jso for command: worktree list')
|
|
expect(stderr).toContain('--json')
|
|
})
|
|
|
|
it('reports a pre-command flag that belongs to another command', async () => {
|
|
await main(['--workspace', 'worktree', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('Unknown flag --workspace for command: worktree list')
|
|
})
|
|
|
|
it('reports a pre-command typo when a global flag splits the command path', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--jso', 'worktree', '--json', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain(
|
|
'Unknown flag --jso for command: worktree list'
|
|
)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it.each(['environment', 'pairing-code'])(
|
|
'rejects --%s without a selector before runtime construction',
|
|
async (flag) => {
|
|
runtimeClientConstructorMock.mockClear()
|
|
|
|
await main([`--${flag}`, 'worktree', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain(`Flag --${flag} requires a value.`)
|
|
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('unknown help command surfaces a suggestion', () => {
|
|
it.each([
|
|
['help prefix', ['help', 'worktree', 'remov']],
|
|
['help flag', ['worktree', 'remov', '--help']]
|
|
])('prints did-you-mean for the %s form', async (_label, argv) => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(argv, '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain('Did you mean: orca worktree')
|
|
logSpy.mockRestore()
|
|
process.exitCode = 0
|
|
})
|
|
})
|
|
|
|
describe('orca root help', () => {
|
|
it('advertises machine-readable agent discovery', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main([], '/tmp/repo')
|
|
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain('agent-context')
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it('advertises host-local account management', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main([], '/tmp/repo')
|
|
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain(
|
|
'account add Add a managed Claude or Codex account on this Orca host'
|
|
)
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain(
|
|
'account list List managed Claude and Codex accounts on this Orca host'
|
|
)
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it('advertises computer-use capabilities discovery', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'computer capabilities Show computer-use provider capabilities'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'computer permissions Show or open computer-use permission setup'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'computer press-key Press a single key such as Return or Escape'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-existing-folder Make a project available on a host by importing an existing folder'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-create Create independent project host setup metadata'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-update Update project host setup metadata'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-delete Remove a project host setup'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain('Agent Sessions And Worktrees:')
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'`worktree create --agent` creates a new checkout with an agent.'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orca terminal create --worktree active --command "codex"'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-start Start a supervised worker locally or on a connected Orca server'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration ask Ask the coordinator a blocking question'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-abandon Fence an uncertain worker without claiming it stopped'
|
|
)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('progressively discloses Linear commands', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
const rootHelp = String(logSpy.mock.calls[0][0])
|
|
expect(rootHelp).toContain('Linear:')
|
|
expect(rootHelp).toContain('linear Read Linear ticket context for agents')
|
|
expect(rootHelp).not.toContain('linear issue')
|
|
expect(rootHelp).not.toContain('linear search')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', '--help'], '/tmp/repo')
|
|
|
|
const groupHelp = String(logSpy.mock.calls[0][0])
|
|
expect(groupHelp).toContain('orca linear')
|
|
expect(groupHelp).toContain('issue')
|
|
expect(groupHelp).toContain('search')
|
|
expect(groupHelp).not.toContain('--comments')
|
|
expect(groupHelp).not.toContain('--attachments')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', 'issue', '--help'], '/tmp/repo')
|
|
|
|
const issueHelp = String(logSpy.mock.calls[0][0])
|
|
expect(issueHelp).toContain('orca linear issue [<id>]')
|
|
expect(issueHelp).toContain('--comments Include threaded Linear comments')
|
|
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
|
|
expect(issueHelp).toContain('--activity Include issue field-change history')
|
|
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
|
|
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', 'search', '--help'], '/tmp/repo')
|
|
|
|
const searchHelp = String(logSpy.mock.calls[0][0])
|
|
expect(searchHelp).toContain('orca linear search <query>')
|
|
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
|
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', 'list-issues', '--help'], '/tmp/repo')
|
|
|
|
const listIssuesHelp = String(logSpy.mock.calls[0][0])
|
|
expect(listIssuesHelp).toContain(
|
|
'--cursor <cursor> Opaque cursor returned by a previous list-issues page'
|
|
)
|
|
expect(listIssuesHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
|
expect(listIssuesHelp).not.toContain('Line cursor from a previous read')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('describes worker-read cursors as opaque', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['orchestration', 'worker-read', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain(
|
|
'--cursor <cursor> Opaque cursor returned by a previous worker-read page'
|
|
)
|
|
expect(help).not.toContain('Line cursor from a previous read')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('advertises Linear issue linking on worktree create and set help', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['worktree', 'create', '--help'], '/tmp/repo')
|
|
|
|
expect(String(logSpy.mock.calls[0][0])).toContain('--linear-issue <identifier-or-url>')
|
|
|
|
logSpy.mockClear()
|
|
await main(['worktree', 'set', '--help'], '/tmp/repo')
|
|
|
|
const setHelp = String(logSpy.mock.calls[0][0])
|
|
expect(setHelp).toContain('--linear-issue <identifier-or-url|null>')
|
|
expect(setHelp).toContain('--linear-issue <id|url|null> Linked Linear issue identifier or URL')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('advertises explicit orchestration task display labels', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['orchestration', 'task-create', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain('[--task-title <text>] [--display-name <text>]')
|
|
expect(help).toContain('--task-title <text> Concise title for the orchestration task')
|
|
expect(help).toContain('--display-name <text> UI label shown for dispatched worker rows')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('hides removed parent-workspace help and scopes create parent selectors', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
const rootHelp = String(logSpy.mock.calls[0][0])
|
|
expect(rootHelp).not.toContain('--parent-workspace')
|
|
expect(rootHelp).toContain('[--parent-worktree <selector>] [--no-parent]')
|
|
|
|
logSpy.mockClear()
|
|
await main(['worktree', 'create', '--help'], '/tmp/repo')
|
|
|
|
const createHelp = String(logSpy.mock.calls[0][0])
|
|
expect(createHelp).not.toContain('--parent-workspace')
|
|
expect(createHelp).not.toContain('checkout/workspace')
|
|
expect(createHelp).not.toContain('caller workspace')
|
|
expect(createHelp).not.toContain('current workspace')
|
|
expect(createHelp).not.toContain('active Orca workspace')
|
|
expect(createHelp).not.toContain('folderWorkspaceId')
|
|
expect(createHelp).toContain('folder:<id>')
|
|
expect(createHelp).toContain('folder:<folderId>')
|
|
expect(createHelp).toContain('worktree:<worktreeId>')
|
|
expect(createHelp).toContain(
|
|
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base'
|
|
)
|
|
|
|
logSpy.mockClear()
|
|
await main(['worktree', 'set', '--help'], '/tmp/repo')
|
|
|
|
const setHelp = String(logSpy.mock.calls[0][0])
|
|
expect(setHelp).not.toContain('--parent-workspace')
|
|
expect(setHelp).not.toContain('folder:<id>')
|
|
expect(setHelp).not.toContain('worktree:<id>')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('distinguishes new worktrees from fresh agent terminals in command help', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['worktree', 'create', '--help'], '/tmp/repo')
|
|
|
|
expect(String(logSpy.mock.calls[0][0])).toContain('This creates a new checkout.')
|
|
expect(String(logSpy.mock.calls[0][0])).toContain(
|
|
'orca terminal create --worktree active --command "codex"'
|
|
)
|
|
|
|
logSpy.mockClear()
|
|
await main(['terminal', 'create', '--help'], '/tmp/repo')
|
|
|
|
const terminalHelp = String(logSpy.mock.calls[0][0])
|
|
expect(terminalHelp).toContain('Use this, not worktree create')
|
|
expect(terminalHelp).toContain(
|
|
'orca terminal create --worktree active --command "codex" --json'
|
|
)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('orca cli worktree awareness', () => {
|
|
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
|
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
|
|
const originalDevCliInvocation = process.env.ORCA_DEV_CLI_INVOCATION
|
|
const originalPairingCode = process.env.ORCA_PAIRING_CODE
|
|
const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
|
|
const originalEnvironment = process.env.ORCA_ENVIRONMENT
|
|
const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID
|
|
const originalWorktreeId = process.env.ORCA_WORKTREE_ID
|
|
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
delete process.env.ORCA_TERMINAL_HANDLE
|
|
delete process.env.ORCA_USER_DATA_PATH
|
|
delete process.env.ORCA_DEV_CLI_INVOCATION
|
|
delete process.env.ORCA_WORKSPACE_ID
|
|
delete process.env.ORCA_WORKTREE_ID
|
|
// Isolate the pane key so claude-teams tests that set it don't leak a
|
|
// senderPaneKey into later orchestration.send assertions.
|
|
delete process.env.ORCA_PANE_KEY
|
|
serveOrcaAppMock.mockReset()
|
|
getDefaultUserDataPathMock.mockClear()
|
|
addEnvironmentFromPairingCodeMock.mockReset()
|
|
listEnvironmentsMock.mockReset()
|
|
spawnMock.mockClear()
|
|
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 (originalDevCliInvocation === undefined) {
|
|
delete process.env.ORCA_DEV_CLI_INVOCATION
|
|
} else {
|
|
process.env.ORCA_DEV_CLI_INVOCATION = originalDevCliInvocation
|
|
}
|
|
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
|
|
}
|
|
if (originalWorkspaceId === undefined) {
|
|
delete process.env.ORCA_WORKSPACE_ID
|
|
} else {
|
|
process.env.ORCA_WORKSPACE_ID = originalWorkspaceId
|
|
}
|
|
if (originalWorktreeId === undefined) {
|
|
delete process.env.ORCA_WORKTREE_ID
|
|
} else {
|
|
process.env.ORCA_WORKTREE_ID = originalWorktreeId
|
|
}
|
|
})
|
|
|
|
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('resolves the invocation cwd from ORCA_CLI_CWD when no cwd is passed', async () => {
|
|
// Why: the SSH relay bridge runs the CLI on the Orca host with the remote
|
|
// shell's cwd carried in ORCA_CLI_CWD (#7716); cwd-based selectors must
|
|
// resolve against it, not the host process cwd.
|
|
process.env.ORCA_CLI_CWD = '/tmp/repo/feature/src'
|
|
try {
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([
|
|
buildWorktree('/tmp/repo', 'main'),
|
|
buildWorktree('/tmp/repo/feature', 'feature/foo')
|
|
]),
|
|
okFixture('req_1', {
|
|
worktree: {
|
|
id: 'repo::/tmp/repo/feature',
|
|
branch: 'feature/foo',
|
|
path: '/tmp/repo/feature'
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['worktree', 'current', '--json'])
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
|
|
worktree: 'id:repo::/tmp/repo/feature'
|
|
})
|
|
} finally {
|
|
delete process.env.ORCA_CLI_CWD
|
|
}
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'prepares and starts Claude Agent Teams in the current Orca terminal',
|
|
async () => {
|
|
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_agent_teams_prepare', {
|
|
launch: {
|
|
env: {
|
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
|
|
TMUX_PANE: '%1',
|
|
PATH: '/tmp/orca-shim:/usr/bin'
|
|
}
|
|
}
|
|
})
|
|
)
|
|
|
|
await main(['claude-teams'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith('agentTeams.prepareLaunch', {
|
|
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
|
|
env: expect.objectContaining({
|
|
ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111'
|
|
})
|
|
})
|
|
expect(spawnMock).toHaveBeenCalledWith('claude', ['--teammate-mode', 'auto'], {
|
|
stdio: 'inherit',
|
|
env: expect.objectContaining({
|
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
TMUX_PANE: '%1'
|
|
})
|
|
})
|
|
}
|
|
)
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'passes Claude Agent Teams arguments through to Claude Code',
|
|
async () => {
|
|
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_agent_teams_prepare', {
|
|
launch: {
|
|
env: {
|
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
|
|
TMUX_PANE: '%1',
|
|
PATH: '/tmp/orca-shim:/usr/bin'
|
|
}
|
|
}
|
|
})
|
|
)
|
|
|
|
await main(
|
|
['claude-teams', '--resume', 'session-1', '--model', 'sonnet', 'review this'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'claude',
|
|
['--teammate-mode', 'auto', '--resume', 'session-1', '--model', 'sonnet', 'review this'],
|
|
{
|
|
stdio: 'inherit',
|
|
env: expect.objectContaining({
|
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
TMUX_PANE: '%1'
|
|
})
|
|
}
|
|
)
|
|
}
|
|
)
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'does not duplicate an explicit Claude teammate mode',
|
|
async () => {
|
|
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_agent_teams_prepare', {
|
|
launch: {
|
|
env: {
|
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
|
|
TMUX_PANE: '%1',
|
|
PATH: '/tmp/orca-shim:/usr/bin'
|
|
}
|
|
}
|
|
})
|
|
)
|
|
|
|
await main(['claude-teams', '--teammate-mode', 'in-process'], '/tmp/repo')
|
|
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'claude',
|
|
['--teammate-mode', 'in-process'],
|
|
expect.objectContaining({ stdio: 'inherit' })
|
|
)
|
|
}
|
|
)
|
|
|
|
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([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'id:<repo-id>::<path>'
|
|
)
|
|
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 Linear URL metadata through worktree.set', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_set_linear', {
|
|
worktree: {
|
|
...buildWorktree('/tmp/repo/child', 'feature/child'),
|
|
linkedLinearIssue: 'STA-335',
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: 'stably'
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'set',
|
|
'--worktree',
|
|
'id:repo::/tmp/repo/child',
|
|
'--linear-issue',
|
|
'https://linear.app/stably/issue/STA-335/test-issue',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('worktree.set', {
|
|
worktree: 'id:repo::/tmp/repo/child',
|
|
displayName: undefined,
|
|
linkedIssue: undefined,
|
|
linkedLinearIssue: 'STA-335',
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: 'stably',
|
|
comment: undefined,
|
|
workspaceStatus: undefined,
|
|
parentWorktree: undefined,
|
|
noParent: false
|
|
})
|
|
})
|
|
|
|
it('clears all Linear metadata through worktree.set', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_clear_linear', {
|
|
worktree: {
|
|
...buildWorktree('/tmp/repo/child', 'feature/child'),
|
|
linkedLinearIssue: null,
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: null
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'set',
|
|
'--worktree',
|
|
'id:repo::/tmp/repo/child',
|
|
'--linear-issue',
|
|
'null',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('worktree.set', {
|
|
worktree: 'id:repo::/tmp/repo/child',
|
|
displayName: undefined,
|
|
linkedIssue: undefined,
|
|
linkedLinearIssue: null,
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: null,
|
|
comment: undefined,
|
|
workspaceStatus: undefined,
|
|
parentWorktree: undefined,
|
|
noParent: false
|
|
})
|
|
})
|
|
|
|
it('rejects invalid Linear issue values on worktree.set before RPC', 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',
|
|
'--linear-issue',
|
|
'not-a-linear-link',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Pass a Linear issue identifier like STA-335'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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 Linear issue metadata through worktree.create', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
|
okFixture('req_create_linear', {
|
|
worktree: {
|
|
...buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1'),
|
|
linkedLinearIssue: 'STA-335',
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: 'stably'
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo-1',
|
|
'--name',
|
|
'feature',
|
|
'--linear-issue',
|
|
'https://linear.app/stably/issue/STA-335/test-issue',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
|
repo: 'id:repo-1',
|
|
name: 'feature',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
linkedLinearIssue: 'STA-335',
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: 'stably',
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: false,
|
|
parentWorktree: undefined,
|
|
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('normalizes bare Linear identifiers through worktree.create', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_create_linear_id', {
|
|
worktree: {
|
|
...buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1'),
|
|
linkedLinearIssue: 'STA-335'
|
|
},
|
|
lineage: null,
|
|
warnings: []
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo-1',
|
|
'--name',
|
|
'feature',
|
|
'--linear-issue',
|
|
'sta-335',
|
|
'--no-parent',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
|
repo: 'id:repo-1',
|
|
name: 'feature',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
linkedLinearIssue: 'STA-335',
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: null,
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: false,
|
|
parentWorktree: undefined,
|
|
noParent: true,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('rejects null Linear issue values on worktree.create before RPC', 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',
|
|
'feature',
|
|
'--linear-issue',
|
|
'null',
|
|
'--no-parent',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Omit --linear-issue on create'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects invalid Linear issue values on worktree.create before RPC', 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',
|
|
'feature',
|
|
'--linear-issue',
|
|
'not-a-linear-link',
|
|
'--no-parent',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Pass a Linear issue identifier like STA-335'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects missing Linear issue values on worktree.create before RPC', 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',
|
|
'feature',
|
|
'--linear-issue',
|
|
'--no-parent',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Missing value for --linear-issue'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('resolves project and host flags to the matching repo for worktree.create', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setups', {
|
|
setups: [
|
|
{
|
|
id: 'setup-local',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'local',
|
|
repoId: 'repo-local',
|
|
path: '/tmp/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
{
|
|
id: 'setup-gpu',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: 'repo-gpu',
|
|
path: '/srv/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
}),
|
|
okFixture('req_create', {
|
|
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
|
|
lineage: null,
|
|
warnings: []
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--project',
|
|
'github:stablyai/orca',
|
|
'--host',
|
|
'runtime:gpu',
|
|
'--name',
|
|
'feature',
|
|
'--no-parent',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
|
repo: 'id:repo-gpu',
|
|
name: 'feature',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: false,
|
|
parentWorktree: undefined,
|
|
noParent: true,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('resolves project-host-setup directly for worktree.create', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setups', {
|
|
setups: [
|
|
{
|
|
id: 'setup-gpu',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: 'repo-gpu',
|
|
path: '/srv/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
}),
|
|
okFixture('req_create', {
|
|
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
|
|
lineage: null,
|
|
warnings: []
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--project-host-setup',
|
|
'setup-gpu',
|
|
'--name',
|
|
'feature',
|
|
'--no-parent',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.create',
|
|
expect.objectContaining({ repo: 'id:repo-gpu' })
|
|
)
|
|
})
|
|
|
|
it('rejects mixing repo and project target flags 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-local',
|
|
'--project',
|
|
'github:stablyai/orca',
|
|
'--name',
|
|
'feature',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Choose either --repo or project target flags, not both.'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('routes traditional parent-worktree selectors through parentWorktree', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_create', {
|
|
worktree: {
|
|
...buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
|
|
parentWorktreeId: 'repo-1::/tmp/repo/parent'
|
|
},
|
|
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',
|
|
'branch:feature/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: 'branch:feature/parent',
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('routes workspace-key parent-worktree selectors through parentWorkspace', async () => {
|
|
const cases = [
|
|
{ selector: 'folder:folder-1', parentWorkspace: 'folder:folder-1' },
|
|
{
|
|
selector: 'worktree:repo-1::/tmp/repo/parent',
|
|
parentWorkspace: 'worktree:repo-1::/tmp/repo/parent'
|
|
},
|
|
{ selector: 'id:folder:folder-1', parentWorkspace: 'folder:folder-1' },
|
|
{
|
|
selector: 'id:worktree:repo-1::/tmp/repo/parent',
|
|
parentWorkspace: 'worktree:repo-1::/tmp/repo/parent'
|
|
}
|
|
]
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
for (const testCase of cases) {
|
|
callMock.mockReset()
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_create', {
|
|
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
|
|
lineage: null,
|
|
workspaceLineage: {
|
|
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
|
|
childInstanceId: 'child-instance',
|
|
parentWorkspaceKey: testCase.parentWorkspace,
|
|
parentInstanceId: null,
|
|
origin: 'cli',
|
|
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
|
createdAt: 1
|
|
},
|
|
warnings: []
|
|
})
|
|
)
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo-1',
|
|
'--name',
|
|
'child',
|
|
'--parent-worktree',
|
|
testCase.selector,
|
|
'--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,
|
|
parentWorkspace: testCase.parentWorkspace,
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
}
|
|
})
|
|
|
|
it('passes folder workspace environment lineage through worktree.create', async () => {
|
|
process.env.ORCA_WORKSPACE_ID = 'folder:folder-1'
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
|
okFixture('req_create', {
|
|
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
|
|
lineage: null,
|
|
workspaceLineage: {
|
|
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
|
|
childInstanceId: 'child-instance',
|
|
parentWorkspaceKey: 'folder:folder-1',
|
|
parentInstanceId: null,
|
|
origin: 'cli',
|
|
capture: { source: 'env-workspace', confidence: 'inferred' },
|
|
createdAt: 1
|
|
},
|
|
warnings: []
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
|
repo: 'id:repo-1',
|
|
name: 'child',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: false,
|
|
parentWorktree: undefined,
|
|
envParentWorkspace: 'folder:folder-1',
|
|
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
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,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('routes active/current folder workspace parent selectors through parentWorkspace on create', async () => {
|
|
const folderWorkspace = {
|
|
...buildWorktree('/tmp/folder', '', '', 'folder-workspace:group-1'),
|
|
id: 'folder:folder-1',
|
|
repoId: 'folder-workspace:group-1',
|
|
displayName: 'Folder'
|
|
}
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
for (const parentSelector of ['current', 'active']) {
|
|
callMock.mockReset()
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([folderWorkspace]),
|
|
okFixture('req_create', {
|
|
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
|
|
lineage: null,
|
|
workspaceLineage: {
|
|
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
|
|
childInstanceId: 'child-instance',
|
|
parentWorkspaceKey: 'folder:folder-1',
|
|
parentInstanceId: null,
|
|
origin: 'cli',
|
|
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
|
createdAt: 1
|
|
},
|
|
warnings: []
|
|
})
|
|
)
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo-1',
|
|
'--name',
|
|
'child',
|
|
'--parent-worktree',
|
|
parentSelector,
|
|
'--json'
|
|
],
|
|
'/tmp/folder/src'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
|
repo: 'id:repo-1',
|
|
name: 'child',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: false,
|
|
parentWorktree: undefined,
|
|
parentWorkspace: 'folder:folder-1',
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
}
|
|
})
|
|
|
|
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 one parent selector or --no-parent.'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects removed parent-workspace on worktree.create', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
const outputModes = [[], ['--json']]
|
|
|
|
for (const outputArgs of outputModes) {
|
|
logSpy.mockClear()
|
|
errSpy.mockClear()
|
|
process.exitCode = priorExitCode
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo-1',
|
|
'--name',
|
|
'child',
|
|
'--parent-workspace',
|
|
'folder:folder-1',
|
|
...outputArgs
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
const output = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
|
|
expect(output).toContain('Unknown flag --parent-workspace for command: worktree create')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
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('reports runtime parent selector failures without hidden flag guidance', async () => {
|
|
callMock.mockRejectedValueOnce(
|
|
new RuntimeRpcFailureError({
|
|
id: 'req_create',
|
|
ok: false,
|
|
error: {
|
|
code: 'LINEAGE_PARENT_NOT_FOUND',
|
|
message: 'Parent selector was not found.',
|
|
data: {
|
|
nextSteps: [
|
|
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<worktreeId>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
|
|
'Retry with --no-parent to create without lineage.'
|
|
]
|
|
}
|
|
},
|
|
_meta: { runtimeId: 'runtime-1' }
|
|
})
|
|
)
|
|
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',
|
|
'folder:missing',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
const output = String(logSpy.mock.calls[0][0])
|
|
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
|
repo: 'id:repo-1',
|
|
name: 'child',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: false,
|
|
parentWorktree: undefined,
|
|
parentWorkspace: 'folder:missing',
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
expect(output).toContain('"ok": false')
|
|
expect(output).toContain('Parent selector was not found.')
|
|
expect(output).toContain('--parent-worktree selector')
|
|
expect(output).not.toContain('--parent-workspace')
|
|
expect(errSpy).not.toHaveBeenCalled()
|
|
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,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
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',
|
|
cliProvenanceRequest: { callerTerminalHandle: 'term_parent' }
|
|
})
|
|
})
|
|
|
|
it('marks every worktree.create as CLI-created even from an external shell', async () => {
|
|
// Why: the sidebar badge/filter must catch hand-typed creates too, so the
|
|
// provenance request is sent with no terminal handle rather than omitted.
|
|
delete process.env.ORCA_TERMINAL_HANDLE
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_create_external', {
|
|
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'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'worktree.create',
|
|
expect.objectContaining({ cliProvenanceRequest: {} })
|
|
)
|
|
})
|
|
|
|
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,
|
|
recipeJson: false,
|
|
projectRoot: null
|
|
})
|
|
})
|
|
|
|
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,
|
|
recipeJson: false,
|
|
projectRoot: null
|
|
})
|
|
})
|
|
|
|
it('starts a recipe JSON headless server for VM recipes', async () => {
|
|
serveOrcaAppMock.mockResolvedValue(0)
|
|
|
|
await main(
|
|
[
|
|
'serve',
|
|
'--pairing-address',
|
|
'wss://sandbox.example.com',
|
|
'--project-root',
|
|
'/workspace/repo',
|
|
'--recipe-json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(serveOrcaAppMock).toHaveBeenCalledWith({
|
|
json: false,
|
|
port: null,
|
|
pairingAddress: 'wss://sandbox.example.com',
|
|
noPairing: false,
|
|
mobilePairing: false,
|
|
recipeJson: true,
|
|
projectRoot: '/workspace/repo'
|
|
})
|
|
})
|
|
|
|
it('runs vm recipe doctor locally without contacting the app runtime', async () => {
|
|
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-'))
|
|
try {
|
|
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
|
|
const startScript = path.join(repoPath, 'scripts', 'orca-vm', 'start.sh')
|
|
const cleanupScript = path.join(repoPath, 'scripts', 'orca-vm', 'cleanup.sh')
|
|
writeFileSync(startScript, '#!/bin/sh\n')
|
|
writeFileSync(cleanupScript, '#!/bin/sh\n')
|
|
chmodSync(startScript, 0o755)
|
|
chmodSync(cleanupScript, 0o755)
|
|
writeFileSync(
|
|
path.join(repoPath, 'orca.yaml'),
|
|
[
|
|
'environmentRecipes:',
|
|
' - id: cloud-sandbox',
|
|
' name: Cloud Sandbox',
|
|
' create: ./scripts/orca-vm/start.sh',
|
|
' destroy: ./scripts/orca-vm/cleanup.sh'
|
|
].join('\n')
|
|
)
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['vm', 'recipe', 'doctor', 'cloud-sandbox', '--repo-path', repoPath, '--json'])
|
|
|
|
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
|
|
ok: boolean
|
|
checks: { id: string; status: string }[]
|
|
}
|
|
if (!output.ok) {
|
|
throw new Error(JSON.stringify(output))
|
|
}
|
|
expect(output.ok).toBe(true)
|
|
expect(output.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: 'orca_yaml.parse', status: 'pass' }),
|
|
expect.objectContaining({ id: 'recipe.exists', status: 'pass' }),
|
|
expect.objectContaining({ id: 'recipe.create', status: 'pass' }),
|
|
expect.objectContaining({ id: 'recipe.destroy', status: 'pass' })
|
|
])
|
|
)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
rmSync(repoPath, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('warns when vm recipe doctor finds no cleanup hook', async () => {
|
|
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-'))
|
|
try {
|
|
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
|
|
writeFileSync(path.join(repoPath, 'scripts', 'orca-vm', 'start.sh'), '#!/bin/sh\n')
|
|
writeFileSync(
|
|
path.join(repoPath, 'orca.yaml'),
|
|
[
|
|
'environmentRecipes:',
|
|
' - id: manual-sandbox',
|
|
' name: Manual Sandbox',
|
|
' create: ./scripts/orca-vm/start.sh'
|
|
].join('\n')
|
|
)
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['vm', 'recipe', 'doctor', 'manual-sandbox', '--repo-path', repoPath, '--json'])
|
|
|
|
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
|
|
ok: boolean
|
|
checks: { id: string; status: string; remediation?: string }[]
|
|
}
|
|
if (!output.ok) {
|
|
throw new Error(JSON.stringify(output))
|
|
}
|
|
expect(output.ok).toBe(true)
|
|
expect(output.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'recipe.destroy',
|
|
status: 'warn',
|
|
remediation: 'Add destroy or explicitly set destroy: none.'
|
|
})
|
|
)
|
|
} finally {
|
|
rmSync(repoPath, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('runs vm recipe doctor provision mode and invokes cleanup', async () => {
|
|
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-provision-'))
|
|
const pairingCode = encodePairingOffer({
|
|
v: PAIRING_OFFER_VERSION,
|
|
endpoint: 'ws://sandbox.example.com:6767',
|
|
deviceToken: 'token',
|
|
publicKeyB64: 'public-key'
|
|
})
|
|
try {
|
|
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
|
|
writeFileSync(
|
|
path.join(repoPath, 'scripts', 'orca-vm', 'start.js'),
|
|
[
|
|
'console.log(JSON.stringify({',
|
|
' schemaVersion: 1,',
|
|
` pairingCode: ${JSON.stringify(pairingCode)},`,
|
|
" projectRoot: '/workspace/repo'",
|
|
'}))'
|
|
].join('\n')
|
|
)
|
|
writeFileSync(
|
|
path.join(repoPath, 'scripts', 'orca-vm', 'cleanup.js'),
|
|
[
|
|
"const fs = require('fs')",
|
|
"const input = fs.readFileSync(0, 'utf8')",
|
|
'const payload = JSON.parse(input)',
|
|
"fs.writeFileSync('cleanup-ran.json', JSON.stringify(payload))"
|
|
].join('\n')
|
|
)
|
|
writeFileSync(
|
|
path.join(repoPath, 'orca.yaml'),
|
|
[
|
|
'environmentRecipes:',
|
|
' - id: cloud-sandbox',
|
|
' name: Cloud Sandbox',
|
|
` create: ${JSON.stringify(`${process.execPath} ./scripts/orca-vm/start.js`)}`,
|
|
` destroy: ${JSON.stringify(`${process.execPath} ./scripts/orca-vm/cleanup.js`)}`
|
|
].join('\n')
|
|
)
|
|
const { EventEmitter } = await import('node:events')
|
|
const startChild = Object.assign(new EventEmitter(), {
|
|
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stdin: { write: vi.fn(), end: vi.fn() },
|
|
kill: vi.fn()
|
|
})
|
|
const cleanupChild = Object.assign(new EventEmitter(), {
|
|
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stdin: { write: vi.fn(), end: vi.fn() },
|
|
kill: vi.fn()
|
|
})
|
|
spawnMock
|
|
.mockImplementationOnce(() => {
|
|
process.nextTick(() => {
|
|
startChild.stdout.emit(
|
|
'data',
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
pairingCode,
|
|
projectRoot: '/workspace/repo'
|
|
})
|
|
)
|
|
startChild.emit('exit', 0, null)
|
|
startChild.emit('close', 0, null)
|
|
})
|
|
return startChild
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
process.nextTick(() => {
|
|
cleanupChild.emit('exit', 0, null)
|
|
cleanupChild.emit('close', 0, null)
|
|
})
|
|
return cleanupChild
|
|
})
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main([
|
|
'vm',
|
|
'recipe',
|
|
'doctor',
|
|
'cloud-sandbox',
|
|
'--repo-path',
|
|
repoPath,
|
|
'--provision',
|
|
'--json'
|
|
])
|
|
|
|
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
|
|
ok: boolean
|
|
checks: { id: string; status: string }[]
|
|
provisionTranscript?: {
|
|
provision: { exitCode: number | null; stdout: string; stderr: string }
|
|
destroy?: { exitCode: number | null; stdout: string; stderr: string }
|
|
}
|
|
}
|
|
if (!output.ok) {
|
|
throw new Error(JSON.stringify(output))
|
|
}
|
|
expect(output.ok).toBe(true)
|
|
expect(output.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: 'recipe.provision', status: 'pass' }),
|
|
expect.objectContaining({ id: 'recipe.result.endpoint.public_ws', status: 'warn' }),
|
|
expect.objectContaining({ id: 'recipe.result.project_root', status: 'pass' }),
|
|
expect.objectContaining({ id: 'recipe.destroy.run', status: 'pass' })
|
|
])
|
|
)
|
|
// The transcript carries both stages so the agent can self-diagnose.
|
|
expect(output.provisionTranscript?.provision.exitCode).toBe(0)
|
|
expect(output.provisionTranscript?.destroy?.exitCode).toBe(0)
|
|
const cleanupPayload = JSON.parse(
|
|
String(vi.mocked(cleanupChild.stdin.end).mock.calls[0]?.[0])
|
|
) as { recipeId: string; recipeResult: { projectRoot: string } }
|
|
expect(cleanupPayload).toMatchObject({
|
|
recipeId: 'cloud-sandbox',
|
|
recipeResult: { projectRoot: '/workspace/repo' }
|
|
})
|
|
} finally {
|
|
rmSync(repoPath, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('returns the full create transcript when provision fails so the agent can self-diagnose', async () => {
|
|
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-provision-fail-'))
|
|
try {
|
|
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
|
|
writeFileSync(path.join(repoPath, 'scripts', 'orca-vm', 'start.js'), 'process.exit(0)')
|
|
writeFileSync(
|
|
path.join(repoPath, 'orca.yaml'),
|
|
[
|
|
'environmentRecipes:',
|
|
' - id: cloud-sandbox',
|
|
' name: Cloud Sandbox',
|
|
` create: ${JSON.stringify(`${process.execPath} ./scripts/orca-vm/start.js`)}`,
|
|
' destroy: none'
|
|
].join('\n')
|
|
)
|
|
const { EventEmitter } = await import('node:events')
|
|
const startChild = Object.assign(new EventEmitter(), {
|
|
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
|
|
stdin: { write: vi.fn(), end: vi.fn() },
|
|
kill: vi.fn()
|
|
})
|
|
// create emits a non-JSON line to stdout + a real diagnostic to stderr, then exits 0
|
|
spawnMock.mockImplementationOnce(() => {
|
|
process.nextTick(() => {
|
|
startChild.stdout.emit('data', 'Provisioning sandbox...\n')
|
|
startChild.stderr.emit('data', 'vercel: error: missing scope\n')
|
|
startChild.emit('exit', 0, null)
|
|
startChild.emit('close', 0, null)
|
|
})
|
|
return startChild
|
|
})
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main([
|
|
'vm',
|
|
'recipe',
|
|
'doctor',
|
|
'cloud-sandbox',
|
|
'--repo-path',
|
|
repoPath,
|
|
'--provision',
|
|
'--json'
|
|
])
|
|
|
|
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
|
|
ok: boolean
|
|
checks: { id: string; status: string }[]
|
|
provisionTranscript?: {
|
|
provision: {
|
|
exitCode: number | null
|
|
stdout: string
|
|
stderr: string
|
|
parseError?: string
|
|
}
|
|
}
|
|
}
|
|
expect(output.ok).toBe(false)
|
|
expect(output.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: 'recipe.provision', status: 'fail' })
|
|
])
|
|
)
|
|
// The agent gets the full create output, not a 500-char tail.
|
|
expect(output.provisionTranscript?.provision.stdout).toContain('Provisioning sandbox...')
|
|
expect(output.provisionTranscript?.provision.stderr).toContain('missing scope')
|
|
expect(output.provisionTranscript?.provision.parseError).toBeTruthy()
|
|
process.exitCode = priorExitCode
|
|
} finally {
|
|
rmSync(repoPath, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects recipe JSON output without a project root', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main(['serve', '--recipe-json'], '/tmp/repo')
|
|
|
|
expect(serveOrcaAppMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Recipe JSON output requires --project-root.'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects recipe JSON output with mobile pairing', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main(
|
|
['serve', '--recipe-json', '--project-root', '/workspace/repo', '--mobile-pairing'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(serveOrcaAppMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Recipe JSON output requires runtime pairing; remove --mobile-pairing.'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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('lists projects through the project-first runtime API', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_list', {
|
|
projects: [
|
|
{
|
|
id: 'github:stablyai/orca',
|
|
displayName: 'Orca',
|
|
badgeColor: '#7c3aed',
|
|
providerIdentity: {
|
|
provider: 'github',
|
|
owner: 'stablyai',
|
|
repo: 'orca'
|
|
},
|
|
sourceRepoIds: ['repo-1'],
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['project', 'list', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith('project.list')
|
|
})
|
|
|
|
it('filters project host setups locally after fetching setup compatibility state', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setups', {
|
|
setups: [
|
|
{
|
|
id: 'setup-local',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'local',
|
|
repoId: 'repo-local',
|
|
path: '/tmp/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
{
|
|
id: 'setup-remote',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: 'repo-remote',
|
|
path: '/srv/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
})
|
|
)
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['project', 'setups', '--project', 'github:stablyai/orca', '--host', 'runtime:gpu'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('projectHostSetup.list')
|
|
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote')
|
|
expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local')
|
|
})
|
|
|
|
it('sets up an existing project folder with a path resolved against the local cli cwd', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setup', {
|
|
result: {
|
|
project: {
|
|
id: 'github:stablyai/orca',
|
|
displayName: 'Orca',
|
|
badgeColor: '#7c3aed',
|
|
sourceRepoIds: ['repo-1'],
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
setup: {
|
|
id: 'setup-local',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'local',
|
|
repoId: 'repo-1',
|
|
path: path.resolve('/tmp/orca'),
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'imported-existing-folder',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
repo: {
|
|
id: 'repo-1',
|
|
path: path.resolve('/tmp/orca'),
|
|
displayName: 'Orca',
|
|
badgeColor: '#7c3aed',
|
|
addedAt: 1
|
|
}
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'project',
|
|
'setup-existing-folder',
|
|
'--project',
|
|
'github:stablyai/orca',
|
|
'--host',
|
|
'local',
|
|
'--path',
|
|
'..',
|
|
'--kind',
|
|
'git',
|
|
'--display-name',
|
|
'Orca',
|
|
'--json'
|
|
],
|
|
'/tmp/orca/worktrees/feature'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('projectHostSetup.setupExistingFolder', {
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'local',
|
|
path: path.resolve('/tmp/orca/worktrees'),
|
|
kind: 'git',
|
|
displayName: 'Orca'
|
|
})
|
|
})
|
|
|
|
it('rejects remote project setup 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(
|
|
[
|
|
'project',
|
|
'setup-existing-folder',
|
|
'--project',
|
|
'github:stablyai/orca',
|
|
'--host',
|
|
'runtime:gpu',
|
|
'--path',
|
|
'./orca',
|
|
'--pairing-code',
|
|
'remote-runtime',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Remote project setup requires --path to be an absolute path on the remote server.'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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,
|
|
cliProvenanceRequest: {}
|
|
})
|
|
})
|
|
|
|
it('starts an agent worktree in the background unless activation is explicit', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
|
okFixture('req_create', {
|
|
worktree: buildWorktree('/tmp/repo/agent-task', 'agent-task', 'abc', 'repo-1'),
|
|
lineage: null,
|
|
warnings: []
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo-1',
|
|
'--name',
|
|
'agent-task',
|
|
'--agent',
|
|
'codex',
|
|
'--prompt',
|
|
'hi',
|
|
'--setup',
|
|
'run',
|
|
'--json'
|
|
],
|
|
'/tmp/repo/src'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
|
repo: 'id:repo-1',
|
|
name: 'agent-task',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: false,
|
|
setupDecision: 'run',
|
|
parentWorktree: undefined,
|
|
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {},
|
|
startupAgent: 'codex',
|
|
startupPrompt: 'hi'
|
|
})
|
|
})
|
|
|
|
it('infers the repo and honors explicit activation on worktree.create', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
|
okFixture('req_create', {
|
|
worktree: buildWorktree('/tmp/repo/agent-task', 'agent-task', 'abc', 'repo-1'),
|
|
lineage: null,
|
|
warnings: []
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--name',
|
|
'agent-task',
|
|
'--agent',
|
|
'codex',
|
|
'--prompt',
|
|
'hi',
|
|
'--activate',
|
|
'--json'
|
|
],
|
|
'/tmp/repo/src'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
|
repo: 'id:repo-1',
|
|
name: 'agent-task',
|
|
baseBranch: undefined,
|
|
linkedIssue: undefined,
|
|
comment: undefined,
|
|
runHooks: false,
|
|
activate: true,
|
|
parentWorktree: undefined,
|
|
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
|
noParent: false,
|
|
callerTerminalHandle: undefined,
|
|
cliProvenanceRequest: {},
|
|
startupAgent: 'codex',
|
|
startupPrompt: 'hi'
|
|
})
|
|
})
|
|
|
|
it('rejects prompt without agent 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', '--prompt', 'hi', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'--prompt requires --agent'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects unknown agents on worktree.create', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main(
|
|
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--agent', 'wat', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: 'invalid_argument',
|
|
message: 'Unknown TUI agent "wat"'
|
|
}
|
|
})
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects agent, prompt, and setup flags without values 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', '--agent'],
|
|
'/tmp/repo'
|
|
)
|
|
expect(callMock.mock.calls.some(([method]) => method === 'worktree.create')).toBe(false)
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Missing value for --agent'
|
|
)
|
|
|
|
callMock.mockClear()
|
|
logSpy.mockClear()
|
|
errSpy.mockClear()
|
|
process.exitCode = priorExitCode
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'create',
|
|
'--repo',
|
|
'id:repo-1',
|
|
'--name',
|
|
'child',
|
|
'--agent',
|
|
'codex',
|
|
'--prompt'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
expect(callMock.mock.calls.some(([method]) => method === 'worktree.create')).toBe(false)
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Missing value for --prompt'
|
|
)
|
|
|
|
callMock.mockClear()
|
|
logSpy.mockClear()
|
|
errSpy.mockClear()
|
|
process.exitCode = priorExitCode
|
|
|
|
await main(
|
|
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--setup'],
|
|
'/tmp/repo'
|
|
)
|
|
expect(callMock.mock.calls.some(([method]) => method === 'worktree.create')).toBe(false)
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Missing value for --setup'
|
|
)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects contradictory setup flags 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',
|
|
'--run-hooks',
|
|
'--setup',
|
|
'skip',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'Choose either --run-hooks or --setup run'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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,
|
|
presentation: 'focused'
|
|
})
|
|
})
|
|
|
|
it('prints terminal.read fallback screen lines in json mode', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_terminal_read', {
|
|
terminal: {
|
|
handle: 'term_worker',
|
|
status: 'running',
|
|
tail: ['Claude Code', 'Checking files', 'Waiting for input'],
|
|
truncated: false,
|
|
limited: true,
|
|
oldestCursor: '0',
|
|
nextCursor: '3000',
|
|
latestCursor: '3000',
|
|
returnedLineCount: 3
|
|
}
|
|
})
|
|
)
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['terminal', 'read', '--terminal', 'term_worker', '--limit', '120', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('terminal.read', {
|
|
terminal: 'term_worker',
|
|
limit: 120
|
|
})
|
|
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
|
|
expect(printed.result.terminal.tail).toEqual([
|
|
'Claude Code',
|
|
'Checking files',
|
|
'Waiting for input'
|
|
])
|
|
})
|
|
|
|
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,
|
|
presentation: 'focused',
|
|
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('rejects no-flag orchestration reset before calling the runtime', async () => {
|
|
await main(['orchestration', 'reset'], '/tmp/repo')
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(process.exitCode).toBe(1)
|
|
})
|
|
|
|
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'
|
|
}
|
|
])('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.each([
|
|
['orchestration', 'reset', '--tasks', '--messages'],
|
|
['orchestration', 'reset', '--all', '--tasks']
|
|
])('rejects conflicting reset scopes for $args', async (...args) => {
|
|
await main(args, '/tmp/repo')
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(process.exitCode).toBe(1)
|
|
})
|
|
|
|
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',
|
|
'--task-title',
|
|
'Child workspace',
|
|
'--display-name',
|
|
'Spawn child workspace'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('orchestration.taskCreate', {
|
|
spec: 'spawn child workspace',
|
|
taskTitle: 'Child workspace',
|
|
displayName: '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('passes dev mode from an explicit dev CLI marker with a custom profile path', async () => {
|
|
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
|
|
process.env.ORCA_USER_DATA_PATH = '/tmp/federation-acceptance-profile'
|
|
process.env.ORCA_DEV_CLI_INVOCATION = '1'
|
|
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',
|
|
expect.objectContaining({ 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('collects and formats memory diagnostics', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_memory', {
|
|
app: {
|
|
cpu: 1.25,
|
|
memory: 1024 * 1024,
|
|
main: { cpu: 0.5, memory: 512 * 1024 },
|
|
renderer: { cpu: 0.5, memory: 384 * 1024 },
|
|
other: { cpu: 0.25, memory: 128 * 1024 },
|
|
history: [1024 * 1024]
|
|
},
|
|
worktrees: [
|
|
{
|
|
worktreeId: 'repo::/tmp/repo/feature',
|
|
worktreeName: 'feature',
|
|
repoId: 'repo',
|
|
repoName: 'Orca',
|
|
cpu: 2.5,
|
|
memory: 1024 * 1024,
|
|
sessions: [
|
|
{
|
|
sessionId: 'pty-1',
|
|
paneKey: null,
|
|
pid: 123,
|
|
cpu: 2.5,
|
|
memory: 1024 * 1024
|
|
}
|
|
],
|
|
history: [1024 * 1024]
|
|
}
|
|
],
|
|
host: {
|
|
totalMemory: 8 * 1024 * 1024,
|
|
freeMemory: 2 * 1024 * 1024,
|
|
availableMemory: 2 * 1024 * 1024,
|
|
availableMemorySource: 'free-memory',
|
|
usedMemory: 6 * 1024 * 1024,
|
|
memoryUsagePercent: 75,
|
|
cpuCoreCount: 8,
|
|
loadAverage1m: 1.25
|
|
},
|
|
processMemoryMetric: 'rss',
|
|
totalCpu: 3.75,
|
|
totalMemory: 2 * 1024 * 1024,
|
|
collectedAt: 1000
|
|
})
|
|
)
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['diagnostics', 'memory'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith('diagnostics.memory')
|
|
const output = logSpy.mock.calls.flat().join('\n')
|
|
expect(output).toContain('totalMemory: 2.0 MB')
|
|
expect(output).toContain('processMemoryMetric: summed RSS; shared or aliased pages may repeat')
|
|
expect(output).toContain('hostAvailable: 2.0 MB (free-memory)')
|
|
expect(output).toContain('app: 1.0 MB')
|
|
expect(output).toContain('- feature 1.0 MB 2.5% 1 session')
|
|
})
|
|
|
|
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('passes emulator gesture points through to the runtime', async () => {
|
|
const points = [
|
|
{ type: 'begin', x: 0.5, y: 0.98, edge: 3 },
|
|
{ type: 'move', x: 0.5, y: 0.4, edge: 3 },
|
|
{ type: 'end', x: 0.5, y: 0.2, edge: 3 }
|
|
]
|
|
queueFixtures(callMock, okFixture('req_emulator_gesture', { ok: true }))
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['emulator', 'gesture', JSON.stringify(points), '--worktree', 'id:wt-1', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('emulator.gesture', {
|
|
points,
|
|
device: undefined,
|
|
emulator: undefined,
|
|
worktree: 'id:wt-1'
|
|
})
|
|
})
|
|
|
|
it('rejects emulator gesture points outside normalized coordinates', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main(
|
|
[
|
|
'emulator',
|
|
'gesture',
|
|
JSON.stringify([
|
|
{ type: 'begin', x: 1.2, y: 0.8 },
|
|
{ type: 'end', x: 0.5, y: 0.2 }
|
|
]),
|
|
'--worktree',
|
|
'id:wt-1',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: 'invalid_argument',
|
|
message: '--points[0].x must be between 0 and 1'
|
|
}
|
|
})
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
it('rejects emulator gesture points with invalid edge markers', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
const priorExitCode = process.exitCode
|
|
|
|
await main(
|
|
[
|
|
'emulator',
|
|
'gesture',
|
|
JSON.stringify([
|
|
{ type: 'begin', x: 0.5, y: 0.98, edge: 8 },
|
|
{ type: 'end', x: 0.5, y: 0.2, edge: 8 }
|
|
]),
|
|
'--worktree',
|
|
'id:wt-1',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: 'invalid_argument',
|
|
message: 'gesture point 0 edge must be an integer between 0 and 4'
|
|
}
|
|
})
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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('resolves project and host flags for automation create', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setups', {
|
|
setups: [
|
|
{
|
|
id: 'setup-local',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'local',
|
|
repoId: 'repo-local',
|
|
path: '/tmp/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
{
|
|
id: 'setup-gpu',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: 'repo-gpu',
|
|
path: '/srv/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
}),
|
|
okFixture('req_automation_create', {
|
|
automation: { id: 'auto-1', name: 'GPU review' }
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'automations',
|
|
'create',
|
|
'--name',
|
|
'GPU review',
|
|
'--trigger',
|
|
'daily',
|
|
'--prompt',
|
|
'Review open changes',
|
|
'--provider',
|
|
'codex',
|
|
'--project',
|
|
'github:stablyai/orca',
|
|
'--host',
|
|
'runtime:gpu',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'automation.create',
|
|
expect.objectContaining({
|
|
repo: 'id:repo-gpu',
|
|
runContext: {
|
|
kind: 'workspace-run',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
projectHostSetupId: 'setup-gpu',
|
|
repoId: 'repo-gpu',
|
|
path: '/srv/orca'
|
|
},
|
|
workspace: undefined,
|
|
workspaceMode: 'new_per_run'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('resolves project-host-setup flags for automation edit with explicit run context', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setups', {
|
|
setups: [
|
|
{
|
|
id: 'setup-gpu',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: 'repo-gpu',
|
|
path: '/srv/orca',
|
|
displayName: 'Orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
}),
|
|
okFixture('req_edit', {
|
|
automation: { id: 'auto-1', name: 'GPU review' }
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
['automations', 'edit', 'auto-1', '--project-host-setup', 'setup-gpu', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'automation.update',
|
|
expect.objectContaining({
|
|
id: 'auto-1',
|
|
updates: expect.objectContaining({
|
|
repo: 'id:repo-gpu',
|
|
runContext: {
|
|
kind: 'workspace-run',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
projectHostSetupId: 'setup-gpu',
|
|
repoId: 'repo-gpu',
|
|
path: '/srv/orca'
|
|
}
|
|
})
|
|
})
|
|
)
|
|
})
|
|
|
|
it('passes automation source context JSON through create', async () => {
|
|
const sourceContext = {
|
|
kind: 'task-source',
|
|
provider: 'github',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
projectHostSetupId: 'setup-gpu',
|
|
repoId: 'repo-gpu',
|
|
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
|
|
accountLabel: 'gpu-bot'
|
|
}
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_automation_create', {
|
|
automation: { id: 'auto-1', name: 'GPU task review' }
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'automations',
|
|
'create',
|
|
'--name',
|
|
'GPU task review',
|
|
'--trigger',
|
|
'daily',
|
|
'--prompt',
|
|
'Review open work',
|
|
'--provider',
|
|
'codex',
|
|
'--repo',
|
|
'id:repo-gpu',
|
|
'--source-context',
|
|
JSON.stringify(sourceContext),
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'automation.create',
|
|
expect.objectContaining({
|
|
repo: 'id:repo-gpu',
|
|
sourceContext
|
|
})
|
|
)
|
|
})
|
|
|
|
it('clears automation source context on edit with null', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_edit', {
|
|
automation: { id: 'auto-1', name: 'GPU task review' }
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'automation.update',
|
|
expect.objectContaining({
|
|
id: 'auto-1',
|
|
updates: expect.objectContaining({
|
|
sourceContext: null
|
|
})
|
|
})
|
|
)
|
|
})
|
|
|
|
it('rejects invalid automation source context JSON 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',
|
|
'GPU task review',
|
|
'--trigger',
|
|
'daily',
|
|
'--prompt',
|
|
'Review open work',
|
|
'--provider',
|
|
'codex',
|
|
'--repo',
|
|
'id:repo-gpu',
|
|
'--source-context',
|
|
'{nope',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
|
'--source-context must be valid JSON'
|
|
)
|
|
expect(process.exitCode).toBe(1)
|
|
|
|
process.exitCode = priorExitCode
|
|
})
|
|
|
|
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
|
|
})
|
|
|
|
it('updates project host setup metadata through the project-first runtime API', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setup_update', {
|
|
result: {
|
|
project: {
|
|
id: 'github:stablyai/orca',
|
|
displayName: 'Orca',
|
|
badgeColor: '#7c3aed',
|
|
sourceRepoIds: [],
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
setup: {
|
|
id: 'setup-gpu',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: '',
|
|
path: '/srv/orca',
|
|
displayName: 'GPU VM',
|
|
setupState: 'ready',
|
|
setupMethod: 'imported-existing-folder',
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
}
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'project',
|
|
'setup-update',
|
|
'--setup',
|
|
'setup-gpu',
|
|
'--display-name',
|
|
'GPU VM',
|
|
'--path',
|
|
'/srv/orca',
|
|
'--worktree-base-path',
|
|
'../worktrees',
|
|
'--state',
|
|
'ready',
|
|
'--method',
|
|
'imported-existing-folder',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('projectHostSetup.update', {
|
|
setupId: 'setup-gpu',
|
|
updates: {
|
|
displayName: 'GPU VM',
|
|
path: path.resolve('/tmp/repo', '/srv/orca'),
|
|
worktreeBasePath: '../worktrees',
|
|
gitUsername: undefined,
|
|
kind: undefined,
|
|
setupState: 'ready',
|
|
setupMethod: 'imported-existing-folder'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('creates independent project host setup metadata through the project-first runtime API', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setup_create', {
|
|
result: {
|
|
project: {
|
|
id: 'github:stablyai/orca',
|
|
displayName: 'Orca',
|
|
badgeColor: '#7c3aed',
|
|
sourceRepoIds: [],
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
setup: {
|
|
id: 'setup-gpu',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: '',
|
|
path: '',
|
|
displayName: 'GPU VM',
|
|
setupState: 'setting-up',
|
|
setupMethod: 'provisioned',
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
}
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(
|
|
[
|
|
'project',
|
|
'setup-create',
|
|
'--project',
|
|
'github:stablyai/orca',
|
|
'--host',
|
|
'runtime:gpu',
|
|
'--setup-id',
|
|
'setup-gpu',
|
|
'--display-name',
|
|
'GPU VM',
|
|
'--state',
|
|
'setting-up',
|
|
'--method',
|
|
'provisioned',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenCalledWith('projectHostSetup.create', {
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
setupId: 'setup-gpu',
|
|
path: undefined,
|
|
kind: undefined,
|
|
displayName: 'GPU VM',
|
|
worktreeBasePath: undefined,
|
|
gitUsername: undefined,
|
|
setupState: 'setting-up',
|
|
setupMethod: 'provisioned'
|
|
})
|
|
})
|
|
|
|
it('deletes project host setup metadata through the project-first runtime API', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_project_setup_delete', {
|
|
result: {
|
|
project: {
|
|
id: 'github:stablyai/orca',
|
|
displayName: 'Orca',
|
|
badgeColor: '#7c3aed',
|
|
sourceRepoIds: [],
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
setup: {
|
|
id: 'setup-gpu',
|
|
projectId: 'github:stablyai/orca',
|
|
hostId: 'runtime:gpu',
|
|
repoId: '',
|
|
path: '/srv/orca',
|
|
displayName: 'GPU VM',
|
|
setupState: 'ready',
|
|
setupMethod: 'imported-existing-folder',
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
}
|
|
}
|
|
})
|
|
)
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', {
|
|
setupId: 'setup-gpu'
|
|
})
|
|
})
|
|
})
|