From 9b12558f88a4f3d205492a2c9a7db96f394f6d76 Mon Sep 17 00:00:00 2001 From: floydkim Date: Sun, 1 Mar 2026 21:46:09 +0900 Subject: [PATCH 1/3] chore(e2e): retry flaky Maestro runs without rebuilding apps --- .github/workflows/e2e-matrix.yml | 8 ++- e2e/run.ts | 97 ++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 3226e8a0..377f6f71 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -113,11 +113,13 @@ jobs: - name: Run iOS E2E env: MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000" + E2E_RETRY_COUNT: "3" + E2E_RETRY_DELAY_SEC: "30" run: | if [ "${{ startsWith(matrix.app, 'Expo') }}" = "true" ]; then - npm run e2e -- --app "${{ matrix.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" --framework expo + npm run e2e -- --app "${{ matrix.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" --framework expo --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC" else - npm run e2e -- --app "${{ matrix.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" + npm run e2e -- --app "${{ matrix.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC" fi e2e-android: @@ -180,4 +182,4 @@ jobs: arch: x86_64 profile: pixel_7 emulator-boot-timeout: 900 - script: if [ "${{ startsWith(matrix.app, 'Expo') }}" = "true" ]; then npm run e2e -- --app "${{ matrix.app }}" --platform android --framework expo; else npm run e2e -- --app "${{ matrix.app }}" --platform android; fi + script: if [ "${{ startsWith(matrix.app, 'Expo') }}" = "true" ]; then npm run e2e -- --app "${{ matrix.app }}" --platform android --framework expo --retry-count 3 --retry-delay-sec 30; else npm run e2e -- --app "${{ matrix.app }}" --platform android --retry-count 3 --retry-delay-sec 30; fi diff --git a/e2e/run.ts b/e2e/run.ts index 63cfdbef..91c3b9c0 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -14,6 +14,24 @@ interface CliOptions { framework?: "expo"; simulator?: string; maestroOnly?: boolean; + retryCount: number; + retryDelaySec: number; +} + +function parseRetryCountOption(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error("retry-count must be an integer >= 1"); + } + return parsed; +} + +function parseRetryDelaySecOption(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error("retry-delay-sec must be an integer >= 0"); + } + return parsed; } const program = new Command() @@ -23,12 +41,25 @@ const program = new Command() .requiredOption("--platform ", "Platform: ios or android") .option("--framework ", "Framework: expo") .option("--simulator ", "iOS simulator name (default: booted)") - .option("--maestro-only", "Skip build, only run test flows", false); + .option("--maestro-only", "Skip build, only run test flows", false) + .option( + "--retry-count ", + "Retry attempts for each Maestro execution block", + parseRetryCountOption, + 1, + ) + .option( + "--retry-delay-sec ", + "Delay between Maestro retries in seconds", + parseRetryDelaySecOption, + 10, + ); async function main() { const options = program.parse(process.argv).opts(); const appPath = getAppPath(options.app); const repoRoot = path.resolve(__dirname, ".."); + const retryDelayMs = options.retryDelaySec * 1000; if (!fs.existsSync(appPath)) { console.error(`Example app not found: ${appPath}`); @@ -66,7 +97,9 @@ async function main() { // 5. Run Maestro — Phase 1: main flows console.log("\n=== [run-maestro: phase 1] ==="); const flowsDir = path.resolve(__dirname, "flows"); - await runMaestro(flowsDir, options.platform, appId); + await withRetry("run-maestro: phase 1", options.retryCount, retryDelayMs, () => + runMaestro(flowsDir, options.platform, appId), + ); // 6. Disable release for rollback test console.log("\n=== [disable-release] ==="); @@ -83,7 +116,12 @@ async function main() { // 7. Run Maestro — Phase 2: rollback to binary console.log("\n=== [run-maestro: phase 2 (rollback to binary)] ==="); const rollbackDir = path.resolve(__dirname, "flows-rollback"); - await runMaestro(rollbackDir, options.platform, appId); + await withRetry( + "run-maestro: phase 2 (rollback to binary)", + options.retryCount, + retryDelayMs, + () => runMaestro(rollbackDir, options.platform, appId), + ); // 8. Prepare partial rollback: release 1.0.1 + 1.0.2 with different hashes console.log("\n=== [prepare-bundle: partial rollback] ==="); @@ -124,7 +162,12 @@ async function main() { // 9. Run Maestro — update to 1.0.2 console.log("\n=== [run-maestro: partial rollback — update to 1.0.2] ==="); const updateFlow = path.resolve(__dirname, "flows-partial-rollback/01-update-to-latest.yaml"); - await runMaestro(updateFlow, options.platform, appId); + await withRetry( + "run-maestro: partial rollback — update to 1.0.2", + options.retryCount, + retryDelayMs, + () => runMaestro(updateFlow, options.platform, appId), + ); // 10. Disable only 1.0.2 → rollback target is 1.0.1 (not binary) console.log("\n=== [disable-release: 1.0.2 only] ==="); @@ -139,7 +182,12 @@ async function main() { // 11. Run Maestro — rollback from 1.0.2 to 1.0.1 console.log("\n=== [run-maestro: partial rollback — rollback to 1.0.1] ==="); const rollbackFlow = path.resolve(__dirname, "flows-partial-rollback/02-rollback-to-previous.yaml"); - await runMaestro(rollbackFlow, options.platform, appId); + await withRetry( + "run-maestro: partial rollback — rollback to 1.0.1", + options.retryCount, + retryDelayMs, + () => runMaestro(rollbackFlow, options.platform, appId), + ); console.log("\n=== E2E tests passed ==="); } catch (error) { @@ -231,6 +279,45 @@ function buildCodePushBundleIdentifier(appName: string): string { return `com.${normalized}`; } +async function withRetry( + label: string, + retryCount: number, + retryDelayMs: number, + action: () => Promise, +): Promise { + for (let attempt = 1; attempt <= retryCount; attempt += 1) { + if (retryCount > 1) { + console.log(`[retry] ${label} attempt ${attempt}/${retryCount}`); + } + + try { + await action(); + if (attempt > 1) { + console.log(`[retry] ${label} succeeded on attempt ${attempt}/${retryCount}`); + } + return; + } catch (error) { + if (attempt === retryCount) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + console.warn(`[retry] ${label} failed on attempt ${attempt}/${retryCount}: ${message}`); + + if (retryDelayMs > 0) { + console.log(`[retry] waiting ${retryDelayMs / 1000}s before retry`); + await sleep(retryDelayMs); + } + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + function runMaestro( flowsDir: string, platform: "ios" | "android", From 9a54e35c24ebfc41dc598c6b8a05aee577a96833 Mon Sep 17 00:00:00 2001 From: floydkim Date: Sun, 1 Mar 2026 21:51:02 +0900 Subject: [PATCH 2/3] chore(e2e): add Maestro retry args to e2e-manual.yml --- .github/workflows/e2e-manual.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-manual.yml b/.github/workflows/e2e-manual.yml index 2fe14271..b79cb4f5 100644 --- a/.github/workflows/e2e-manual.yml +++ b/.github/workflows/e2e-manual.yml @@ -82,11 +82,14 @@ jobs: echo "simulator=$SIMULATOR_NAME" >> "$GITHUB_OUTPUT" - name: Run iOS E2E + env: + E2E_RETRY_COUNT: "3" + E2E_RETRY_DELAY_SEC: "30" run: | if [ "${{ inputs.expo }}" = "true" ]; then - npm run e2e -- --app "${{ inputs.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" --framework expo + npm run e2e -- --app "${{ inputs.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" --framework expo --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC" else - npm run e2e -- --app "${{ inputs.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" + npm run e2e -- --app "${{ inputs.app }}" --platform ios --simulator "${{ steps.boot-simulator.outputs.simulator }}" --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC" fi e2e-android: @@ -144,4 +147,4 @@ jobs: arch: x86_64 profile: pixel_7 emulator-boot-timeout: 900 - script: if [ "${{ inputs.expo }}" = "true" ]; then npm run e2e -- --app "${{ inputs.app }}" --platform android --framework expo; else npm run e2e -- --app "${{ inputs.app }}" --platform android; fi + script: if [ "${{ inputs.expo }}" = "true" ]; then npm run e2e -- --app "${{ inputs.app }}" --platform android --framework expo --retry-count 3 --retry-delay-sec 30; else npm run e2e -- --app "${{ inputs.app }}" --platform android --retry-count 3 --retry-delay-sec 30; fi From 5a1891164cce094b609a910f5ed1483fecbb27a3 Mon Sep 17 00:00:00 2001 From: floydkim Date: Sun, 1 Mar 2026 21:53:10 +0900 Subject: [PATCH 3/3] chore(e2e): refactor Android retry args to match iOS env-based workflow config --- .github/workflows/e2e-manual.yml | 5 ++++- .github/workflows/e2e-matrix.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-manual.yml b/.github/workflows/e2e-manual.yml index b79cb4f5..335715ec 100644 --- a/.github/workflows/e2e-manual.yml +++ b/.github/workflows/e2e-manual.yml @@ -141,10 +141,13 @@ jobs: echo "$HOME/.maestro-runner/bin" >> "$GITHUB_PATH" - name: Run Android E2E + env: + E2E_RETRY_COUNT: "3" + E2E_RETRY_DELAY_SEC: "30" uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 arch: x86_64 profile: pixel_7 emulator-boot-timeout: 900 - script: if [ "${{ inputs.expo }}" = "true" ]; then npm run e2e -- --app "${{ inputs.app }}" --platform android --framework expo --retry-count 3 --retry-delay-sec 30; else npm run e2e -- --app "${{ inputs.app }}" --platform android --retry-count 3 --retry-delay-sec 30; fi + script: if [ "${{ inputs.expo }}" = "true" ]; then npm run e2e -- --app "${{ inputs.app }}" --platform android --framework expo --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC"; else npm run e2e -- --app "${{ inputs.app }}" --platform android --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC"; fi diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 377f6f71..7024c55a 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -176,10 +176,13 @@ jobs: echo "$HOME/.maestro-runner/bin" >> "$GITHUB_PATH" - name: Run Android E2E + env: + E2E_RETRY_COUNT: "3" + E2E_RETRY_DELAY_SEC: "30" uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 arch: x86_64 profile: pixel_7 emulator-boot-timeout: 900 - script: if [ "${{ startsWith(matrix.app, 'Expo') }}" = "true" ]; then npm run e2e -- --app "${{ matrix.app }}" --platform android --framework expo --retry-count 3 --retry-delay-sec 30; else npm run e2e -- --app "${{ matrix.app }}" --platform android --retry-count 3 --retry-delay-sec 30; fi + script: if [ "${{ startsWith(matrix.app, 'Expo') }}" = "true" ]; then npm run e2e -- --app "${{ matrix.app }}" --platform android --framework expo --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC"; else npm run e2e -- --app "${{ matrix.app }}" --platform android --retry-count "$E2E_RETRY_COUNT" --retry-delay-sec "$E2E_RETRY_DELAY_SEC"; fi