Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cold-wda-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---

Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent.
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
Expand Down Expand Up @@ -264,6 +272,7 @@ export type AppiumDevice = {
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
udid: string;
runtimeIdentifier?: string;
};

export async function resolveAppiumDeviceAsync({
Expand All @@ -290,6 +299,7 @@ export async function resolveAppiumDeviceAsync({
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: bootedDevice.udid,
runtimeIdentifier: bootedDevice.runtime,
};
}
case BuildRuntimePlatform.LINUX: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> => new Promise(resolve => realSetTimeout(resolve, ms));

async function waitUntil(predicate: () => boolean): Promise<void> {
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<Record<string, unknown>> | 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);
Original file line number Diff line number Diff line change
@@ -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, detached: true })
);
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({});
});
76 changes: 76 additions & 0 deletions packages/build-tools/src/steps/utils/preinstalledWda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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';

import { killProcessGroup } from '../../utils/processes';

export async function preparePreinstalledWdaAsync({
runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
}: {
runtimeIdentifier: string | undefined;
appiumHome: string;
appiumBinPath: string;
appiumEnv: BuildStepEnv;
logger: bunyan;
}): Promise<Record<string, unknown>> {
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.
const download = spawn(
appiumBinPath,
[
'driver',
'run',
'xcuitest',
'download-wda',
'--',
'--outdir',
outdir,
'--platform',
'iOS',
'--kind',
'sim',
],
{ 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.');
}
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 {};
}
}
Loading