From 5043e4dca87ee4b2524d14712d49697687e8b7ef Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 01:25:16 -0700 Subject: [PATCH 1/5] [build-tools] Let serve-sim start the iOS Simulator for Appium Co-Authored-By: GPT-6.1-Sol --- .../startAppiumRemoteSession.test.ts | 145 +++++++++++++- .../functions/startAppiumRemoteSession.ts | 188 +++++++++++------- 2 files changed, 251 insertions(+), 82 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts index 4d3448c4df..f39dab8bc8 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -10,6 +10,7 @@ import { sleepAsync } from '../../../utils/retry'; import { turtleFetch } from '../../../utils/turtleFetch'; import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents'; import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost'; +import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda'; import { selectXcodeDeveloperDirectoryAsync, spawnDetached, @@ -28,7 +29,11 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({ AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() }, })); jest.mock('../../../utils/IosSimulatorUtils', () => ({ - IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() }, + IosSimulatorUtils: { + getAvailableDevicesAsync: jest.fn(), + getDeviceAsync: jest.fn(), + resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'), + }, })); jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), @@ -49,6 +54,14 @@ jest.mock('../../../utils/turtleFetch', () => ({ })); jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() })); jest.mock('../../utils/deviceSessionHost'); +jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() })); +jest.mock('../../utils/iosAppArtifact', () => ({ + readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'), +})); +jest.mock('../../utils/localEgressGuard', () => ({ + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null), +})); + jest.mock('../../utils/localEgressSession', () => ({ ...jest.requireActual('../../utils/localEgressSession'), uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(), @@ -89,7 +102,6 @@ describe(resolveAppiumDeviceAsync, () => { resolveAppiumDeviceAsync({ runtimePlatform: BuildRuntimePlatform.DARWIN, env: {}, - logger, }) ).resolves.toEqual({ platformName: 'iOS', @@ -98,7 +110,6 @@ describe(resolveAppiumDeviceAsync, () => { udid: 'ios-simulator-id', runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', }); - expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger }); expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({ env: {}, filter: 'booted', @@ -114,7 +125,6 @@ describe(resolveAppiumDeviceAsync, () => { resolveAppiumDeviceAsync({ runtimePlatform: BuildRuntimePlatform.LINUX, env: {}, - logger, }) ).resolves.toEqual({ platformName: 'Android', @@ -213,6 +223,12 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => const stopEventCollection = jest.fn(); beforeEach(() => { + jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({}); + jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ + udid: 'CHOSEN-IOS-UDID', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + state: 'Shutdown', + } as never); jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({ stdout: args.includes('--json') ? '{}' : '', })) as never); @@ -254,12 +270,13 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => async function runSessionAsync( logger: { info: jest.Mock; warn: jest.Mock }, - maxIdleTimeMinutes?: number + maxIdleTimeMinutes?: number, + runtimePlatform = BuildRuntimePlatform.LINUX ): Promise { await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!( { logger, - global: { runtimePlatform: BuildRuntimePlatform.LINUX }, + global: { runtimePlatform }, } as unknown as BuildStepContext, { inputs: { @@ -285,6 +302,115 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => ); } + it('selects Xcode only once for an already-prepared iOS session', async () => { + stopAppium.mockResolvedValue(undefined); + jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear(); + jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([ + { + udid: 'chosen-ios-udid', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4', + } as never, + ]); + jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({ + 'appium:usePreinstalledWDA': true, + 'appium:prebuiltWDAPath': '/tmp/legacy-wda.app', + }); + await runSessionAsync({ info: jest.fn(), warn: jest.fn() }, undefined, BuildRuntimePlatform.DARWIN); + expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith( + expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' }) + ); + expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled(); + expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({ + 'appium:eventTimings': true, + 'appium:usePreinstalledWDA': true, + 'appium:prebuiltWDAPath': '/tmp/legacy-wda.app', + }); + expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1); + expect( + jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0] + ).toBeLessThan( + jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mock.invocationCallOrder[0] + ); + }); + + it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => { + stopAppium.mockResolvedValue(undefined); + jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear(); + jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockClear(); + jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({ + 'appium:usePreinstalledWDA': true, + 'appium:prebuiltWDAPath': '/tmp/selected-wda.app', + }); + let releaseInstall!: (value: unknown) => void; + let enteredInstall!: () => void; + const installEntered = new Promise(resolve => { + enteredInstall = resolve; + }); + jest.mocked(spawn).mockImplementationOnce(() => { + enteredInstall(); + return new Promise(resolve => { + releaseInstall = resolve; + }) as never; + }); + const running = createStartAppiumRemoteSessionBuildFunction({} 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 }, + device_identifier: { value: 'chosen-device' }, + install_app_path: { value: '/tmp/App.app' }, + launch_args: { value: ['--literal'] }, + open_url: { value: 'example://home' }, + }, + outputs: {}, + env: { + DEVICE_RUN_SESSION_ID: 'device-run-session-id', + EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com', + NGROK_AUTHTOKEN: 'ngrok-token', + }, + } as never + ); + await installEntered; + expect( + jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0] + ).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + iosSimulatorUdid: 'CHOSEN-IOS-UDID', + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['--literal'], + openUrl: 'example://home', + }) + ); + expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled(); + releaseInstall({ stdout: '' }); + await running; + expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({ + udid: 'CHOSEN-IOS-UDID', + env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }), + }); + expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + appiumHome: jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME, + }) + ); + expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({ + 'appium:eventTimings': true, + 'appium:usePreinstalledWDA': true, + 'appium:prebuiltWDAPath': '/tmp/selected-wda.app', + }); + expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1); + expect(stopAppium).toHaveBeenCalledTimes(1); + }); + it('keeps the readiness error when Appium cannot be stopped', async () => { let now = Date.now(); jest.spyOn(Date, 'now').mockImplementation(() => now); @@ -331,6 +457,13 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => const logger = { info: jest.fn(), warn: jest.fn() }; await expect(runSessionAsync(logger)).rejects.toBe(stopError); + expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith( + expect.objectContaining({ runtimeIdentifier: undefined }) + ); + expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled(); + expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({ + 'appium:eventTimings': true, + }); expectAppiumStoppedAndHomeRemoved(logger); expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan( stopAppium.mock.invocationCallOrder[0] diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index bd1fbd181c..82a5c24659 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -1,6 +1,6 @@ import { SystemError } from '@expo/eas-build-job'; import { type bunyan } from '@expo/logger'; -import { asyncResult } from '@expo/results'; +import { type Result, asyncResult } from '@expo/results'; import { BuildFunction, BuildRuntimePlatform, @@ -21,12 +21,13 @@ import { withLocalEgressSession, } from '../utils/localEgressSession'; import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; import { createNetworkCaptureInputProviders, parseNetworkCaptureInputs, } from '../utils/networkCaptureFields'; import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils'; -import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils'; +import { type IosSimulatorUuid, IosSimulatorUtils } from '../../utils/IosSimulatorUtils'; import { PackageManager, resolveConfiguredPackageManager, @@ -39,6 +40,7 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda'; import { createCorsOriginsInputProvider, createFrameAncestorsInputProvider, + createIosSessionStartupInputProviders, createPrivatePreviewUrlInputProvider, createPublicPreviewInputProvider, createServeSimLaunchInputProviders, @@ -51,7 +53,6 @@ import { omitNgrokAuthtoken, parseCorsOriginsInput, parseFrameAncestorsInput, - parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, spawnDetached, startNgrokTunnelAsync, @@ -78,6 +79,7 @@ export function createStartAppiumRemoteSessionBuildFunction( __metricsId: 'eas/start_appium_remote_session', inputProviders: [ ...createServeSimLaunchInputProviders(), + ...createIosSessionStartupInputProviders(), ...createNetworkCaptureInputProviders(), createPublicPreviewInputProvider(), createPrivatePreviewUrlInputProvider(), @@ -102,14 +104,21 @@ export function createStartAppiumRemoteSessionBuildFunction( const packageVersion = inputs.package_version.value as string | undefined; const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined; const { runtimePlatform } = global; - const launch = parseServeSimLaunchInputs( - { - launchAppIdentifier: inputs.launch_app_identifier?.value, - launchArgs: inputs.launch_args?.value, - openUrl: inputs.open_url?.value, - }, - { runtimePlatform } - ); + if (runtimePlatform === BuildRuntimePlatform.DARWIN) { + await selectXcodeDeveloperDirectoryAsync({ env, logger }); + } + const { launch, iosStartup } = await resolveIosSessionStartupAsync({ + runtimePlatform, + bootSimulator: inputs.boot_simulator?.value as boolean | undefined, + deviceIdentifier: inputs.device_identifier?.value as string | undefined, + installAppPath: inputs.install_app_path?.value as string | undefined, + launchAppIdentifier: inputs.launch_app_identifier?.value, + launchArgs: inputs.launch_args?.value, + openUrl: inputs.open_url?.value, + env, + logger, + signal, + }); const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( { networkCapture: inputs.network_capture?.value, @@ -122,53 +131,95 @@ export function createStartAppiumRemoteSessionBuildFunction( logger.info( `Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` ); - const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger }); - // Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the - // simulator stream with it, and UiAutomator2 merges long recordings with it. The - // macOS session image does not ship ffmpeg, so install it concurrently with Appium. - // Both installs settle before the step continues or fails, so no install outlives it. - const [appiumInstall] = await Promise.all([ - asyncResult( - installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger }) - ), - ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }), - ]); - const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue(); - const wdaCapabilities = await preparePreinstalledWdaAsync({ - runtimeIdentifier: device.runtimeIdentifier, - appiumHome, - appiumBinPath, - appiumEnv, - logger, - }); - - const appiumProcess = spawnDetached({ - command: appiumBinPath, - args: [ - '--address', - APPIUM_HOST, - '--port', - String(APPIUM_PORT), - '--base-path', - '/', - '--log-level', - 'error', - // Appium 3 gates session listing (GET /appium/sessions) behind the - // session_discovery insecure feature. We rely on it to poll for - // Appium Event Timings, so enable it for all drivers. - '--allow-insecure', - '*:session_discovery', - '--default-capabilities', - JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }), - ], - env: appiumEnv, - logger, - }); + const device: AppiumDevice = iosStartup + ? { + platformName: 'iOS', + automationName: 'XCUITest', + driverName: 'xcuitest', + udid: iosStartup.iosSimulatorUdid, + } + : await resolveAppiumDeviceAsync({ runtimePlatform, env }); + const startupAbortController = new AbortController(); + const startupSignal = signal + ? AbortSignal.any([signal, startupAbortController.signal]) + : startupAbortController.signal; + let hostStartup: Promise> | undefined; + let appiumProcess: ReturnType | undefined; + let appiumHome: string | undefined; let eventCollection: Awaited> | undefined; let appiumTunnel: Awaited> | undefined; - let sessionHost: DeviceSessionHost | undefined; let sessionFailed = false; + const startHost = () => + startDeviceSessionHostAsync(ctx, { + ...iosStartup, + separateLogPhase: true, + runtimePlatform, + env, + logger, + timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, + frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), + corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), + signal: startupSignal, + ...launch, + networkCapture, + networkCaptureFields, + }); try { + if (iosStartup) { + hostStartup = asyncResult(startHost()); + } + // Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the + // simulator stream with it, and UiAutomator2 merges long recordings with it. The + // macOS session image does not ship ffmpeg, so install it concurrently with Appium. + // Both installs settle before the step continues or fails, so no install outlives it. + const [appiumInstall] = await Promise.all([ + asyncResult( + installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger }) + ), + ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }), + ]); + const installation = appiumInstall.enforceValue(); + appiumHome = installation.appiumHome; + const { appiumBinPath, appiumEnv } = installation; + const wdaCapabilities = await preparePreinstalledWdaAsync({ + runtimeIdentifier: iosStartup + ? ( + await IosSimulatorUtils.getDeviceAsync({ + udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid, + env, + }) + )?.runtime + : device.runtimeIdentifier, + appiumHome, + appiumBinPath, + appiumEnv, + logger, + }); + + appiumProcess = spawnDetached({ + command: appiumBinPath, + args: [ + '--address', + APPIUM_HOST, + '--port', + String(APPIUM_PORT), + '--base-path', + '/', + '--log-level', + 'error', + // Appium 3 gates session listing (GET /appium/sessions) behind the + // session_discovery insecure feature. We rely on it to poll for + // Appium Event Timings, so enable it for all drivers. + '--allow-insecure', + '*:session_discovery', + '--default-capabilities', + JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }), + ], + env: appiumEnv, + logger, + }); await waitForAppiumReadyAsync({ appiumProcess, logger }); eventCollection = await startAppiumEventCollectionAsync({ ctx, @@ -190,22 +241,7 @@ export function createStartAppiumRemoteSessionBuildFunction( if (launchDescription) { logger.info(launchDescription); } - sessionHost = await startDeviceSessionHostAsync(ctx, { - separateLogPhase: true, - runtimePlatform, - env, - logger, - timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, - disableShare: inputs.public_preview?.value === false, - privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, - frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), - corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), - launchAppIdentifier: launch.launchAppIdentifier, - launchArgs: launch.launchArgs, - openUrl: launch.openUrl, - networkCapture, - networkCaptureFields, - }); + const sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken, @@ -248,6 +284,7 @@ export function createStartAppiumRemoteSessionBuildFunction( sessionFailed = true; throw error; } finally { + startupAbortController.abort(new Error('Appium session ended.')); await finishRemoteSessionAsync({ logger, sessionFailed, @@ -260,14 +297,16 @@ export function createStartAppiumRemoteSessionBuildFunction( await eventCollection?.stopAsync(); } finally { try { - await appiumProcess.stopAsync(); + await appiumProcess?.stopAsync(); } finally { - await fs.promises.rm(appiumHome, { recursive: true, force: true }); + if (appiumHome) { + await fs.promises.rm(appiumHome, { recursive: true, force: true }); + } } } })(), ], - ['session host', sessionHost?.finishAsync()], + ['session host', hostStartup?.then(result => result.value?.finishAsync())], ], }); } @@ -297,15 +336,12 @@ export type AppiumDevice = { export async function resolveAppiumDeviceAsync({ runtimePlatform, env, - logger, }: { runtimePlatform: BuildRuntimePlatform; env: BuildStepEnv; - logger: bunyan; }): Promise { switch (runtimePlatform) { case BuildRuntimePlatform.DARWIN: { - await selectXcodeDeveloperDirectoryAsync({ env, logger }); const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'booted', From d66908e81c2d75b2cdc7711aef132ddf36614faf Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 11:26:15 -0700 Subject: [PATCH 2/5] [build-tools] Mock Appium app metadata at its own boundary --- .changeset/serve-sim-pr-4603.md | 5 +++++ .../functions/__tests__/startAppiumRemoteSession.test.ts | 1 + .../src/steps/functions/startAppiumRemoteSession.ts | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/serve-sim-pr-4603.md diff --git a/.changeset/serve-sim-pr-4603.md b/.changeset/serve-sim-pr-4603.md new file mode 100644 index 0000000000..5f5a497951 --- /dev/null +++ b/.changeset/serve-sim-pr-4603.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Let serve-sim boot, install and launch Appium iOS sessions. diff --git a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts index f39dab8bc8..c68082cae2 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -56,6 +56,7 @@ jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.mock('../../utils/deviceSessionHost'); jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() })); jest.mock('../../utils/iosAppArtifact', () => ({ + ...jest.requireActual('../../utils/iosAppArtifact'), readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'), })); jest.mock('../../utils/localEgressGuard', () => ({ diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index 82a5c24659..bb0c5fbd7b 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -27,7 +27,7 @@ import { parseNetworkCaptureInputs, } from '../utils/networkCaptureFields'; import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils'; -import { type IosSimulatorUuid, IosSimulatorUtils } from '../../utils/IosSimulatorUtils'; +import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; import { PackageManager, resolveConfiguredPackageManager, From 511d0f1ecf4ebfd2e5ef9ebfdaa63edfcacf398f Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 15 Oct 2026 11:20:48 -0700 Subject: [PATCH 3/5] [build-tools] Stop Appium setup when Simulator startup fails Co-Authored-By: Codex --- .../startAppiumRemoteSession.test.ts | 212 +++++++++++++++++- .../functions/startAppiumRemoteSession.ts | 173 +++++++++++--- .../utils/__tests__/preinstalledWda.test.ts | 35 +++ .../__tests__/remoteDeviceRunSession.test.ts | 81 +++++++ .../src/steps/utils/preinstalledWda.ts | 11 + .../src/steps/utils/remoteDeviceRunSession.ts | 50 ++++- 6 files changed, 519 insertions(+), 43 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts index c68082cae2..a3f8b0e922 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -6,12 +6,14 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { type CustomBuildContext } from '../../../customBuildContext'; import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils'; import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; +import * as processes from '../../../utils/processes'; import { sleepAsync } from '../../../utils/retry'; import { turtleFetch } from '../../../utils/turtleFetch'; import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents'; import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost'; import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda'; import { + ensureFfmpegInstalledOnceAsync, selectXcodeDeveloperDirectoryAsync, spawnDetached, startNgrokTunnelAsync, @@ -38,6 +40,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({ jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), selectXcodeDeveloperDirectoryAsync: jest.fn(), + ensureFfmpegInstalledOnceAsync: jest.fn(), spawnDetached: jest.fn(), startNgrokTunnelAsync: jest.fn(), waitForDeviceRunSessionStoppedAsync: jest.fn(), @@ -224,6 +227,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => const stopEventCollection = jest.fn(); beforeEach(() => { + jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined); jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({}); jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ udid: 'CHOSEN-IOS-UDID', @@ -272,7 +276,8 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => async function runSessionAsync( logger: { info: jest.Mock; warn: jest.Mock }, maxIdleTimeMinutes?: number, - runtimePlatform = BuildRuntimePlatform.LINUX + runtimePlatform = BuildRuntimePlatform.LINUX, + startupInputs: Record = {} ): Promise { await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!( { @@ -283,6 +288,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => inputs: { package_version: { value: undefined }, max_idle_time_minutes: { value: maxIdleTimeMinutes }, + ...startupInputs, }, outputs: {}, env: { @@ -316,7 +322,11 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => 'appium:usePreinstalledWDA': true, 'appium:prebuiltWDAPath': '/tmp/legacy-wda.app', }); - await runSessionAsync({ info: jest.fn(), warn: jest.fn() }, undefined, BuildRuntimePlatform.DARWIN); + await runSessionAsync( + { info: jest.fn(), warn: jest.fn() }, + undefined, + BuildRuntimePlatform.DARWIN + ); expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith( expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' }) ); @@ -391,8 +401,12 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => ); expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled(); + const readyHost = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value; + expect(readyHost.finishAsync).not.toHaveBeenCalled(); + expect(jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].signal?.aborted).toBe(false); releaseInstall({ stdout: '' }); await running; + expect(readyHost.finishAsync).toHaveBeenCalledTimes(1); expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({ udid: 'CHOSEN-IOS-UDID', env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }), @@ -412,6 +426,200 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => expect(stopAppium).toHaveBeenCalledTimes(1); }); + it('drains a failed Simulator startup installer before removing its Appium home', async () => { + const hostError = new Error('Simulator boot failed'); + let failHost!: (error: Error) => void; + jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce( + new Promise((_resolve, reject) => { + failHost = reject; + }) + ); + let enteredInstall!: () => void; + const installEntered = new Promise(resolve => { + enteredInstall = resolve; + }); + let closeInstaller!: (error: Error) => void; + const child = { pid: 12345, kill: jest.fn() }; + jest.mocked(spawn).mockImplementationOnce(() => { + enteredInstall(); + return Object.assign( + new Promise((_resolve, reject) => { + closeInstaller = reject; + }), + { child } + ) as never; + }); + const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {}); + const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, { + boot_simulator: { value: true }, + device_identifier: { value: 'chosen-device' }, + }); + const settled = jest.fn(); + const observed = running.then(settled, settled); + try { + await installEntered; + const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string; + const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1]; + hostOptions.onStartupError?.(hostError); + failHost(hostError); + await Promise.resolve(); + expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL'); + expect(settled).not.toHaveBeenCalled(); + expect(fs.existsSync(home)).toBe(true); + closeInstaller(new Error('installer interrupted')); + await expect(running).rejects.toBe(hostError); + expect(fs.existsSync(home)).toBe(false); + expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled(); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + } finally { + closeInstaller?.(new Error('test cleanup')); + await observed; + } + }); + + it('removes a completed Appium install when Simulator failure interrupts FFmpeg setup', async () => { + const hostError = new Error('Simulator startup failed'); + let failHost!: (error: Error) => void; + jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce( + new Promise((_resolve, reject) => { + failHost = reject; + }) + ); + jest + .mocked(ensureFfmpegInstalledOnceAsync) + .mockImplementationOnce( + ({ signal }) => + new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true })) + ); + let installed!: () => void; + const installationFinished = new Promise(resolve => { + installed = resolve; + }); + jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => { + if (args[0] === 'driver' && args[1] === 'install') { + installed(); + } + return { stdout: args.includes('--json') ? '{}' : '' }; + }) as never); + const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, { + boot_simulator: { value: true }, + device_identifier: { value: 'chosen-device' }, + }); + await installationFinished; + await new Promise(resolve => setImmediate(resolve)); + jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError); + failHost(hostError); + await expect(running).rejects.toBe(hostError); + const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string; + expect(fs.existsSync(home)).toBe(false); + expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled(); + }); + + it('preserves an installer failure before a later host failure during home cleanup', async () => { + const installError = new Error('npm install failed'); + const hostError = new Error('later Simulator failure'); + let failHost!: (error: Error) => void; + jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce( + new Promise((_resolve, reject) => { + failHost = reject; + }) + ); + jest.mocked(spawn).mockRejectedValueOnce(installError); + const remove = fs.promises.rm.bind(fs.promises); + let enteredCleanup!: () => void; + const cleanupEntered = new Promise(resolve => { + enteredCleanup = resolve; + }); + let releaseCleanup!: () => void; + const cleanupGate = new Promise(resolve => { + releaseCleanup = resolve; + }); + jest.spyOn(fs.promises, 'rm').mockImplementationOnce(async (directory, options) => { + enteredCleanup(); + await cleanupGate; + await remove(directory, options); + }); + const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, { + boot_simulator: { value: true }, + device_identifier: { value: 'chosen-device' }, + }); + const observed = running.catch(() => {}); + try { + await cleanupEntered; + const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1]; + expect(options.signal?.reason).toBe(installError); + options.onStartupError?.(hostError); + failHost(hostError); + releaseCleanup(); + await expect(running).rejects.toBe(installError); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + } finally { + failHost(hostError); + releaseCleanup(); + await observed; + } + }); + + it.each(['WDA download', 'Appium readiness'])( + 'keeps the Simulator error when it interrupts %s', + async stage => { + stopAppium.mockResolvedValue(undefined); + const hostError = new Error('Simulator launch failed'); + const laterError = new Error(`${stage} interrupted`); + let failHost!: (error: Error) => void; + jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce( + new Promise((_resolve, reject) => { + failHost = reject; + }) + ); + let enteredStage!: () => void; + const stageEntered = new Promise(resolve => { + enteredStage = resolve; + }); + let rejectStage!: (error: Error) => void; + let setupSignal: AbortSignal | undefined; + const waitForStage = (signal?: AbortSignal): Promise => { + setupSignal = signal; + enteredStage(); + return new Promise((_resolve, reject) => { + rejectStage = reject; + signal?.addEventListener('abort', () => reject(laterError), { once: true }); + }); + }; + if (stage === 'WDA download') { + jest + .mocked(preparePreinstalledWdaAsync) + .mockImplementationOnce(({ signal }) => waitForStage(signal)); + } else { + jest + .mocked(turtleFetch) + .mockImplementationOnce((_url, _method, options) => + waitForStage(options?.signal ?? undefined) + ); + } + const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, { + boot_simulator: { value: true }, + device_identifier: { value: 'chosen-device' }, + }); + const observed = running.catch(() => {}); + try { + await stageEntered; + jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError); + failHost(hostError); + await Promise.resolve(); + expect(setupSignal?.aborted).toBe(true); + await expect(running).rejects.toBe(hostError); + const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string; + expect(fs.existsSync(home)).toBe(false); + expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled(); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + } finally { + rejectStage?.(laterError); + await observed; + } + } + ); + it('keeps the readiness error when Appium cannot be stopped', async () => { let now = Date.now(); jest.spyOn(Date, 'now').mockImplementation(() => now); diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index bb0c5fbd7b..4eb0073ad9 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -8,10 +8,11 @@ import { BuildStepInput, BuildStepInputValueTypeName, } from '@expo/steps'; -import spawn from '@expo/turtle-spawn'; +import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import semver from 'semver'; import { z } from 'zod'; @@ -33,6 +34,7 @@ import { resolveConfiguredPackageManager, resolvePackageAdd, } from '../../utils/packageManager'; +import { killProcessGroup } from '../../utils/processes'; import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; import { startAppiumEventCollectionAsync } from '../utils/appiumEvents'; @@ -143,7 +145,9 @@ export function createStartAppiumRemoteSessionBuildFunction( const startupSignal = signal ? AbortSignal.any([signal, startupAbortController.signal]) : startupAbortController.signal; + const setupSignal = iosStartup ? startupSignal : undefined; let hostStartup: Promise> | undefined; + let sessionHost: DeviceSessionHost | undefined; let appiumProcess: ReturnType | undefined; let appiumHome: string | undefined; let eventCollection: Awaited> | undefined; @@ -162,9 +166,15 @@ export function createStartAppiumRemoteSessionBuildFunction( frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), signal: startupSignal, + onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, ...launch, networkCapture, networkCaptureFields, + }).catch(error => { + if (iosStartup) { + startupAbortController.abort(error); + } + throw error; }); try { if (iosStartup) { @@ -173,15 +183,30 @@ export function createStartAppiumRemoteSessionBuildFunction( // Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the // simulator stream with it, and UiAutomator2 merges long recordings with it. The // macOS session image does not ship ffmpeg, so install it concurrently with Appium. - // Both installs settle before the step continues or fails, so no install outlives it. + // Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner. const [appiumInstall] = await Promise.all([ asyncResult( - installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger }) + installAppiumAsync({ + versionSpec, + driverName: device.driverName, + env, + logger, + signal: setupSignal, + onError: iosStartup ? error => startupAbortController.abort(error) : undefined, + }).catch(error => { + if (iosStartup) { + startupAbortController.abort(error); + } + throw error; + }) + ), + asyncResult( + ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal }) ), - ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }), ]); const installation = appiumInstall.enforceValue(); appiumHome = installation.appiumHome; + setupSignal?.throwIfAborted(); const { appiumBinPath, appiumEnv } = installation; const wdaCapabilities = await preparePreinstalledWdaAsync({ runtimeIdentifier: iosStartup @@ -196,8 +221,10 @@ export function createStartAppiumRemoteSessionBuildFunction( appiumBinPath, appiumEnv, logger, + signal: setupSignal, }); + setupSignal?.throwIfAborted(); appiumProcess = spawnDetached({ command: appiumBinPath, args: [ @@ -220,7 +247,11 @@ export function createStartAppiumRemoteSessionBuildFunction( env: appiumEnv, logger, }); - await waitForAppiumReadyAsync({ appiumProcess, logger }); + await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal }); + if (hostStartup) { + sessionHost = (await hostStartup).enforceValue(); + } + setupSignal?.throwIfAborted(); eventCollection = await startAppiumEventCollectionAsync({ ctx, deviceRunSessionId, @@ -241,7 +272,7 @@ export function createStartAppiumRemoteSessionBuildFunction( if (launchDescription) { logger.info(launchDescription); } - const sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); + sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken, @@ -282,7 +313,7 @@ export function createStartAppiumRemoteSessionBuildFunction( }); } catch (error) { sessionFailed = true; - throw error; + throw setupSignal?.aborted ? setupSignal.reason : error; } finally { startupAbortController.abort(new Error('Appium session ended.')); await finishRemoteSessionAsync({ @@ -378,65 +409,135 @@ export async function installAppiumAsync({ driverName, env, logger, + signal, + onError, }: { versionSpec: string; driverName: AppiumDevice['driverName']; env: BuildStepEnv; logger: bunyan; + signal?: AbortSignal; + onError?: (error: unknown) => void; }): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> { + signal?.throwIfAborted(); const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-')); - await fs.promises.writeFile( - path.join(appiumHome, 'package.json'), - `${JSON.stringify({ name: 'eas-appium-home', private: true })}\n` - ); - const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome }; - const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium'); - const add = resolvePackageAdd( - resolveConfiguredPackageManager(env, PackageManager.NPM), - `appium@${versionSpec}` - ); + try { + signal?.throwIfAborted(); + await fs.promises.writeFile( + path.join(appiumHome, 'package.json'), + `${JSON.stringify({ name: 'eas-appium-home', private: true })}\n` + ); + const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome }; + const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium'); + const add = resolvePackageAdd( + resolveConfiguredPackageManager(env, PackageManager.NPM), + `appium@${versionSpec}` + ); - logger.info(`Installing appium@${versionSpec} with ${add.command}.`); - await spawn(add.command, add.args, { - cwd: appiumHome, - env: appiumEnv, - logger, - }); - const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], { - env: appiumEnv, - stdio: 'pipe', - }); - const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout)); - if (installedDrivers[driverName]?.installed) { - logger.info(`Updating the installed Appium ${driverName} driver.`); - await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger }); - } else { - logger.info(`Installing the Appium ${driverName} driver.`); - await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger }); + logger.info(`Installing appium@${versionSpec} with ${add.command}.`); + await runAppiumSetupCommandAsync( + add.command, + add.args, + { + cwd: appiumHome, + env: appiumEnv, + logger, + }, + signal + ); + const { stdout } = await runAppiumSetupCommandAsync( + appiumBinPath, + ['driver', 'list', '--installed', '--json'], + { + env: appiumEnv, + stdio: 'pipe', + }, + signal + ); + const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout)); + if (installedDrivers[driverName]?.installed) { + logger.info(`Updating the installed Appium ${driverName} driver.`); + await runAppiumSetupCommandAsync( + appiumBinPath, + ['driver', 'update', driverName], + { env: appiumEnv, logger }, + signal + ); + } else { + logger.info(`Installing the Appium ${driverName} driver.`); + await runAppiumSetupCommandAsync( + appiumBinPath, + ['driver', 'install', driverName], + { env: appiumEnv, logger }, + signal + ); + } + return { appiumHome, appiumBinPath, appiumEnv }; + } catch (error) { + onError?.(error); + if (signal) { + await fs.promises.rm(appiumHome, { recursive: true, force: true }); + signal.throwIfAborted(); + } + throw error; + } +} + +async function runAppiumSetupCommandAsync( + command: string, + args: string[], + options: SpawnOptions, + signal?: AbortSignal +): Promise { + if (!signal) { + return await spawn(command, args, options); + } + signal.throwIfAborted(); + const task = spawn(command, args, { ...options, detached: true }); + const abort = (): void => killProcessGroup(task.child, 'SIGKILL'); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + } + try { + const result = await task; + signal.throwIfAborted(); + return result; + } finally { + signal.removeEventListener('abort', abort); } - return { appiumHome, appiumBinPath, appiumEnv }; } async function waitForAppiumReadyAsync({ appiumProcess, logger, + signal, }: { appiumProcess: { getOutput: () => string }; logger: bunyan; + signal?: AbortSignal; }): Promise { const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS; while (Date.now() < deadline) { + signal?.throwIfAborted(); try { const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', { timeout: 2_000, retries: 0, logger, + signal, }); + signal?.throwIfAborted(); if (response.ok) { return; } } catch {} - await sleepAsync(1_000); + signal?.throwIfAborted(); + if (signal) { + await setTimeoutAsync(1_000, undefined, { signal }); + } else { + await sleepAsync(1_000); + } } const output = appiumProcess.getOutput(); throw new SystemError( diff --git a/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts b/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts index 989715ba96..76dc93eeff 100644 --- a/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import * as processes from '../../../utils/processes'; import { preparePreinstalledWdaAsync } from '../preinstalledWda'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); @@ -78,3 +79,37 @@ it('does not enable preinstalled startup when the download produces no runner', jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never); await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({}); }); + +it('drains an aborted WDA downloader before removing its partial artifact', async () => { + await fs.promises.mkdir(outdir, { recursive: true }); + const controller = new AbortController(); + const error = new Error('Simulator startup failed'); + const child = { pid: 12345, kill: jest.fn() }; + let closeDownloader!: (error: Error) => void; + jest.mocked(spawn).mockReturnValueOnce( + Object.assign( + new Promise((_resolve, reject) => { + closeDownloader = reject; + }), + { child } + ) as never + ); + const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {}); + const running = preparePreinstalledWdaAsync({ ...options, signal: controller.signal }); + const settled = jest.fn(); + const observed = running.then(settled, settled); + try { + controller.abort(error); + expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL'); + expect(settled).not.toHaveBeenCalled(); + expect(fs.existsSync(outdir)).toBe(true); + closeDownloader(new Error('download interrupted')); + await expect(running).rejects.toBe(error); + expect(fs.existsSync(outdir)).toBe(false); + expect(logger.warn).not.toHaveBeenCalled(); + } finally { + closeDownloader(error); + await observed; + killGroup.mockRestore(); + } +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 3f3e568d33..8c466fdfd9 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -18,6 +18,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; +import * as processes from '../../../utils/processes'; import { IosSimulatorRecordingUtils, SERVE_SIM_STOP_GRACE_PERIOD_MS, @@ -2176,6 +2177,86 @@ describe(ensureFfmpegInstalledOnceAsync, () => { expect(spawnMock).toHaveBeenCalledTimes(3); }); + it('drains an owned FFmpeg installer on abort without rejecting shared callers', async () => { + const controller = new AbortController(); + const error = new Error('Simulator startup failed'); + const child = { pid: 12345, kill: jest.fn() }; + let closeInstaller!: (error: Error) => void; + spawnMock.mockReturnValueOnce( + Object.assign( + new Promise((_resolve, reject) => { + closeInstaller = reject; + }), + { child } + ) as never + ); + const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {}); + const options = { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createPathEnv(), + logger: createLoggerMock(), + }; + const owner = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal }); + const settled = jest.fn(); + const observed = owner.then(settled, settled); + try { + await new Promise(resolve => setImmediate(resolve)); + const shared = ensureFfmpegInstalledOnceAsync(options); + controller.abort(error); + expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL'); + expect(settled).not.toHaveBeenCalled(); + closeInstaller(new Error('brew interrupted')); + await expect(owner).rejects.toBe(error); + await expect(shared).resolves.toBeUndefined(); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(options.logger.warn).not.toHaveBeenCalled(); + } finally { + closeInstaller?.(error); + await observed; + killGroup.mockRestore(); + } + }); + + it('stops an aborted shared waiter without cancelling the installer owner', async () => { + let finishInstall!: () => void; + spawnMock.mockReturnValueOnce( + new Promise(resolve => { + finishInstall = resolve; + }) as never + ); + spawnMock.mockReturnValue(spawnResolved()); + const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {}); + const controller = new AbortController(); + const error = new Error('Appium startup failed'); + const options = { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createPathEnv(), + logger: createLoggerMock(), + }; + const owner = ensureFfmpegInstalledOnceAsync(options); + let ownerFinished = false; + const observedOwner = owner.then(() => { + ownerFinished = true; + }); + const waiter = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal }); + const observedWaiter = waiter.catch(() => {}); + try { + await new Promise(resolve => setImmediate(resolve)); + controller.abort(error); + await expect(waiter).rejects.toBe(error); + expect(ownerFinished).toBe(false); + expect(killGroup).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledTimes(1); + finishInstall(); + await expect(owner).resolves.toBeUndefined(); + expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall); + } finally { + finishInstall(); + await Promise.all([observedOwner, observedWaiter]); + killGroup.mockRestore(); + } + }); + it('still installs on linux when the apt index refresh fails', async () => { spawnMock .mockReturnValueOnce(spawnRejected()) // apt-get update diff --git a/packages/build-tools/src/steps/utils/preinstalledWda.ts b/packages/build-tools/src/steps/utils/preinstalledWda.ts index e58cc94efe..6ad49ca0cf 100644 --- a/packages/build-tools/src/steps/utils/preinstalledWda.ts +++ b/packages/build-tools/src/steps/utils/preinstalledWda.ts @@ -12,13 +12,16 @@ export async function preparePreinstalledWdaAsync({ appiumBinPath, appiumEnv, logger, + signal, }: { runtimeIdentifier: string | undefined; appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv; logger: bunyan; + signal?: AbortSignal; }): Promise> { + signal?.throwIfAborted(); const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec( runtimeIdentifier ?? '' )?.[1]; @@ -52,10 +55,17 @@ export async function preparePreinstalledWdaAsync({ // Appium's downloader inherits its pipes; killing only Appium leaves spawn's // close event pending. Kill the owned group, then await close before cleanup. const timeout = setTimeout(() => killProcessGroup(download.child, 'SIGKILL'), 60_000); + const abort = (): void => killProcessGroup(download.child, 'SIGKILL'); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) { + abort(); + } try { await download; + signal?.throwIfAborted(); } finally { clearTimeout(timeout); + signal?.removeEventListener('abort', abort); } if (!(await fs.promises.stat(runner)).isDirectory()) { throw new Error('The downloaded WebDriverAgent runner is not an app directory.'); @@ -67,6 +77,7 @@ export async function preparePreinstalledWdaAsync({ }; } catch (err) { await fs.promises.rm(outdir, { recursive: true, force: true }); + signal?.throwIfAborted(); logger.warn( { err }, 'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.' diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 3ddb98e4f2..297a39a091 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -25,7 +25,7 @@ import { parseOpenUrlInput, } from '../functions/launchApplication'; import { Sentry } from '../../sentry'; -import { isProcessGroupRunning } from '../../utils/processes'; +import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes'; import { sleepAsync } from '../../utils/retry'; import { createProcessOutput } from './processOutput'; import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout'; @@ -317,14 +317,28 @@ function warmUpFfmpeg(env: BuildStepEnv): void { async function installFfmpegWithHomebrewAsync({ env, logger, + signal, }: { env: BuildStepEnv; logger: bunyan; + signal?: AbortSignal; }): Promise { - await spawn('brew', ['install', 'ffmpeg'], { + signal?.throwIfAborted(); + const install = spawn('brew', ['install', 'ffmpeg'], { env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, logger, + ...(signal ? { detached: true } : {}), }); + const abort = (): void => killProcessGroup(install.child, 'SIGKILL'); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) { + abort(); + } + try { + await install; + } finally { + signal?.removeEventListener('abort', abort); + } } async function installFfmpegWithAptAsync({ @@ -362,13 +376,16 @@ async function ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, + signal, }: { runtimePlatform: BuildRuntimePlatform; env: BuildStepEnv; logger: bunyan; + signal?: AbortSignal; }): Promise { try { const ffmpegPath = await findFfmpegOnPathAsync(env); + signal?.throwIfAborted(); if (ffmpegPath) { logger.info(`ffmpeg is already installed at ${ffmpegPath}.`); warmUpFfmpeg(env); @@ -382,13 +399,17 @@ async function ensureFfmpegInstalledAsync({ } for the device session.` ); if (isDarwin) { - await installFfmpegWithHomebrewAsync({ env, logger }); + await installFfmpegWithHomebrewAsync({ env, logger, signal }); } else { await installFfmpegWithAptAsync({ env, logger }); } + signal?.throwIfAborted(); logger.info('Installed ffmpeg.'); warmUpFfmpeg(env); } catch (err) { + if (signal?.aborted) { + return; + } const error = err instanceof Error ? err : new Error(String(err)); Sentry.capture('Could not install ffmpeg for the device session', error, { level: 'warning', @@ -404,20 +425,39 @@ export async function ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, + signal, }: { runtimePlatform: BuildRuntimePlatform; env: BuildStepEnv; logger: bunyan; + signal?: AbortSignal; }): Promise { + signal?.throwIfAborted(); if (ffmpegSetupPromise) { - await ffmpegSetupPromise; + if (signal) { + const sharedSetup = ffmpegSetupPromise; + await new Promise((resolve, reject) => { + const abort = (): void => reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + void sharedSetup.then(() => { + signal.removeEventListener('abort', abort); + resolve(); + }); + if (signal.aborted) { + abort(); + } + }); + } else { + await ffmpegSetupPromise; + } return; } - const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger }); + const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, signal }); ffmpegSetupPromise = setupPromise; try { await setupPromise; + signal?.throwIfAborted(); } finally { if (ffmpegSetupPromise === setupPromise) { ffmpegSetupPromise = undefined; From 88834d167cdda59a1cec8432790d809885dd8648 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 15 Oct 2026 11:35:16 -0700 Subject: [PATCH 4/5] [build-tools] Retry cancelled shared FFmpeg setup for surviving callers Co-Authored-By: Codex --- .../__tests__/remoteDeviceRunSession.test.ts | 53 +++++++++++++++++-- .../src/steps/utils/remoteDeviceRunSession.ts | 23 +++++--- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 8c466fdfd9..64975fcf6b 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -2177,7 +2177,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => { expect(spawnMock).toHaveBeenCalledTimes(3); }); - it('drains an owned FFmpeg installer on abort without rejecting shared callers', async () => { + it('retries one aborted FFmpeg install for two surviving shared callers after it drains', async () => { const controller = new AbortController(); const error = new Error('Simulator startup failed'); const child = { pid: 12345, kill: jest.fn() }; @@ -2190,6 +2190,13 @@ describe(ensureFfmpegInstalledOnceAsync, () => { { child } ) as never ); + let finishReplacement!: () => void; + spawnMock.mockReturnValueOnce( + new Promise(resolve => { + finishReplacement = resolve; + }) as never + ); + spawnMock.mockReturnValue(spawnResolved()); const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {}); const options = { runtimePlatform: BuildRuntimePlatform.DARWIN, @@ -2197,22 +2204,34 @@ describe(ensureFfmpegInstalledOnceAsync, () => { logger: createLoggerMock(), }; const owner = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal }); + const surviving = [ + ensureFfmpegInstalledOnceAsync(options), + ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }), + ]; const settled = jest.fn(); const observed = owner.then(settled, settled); + const borrowersSettled = jest.fn(); + const observedBorrowers = Promise.all(surviving).then(borrowersSettled, borrowersSettled); try { await new Promise(resolve => setImmediate(resolve)); - const shared = ensureFfmpegInstalledOnceAsync(options); controller.abort(error); expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL'); expect(settled).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledTimes(1); closeInstaller(new Error('brew interrupted')); await expect(owner).rejects.toBe(error); - await expect(shared).resolves.toBeUndefined(); - expect(spawnMock).toHaveBeenCalledTimes(1); + await new Promise(resolve => setImmediate(resolve)); + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(borrowersSettled).not.toHaveBeenCalled(); + finishReplacement(); + await expect(Promise.all(surviving)).resolves.toEqual([undefined, undefined]); + expect(spawnMock).toHaveBeenCalledTimes(3); + expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall); expect(options.logger.warn).not.toHaveBeenCalled(); } finally { closeInstaller?.(error); - await observed; + finishReplacement(); + await Promise.all([observed, observedBorrowers]); killGroup.mockRestore(); } }); @@ -2294,6 +2313,30 @@ describe(ensureFfmpegInstalledOnceAsync, () => { expect(jest.mocked(Sentry).capture).toHaveBeenCalled(); }); + it('does not retry an ordinary shared FFmpeg install failure', async () => { + let failInstall!: (error: Error) => void; + spawnMock.mockReturnValueOnce( + new Promise((_resolve, reject) => { + failInstall = reject; + }) as never + ); + const options = { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createPathEnv(), + logger: createLoggerMock(), + }; + const callers = [ + ensureFfmpegInstalledOnceAsync(options), + ensureFfmpegInstalledOnceAsync(options), + ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }), + ]; + await new Promise(resolve => setImmediate(resolve)); + failInstall(new Error('Homebrew unavailable')); + await expect(Promise.all(callers)).resolves.toEqual([undefined, undefined, undefined]); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(options.logger.warn).toHaveBeenCalledTimes(1); + }); + // The caller runs this with `void` and the worker installs no unhandledRejection // handler, so a rejection here would crash the process. `spawn` is not async and // can throw synchronously, which `asyncResult` cannot catch. diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 297a39a091..c75babbce3 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -356,7 +356,7 @@ async function installFfmpegWithAptAsync({ await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger }); } -let ffmpegSetupPromise: Promise | undefined; +let ffmpegSetupPromise: Promise<'aborted' | void> | undefined; /** * Install ffmpeg when the runtime does not already provide it. Device-session @@ -382,7 +382,7 @@ async function ensureFfmpegInstalledAsync({ env: BuildStepEnv; logger: bunyan; signal?: AbortSignal; -}): Promise { +}): Promise<'aborted' | void> { try { const ffmpegPath = await findFfmpegOnPathAsync(env); signal?.throwIfAborted(); @@ -408,7 +408,7 @@ async function ensureFfmpegInstalledAsync({ warmUpFfmpeg(env); } catch (err) { if (signal?.aborted) { - return; + return 'aborted'; } const error = err instanceof Error ? err : new Error(String(err)); Sentry.capture('Could not install ffmpeg for the device session', error, { @@ -434,21 +434,28 @@ export async function ensureFfmpegInstalledOnceAsync({ }): Promise { signal?.throwIfAborted(); if (ffmpegSetupPromise) { + const sharedSetup = ffmpegSetupPromise; + let outcome: 'aborted' | void; if (signal) { - const sharedSetup = ffmpegSetupPromise; - await new Promise((resolve, reject) => { + outcome = await new Promise<'aborted' | void>((resolve, reject) => { const abort = (): void => reject(signal.reason); signal.addEventListener('abort', abort, { once: true }); - void sharedSetup.then(() => { + void sharedSetup.then(result => { signal.removeEventListener('abort', abort); - resolve(); + resolve(result); }); if (signal.aborted) { abort(); } }); } else { - await ffmpegSetupPromise; + outcome = await sharedSetup; + } + if (outcome === 'aborted') { + if (ffmpegSetupPromise === sharedSetup) { + ffmpegSetupPromise = undefined; + } + await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal }); } return; } From 9c145a490e217a8629a2d2357feb28a79e9d6a4f Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 19 Oct 2026 18:09:11 -0700 Subject: [PATCH 5/5] [build-tools] Test Appium preview settings after startup rebase --- .../startAppiumRemoteSession.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts index a3f8b0e922..3c70590799 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -309,6 +309,34 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => ); } + it.each([false, true])( + 'preserves preview settings with Simulator startup %s', + async bootSimulator => { + stopAppium.mockResolvedValue(undefined); + jest + .mocked(IosSimulatorUtils.getAvailableDevicesAsync) + .mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]); + await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, { + ...(bootSimulator + ? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } } + : {}), + public_preview: { value: false }, + private_preview_url: { value: 'https://expo.dev/private-preview' }, + frame_ancestors: { value: ['https://preview.example.com'] }, + cors_origins: { value: ['https://api.example.com'] }, + }); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + disableShare: true, + privatePreviewUrl: 'https://expo.dev/private-preview', + frameAncestors: ['https://preview.example.com'], + corsOrigins: ['https://api.example.com'], + }) + ); + } + ); + it('selects Xcode only once for an already-prepared iOS session', async () => { stopAppium.mockResolvedValue(undefined); jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();