From b7e64e1368c73ceb82d2f621e93545578c6b4615 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 19 Oct 2026 11:31:58 +0200 Subject: [PATCH 1/4] test(e2e): retry the iOS "Open React Native" tap and fail fast On CI the XCUITest tap on "Open React Native" is sometimes delivered but the NavigationLink doesn't push. The test then re-enabled Detox synchronization in a finally block, and that call never returned: DetoxSync keeps the app busy on "React Native (bundle load)" until RN content appears. The test hung until the 720s Jest timeout. Retry the tap (up to 3 attempts within the existing timeout, only while the button is still visible) and only re-enable synchronization once RN is visible. Refs #498 --- .../e2e/appleAppDetoxUtils.cjs | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs index 4761a997..c860f1ea 100644 --- a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs @@ -115,30 +115,68 @@ async function tapSwiftUIControl(matcher) { }); } -/** Push RNAppScreen from the native shell and wait until `isReady` resolves. */ -async function openReactNative(isReady) { +const OPEN_REACT_NATIVE_ATTEMPTS = 3; + +/** + * Push RNAppScreen from the native shell and wait until `isReady(timeoutMs)` resolves. + * + * On CI the XCUITest tap is sometimes delivered but the NavigationLink does not push, so + * the tap is retried while the shell is still showing. `totalTimeoutMs` is split across + * the attempts. + * + * Synchronization is only turned back on once RN is visible: DetoxSync keeps the app busy + * on "React Native (bundle load)" until RN content appears, so enabling it on the shell + * waits until the Jest test timeout instead of failing. + */ +async function openReactNative(isReady, totalTimeoutMs) { + const attemptTimeoutMs = Math.round( + totalTimeoutMs / OPEN_REACT_NATIVE_ATTEMPTS + ); await device.disableSynchronization(); - try { - await waitForVisible(OPEN_REACT_NATIVE_BUTTON, DETOX_TIMING.VISIBILITY_TIMEOUT_MS); - await tapSwiftUIControl(OPEN_REACT_NATIVE_BUTTON); - await isReady(); - } finally { - await device.enableSynchronization(); + await waitForVisible( + OPEN_REACT_NATIVE_BUTTON, + DETOX_TIMING.VISIBILITY_TIMEOUT_MS + ); + + let lastError; + for (let attempt = 1; attempt <= OPEN_REACT_NATIVE_ATTEMPTS; attempt++) { + // After a successful push the shell is hidden; keep waiting instead of tapping RN content. + if (attempt === 1 || (await isVisible(OPEN_REACT_NATIVE_BUTTON))) { + await tapSwiftUIControl(OPEN_REACT_NATIVE_BUTTON); + } + try { + await isReady(attemptTimeoutMs); + await device.enableSynchronization(); + return; + } catch (error) { + lastError = error; + console.warn( + `[AppleApp] React Native screen not visible after attempt ${attempt}/${OPEN_REACT_NATIVE_ATTEMPTS}` + ); + } } + throw lastError; } async function openReactNativeVanilla() { - await openReactNative(() => - waitForVisible(by.id(ids.rnAppHome), DETOX_TIMING.VISIBILITY_TIMEOUT_MS) + await openReactNative( + (timeoutMs) => waitForVisible(by.id(ids.rnAppHome), timeoutMs), + DETOX_TIMING.VISIBILITY_TIMEOUT_MS ); } async function openReactNativeExpo() { - await openReactNative(() => - waitForAnyVisible( - [EXPO_HOME_TAB_MATCHERS[0], EXPO_HOME_TAB_MATCHERS[1], EXPO_WELCOME_TITLE], - 60_000 - ) + await openReactNative( + (timeoutMs) => + waitForAnyVisible( + [ + EXPO_HOME_TAB_MATCHERS[0], + EXPO_HOME_TAB_MATCHERS[1], + EXPO_WELCOME_TITLE, + ], + timeoutMs + ), + 60_000 ); } From 0c1f38ed6fdc879fdb49db20bec449442d968e87 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 19 Oct 2026 11:34:50 +0200 Subject: [PATCH 2/4] test(e2e): shorten openReactNative comment --- .../e2e/appleAppDetoxUtils.cjs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs index c860f1ea..c726b0ed 100644 --- a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs @@ -120,13 +120,7 @@ const OPEN_REACT_NATIVE_ATTEMPTS = 3; /** * Push RNAppScreen from the native shell and wait until `isReady(timeoutMs)` resolves. * - * On CI the XCUITest tap is sometimes delivered but the NavigationLink does not push, so - * the tap is retried while the shell is still showing. `totalTimeoutMs` is split across - * the attempts. - * - * Synchronization is only turned back on once RN is visible: DetoxSync keeps the app busy - * on "React Native (bundle load)" until RN content appears, so enabling it on the shell - * waits until the Jest test timeout instead of failing. + * The tap sometimes doesn't push on CI (#498): retry it up to 3 times, then fail fast. */ async function openReactNative(isReady, totalTimeoutMs) { const attemptTimeoutMs = Math.round( @@ -140,7 +134,7 @@ async function openReactNative(isReady, totalTimeoutMs) { let lastError; for (let attempt = 1; attempt <= OPEN_REACT_NATIVE_ATTEMPTS; attempt++) { - // After a successful push the shell is hidden; keep waiting instead of tapping RN content. + // Only re-tap while the shell is still showing. if (attempt === 1 || (await isVisible(OPEN_REACT_NATIVE_BUTTON))) { await tapSwiftUIControl(OPEN_REACT_NATIVE_BUTTON); } From 5f29ec86df6fd90bb82312e1e17c6458a11791df Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 19 Oct 2026 11:40:00 +0200 Subject: [PATCH 3/4] test(e2e): enable sync after the retry loop, warn only before a retry --- .../e2e/appleAppDetoxUtils.cjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs index c726b0ed..3b5bd3e3 100644 --- a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs @@ -132,24 +132,24 @@ async function openReactNative(isReady, totalTimeoutMs) { DETOX_TIMING.VISIBILITY_TIMEOUT_MS ); - let lastError; - for (let attempt = 1; attempt <= OPEN_REACT_NATIVE_ATTEMPTS; attempt++) { + for (let attempt = 1; ; attempt++) { // Only re-tap while the shell is still showing. if (attempt === 1 || (await isVisible(OPEN_REACT_NATIVE_BUTTON))) { await tapSwiftUIControl(OPEN_REACT_NATIVE_BUTTON); } try { await isReady(attemptTimeoutMs); - await device.enableSynchronization(); - return; + break; } catch (error) { - lastError = error; + if (attempt === OPEN_REACT_NATIVE_ATTEMPTS) { + throw error; + } console.warn( - `[AppleApp] React Native screen not visible after attempt ${attempt}/${OPEN_REACT_NATIVE_ATTEMPTS}` + `[AppleApp] React Native screen not visible after attempt ${attempt}/${OPEN_REACT_NATIVE_ATTEMPTS}, retrying` ); } } - throw lastError; + await device.enableSynchronization(); } async function openReactNativeVanilla() { From 408fd2da6b13e79440cae95917754f9c7efb7c10 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 19 Oct 2026 14:04:58 +0200 Subject: [PATCH 4/4] test(e2e): log whether the iOS RN retry re-taps or keeps waiting --- .../e2e/appleAppDetoxUtils.cjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs index 3b5bd3e3..b2f72b75 100644 --- a/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/appleAppDetoxUtils.cjs @@ -134,7 +134,14 @@ async function openReactNative(isReady, totalTimeoutMs) { for (let attempt = 1; ; attempt++) { // Only re-tap while the shell is still showing. - if (attempt === 1 || (await isVisible(OPEN_REACT_NATIVE_BUTTON))) { + const shouldTap = + attempt === 1 || (await isVisible(OPEN_REACT_NATIVE_BUTTON)); + if (attempt > 1) { + console.warn( + `[AppleApp] React Native screen not visible after attempt ${attempt - 1}/${OPEN_REACT_NATIVE_ATTEMPTS}, ${shouldTap ? 're-tapping' : 'button gone, still waiting'}` + ); + } + if (shouldTap) { await tapSwiftUIControl(OPEN_REACT_NATIVE_BUTTON); } try { @@ -144,9 +151,6 @@ async function openReactNative(isReady, totalTimeoutMs) { if (attempt === OPEN_REACT_NATIVE_ATTEMPTS) { throw error; } - console.warn( - `[AppleApp] React Native screen not visible after attempt ${attempt}/${OPEN_REACT_NATIVE_ATTEMPTS}, retrying` - ); } } await device.enableSynchronization();