From 67188d0a55f6666a6dbdb7eb66015b6a6267b25c Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 2 Nov 2026 18:53:38 -0700 Subject: [PATCH 1/2] [build-tools] Observe Argent Stop requests during startup --- .changeset/argent-stop-during-startup.md | 5 + .../src/__tests__/utils/startupWatch.ts | 22 ++ ...tArgentRemoteSession-orchestration.test.ts | 312 +++++++++++++++++- .../functions/startArgentRemoteSession.ts | 104 ++++-- 4 files changed, 405 insertions(+), 38 deletions(-) create mode 100644 .changeset/argent-stop-during-startup.md create mode 100644 packages/build-tools/src/__tests__/utils/startupWatch.ts diff --git a/.changeset/argent-stop-during-startup.md b/.changeset/argent-stop-during-startup.md new file mode 100644 index 0000000000..3d1dccd74e --- /dev/null +++ b/.changeset/argent-stop-during-startup.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Stop an Argent session that is still starting up when it is stopped. diff --git a/packages/build-tools/src/__tests__/utils/startupWatch.ts b/packages/build-tools/src/__tests__/utils/startupWatch.ts new file mode 100644 index 0000000000..1a7dbf1f03 --- /dev/null +++ b/packages/build-tools/src/__tests__/utils/startupWatch.ts @@ -0,0 +1,22 @@ +import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup'; +import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession'; + +/** + * A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the + * returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like + * a Stop request during startup. + */ +export function createStartupWatchMock({ + startup, +}: Parameters[0]): ReturnType< + typeof watchDeviceRunSessionStartup +> & { stop: () => void } { + const stopped = new DeviceRunSessionStoppedError('Stopped during startup.'); + return { + stopped, + stop: () => startup.abort(stopped), + isStopped: () => startup.signal.reason === stopped, + completeAsync: jest.fn(async () => startup.signal.throwIfAborted()), + finishAsync: jest.fn(async () => {}), + }; +} diff --git a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts index 912b95edb4..c01484590d 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts @@ -29,6 +29,8 @@ import { runOnWarmIosSimulatorAsync, } from '../../utils/warmDeviceSessionHost'; import { type WarmIosSession } from '../../utils/warmIosSession'; +import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup'; +import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch'; import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession'; import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact'; @@ -86,6 +88,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({ uploadRemoteSessionConfigAsync: jest.fn(), waitForDeviceRunSessionStoppedAsync: jest.fn(), })); +jest.mock('../../utils/deviceRunSessionStartup', () => ({ + watchDeviceRunSessionStartup: jest.fn(), +})); const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home'); const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent'); @@ -136,6 +141,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { }); jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started'); jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined); + jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock); // The (real) tool-server state wait reads this file from the redirected ~/.argent. await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true }); @@ -149,6 +155,308 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { await fs.promises.rm(TEST_HOME, { recursive: true, force: true }); }); + it.each([ + [BuildRuntimePlatform.DARWIN, false], + [BuildRuntimePlatform.LINUX, false], + [BuildRuntimePlatform.DARWIN, true], + ])( + 'handles Stop during %s setup, keeping teardown failure=%s visible', + async (runtimePlatform, teardownFails) => { + let rejectSetup!: (error: Error) => void; + const setup = Object.assign( + new Promise((_resolve, reject) => { + rejectSetup = reject; + }), + { child: { pid: 12345 } } + ); + jest.mocked(spawn).mockReturnValueOnce(setup as unknown as ReturnType); + jest + .mocked(killProcessGroup) + .mockImplementationOnce(() => rejectSetup(new Error('setup killed'))); + const teardownError = new Error('host teardown failed'); + if (teardownFails) { + mockPreviewStopAsync.mockRejectedValueOnce(teardownError); + } + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + const running = Promise.resolve( + buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + boot_simulator: { + value: runtimePlatform === BuildRuntimePlatform.DARWIN ? true : undefined, + }, + }, + outputs: {}, + env: {}, + } as never + ) + ); + running.catch(() => {}); + try { + for (let i = 0; i < 20 && !jest.mocked(spawn).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(spawn).toHaveBeenCalledTimes(1); + const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value; + watch.stop(); + if (teardownFails) { + await expect(running).rejects.toBe(teardownError); + } else { + await expect(running).resolves.toBeUndefined(); + } + expect(killProcessGroup).toHaveBeenCalledTimes(1); + expect(spawnDetached).not.toHaveBeenCalled(); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled(); + expect(watch.finishAsync).toHaveBeenCalled(); + } finally { + rejectSetup(new Error('test cleanup')); + await running.catch(() => {}); + } + } + ); + + it('reports late host cleanup failure after startup Stop', async () => { + const ctx = {} as CustomBuildContext; + const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; + let entered!: () => void; + const starting = new Promise(resolve => { + entered = resolve; + }); + let failed!: AggregateError; + jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => { + entered(); + return await new Promise((_resolve, reject) => { + options.signal!.addEventListener( + 'abort', + () => { + failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]); + setImmediate(() => reject(failed)); + }, + { once: true } + ); + }); + }); + const running = createStartArgentRemoteSessionBuildFunction(ctx).fn!( + { logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never, + { + inputs: { + boot_simulator: { value: true }, + device_identifier: { value: 'selected-ios-udid' }, + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ) as Promise; + running.catch(() => {}); + await starting; + jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop(); + const failure = await running.then( + () => undefined, + error => error + ); + expect(failure).toBe(failed); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + }); + + it('keeps watching startup when warm adoption retries cold', async () => { + const ctx = {} as CustomBuildContext; + const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; + const adoption = new Error('warm adoption failed'); + jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => { + try { + return await run({ + device: { udid: 'selected-ios-udid', name: 'iPhone 17' }, + } as WarmIosSession); + } catch (error) { + if (error !== adoption) { + throw error; + } + return await run(undefined); + } + }); + jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => { + options.onStartupError?.(adoption); + throw adoption; + }); + await expect( + Promise.resolve( + createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!( + { logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never, + { + inputs: { + boot_simulator: { value: true }, + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ) + ) + ).resolves.toBeUndefined(); + expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1); + expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2); + }); + + it.each([false, true])( + 'observes Stop during warm claim and preserves cleanup failure=%s', + async failCleanup => { + const ctx = {} as CustomBuildContext; + const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; + let entered!: () => void; + const claiming = new Promise(resolve => { + entered = resolve; + }); + const cleanup = new Error('warm cleanup failed'); + jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => { + entered(); + await new Promise((_resolve, reject) => { + claim.signal!.addEventListener( + 'abort', + () => + reject( + failCleanup + ? new AggregateError([claim.signal!.reason, cleanup]) + : claim.signal!.reason + ), + { once: true } + ); + }); + }); + const running = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!( + { logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ) as Promise; + running.catch(() => {}); + await claiming; + jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop(); + if (failCleanup) { + await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) }); + } else { + await expect(running).resolves.toBeUndefined(); + } + expect(startDeviceSessionHostAsync).not.toHaveBeenCalled(); + } + ); + it('does not open the preview after Stop while the tools tunnel is opening', async () => { + let tunnelEntered!: () => void; + const entered = new Promise(resolve => { + tunnelEntered = resolve; + }); + let finishTunnel!: () => void; + const pendingTunnel = new Promise(resolve => { + finishTunnel = resolve; + }); + jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => { + tunnelEntered(); + await pendingTunnel; + return { + url: 'https://argent.example.com', + subdomainId: 'argent', + stopAsync: mockTunnelStopAsync, + }; + }); + const openPreviewAsync = jest.fn(); + jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({ + openPreviewAsync, + finishAsync: mockPreviewStopAsync, + getLastPreviewActivityAt: jest.fn(), + }); + const running = Promise.resolve( + createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + boot_simulator: { value: true }, + }, + outputs: {}, + env: {}, + } as never + ) + ); + running.catch(() => {}); + try { + await entered; + jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop(); + } finally { + finishTunnel(); + } + await expect(running).resolves.toBeUndefined(); + expect(openPreviewAsync).not.toHaveBeenCalled(); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + }); + + it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])( + 'preserves an earlier prepared %s host failure when Stop arrives during cleanup', + async runtimePlatform => { + const failure = new Error('Host became ready without a session token'); + let cleanupEntered!: () => void; + const entered = new Promise(resolve => { + cleanupEntered = resolve; + }); + let finishCleanup!: () => void; + const cleanup = new Promise(resolve => { + finishCleanup = resolve; + }); + jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => { + options.onStartupError?.(failure); + cleanupEntered(); + await cleanup; + throw failure; + }); + const running = Promise.resolve( + createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ) + ); + running.catch(() => {}); + try { + await entered; + jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop(); + } finally { + finishCleanup(); + } + await expect(running).rejects.toBe(failure); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled(); + expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); + } + ); + it('reports an early exit with output and stops Argent before opening tunnels', async () => { const stopError = new Error('drain timed out'); const stopServer = jest.fn().mockRejectedValue(stopError); @@ -964,7 +1272,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { }); it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])( - 'keeps %s prepared setup commands outside the new cancellation path', + 'makes %s prepared setup commands cancellable', async runtimePlatform => { const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); await buildFunction.fn!( @@ -983,7 +1291,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { ); expect(spawn).toHaveBeenCalledTimes(3); for (const [, , options] of jest.mocked(spawn).mock.calls) { - expect(options).not.toHaveProperty('detached'); + expect(options).toHaveProperty('detached', true); expect(options).not.toHaveProperty('signal'); } expect(killProcessGroup).not.toHaveBeenCalled(); diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index 62737139ef..c13d0cfa16 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -41,7 +41,9 @@ import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/proces import { sleepAsync } from '../../utils/retry'; import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts'; import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents'; +import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup'; import { + DeviceRunSessionStoppedError, createCorsOriginsInputProvider, createFrameAncestorsInputProvider, createIosSessionStartupInputProviders, @@ -143,11 +145,20 @@ export function createStartArgentRemoteSessionBuildFunction( }); const versionSpec = packageVersion ?? 'latest'; const { runtimePlatform } = global; - if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - await selectXcodeDeveloperDirectoryAsync({ env, logger }); - } + const startupStop = new AbortController(); + const watchSignal = signal + ? AbortSignal.any([signal, startupStop.signal]) + : startupStop.signal; + const startupWatch = watchDeviceRunSessionStartup({ + ctx, + deviceRunSessionId, + logger, + startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) }, + }); const deviceIdentifier = inputs.device_identifier?.value as string | undefined; const startSessionAsync = async (warmSession?: WarmIosSession): Promise => { + const startupAbortController = new AbortController(); + const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]); const { launch, iosStartup } = await resolveIosSessionStartupAsync({ runtimePlatform, bootSimulator: inputs.boot_simulator?.value as boolean | undefined, @@ -158,7 +169,7 @@ export function createStartArgentRemoteSessionBuildFunction( openUrl: inputs.open_url?.value, env, logger, - signal, + signal: startupSignal, }); const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( { @@ -182,11 +193,6 @@ export function createStartArgentRemoteSessionBuildFunction( const argentExec = (args: string[]): { command: string; args: string[] } => resolvePackageExec(packageManager, args); - const startupAbortController = new AbortController(); - const startupSignal = signal - ? AbortSignal.any([signal, startupAbortController.signal]) - : startupAbortController.signal; - const setupSignal = iosStartup ? startupSignal : undefined; const artifactPollAbortController = new AbortController(); let artifactPollingPromise: Promise | undefined; let eventCollection: @@ -213,16 +219,14 @@ export function createStartArgentRemoteSessionBuildFunction( timeoutMs: STARTUP_TIMEOUT_MS, preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, signal: startupSignal, - onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, + onStartupError: error => startupAbortController.abort(error), ...launch, networkCapture, networkCaptureFields, }, ...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : []) ).catch(error => { - if (iosStartup) { - startupAbortController.abort(error); - } + startupAbortController.abort(error); throw error; }); try { @@ -251,7 +255,7 @@ export function createStartArgentRemoteSessionBuildFunction( enableArtifacts.command, enableArtifacts.args, { env, logger }, - setupSignal + startupSignal ); logger.info('Enabling the Argent tool-server event log flag.'); @@ -264,7 +268,7 @@ export function createStartArgentRemoteSessionBuildFunction( enableEventLog.command, enableEventLog.args, { env, logger }, - setupSignal + startupSignal ); logger.info('Disabling the Argent video watermark flag.'); @@ -279,17 +283,17 @@ export function createStartArgentRemoteSessionBuildFunction( disableVideoWatermark.command, disableVideoWatermark.args, { env, logger }, - setupSignal + startupSignal ); } catch (err) { - setupSignal?.throwIfAborted(); + startupSignal?.throwIfAborted(); logger.warn( { err }, 'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.' ); } - setupSignal?.throwIfAborted(); + startupSignal?.throwIfAborted(); const startServer = argentExec([ `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'server', @@ -330,7 +334,7 @@ export function createStartArgentRemoteSessionBuildFunction( ancestorPid: argentServer.pid, timeoutMs: STARTUP_TIMEOUT_MS, getExitError: argentServer.getExitError, - signal: setupSignal, + signal: startupSignal, }); toolServerPort = toolServerState.port; toolServerToken = toolServerState.token; @@ -350,7 +354,7 @@ export function createStartArgentRemoteSessionBuildFunction( if (hostStartup) { sessionHost = (await hostStartup).enforceValue(); } - setupSignal?.throwIfAborted(); + startupSignal?.throwIfAborted(); } logger.info(`Argent tool-server is listening on port ${toolServerPort}.`); const artifactPollSignal = signal @@ -388,6 +392,7 @@ export function createStartArgentRemoteSessionBuildFunction( logger.info(launchDescription); } sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); + startupSignal.throwIfAborted(); const webPreview = await sessionHost.openPreviewAsync(); logger.info( webPreview.sharePageUrl @@ -397,7 +402,7 @@ export function createStartArgentRemoteSessionBuildFunction( const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({ env, - signal, + signal: startupSignal, ctx, deviceRunSessionId, remoteConfig: { @@ -413,6 +418,7 @@ export function createStartArgentRemoteSessionBuildFunction( return; } + await startupWatch.completeAsync(); await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, @@ -429,8 +435,19 @@ export function createStartArgentRemoteSessionBuildFunction( : undefined, }); } catch (error) { + if ( + startupSignal.reason instanceof DeviceRunSessionStoppedError && + !(error instanceof AggregateError) + ) { + throw startupSignal.reason; + } sessionFailed = true; - throw setupSignal?.aborted ? setupSignal.reason : error; + throw error instanceof AggregateError + ? error + : startupSignal.aborted && + !(startupSignal.reason instanceof DeviceRunSessionStoppedError) + ? startupSignal.reason + : error; } finally { startupAbortController.abort(new Error('Argent session ended.')); const toolTeardown = (async () => { @@ -470,6 +487,9 @@ export function createStartArgentRemoteSessionBuildFunction( // The warm host owns Argent too; leave it alive until the final downloads finish. await Promise.allSettled([toolTeardown]); } + if (result.reason instanceof AggregateError) { + throw result.reason; + } await result.value?.finishAsync(); }), ], @@ -477,20 +497,32 @@ export function createStartArgentRemoteSessionBuildFunction( }); } }; - if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) { - await runOnWarmIosSimulatorAsync( - { - engine: 'argent', - requestedDevice: deviceIdentifier, - packageVersion, - env, - logger, - signal, - }, - startSessionAsync - ); - } else { - await startSessionAsync(); + try { + if (runtimePlatform === BuildRuntimePlatform.DARWIN) { + await selectXcodeDeveloperDirectoryAsync({ env, logger }); + } + watchSignal.throwIfAborted(); + if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) { + await runOnWarmIosSimulatorAsync( + { + engine: 'argent', + requestedDevice: deviceIdentifier, + packageVersion, + env, + logger, + signal: watchSignal, + }, + startSessionAsync + ); + } else { + await startSessionAsync(); + } + } catch (error) { + if (error !== startupWatch.stopped) { + throw error; + } + } finally { + await startupWatch.finishAsync(); } }), }); From e632cbcfbd0adacdb2d56586b1c29b38ad82ecb4 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 2 Nov 2026 21:45:51 -0700 Subject: [PATCH 2/2] test(build-tools): Cover Argent parent cancellation and cleanup errors --- ...tArgentRemoteSession-orchestration.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts index c01484590d..0f3862de6b 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts @@ -222,6 +222,61 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { } ); + it('does not allocate Argent or a host after parent cancellation during Xcode selection', async () => { + const logger = { info: jest.fn(), warn: jest.fn() }; + const controller = new AbortController(); + const failure = new Error('parent step cancelled'); + jest + .mocked(selectXcodeDeveloperDirectoryAsync) + .mockImplementationOnce(async () => controller.abort(failure)); + await expect( + createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!( + { + logger, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + signal: controller.signal, + } as never + ) + ).rejects.toBe(failure); + expect(spawnDetached).not.toHaveBeenCalled(); + expect(startDeviceSessionHostAsync).not.toHaveBeenCalled(); + }); + + it('preserves an aggregate host startup failure', async () => { + const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; + const failure = new AggregateError([ + new Error('host failed'), + new Error('host cleanup failed'), + ]); + jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure); + await expect( + createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!( + { + logger, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + boot_simulator: { value: true }, + }, + outputs: {}, + env: {}, + } as never + ) + ).rejects.toBe(failure); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + }); + it('reports late host cleanup failure after startup Stop', async () => { const ctx = {} as CustomBuildContext; const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };