Files
Juliusz Wajgelt a9307d8202 Split the iOS E2E template-app workflow and harden the Maestro runner (#58484)
Summary:
This PR separates the template app's iOS build from its Maestro test run and updates the shared iOS runner:

- Extract the existing template-app build steps into a `build` job. The project initialization, CocoaPods setup, and `xcodebuild` commands come from the previous combined `test` job; `Prepare artifacts` is renamed to `Build the app`.
- Add a separate `test` job that depends on `build` and runs the Debug and Release apps. It repeats the checkout, Node, and Yarn setup from the old job and moves the existing Maestro execution and status reporting into this job. Both jobs keep `macos-26-large`.
- Upload the built apps as artifacts and download them in the test job. Enable artifact replacement so the existing workflow retries can upload rebuilt apps under the same names.
- Add `Prepare project for Metro` for Debug tests. Its package lookup, template-branch selection, and `init-project-e2e.js` invocation are copied from the existing `Prepare artifacts` step. The new job has a fresh filesystem, so it needs to recreate the JavaScript project for Metro; this step only repeats project initialization, leaving CocoaPods and the native build in the build job.
- Update Maestro's `app-path` to point to the downloaded `RNTestProject.app`, replacing the path to the previous job's local Xcode build output.
- Pass the selected simulator's UDID to app installation and video recording instead of using `booted`, so both target the same simulator as Maestro.
- Make flow execution asynchronous and await the recorder's exit after sending SIGINT, so the video finishes writing and Node processes the child exit before the next flow starts. Send SIGKILL if the recorder has not exited within 30 seconds. (The missing wait predates this PR. Recordings were already uploaded as artifacts, but CI did not validate the videos, so recording problems could go unnoticed. [https://github.com/react/react-native/issues/57749](https://github.com/react/react-native/pull/57749) introduced a separate recorder per flow, reusing the same filenames, which allows the next recording to start before the previous one finishes.)
- Skip `helpers/` when collecting flows, since these files are fragments included by other tests through `runFlow`.
- Update unit tests to await flow execution and simulate recorder exit events. Add coverage for waiting on the recorder, skipping helpers, and reporting failure after all retries are exhausted.

## Changelog:

[INTERNAL] [CHANGED] - Separate iOS template-app E2E builds from test execution and improve Maestro runner reliability.

Pull Request resolved: https://github.com/react/react-native/pull/58484

Test Plan:
- Author-reported E2E validation used `macos-26` instead of the checked-in `macos-26-large`: RNTester passed 38/38 flows in both Debug and Release, and the template app passed in both configurations. These runs were not repeated during this review.
- Runner unit tests with the repository's Jest configuration: **8/8 passed** — `yarn jest .github/workflow-scripts/__tests__/maestro-ios-test.js`.
- `node --check .github/workflow-scripts/maestro-ios.js` — passed.
- Prettier check on the updated unit tests and workflow — passed.
- `git diff --check` — passed.
- E2E with the local fixes and artifact replacement during a GitHub Actions retry were not run during this review.

Reviewed By: christophpurrer

Differential Revision: D120135261

Pulled By: cipolleschi

fbshipit-source-id: 8cd04d8a034d595d14d380831b418183ea697feb
2026-09-17 08:09:56 -07:00

282 lines
8.3 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
const childProcess = require('child_process');
const fs = require('fs');
const usage = `
=== Usage ===
node maestro-ios.js <path to app> <app_id> <maestro_flow> <jsengine> <flavor> <working_directory> [device_model] [device_os]
@param {string} appPath - Path to the app APK
@param {string} appId - App ID that needs to be launched
@param {string} maestroFlow - Path to the maestro flow to be executed
@param {string} jsengine - The JSEngine to use for the test
@param {string} flavor - Flavor of the app to be launched. Can be 'Release' or 'Debug'
@param {string} workingDirectory - Working directory from where to run Metro
@param {string} deviceModel - Optional Maestro device model, such as iPhone-17-Pro
@param {string} deviceOS - Optional Maestro device OS, such as iOS-26-2
==============
`;
const MAX_ATTEMPTS = 5;
function findAvailableSimulator(deviceModel, deviceOS) {
const output = childProcess.execSync(
'xcrun simctl list devices available -j',
);
const devicesByRuntime = JSON.parse(String(output)).devices;
if ((deviceModel == null) !== (deviceOS == null)) {
throw new Error('Device model and OS must be configured together');
}
if (deviceModel != null && deviceOS != null) {
const runtime = `com.apple.CoreSimulator.SimRuntime.${deviceOS}`;
const simulatorName = deviceModel.replaceAll('-', ' ');
const simulator = devicesByRuntime[runtime]?.find(
device => device.name === simulatorName,
);
if (simulator == null) {
throw new Error(
`Unable to find ${simulatorName} simulator on ${deviceOS}`,
);
}
return simulator;
}
const devices = Object.values(devicesByRuntime).flat().reverse();
const simulator = devices.find(device => /^iPhone .* Pro$/.test(device.name));
if (simulator == null) {
throw new Error('Unable to find an available iPhone Pro simulator');
}
return simulator;
}
function launchSimulator(simulator) {
console.log(`Launching simulator ${simulator.name} (${simulator.udid})`);
try {
childProcess.execSync(`xcrun simctl boot "${simulator.udid}"`);
} catch (error) {
if (
!error.message.includes('Unable to boot device in current state: Booted')
) {
throw error;
}
}
}
function installAppOnSimulator(appPath, udid) {
console.log(`Installing app at path ${appPath}`);
childProcess.execSync(`xcrun simctl install "${udid}" "${appPath}"`);
}
function bringSimulatorInForeground() {
console.log('Bringing simulator in foreground');
childProcess.execSync('open -a simulator');
}
function sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
async function launchAppOnSimulator(appId, udid, isDebug) {
console.log('Launch the app');
childProcess.execSync(`xcrun simctl launch "${udid}" "${appId}"`);
if (isDebug) {
console.log('Wait for metro to warm');
await sleep(20 * 1000);
}
}
function startVideoRecording(udid, currentAttempt) {
console.log(
`Start video record using pid: video_record_${currentAttempt}.pid`,
);
const recordingArgs =
`simctl io ${udid} recordVideo --force video_record_${currentAttempt}.mov`.split(
' ',
);
const recordingProcess = childProcess.spawn('xcrun', recordingArgs, {
detached: true,
stdio: 'ignore',
});
return recordingProcess;
}
// The movie is only written after SIGINT, so returning early truncates it.
const RECORDING_SHUTDOWN_TIMEOUT_MS = 30 * 1000;
function stopVideoRecording(recordingProcess) {
if (!recordingProcess) {
console.log("Passed a null recording process. Can't kill it");
return Promise.resolve();
}
console.log(`Stop video record using pid: ${recordingProcess.pid}`);
if (
recordingProcess.exitCode != null ||
recordingProcess.signalCode != null
) {
return Promise.resolve();
}
// Awaiting the exit is also what reaps the child: the flows run in a
// synchronous loop, so nothing else turns the event loop.
return new Promise(resolve => {
let settled = false;
const done = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
console.log(
`Recorder ${recordingProcess.pid} did not exit in time, killing it`,
);
recordingProcess.kill('SIGKILL');
// SIGKILL on an already-exited process is a no-op that emits no `exit`,
// so resolve here instead of waiting for an event that may never come.
done();
}, RECORDING_SHUTDOWN_TIMEOUT_MS);
timer.unref?.();
recordingProcess.once('exit', done);
recordingProcess.once('error', done);
recordingProcess.kill('SIGINT');
// The process may have exited between the guard above and attaching the
// listeners; that `exit` event is already gone, so re-check and resolve.
if (
recordingProcess.exitCode != null ||
recordingProcess.signalCode != null
) {
done();
}
});
}
async function executeFlowWithRetries(
appId,
udid,
flow,
jsengine,
currentAttempt,
) {
const recProcess = startVideoRecording(udid, currentAttempt);
try {
const timeout = 1000 * 60 * 10; // 10 minutes
const command = `$HOME/.maestro/bin/maestro --udid="${udid}" test "${flow}" --format junit -e APP_ID="${appId}"`;
console.info(`Executing flow: ${flow} (attempt ${currentAttempt})`);
console.log(command);
childProcess.execSync(`MAESTRO_DRIVER_STARTUP_TIMEOUT=1500000 ${command}`, {
stdio: 'inherit',
timeout,
});
await stopVideoRecording(recProcess);
} catch (error) {
await stopVideoRecording(recProcess);
if (currentAttempt < MAX_ATTEMPTS) {
console.info(`Retrying flow: ${flow}`);
await executeFlowWithRetries(
appId,
udid,
flow,
jsengine,
currentAttempt + 1,
);
} else {
console.error(
`Failed to execute flow ${flow} after ${MAX_ATTEMPTS} attempts.`,
);
throw error;
}
}
}
async function executeFlows(appId, udid, maestroFlow, jsengine) {
if (!fs.existsSync(maestroFlow) || !fs.lstatSync(maestroFlow).isDirectory()) {
await executeFlowWithRetries(appId, udid, maestroFlow, jsengine, 1);
return;
}
for (const file of fs.readdirSync(maestroFlow).sort()) {
const filePath = `${maestroFlow.replace(/\/$/, '')}/${file}`;
if (fs.lstatSync(filePath).isDirectory()) {
// Fragments pulled in via `runFlow`; they have no `launchApp` of their
// own and fail when run standalone.
if (file === 'helpers') {
continue;
}
await executeFlows(appId, udid, filePath, jsengine);
} else if (file.endsWith('.yml') || file.endsWith('.yaml')) {
await executeFlowWithRetries(appId, udid, filePath, jsengine, 1);
}
}
}
async function main(args = process.argv.slice(2)) {
if (args.length < 6 || args.length > 8) {
throw new Error(`Invalid number of arguments.\n${usage}`);
}
const appPath = args[0];
const appId = args[1];
const maestroFlow = args[2];
const jsengine = args[3];
const isDebug = args[4] === 'Debug';
const workingDirectory = args[5];
const deviceModel = args[6] || null;
const deviceOS = args[7] || null;
console.info('\n==============================');
console.info('Running tests for iOS with the following parameters:');
console.info(`APP_PATH: ${appPath}`);
console.info(`APP_ID: ${appId}`);
console.info(`MAESTRO_FLOW: ${maestroFlow}`);
console.info(`JS_ENGINE: ${jsengine}`);
console.info(`IS_DEBUG: ${isDebug}`);
console.info(`WORKING_DIRECTORY: ${workingDirectory}`);
console.info(`DEVICE_MODEL: ${deviceModel ?? '<automatic>'}`);
console.info(`DEVICE_OS: ${deviceOS ?? '<automatic>'}`);
console.info('==============================\n');
const simulator = findAvailableSimulator(deviceModel, deviceOS);
launchSimulator(simulator);
installAppOnSimulator(appPath, simulator.udid);
bringSimulatorInForeground();
await launchAppOnSimulator(appId, simulator.udid, isDebug);
await executeFlows(appId, simulator.udid, maestroFlow, jsengine);
console.log('Test finished');
}
if (require.main === module) {
main();
}
module.exports = {
executeFlows,
findAvailableSimulator,
};