mirror of
https://github.com/react/react-native.git
synced 2026-09-28 13:23:09 +08:00
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
424 lines
11 KiB
JavaScript
424 lines
11 KiB
JavaScript
/**
|
|
* 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 childProcess = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const usage = `
|
|
=== Usage ===
|
|
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
|
|
@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
|
|
@param {string} excludeTags - Comma-separated flow tags to exclude
|
|
==============
|
|
`;
|
|
|
|
const DEFAULT_STATE_PATH = '/tmp/maestro-android-state/results.json';
|
|
const MAESTRO_LOG_DIRECTORY = '/tmp/MaestroLogs';
|
|
const DIAGNOSTIC_COMMAND_TIMEOUT = 15000;
|
|
const STATE_VERSION = 1;
|
|
|
|
function logAndroidAbiConfiguration() {
|
|
const properties = [
|
|
'ro.product.cpu.abi',
|
|
'ro.product.cpu.abilist',
|
|
'ro.dalvik.vm.native.bridge',
|
|
];
|
|
|
|
console.info('Android ABI configuration:');
|
|
for (const property of properties) {
|
|
const value = childProcess
|
|
.execFileSync('adb', ['shell', 'getprop', property], {encoding: 'utf8'})
|
|
.trim();
|
|
console.info(`- ${property}: ${value || '<empty>'}`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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))
|
|
.split(path.sep)
|
|
.join('/');
|
|
}
|
|
|
|
function createEmptyState() {
|
|
return {version: STATE_VERSION, flows: {}};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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
|
|
try {
|
|
childProcess.execSync(
|
|
`MAESTRO_DRIVER_STARTUP_TIMEOUT=120000 $HOME/.maestro/bin/maestro test "${flow}" --format junit -e APP_ID="${appId}" --debug-output ${MAESTRO_LOG_DIRECTORY}`,
|
|
{stdio: 'inherit', timeout},
|
|
);
|
|
} catch (error) {
|
|
captureFailureArtifacts(flow);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function captureFailureArtifacts(flow) {
|
|
fs.mkdirSync(MAESTRO_LOG_DIRECTORY, {recursive: true});
|
|
const artifactName = path.basename(flow, path.extname(flow));
|
|
|
|
try {
|
|
const screenshot = childProcess.execFileSync(
|
|
'adb',
|
|
['exec-out', 'screencap', '-p'],
|
|
{maxBuffer: 20 * 1024 * 1024, timeout: DIAGNOSTIC_COMMAND_TIMEOUT},
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(MAESTRO_LOG_DIRECTORY, `${artifactName}-failure.png`),
|
|
screenshot,
|
|
);
|
|
} catch (error) {
|
|
console.error(`Failed to capture screenshot for ${flow}: ${error}`);
|
|
}
|
|
|
|
try {
|
|
childProcess.execFileSync(
|
|
'adb',
|
|
['shell', 'uiautomator', 'dump', '/sdcard/window.xml'],
|
|
{stdio: 'ignore', timeout: DIAGNOSTIC_COMMAND_TIMEOUT},
|
|
);
|
|
const hierarchy = childProcess.execFileSync(
|
|
'adb',
|
|
['exec-out', 'cat', '/sdcard/window.xml'],
|
|
{maxBuffer: 20 * 1024 * 1024, timeout: DIAGNOSTIC_COMMAND_TIMEOUT},
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(MAESTRO_LOG_DIRECTORY, `${artifactName}-failure.xml`),
|
|
hierarchy,
|
|
);
|
|
} catch (error) {
|
|
console.error(`Failed to capture UI hierarchy for ${flow}: ${error}`);
|
|
}
|
|
|
|
try {
|
|
const logcat = childProcess.execFileSync(
|
|
'adb',
|
|
['logcat', '-d', '-v', 'threadtime'],
|
|
{maxBuffer: 50 * 1024 * 1024, timeout: DIAGNOSTIC_COMMAND_TIMEOUT},
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(MAESTRO_LOG_DIRECTORY, `${artifactName}-logcat.txt`),
|
|
logcat,
|
|
);
|
|
} catch (error) {
|
|
console.error(`Failed to capture logcat for ${flow}: ${error}`);
|
|
}
|
|
}
|
|
|
|
async function stopScreenRecording(screenrecordProcess) {
|
|
try {
|
|
childProcess.execFileSync('adb', ['shell', 'pkill', '-2', 'screenrecord'], {
|
|
stdio: 'ignore',
|
|
});
|
|
} catch {
|
|
screenrecordProcess.kill('SIGINT');
|
|
}
|
|
|
|
if (
|
|
screenrecordProcess.exitCode == null &&
|
|
screenrecordProcess.signalCode == null
|
|
) {
|
|
await Promise.race([
|
|
new Promise(resolve => screenrecordProcess.once('close', resolve)),
|
|
sleep(5000),
|
|
]);
|
|
}
|
|
|
|
if (
|
|
screenrecordProcess.exitCode == null &&
|
|
screenrecordProcess.signalCode == null
|
|
) {
|
|
screenrecordProcess.kill('SIGKILL');
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
writeResultsSummary(flowKeys, state);
|
|
|
|
if (failedFlows.length > 0) {
|
|
throw new Error(
|
|
`${failedFlows.length} Maestro flow(s) failed:\n${failedFlows.join('\n')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main(args = process.argv.slice(2)) {
|
|
if (args.length < 5 || args.length > 7) {
|
|
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;
|
|
const excludeTags = (args[6] ?? '')
|
|
.split(',')
|
|
.map(tag => tag.trim())
|
|
.filter(Boolean);
|
|
|
|
console.info('\n==============================');
|
|
console.info('Running tests for Android with the following parameters:');
|
|
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(`EXCLUDE_TAGS: ${excludeTags.join(',') || '<none>'}`);
|
|
console.info('==============================\n');
|
|
|
|
logAndroidAbiConfiguration();
|
|
|
|
console.info('Install app');
|
|
childProcess.execSync(`adb install ${appPath}`, {stdio: 'ignore'});
|
|
|
|
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', metroLog, metroLog],
|
|
detached: true,
|
|
});
|
|
fs.closeSync(metroLog);
|
|
metroProcess.unref();
|
|
console.info(`- Metro PID: ${metroProcess.pid}`);
|
|
|
|
console.info('Wait For Metro to Start');
|
|
await sleep(5000);
|
|
}
|
|
|
|
console.info('Start the app');
|
|
childProcess.execSync(`adb shell monkey -p ${appId} 1`, {stdio: 'ignore'});
|
|
|
|
if (isDebug) {
|
|
console.info('Wait For App to warm from Metro');
|
|
await sleep(10000);
|
|
}
|
|
|
|
console.info('Start recording to /sdcard/screen.mp4');
|
|
const screenrecordProcess = childProcess.spawn(
|
|
'adb',
|
|
['shell', 'screenrecord', '/sdcard/screen.mp4'],
|
|
{stdio: 'ignore'},
|
|
);
|
|
|
|
let error = null;
|
|
try {
|
|
const flows = filterFlowsByTags(collectFlows(maestroFlow), excludeTags);
|
|
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');
|
|
await stopScreenRecording(screenrecordProcess);
|
|
childProcess.execSync('adb pull /sdcard/screen.mp4', {stdio: 'ignore'});
|
|
|
|
if (isDebug && metroProcess != null) {
|
|
const pid = metroProcess.pid;
|
|
console.info(`Kill Metro. PID: ${pid}`);
|
|
process.kill(pid);
|
|
console.info('Metro Killed');
|
|
}
|
|
}
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch(error => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
collectFlows,
|
|
executeFlowSuite,
|
|
filterFlowsByTags,
|
|
formatResults,
|
|
loadState,
|
|
};
|