From 1ce62178b9ee7885a6d41abd3c2f9f4ccc424cca Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Tue, 18 Aug 2026 15:49:57 +0100 Subject: [PATCH 1/7] Retry only failed Android E2E flows --- .github/actions/maestro-android/action.yml | 17 +- .../__tests__/maestro-android-test.js | 87 ++++++ .github/workflow-scripts/maestro-android.js | 248 +++++++++++++----- .github/workflows/e2e-android-rntester.yml | 13 +- .github/workflows/e2e-android-templateapp.yml | 13 +- .github/workflows/test-all.yml | 6 + 6 files changed, 317 insertions(+), 67 deletions(-) create mode 100644 .github/workflow-scripts/__tests__/maestro-android-test.js diff --git a/.github/actions/maestro-android/action.yml b/.github/actions/maestro-android/action.yml index fde5cec3e665..b3eba03725c6 100644 --- a/.github/actions/maestro-android/action.yml +++ b/.github/actions/maestro-android/action.yml @@ -26,6 +26,13 @@ inputs: required: false default: x86 description: The architecture of the emulator to run + test-state-path: + required: false + default: /tmp/maestro-android-state/results.json + description: The path used to persist per-flow test results between retries + test-state-artifact-name: + required: true + description: The artifact used to pass per-flow test results to retry jobs runs: using: composite @@ -64,7 +71,7 @@ runs: cores: '4' disable-animations: false avd-name: e2e_emulator - script: node .github/workflow-scripts/maestro-android.js ${{ inputs.app-path }} ${{ inputs.app-id }} ${{ inputs.maestro-flow }} ${{ inputs.flavor }} ${{ inputs.working-directory }} + script: node .github/workflow-scripts/maestro-android.js ${{ inputs.app-path }} ${{ inputs.app-id }} ${{ inputs.maestro-flow }} ${{ inputs.flavor }} ${{ inputs.working-directory }} ${{ inputs.test-state-path }} - name: Normalize APP_ID id: normalize-app-id shell: bash @@ -81,6 +88,14 @@ runs: path: | report.xml screen.mp4 + - name: Store per-flow test state + uses: actions/upload-artifact@v6 + if: always() + with: + name: ${{ inputs.test-state-artifact-name }} + overwrite: true + if-no-files-found: warn + path: ${{ inputs.test-state-path }} - name: Store Logs if: steps.run-tests.outcome == 'failure' uses: actions/upload-artifact@v6 diff --git a/.github/workflow-scripts/__tests__/maestro-android-test.js b/.github/workflow-scripts/__tests__/maestro-android-test.js new file mode 100644 index 000000000000..cd024f9bea22 --- /dev/null +++ b/.github/workflow-scripts/__tests__/maestro-android-test.js @@ -0,0 +1,87 @@ +/** + * 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 fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + collectFlows, + executeFlowSuite, + loadState, +} = require('../maestro-android'); + +describe('Maestro Android runner', () => { + let temporaryDirectory; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'maestro-android-test-'), + ); + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, {recursive: true, force: true}); + }); + + it('collects flows recursively in a stable order', () => { + const nestedDirectory = path.join(temporaryDirectory, 'nested'); + fs.mkdirSync(nestedDirectory); + fs.writeFileSync(path.join(temporaryDirectory, 'second.yaml'), 'appId: x'); + fs.writeFileSync(path.join(temporaryDirectory, 'image.png'), 'not a flow'); + fs.writeFileSync(path.join(nestedDirectory, 'first.yml'), 'appId: x'); + + expect(collectFlows(temporaryDirectory)).toEqual([ + path.join(nestedDirectory, 'first.yml'), + path.join(temporaryDirectory, 'second.yaml'), + ]); + }); + + it('runs every flow and retries only flows that have not passed', () => { + const flows = ['first.yml', 'second.yml', 'third.yml'].map(file => + path.join(temporaryDirectory, file), + ); + const statePath = path.join(temporaryDirectory, 'state', 'results.json'); + const firstAttempt = jest.fn(flow => { + if (flow.endsWith('second.yml')) { + throw new Error('failed assertion'); + } + }); + + expect(() => + executeFlowSuite({ + flows, + appId: 'com.example', + state: loadState(statePath), + statePath, + executeFlow: firstAttempt, + }), + ).toThrow('1 Maestro flow(s) failed'); + expect(firstAttempt).toHaveBeenCalledTimes(3); + + const retry = jest.fn(); + executeFlowSuite({ + flows, + appId: 'com.example', + state: loadState(statePath), + statePath, + executeFlow: retry, + }); + + expect(retry).toHaveBeenCalledTimes(1); + expect(retry.mock.calls[0][0]).toBe(flows[1]); + + const finalState = loadState(statePath); + expect(Object.values(finalState.flows)).toEqual([ + {status: 'passed', attempts: 1}, + {status: 'passed', attempts: 2}, + {status: 'passed', attempts: 1}, + ]); + }); +}); diff --git a/.github/workflow-scripts/maestro-android.js b/.github/workflow-scripts/maestro-android.js index 74507ff6bcab..b4729fb4f4de 100644 --- a/.github/workflow-scripts/maestro-android.js +++ b/.github/workflow-scripts/maestro-android.js @@ -9,83 +9,198 @@ const childProcess = require('child_process'); const fs = require('fs'); +const path = require('path'); const usage = ` === Usage === -node maestro-android.js +node maestro-android.js [test_state_path] @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} maestroFlow - Path to the Maestro flow or folder to execute @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 +@param {string} testStatePath - File used to persist per-flow results between CI retries ============== `; -const args = process.argv.slice(2); +const DEFAULT_STATE_PATH = '/tmp/maestro-android-state/results.json'; +const STATE_VERSION = 1; -if (args.length !== 5) { - throw new Error(`Invalid number of arguments.\n${usage}`); +function collectFlows(flowPath) { + if (!fs.existsSync(flowPath) || !fs.lstatSync(flowPath).isDirectory()) { + return [flowPath]; + } + + const flows = []; + for (const file of fs.readdirSync(flowPath).sort()) { + const filePath = path.join(flowPath, file); + if (fs.lstatSync(filePath).isDirectory()) { + flows.push(...collectFlows(filePath)); + } else if (file.endsWith('.yml') || file.endsWith('.yaml')) { + flows.push(filePath); + } + // Skip non-flow files (e.g. screenshot baselines under screenshots/). + } + return flows; } -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]; +function getFlowKey(flow) { + return path + .relative(process.cwd(), path.resolve(flow)) + .split(path.sep) + .join('/'); +} -const MAX_ATTEMPTS = 3; +function createEmptyState() { + return {version: STATE_VERSION, flows: {}}; +} -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; - } +function loadState(statePath) { + if (!fs.existsSync(statePath)) { + return createEmptyState(); } + + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + if ( + state.version !== STATE_VERSION || + state.flows == null || + typeof state.flows !== 'object' + ) { + throw new Error(`Invalid Maestro test state at ${statePath}`); + } + return state; } -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 if (file.endsWith('.yml') || file.endsWith('.yaml')) { - await executeFlowWithRetries(filePath, 0); +function saveState(statePath, state) { + fs.mkdirSync(path.dirname(statePath), {recursive: true}); + const temporaryPath = `${statePath}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`); + fs.renameSync(temporaryPath, statePath); +} + +function runMaestroFlow(flow, appId) { + 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="${appId}" --debug-output /tmp/MaestroLogs`, + {stdio: 'inherit', timeout}, + ); +} + +function formatResults(flowKeys, state) { + const results = flowKeys.map(flow => ({flow, ...state.flows[flow]})); + const counts = results.reduce( + (result, flow) => { + result[flow.status] += 1; + return result; + }, + {passed: 0, failed: 0, pending: 0}, + ); + const rows = results + .map( + result => + `| ${result.status} | \`${result.flow.replaceAll('|', '\\|')}\` | ${result.attempts} |`, + ) + .join('\n'); + + return `### Android Maestro E2E results + +Passed: ${counts.passed} · Failed: ${counts.failed} · Pending: ${counts.pending} + +| Status | Flow | CI attempts | +| --- | --- | ---: | +${rows} +`; +} + +function writeResultsSummary(flowKeys, state) { + const summary = formatResults(flowKeys, state); + console.info(`\n${summary}`); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + } +} + +function executeFlowSuite({ + flows, + appId, + state, + statePath, + executeFlow = runMaestroFlow, +}) { + const flowKeys = flows.map(getFlowKey); + + for (const flow of flowKeys) { + state.flows[flow] ??= {status: 'pending', attempts: 0}; + } + saveState(statePath, state); + + const failedFlows = []; + for (let index = 0; index < flows.length; index++) { + const flow = flows[index]; + const flowKey = flowKeys[index]; + const result = state.flows[flowKey]; + + if (result.status === 'passed') { + console.info(`Skipping previously passed flow: ${flow}`); + continue; + } + + result.attempts += 1; + try { + executeFlow(flow, appId); + result.status = 'passed'; + delete result.error; + } catch (error) { + result.status = 'failed'; + result.error = error instanceof Error ? error.message : String(error); + failedFlows.push(flowKey); + console.error(`Flow failed: ${flow}`); + } finally { + saveState(statePath, state); } - // Skip non-flow files (e.g. screenshot baselines under screenshots/). + } + + writeResultsSummary(flowKeys, state); + + if (failedFlows.length > 0) { + throw new Error( + `${failedFlows.length} Maestro flow(s) failed:\n${failedFlows.join('\n')}`, + ); } } -async function main() { +async function main(args = process.argv.slice(2)) { + if (args.length < 5 || args.length > 6) { + throw new Error(`Invalid number of arguments.\n${usage}`); + } + + const appPath = args[0]; + const appId = args[1]; + const maestroFlow = args[2]; + const isDebug = args[3] === 'debug'; + const workingDirectory = args[4]; + const statePath = args[5] ?? DEFAULT_STATE_PATH; + 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(`APP_PATH: ${appPath}`); + console.info(`APP_ID: ${appId}`); + console.info(`MAESTRO_FLOW: ${maestroFlow}`); + console.info(`IS_DEBUG: ${isDebug}`); + console.info(`WORKING_DIRECTORY: ${workingDirectory}`); + console.info(`TEST_STATE_PATH: ${statePath}`); console.info('==============================\n'); console.info('Install app'); - childProcess.execSync(`adb install ${APP_PATH}`, {stdio: 'ignore'}); + childProcess.execSync(`adb install ${appPath}`, {stdio: 'ignore'}); let metroProcess = null; - if (IS_DEBUG) { + if (isDebug) { console.info('Start Metro'); - childProcess.execSync(`cd ${WORKING_DIRECTORY}`, {stdio: 'ignore'}); - metroProcess = childProcess.spawn('yarn', ['start', '&'], { - cwd: WORKING_DIRECTORY, + metroProcess = childProcess.spawn('yarn', ['start'], { + cwd: workingDirectory, stdio: 'ignore', detached: true, }); @@ -97,9 +212,9 @@ async function main() { } console.info('Start the app'); - childProcess.execSync(`adb shell monkey -p ${APP_ID} 1`, {stdio: 'ignore'}); + childProcess.execSync(`adb shell monkey -p ${appId} 1`, {stdio: 'ignore'}); - if (IS_DEBUG) { + if (isDebug) { console.info('Wait For App to warm from Metro'); await sleep(10000); } @@ -112,36 +227,29 @@ async function main() { }) .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; + const flows = collectFlows(maestroFlow); + const state = loadState(statePath); + console.info(`Start testing ${flows.length} flow(s)`); + executeFlowSuite({flows, appId, state, statePath}); + } catch (caughtError) { + error = caughtError; } finally { console.info('Stop recording'); childProcess.execSync('adb pull /sdcard/screen.mp4', {stdio: 'ignore'}); - if (IS_DEBUG && metroProcess != null) { + if (isDebug && metroProcess != null) { const pid = metroProcess.pid; console.info(`Kill Metro. PID: ${pid}`); process.kill(pid); - console.info(`Metro Killed`); + console.info('Metro Killed'); } } if (error) { throw error; } - process.exit(); } function sleep(ms) { @@ -150,4 +258,16 @@ function sleep(ms) { }); } -main(); +if (require.main === module) { + main().catch(error => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { + collectFlows, + executeFlowSuite, + formatResults, + loadState, +}; diff --git a/.github/workflows/e2e-android-rntester.yml b/.github/workflows/e2e-android-rntester.yml index 5fba707f9247..e403ea4541a2 100644 --- a/.github/workflows/e2e-android-rntester.yml +++ b/.github/workflows/e2e-android-rntester.yml @@ -9,6 +9,9 @@ on: fail-on-error: type: boolean default: false + retry-attempt: + type: number + default: 0 outputs: status: description: 'The result of the E2E tests (success or failure)' @@ -37,16 +40,24 @@ jobs: path: ./packages/rn-tester/android/app/build/outputs/apk/${{ matrix.flavor }}/ - name: Print folder structure run: ls -lR ./packages/rn-tester/android/app/build/outputs/apk/${{ matrix.flavor }}/ + - name: Download previous per-flow test state + if: ${{ inputs.retry-attempt > 0 }} + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: e2e_android_rntester_state_${{ matrix.flavor }}_x86_NewArch + path: /tmp/maestro-android-state - name: Run E2E Tests id: run-tests continue-on-error: true uses: ./.github/actions/maestro-android - timeout-minutes: 60 + timeout-minutes: 90 with: app-path: ./packages/rn-tester/android/app/build/outputs/apk/${{ matrix.flavor }}/app-x86-${{ matrix.flavor }}.apk app-id: com.facebook.react.uiapp maestro-flow: ./packages/rn-tester/.maestro flavor: ${{ matrix.flavor }} + test-state-artifact-name: e2e_android_rntester_state_${{ matrix.flavor }}_x86_NewArch - name: Report status id: report-status if: ${{ always() && steps.run-tests.outcome == 'failure' }} diff --git a/.github/workflows/e2e-android-templateapp.yml b/.github/workflows/e2e-android-templateapp.yml index 0963cc69ee4a..2d818766a61c 100644 --- a/.github/workflows/e2e-android-templateapp.yml +++ b/.github/workflows/e2e-android-templateapp.yml @@ -9,6 +9,9 @@ on: fail-on-error: type: boolean default: false + retry-attempt: + type: number + default: 0 outputs: status: description: 'The result of the E2E tests (success or failure)' @@ -73,11 +76,18 @@ jobs: CAPITALIZED_FLAVOR=$(echo "${{ matrix.flavor }}" | awk '{print toupper(substr($0, 1, 1)) substr($0, 2)}') ./gradlew assemble$CAPITALIZED_FLAVOR --no-daemon -PreactNativeArchitectures=x86 + - name: Download previous per-flow test state + if: ${{ inputs.retry-attempt > 0 }} + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: e2e_android_templateapp_state_${{ matrix.flavor }}_x86_NewArch + path: /tmp/maestro-android-state - name: Run E2E Tests id: run-tests continue-on-error: true uses: ./.github/actions/maestro-android - timeout-minutes: 60 + timeout-minutes: 90 with: app-path: /tmp/RNTestProject/android/app/build/outputs/apk/${{ matrix.flavor }}/app-${{ matrix.flavor }}.apk app-id: com.rntestproject @@ -85,6 +95,7 @@ jobs: install-java: 'false' flavor: ${{ matrix.flavor }} working-directory: /tmp/RNTestProject + test-state-artifact-name: e2e_android_templateapp_state_${{ matrix.flavor }}_x86_NewArch - name: Report status id: report-status if: ${{ always() && steps.run-tests.outcome == 'failure' }} diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 3b1a45e6bd39..d2c63d843967 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -247,6 +247,8 @@ jobs: needs: test_e2e_android_templateapp if: ${{ always() && needs.test_e2e_android_templateapp.outputs.status == 'failure' }} uses: ./.github/workflows/e2e-android-templateapp.yml + with: + retry-attempt: 1 secrets: inherit test_e2e_android_templateapp_retry_2: @@ -255,6 +257,7 @@ jobs: uses: ./.github/workflows/e2e-android-templateapp.yml with: fail-on-error: true + retry-attempt: 2 secrets: inherit build_fantom_runner: @@ -336,6 +339,8 @@ jobs: needs: test_e2e_android_rntester if: ${{ always() && needs.test_e2e_android_rntester.outputs.status == 'failure' }} uses: ./.github/workflows/e2e-android-rntester.yml + with: + retry-attempt: 1 secrets: inherit test_e2e_android_rntester_retry_2: @@ -344,6 +349,7 @@ jobs: uses: ./.github/workflows/e2e-android-rntester.yml with: fail-on-error: true + retry-attempt: 2 secrets: inherit build_npm_package: From e6fd0070d49c90275509d9c5c670615c14acefa1 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Tue, 18 Aug 2026 17:43:03 +0100 Subject: [PATCH 2/7] Preserve Android E2E state after timeouts --- .github/actions/maestro-android/action.yml | 11 ----------- .github/workflows/e2e-android-rntester.yml | 9 ++++++++- .github/workflows/e2e-android-templateapp.yml | 9 ++++++++- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/actions/maestro-android/action.yml b/.github/actions/maestro-android/action.yml index b3eba03725c6..fa9f9a0a9973 100644 --- a/.github/actions/maestro-android/action.yml +++ b/.github/actions/maestro-android/action.yml @@ -30,9 +30,6 @@ inputs: required: false default: /tmp/maestro-android-state/results.json description: The path used to persist per-flow test results between retries - test-state-artifact-name: - required: true - description: The artifact used to pass per-flow test results to retry jobs runs: using: composite @@ -88,14 +85,6 @@ runs: path: | report.xml screen.mp4 - - name: Store per-flow test state - uses: actions/upload-artifact@v6 - if: always() - with: - name: ${{ inputs.test-state-artifact-name }} - overwrite: true - if-no-files-found: warn - path: ${{ inputs.test-state-path }} - name: Store Logs if: steps.run-tests.outcome == 'failure' uses: actions/upload-artifact@v6 diff --git a/.github/workflows/e2e-android-rntester.yml b/.github/workflows/e2e-android-rntester.yml index e403ea4541a2..14c2291cc47a 100644 --- a/.github/workflows/e2e-android-rntester.yml +++ b/.github/workflows/e2e-android-rntester.yml @@ -57,7 +57,14 @@ jobs: app-id: com.facebook.react.uiapp maestro-flow: ./packages/rn-tester/.maestro flavor: ${{ matrix.flavor }} - test-state-artifact-name: e2e_android_rntester_state_${{ matrix.flavor }}_x86_NewArch + - name: Store per-flow test state + if: always() + uses: actions/upload-artifact@v6 + with: + name: e2e_android_rntester_state_${{ matrix.flavor }}_x86_NewArch + overwrite: true + if-no-files-found: warn + path: /tmp/maestro-android-state/results.json - name: Report status id: report-status if: ${{ always() && steps.run-tests.outcome == 'failure' }} diff --git a/.github/workflows/e2e-android-templateapp.yml b/.github/workflows/e2e-android-templateapp.yml index 2d818766a61c..7713e486ebc3 100644 --- a/.github/workflows/e2e-android-templateapp.yml +++ b/.github/workflows/e2e-android-templateapp.yml @@ -95,7 +95,14 @@ jobs: install-java: 'false' flavor: ${{ matrix.flavor }} working-directory: /tmp/RNTestProject - test-state-artifact-name: e2e_android_templateapp_state_${{ matrix.flavor }}_x86_NewArch + - name: Store per-flow test state + if: always() + uses: actions/upload-artifact@v6 + with: + name: e2e_android_templateapp_state_${{ matrix.flavor }}_x86_NewArch + overwrite: true + if-no-files-found: warn + path: /tmp/maestro-android-state/results.json - name: Report status id: report-status if: ${{ always() && steps.run-tests.outcome == 'failure' }} From eab1a5b536bc35a9e16fdd79c8de179e1c28329a Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Tue, 18 Aug 2026 20:30:30 +0100 Subject: [PATCH 3/7] Skip completed Android E2E retries --- .../__tests__/maestro-android-test.js | 2 ++ .github/workflows/e2e-android-rntester.yml | 11 +++++++++++ .github/workflows/e2e-android-templateapp.yml | 11 +++++++++++ 3 files changed, 24 insertions(+) diff --git a/.github/workflow-scripts/__tests__/maestro-android-test.js b/.github/workflow-scripts/__tests__/maestro-android-test.js index cd024f9bea22..77b5711030bc 100644 --- a/.github/workflow-scripts/__tests__/maestro-android-test.js +++ b/.github/workflow-scripts/__tests__/maestro-android-test.js @@ -24,9 +24,11 @@ describe('Maestro Android runner', () => { temporaryDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'maestro-android-test-'), ); + jest.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { + jest.restoreAllMocks(); fs.rmSync(temporaryDirectory, {recursive: true, force: true}); }); diff --git a/.github/workflows/e2e-android-rntester.yml b/.github/workflows/e2e-android-rntester.yml index 14c2291cc47a..b6b08b2f68b4 100644 --- a/.github/workflows/e2e-android-rntester.yml +++ b/.github/workflows/e2e-android-rntester.yml @@ -47,8 +47,19 @@ jobs: with: name: e2e_android_rntester_state_${{ matrix.flavor }}_x86_NewArch path: /tmp/maestro-android-state + - name: Check for unfinished E2E flows + id: test-state + shell: bash + run: | + SHOULD_RUN=true + if [[ -f /tmp/maestro-android-state/results.json ]] && \ + jq -e '(.flows | length > 0) and all(.flows[]; .status == "passed")' /tmp/maestro-android-state/results.json > /dev/null; then + SHOULD_RUN=false + fi + echo "should-run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" - name: Run E2E Tests id: run-tests + if: steps.test-state.outputs.should-run == 'true' continue-on-error: true uses: ./.github/actions/maestro-android timeout-minutes: 90 diff --git a/.github/workflows/e2e-android-templateapp.yml b/.github/workflows/e2e-android-templateapp.yml index 7713e486ebc3..f7b010114bfc 100644 --- a/.github/workflows/e2e-android-templateapp.yml +++ b/.github/workflows/e2e-android-templateapp.yml @@ -83,8 +83,19 @@ jobs: with: name: e2e_android_templateapp_state_${{ matrix.flavor }}_x86_NewArch path: /tmp/maestro-android-state + - name: Check for unfinished E2E flows + id: test-state + shell: bash + run: | + SHOULD_RUN=true + if [[ -f /tmp/maestro-android-state/results.json ]] && \ + jq -e '(.flows | length > 0) and all(.flows[]; .status == "passed")' /tmp/maestro-android-state/results.json > /dev/null; then + SHOULD_RUN=false + fi + echo "should-run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" - name: Run E2E Tests id: run-tests + if: steps.test-state.outputs.should-run == 'true' continue-on-error: true uses: ./.github/actions/maestro-android timeout-minutes: 90 From b7e9073c96281ca9c1b3d0235707399aaac0509a Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 19 Aug 2026 11:00:56 +0100 Subject: [PATCH 4/7] Dismiss LogBox in Android E2E flows --- .../Libraries/LogBox/UI/LogBoxButton.js | 3 +++ .../LogBox/UI/LogBoxNotificationDismissButton.js | 1 + .../__snapshots__/LogBoxNotification-itest.js.snap | 1 + packages/rn-tester/.maestro/image-wide-gamut.yml | 13 ++++++++++--- .../rn-tester/.maestro/legacy-native-module.yml | 8 ++++++++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js index d86a7290c7fc..4214a7cf4ec8 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js @@ -21,6 +21,7 @@ import {useState} from 'react'; component LogBoxButton( id?: string, + testID?: string, backgroundColor: Readonly<{ default: string, pressed: string, @@ -45,6 +46,7 @@ component LogBoxButton( return ( Date: Wed, 19 Aug 2026 11:20:44 +0100 Subject: [PATCH 5/7] Handle RNTester image prefetch rejection --- .../Libraries/LogBox/UI/LogBoxButton.js | 3 --- .../LogBox/UI/LogBoxNotificationDismissButton.js | 1 - .../__snapshots__/LogBoxNotification-itest.js.snap | 1 - packages/rn-tester/.maestro/image-wide-gamut.yml | 13 +++---------- .../rn-tester/.maestro/legacy-native-module.yml | 8 -------- .../rn-tester/js/examples/Image/ImageExample.js | 4 ++++ 6 files changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js index 4214a7cf4ec8..d86a7290c7fc 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js @@ -21,7 +21,6 @@ import {useState} from 'react'; component LogBoxButton( id?: string, - testID?: string, backgroundColor: Readonly<{ default: string, pressed: string, @@ -46,7 +45,6 @@ component LogBoxButton( return ( {}); // Remote JPEG (RN OSS test fixture) used by the progressive example. Trusted by // the API 24 Android CI emulator and reachable on both platforms. const LARGE_JPEG = From 8daf3175c6b333446a8585dbcf7b5cb21425e885 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 19 Aug 2026 12:16:16 +0100 Subject: [PATCH 6/7] Make wide-gamut image test deterministic --- .../rn-tester/.maestro/image-wide-gamut.yml | 3 +- .../js/examples/Image/ImageExample.js | 53 +++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/rn-tester/.maestro/image-wide-gamut.yml b/packages/rn-tester/.maestro/image-wide-gamut.yml index 4691b311551b..f607bc875423 100644 --- a/packages/rn-tester/.maestro/image-wide-gamut.yml +++ b/packages/rn-tester/.maestro/image-wide-gamut.yml @@ -36,9 +36,8 @@ tags: direction: DOWN speed: 40 timeout: 20000 - # External URL; accept loaded or error. - scrollUntilVisible: - element: 'P3: (loaded|error)' + element: 'P3: loaded' direction: DOWN speed: 40 timeout: 20000 diff --git a/packages/rn-tester/js/examples/Image/ImageExample.js b/packages/rn-tester/js/examples/Image/ImageExample.js index f7c3ce88f2c7..03564ca7d8d4 100644 --- a/packages/rn-tester/js/examples/Image/ImageExample.js +++ b/packages/rn-tester/js/examples/Image/ImageExample.js @@ -47,9 +47,54 @@ void prefetchTask.catch(() => {}); // the API 24 Android CI emulator and reachable on both platforms. const LARGE_JPEG = 'https://www.facebook.com/assets/react_native_oss_tests/large-image@1x.jpg'; -// Display-P3 wide-gamut sample (WebKit color-gamut test image). -const WIDE_GAMUT_P3_URL = - 'https://webkit.org/blog-files/color-gamut/Webkit-logo-P3.png'; +// Display-P3 wide-gamut sample from the WebKit color-gamut test. Keep the +// ICC-profiled fixture inline so the example does not depend on network access. +const WIDE_GAMUT_P3_DATA_URI = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAABY2lDQ1BrQ0dDb2xvclNwYWNlRGlzcGxheVAzAAAokX2QsUvDUBDG' + + 'v1aloHUQHRwcMolDlJIKuji0FURxCFXB6pS+pqmQxkeSIgU3/4GC/4EKzm4Whzo6OAiik+jm5KTgouV5L4mkInqP435877vjOCA5' + + 'bnBu9wOoO75bXMorm6UtJfWMBL0gDObxnK6vSv6uP+P9PvTeTstZv///jcGK6TGqn5QZxl0fSKjE+p7PJe8Tj7m0FHFLshXyieRy' + + 'yOeBZ71YIL4mVljNqBC/EKvlHt3q4brdYNEOcvu06WysyTmUE1jEDjxw2DDQhAId2T/8s4G/gF1yN+FSn4UafOrJkSInmMTLcMAw' + + 'A5VYQ4ZSk3eO7ncX3U+NtYMnYKEjhLiItZUOcDZHJ2vH2tQ8MDIEXLW54RqB1EeZrFaB11NguASM3lDPtlfNauH26Tww8CjE2ySQ' + + 'OgS6LSE+joToHlPzA3DpfAEDp2ITpJYOWwAAACBjSFJNAABtmAAAc48AAQg1AAB+agAAZMkAAQmxAAAxcQAAE7wS/w/XAAAABGNJ' + + 'Q1AMDQABbgPj7wAAAOhlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIA' + + 'AAExAAIAAAAkAAAAcgEyAAIAAAAUAAAAlodpAAQAAAABAAAAqgAAAAAAAABIAAAAAQAAAEgAAAABQWRvYmUgUGhvdG9zaG9wIEND' + + 'IDIwMTUgKE1hY2ludG9zaCkAMjAxNjowNjoyMiAxMTozNDo1NwAAA5AEAAIAAAAUAAAA1KACAAQAAAABAAAAQKADAAQAAAABAAAA' + + 'QAAAAAAyMDE2OjA2OjIyIDExOjM0OjU3ABq+17sAAAAJcEhZcwAACxMAAAsTAQCanBgAAAPRaVRYdFhNTDpjb20uYWRvYmUueG1w' + + 'AAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA2LjAuMCI+CiAgIDxyZGY6' + + 'UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVz' + + 'Y3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIK' + + 'ICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnRp' + + 'ZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIj4KICAgICAgICAgPHhtcDpDcmVhdG9yVG9vbD5BZG9iZSBQaG90b3No' + + 'b3AgQ0MgMjAxNSAoTWFjaW50b3NoKTwveG1wOkNyZWF0b3JUb29sPgogICAgICAgICA8eG1wOk1vZGlmeURhdGU+MjAxNi0wNi0y' + + 'MlQxMTozNDo1NzwveG1wOk1vZGlmeURhdGU+CiAgICAgICAgIDx4bXA6Q3JlYXRlRGF0ZT4yMDE2LTA2LTIyVDExOjM0OjU3PC94' + + 'bXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjEwMDA8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgog' + + 'ICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjY1NTM1PC9leGlmOkNvbG9yU3BhY2U+CiAgICAgICAgIDxleGlmOlBpeGVsWURpbWVu' + + 'c2lvbj4xMDAwPC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNv' + + 'bHV0aW9uVW5pdD4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+NzI8L3RpZmY6WFJlc29sdXRpb24+CiAgICAgICAgIDx0aWZm' + + 'OllSZXNvbHV0aW9uPjcyPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVu' + + 'dGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kl/0+VQAABktJREFUeAHt' + + '2gl34yoMBeD27dv//6Vv3/nS3gx1jQHb6ZmeRmeIMUji6kpgJ53H/x4eyr+PK1983NCfIr8TcK+AD87AfQt88AJ4+PAV8NVbVIAX' + + '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'; type ImageSource = Readonly<{ uri: string, @@ -979,7 +1024,7 @@ function WideGamutTransparencyExample(): React.Node { setP3Status('loaded')} onError={() => setP3Status('error')} /> From 13d130dba6edd53a8c2bea3f19c0db2690803702 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 19 Aug 2026 13:13:03 +0100 Subject: [PATCH 7/7] Capture Android E2E failure artifacts --- .github/actions/maestro-android/action.yml | 3 +- .github/workflow-scripts/maestro-android.js | 113 ++++++++++++++++++-- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/.github/actions/maestro-android/action.yml b/.github/actions/maestro-android/action.yml index fa9f9a0a9973..8962e2894392 100644 --- a/.github/actions/maestro-android/action.yml +++ b/.github/actions/maestro-android/action.yml @@ -86,9 +86,10 @@ runs: report.xml screen.mp4 - name: Store Logs - if: steps.run-tests.outcome == 'failure' + if: always() uses: actions/upload-artifact@v6 with: name: maestro-logs-android-${{ steps.normalize-app-id.outputs.app-id }}-${{ inputs.flavor }}-${{ inputs.emulator-arch }}-NewArch overwrite: true + if-no-files-found: ignore path: /tmp/MaestroLogs diff --git a/.github/workflow-scripts/maestro-android.js b/.github/workflow-scripts/maestro-android.js index b4729fb4f4de..df109654f4b9 100644 --- a/.github/workflow-scripts/maestro-android.js +++ b/.github/workflow-scripts/maestro-android.js @@ -25,6 +25,7 @@ node maestro-android.js screenrecordProcess.once('close', resolve)), + sleep(5000), + ]); + } + + if ( + screenrecordProcess.exitCode == null && + screenrecordProcess.signalCode == null + ) { + screenrecordProcess.kill('SIGKILL'); + } } function formatResults(flowKeys, state) { @@ -199,11 +284,17 @@ async function main(args = process.argv.slice(2)) { let metroProcess = null; if (isDebug) { console.info('Start Metro'); + fs.mkdirSync(MAESTRO_LOG_DIRECTORY, {recursive: true}); + const metroLog = fs.openSync( + path.join(MAESTRO_LOG_DIRECTORY, 'metro.log'), + 'a', + ); metroProcess = childProcess.spawn('yarn', ['start'], { cwd: workingDirectory, - stdio: 'ignore', + stdio: ['ignore', metroLog, metroLog], detached: true, }); + fs.closeSync(metroLog); metroProcess.unref(); console.info(`- Metro PID: ${metroProcess.pid}`); @@ -220,12 +311,11 @@ async function main(args = process.argv.slice(2)) { } console.info('Start recording to /sdcard/screen.mp4'); - childProcess - .exec('adb shell screenrecord /sdcard/screen.mp4', { - stdio: 'ignore', - detached: true, - }) - .unref(); + const screenrecordProcess = childProcess.spawn( + 'adb', + ['shell', 'screenrecord', '/sdcard/screen.mp4'], + {stdio: 'ignore'}, + ); let error = null; try { @@ -237,6 +327,7 @@ async function main(args = process.argv.slice(2)) { error = caughtError; } finally { console.info('Stop recording'); + await stopScreenRecording(screenrecordProcess); childProcess.execSync('adb pull /sdcard/screen.mp4', {stdio: 'ignore'}); if (isDebug && metroProcess != null) {