From 93117ea1b84685ec3b6ba8951c9fc3ce2ecdd933 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Tue, 31 Dec 2024 09:21:18 -0800 Subject: [PATCH] Move E2E scripts to js (#48419) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/48419 This change moves the E2E scripts for iOS to a JS script. This should make it much easier to modify the code in case we need to change it. ## Changelog [Internal] - Move e2e script from bash to JS Reviewed By: cortinico Differential Revision: D67737950 fbshipit-source-id: d0b0411c8a4d688c10e460e70b11dbfc83aaa135 --- .github/actions/maestro-ios/action.yml | 53 +------- .github/workflow-scripts/maestro-ios.js | 167 ++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 46 deletions(-) create mode 100644 .github/workflow-scripts/maestro-ios.js diff --git a/.github/actions/maestro-ios/action.yml b/.github/actions/maestro-ios/action.yml index e71fd6d0c2c53e..fe059326119836 100644 --- a/.github/actions/maestro-ios/action.yml +++ b/.github/actions/maestro-ios/action.yml @@ -57,52 +57,13 @@ runs: # Maestro can fail in case of flakyness, we have some retry logic. set +e - echo "Launching iOS Simulator: iPhone 15 Pro" - xcrun simctl boot "iPhone 15 Pro" - - echo "Installing app on Simulator" - xcrun simctl install booted "${{ inputs.app-path }}" - - echo "Retrieving device UDID" - UDID=$(xcrun simctl list devices booted -j | jq -r '[.devices[]] | add | first | .udid') - echo "UDID is $UDID" - - echo "Bring simulator in foreground" - open -a simulator - - echo "Launch the app" - xcrun simctl launch $UDID ${{ inputs.app-id }} - - if [[ ${{ inputs.flavor }} == 'Debug' ]]; then - # To give the app time to warm the metro's cache - sleep 20 - fi - - echo "Running tests with Maestro" - export MAESTRO_DRIVER_STARTUP_TIMEOUT=1500000 # 25 min. CI is extremely slow - - # Add retries for flakyness - MAX_ATTEMPTS=5 - CURR_ATTEMPT=0 - RESULT=1 - - while [[ $CURR_ATTEMPT -lt $MAX_ATTEMPTS ]] && [[ $RESULT -ne 0 ]]; do - CURR_ATTEMPT=$((CURR_ATTEMPT+1)) - echo "Attempt number $CURR_ATTEMPT" - - echo "Start video record using pid: video_record_${{ inputs.jsengine }}_$CURR_ATTEMPT.pid" - xcrun simctl io booted recordVideo video_record_$CURR_ATTEMPT.mov & echo $! > video_record_${{ inputs.jsengine }}_$CURR_ATTEMPT.pid - - echo '$HOME/.maestro/bin/maestro --udid=$UDID test ${{ inputs.maestro-flow }} --format junit -e APP_ID=${{ inputs.app-id }}' - $HOME/.maestro/bin/maestro --udid=$UDID test ${{ inputs.maestro-flow }} --format junit -e APP_ID=${{ inputs.app-id }} --debug-output /tmp/MaestroLogs - - RESULT=$? - - # Stop video - kill -SIGINT $(cat video_record_${{ inputs.jsengine }}_$CURR_ATTEMPT.pid) - done - - exit $RESULT + node .github/workflow-scripts/maestro-ios.js \ + "${{ inputs.app-path }}" \ + "${{ inputs.app-id }}" \ + "${{ inputs.maestro-flow }}" \ + "${{ inputs.jsengine }}" \ + "${{ inputs.flavor }}" \ + "${{ inputs.working-directory }}" - name: Store video record if: always() uses: actions/upload-artifact@v4.3.4 diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js new file mode 100644 index 00000000000000..cf0a578f5b352e --- /dev/null +++ b/.github/workflow-scripts/maestro-ios.js @@ -0,0 +1,167 @@ +/** + * 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-android.js + +@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 +============== +`; + +const args = process.argv.slice(2); + +if (args.length !== 6) { + throw new Error(`Invalid number of arguments.\n${usage}`); +} + +const APP_PATH = args[0]; +const APP_ID = args[1]; +const MAESTRO_FLOW = args[2]; +const JS_ENGINE = args[3]; +const IS_DEBUG = args[4] === 'Debug'; +const WORKING_DIRECTORY = args[5]; + +const MAX_ATTEMPTS = 5; + +function launchSimulator(simulatorName) { + console.log(`Launching simulator ${simulatorName}`); + try { + childProcess.execSync(`xcrun simctl boot "${simulatorName}"`); + } catch (error) { + if ( + !error.message.includes('Unable to boot device in current state: Booted') + ) { + throw error; + } + } +} + +function installAppOnSimulator(appPath) { + console.log(`Installing app at path ${appPath}`); + childProcess.execSync(`xcrun simctl install booted "${appPath}"`); +} + +function extractSimulatorUDID() { + console.log('Retrieving device UDID'); + const command = `xcrun simctl list devices booted -j | jq -r '[.devices[]] | add | first | .udid'`; + const udid = String(childProcess.execSync(command)).trim(); + console.log(`UDID is ${udid}`); + return udid; +} + +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(jsengine, currentAttempt) { + console.log( + `Start video record using pid: video_record_${jsengine}_${currentAttempt}.pid`, + ); + childProcess.exec( + `xcrun simctl io booted recordVideo video_record_${jsengine}_${currentAttempt}.mov & echo $! > video_record_${jsengine}_${currentAttempt}.pid`, + ); +} + +function stopVideoRecording(jsengine, currentAttempt) { + console.log( + `Stop video record using pid: video_record_${jsengine}_${currentAttempt}.pid`, + ); + childProcess.exec( + `kill -SIGINT $(cat video_record_${jsengine}_${currentAttempt}.pid)`, + ); +} + +function executeTestsWithRetries( + appId, + udid, + maestroFlow, + jsengine, + currentAttempt, +) { + try { + startVideoRecording(jsengine, currentAttempt); + + const timeout = 1000 * 60 * 10; // 10 minutes + const command = `$HOME/.maestro/bin/maestro --udid="${udid}" test "${maestroFlow}" --format junit -e APP_ID="${appId}"`; + console.log(command); + childProcess.execSync(`MAESTRO_DRIVER_STARTUP_TIMEOUT=1500000 ${command}`, { + stdio: 'inherit', + timeout, + }); + + stopVideoRecording(jsengine, currentAttempt); + } catch (error) { + // Can't put this in the finally block because it will be executed after the + // recursive call of executeTestsWithRetries + stopVideoRecording(jsengine, currentAttempt); + + if (currentAttempt < MAX_ATTEMPTS) { + executeTestsWithRetries( + appId, + udid, + maestroFlow, + jsengine, + currentAttempt + 1, + ); + } else { + console.error(`Failed to execute flow after ${MAX_ATTEMPTS} attempts.`); + throw error; + } + } +} + +async function main() { + console.info('\n=============================='); + console.info('Running tests for iOS with the following parameters:'); + console.info(`APP_PATH: ${APP_PATH}`); + console.info(`APP_ID: ${APP_ID}`); + console.info(`MAESTRO_FLOW: ${MAESTRO_FLOW}`); + console.info(`JS_ENGINE: ${JS_ENGINE}`); + console.info(`IS_DEBUG: ${IS_DEBUG}`); + console.info(`WORKING_DIRECTORY: ${WORKING_DIRECTORY}`); + console.info('==============================\n'); + + const simulatorName = 'iPhone 15 Pro'; + launchSimulator(simulatorName); + installAppOnSimulator(APP_PATH); + const udid = extractSimulatorUDID(); + bringSimulatorInForeground(); + await launchAppOnSimulator(APP_ID, udid, IS_DEBUG); + executeTestsWithRetries(APP_ID, udid, MAESTRO_FLOW, JS_ENGINE, 1); + console.log('Test finished'); + process.exit(0); +} + +main();