From 08e45af9e51b59a50c1383115ffa2bde6b335e4c 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 b5222034181b8d97d9b9490cb38a8417f6aa5dbc 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 7746eee10a1a1ff958685bf31764b1f155444f99 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 d458a8e98fe8f45e51c6a1c75bc791e5dace68f6 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 5403d157693f1062b4b750c66642a527540ef9bd 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 d8a8cc1ac6e565b16d66b49c15c497ec6b008cdb 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', () => {