diff --git a/.changeset/prepared-appium-session.md b/.changeset/prepared-appium-session.md new file mode 100644 index 0000000000..9cef67180c --- /dev/null +++ b/.changeset/prepared-appium-session.md @@ -0,0 +1,6 @@ +--- +'@expo/simulator-launcher': minor +'@expo/build-tools': minor +--- + +Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. diff --git a/packages/build-tools/src/steps/__tests__/warmFunctions.test.ts b/packages/build-tools/src/steps/__tests__/warmFunctions.test.ts index 618aa526d1..19a2d54803 100644 --- a/packages/build-tools/src/steps/__tests__/warmFunctions.test.ts +++ b/packages/build-tools/src/steps/__tests__/warmFunctions.test.ts @@ -3,7 +3,7 @@ import { getEasFunctions } from '../easFunctions'; it('registers opt-in session functions with the existing inputs', () => { const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext); - for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote']) { + for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) { const existing = functions.find(fn => fn.id === `start_${name}_session`)!; const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!; expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`); diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index ac044eae08..fd52bc969c 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -115,6 +115,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { createStartArgentRemoteSessionBuildFunction(ctx), createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }), createStartAppiumRemoteSessionBuildFunction(ctx), + createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }), createStartAndroidEmulatorBuildFunction(), createStartCuttlefishDeviceBuildFunction(), createStartIosSimulatorBuildFunction(), 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 db9e5cd3c8..84a827e26c 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -13,6 +13,11 @@ import { turtleFetch } from '../../../utils/turtleFetch'; import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents'; import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost'; import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda'; +import { + WarmSessionAdoptionError, + runOnWarmIosSimulatorAsync, +} from '../../utils/warmDeviceSessionHost'; +import * as warmIosSession from '../../utils/warmIosSession'; import { ensureFfmpegInstalledOnceAsync, selectXcodeDeveloperDirectoryAsync, @@ -58,6 +63,14 @@ jest.mock('../../../utils/turtleFetch', () => ({ })); jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() })); jest.mock('../../utils/deviceSessionHost'); +jest.mock('../../utils/warmDeviceSessionHost', () => ({ + ...jest.createMockFromModule( + '../../utils/warmDeviceSessionHost' + ), + WarmSessionAdoptionError: jest.requireActual( + '../../utils/warmDeviceSessionHost' + ).WarmSessionAdoptionError, +})); jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() })); jest.mock('../../utils/iosAppArtifact', () => ({ ...jest.requireActual('../../utils/iosAppArtifact'), @@ -275,6 +288,36 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => { }); }); + it('claims the warm Simulator for Appium without its package version', async () => { + jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined); + const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, { + warm: true, + }); + expect(buildFunction.id).toBe('start_warm_appium_remote_session'); + await buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: '3.5.0' }, + max_idle_time_minutes: { value: undefined }, + device_identifier: { value: 'iPhone 17' }, + }, + outputs: {}, + env: { + DEVICE_RUN_SESSION_ID: 'session', + EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example', + NGROK_AUTHTOKEN: 'ngrok-token', + }, + } as never + ); + const [claim] = jest.mocked(runOnWarmIosSimulatorAsync).mock.calls[0]; + expect(claim).toMatchObject({ engine: 'appium', requestedDevice: 'iPhone 17' }); + expect(claim).not.toHaveProperty('packageVersion'); + }); + it('declares the launch inputs so serve-sim can launch the app', () => { const ctx = {} as unknown as CustomBuildContext; const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx); @@ -298,6 +341,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => const stopError = new Error('Process output drain timed out after 5000ms.'); const stopAppium = jest.fn(); const stopEventCollection = jest.fn(); + const finishHost = jest.fn(); beforeEach(() => { jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined); @@ -331,6 +375,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => subdomainId: 'appium-abc', stopAsync: jest.fn(), }); + finishHost.mockResolvedValue(undefined); jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({ openPreviewAsync: jest.fn().mockResolvedValue({ previewPageUrl: 'https://expo.dev/simulator-preview/preview-id', @@ -338,7 +383,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => closeAsync: jest.fn(), }), getLastPreviewActivityAt: jest.fn(), - finishAsync: jest.fn(), + finishAsync: finishHost, }); }); @@ -350,9 +395,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => logger: { info: jest.Mock; warn: jest.Mock }, maxIdleTimeMinutes?: number, runtimePlatform = BuildRuntimePlatform.LINUX, - startupInputs: Record = {} + startupInputs: Record = {}, + warm = false ): Promise { - await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!( + await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, { warm }).fn!( { logger, global: { runtimePlatform }, @@ -405,6 +451,187 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => expect(options.iosSimulatorUdid).toBeUndefined(); }); + function mockWarmSession(): jest.Mock { + const stopWarm = jest.fn().mockResolvedValue(undefined); + const warm: warmIosSession.WarmIosSession = { + device: { udid: 'CHOSEN-IOS-UDID', name: 'iPhone 17' }, + server: { pid: 100, port: 3200, token: 'warm-token' }, + agentDeviceStateDir: '/warm/agent-device', + argentEventLogPath: '/warm/argent-events', + prepareToolsAsync: jest.fn(), + process: { + pid: 100, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopWarm, + }, + }; + jest.spyOn(warmIosSession, 'tryClaimWarmIosSessionAsync').mockResolvedValue(warm); + jest + .mocked(runOnWarmIosSimulatorAsync) + .mockImplementation( + jest.requireActual( + '../../utils/warmDeviceSessionHost' + ).runOnWarmIosSimulatorAsync + ); + return stopWarm; + } + + it.each(['event collection', 'Appium process'])( + 'waits for warm %s teardown before finishing the session host', + async stage => { + mockWarmSession(); + stopAppium.mockResolvedValue(undefined); + let finishTeardown = () => {}; + const teardownGate = new Promise(resolve => { + finishTeardown = resolve; + }); + let startedTeardown = () => {}; + const teardownStarted = new Promise(resolve => { + startedTeardown = resolve; + }); + const stop = async () => { + startedTeardown(); + await teardownGate; + }; + (stage === 'event collection' ? stopEventCollection : stopAppium).mockImplementationOnce( + stop + ); + + const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true); + try { + await teardownStarted; + await Promise.resolve(); + expect(finishHost).not.toHaveBeenCalled(); + if (stage === 'event collection') { + expect(stopAppium).not.toHaveBeenCalled(); + } + } finally { + finishTeardown(); + await running; + } + expect(finishHost).toHaveBeenCalledTimes(1); + expect(stopAppium.mock.invocationCallOrder[0]).toBeLessThan( + finishHost.mock.invocationCallOrder[0] + ); + const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME; + expect(fs.existsSync(home ?? '')).toBe(false); + } + ); + + it.each(['event collection', 'Appium process'])( + 'finishes the warm host after %s teardown fails', + async stage => { + const stopWarm = mockWarmSession(); + stopAppium.mockResolvedValue(undefined); + const error = new Error(`${stage} stop failed`); + (stage === 'event collection' ? stopEventCollection : stopAppium).mockRejectedValueOnce( + error + ); + + await expect( + runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true) + ).rejects.toBe(error); + + expect(stopAppium).toHaveBeenCalledTimes(1); + expect(finishHost).toHaveBeenCalledTimes(1); + expect(stopWarm).toHaveBeenCalledTimes(1); + expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1); + } + ); + + it.each([false, true])( + 'finishes a cold host while Appium is still stopping (warm step: %s)', + async warm => { + if (warm) { + jest.mocked(runOnWarmIosSimulatorAsync).mockImplementationOnce((_claim, run) => run()); + } + let finishAppium = () => {}; + const appiumGate = new Promise(resolve => { + finishAppium = resolve; + }); + let startedAppiumStop = () => {}; + const appiumStopping = new Promise(resolve => { + startedAppiumStop = resolve; + }); + stopAppium.mockImplementationOnce(async () => { + startedAppiumStop(); + await appiumGate; + }); + let startedHostFinish = () => {}; + const hostFinishing = new Promise(resolve => { + startedHostFinish = resolve; + }); + finishHost.mockImplementationOnce(async () => startedHostFinish()); + + const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, warm); + try { + await appiumStopping; + await Promise.resolve(); + expect(finishHost).toHaveBeenCalledTimes(1); + await hostFinishing; + } finally { + finishAppium(); + await running; + } + } + ); + + it.each(['stopped', 'process stop failed', 'home cleanup failed'])( + 'retries a failed warm adoption only after Appium stops (%s)', + async cleanup => { + const stopFails = cleanup === 'process stop failed'; + const adoptionError = new WarmSessionAdoptionError(); + const stopWarm = mockWarmSession(); + let failHost: (error: Error) => void = () => {}; + jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce( + new Promise((_resolve, reject) => { + failHost = reject; + }) + ); + jest.mocked(spawnDetached).mockImplementationOnce(() => { + failHost(adoptionError); + return { + pid: 4242, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopAppium, + }; + }); + if (!stopFails) { + stopAppium.mockResolvedValue(undefined); + } + if (cleanup === 'home cleanup failed') { + jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(new Error('home cleanup failed')); + } + + const running = runSessionAsync( + { info: jest.fn(), warn: jest.fn() }, + undefined, + BuildRuntimePlatform.DARWIN, + { boot_simulator: { value: true }, device_identifier: { value: 'iPhone 17' } }, + true + ); + + if (stopFails) { + await expect(running).rejects.toMatchObject({ errors: [adoptionError, stopError] }); + expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1); + expect(stopWarm).toHaveBeenCalledTimes(1); + } else { + await expect(running).resolves.toBeUndefined(); + expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2); + expect(stopWarm).toHaveBeenCalledTimes(1); + } + expect(stopAppium).toHaveBeenCalledTimes(stopFails ? 1 : 2); + if (cleanup === 'home cleanup failed') { + const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME; + if (home) { + await fs.promises.rm(home, { recursive: true, force: true }); + } + } + } + ); + it.each([false, true])( 'preserves preview settings with Simulator startup %s', async bootSimulator => { diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index ab5028254a..5dc2643412 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -22,6 +22,12 @@ import { withLocalEgressSession, } from '../utils/localEgressSession'; import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { + WarmSessionAdoptionError, + createWarmDeviceSessionServer, + runOnWarmIosSimulatorAsync, +} from '../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../utils/warmIosSession'; import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; import { createNetworkCaptureInputProviders, @@ -73,13 +79,14 @@ const AppiumInstalledDriversSchema = z.record( ); export function createStartAppiumRemoteSessionBuildFunction( - ctx: CustomBuildContext + ctx: CustomBuildContext, + { warm = false }: { warm?: boolean } = {} ): BuildFunction { return new BuildFunction({ namespace: 'eas', - id: 'start_appium_remote_session', + id: warm ? 'start_warm_appium_remote_session' : 'start_appium_remote_session', name: 'Start Appium remote session', - __metricsId: 'eas/start_appium_remote_session', + __metricsId: warm ? 'eas/start_warm_appium_remote_session' : 'eas/start_appium_remote_session', inputProviders: [ ...createServeSimLaunchInputProviders(), ...createIosSessionStartupInputProviders(), @@ -110,247 +117,283 @@ export function createStartAppiumRemoteSessionBuildFunction( 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, - networkCaptureFields: inputs.network_capture_fields?.value, - }, - { runtimePlatform } - ); - const versionSpec = resolveAppium3VersionSpec(packageVersion); - - logger.info( - `Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` - ); - 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; - 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; - let appiumTunnel: Awaited> | undefined; - let sessionFailed = false; - const startHost = () => - startDeviceSessionHostAsync(ctx, { - ...iosStartup, - previewDeviceId: device.udid, - separateLogPhase: true, + const deviceIdentifier = inputs.device_identifier?.value as string | undefined; + const startSessionAsync = async (warmSession?: WarmIosSession): Promise => { + const { launch, iosStartup } = await resolveIosSessionStartupAsync({ runtimePlatform, + bootSimulator: inputs.boot_simulator?.value as boolean | undefined, + deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier, + 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, - timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, - preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, - 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, - onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, - ...launch, - networkCapture, - networkCaptureFields, - }).catch(error => { - if (iosStartup) { - startupAbortController.abort(error); - } - throw error; + signal, }); - 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 owned installs settle before proceeding; a shared FFmpeg install keeps its owner. - const [appiumInstall] = await Promise.all([ - asyncResult( - installAppiumAsync({ - versionSpec, - driverName: device.driverName, + const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( + { + networkCapture: inputs.network_capture?.value, + networkCaptureFields: inputs.network_capture_fields?.value, + }, + { runtimePlatform } + ); + const versionSpec = resolveAppium3VersionSpec(packageVersion); + + logger.info( + `Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` + ); + 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; + 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; + let appiumTunnel: Awaited> | undefined; + let sessionFailed = false; + let sessionError: unknown; + const startHost = () => + startDeviceSessionHostAsync( + ctx, + { + ...iosStartup, + previewDeviceId: device.udid, + separateLogPhase: true, + runtimePlatform, 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 }) - ), - ]); - const installation = appiumInstall.enforceValue(); - appiumHome = installation.appiumHome; - setupSignal?.throwIfAborted(); - 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, - signal: setupSignal, - }); + timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, + preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, + 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, + onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, + ...launch, + networkCapture, + networkCaptureFields, + }, + ...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : []) + ).catch(error => { + if (iosStartup) { + startupAbortController.abort(error); + } + throw error; + }); + 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 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, + 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 }) + ), + ]); + const installation = appiumInstall.enforceValue(); + appiumHome = installation.appiumHome; + setupSignal?.throwIfAborted(); + 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, + signal: setupSignal, + }); - setupSignal?.throwIfAborted(); - const useFdaHelper = - runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER); - appiumProcess = spawnDetached({ - command: useFdaHelper ? FDA_HELPER : appiumBinPath, - args: [ - ...(useFdaHelper ? [appiumBinPath] : []), - '--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, - // Without it, XCUITest restarted the booted Simulator at session start, which - // closed the app serve-sim had launched. EAS VMs never show the Simulator window. - ...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}), - ...wdaCapabilities, - }), - ], - env: appiumEnv, - logger, - }); - await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal }); - if (hostStartup) { - sessionHost = (await hostStartup).enforceValue(); - } - setupSignal?.throwIfAborted(); - eventCollection = await startAppiumEventCollectionAsync({ - ctx, - deviceRunSessionId, - appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`, - logger, - }); - appiumTunnel = await startNgrokTunnelAsync({ - port: APPIUM_PORT, - subdomainPrefix: 'appium', - baseDomain: ngrokTunnelDomain, - authtoken: ngrokAuthtoken, - logger, - }); + setupSignal?.throwIfAborted(); + const useFdaHelper = + runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER); + appiumProcess = spawnDetached({ + command: useFdaHelper ? FDA_HELPER : appiumBinPath, + args: [ + ...(useFdaHelper ? [appiumBinPath] : []), + '--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, + // Without it, XCUITest restarted the booted Simulator at session start, which + // closed the app serve-sim had launched. EAS VMs never show the Simulator window. + ...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}), + ...wdaCapabilities, + }), + ], + env: appiumEnv, + logger, + }); + await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal }); + if (hostStartup) { + sessionHost = (await hostStartup).enforceValue(); + } + setupSignal?.throwIfAborted(); + eventCollection = await startAppiumEventCollectionAsync({ + ctx, + deviceRunSessionId, + appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`, + logger, + }); + appiumTunnel = await startNgrokTunnelAsync({ + port: APPIUM_PORT, + subdomainPrefix: 'appium', + baseDomain: ngrokTunnelDomain, + authtoken: ngrokAuthtoken, + logger, + }); - // The preview host uses the same device selected for Appium. - const launchDescription = describeServeSimLaunch(launch); - if (launchDescription) { - logger.info(launchDescription); - } - sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); - const webPreview = await sessionHost.openPreviewAsync(); + // The preview host uses the same device selected for Appium. + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); + } + sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); + const webPreview = await sessionHost.openPreviewAsync(); - const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({ - env, - signal, - ctx, - deviceRunSessionId, - remoteConfig: { - appiumUrl: appiumTunnel.url, - capabilities: { - platformName: device.platformName, - 'appium:automationName': device.automationName, - 'appium:udid': device.udid, + const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({ + env, + signal, + ctx, + deviceRunSessionId, + remoteConfig: { + appiumUrl: appiumTunnel.url, + capabilities: { + platformName: device.platformName, + 'appium:automationName': device.automationName, + 'appium:udid': device.udid, + }, + webPreviewUrl: webPreview.previewPageUrl, + previewApiUrl: webPreview.apiUrl, + ...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}), }, - webPreviewUrl: webPreview.previewPageUrl, - previewApiUrl: webPreview.apiUrl, - ...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}), - }, - logger, - }); - if (sessionStart === 'stopped') { - return; - } + logger, + }); + if (sessionStart === 'stopped') { + return; + } - await waitForDeviceRunSessionStoppedAsync({ - ctx, - deviceRunSessionId, - logger, - signal, - idleTimeout: - maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0 - ? { - maxIdleTimeMinutes, - getLastEventObservedAt: eventCollection.getLastEventObservedAt, - getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(), - } - : undefined, - }); - } catch (error) { - sessionFailed = true; - throw setupSignal?.aborted ? setupSignal.reason : error; - } finally { - startupAbortController.abort(new Error('Appium session ended.')); - await finishRemoteSessionAsync({ - logger, - sessionFailed, - teardown: [ - ['Appium tunnel', appiumTunnel?.stopAsync()], - [ - 'Appium server', - (async () => { - try { - await eventCollection?.stopAsync(); - } finally { - try { - await appiumProcess?.stopAsync(); - } finally { - if (appiumHome) { - await fs.promises.rm(appiumHome, { recursive: true, force: true }); - } + await waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId, + logger, + signal, + idleTimeout: + maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0 + ? { + maxIdleTimeMinutes, + getLastEventObservedAt: eventCollection.getLastEventObservedAt, + getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(), } + : undefined, + }); + } catch (error) { + sessionFailed = true; + sessionError = setupSignal?.aborted ? setupSignal.reason : error; + throw sessionError; + } finally { + startupAbortController.abort(new Error('Appium session ended.')); + const appiumStopErrors: unknown[] = []; + const appiumTeardown = (async () => { + try { + await eventCollection?.stopAsync(); + } finally { + try { + await appiumProcess?.stopAsync(); + } catch (error) { + appiumStopErrors.push(error); + throw error; + } finally { + if (appiumHome) { + await fs.promises.rm(appiumHome, { recursive: true, force: true }); } - })(), + } + } + })(); + await finishRemoteSessionAsync({ + logger, + sessionFailed, + teardown: [ + ['Appium tunnel', appiumTunnel?.stopAsync()], + ['Appium server', appiumTeardown], + [ + 'session host', + hostStartup?.then(async result => { + if (warmSession) { + await Promise.allSettled([appiumTeardown]); + } + await result.value?.finishAsync(); + }), + ], ], - ['session host', hostStartup?.then(result => result.value?.finishAsync())], - ], - }); + }); + if (sessionError instanceof WarmSessionAdoptionError && appiumStopErrors.length) { + throw new AggregateError( + [sessionError, ...appiumStopErrors], + 'Could not stop Appium after warm adoption failed; a cold session cannot start safely.' + ); + } + } + }; + if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) { + await runOnWarmIosSimulatorAsync( + // package_version is the Appium version, which the job installs either way. + { engine: 'appium', requestedDevice: deviceIdentifier, env, logger, signal }, + startSessionAsync + ); + } else { + await startSessionAsync(); } }), }); diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index cc6a14d5ff..bc73cba894 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -15,11 +15,11 @@ exec bunx @expo/simulator-launcher@latest start - `start`: run one orphan serve-sim for the configured devices. Its default device becomes ready first; additional devices warm in the background. Argent and agent-device prestart on the default. - `claim ENGINE [DEVICE]`: reserve one job and return its selected device plus the existing server's - identity, port and credentials. Engines: `serve-sim`, `agent-device`, `argent`. + identity, port and credentials. Engines: `serve-sim`, `agent-device`, `argent`, `appium`. No DEVICE means the first configured device. Claim does not boot the selected device or wait for its tool. Build-tools privately adopts that device on the existing serve-sim port. - `prepare-tools`: continue the consumed claim after adoption. Keep global Argent, reuse the default - agent-device runner or prepare a runner for the selected device. + agent-device runner or prepare a runner for the selected device. Appium waits for old XCTest cleanup. - `stop`: permanently end warmup for this boot and wait for all owned resources to be cleaned up. ```sh @@ -66,6 +66,7 @@ serializes updates; an abandoned lock fails closed. This private format requires and app actions. Job-time version and authentication checks remain authoritative. - Agent-device prestarts on the default device only. A secondary claim prepares its runner after adoption and old-runner cleanup finish. One device-independent Argent server is reused for the claimed device. + Appium waits for unused tools to stop before starting its own WDA. - Warmup downloads/extracts the latest stable Expo Go once and reuses it for the second device. Each initial device gets a preinstall attempt; ordinary failures warn. Claim prevents new preinstalls. `prepare-tools` waits for an install still running on the claimed device and diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index 4824f60979..ec1bbe2092 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -325,7 +325,7 @@ test('stop during post-adopt preparation prevents toolsReady and waits for owned const [completion] = await beginSession(); await status('ready'); jest.spyOn(process.stdout, 'write').mockReturnValue(true); - await claimAsync(boot, 'serve-sim'); + await claimAsync(boot, 'appium'); const entered = deferred(); const release = deferred(); let preparationSignal: AbortSignal; diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index eca6ab7299..2509014259 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -377,6 +377,42 @@ it('keeps global Argent when selecting another device', async () => { await session.stopAsync(); }); +it.each(['appium'] as const)( + 'does not finish %s preparation before unused tool cleanup settles', + async engine => { + const retirement = deferred(); + jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => { + if (handle === agent) { + await retirement.promise; + } + }); + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine }); + let ready = false; + const preparing = session.prepareToolsAsync().then(() => { + ready = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(ready).toBe(false); + retirement.resolve(); + await preparing; + await session.stopAsync(); + } +); + +it('Appium preparation rejects failed XCTest retirement and terminal stop retries the owned resources', async () => { + const failure = new Error('XCTest stop failed'); + jest.mocked(stopProcessAndDescendantsAsync).mockRejectedValueOnce(failure); + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'appium' }); + await expect(session.prepareToolsAsync()).rejects.toThrow(); + // The terminal stop retries XCTest; that retry succeeds here, so the stop does too. + await session.stopAsync(); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO'); +}); + it('rejects a changed server identity before starting the selected runner', async () => { const session = createSession(); await session.prepareAsync(); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index b590113c11..7b8b00437f 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -468,7 +468,8 @@ export function createWarmSimulatorSession({ // runs in the background but is joined before teardown. startSimulatorsSetupAsync([info.device.udid], Promise.resolve()).catch(() => {}); const tool = warmTools.find(tool => tool.name === engine); - if (tool?.name === 'agent-device' && !startedTools.has(tool)) { + // In particular, Appium cannot start WDA until old XCTest retirement succeeded. + if (engine === 'appium' || (tool?.name === 'agent-device' && !startedTools.has(tool))) { const results = await toolRetirement; const failed = results.find(result => result.status === 'rejected'); if (failed?.status === 'rejected') { diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts index efe0feb8af..6764f7393a 100644 --- a/packages/simulator-launcher/src/utils/state.ts +++ b/packages/simulator-launcher/src/utils/state.ts @@ -20,7 +20,7 @@ export const WarmServerSchema = z.object({ }); const ToolServerSchema = WarmServerSchema.pick({ pid: true, port: true, token: true }); export const ClaimInputSchema = z.object({ - engine: z.enum(['serve-sim', 'agent-device', 'argent']), + engine: z.enum(['serve-sim', 'agent-device', 'argent', 'appium']), requestedDevice: NonBlankStringSchema.optional(), }); /** The Expo Go this launcher installed on `udid` and never launched. */