Stabilize Android E2E tests for ARM64 APKs (#58140)

Summary:
Stabilizes the Android RNTester E2E jobs after switching their APKs to ARM64 while continuing to run the emulator on an x86_64 host.

- Updates the Android wide-gamut screenshot baseline using the stable ARM64 result from the API 35 emulator. The emulator does not support wide color, so the Display-P3 fixture is converted to solid sRGB red.
- Marks the FlatList `maintainVisibleContentPosition` flows as Android release-only. These flows remain fully covered by the release APK, where they consistently pass, while avoiding debug-only timing failures caused by running the ARM64 debug runtime through native translation.
- Adds generic tag filtering to the Android Maestro runner and unit coverage for it.

Across seven post-migration `main` runs, the release APK passed every FlatList flow, while the debug APK consistently dropped or delayed Maestro interactions and skipped up to 172 frames during startup.

## Changelog:

[INTERNAL] [FIXED] - Stabilize Android RNTester E2E tests when running ARM64 APKs on x86_64 emulators.

Pull Request resolved: https://github.com/react/react-native/pull/58140

Test Plan:
- `./node_modules/.bin/jest .github/workflow-scripts/__tests__/maestro-android-test.js --runInBand --config='{"testEnvironment":"node","transform":{},"roots":["<rootDir>/.github/workflow-scripts"]}'` — passed (3 tests)
- `maestro 2.6.1 check-syntax` for all 24 tagged FlatList flows — passed
- Prettier check for all changed text files — passed
- `git diff --check` — passed
- Verified the filtered RNTester suite contains 16 debug flows and excludes 24 release-only FlatList MVCP flows
- Compared the new baseline against ARM64 CI screenshots: exact match for release; debug RMSE 0.0024
- Manually exercised the ARM64 release APK on an API 35 ARM64 emulator at 320×640: FlatList offsets progressed as expected (`500 → 544 → 2744 → 4944 → 7144`), and five rapid prepends reached `11500`

Related failing run: https://github.com/react/react-native/actions/runs/32821468795

Reviewed By: Abbondanzo

Differential Revision: D117361459

Pulled By: cortinico

fbshipit-source-id: 4514f8c42f7599c875672efe3c56aae7c9f0395c
This commit is contained in:
Nicola Corti
2026-08-25 10:59:46 -07:00
committed by meta-codesync[bot]
parent 14184ec643
commit ea06a3191f
30 changed files with 125 additions and 6 deletions
+5 -1
View File
@@ -38,6 +38,10 @@ inputs:
required: false
default: /tmp/maestro-android-state/results.json
description: The path used to persist per-flow test results between retries
exclude-tags:
required: false
default: ''
description: Comma-separated flow tags to exclude from the test run
runs:
using: composite
@@ -77,7 +81,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 }} ${{ inputs.test-state-path }}
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 }} "${{ inputs.exclude-tags }}"
- name: Normalize APP_ID
id: normalize-app-id
shell: bash
@@ -14,6 +14,7 @@ const path = require('path');
const {
collectFlows,
executeFlowSuite,
filterFlowsByTags,
loadState,
} = require('../maestro-android');
@@ -45,6 +46,26 @@ describe('Maestro Android runner', () => {
]);
});
it('excludes flows with matching tags', () => {
const releaseOnlyFlow = path.join(
temporaryDirectory,
'android-release-only.yml',
);
const regularFlow = path.join(temporaryDirectory, 'regular.yml');
fs.writeFileSync(
releaseOnlyFlow,
'appId: x\ntags:\n - android-release-only\n---\n- launchApp\n',
);
fs.writeFileSync(regularFlow, 'appId: x\n---\n- launchApp\n');
expect(
filterFlowsByTags(
[releaseOnlyFlow, regularFlow],
['android-release-only'],
),
).toEqual([regularFlow]);
});
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),
+43 -3
View File
@@ -13,7 +13,7 @@ const path = require('path');
const usage = `
=== Usage ===
node maestro-android.js <path to app> <app_id> <maestro_flow> <flavor> <working_directory> [test_state_path]
node maestro-android.js <path to app> <app_id> <maestro_flow> <flavor> <working_directory> [test_state_path] [exclude_tags]
@param {string} appPath - Path to the app APK
@param {string} appId - App ID that needs to be launched
@@ -21,6 +21,7 @@ node maestro-android.js <path to app> <app_id> <maestro_flow> <flavor> <working_
@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
@param {string} excludeTags - Comma-separated flow tags to exclude
==============
`;
@@ -63,6 +64,39 @@ function collectFlows(flowPath) {
return flows;
}
function getFlowTags(flow) {
const contents = fs.readFileSync(flow, 'utf8');
const tags = [];
const tagBlockPattern = /^tags:\s*\r?\n((?:^[ \t]+-[ \t]*[^\r\n]+\r?\n?)*)/gm;
for (const match of contents.matchAll(tagBlockPattern)) {
for (const line of match[1].split(/\r?\n/)) {
const tag = line.match(/^[ \t]+-[ \t]*(.+?)\s*$/)?.[1];
if (tag != null) {
tags.push(tag);
}
}
}
return tags;
}
function filterFlowsByTags(flows, excludeTags) {
if (excludeTags.length === 0) {
return flows;
}
return flows.filter(flow => {
const tags = getFlowTags(flow);
const excludedTag = tags.find(tag => excludeTags.includes(tag));
if (excludedTag != null) {
console.info(`Skipping flow tagged ${excludedTag}: ${flow}`);
return false;
}
return true;
});
}
function getFlowKey(flow) {
return path
.relative(process.cwd(), path.resolve(flow))
@@ -274,7 +308,7 @@ function executeFlowSuite({
}
async function main(args = process.argv.slice(2)) {
if (args.length < 5 || args.length > 6) {
if (args.length < 5 || args.length > 7) {
throw new Error(`Invalid number of arguments.\n${usage}`);
}
@@ -284,6 +318,10 @@ async function main(args = process.argv.slice(2)) {
const isDebug = args[3] === 'debug';
const workingDirectory = args[4];
const statePath = args[5] ?? DEFAULT_STATE_PATH;
const excludeTags = (args[6] ?? '')
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
console.info('\n==============================');
console.info('Running tests for Android with the following parameters:');
@@ -293,6 +331,7 @@ async function main(args = process.argv.slice(2)) {
console.info(`IS_DEBUG: ${isDebug}`);
console.info(`WORKING_DIRECTORY: ${workingDirectory}`);
console.info(`TEST_STATE_PATH: ${statePath}`);
console.info(`EXCLUDE_TAGS: ${excludeTags.join(',') || '<none>'}`);
console.info('==============================\n');
logAndroidAbiConfiguration();
@@ -338,7 +377,7 @@ async function main(args = process.argv.slice(2)) {
let error = null;
try {
const flows = collectFlows(maestroFlow);
const flows = filterFlowsByTags(collectFlows(maestroFlow), excludeTags);
const state = loadState(statePath);
console.info(`Start testing ${flows.length} flow(s)`);
executeFlowSuite({flows, appId, state, statePath});
@@ -378,6 +417,7 @@ if (require.main === module) {
module.exports = {
collectFlows,
executeFlowSuite,
filterFlowsByTags,
formatResults,
loadState,
};
@@ -71,6 +71,10 @@ jobs:
emulator-api-level: '35'
emulator-target: google_apis
emulator-arch: x86_64
# These timing-sensitive flows remain covered by the release APK.
# Running the debug ARM64 APK through native translation can delay
# React commits long enough for Maestro to miss taps and assertions.
exclude-tags: ${{ matrix.flavor == 'debug' && 'android-release-only' || '' }}
- name: Store per-flow test state
if: always()
uses: actions/upload-artifact@v6
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition with append (baseline)
# Appending items should NOT affect scroll offset (delta ~0)
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition — complex concurrent mutations
# Tests prepend + append + delete in sequence
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
- setOrientation: portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition — delete anchor item
# When the anchor item (first visible) is deleted, MVCP should select a new anchor
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
- setOrientation: portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition — delete from middle
# When items are deleted from the middle, MVCP should adjust scroll offset
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
- setOrientation: portrait
@@ -3,6 +3,8 @@
# _firstVisibleView.frame on nil returns {0,0}, causing incorrect scroll
# correction.
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition — first prepend only
# Single prepend with fixed-height items: delta should be ~44px (40px height + 4px margin)
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -2,6 +2,8 @@
# Verifies that after horizontal mode with 50 prepended items and scroll to 500,
# reset returns offset to 0.
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
- setOrientation: portrait
@@ -2,6 +2,8 @@
# Items are 200px wide + 4px margin = 204px each
# In inverted mode, prepending adds items to the right end
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition with view recycling in horizontal + inverted mode
# Items are 200px wide + 4px margin = 204px each
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition in horizontal mode
# Horizontal: items are 200px wide, delta should be ~204px (200px + 4px margin)
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition with view recycling in horizontal mode
# Items are 200px wide + 4px margin = 204px each
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -2,6 +2,8 @@
# Delta is +44 (same as non-inverted) — frame-based delta measures actual frame shift,
# not logical order. Prepending shifts anchor view frame downward in both modes.
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -3,6 +3,8 @@
# With windowSize=3, only ~3 pages of items are rendered
# In inverted mode, items display in reverse order but MVCP delta behavior is the same
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -7,6 +7,8 @@
# - After prepend: record offset
# - Delta should equal height of prepended items (~44px per item with margin)
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -87,6 +89,8 @@ appId: ${APP_ID}
---
# Test that user scroll is not interrupted during prepend
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
- assertVisible: 'Components'
@@ -2,6 +2,8 @@
# Verifies that scroll position remains stable after momentum scroll completes
# post-prepend. MVCP correction runs asynchronously in didMountItems.
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -6,6 +6,8 @@
# This test verifies that MVCP survives an orientation change and continues
# to work correctly after the change.
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -4,6 +4,8 @@
# The native side is unaffected by bottom deletes since MVCP only looks at
# the first visible view, but re-ordering edge case is untested.
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition — pull-to-refresh pattern
# Simulates scroll-to-top then prepend (like pull-to-refresh with new items)
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
- setOrientation: portrait
@@ -2,6 +2,8 @@
# Exercises the throttle edge case where pendingScrollUpdateCount may not decrement
# promptly, blocking render window updates. All prepends fired without waiting.
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -2,6 +2,8 @@
# Items are 40px tall + 4px margin = 44px each
# With windowSize=3, only ~3 pages of items are rendered
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -3,6 +3,8 @@
# during MVCP active causes additive correction (MVCP delta added on top
# of the scrollTo target).
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition with scroll event throttle
# Throttle affects timing but not final delta: should be ~44px
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test variable-height items with first prepend
# Single prepend with variable height: delta should be 28-112px
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -1,6 +1,8 @@
# Test FlatList maintainVisibleContentPosition with variable-height items
# Delta should be between 28-112px (random height from [30,50,70,90,110])
appId: ${APP_ID}
tags:
- android-release-only
---
- launchApp
# Change to portrait
@@ -3,8 +3,8 @@ tags:
- android-only
---
# Android-only: alpha transparency + sRGB vs Display-P3 wide-gamut. Color
# fidelity needs a screenshot, so we assert load status only. Flaky on the iOS
# E2E job, so guarded to Android; iOS skips this flow (empty=pass).
# fidelity is covered by a screenshot captured from the ARM64 APK on the API 35
# sRGB emulator. The flow is guarded because it is flaky on the iOS E2E job.
- runFlow:
when:
platform: Android
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB