From 467f745805f70c758dcdd3b6e341d690812ef36a Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Thu, 3 Sep 2026 09:35:17 +0200 Subject: [PATCH 1/6] Split the iOS E2E template-app workflow into build and test jobs Build the app in a job of its own and hand it to the test job as an artifact, as the RNTester E2E already does. --- .github/workflows/e2e-ios-templateapp.yml | 64 +++++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-ios-templateapp.yml b/.github/workflows/e2e-ios-templateapp.yml index 20e5a9c5dff3..03647512172a 100644 --- a/.github/workflows/e2e-ios-templateapp.yml +++ b/.github/workflows/e2e-ios-templateapp.yml @@ -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: @@ -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" @@ -92,12 +90,68 @@ jobs: -sdk "iphonesimulator" \ -destination "generic/platform=iOS Simulator" \ -derivedDataPath "/tmp/RNTestProject" + - name: Upload app + uses: actions/upload-artifact@v6 + with: + name: RNTestProject-${{ matrix.flavor }} + 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 }} From f79e8a04d3c4a182e3e0d5f7596fce5fa790b0d3 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Thu, 3 Sep 2026 09:46:06 +0200 Subject: [PATCH 2/6] Harden the iOS Maestro runner script and two scroll flows Address the simulator by explicit UDID rather than the 'booted' alias, skip helper fragments that have no launchApp of their own, and await the video recorder's exit so it is reaped rather than left as a zombie per flow and the movie is fully written before the next flow starts. Give the two ScrollView maintainVisibleContentPosition flows a 90s scrollUntilVisible timeout: the example sits far down the list and each scroll step serialises the whole accessibility tree. --- .github/workflow-scripts/maestro-ios.js | 79 +++++++++++++++---- .../scrollview-minindex-maintainvisible.yml | 3 + .../scrollview-threshold-maintainvisible.yml | 3 + 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js index 02ed4a08d5d0..58c62e247be4 100644 --- a/.github/workflow-scripts/maestro-ios.js +++ b/.github/workflow-scripts/maestro-ios.js @@ -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() { @@ -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, { @@ -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}"`; @@ -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.`, @@ -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); } } } @@ -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'); } diff --git a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml index 0925eda988fe..d0c6fa6bb0d8 100644 --- a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml @@ -14,6 +14,9 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 + # Each scroll step ships the whole accessibility tree over the wire + # against a remote simulator, which the default timeout cannot absorb. + timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' - waitForAnimationToEnd: diff --git a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml index c865c3cb26e9..9c179612b9fa 100644 --- a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml @@ -14,6 +14,9 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 + # Each scroll step ships the whole accessibility tree over the wire + # against a remote simulator, which the default timeout cannot absorb. + timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' - waitForAnimationToEnd: From 44ef526ef5e3b11407c4ace04bc3458121ac8f0e Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Fri, 11 Sep 2026 14:00:17 +0200 Subject: [PATCH 3/6] Fix async Maestro runner tests and template app retry uploads --- .../__tests__/maestro-ios-test.js | 91 +++++++++++++++++-- .github/workflows/e2e-ios-templateapp.yml | 1 + 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.github/workflow-scripts/__tests__/maestro-ios-test.js b/.github/workflow-scripts/__tests__/maestro-ios-test.js index 8b23a47a3418..2ce69670a965 100644 --- a/.github/workflow-scripts/__tests__/maestro-ios-test.js +++ b/.github/workflow-scripts/__tests__/maestro-ios-test.js @@ -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( @@ -46,13 +55,13 @@ 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) { @@ -60,6 +69,76 @@ describe('Maestro iOS runner', () => { } }); + 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 () => { + 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'); + } + }); + it('selects an iPhone Pro simulator from the latest runtime', () => { childProcess.execSync.mockReturnValue( JSON.stringify({ diff --git a/.github/workflows/e2e-ios-templateapp.yml b/.github/workflows/e2e-ios-templateapp.yml index 03647512172a..7ef8845069c9 100644 --- a/.github/workflows/e2e-ios-templateapp.yml +++ b/.github/workflows/e2e-ios-templateapp.yml @@ -94,6 +94,7 @@ jobs: uses: actions/upload-artifact@v6 with: name: RNTestProject-${{ matrix.flavor }} + overwrite: true path: /tmp/RNTestProject/Build/Products/${{ matrix.flavor }}-iphonesimulator/RNTestProject.app test: From 558975cf0c75abdd587d7da7d175f207ba263f37 Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Fri, 11 Sep 2026 14:01:35 +0200 Subject: [PATCH 4/6] Move scroll timeout rationale to the PR description --- .../rn-tester/.maestro/scrollview-minindex-maintainvisible.yml | 2 -- .../rn-tester/.maestro/scrollview-threshold-maintainvisible.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml index d0c6fa6bb0d8..23a5b3b5fef1 100644 --- a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml @@ -14,8 +14,6 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 - # Each scroll step ships the whole accessibility tree over the wire - # against a remote simulator, which the default timeout cannot absorb. timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' diff --git a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml index 9c179612b9fa..972a14d388d4 100644 --- a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml @@ -14,8 +14,6 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 - # Each scroll step ships the whole accessibility tree over the wire - # against a remote simulator, which the default timeout cannot absorb. timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' From cce6efd7101783dff488ce79b3c88bb850126fcc Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Fri, 11 Sep 2026 14:05:14 +0200 Subject: [PATCH 5/6] Defer scroll timeout increases to the remote simulator PR --- .../rn-tester/.maestro/scrollview-minindex-maintainvisible.yml | 1 - .../rn-tester/.maestro/scrollview-threshold-maintainvisible.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml index 23a5b3b5fef1..0925eda988fe 100644 --- a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml @@ -14,7 +14,6 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 - timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' - waitForAnimationToEnd: diff --git a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml index 972a14d388d4..c865c3cb26e9 100644 --- a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml @@ -14,7 +14,6 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 - timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' - waitForAnimationToEnd: From 42169f7a3f799393a96bdc14c6cd6cbd2bf6b096 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Fri, 11 Sep 2026 14:30:52 +0200 Subject: [PATCH 6/6] Expect the retry-exhaustion console.error in the Maestro runner test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository Jest setup fails any test that calls console.error, and the runner logs one when a flow exhausts its retries — exactly the path this test exercises. Silence it for this test only and assert the message, so the guard still covers the rest of the suite. --- .github/workflow-scripts/__tests__/maestro-ios-test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflow-scripts/__tests__/maestro-ios-test.js b/.github/workflow-scripts/__tests__/maestro-ios-test.js index 2ce69670a965..f5f9686e4e09 100644 --- a/.github/workflow-scripts/__tests__/maestro-ios-test.js +++ b/.github/workflow-scripts/__tests__/maestro-ios-test.js @@ -122,6 +122,9 @@ describe('Maestro iOS runner', () => { }); 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(() => { @@ -137,6 +140,9 @@ describe('Maestro iOS runner', () => { 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', () => {