Skip to content
Open
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
97 changes: 91 additions & 6 deletions .github/workflow-scripts/__tests__/maestro-ios-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,33 @@ jest.mock('fs', () => ({
}));

const childProcess = require('child_process');
const {EventEmitter} = require('events');
const fs = require('fs');

const {executeFlows, findAvailableSimulator} = require('../maestro-ios');

describe('Maestro iOS runner', () => {
beforeEach(() => {
jest.clearAllMocks();
childProcess.spawn.mockReturnValue({pid: 1, kill: jest.fn()});
jest.resetAllMocks();
childProcess.spawn.mockImplementation(() => {
const recordingProcess = new EventEmitter();
recordingProcess.pid = 1;
recordingProcess.kill = jest.fn(() => {
recordingProcess.emit('exit', 0, null);
return true;
});
return recordingProcess;
});
});

it('executes each YAML flow separately and skips other files', () => {
it('executes each YAML flow separately and skips other files', async () => {
fs.existsSync.mockReturnValue(true);
fs.lstatSync.mockImplementation(path => ({
isDirectory: () => path === 'flows/',
}));
fs.readdirSync.mockReturnValue(['second.yaml', 'image.png', 'first.yml']);

executeFlows('com.example', 'device-id', 'flows/', 'Hermes');
await executeFlows('com.example', 'device-id', 'flows/', 'Hermes');

expect(childProcess.execSync).toHaveBeenCalledTimes(2);
expect(childProcess.execSync.mock.calls[0][0]).toContain(
Expand All @@ -46,20 +55,96 @@ describe('Maestro iOS runner', () => {
);
});

it('retries only the failing flow', () => {
it('retries only the failing flow', async () => {
fs.existsSync.mockReturnValue(false);
childProcess.execSync.mockImplementationOnce(() => {
throw new Error('Maestro driver failed');
});

executeFlows('com.example', 'device-id', 'flow.yml', 'Hermes');
await executeFlows('com.example', 'device-id', 'flow.yml', 'Hermes');

expect(childProcess.execSync).toHaveBeenCalledTimes(2);
for (const call of childProcess.execSync.mock.calls) {
expect(call[0]).toContain('test "flow.yml"');
}
});

it('waits for the recorder to exit before starting the next flow', async () => {
fs.existsSync.mockReturnValue(true);
fs.lstatSync.mockImplementation(path => ({
isDirectory: () => path === 'flows/',
}));
fs.readdirSync.mockReturnValue(['first.yml', 'second.yml']);

const recordingProcess = new EventEmitter();
recordingProcess.pid = 1;
recordingProcess.kill = jest.fn(() => true);
childProcess.spawn.mockReturnValueOnce(recordingProcess);

const execution = executeFlows(
'com.example',
'device-id',
'flows/',
'Hermes',
);

await new Promise(resolve =>
jest.requireActual('timers').setImmediate(resolve),
);

expect(recordingProcess.kill).toHaveBeenCalledWith('SIGINT');
expect(childProcess.execSync).toHaveBeenCalledTimes(1);
expect(childProcess.spawn).toHaveBeenCalledTimes(1);

recordingProcess.emit('exit', 0, null);
await execution;

expect(childProcess.execSync).toHaveBeenCalledTimes(2);
expect(childProcess.spawn).toHaveBeenCalledTimes(2);
});

it('skips helper directories while recursing into flow directories', async () => {
fs.existsSync.mockReturnValue(true);
fs.lstatSync.mockImplementation(path => ({
isDirectory: () => !path.endsWith('.yml'),
}));
fs.readdirSync.mockImplementation(path =>
path === 'flows/' ? ['helpers', 'nested'] : ['flow.yml'],
);

await executeFlows('com.example', 'device-id', 'flows/', 'Hermes');

expect(fs.readdirSync).not.toHaveBeenCalledWith('flows/helpers');
expect(childProcess.execSync).toHaveBeenCalledTimes(1);
expect(childProcess.execSync.mock.calls[0][0]).toContain(
'test "flows/nested/flow.yml"',
);
});

it('rejects after exhausting retries and stops every recorder', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
fs.existsSync.mockReturnValue(false);
const error = new Error('Maestro driver failed');
childProcess.execSync.mockImplementation(() => {
throw error;
});

await expect(
executeFlows('com.example', 'device-id', 'flow.yml', 'Hermes'),
).rejects.toBe(error);

expect(childProcess.execSync).toHaveBeenCalledTimes(5);
expect(childProcess.spawn).toHaveBeenCalledTimes(5);
for (const {value: recordingProcess} of childProcess.spawn.mock.results) {
expect(recordingProcess.kill).toHaveBeenCalledWith('SIGINT');
}
expect(consoleError).toHaveBeenCalledWith(
'Failed to execute flow flow.yml after 5 attempts.',
);
});

it('selects an iPhone Pro simulator from the latest runtime', () => {
childProcess.execSync.mockReturnValue(
JSON.stringify({
Expand Down
79 changes: 62 additions & 17 deletions .github/workflow-scripts/maestro-ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ function launchSimulator(simulator) {
}
}

function installAppOnSimulator(appPath) {
function installAppOnSimulator(appPath, udid) {
console.log(`Installing app at path ${appPath}`);
childProcess.execSync(`xcrun simctl install booted "${appPath}"`);
childProcess.execSync(`xcrun simctl install "${udid}" "${appPath}"`);
}

function bringSimulatorInForeground() {
Expand All @@ -102,13 +102,13 @@ async function launchAppOnSimulator(appId, udid, isDebug) {
}
}

function startVideoRecording(jsengine, currentAttempt) {
function startVideoRecording(udid, currentAttempt) {
console.log(
`Start video record using pid: video_record_${currentAttempt}.pid`,
);

const recordingArgs =
`simctl io booted recordVideo --force video_record_${currentAttempt}.mov`.split(
`simctl io ${udid} recordVideo --force video_record_${currentAttempt}.mov`.split(
' ',
);
const recordingProcess = childProcess.spawn('xcrun', recordingArgs, {
Expand All @@ -119,19 +119,53 @@ function startVideoRecording(jsengine, currentAttempt) {
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;
return Promise.resolve();
}

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

recordingProcess.kill('SIGINT');
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 => {
const done = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
console.log(
`Recorder ${recordingProcess.pid} did not exit in time, killing it`,
);
recordingProcess.kill('SIGKILL');
}, RECORDING_SHUTDOWN_TIMEOUT_MS);
timer.unref?.();

recordingProcess.once('exit', done);
recordingProcess.once('error', done);
recordingProcess.kill('SIGINT');
});
}

function executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt) {
const recProcess = startVideoRecording(jsengine, currentAttempt);
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}"`;
Expand All @@ -142,13 +176,19 @@ function executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt) {
timeout,
});

stopVideoRecording(recProcess);
await stopVideoRecording(recProcess);
} catch (error) {
stopVideoRecording(recProcess);
await stopVideoRecording(recProcess);

if (currentAttempt < MAX_ATTEMPTS) {
console.info(`Retrying flow: ${flow}`);
executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt + 1);
await executeFlowWithRetries(
appId,
udid,
flow,
jsengine,
currentAttempt + 1,
);
} else {
console.error(
`Failed to execute flow ${flow} after ${MAX_ATTEMPTS} attempts.`,
Expand All @@ -158,18 +198,23 @@ function executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt) {
}
}

function executeFlows(appId, udid, maestroFlow, jsengine) {
async function executeFlows(appId, udid, maestroFlow, jsengine) {
if (!fs.existsSync(maestroFlow) || !fs.lstatSync(maestroFlow).isDirectory()) {
executeFlowWithRetries(appId, udid, maestroFlow, jsengine, 1);
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()) {
executeFlows(appId, udid, filePath, jsengine);
// 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')) {
executeFlowWithRetries(appId, udid, filePath, jsengine, 1);
await executeFlowWithRetries(appId, udid, filePath, jsengine, 1);
}
}
}
Expand Down Expand Up @@ -202,10 +247,10 @@ async function main(args = process.argv.slice(2)) {

const simulator = findAvailableSimulator(deviceModel, deviceOS);
launchSimulator(simulator);
installAppOnSimulator(appPath);
installAppOnSimulator(appPath, simulator.udid);
bringSimulatorInForeground();
await launchAppOnSimulator(appId, simulator.udid, isDebug);
executeFlows(appId, simulator.udid, maestroFlow, jsengine);
await executeFlows(appId, simulator.udid, maestroFlow, jsengine);
console.log('Test finished');
}

Expand Down
65 changes: 60 additions & 5 deletions .github/workflows/e2e-ios-templateapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ on:
value: ${{ jobs.report.outputs.status }}

jobs:
test:
build:
runs-on: macos-26-large
outputs:
status: ${{ steps.report-status.outputs.status }}
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -62,7 +60,7 @@ jobs:
run: |
git config --global user.email "react-native-bot@meta.com"
git config --global user.name "React Native Bot"
- name: Prepare artifacts
- name: Build the app
run: |
REACT_NATIVE_PKG=$(find /tmp/react-native-tmp -type f -name "*.tgz")
echo "React Native tgs is $REACT_NATIVE_PKG"
Expand Down Expand Up @@ -92,12 +90,69 @@ jobs:
-sdk "iphonesimulator" \
-destination "generic/platform=iOS Simulator" \
-derivedDataPath "/tmp/RNTestProject"
- name: Upload app
uses: actions/upload-artifact@v6
with:
name: RNTestProject-${{ matrix.flavor }}
overwrite: true
path: /tmp/RNTestProject/Build/Products/${{ matrix.flavor }}-iphonesimulator/RNTestProject.app

test:
needs: build
runs-on: macos-26-large
outputs:
status: ${{ steps.report-status.outputs.status }}
strategy:
fail-fast: false
matrix:
flavor: [Debug, Release]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup node.js
uses: ./.github/actions/setup-node
- name: Run yarn
uses: ./.github/actions/yarn-install
- name: Download app
uses: actions/download-artifact@v7
with:
name: RNTestProject-${{ matrix.flavor }}
path: /tmp/RNTestProjectBuild/RNTestProject.app
- name: Check downloaded folder content
run: ls -l /tmp/RNTestProjectBuild/RNTestProject.app
- name: Download React Native Package
if: ${{ matrix.flavor == 'Debug' }}
uses: actions/download-artifact@v7
with:
name: react-native-package
path: /tmp/react-native-tmp
- name: Configure git
if: ${{ matrix.flavor == 'Debug' }}
shell: bash
run: |
git config --global user.email "react-native-bot@meta.com"
git config --global user.name "React Native Bot"
- name: Prepare project for Metro
if: ${{ matrix.flavor == 'Debug' }}
# In Debug the app loads its bundle from Metro, which must run from an
# initialized project. Re-initialize it here (JS only — no pods); the
# native app itself comes prebuilt from the `build` job.
run: |
REACT_NATIVE_PKG=$(find /tmp/react-native-tmp -type f -name "*.tgz")
echo "React Native tgs is $REACT_NATIVE_PKG"

BRANCH=${{ github.ref_name }}
if ! [[ $BRANCH == *-stable* ]]; then
BRANCH=main
fi

node ./scripts/e2e/init-project-e2e.js --projectName RNTestProject --currentBranch $BRANCH --directory /tmp/RNTestProject --pathToLocalReactNative $REACT_NATIVE_PKG
- name: Run E2E Tests
id: run-tests
continue-on-error: true
uses: ./.github/actions/maestro-ios
with:
app-path: '/tmp/RNTestProject/Build/Products/${{ matrix.flavor }}-iphonesimulator/RNTestProject.app'
app-path: '/tmp/RNTestProjectBuild/RNTestProject.app'
app-id: org.reactjs.native.example.RNTestProject
maestro-flow: ./scripts/e2e/.maestro/
flavor: ${{ matrix.flavor }}
Expand Down
Loading