mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
* feat(browser): process-wide browser identity, chosen before ready
Electron resolves worker identity from a single process-global default, so two
coherent identities cannot coexist in one process. This makes clean/native one
app-wide decision read before `ready`, instead of a per-profile one that leaves
documents on one identity and every worker request on the other.
Both identities are load-bearing, measured across four origins at five reps:
the cleaned identity clears an embedded Turnstile widget and WhatsApp's browser
check where native is refused; native clears a full-page Cloudflare interstitial
that the cleaned identity never clears.
Base commit only: removing the per-profile field, its settings surface, and the
migration notice follow.
* test(browser): cover cross-context UA wire identity
* refactor(browser): make user agent identity app-wide
* test(browser): repair process identity wire fixture
* Fix browser identity startup migration failures
* WIP: rescue in-flight reduced-design work from a dead worker
Worker ctx_cb5b1262d7fe stopped ~2h ago mid-implementation (last heartbeat
2026-09-14T22:48:06Z) leaving this uncommitted. Committed unverified to make it
recoverable; not reviewed, not necessarily green.
* fix(browser): repair the rescued identity work so it typechecks
Finishes the interrupted edits in 7db9c54b54:
- browser-user-agent-migration-notice.ts was truncated mid-write; close the
then() callback so the file parses.
- Register browser.identity.get/set in the generated RPC params catalog so the
params type-parity gate is satisfied.
- Retire the persistence assertions for the superseded design: a
migratedNativeProfileIds event map, a notice-acknowledgement clear, and a
global persistence-failure accessor. Legacy userAgentMode bytes are retained
now, so these assert retention plus a failed notice write still hydrating.
- The in-memory fs fixture threw a codeless ENOENT, which reads as "unreadable"
rather than "missing" and made every identity write refuse. Carry the code.
- Use the segmented control's per-option disabled rather than adding a
control-level prop it does not have.
* refactor(browser): make the identity store the only writer
The rescued work already serialized identity writes, but the writer lived beside the pre-ready reader, so nothing stopped a second caller from writing the record directly -- which is the shape of the bug this change set removes.
browser-identity-mode-record.ts is now read-only: record shape, path, parsing and the pre-ready synchronous read. browser-identity-mode-store.ts owns every mutation behind one queue, holds the snapshot and listeners, and derives restartRequired from appliedMode vs configuredMode rather than storing it. Consumers move to the store.
The two identity RPC methods also move out of browser-core.ts into browser-identity-rpc.ts: they read and write this host's own process identity rather than driving a page, and browser-core.ts was over its line cap. The generated params catalog is byte-identical.
* feat(browser): make resetting unhealthy identity data explicit and lossless
A corrupt or newer-version record left the identity unchangeable with no way out. An explicit reset now copies the old bytes verbatim to a fresh unique path before publishing a replacement, and refuses the whole operation if that backup cannot be written -- so the reset can never be the thing that loses the data. Nothing resets automatically.
Future-version data says update Orca rather than reporting corruption. Reset is opt-in via browser.identity.set and orca browser identity set --reset.
ProfileCreate and BrowserIdentitySet move to browser-identity-params.ts: both carry the per-profile to app-wide identity move, and browser-params.ts was over its line cap.
Also registers browser as a top-level CLI name so the Windows launch redirect covers it -- without it orca browser identity get boots the GUI and exits silently there -- and adds the canonical browser identity show alias the CLI vocabulary policy requires.
* feat(browser): advertise the identity capability only where it exists
browser.identity.v1 was static, so every host claimed it including one that never initialized the identity store, where both methods can only throw. It now follows the browser.headless.v1 precedent and is pushed at status time when the store is actually initialized.
Also covers the retired profileCreate userAgentMode field at the dispatcher rather than only at the schema, so an older client provably gets the changed-semantics rejection over the wire instead of a success with the field quietly dropped.
* refactor(browser): delete the identity write queue and guard backup uniqueness
The queue could not be falsified by any test: writeRecord is synchronous end to end, so two calls cannot interleave and removing serialization entirely left every store test green. Carrying machinery whose guard is unconstructible is what the design review told us to cut, so it is gone. If durable writes ever become async, serialization comes back with the change that makes it testable.
The test that claimed to prove serialization now states what it actually pins -- the later of two selections is the one that survives -- and the module doc no longer claims a queue that is not there.
Adds the guard that was missing on reset: two resets across separate launches must produce two distinct backups, each holding its own original bytes. Verified discriminating -- a fixed backup filename fails it.
* test(browser): guard the identity capability and harden two weak assertions
Pins the mixed-version guarantee that had no test: browser.identity.v1 is advertised when the identity store is initialized and absent when it is not. Verified discriminating -- advertising it unconditionally fails the test.
The profileCreate rejection test asserted ok:false against a runtime with no browserProfileCreate, so that assertion passed even when the retired field was accepted. It now stubs a working runtime method, making ok:false load-bearing, and asserts the runtime is never reached.
Removes the persistence fixture's dead failIdentityWrite branch on writeFileAtomically: nothing on that path calls it, so it implied a second write mechanism that does not exist. Failure is injected through node:fs, which is what the identity write actually uses.
* test(browser): classify the identity channels on the preview seam
The channel split is asserted total, so adding browser:identity:get/set left it
short by two. They manage the host's own process-wide user-agent choice rather
than acting on a guest the reader is looking at, so they sit with the session
and profile channels, not the preview tools.
* test(browser): audit the identity rig's global-fetch call sites
The wire probe server and CDP collector arrived with the cross-context coverage
and were never added to the audit list. The collector's two real call sites are
safe: the poll cancels its unread body and the version probe consumes it through
response.json(). Every hit in the probe server is inside an injected page or
worker script source string, not a call this process makes.
* fix(browser): strip an app name that contains a space
app.setName decides the app token in the user agent, and dev sets "Orca Dev".
The cleaner matched a single whitespace-delimited token, which cannot span that
space, so the replace failed outright and every dev build presented
"Orca Dev/1.4.203" on the wire — the exact token class that gets transplanted
sessions revoked.
Anchoring on the engine comment and consuming lazily up to Chrome/ removes any
number of app tokens. A user agent without that comment is returned unchanged
rather than mangled, because over-stripping is worse than under-stripping.
The function had no unit test at all; it was only exercised through the
real-Electron wire tests, which run with a single-token fixture name. That is
why this survived.
* fix(browser): anchor the cleaner on the gap before Chrome/
My first attempt anchored on the engine comment, which broke a startup fixture
whose platform comment is "(Test)" with no "(KHTML, like Gecko)" at all — the app
token survived and the ordering test went red.
Anchoring on the nearest ")" before Chrome/ and consuming only non-")" tokens
keeps the match inside that gap, so it handles a multi-word app name, a synthetic
platform comment, and an already-clean identity alike. A user agent with no such
gap is still returned unchanged.
The fixture shape is now a test case, since it is what caught the first attempt.
* test(browser): repair the cleaner's case table
A missing comma between two it.each elements was reformatted into an index
expression, collapsing the table so every case ran with undefined input.
* test(browser): make a CI-only capture failure diagnosable
This probe passes locally and fails on CI with an empty receipt set, an empty
CDP diagnostic list, and a fixture that still exits 0 — so the assertion message
carried nothing usable. Thread the fixture's own result and stderr into the
capture assertion so the next run says what the fixture actually did.
* fix(browser): let an explicit choice retire the migration notice for good
The retired per-profile userAgentMode bytes are retained on disk by design, so
every launch rediscovers them and re-arms the notice — including the launch
right after the user answers it, and every launch after that. Documented as
one-time, it was permanent.
The record already carries explicitSelection, which is exactly the fact that
should end the notice. Gate the mark at the single writer rather than deleting
the legacy key, so the retained bytes stay untouched and disk never claims a
notice is pending beside a choice the user already made.
The new test pushed the persistence suite past max-lines, so the in-memory fs
and module mocks move to a named fixture module and the retired-identity tests
move beside them in their own file.
* fix(browser): stop reporting an unhydratable profile as a retired choice
A profile that fails validation for a reason unrelated to identity — a non-UUID
id, a mismatched partition — armed both the notice and its degraded flag. Since
hydrateFromPersisted skips such entries silently and nothing ever repairs them,
the user got "an old browser identity choice could not be inspected" forever,
about a profile that never carried one.
Key the notice on the presence of userAgentMode instead, and use validation only
to decide whether the choice that was found is inspectable. Refusing to hydrate
an entry and finding a retired choice are now separate facts.
The old case table asserted the defect for null, 42 and 'broken', so it is
replaced by two tables stating the new contract rather than adapted to pass.
* fix(browser): stop rewriting worker requests for viewport emulation
A worker request carries no webContentsId, so it always took the session-wide
branch and picked up the mobile UA if any tab in the session had a mobile
preset. That made a single context disagree with itself: a desktop tab's shared
worker reported a desktop navigator.userAgent — the per-target CDP override
cannot reach a worker — while its fetches left as CriOS. It also leaked across
tabs, and closing the emulated tab silently reverted it.
On main the divergence was between contexts, each internally coherent. Making
one context internally inconsistent is worse by this PR's own standard, so
accept that viewport emulation reaches documents only. Workers keep the session
identity on the wire, which is the identity they report in JavaScript.
That left hasSessionMobileViewportIntent with no reader, so the map it fed and
its three accessors go too, rather than leaving a dead latch behind the guard.
The electron fixture models this rule in its own header hook, so its hook and
both mobile arms are rewritten around the invariant that each context's wire
identity equals the identity its own JavaScript reports — not adapted to keep
the old path list passing.
* test(browser): point the identity tests at keys and writers that exist
browserUserAgentMode appears in zero production files and zero commits on main;
`git log -S` finds nothing. The retired key is profile.userAgentMode inside
browser-session-meta.json. Two tests were built on the invented one.
The global-settings test is deleted rather than repointed: no browser identity
key has ever lived in global settings, and stripRetiredGlobalSettings strips
only three unrelated keys, so the test asserted that an arbitrary unknown key
survives an object spread — a fact about the normalizer, not about identity.
The ready-phase test asserted on writeFileAtomically while the identity store
writes through writeFileDurableSync, so it could not go red for the write it
existed to forbid. It now watches the real writer, matched on the record path so
an unrelated durable write cannot fail it for the wrong reason, and the invented
settings key is gone from the Store mock.
Proven by ablation: injecting a byte-identical rewrite of the record into ready
composition leaves every snapshot and record assertion green and is caught only
by the new assertion, while writeFileAtomically is never called.
* fix(browser): let an unavailable process identity reject instead of throwing
installBrowserSessionPartitionPolicies returned Promise<void> without being
async, and configures the user agent policy before any suspension point.
getBrowserProcessUserAgentIdentity throws when the process identity was never
initialized, so that throw escaped synchronously past every caller's handler:
`void install(...).catch(...)` in the registry, and a bare `void install(...)`
in the route policies, which has no handler at all.
Bookkeeping must never gate a user action. Session startup would have died on a
failure its callers were already written to absorb and report.
* docs(browser): scope the meta-store claim about dropped legacy keys
The comment said persistMeta drops legacy keys on the next write because the
loader no longer carries them. That holds for the top-level userAgent keys it
describes, but not for the retired per-profile userAgentMode: it sits inside
each BrowserSessionProfile in `profiles`, which is carried through untouched, so
those bytes survive every write.
Retaining them is deliberate — it is what makes rollback and data-loss machinery
unnecessary, and the startup notice keys on their presence — so the comment read
as broader cover than it provided, in the one place someone would look before
deciding it was safe to strip them.
* test(browser): pin the unmapped-webContents path beside an emulated tab
A popup carries a webContentsId that maps to no registered tab, so it resolves
through the same branch as a worker request that carries none at all. The branch
already handled both, but only the absent-id case was covered.
* test(browser): make the ordering fixture exhibit a multi-word app name
This file sets the dev app name to "Orca Development" and then used a
single-token user agent fixture, so it set up the multi-word scenario and used a
fixture that could not exhibit it — which is how the multi-word app-name leak
got through. The fixture now carries a two-word app token, matching what
app.setName produces in dev, and the assertion names both words: a single \S+
match would leave "Orca" on the wire and still pass a one-token check.
* test(settings): cover the local branch of the browser identity setting
The only existing test covered the remote-host branch. The local branch — load,
select, refused write, and reset-required — had none, and that is the path the
retired-identity notice sends users down to make the choice that retires it.
Covers the selected-mode render, the commit that reports restartRequired, a
refused write surfacing its message without showing the mode as changed, and the
reset-required state offering no control.
* test(browser): run the real registry path in the ready identity pin
The test stubbed browser-session-startup and browser-session-registry, which are
the one ready-phase path that can write the identity record, so the record
content assertion could not fail for the write it existed to forbid.
Both are now real. Only the pieces hanging off the identity path are stubbed —
partition policies, route sessions, cookie staging, webauthn — so the meta load,
the retired-choice inspection, the identity store and the durable write all run
for real against temp directories. The canonical path mock moves to
persistence/loading-store/user-data-path, which is where the registry reads it;
mocking persistence alone left the registry pointed elsewhere. The active
profile directory is now a real temp dir, so the seeded browser-session-meta.json
is actually found — against the old /test-profile literal the meta load found
nothing and the whole exercise would have been vacuous.
A third case proves the path is live: with no explicit choice, the same retired
profile arms the notice through ready and lands migrationNoticePending on disk.
The two authority cases assert the opposite, that an explicit choice leaves the
record untouched.
initializeBrowserSessionsForApp latches on module state, so each case resets
modules and imports ready dynamically.
Ablated: disabling the explicitSelection gate turns both authority cases red on
the record content assertion while the arming case stays green.
* fix(browser): reject an unrecognized identity mode at the IPC door
normalizeBrowserUserAgentMode turned any unrecognized value into 'clean', so the
IPC door reported success for a mode it had quietly replaced, while the RPC door
validates against z.enum(['clean', 'native']) and rejects. One concept answered
an unknown value two different ways, and a future mode name was silently
downgraded rather than refused.
The handler now rejects, which is what the RPC door does and what the renderer
already handles — its catch puts the message in the error slot. Returning a
result instead would have meant inventing a fourth error code for a case no
legitimate caller can reach.
normalizeBrowserUserAgentMode had no other consumer, so it goes with the change:
leaving a coercion helper called "normalize" in shared/ invites the behaviour
straight back in.
* fix(settings): name the reset command where identity data is unusable
When configuredMode is null the setting says identity data must be reset
explicitly and then offers no control, because the reset overwrites data that
may belong to a newer Orca. The only escape is the CLI, which the message never
named — so it told the user to do something and gave them no way to do it.
Copy only: one line naming the command, no control and no destructive action in
the UI. The command goes in a new key beside the existing sentence rather than
expanding its default, which keeps the already-translated string valid.
No en.json entry: this component has no catalog entries for any of its keys, so
English resolves from the call-site defaults and adding one only for the new key
would be inconsistent with its siblings.
* fix(i18n): add the browser identity keys to the localization catalog
* fix(i18n): regenerate the runtime-required English catalog
* fix(browser): attach nested CDP targets paused before enabling Network
An OOPIF or dedicated worker was reached only through Target.targetCreated plus
an explicit attachToTarget, which never pauses the target. The frame could issue
its subresource fetch before Network.enable took effect, so the capture came back
empty and the cross-context assertion failed under CI load.
Re-arm auto-attach on each attached session, filtered to nested target types, so
an OOPIF or worker arrives waiting for the debugger and its enables are ordered
ahead of the resume. Drop the explicit attach, which is now both redundant and
the racy path.
* fix(settings): localize the browser identity search keywords
* fix(browser): await route policy setup
* fix(browser): satisfy strict static analysis
* test(browser): update live identity fixture API
* test(browser): preserve native UA in live probe
* fix(browser): close the open review findings on the identity revert
- drop a stray JSDoc left over from the removed per-profile setting
- leave user agents without a Chromium engine comment byte-identical
instead of anchoring the app-token strip on the OS comment and
destroying a real engine token
- localize the browser identity unavailable error
- correct the worker comment: only shared and service worker requests
carry no webContentsId, so emulation still reaches dedicated workers
- retire the session user agent policy when a profile is deleted
* test(browser): model a real Electron fallback in the startup UA fixture
The ordering fixture carried no "(KHTML, like Gecko)" engine comment, a
shape app.userAgentFallback cannot actually produce. That unfaithfulness
was what made the old over-stripping look correct, and it broke once the
cleaner started leaving non-Chromium identities alone.
Add the engine comment, keeping the two-word "Orca Development" app token
so the multi-word leak this test exists to catch is still caught. Both
assertions are unchanged.
701 lines
26 KiB
TypeScript
701 lines
26 KiB
TypeScript
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 () => {
|
|
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
|
return createRuntimeClientModuleMock({
|
|
callMock,
|
|
runtimeClientConstructorMock,
|
|
serveOrcaAppMock,
|
|
getDefaultUserDataPathMock
|
|
})
|
|
})
|
|
|
|
vi.mock('./runtime/environments', () => ({
|
|
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
|
listEnvironments: listEnvironmentsMock,
|
|
removeEnvironment: vi.fn(),
|
|
resolveEnvironment: vi.fn()
|
|
}))
|
|
|
|
vi.mock('child_process', async () => {
|
|
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
|
return createChildProcessModuleMock(spawnMock)
|
|
})
|
|
|
|
import { COMMAND_SPECS, main } from './index'
|
|
import { formatFlagHelp } from './help'
|
|
import { GLOBAL_FLAGS, specPaths } from './args'
|
|
import { okFixture, queueFixtures } from './test-fixtures'
|
|
|
|
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_show', { worktree: { hostId: 'local' } }),
|
|
okFixture('req', { removed: true })
|
|
)
|
|
|
|
await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'local', force: true })
|
|
)
|
|
})
|
|
|
|
it('runs `worktree delete` as the canonical `worktree rm`', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_show', { worktree: { hostId: 'runtime:env-1' } }),
|
|
okFixture('req', { removed: true })
|
|
)
|
|
|
|
await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'runtime:env-1' })
|
|
)
|
|
})
|
|
|
|
it('fails closed when worktree removal cannot resolve a host', async () => {
|
|
queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } }))
|
|
const priorExitCode = process.exitCode
|
|
|
|
try {
|
|
await main(['worktree', 'rm', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('worktree.show', { worktree: 'id:wt-1' })
|
|
} finally {
|
|
process.exitCode = priorExitCode
|
|
}
|
|
})
|
|
|
|
// #19334: a failed archive hook blocks removal, so the CLI must exit non-zero rather than
|
|
// report a delete that did not happen — and the waiver must ride its own flag, never --force.
|
|
it('exits non-zero when worktree removal is refused by a failed archive hook', async () => {
|
|
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
|
|
callMock.mockRejectedValueOnce(
|
|
Object.assign(new Error('Archive hook failed for worktree: /tmp/wt — exited 23.'), {
|
|
code: 'worktree_archive_hook_failed'
|
|
})
|
|
)
|
|
const priorExitCode = process.exitCode
|
|
|
|
try {
|
|
await main(
|
|
['worktree', 'rm', '--worktree', 'id:wt-1', '--force', '--run-hooks', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({
|
|
runHooks: true,
|
|
allowFailedArchiveHook: false
|
|
})
|
|
)
|
|
} finally {
|
|
process.exitCode = priorExitCode
|
|
}
|
|
})
|
|
|
|
// #19334 S4: the waiver only applies to a hook that ran, so alone it silently does nothing.
|
|
it('rejects the archive-hook waiver without --run-hooks instead of ignoring it', async () => {
|
|
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
|
|
const priorExitCode = process.exitCode
|
|
|
|
try {
|
|
await main(
|
|
['worktree', 'rm', '--worktree', 'id:wt-1', '--allow-failed-archive-hook', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
// The removal must never have been attempted.
|
|
expect(callMock).not.toHaveBeenCalledWith('worktree.rm', expect.anything())
|
|
} finally {
|
|
process.exitCode = priorExitCode
|
|
}
|
|
})
|
|
|
|
it('forwards the explicit archive-hook waiver on worktree rm', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_show', { worktree: { hostId: 'local' } }),
|
|
okFixture('req', { removed: true })
|
|
)
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'rm',
|
|
'--worktree',
|
|
'id:wt-1',
|
|
'--run-hooks',
|
|
'--allow-failed-archive-hook',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({ runHooks: true, allowFailedArchiveHook: true })
|
|
)
|
|
})
|
|
|
|
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('artifact runtime routing', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
vi.restoreAllMocks()
|
|
process.exitCode = 0
|
|
})
|
|
|
|
it('uses the desktop runtime despite remote-selection environment fallbacks', async () => {
|
|
vi.stubEnv('ORCA_ENVIRONMENT', 'remote-environment')
|
|
vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code')
|
|
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
callMock.mockResolvedValue(okFixture('artifact-list', { status: 'ok', value: [] }))
|
|
runtimeClientConstructorMock.mockClear()
|
|
|
|
await main(['artifacts', 'list', '--json'], '/folder-workspace')
|
|
|
|
expect(process.exitCode).not.toBe(1)
|
|
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null)
|
|
expect(callMock).toHaveBeenCalledWith('artifacts.list', {})
|
|
})
|
|
})
|
|
|
|
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('names the offending --worktree value and the valid forms on selector_not_found', async () => {
|
|
const { RuntimeRpcFailureError } = await import('./runtime/types.js')
|
|
callMock.mockRejectedValue(
|
|
new RuntimeRpcFailureError({
|
|
id: 'req_selector',
|
|
ok: false,
|
|
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
|
_meta: { runtimeId: 'runtime_local' }
|
|
})
|
|
)
|
|
|
|
await main(
|
|
['orchestration', 'worker-start', '--task', 't1', '--worktree', 'repo-1', '--agent', 'codex'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('No Orca workspace matched the worktree selector "repo-1"')
|
|
expect(stderr).toContain('id:repo-1::<absolute-path>')
|
|
expect(stderr).toContain('Valid selector forms:')
|
|
})
|
|
|
|
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('nested command group help', () => {
|
|
it.each([
|
|
['browser', ['browser'], ['identity get', 'identity set']],
|
|
['browser identity', ['browser', 'identity'], ['get', 'set']]
|
|
])(
|
|
'prints successful help for %s without constructing a runtime client',
|
|
async (_, path, commands) => {
|
|
const previousExitCode = process.exitCode
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
runtimeClientConstructorMock.mockClear()
|
|
process.exitCode = 0
|
|
|
|
try {
|
|
await main([...path, '--help'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(0)
|
|
const output = logSpy.mock.calls.flat().join('\n')
|
|
expect(output).toContain(`orca ${path.join(' ')}`)
|
|
for (const command of commands) {
|
|
expect(output).toContain(command)
|
|
}
|
|
expect(output).not.toContain('Unknown command')
|
|
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
process.exitCode = previousExitCode
|
|
logSpy.mockRestore()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
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('labels retired coordinator scheduler commands at the root', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
const output = String(logSpy.mock.calls[0]?.[0])
|
|
expect(output).toContain(
|
|
'orchestration coordinator-start Retired: load the current orchestration skill'
|
|
)
|
|
expect(output).toContain(
|
|
'orchestration coordinator-stop Retired: load the current orchestration skill'
|
|
)
|
|
expect(output).not.toContain('Start the legacy automatic coordinator loop')
|
|
expect(output).not.toContain('Stop the legacy automatic coordinator loop')
|
|
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(logSpy.mock.calls[0][0]).toContain(
|
|
"orchestration worker-release Release a settled worker's terminal after archiving its output"
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-retain Keep a worker terminal live for debugging'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-list Report worker terminal resource accounting'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).not.toContain('orchestration worker-cleanup')
|
|
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 from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
|
|
)
|
|
expect(listIssuesHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
|
expect(listIssuesHelp).toContain('0=none, 1=urgent, 2=high, 3=medium, 4=low')
|
|
expect(listIssuesHelp).toContain(
|
|
'--limit <n> Max issues to return; omit to return every match'
|
|
)
|
|
expect(listIssuesHelp).not.toContain('Line cursor from a previous read')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('documents the machine-readable terminal topology opt-in', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['terminal', 'list', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain('[--include-visual-layouts] [--json]')
|
|
expect(help).toContain('--include-visual-layouts Include tab and pane topology in JSON output')
|
|
expect(help).toContain('JSON omits visualLayouts by default')
|
|
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('describes worker-list cursors as opaque page cursors', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['orchestration', 'worker-list', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain('[--cursor <cursor>]')
|
|
expect(help).toContain('--cursor <cursor> Opaque page cursor copied from page.nextCursor')
|
|
expect(help).toContain('Continue with the opaque page.nextCursor value unchanged.')
|
|
expect(help).not.toContain('--cursor <dispatch_id>')
|
|
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]')
|
|
expect(rootHelp).toContain('identity:<identity>')
|
|
|
|
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(formatFlagHelp('parent-worktree')).toContain('identity:<identity>')
|
|
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()
|
|
})
|
|
})
|