From 7c82ba26805c490af5362a69030795a934cfd716 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 13 Oct 2026 18:43:29 -0700 Subject: [PATCH 1/4] [build-tools] Launch preinstalled WDA in iOS remote sessions Edited-by: Codex --- .../startAppiumRemoteSession.test.ts | 10 ++- .../functions/startAppiumRemoteSession.ts | 12 ++- .../utils/__tests__/preinstalledWda.test.ts | 80 +++++++++++++++++++ .../src/steps/utils/preinstalledWda.ts | 66 +++++++++++++++ 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts create mode 100644 packages/build-tools/src/steps/utils/preinstalledWda.ts 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 0204695714..524d27d765 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -51,9 +51,12 @@ describe(resolveAppiumDeviceAsync, () => { }); it('returns XCUITest capabilities for the booted iOS simulator', async () => { - jest - .mocked(IosSimulatorUtils.getAvailableDevicesAsync) - .mockResolvedValue([{ udid: 'ios-simulator-id' } as never]); + jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([ + { + udid: 'ios-simulator-id', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + } as never, + ]); await expect( resolveAppiumDeviceAsync({ @@ -66,6 +69,7 @@ describe(resolveAppiumDeviceAsync, () => { automationName: 'XCUITest', driverName: 'xcuitest', udid: 'ios-simulator-id', + runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', }); expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger }); expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({ diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index ad780ead68..82c3caabe9 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -35,6 +35,7 @@ import { import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; import { startAppiumEventCollectionAsync } from '../utils/appiumEvents'; +import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda'; import { createServeSimLaunchInputProviders, describeServeSimLaunch, @@ -121,6 +122,13 @@ export function createStartAppiumRemoteSessionBuildFunction( 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, @@ -139,7 +147,7 @@ export function createStartAppiumRemoteSessionBuildFunction( '--allow-insecure', '*:session_discovery', '--default-capabilities', - JSON.stringify({ 'appium:eventTimings': true }), + JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }), ], env: appiumEnv, }); @@ -264,6 +272,7 @@ export type AppiumDevice = { automationName: 'XCUITest' | 'UiAutomator2'; driverName: 'xcuitest' | 'uiautomator2'; udid: string; + runtimeIdentifier?: string; }; export async function resolveAppiumDeviceAsync({ @@ -290,6 +299,7 @@ export async function resolveAppiumDeviceAsync({ automationName: 'XCUITest', driverName: 'xcuitest', udid: bootedDevice.udid, + runtimeIdentifier: bootedDevice.runtime, }; } case BuildRuntimePlatform.LINUX: { diff --git a/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts b/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts new file mode 100644 index 0000000000..160ed8ed2a --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts @@ -0,0 +1,80 @@ +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { preparePreinstalledWdaAsync } from '../preinstalledWda'; + +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); + +const appiumHome = path.join(os.tmpdir(), 'owned-appium-home'); +const outdir = path.join(appiumHome, 'preinstalled-wda'); +const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app'); +const logger = { info: jest.fn(), warn: jest.fn() }; +const options = { + runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + appiumHome, + appiumBinPath: path.join(appiumHome, 'node_modules', '.bin', 'appium'), + appiumEnv: { APPIUM_HOME: appiumHome }, + logger: logger as never, +}; + +it('downloads the driver-matched Simulator runner and retains its XCTest frameworks', async () => { + jest.mocked(spawn).mockImplementation((async () => { + await fs.promises.mkdir(path.join(runner, 'Frameworks', 'XCTest.framework'), { + recursive: true, + }); + return { stdout: '' }; + }) as never); + + await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({ + 'appium:usePreinstalledWDA': true, + 'appium:prebuiltWDAPath': runner, + }); + expect(spawn).toHaveBeenCalledWith( + options.appiumBinPath, + [ + 'driver', + 'run', + 'xcuitest', + 'download-wda', + '--', + '--outdir', + outdir, + '--platform', + 'iOS', + '--kind', + 'sim', + ], + expect.objectContaining({ env: options.appiumEnv, timeout: 60_000, killSignal: 'SIGKILL' }) + ); + expect( + (await fs.promises.stat(path.join(runner, 'Frameworks', 'XCTest.framework'))).isDirectory() + ).toBe(true); +}); + +it.each([ + undefined, + 'com.apple.CoreSimulator.SimRuntime.iOS-16-4', + 'com.apple.CoreSimulator.SimRuntime.tvOS-26-5', +])('keeps the existing startup for unsupported runtime %s', async runtimeIdentifier => { + await expect(preparePreinstalledWdaAsync({ ...options, runtimeIdentifier })).resolves.toEqual({}); + expect(spawn).not.toHaveBeenCalled(); +}); + +it('falls back after a failed download and removes only its own incomplete artifact', async () => { + await fs.promises.mkdir(outdir, { recursive: true }); + const sibling = path.join(appiumHome, 'keep'); + await fs.promises.writeFile(sibling, 'keep'); + jest.mocked(spawn).mockRejectedValue(new Error('download unavailable')); + + await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({}); + await expect(fs.promises.stat(outdir)).rejects.toThrow(); + await expect(fs.promises.readFile(sibling, 'utf8')).resolves.toBe('keep'); + expect(logger.warn).toHaveBeenCalled(); +}); + +it('does not enable preinstalled startup when the download produces no runner', async () => { + jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never); + await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({}); +}); diff --git a/packages/build-tools/src/steps/utils/preinstalledWda.ts b/packages/build-tools/src/steps/utils/preinstalledWda.ts new file mode 100644 index 0000000000..95f3339618 --- /dev/null +++ b/packages/build-tools/src/steps/utils/preinstalledWda.ts @@ -0,0 +1,66 @@ +import { type bunyan } from '@expo/logger'; +import { type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs'; +import path from 'node:path'; + +export async function preparePreinstalledWdaAsync({ + runtimeIdentifier, + appiumHome, + appiumBinPath, + appiumEnv, + logger, +}: { + runtimeIdentifier: string | undefined; + appiumHome: string; + appiumBinPath: string; + appiumEnv: BuildStepEnv; + logger: bunyan; +}): Promise> { + const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec( + runtimeIdentifier ?? '' + )?.[1]; + // WDA v13+ supports preinstalled launch on iOS 17 and newer. + if (!iosMajor || Number(iosMajor) < 17) { + return {}; + } + + const outdir = path.join(appiumHome, 'preinstalled-wda'); + const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app'); + try { + // The driver selects the release matching its installed WDA and the host architecture. + // Keep the Simulator runner intact; removing its XCTest frameworks breaks launch. + await spawn( + appiumBinPath, + [ + 'driver', + 'run', + 'xcuitest', + 'download-wda', + '--', + '--outdir', + outdir, + '--platform', + 'iOS', + '--kind', + 'sim', + ], + { env: appiumEnv, logger, timeout: 60_000, killSignal: 'SIGKILL' } + ); + if (!(await fs.promises.stat(runner)).isDirectory()) { + throw new Error('The downloaded WebDriverAgent runner is not an app directory.'); + } + logger.info('Using preinstalled WebDriverAgent to avoid xcodebuild during session creation.'); + return { + 'appium:usePreinstalledWDA': true, + 'appium:prebuiltWDAPath': runner, + }; + } catch (err) { + await fs.promises.rm(outdir, { recursive: true, force: true }); + logger.warn( + { err }, + 'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.' + ); + return {}; + } +} From d7c7e06fb05354c853e14a20bc231d12a6752e30 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 13 Oct 2026 18:45:08 -0700 Subject: [PATCH 2/4] [build-tools] Add preinstalled WDA changelog entry Edited-by: Codex --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c1592b773..18a22bc89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp)) + ### ๐Ÿงน Chores ## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14 From 79c029670496d8533f1f7fe24ade160a246ce365 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 14 Oct 2026 11:50:06 -0700 Subject: [PATCH 3/4] [build-tools] Bound the WDA downloader process tree --- .../__tests__/preinstalledWda.process.test.ts | 119 ++++++++++++++++++ .../utils/__tests__/preinstalledWda.test.ts | 2 +- .../src/steps/utils/preinstalledWda.ts | 14 ++- 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/preinstalledWda.process.test.ts diff --git a/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.process.test.ts b/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.process.test.ts new file mode 100644 index 0000000000..1d0e2f4fef --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.process.test.ts @@ -0,0 +1,119 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { preparePreinstalledWdaAsync } from '../preinstalledWda'; + +jest.unmock('node:fs'); +jest.unmock('@expo/turtle-spawn'); +jest.unmock('@expo/logger'); + +const realSetTimeout = setTimeout; +const realClearTimeout = clearTimeout; +const sleep = (ms: number): Promise => new Promise(resolve => realSetTimeout(resolve, ms)); + +async function waitUntil(predicate: () => boolean): Promise { + const deadline = Date.now() + 3_000; + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Subprocess fixture did not reach the expected state'); + } + await sleep(20); + } +} + +function isRunning(pid: number): boolean { + try { + // Orphaned descendants can remain zombies until init reaps them. + const state = execFileSync('ps', ['-p', String(pid), '-o', 'stat=']) + .toString() + .trim(); + return state.length > 0 && !state.startsWith('Z'); + } catch { + return false; + } +} + +it('kills a stalled downloader and its descendants before fallback removes the artifact', async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wda-download-timeout-')); + const appiumHome = path.join(root, 'appium'); + const outdir = path.join(appiumHome, 'preinstalled-wda'); + const pidFiles = ['parent', 'child', 'grandchild'].map(name => path.join(root, `${name}.pid`)); + const executable = path.join(root, 'appium.cjs'); + const logger = { child: () => logger, info: jest.fn(), warn: jest.fn() }; + let preparation: Promise> | undefined; + let fallbackDeadline: NodeJS.Timeout | undefined; + try { + await fs.promises.mkdir(outdir, { recursive: true }); + await fs.promises.writeFile(path.join(outdir, 'incomplete'), 'partial download'); + await fs.promises.writeFile(path.join(appiumHome, 'keep'), 'unrelated'); + await fs.promises.writeFile( + executable, + `#!${process.execPath} +const fs = require('node:fs'); +const path = require('node:path'); +const {spawn} = require('node:child_process'); +const role = process.argv[2] === 'driver' ? 'parent' : process.argv[2]; +const outdir = role === 'parent' ? process.argv[process.argv.indexOf('--outdir') + 1] : process.argv[3]; +fs.writeFileSync(path.join(__dirname, role + '.pid'), String(process.pid)); +if (role !== 'grandchild') { + spawn(process.execPath, [__filename, role === 'parent' ? 'child' : 'grandchild', outdir], {stdio: 'inherit'}); +} +setInterval(() => { + if (role === 'grandchild') { + fs.mkdirSync(outdir, {recursive: true}); + fs.writeFileSync(path.join(outdir, 'incomplete'), 'still downloading'); + } +}, 50); +` + ); + await fs.promises.chmod(executable, 0o700); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'Date'] }); + preparation = preparePreinstalledWdaAsync({ + runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + appiumHome, + appiumBinPath: executable, + appiumEnv: {}, + logger: logger as never, + }); + await waitUntil(() => pidFiles.every(file => fs.existsSync(file))); + const pids = pidFiles.map(file => Number(fs.readFileSync(file, 'utf8'))); + expect(pids.every(isRunning)).toBe(true); + + jest.advanceTimersByTime(60_000); + await expect( + Promise.race([ + preparation, + new Promise((_, reject) => { + fallbackDeadline = realSetTimeout( + () => reject(new Error('Fallback remained blocked by inherited downloader pipes')), + 2_000 + ); + }), + ]) + ).resolves.toEqual({}); + expect(logger.warn.mock.calls[0]?.[0].err.signal).toBe('SIGKILL'); + await waitUntil(() => pids.every(pid => !isRunning(pid))); + await sleep(100); + expect(fs.existsSync(outdir)).toBe(false); + expect(fs.readFileSync(path.join(appiumHome, 'keep'), 'utf8')).toBe('unrelated'); + expect(logger.warn).toHaveBeenCalled(); + } finally { + realClearTimeout(fallbackDeadline); + jest.useRealTimers(); + // Also clean up the fixture if an assertion fails before the timeout fires. + for (const file of pidFiles) { + if (fs.existsSync(file)) { + try { + const pid = Number(fs.readFileSync(file, 'utf8')); + if (isRunning(pid)) { + process.kill(pid, 'SIGKILL'); + } + } catch {} + } + } + await preparation?.catch(() => {}); + await fs.promises.rm(root, { recursive: true, force: true }); + } +}, 10_000); 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 160ed8ed2a..989715ba96 100644 --- a/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/preinstalledWda.test.ts @@ -46,7 +46,7 @@ it('downloads the driver-matched Simulator runner and retains its XCTest framewo '--kind', 'sim', ], - expect.objectContaining({ env: options.appiumEnv, timeout: 60_000, killSignal: 'SIGKILL' }) + expect.objectContaining({ env: options.appiumEnv, detached: true }) ); expect( (await fs.promises.stat(path.join(runner, 'Frameworks', 'XCTest.framework'))).isDirectory() diff --git a/packages/build-tools/src/steps/utils/preinstalledWda.ts b/packages/build-tools/src/steps/utils/preinstalledWda.ts index 95f3339618..e58cc94efe 100644 --- a/packages/build-tools/src/steps/utils/preinstalledWda.ts +++ b/packages/build-tools/src/steps/utils/preinstalledWda.ts @@ -4,6 +4,8 @@ import spawn from '@expo/turtle-spawn'; import fs from 'node:fs'; import path from 'node:path'; +import { killProcessGroup } from '../../utils/processes'; + export async function preparePreinstalledWdaAsync({ runtimeIdentifier, appiumHome, @@ -30,7 +32,7 @@ export async function preparePreinstalledWdaAsync({ try { // The driver selects the release matching its installed WDA and the host architecture. // Keep the Simulator runner intact; removing its XCTest frameworks breaks launch. - await spawn( + const download = spawn( appiumBinPath, [ 'driver', @@ -45,8 +47,16 @@ export async function preparePreinstalledWdaAsync({ '--kind', 'sim', ], - { env: appiumEnv, logger, timeout: 60_000, killSignal: 'SIGKILL' } + { env: appiumEnv, logger, detached: true } ); + // 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); + try { + await download; + } finally { + clearTimeout(timeout); + } if (!(await fs.promises.stat(runner)).isDirectory()) { throw new Error('The downloaded WebDriverAgent runner is not an app directory.'); } From 3d19a14ec7d848a3ab0a345364ce42739de629c3 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 14 Oct 2026 14:16:50 -0700 Subject: [PATCH 4/4] [build-tools] Move WDA startup release note to a changeset --- .changeset/cold-wda-startup.md | 5 +++++ CHANGELOG.md | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .changeset/cold-wda-startup.md diff --git a/.changeset/cold-wda-startup.md b/.changeset/cold-wda-startup.md new file mode 100644 index 0000000000..1c88860b4a --- /dev/null +++ b/.changeset/cold-wda-startup.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a22bc89b..8c1592b773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,6 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes -- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp)) - ### ๐Ÿงน Chores ## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14