Skip to content
Draft
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
42 changes: 42 additions & 0 deletions .github/actions/prepare-hermes-v1-app/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: prepare-hermes-v1-app
description: Prepares a React Native app with Hermes V1 enabled
inputs:
retry-count:
description: 'Number of times to retry the yarn install on failure'
runs:
using: composite
steps:
- name: Create new app
shell: bash
run: |
cd /tmp
npx @react-native-community/cli init RNApp --skip-install --version nightly

- name: Apply patch if specified
shell: bash
run: |
if [ -f "$GITHUB_WORKSPACE/patches/hermes-v1.patch" ]; then
echo "Applying patch: hermes-v1.patch"
cd /tmp/RNApp
node "$GITHUB_WORKSPACE/scripts/apply-patch.js" "$GITHUB_WORKSPACE/patches/hermes-v1.patch"
echo "✅ Patch applied successfully"
else
echo "⚠️ Warning: Patch file not found: patches/hermes-v1.patch"
exit 1
fi

- name: Install app dependencies with retry
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: ${{ inputs.retry-count }}
retry_wait_seconds: 15
shell: bash
command: |
cd /tmp/RNApp
yarn install
on_retry_command: |
echo "Cleaning up for yarn retry..."
cd /tmp/RNApp
rm -rf node_modules yarn.lock || true
yarn cache clean || true
21 changes: 21 additions & 0 deletions .github/actions/setup-maestro/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: setup-maestro
description: Sets up the Maestro testing environment
runs:
using: composite
steps:
- name: Setup Java
uses: actions/setup-java@v2
with:
java-version: '17'
distribution: 'zulu'

- name: Install Maestro
shell: bash
run: export MAESTRO_VERSION=1.40.0; curl -Ls "https://get.maestro.mobile.dev" | bash

- name: Install Maestro dependencies
if: runner.os == 'macOS'
shell: bash
run: |
brew tap facebook/fb
brew install facebook/fb/idb-companion
4 changes: 4 additions & 0 deletions .github/workflow-scripts/e2e/.maestro/start.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
appId: ${APP_ID} # iOS: org.reactjs.native.example.RNApp | Android: com.rnapp
---
- launchApp
- assertVisible: "Welcome to React Native"
129 changes: 129 additions & 0 deletions .github/workflow-scripts/maestro-android.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* 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 <path to app> <app_id> <maestro_flow> <flavor> <working_directory>

@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} 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 !== 5) {
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 IS_DEBUG = args[3] === 'debug';
const WORKING_DIRECTORY = args[4];

const MAX_ATTEMPTS = 3;

async function executeFlowWithRetries(flow, currentAttempt) {
try {
console.info(`Executing flow: ${flow}`);
const timeout = 1000 * 60 * 10; // 10 minutes
childProcess.execSync(
`MAESTRO_DRIVER_STARTUP_TIMEOUT=120000 $HOME/.maestro/bin/maestro test ${flow} --format junit -e APP_ID=${APP_ID} --debug-output /tmp/MaestroLogs`,
{stdio: 'inherit', timeout},
);
} catch (err) {
if (currentAttempt < MAX_ATTEMPTS) {
console.info(`Retrying...`);
await executeFlowWithRetries(flow, currentAttempt + 1);
} else {
throw err;
}
}
}

async function executeFlowInFolder(flowFolder) {
const files = fs.readdirSync(flowFolder);
for (const file of files) {
const filePath = `${flowFolder}/${file}`;
if (fs.lstatSync(filePath).isDirectory()) {
await executeFlowInFolder(filePath);
} else {
await executeFlowWithRetries(filePath, 0);
}
}
}

async function main() {
console.info('\n==============================');
console.info('Running tests for Android with the following parameters:');
console.info(`APP_PATH: ${APP_PATH}`);
console.info(`APP_ID: ${APP_ID}`);
console.info(`MAESTRO_FLOW: ${MAESTRO_FLOW}`);
console.info(`IS_DEBUG: ${IS_DEBUG}`);
console.info(`WORKING_DIRECTORY: ${WORKING_DIRECTORY}`);
console.info('==============================\n');

console.info('Install app');
childProcess.execSync(`adb install ${APP_PATH}`, {stdio: 'ignore'});

console.info('Start the app');
childProcess.execSync(`adb shell monkey -p ${APP_ID} 1`, {stdio: 'ignore'});

if (IS_DEBUG) {
console.info('Wait For App to warm from Metro');
await sleep(25000);
}

console.info('Start recording to /sdcard/screen.mp4');
childProcess
.exec('adb shell screenrecord /sdcard/screen.mp4', {
stdio: 'ignore',
detached: true,
})
.unref();

console.info(`Start testing ${MAESTRO_FLOW}`);
let error = null;
try {
//check if MAESTRO_FLOW is a folder
if (
fs.existsSync(MAESTRO_FLOW) &&
fs.lstatSync(MAESTRO_FLOW).isDirectory()
) {
await executeFlowInFolder(MAESTRO_FLOW);
} else {
await executeFlowWithRetries(MAESTRO_FLOW, 0);
}
} catch (err) {
error = err;
} finally {
console.info('Stop recording');
childProcess.execSync('adb pull /sdcard/screen.mp4', {stdio: 'ignore'});
}

if (error) {
throw error;
}
process.exit();
}

function sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}

main();
178 changes: 178 additions & 0 deletions .github/workflow-scripts/maestro-ios.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* 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 <path to app> <app_id> <maestro_flow> <flavor> <working_directory>

@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_${currentAttempt}.pid`,
);

const recordingArgs =
`simctl io booted recordVideo video_record_${currentAttempt}.mov`.split(
' ',
);
const recordingProcess = childProcess.spawn('xcrun', recordingArgs, {
detached: true,
stdio: 'ignore',
});

return recordingProcess;
}

function stopVideoRecording(recordingProcess) {
if (!recordingProcess) {
console.log("Passed a null recording process. Can't kill it");
return;
}

console.log(`Stop video record using pid: ${recordingProcess.pid}`);

recordingProcess.kill('SIGINT');
}

async function executeTestsWithRetries(
appId,
udid,
maestroFlow,
jsengine,
currentAttempt,
) {
const recProcess = startVideoRecording(jsengine, currentAttempt);
try {
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(recProcess);
} catch (error) {
await sleep(5000);
// Can't put this in the finally block because it will be executed after the
// recursive call of executeTestsWithRetries
stopVideoRecording(recProcess);

if (currentAttempt < MAX_ATTEMPTS) {
await 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 16 Pro';
launchSimulator(simulatorName);
installAppOnSimulator(APP_PATH);
const udid = extractSimulatorUDID();
bringSimulatorInForeground();
await launchAppOnSimulator(APP_ID, udid, IS_DEBUG);
await executeTestsWithRetries(APP_ID, udid, MAESTRO_FLOW, JS_ENGINE, 1);

console.log('Test finished');
process.exit(0);
}

main();
Loading