From 70e9b2a6c4f2bf49ba188ad677453974e07b7d3d Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 9 Nov 2026 21:08:55 -0800 Subject: [PATCH 1/2] feat(cli): orca worktree create --on cloud[:] [--env] creates an Orca Cloud workspace (private build); clear plan/at-cap/payment/sign-in errors; API accepts a team environment --- cloud/apps/sandbox-control/src/api/router.ts | 22 +++ .../sandbox-control/src/api/workspaces.ts | 5 +- .../test/api-followups.test.ts | 11 ++ src/cli/cloud/cloud-api-client.ts | 43 ++++ src/cli/cloud/cloud-operations.ts | 2 + .../index-worktree-create-on-cloud.test.ts | 186 ++++++++++++++++++ src/cli/cloud/worktree-create-on-cloud.ts | 36 +++- src/cli/specs/worktree-create.ts | 9 +- src/cli/worktree-handler-groups.ts | 16 +- 9 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 src/cli/cloud/index-worktree-create-on-cloud.test.ts diff --git a/cloud/apps/sandbox-control/src/api/router.ts b/cloud/apps/sandbox-control/src/api/router.ts index 9b8f6905fd4..b8266a77c94 100644 --- a/cloud/apps/sandbox-control/src/api/router.ts +++ b/cloud/apps/sandbox-control/src/api/router.ts @@ -79,6 +79,8 @@ const CreateWorkspaceBody = z repo: z.object({ url: RepoUrl, ref: Ref.optional() }).strict().optional(), machineSize: Size.optional(), region: Region.optional(), + /** Team environment to boot from: `env_…` (its active build) or a specific build `bld_…`. */ + environment: z.string().regex(/^(env|bld)_[A-Za-z0-9_-]{4,64}$/).optional(), /** Optional first message: queued and delivered as soon as the VM is up. */ message: z.object({ text: z.string().min(1).max(MAX_MESSAGE_CHARS), agent: z.enum(AGENTS).optional() }).strict().optional() }) @@ -123,6 +125,24 @@ function originFor(caller: ApiCaller): { origin: WorkspaceOrigin; originRef: str return { origin: 'api', originRef: caller.via === 'user' ? (caller.keyId ?? null) : null } } +/** `env_…` → that team environment's active build; `bld_…` passes through (lifecycle checks it). */ +async function resolveEnvironmentBuild(db: D1Database, accountId: string, id: string): Promise { + if (id.startsWith('bld_')) { + return id + } + const row = await db + .prepare('SELECT active_build_id FROM environments WHERE id = ? AND account_id = ? AND deleted_at IS NULL') + .bind(id, accountId) + .first<{ active_build_id: string | null }>() + if (!row) { + throw new ApiError(404, 'not_found', 'environment') + } + if (!row.active_build_id) { + throw new ApiError(409, 'conflict', 'environment_not_built') + } + return row.active_build_id +} + function messageBucket(caller: ApiCaller): string { return caller.via === 'workspace' ? `msg:ws:${caller.sandboxId}` : `msg:${caller.accountId}:${caller.userId}` } @@ -383,6 +403,7 @@ async function route(request: Request, url: URL, caller: ApiCaller, ctx: ApiCont await consumeRate(ctx.db, messageBucket(caller), MESSAGES_PER_MINUTE, now) } const project = body.projectId ? await getProject(ctx.db, caller.accountId, body.projectId) : null + const environmentBuildId = body.environment ? await resolveEnvironmentBuild(ctx.db, caller.accountId, body.environment) : undefined await consumeCreateToken(life.db, life.config.guardrails!, caller.accountId, now) const { origin, originRef } = originFor(caller) const started = performance.now() @@ -392,6 +413,7 @@ async function route(request: Request, url: URL, caller: ApiCaller, ctx: ApiCont repo: body.repo, machineSize: body.machineSize, region: body.region, + ...(environmentBuildId ? { environmentBuildId } : {}), origin, originRef }) diff --git a/cloud/apps/sandbox-control/src/api/workspaces.ts b/cloud/apps/sandbox-control/src/api/workspaces.ts index 18f8b93dd61..096a13585e2 100644 --- a/cloud/apps/sandbox-control/src/api/workspaces.ts +++ b/cloud/apps/sandbox-control/src/api/workspaces.ts @@ -142,6 +142,8 @@ export async function createWorkspace( name?: string project?: ProjectRow | null repo?: { url: string; ref?: string } + /** Boot from this environment build (bld_…); default: the project's pinned build, else the repo's active one. */ + environmentBuildId?: string machineSize?: MachineSize region?: string origin: WorkspaceOrigin @@ -157,7 +159,8 @@ export async function createWorkspace( workspaceId: input.workspaceId ?? randomId(input.origin === 'routine' ? 'rtn' : 'api'), machineSize: input.machineSize ?? input.project?.machine_size ?? 'standard', ...((input.region ?? input.project?.region) ? { region: (input.region ?? input.project?.region)! } : {}), - ...(repo ? { repo } : {}) + ...(repo ? { repo } : {}), + ...((input.environmentBuildId ?? input.project?.build_id) ? { environmentBuildId: (input.environmentBuildId ?? input.project?.build_id)! } : {}) }) await setWorkspaceMeta(life.db, ready.row.id, { name: input.name ?? null, diff --git a/cloud/apps/sandbox-control/test/api-followups.test.ts b/cloud/apps/sandbox-control/test/api-followups.test.ts index 6400165af7e..c1583475998 100644 --- a/cloud/apps/sandbox-control/test/api-followups.test.ts +++ b/cloud/apps/sandbox-control/test/api-followups.test.ts @@ -104,3 +104,14 @@ describe('teammate names', () => { expect(messages[0]).toMatchObject({ sentBy: 'bob', sentByName: 'Bob Byte' }) }) }) + +describe('workspace create with a team environment', () => { + it('rejects an unknown environment before booting anything, and a malformed id as invalid', async () => { + const h = await harness() + const missing = await h.call('POST', '/v1/workspaces', { body: { environment: 'env_missing01' } }) + expect(missing.status).toBe(404) + expect(missing.body).toEqual({ error: 'not_found', reason: 'environment' }) + expect((await h.call('POST', '/v1/workspaces', { body: { environment: 'prod' } })).status).toBe(400) + expect(h.vercel.boxes.size).toBe(0) + }) +}) diff --git a/src/cli/cloud/cloud-api-client.ts b/src/cli/cloud/cloud-api-client.ts index bbd55f27080..0518e6bffc4 100644 --- a/src/cli/cloud/cloud-api-client.ts +++ b/src/cli/cloud/cloud-api-client.ts @@ -131,6 +131,49 @@ export function cloudApiError( data([]) ) } + if ( + code === 'entitlement_denied' && + (reason === 'concurrency_limit' || reason === 'member_concurrency_limit') + ) { + return new RuntimeClientError( + 'cloud_at_capacity', + reason === 'member_concurrency_limit' + ? "You're at your limit of running sandboxes. Nothing was created." + : "You're at your team's limit of running sandboxes. Nothing was created.", + data([ + 'Put one to sleep or delete it, then try again: `orca cloud workspace list`, then `orca cloud workspace sleep `.' + ]) + ) + } + if (code === 'entitlement_denied' || code === 'billing_required' || status === 402) { + const outOfHours = reason === 'included_hours_exhausted' || reason === 'overage_cap_reached' + const payment = reason === 'payment_past_due' || reason === 'payment_grace_expired' + if (payment) { + return new RuntimeClientError( + 'cloud_payment_failed', + "Payment failed, so new sandboxes can't start. Nothing was created.", + data(['Ask an owner or admin to update the payment method.']) + ) + } + if (reason === 'machine_size_not_in_plan') { + return new RuntimeClientError( + 'cloud_plan_required', + "That sandbox size isn't in your team's plan. Nothing was created.", + data(['Use --size standard.']) + ) + } + return new RuntimeClientError( + outOfHours ? 'cloud_out_of_hours' : 'cloud_plan_required', + outOfHours + ? 'Your team is out of sandbox hours (or at its monthly spend cap). Nothing was created, and your files are safe.' + : 'Orca Cloud needs a Team plan. Nothing was created, and you weren’t charged.', + data([ + outOfHours + ? 'Ask an owner or admin to turn on overage or raise the spend cap.' + : 'Ask an owner or admin to start a Team plan in Orca → Settings → Plan & billing.' + ]) + ) + } if (status === 403 && code === 'insufficient_scope') { return new RuntimeClientError( 'cloud_forbidden', diff --git a/src/cli/cloud/cloud-operations.ts b/src/cli/cloud/cloud-operations.ts index 031f66ae763..01e1b09caec 100644 --- a/src/cli/cloud/cloud-operations.ts +++ b/src/cli/cloud/cloud-operations.ts @@ -152,6 +152,8 @@ export type CloudWorkspaceCreateInput = { name?: string projectId?: string repo?: { url: string; ref?: string } + /** Team environment: `env_…` (its active build) or a build `bld_…`. */ + environment?: string machineSize?: 'standard' | 'large' region?: string message?: { text: string; agent?: (typeof CLOUD_AGENTS)[number] } diff --git a/src/cli/cloud/index-worktree-create-on-cloud.test.ts b/src/cli/cloud/index-worktree-create-on-cloud.test.ts new file mode 100644 index 00000000000..a1902066985 --- /dev/null +++ b/src/cli/cloud/index-worktree-create-on-cloud.test.ts @@ -0,0 +1,186 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { callMock, runtimeClientConstructorMock, serveOrcaAppMock, getDefaultUserDataPathMock } = + vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data') + })) + +vi.mock('../runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('../index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +import { main } from '../index' + +let home: string +let out: string[] +let err: string[] +const fetchMock = vi.fn() + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-cloud-main-')) + out = [] + err = [] + vi.spyOn(console, 'log').mockImplementation((line: unknown) => void out.push(String(line))) + vi.spyOn(console, 'error').mockImplementation((line: unknown) => void err.push(String(line))) + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + out.push(String(chunk)) + return true + }) as typeof process.stdout.write) + vi.stubEnv('ORCA_CLOUD_CREDENTIALS_FILE', join(home, 'credentials.json')) + vi.stubEnv('ORCA_CLOUD_TOKEN', 'orca_ws_test') + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://cloud.test') + vi.stubGlobal('fetch', fetchMock) + process.exitCode = undefined +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + fetchMock.mockReset() + runtimeClientConstructorMock.mockReset() + rmSync(home, { recursive: true, force: true }) + process.exitCode = undefined +}) + +describe('orca worktree create --on cloud via main()', () => { + it('creates an Orca Cloud workspace through the API, without the Orca runtime', async () => { + fetchMock.mockResolvedValue( + Response.json( + { workspace: { id: 'sbx_on1', name: 'fix', status: 'running', origin: 'vm' } }, + { status: 201 } + ) + ) + await main( + [ + 'worktree', + 'create', + '--on', + 'cloud', + '--name', + 'fix', + '--repo', + 'https://github.com/acme/app', + '--agent', + 'claude', + '--prompt', + 'hi', + '--json' + ], + home + ) + expect(process.exitCode).toBeUndefined() + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://cloud.test/v1/workspaces') + expect(JSON.parse(String(init.body))).toEqual({ + name: 'fix', + repo: { url: 'https://github.com/acme/app' }, + message: { text: 'hi', agent: 'claude' } + }) + expect(JSON.parse(out.join(''))).toMatchObject({ + ok: true, + schemaVersion: 1, + result: { host: { id: 'sbx_on1', origin: { target: 'cloud' } } } + }) + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + }) + + it('boots from a team environment with --env and reports a clear error for bad ids', async () => { + fetchMock.mockResolvedValue( + Response.json( + { workspace: { id: 'sbx_on2', name: 'e', status: 'running', origin: 'api' } }, + { status: 201 } + ) + ) + await main( + [ + 'worktree', + 'create', + '--on', + 'cloud', + '--repo', + 'https://github.com/acme/app', + '--env', + 'env_abcd1234', + '--json' + ], + home + ) + expect(process.exitCode).toBeUndefined() + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(init.body))).toMatchObject({ environment: 'env_abcd1234' }) + fetchMock.mockReset() + out = [] + await main(['worktree', 'create', '--on', 'cloud', '--env', 'prod', '--json'], home) + expect(process.exitCode).not.toBeUndefined() + expect(JSON.parse(out.join(''))).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each([ + [ + { error: 'entitlement_denied', reason: 'concurrency_limit' }, + 'cloud_at_capacity', + "team's limit of running sandboxes" + ], + [ + { error: 'entitlement_denied', reason: 'no_subscription' }, + 'cloud_plan_required', + 'needs a Team plan' + ], + [ + { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, + 'cloud_out_of_hours', + 'out of sandbox hours' + ] + ])('maps %o to %s with a next step', async (body, code, text) => { + fetchMock.mockResolvedValue(Response.json(body, { status: 403 })) + await main( + ['worktree', 'create', '--on', 'cloud', '--repo', 'https://github.com/acme/app', '--json'], + home + ) + expect(process.exitCode).not.toBeUndefined() + const payload = JSON.parse(out.join('')) + expect(payload).toMatchObject({ ok: false, error: { code } }) + expect(payload.error.message).toContain(text) + }) + + it('says how to sign in when there are no credentials', async () => { + vi.stubEnv('ORCA_CLOUD_TOKEN', '') + vi.stubEnv('ORCA_CLOUD_API_URL', '') + await main( + ['worktree', 'create', '--on', 'cloud', '--repo', 'https://github.com/acme/app', '--json'], + home + ) + expect(JSON.parse(out.join(''))).toMatchObject({ + ok: false, + error: { code: 'cloud_not_signed_in' } + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('refuses other --on targets in this build, before any request', async () => { + await main(['worktree', 'create', '--on', 'recipe:dev', '--name', 'x', '--json'], home) + expect(JSON.parse(out.join(''))).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(fetchMock).not.toHaveBeenCalled() + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/cli/cloud/worktree-create-on-cloud.ts b/src/cli/cloud/worktree-create-on-cloud.ts index 91607d083cc..46fc0693fe0 100644 --- a/src/cli/cloud/worktree-create-on-cloud.ts +++ b/src/cli/cloud/worktree-create-on-cloud.ts @@ -35,6 +35,7 @@ const SUPPORTED = new Set([ 'size', 'agent', 'prompt', + 'env', 'progress', 'json', 'help' @@ -135,7 +136,7 @@ export async function createWorktreeOnCloud( if (unsupported.length > 0) { throw new RuntimeClientError( 'invalid_argument', - `--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --size, --agent/--prompt.`, + `--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --env, --size, --agent/--prompt.`, { field: unsupported[0] } ) } @@ -178,8 +179,17 @@ export async function createWorktreeOnCloud( } const api = cloudClient(deps) const name = getOptionalStringFlag(flags, 'name') + const environment = getOptionalStringFlag(flags, 'env') + if (environment !== undefined && !/^(env|bld)_[A-Za-z0-9_-]{4,64}$/.test(environment)) { + throw new RuntimeClientError( + 'invalid_argument', + '--env takes a team environment id (env_…) or a build id (bld_…).', + { field: 'env' } + ) + } const input: CloudWorkspaceCreateInput = { ...(name ? { name } : {}), + ...(environment ? { environment } : {}), ...(target.project ? { projectId: await resolveCloudProjectId(api, target.project) } : {}), ...(repo ? { repo: { url: repo.url, ...(ref ? { ref } : {}) } } : {}), ...(machineSize ? { machineSize } : {}), @@ -209,3 +219,27 @@ export async function createWorktreeOnCloud( (value) => formatCloudWorkspace(value, deps.now()) ) } + +/** + * Private build hook for `worktree create`: the only `--on` target here is Orca Cloud, which talks to + * the Orca Cloud API instead of the runtime. True when it handled the command. + */ +export async function maybeCreateWorktreeOnCloud( + context: Pick +): Promise { + const { flags } = context + if (!flags.has('on') && !flags.has('env') && !flags.has('size') && !flags.has('progress')) { + return false + } + if (!readCloudOnTarget(flags)) { + throw new RuntimeClientError( + 'invalid_argument', + flags.has('on') + ? '--on supports `cloud` or `cloud:` in this build.' + : '--env, --size and --progress apply to `--on cloud`.', + { field: flags.has('on') ? 'on' : 'env' } + ) + } + await createWorktreeOnCloud(context) + return true +} diff --git a/src/cli/specs/worktree-create.ts b/src/cli/specs/worktree-create.ts index 743f4b68504..a685ee83185 100644 --- a/src/cli/specs/worktree-create.ts +++ b/src/cli/specs/worktree-create.ts @@ -6,7 +6,7 @@ export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = { repeatableFlags: ['reference'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--model ] [--effort ] [--surface chat|terminal] [--setup run|skip|inherit] [--base-branch ] [--branch ] [--reference ...] [--reference-site ] [--issue ] [--pr ] [--linear-issue ] [--gitlab-issue ] [--gitlab-mr ] [--comment ] [--parent-worktree [--parent-environment ]] [--no-parent] [--run-hooks] [--activate] [--retry-launch ] [--json]', + 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--model ] [--effort ] [--surface chat|terminal] [--setup run|skip|inherit] [--base-branch ] [--branch ] [--reference ...] [--reference-site ] [--issue ] [--pr ] [--linear-issue ] [--gitlab-issue ] [--gitlab-mr ] [--comment ] [--parent-worktree [--parent-environment ]] [--no-parent] [--run-hooks] [--activate] [--retry-launch ] [--json]\n orca worktree create --on cloud[:] [--name ] [--repo ] [--base-branch ] [--env ] [--size standard|large] [--agent claude|codex --prompt ] [--progress text|json|none] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', @@ -35,7 +35,12 @@ export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = { 'parent-environment', 'no-parent', 'run-hooks', - 'activate' + 'activate', + // Private build: `--on cloud` creates an Orca Cloud workspace (src/cli/cloud/worktree-create-on-cloud.ts). + 'on', + 'env', + 'size', + 'progress' ], notes: [ 'Repeat --reference to attach full issue or review URLs, or Jira issue keys, before setup or agent startup. Use --reference-site to choose the Jira site for keys. Other providers require full URLs. Cannot combine with legacy link flags.', diff --git a/src/cli/worktree-handler-groups.ts b/src/cli/worktree-handler-groups.ts index 8e6ce90a7a8..27e0003f7ba 100644 --- a/src/cli/worktree-handler-groups.ts +++ b/src/cli/worktree-handler-groups.ts @@ -12,7 +12,21 @@ export const WORKTREE_HANDLER_GROUPS: readonly HandlerGroup[] = [ 'worktree set', 'worktree rm' ], - load: async () => (await import('./handlers/worktree.js')).WORKTREE_HANDLERS + load: async () => { + const { WORKTREE_HANDLERS } = await import('./handlers/worktree.js') + // Private build: `worktree create --on cloud` goes to the Orca Cloud API, not the runtime. + const { maybeCreateWorktreeOnCloud } = await import('./cloud/worktree-create-on-cloud.js') + const create = WORKTREE_HANDLERS['worktree create']! + return { + ...WORKTREE_HANDLERS, + 'worktree create': async (context) => { + if (await maybeCreateWorktreeOnCloud(context)) { + return + } + return create(context) + } + } + } }, { name: 'worktree-activate', From 84d68eb0c88fb55c341f50a50fcb3290d5260644 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 9 Nov 2026 21:11:02 -0800 Subject: [PATCH 2/2] test(cli): no casts in the --on cloud dispatch tests --- .../index-worktree-create-on-cloud.test.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/cli/cloud/index-worktree-create-on-cloud.test.ts b/src/cli/cloud/index-worktree-create-on-cloud.test.ts index a1902066985..786f902adbb 100644 --- a/src/cli/cloud/index-worktree-create-on-cloud.test.ts +++ b/src/cli/cloud/index-worktree-create-on-cloud.test.ts @@ -26,7 +26,10 @@ import { main } from '../index' let home: string let out: string[] let err: string[] -const fetchMock = vi.fn() +let stdoutSpy: { mock: { calls: unknown[][] }; mockClear: () => void } +/** Everything the CLI printed: console.log lines plus raw stdout writes. */ +const printed = () => out.join('') + stdoutSpy.mock.calls.map((call) => String(call[0])).join('') +const fetchMock = vi.fn() beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'orca-cloud-main-')) @@ -34,10 +37,7 @@ beforeEach(() => { err = [] vi.spyOn(console, 'log').mockImplementation((line: unknown) => void out.push(String(line))) vi.spyOn(console, 'error').mockImplementation((line: unknown) => void err.push(String(line))) - vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { - out.push(String(chunk)) - return true - }) as typeof process.stdout.write) + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) vi.stubEnv('ORCA_CLOUD_CREDENTIALS_FILE', join(home, 'credentials.json')) vi.stubEnv('ORCA_CLOUD_TOKEN', 'orca_ws_test') vi.stubEnv('ORCA_CLOUD_API_URL', 'https://cloud.test') @@ -82,14 +82,14 @@ describe('orca worktree create --on cloud via main()', () => { home ) expect(process.exitCode).toBeUndefined() - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe('https://cloud.test/v1/workspaces') - expect(JSON.parse(String(init.body))).toEqual({ + const [url, init] = fetchMock.mock.calls[0]! + expect(String(url)).toBe('https://cloud.test/v1/workspaces') + expect(JSON.parse(String(init?.body))).toEqual({ name: 'fix', repo: { url: 'https://github.com/acme/app' }, message: { text: 'hi', agent: 'claude' } }) - expect(JSON.parse(out.join(''))).toMatchObject({ + expect(JSON.parse(printed())).toMatchObject({ ok: true, schemaVersion: 1, result: { host: { id: 'sbx_on1', origin: { target: 'cloud' } } } @@ -119,13 +119,14 @@ describe('orca worktree create --on cloud via main()', () => { home ) expect(process.exitCode).toBeUndefined() - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(JSON.parse(String(init.body))).toMatchObject({ environment: 'env_abcd1234' }) + const [, init] = fetchMock.mock.calls[0]! + expect(JSON.parse(String(init?.body))).toMatchObject({ environment: 'env_abcd1234' }) fetchMock.mockReset() out = [] + stdoutSpy.mockClear() await main(['worktree', 'create', '--on', 'cloud', '--env', 'prod', '--json'], home) expect(process.exitCode).not.toBeUndefined() - expect(JSON.parse(out.join(''))).toMatchObject({ + expect(JSON.parse(printed())).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) @@ -155,7 +156,7 @@ describe('orca worktree create --on cloud via main()', () => { home ) expect(process.exitCode).not.toBeUndefined() - const payload = JSON.parse(out.join('')) + const payload = JSON.parse(printed()) expect(payload).toMatchObject({ ok: false, error: { code } }) expect(payload.error.message).toContain(text) }) @@ -167,7 +168,7 @@ describe('orca worktree create --on cloud via main()', () => { ['worktree', 'create', '--on', 'cloud', '--repo', 'https://github.com/acme/app', '--json'], home ) - expect(JSON.parse(out.join(''))).toMatchObject({ + expect(JSON.parse(printed())).toMatchObject({ ok: false, error: { code: 'cloud_not_signed_in' } }) @@ -176,7 +177,7 @@ describe('orca worktree create --on cloud via main()', () => { it('refuses other --on targets in this build, before any request', async () => { await main(['worktree', 'create', '--on', 'recipe:dev', '--name', 'x', '--json'], home) - expect(JSON.parse(out.join(''))).toMatchObject({ + expect(JSON.parse(printed())).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })