From b61c0841c979bb56e25ef049190eff0c0ea6e57a Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Wed, 17 Jun 2026 12:12:47 +0300 Subject: [PATCH 01/15] feat: add vanilla android e2e initial work --- .changeset/clever-mirrors-dream.md | 5 - .changeset/config.json | 1 + .../actions/androidapp-road-test/action.yml | 84 +++++++- .github/workflows/ci.yml | 17 +- apps/AndroidApp/.detoxrc.cjs | 8 + apps/AndroidApp/.detoxrc.expo55.cjs | 10 + apps/AndroidApp/app/build.gradle.kts | 8 + .../android/example/ComposeDetoxBridge.kt | 23 +++ .../brownfield/android/example/DetoxTest.kt | 28 +++ .../example/ExampleInstrumentedTest.kt | 24 --- .../app/src/main/AndroidManifest.xml | 1 + .../android/example/BrownfieldApplication.kt | 37 ++++ .../brownfield/android/example/E2eTestIds.kt | 10 + .../android/example/MainActivity.kt | 109 ++++++---- .../android/example/ReferralsActivity.kt | 16 +- .../android/example/SettingsActivity.kt | 16 +- .../example/components/EspressoTagAnchor.kt | 34 ++++ .../example/components/GreetingCard.kt | 7 +- .../example/components/PostMessageCard.kt | 38 ++-- .../example/components/PostMessageToast.kt | 71 +++++++ apps/AndroidApp/e2e/jest.config.cjs | 14 ++ apps/AndroidApp/e2e/jest.config.expo55.cjs | 14 ++ apps/AndroidApp/package.json | 13 +- apps/AndroidApp/settings.gradle.kts | 3 + .../detox-android-emulator-device.cjs | 84 ++++++++ .../detox-androidapp-variants.cjs | 104 ++++++++++ .../detox-rc-androidapp-emulator-release.cjs | 67 +++++++ .../e2e/androidAppBrownfield.e2e.js | 51 +++++ .../e2e/androidAppDetoxUtils.cjs | 115 +++++++++++ .../e2e/androidAppExpoBrownfield.e2e.js | 51 +++++ .../e2e/detoxUtils.cjs | 30 ++- .../e2e/e2eTestIds.cjs | 2 +- .../package.json | 4 + .../prepare-android-emulator-for-detox.sh | 14 ++ .../src/e2eTestIds.ts | 2 +- package.json | 4 +- packages/brownfield-navigation/.gitignore | 3 + scripts/ci-local-androidapp-android-e2e.sh | 186 ++++++++++++++++++ yarn.lock | 4 + 39 files changed, 1209 insertions(+), 103 deletions(-) delete mode 100644 .changeset/clever-mirrors-dream.md create mode 100644 apps/AndroidApp/.detoxrc.cjs create mode 100644 apps/AndroidApp/.detoxrc.expo55.cjs create mode 100644 apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ComposeDetoxBridge.kt create mode 100644 apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/DetoxTest.kt delete mode 100644 apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ExampleInstrumentedTest.kt create mode 100644 apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/BrownfieldApplication.kt create mode 100644 apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/E2eTestIds.kt create mode 100644 apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt create mode 100644 apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt create mode 100644 apps/AndroidApp/e2e/jest.config.cjs create mode 100644 apps/AndroidApp/e2e/jest.config.expo55.cjs create mode 100644 apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs create mode 100644 apps/brownfield-example-shared-tests/detox-androidapp-variants.cjs create mode 100644 apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs create mode 100644 apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js create mode 100644 apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs create mode 100644 apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js create mode 100755 apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh create mode 100644 packages/brownfield-navigation/.gitignore create mode 100755 scripts/ci-local-androidapp-android-e2e.sh diff --git a/.changeset/clever-mirrors-dream.md b/.changeset/clever-mirrors-dream.md deleted file mode 100644 index a0d24371..00000000 --- a/.changeset/clever-mirrors-dream.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@callstack/brownfield-navigation': minor ---- - -e2e tests diff --git a/.changeset/config.json b/.changeset/config.json index c82a161a..1ff23394 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -24,6 +24,7 @@ "@callstack/brownfield-example-rn-app", "@callstack/brownfield-example-expo-app-54", "@callstack/brownfield-example-expo-app-55", + "@callstack/brownfield-example-shared-tests", "@callstack/brownfield-gradle-plugin-react" ], "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { diff --git a/.github/actions/androidapp-road-test/action.yml b/.github/actions/androidapp-road-test/action.yml index 82e89764..8ef6e6ba 100644 --- a/.github/actions/androidapp-road-test/action.yml +++ b/.github/actions/androidapp-road-test/action.yml @@ -1,5 +1,5 @@ name: Android road test (selected RN app & AndroidApp) -description: Package the given RN app as AAR, publish to Maven Local, and build the corresponding AndroidApp flavor +description: Package the given RN app as AAR, publish to Maven Local, build the corresponding AndroidApp flavor, and optionally run Detox E2E inputs: flavor: @@ -14,6 +14,16 @@ inputs: description: 'Maven path to the RN project, e.g. com/rnapp/brownfieldlib' required: true + run-e2e: + description: 'Run Detox E2E after packaging (uses release APK with embedded JS bundle, no Metro)' + required: false + default: 'false' + + e2e-artifact-name: + description: 'Name prefix for Detox artifacts uploaded on failure' + required: false + default: 'detox-androidapp' + runs: using: composite steps: @@ -75,8 +85,80 @@ runs: run: stat ~/.m2/repository/${{ inputs.rn-project-maven-path }}/0.0.1-SNAPSHOT/brownfieldlib-0.0.1-SNAPSHOT-release.aar shell: bash + - name: Verify embedded JS bundle in release AAR (E2E) + if: inputs.run-e2e == 'true' + run: | + set -euo pipefail + AAR_PATH="${HOME}/.m2/repository/${{ inputs.rn-project-maven-path }}/0.0.1-SNAPSHOT/brownfieldlib-0.0.1-SNAPSHOT-release.aar" + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "${TMP_DIR}"' EXIT + unzip -q "${AAR_PATH}" -d "${TMP_DIR}" + BUNDLE_PATH="$(find "${TMP_DIR}/assets" -name 'index.android.bundle' -print -quit)" + if [[ -z "${BUNDLE_PATH}" ]]; then + echo "error: index.android.bundle missing from ${AAR_PATH} — E2E needs the packaged AAR bundle, not Metro." >&2 + exit 1 + fi + echo "Embedded bundle OK: ${BUNDLE_PATH} ($(wc -c < "${BUNDLE_PATH}") bytes)" + shell: bash + # == AndroidApp == - name: Build native Android Brownfield app + if: inputs.run-e2e != 'true' run: yarn run build:example:android-consumer:${{ inputs.flavor }} shell: bash + + - name: Resolve AndroidApp E2E settings + if: inputs.run-e2e == 'true' + run: | + node <<'NODE' + const { getAndroidAppDetoxVariant } = require('./apps/brownfield-example-shared-tests/detox-androidapp-variants.cjs'); + const variant = getAndroidAppDetoxVariant(process.env.ANDROIDAPP_VARIANT); + const append = (key, value) => { + const fs = require('node:fs'); + fs.appendFileSync(process.env.GITHUB_ENV, `${key}=${value}\n`); + }; + append('ANDROIDAPP_E2E_BUILD_SCRIPT', variant.e2eBuildScript); + append('ANDROIDAPP_E2E_TEST_SCRIPT', variant.e2eTestScript); + NODE + env: + ANDROIDAPP_VARIANT: ${{ inputs.flavor }} + shell: bash + + - name: Install Detox Android artifacts + if: inputs.run-e2e == 'true' + run: node node_modules/detox/scripts/postinstall.js + working-directory: apps/AndroidApp + shell: bash + + - name: Detox build (AndroidApp ${{ inputs.flavor }}) + if: inputs.run-e2e == 'true' + run: yarn "$ANDROIDAPP_E2E_BUILD_SCRIPT" + working-directory: apps/AndroidApp + shell: bash + + - name: Detox test (AndroidApp ${{ inputs.flavor }}) + if: inputs.run-e2e == 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + avd-name: test + disable-animations: true + script: | + bash apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh + cd apps/AndroidApp + yarn "$ANDROIDAPP_E2E_TEST_SCRIPT" + env: + ANDROIDAPP_E2E_TEST_SCRIPT: ${{ env.ANDROIDAPP_E2E_TEST_SCRIPT }} + DETOX_DEVICE: test + + - name: Upload Detox artifacts on failure + if: failure() && inputs.run-e2e == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ inputs.e2e-artifact-name }}-${{ inputs.flavor }}-android + path: apps/AndroidApp/artifacts + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64f3e5b..880b2c84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: - 'apps/brownfield-example-shared-tests/**' androidapp: - 'apps/AndroidApp/**' + - 'apps/brownfield-example-shared-tests/**' appleapp: - 'apps/AppleApp/**' - 'apps/brownfield-example-shared-tests/**' @@ -121,8 +122,9 @@ jobs: swift test --scratch-path "$RUNNER_TEMP/swift-cache/swiftpm" android-androidapp-expo: - name: Android road test (AndroidApp - Expo ${{ matrix.version }}) + name: Android road test${{ matrix.run-e2e == 'true' && ' & E2E' || '' }} (AndroidApp - Expo ${{ matrix.version }}) runs-on: ubuntu-latest + timeout-minutes: ${{ matrix.run-e2e == 'true' && 90 || 60 }} needs: [filter, build-lint] if: | always() && @@ -138,22 +140,27 @@ jobs: matrix: include: - version: '54' + run-e2e: 'false' - version: '55' + run-e2e: 'true' steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Run RNApp -> AndroidApp road test (Expo ${{ matrix.version }}) + - name: Run ExpoApp -> AndroidApp road test${{ matrix.run-e2e == 'true' && ' & Detox E2E' || '' }} (Expo ${{ matrix.version }}) uses: ./.github/actions/androidapp-road-test with: flavor: expo${{ matrix.version }} rn-project-path: apps/ExpoApp${{ matrix.version }} rn-project-maven-path: com/callstack/rnbrownfield/demo/expoapp${{ matrix.version }}/brownfieldlib + run-e2e: ${{ matrix.run-e2e }} + e2e-artifact-name: detox-androidapp-expo${{ matrix.version }} android-androidapp-vanilla: - name: Android road test (AndroidApp - Vanilla) + name: Android road test & E2E (AndroidApp - Vanilla) runs-on: ubuntu-latest + timeout-minutes: 90 needs: [filter, build-lint] if: | always() && @@ -169,12 +176,14 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Run RNApp -> AndroidApp road test (Vanilla) + - name: Run RNApp -> AndroidApp road test & Detox E2E (Vanilla) uses: ./.github/actions/androidapp-road-test with: flavor: vanilla rn-project-path: apps/RNApp rn-project-maven-path: com/rnapp/brownfieldlib + run-e2e: 'true' + e2e-artifact-name: detox-androidapp-vanilla ios-appleapp-vanilla: name: iOS road test & E2E (AppleApp - Vanilla) diff --git a/apps/AndroidApp/.detoxrc.cjs b/apps/AndroidApp/.detoxrc.cjs new file mode 100644 index 00000000..ffd6ed5d --- /dev/null +++ b/apps/AndroidApp/.detoxrc.cjs @@ -0,0 +1,8 @@ +const { + createAndroidAppEmulatorReleaseDetoxConfig, +} = require('../brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs'); + +/** @type {import('detox').DetoxConfig} */ +module.exports = createAndroidAppEmulatorReleaseDetoxConfig({ + gradleFlavor: 'vanilla', +}); diff --git a/apps/AndroidApp/.detoxrc.expo55.cjs b/apps/AndroidApp/.detoxrc.expo55.cjs new file mode 100644 index 00000000..af257732 --- /dev/null +++ b/apps/AndroidApp/.detoxrc.expo55.cjs @@ -0,0 +1,10 @@ +const { + createAndroidAppEmulatorReleaseDetoxConfig, +} = require('../brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs'); + +/** @type {import('detox').DetoxConfig} */ +module.exports = createAndroidAppEmulatorReleaseDetoxConfig({ + gradleFlavor: 'expo55', + detoxConfiguration: 'android.emu.release.expo55', + jestConfigPath: 'e2e/jest.config.expo55.cjs', +}); diff --git a/apps/AndroidApp/app/build.gradle.kts b/apps/AndroidApp/app/build.gradle.kts index d32b6717..35d41a8b 100644 --- a/apps/AndroidApp/app/build.gradle.kts +++ b/apps/AndroidApp/app/build.gradle.kts @@ -25,6 +25,7 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testBuildType = System.getProperty("testBuildType", "debug") } flavorDimensions += "app" @@ -46,6 +47,7 @@ android { buildTypes { release { isMinifyEnabled = false + isDebuggable = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -83,8 +85,14 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation("com.wix:detox:+") androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + // Compose ↔ Espresso bridge (ComposeIdlingResource / EspressoLink) for Detox. + androidTestImplementation("androidx.compose.ui:ui-test") debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + androidTestImplementation(libs.androidx.compose.ui.test.manifest) + // Release Detox E2E: expose Compose semantics to Espresso (debugImplementation is not on release APKs). + releaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ComposeDetoxBridge.kt b/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ComposeDetoxBridge.kt new file mode 100644 index 00000000..6c90b571 --- /dev/null +++ b/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ComposeDetoxBridge.kt @@ -0,0 +1,23 @@ +package com.callstack.brownfield.android.example + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.test.rule.ActivityTestRule +import org.junit.rules.RuleChain +import org.junit.rules.TestRule + +/** + * Connects Jetpack Compose semantics to Espresso's idling/sync layer so Detox can + * interact with the native Compose shell while RN runs in [ReactNativeFragment]. + * + * Uses [createEmptyComposeRule] because [MainActivity] already hosts Compose content; + * Detox owns activity launch via [ActivityTestRule]. + */ +object ComposeDetoxBridge { + fun emptyComposeRule(): ComposeTestRule = createEmptyComposeRule() + + fun ruleChain( + composeRule: ComposeTestRule, + activityRule: ActivityTestRule<*>, + ): TestRule = RuleChain.outerRule(composeRule).around(activityRule) +} diff --git a/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/DetoxTest.kt b/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/DetoxTest.kt new file mode 100644 index 00000000..8d162bd4 --- /dev/null +++ b/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/DetoxTest.kt @@ -0,0 +1,28 @@ +package com.callstack.brownfield.android.example + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import com.wix.detox.Detox +import com.wix.detox.config.DetoxConfig +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class DetoxTest { + private val activityRule = ActivityTestRule(MainActivity::class.java, false, false) + private val composeRule = ComposeDetoxBridge.emptyComposeRule() + + @get:Rule + val ruleChain = ComposeDetoxBridge.ruleChain(composeRule, activityRule) + + @Test + fun runDetoxTests() { + val detoxConfig = DetoxConfig().apply { + rnContextLoadTimeoutSec = 120 + } + Detox.runTests(activityRule, detoxConfig) + } +} diff --git a/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ExampleInstrumentedTest.kt b/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ExampleInstrumentedTest.kt deleted file mode 100644 index 4f241dbe..00000000 --- a/apps/AndroidApp/app/src/androidTest/java/com/callstack/brownfield/android/example/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.callstack.brownfield.android.example - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.callstack.brownfield.android.example", appContext.packageName) - } -} \ No newline at end of file diff --git a/apps/AndroidApp/app/src/main/AndroidManifest.xml b/apps/AndroidApp/app/src/main/AndroidManifest.xml index 8be33948..dbf08070 100644 --- a/apps/AndroidApp/app/src/main/AndroidManifest.xml +++ b/apps/AndroidApp/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ MainScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp) // outer margin + .padding(16.dp) ) } } } } + private fun showReactNativeLoadedToastWhenReady() { + val reactHost = ReactNativeBrownfield.shared.reactHost + reactHost.currentReactContext?.let { + Toast.makeText(this, "React Native has been loaded", Toast.LENGTH_LONG).show() + return + } + + reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + Toast.makeText( + this@MainActivity, + "React Native has been loaded", + Toast.LENGTH_LONG + ).show() + reactHost.removeReactInstanceEventListener(this) + } + }) + } + override fun navigateToSettings(user: UserType) { startActivity(Intent(this, SettingsActivity::class.java)) } @@ -109,27 +126,43 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate { @Composable private fun MainScreen(modifier: Modifier = Modifier) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally // center top bar content - ) { - Spacer(modifier = Modifier.height(3.dp)) + var postMessageToastText by remember { mutableStateOf(null) } - GreetingCard( - name = ReactNativeConstants.APP_NAME, - ) + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(3.dp)) + + GreetingCard( + name = ReactNativeConstants.APP_NAME, + ) - PostMessageCard() + PostMessageCard( + onMessageReceived = { message -> postMessageToastText = message }, + ) - Spacer(modifier = Modifier.height(1.dp)) + Spacer(modifier = Modifier.height(1.dp)) - ReactNativeView( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surface) - ) + ReactNativeView( + modifier = Modifier + .fillMaxWidth() + .height(520.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface) + ) + } + + postMessageToastText?.let { message -> + PostMessageToast( + message = message, + onDismiss = { postMessageToastText = null }, + ) + } } } diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt index a8fc2a9a..c13ae187 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt @@ -14,8 +14,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign +import com.callstack.brownfield.android.example.E2eTestIds import androidx.compose.ui.unit.dp +import com.callstack.brownfield.android.example.components.EspressoTagAnchor import com.callstack.brownfield.android.example.ui.theme.AndroidBrownfieldAppTheme class ReferralsActivity : ComponentActivity() { @@ -27,7 +32,11 @@ class ReferralsActivity : ComponentActivity() { setContent { AndroidBrownfieldAppTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -36,9 +45,12 @@ class ReferralsActivity : ComponentActivity() { verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + EspressoTagAnchor(E2eTestIds.nativeAppNativeReferrals) + Text( text = "Referrals", - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.testTag(E2eTestIds.nativeAppNativeReferrals), ) Text( text = "Opened from BrownfieldNavigation.navigateToReferrals(userId).", diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt index fd127452..21722e64 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt @@ -14,8 +14,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign +import com.callstack.brownfield.android.example.E2eTestIds import androidx.compose.ui.unit.dp +import com.callstack.brownfield.android.example.components.EspressoTagAnchor import com.callstack.brownfield.android.example.ui.theme.AndroidBrownfieldAppTheme class SettingsActivity : ComponentActivity() { @@ -25,7 +30,11 @@ class SettingsActivity : ComponentActivity() { setContent { AndroidBrownfieldAppTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -34,9 +43,12 @@ class SettingsActivity : ComponentActivity() { verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + EspressoTagAnchor(E2eTestIds.nativeAppNativeSettings) + Text( text = "Settings", - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.testTag(E2eTestIds.nativeAppNativeSettings), ) Text( text = "Opened from BrownfieldNavigation.navigateToSettings().", diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt new file mode 100644 index 00000000..634b85e2 --- /dev/null +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt @@ -0,0 +1,34 @@ +package com.callstack.brownfield.android.example.components + +import android.view.View +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView + +/** + * Invisible anchor view so Detox/Espresso [by.id] can match Compose-hosted screens. + * + * Compose [androidx.compose.ui.platform.testTag] is visible to UiAutomator via + * [androidx.compose.ui.semantics.testTagsAsResourceId], but Detox resolves ids through + * Espresso [View.getTag]. This anchor bridges the two. + */ +@Composable +fun EspressoTagAnchor( + tag: String, + modifier: Modifier = Modifier, +) { + AndroidView( + modifier = modifier + .size(1.dp) + .testTag(tag), + factory = { context -> + View(context).apply { + this.tag = tag + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + }, + ) +} diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt index 1f04e99f..9407b1f6 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt @@ -15,7 +15,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign +import com.callstack.brownfield.android.example.E2eTestIds import androidx.compose.ui.unit.dp import com.callstack.brownfield.android.example.BrownfieldStore import com.callstack.brownie.Store @@ -53,10 +55,13 @@ fun GreetingCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { + EspressoTagAnchor(E2eTestIds.nativeAppGreeting) + Text( text = "Hello native $name 👋", style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + modifier = Modifier.testTag(E2eTestIds.nativeAppGreeting), ) Text( diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt index fe405e5d..57b6deb1 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt @@ -1,6 +1,5 @@ package com.callstack.brownfield.android.example.components -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -19,19 +18,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import com.callstack.brownfield.android.example.E2eTestIds import com.callstack.reactnativebrownfield.OnMessageListener import com.callstack.reactnativebrownfield.ReactNativeBrownfield import org.json.JSONObject @Composable -fun PostMessageCard() { +fun PostMessageCard( + onMessageReceived: (String) -> Unit = {}, +) { var nextId by remember { mutableIntStateOf(0) } var draft by remember { mutableStateOf("") } - val lastToast = remember { mutableStateOf(null) } - - val context = LocalContext.current DisposableEffect(Unit) { val listener = OnMessageListener { raw -> @@ -40,14 +39,7 @@ fun PostMessageCard() { } catch (_: Exception) { raw } - val toast = Toast.makeText( - context, - "Received message from React Native: $text", - Toast.LENGTH_LONG - ) - lastToast.value?.cancel() // cancel previous toast if still visible - toast.show() - lastToast.value = toast + onMessageReceived("Received message from React Native: $text") } ReactNativeBrownfield.shared.addMessageListener(listener) onDispose { ReactNativeBrownfield.shared.removeMessageListener(listener) } @@ -66,7 +58,7 @@ fun PostMessageCard() { style = MaterialTheme.typography.titleMedium, modifier = Modifier .padding(bottom = 2.dp) - .align(Alignment.CenterHorizontally) + .align(Alignment.CenterHorizontally), ) Row( @@ -82,12 +74,16 @@ fun PostMessageCard() { placeholder = { Text("Type a message...") }, singleLine = true, ) - Button(onClick = { - val text = draft.ifBlank { "Hello from Android! (#${nextId++})" } - val json = JSONObject().put("text", text).toString() - ReactNativeBrownfield.shared.postMessage(json) - draft = "" - }) { + Button( + onClick = { + val text = draft.ifBlank { "Hello from Android! (#${nextId++})" } + val json = JSONObject().put("text", text).toString() + ReactNativeBrownfield.shared.postMessage(json) + draft = "" + }, + modifier = Modifier.testTag(E2eTestIds.nativeAppPostMessageSend), + ) { + EspressoTagAnchor(E2eTestIds.nativeAppPostMessageSend) Text("Send") } } diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt new file mode 100644 index 00000000..e8edd373 --- /dev/null +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt @@ -0,0 +1,71 @@ +package com.callstack.brownfield.android.example.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.callstack.brownfield.android.example.E2eTestIds +import kotlinx.coroutines.delay + +@Composable +fun PostMessageToast( + message: String, + onDismiss: () -> Unit, +) { + var visible by remember(message) { mutableStateOf(true) } + var scale by remember(message) { mutableFloatStateOf(0.5f) } + var opacity by remember(message) { mutableFloatStateOf(0f) } + + val animatedScale by animateFloatAsState(scale, label = "toastScale") + val animatedOpacity by animateFloatAsState(opacity, label = "toastOpacity") + + LaunchedEffect(message) { + scale = 1f + opacity = 1f + delay(2000) + scale = 0.5f + opacity = 0f + delay(300) + visible = false + onDismiss() + } + + if (visible) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Text( + text = message, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 12.dp) + .padding(bottom = 50.dp) + .scale(animatedScale) + .alpha(animatedOpacity) + .background(Color.Black.copy(alpha = 0.8f), RoundedCornerShape(25.dp)) + .testTag(E2eTestIds.nativeAppPostMessageToast), + ) + EspressoTagAnchor(E2eTestIds.nativeAppPostMessageToast) + } + } +} diff --git a/apps/AndroidApp/e2e/jest.config.cjs b/apps/AndroidApp/e2e/jest.config.cjs new file mode 100644 index 00000000..2d92c9df --- /dev/null +++ b/apps/AndroidApp/e2e/jest.config.cjs @@ -0,0 +1,14 @@ +const path = require('node:path'); +const { + createDetoxJestConfig, +} = require('../../brownfield-example-shared-tests/e2e/createDetoxJestConfig.cjs'); + +module.exports = createDetoxJestConfig({ + e2eDir: __dirname, + testMatch: [ + path.join( + __dirname, + '../../brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js' + ), + ], +}); diff --git a/apps/AndroidApp/e2e/jest.config.expo55.cjs b/apps/AndroidApp/e2e/jest.config.expo55.cjs new file mode 100644 index 00000000..7ff335db --- /dev/null +++ b/apps/AndroidApp/e2e/jest.config.expo55.cjs @@ -0,0 +1,14 @@ +const path = require('node:path'); +const { + createDetoxJestConfig, +} = require('../../brownfield-example-shared-tests/e2e/createDetoxJestConfig.cjs'); + +module.exports = createDetoxJestConfig({ + e2eDir: __dirname, + testMatch: [ + path.join( + __dirname, + '../../brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js' + ), + ], +}); diff --git a/apps/AndroidApp/package.json b/apps/AndroidApp/package.json index 8ee08845..56bfadca 100644 --- a/apps/AndroidApp/package.json +++ b/apps/AndroidApp/package.json @@ -7,6 +7,17 @@ "build:example:android-consumer:expo55": "./gradlew assembleExpo55Release", "build:example:android-consumer:expobeta": "./gradlew assembleExpobetaRelease", "build:example:android-consumer:expo54": "./gradlew assembleExpo54Release", - "build:example:android-consumer:vanilla": "./gradlew assembleVanillaRelease" + "build:example:android-consumer:vanilla": "./gradlew assembleVanillaRelease", + "e2e:build:android": "detox build --configuration android.emu.release", + "e2e:test:android": "detox test --configuration android.emu.release", + "e2e:build:android:expo55": "detox build --config-path .detoxrc.expo55.cjs --configuration android.emu.release.expo55", + "e2e:test:android:expo55": "detox test --config-path .detoxrc.expo55.cjs --configuration android.emu.release.expo55", + "ci:local:e2e:android": "bash ../../scripts/ci-local-androidapp-android-e2e.sh", + "ci:local:e2e:android:expo55": "bash ../../scripts/ci-local-androidapp-android-e2e.sh --variant expo55" + }, + "devDependencies": { + "@callstack/brownfield-example-shared-tests": "workspace:^", + "detox": "^20.27.0", + "jest": "^29.7.0" } } diff --git a/apps/AndroidApp/settings.gradle.kts b/apps/AndroidApp/settings.gradle.kts index 7c3f8a54..51f664bc 100644 --- a/apps/AndroidApp/settings.gradle.kts +++ b/apps/AndroidApp/settings.gradle.kts @@ -15,6 +15,9 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { mavenLocal() + maven { + url = uri("${rootDir}/node_modules/detox/Detox-android") + } google() mavenCentral() } diff --git a/apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs b/apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs new file mode 100644 index 00000000..00e57333 --- /dev/null +++ b/apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs @@ -0,0 +1,84 @@ +'use strict'; + +const { execSync } = require('node:child_process'); + +/** Matches reactivecircus/android-emulator-runner default when `avd-name` is omitted. */ +const FALLBACK_AVD_NAME = 'test'; + +/** + * Local AVD preference order. CI uses API 34 + Pixel 6 (`avd-name: test`); locally we pick + * the closest installed match instead of the first name from `emulator -list-avds` (often an + * old preview device like Nexus_4_API_36). + */ +const PREFERRED_LOCAL_AVD_NAMES = [ + 'Pixel_4_API_34', + 'Pixel_6_Pro_API_33', + 'Pixel_4_API_33', + 'Pixel_9_Pro_XL', +]; + +function tryExec(command) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { + return ''; + } +} + +function listAvdNames() { + const out = tryExec('emulator -list-avds'); + if (!out) return []; + return out + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function isUndesirableLocalAvd(name) { + return /nexus_4/i.test(name) || /_api_3[5-9](_|$)/i.test(name); +} + +function pickPreferredAndroidEmulatorAvd(avds) { + for (const preferred of PREFERRED_LOCAL_AVD_NAMES) { + if (avds.includes(preferred)) { + return preferred; + } + } + + const stable = avds.filter((name) => !isUndesirableLocalAvd(name)); + return stable[0] || avds[0] || FALLBACK_AVD_NAME; +} + +function getRunningEmulatorAvdName() { + const out = tryExec('adb emu avd name'); + if (!out) return ''; + return out.split('\n')[0].trim(); +} + +/** + * AVD name for Detox `android.emulator` config. + * + * Override: `DETOX_DEVICE` or `DETOX_ANDROID_EMULATOR_AVD`. + * Otherwise picks a stable local AVD (prefers API 34 Pixel), or `test` on CI. + */ +function getAndroidEmulatorAvdName() { + const fromEnv = + process.env.DETOX_DEVICE?.trim() || + process.env.DETOX_ANDROID_EMULATOR_AVD?.trim(); + if (fromEnv) return fromEnv; + + const avds = listAvdNames(); + return pickPreferredAndroidEmulatorAvd(avds); +} + +module.exports = { + FALLBACK_AVD_NAME, + PREFERRED_LOCAL_AVD_NAMES, + listAvdNames, + pickPreferredAndroidEmulatorAvd, + getRunningEmulatorAvdName, + getAndroidEmulatorAvdName, +}; diff --git a/apps/brownfield-example-shared-tests/detox-androidapp-variants.cjs b/apps/brownfield-example-shared-tests/detox-androidapp-variants.cjs new file mode 100644 index 00000000..524851d1 --- /dev/null +++ b/apps/brownfield-example-shared-tests/detox-androidapp-variants.cjs @@ -0,0 +1,104 @@ +'use strict'; + +const path = require('node:path'); + +/** @typedef {import('detox').DetoxConfig} DetoxConfig */ + +/** + * AndroidApp Detox / E2E settings per packaged RN host (RNApp, ExpoApp54, ExpoApp55). + * + * Release builds load the JS bundle embedded in the brownfield AAR (no Metro). + * + * @type {Record} + */ +const androidAppDetoxVariants = { + vanilla: { + rnAppDir: 'RNApp', + rnMavenPath: 'com/rnapp/brownfieldlib', + gradleFlavor: 'vanilla', + detoxConfiguration: 'android.emu.release', + detoxRcFile: '.detoxrc.cjs', + e2eBuildScript: 'e2e:build:android', + e2eTestScript: 'e2e:test:android', + e2eTestFile: 'androidAppBrownfield.e2e.js', + nativeGreetingPattern: /Hello native Android/, + }, + expo55: { + rnAppDir: 'ExpoApp55', + rnMavenPath: 'com/callstack/rnbrownfield/demo/expoapp55/brownfieldlib', + gradleFlavor: 'expo55', + detoxConfiguration: 'android.emu.release.expo55', + detoxRcFile: '.detoxrc.expo55.cjs', + e2eBuildScript: 'e2e:build:android:expo55', + e2eTestScript: 'e2e:test:android:expo55', + e2eTestFile: 'androidAppExpoBrownfield.e2e.js', + nativeGreetingPattern: /Hello native Android \(Expo 55\)/, + }, +}; + +/** + * @param {string} variant AndroidApp road-test variant (`vanilla`, `expo54`, `expo55`). + */ +function getAndroidAppDetoxVariant(variant) { + const config = androidAppDetoxVariants[variant]; + if (!config) { + throw new Error( + `Unknown AndroidApp Detox variant: ${variant}. Expected one of: ${Object.keys(androidAppDetoxVariants).join(', ')}` + ); + } + return config; +} + +/** + * @param {string} androidAppRoot Absolute or relative path to apps/AndroidApp. + * @param {ReturnType} variant + */ +function getAndroidAppReleaseApkPath(androidAppRoot, variant) { + const flavor = variant.gradleFlavor; + return path.join( + androidAppRoot, + 'app', + 'build', + 'outputs', + 'apk', + flavor, + 'release', + `app-${flavor}-release.apk` + ); +} + +/** + * @param {string} androidAppRoot + * @param {ReturnType} variant + */ +function getAndroidAppReleaseAndroidTestApkPath(androidAppRoot, variant) { + const flavor = variant.gradleFlavor; + return path.join( + androidAppRoot, + 'app', + 'build', + 'outputs', + 'apk', + 'androidTest', + flavor, + 'release', + `app-${flavor}-release-androidTest.apk` + ); +} + +module.exports = { + androidAppDetoxVariants, + getAndroidAppDetoxVariant, + getAndroidAppReleaseApkPath, + getAndroidAppReleaseAndroidTestApkPath, +}; diff --git a/apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs b/apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs new file mode 100644 index 00000000..89c0d99d --- /dev/null +++ b/apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs @@ -0,0 +1,67 @@ +'use strict'; + +const { getAndroidEmulatorAvdName } = require('./detox-android-emulator-device.cjs'); + +/** + * Detox Android emulator release config for AndroidApp (native Gradle consumer). + * + * Unlike RN/Expo host apps, AndroidApp links prebuilt brownfield AARs from Maven Local. + * Package and publish the matching RN app first, then assemble the flavor release APK. + * + * @param {{ + * gradleFlavor: string, + * detoxConfiguration?: string, + * jestConfigPath?: string, + * }} options + * @returns {import('detox').DetoxConfig} + */ +function createAndroidAppEmulatorReleaseDetoxConfig({ + gradleFlavor, + detoxConfiguration = 'android.emu.release', + jestConfigPath = 'e2e/jest.config.cjs', +}) { + const flavorCapitalized = gradleFlavor.charAt(0).toUpperCase() + gradleFlavor.slice(1); + const detoxAndroidReleaseBuild = + `./gradlew assemble${flavorCapitalized}Release` + + ` assemble${flavorCapitalized}ReleaseAndroidTest -DtestBuildType=release`; + + const binaryPath = `app/build/outputs/apk/${gradleFlavor}/release/app-${gradleFlavor}-release.apk`; + const testBinaryPath = `app/build/outputs/apk/androidTest/${gradleFlavor}/release/app-${gradleFlavor}-release-androidTest.apk`; + + return { + testRunner: { + $0: 'jest', + args: { + config: jestConfigPath, + _: ['e2e'], + }, + jest: { + setupTimeout: 300000, + }, + }, + apps: { + 'android.release': { + type: 'android.apk', + binaryPath, + testBinaryPath, + build: detoxAndroidReleaseBuild, + }, + }, + devices: { + 'android.emulator': { + type: 'android.emulator', + device: { + avdName: getAndroidEmulatorAvdName(), + }, + }, + }, + configurations: { + [detoxConfiguration]: { + device: 'android.emulator', + app: 'android.release', + }, + }, + }; +} + +module.exports = { createAndroidAppEmulatorReleaseDetoxConfig }; diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js new file mode 100644 index 00000000..cb323de5 --- /dev/null +++ b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js @@ -0,0 +1,51 @@ +const { device, element, by, expect: detoxExpect } = require('detox'); +const { brownfieldE2eTestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); +const { + assertDetoxTextMatches, + configureDetoxForBrownfieldAndroid, + waitForVisibleIgnoringSync, +} = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); +const { + scrollToNativeShellVanilla, + waitForAndroidAppReadyVanilla, + sendPostMessageToNativeAndWaitForToast, +} = require('@callstack/brownfield-example-shared-tests/e2e/androidAppDetoxUtils'); + +describe('Brownfield (AndroidApp — Vanilla)', () => { + beforeEach(async () => { + await device.launchApp({ newInstance: true }); + await configureDetoxForBrownfieldAndroid(); + await waitForAndroidAppReadyVanilla(); + }); + + it('shows the native greeting shell and embedded RN home', async () => { + await scrollToNativeShellVanilla(); + await detoxExpect(element(by.id(ids.appleAppGreeting))).toBeVisible(); + await detoxExpect(element(by.id(ids.rnAppHome))).toBeVisible(); + const title = element(by.id(ids.rnAppHomeTitle)); + await detoxExpect(title).toBeVisible(); + await assertDetoxTextMatches(title, /React Native Screen/); + }); + + it('increments the embedded RN shared-store counter', async () => { + const count = element(by.id(ids.counterCount)); + await detoxExpect(count).toBeVisible(); + await assertDetoxTextMatches(count, /Count:\s*0/); + await element(by.id(ids.counterIncrement)).tap(); + await assertDetoxTextMatches(count, /Count:\s*1/); + }); + + it('shows a native toast when RN sends postMessage', async () => { + await sendPostMessageToNativeAndWaitForToast(/Hello from React Native!/); + }); + + it('navigates to native settings from the RN surface', async () => { + await element(by.id(ids.openNativeSettings)).tap(); + await waitForVisibleIgnoringSync(by.id(ids.appleAppNativeSettings), 10000); + }); + + it('navigates to native referrals from the RN surface', async () => { + await element(by.id(ids.openNativeReferrals)).tap(); + await waitForVisibleIgnoringSync(by.id(ids.appleAppNativeReferrals), 10000, 0); + }); +}); diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs new file mode 100644 index 00000000..2da3f249 --- /dev/null +++ b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs @@ -0,0 +1,115 @@ +const { device, element, by, waitFor } = require('detox'); +const { brownfieldE2eTestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); +const { + assertDetoxTextMatches, + dismissAndroidSystemOverlays, + waitForVisibleIgnoringSync, +} = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); + +/** Middle-of-screen anchor — avoids status-bar swipes that open the notification shade. */ +const NATIVE_SHELL_SCROLL_ANCHOR = ids.appleAppGreeting; + +async function scrollNativeShell(fingerDirection) { + const anchor = element(by.id(NATIVE_SHELL_SCROLL_ANCHOR)); + await waitFor(anchor).toBeVisible().withTimeout(10000); + try { + await anchor.swipe(fingerDirection, 'slow', 0.75); + } catch { + await element(by.type('android.widget.ScrollView')).atIndex(0).scroll( + 400, + fingerDirection === 'up' ? 'down' : 'up' + ); + } + await dismissAndroidSystemOverlays(); +} + +async function scrollToEmbeddedRnVanilla() { + await scrollNativeShell('up'); +} + +async function scrollToEmbeddedRnExpo() { + try { + await element(by.label('Home')).atIndex(0).swipe('up', 'fast', 0.85); + } catch { + await scrollToEmbeddedRnVanilla(); + } + await dismissAndroidSystemOverlays(); +} + +async function scrollToNativeShellVanilla() { + await scrollNativeShell('down'); +} + +async function scrollToNativeShellExpo() { + try { + await element(by.label('Home')).atIndex(0).swipe('down', 'fast', 0.85); + } catch { + await scrollToNativeShellVanilla(); + } + await dismissAndroidSystemOverlays(); +} + +async function waitForAndroidAppReadyVanilla() { + await waitFor(element(by.id(ids.appleAppGreeting))).toBeVisible().withTimeout(60000); + + await scrollToEmbeddedRnVanilla(); + + const rnHome = element(by.id(ids.rnAppHome)); + try { + await waitFor(rnHome).toBeVisible().withTimeout(60000); + } catch { + await scrollToEmbeddedRnVanilla(); + await waitFor(rnHome).toBeVisible().withTimeout(30000); + } +} + +async function waitForAndroidAppReadyExpo() { + const homeTab = by.label('Home'); + try { + await waitForVisibleIgnoringSync(homeTab, 120000, 0); + } catch { + await device.disableSynchronization(); + try { + await scrollToEmbeddedRnExpo(); + await waitFor(element(homeTab).atIndex(0)).toBeVisible().withTimeout(30000); + } finally { + await device.enableSynchronization(); + } + } +} + +async function openPostMessageTabExpo() { + await waitForVisibleIgnoringSync(by.label('postMessage API'), 30000, 0); + await element(by.label('postMessage API')).atIndex(0).tap(); + await waitForVisibleIgnoringSync(by.id(ids.sendMessageToNative), 30000); +} + +async function sendPostMessageToNativeAndWaitForToast(rnMessagePattern) { + await waitForVisibleIgnoringSync(by.id(ids.sendMessageToNative), 30000); + await element(by.id(ids.sendMessageToNative)).tap(); + if (rnMessagePattern) { + const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + await assertDetoxTextMatches(bubble, rnMessagePattern); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + await assertDetoxTextMatches(bubble, rnMessagePattern); + } + await waitForVisibleIgnoringSync(by.id(ids.appleAppPostMessageToast), 10000); +} + +module.exports = { + scrollToEmbeddedRnVanilla, + scrollToEmbeddedRnExpo, + scrollToNativeShellVanilla, + scrollToNativeShellExpo, + waitForAndroidAppReadyVanilla, + waitForAndroidAppReadyExpo, + openPostMessageTabExpo, + sendPostMessageToNativeAndWaitForToast, +}; diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js new file mode 100644 index 00000000..495663be --- /dev/null +++ b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js @@ -0,0 +1,51 @@ +const { device, element, by, expect: detoxExpect } = require('detox'); +const { brownfieldE2eTestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); +const { + assertDetoxTextMatches, + configureDetoxForBrownfieldAndroid, + waitForVisibleIgnoringSync, +} = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); +const { + scrollToNativeShellExpo, + waitForAndroidAppReadyExpo, + openPostMessageTabExpo, + sendPostMessageToNativeAndWaitForToast, +} = require('@callstack/brownfield-example-shared-tests/e2e/androidAppDetoxUtils'); + +describe('Brownfield (AndroidApp — Expo)', () => { + beforeEach(async () => { + await device.launchApp({ newInstance: true }); + await configureDetoxForBrownfieldAndroid(); + await waitForAndroidAppReadyExpo(); + }); + + it('shows the native greeting shell and embedded Expo home', async () => { + await scrollToNativeShellExpo(); + const greeting = element(by.id(ids.appleAppGreeting)); + await detoxExpect(greeting).toBeVisible(); + await assertDetoxTextMatches(greeting, /Hello native Android \(Expo 55\)/); + await detoxExpect(element(by.label('Home')).atIndex(0)).toBeVisible(); + await detoxExpect(element(by.text(/Welcome to\s+Expo\s+55/))).toBeVisible(); + }); + + it('shows a native toast when Expo RN sends postMessage', async () => { + await openPostMessageTabExpo(); + await sendPostMessageToNativeAndWaitForToast(/Hello from Expo!/); + }); + + it('records the RN postMessage bubble in the Expo surface', async () => { + await openPostMessageTabExpo(); + await element(by.id(ids.sendMessageToNative)).tap(); + const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + await assertDetoxTextMatches(bubble, /Hello from Expo!/); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + await assertDetoxTextMatches(bubble, /Hello from Expo!/); + }); +}); diff --git a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs index f57d8dc7..6783c827 100644 --- a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs @@ -1,6 +1,27 @@ const assert = require('node:assert/strict'); +const { execSync } = require('node:child_process'); const { device, element, waitFor, expect: detoxExpect } = require('detox'); +function adbShell(command) { + try { + execSync(`adb shell ${command}`, { stdio: 'ignore' }); + } catch { + // Emulator may be offline or the command may be unsupported on older API levels. + } +} + +/** + * Collapse the notification shade via adb. + * Safe after launchApp and after scroll gestures — never press Back here (that can + * finish MainActivity and Espresso reports "No activities found"). + */ +async function dismissAndroidSystemOverlays() { + if (device.getPlatform() !== 'android') { + return; + } + adbShell('cmd statusbar collapse'); +} + function detoxAttrsText(attrs) { if (!attrs || typeof attrs !== 'object') { return ''; @@ -29,12 +50,17 @@ async function configureDetoxForBrownfieldIos() { ]); } +/** AndroidApp release E2E uses the embedded AAR bundle (no Metro). */ +async function configureDetoxForBrownfieldAndroid() { + // No URL blacklist needed on Android. +} + async function waitForVisible(matcher, timeoutMs = 20000) { await waitFor(element(matcher)).toBeVisible().withTimeout(timeoutMs); } /** - * Poll visibility with synchronization disabled (RN Debug keeps the run loop "busy"). + * Poll visibility with synchronization disabled (RN keeps the run loop "busy"). * Do not use waitFor().toBeVisible() while sync is off — it returns immediately. */ async function waitForVisibleIgnoringSync(matcher, timeoutMs = 20000, index = 0) { @@ -59,6 +85,8 @@ async function waitForVisibleIgnoringSync(matcher, timeoutMs = 20000, index = 0) module.exports = { detoxAttrsText, assertDetoxTextMatches, + dismissAndroidSystemOverlays, + configureDetoxForBrownfieldAndroid, configureDetoxForBrownfieldIos, waitForVisible, waitForVisibleIgnoringSync, diff --git a/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs b/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs index 8ca59789..f63463d1 100644 --- a/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs +++ b/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs @@ -1,6 +1,6 @@ 'use strict'; -// Detox E2E (CJS). Keep in sync with ../src/e2eTestIds.ts +// Detox E2E (CJS). Keep in sync with ../src/e2eTestIds.ts and native E2eTestIds (Swift/Kotlin). const brownfieldE2eTestIds = { rnAppHome: 'brownfield-e2e-rnapp-home', rnAppHomeTitle: 'brownfield-e2e-rnapp-home-title', diff --git a/apps/brownfield-example-shared-tests/package.json b/apps/brownfield-example-shared-tests/package.json index 46748693..77002886 100644 --- a/apps/brownfield-example-shared-tests/package.json +++ b/apps/brownfield-example-shared-tests/package.json @@ -14,11 +14,15 @@ "./e2e/e2eTestIds": "./e2e/e2eTestIds.cjs", "./e2e/detoxUtils": "./e2e/detoxUtils.cjs", "./e2e/appleAppDetoxUtils": "./e2e/appleAppDetoxUtils.cjs", + "./e2e/androidAppDetoxUtils": "./e2e/androidAppDetoxUtils.cjs", "./e2e/createDetoxJestConfig": "./e2e/createDetoxJestConfig.cjs", "./detox-rc-ios-sim-debug": "./detox-rc-ios-sim-debug.cjs", "./detox-rc-appleapp-ios-sim-debug": "./detox-rc-appleapp-ios-sim-debug.cjs", + "./detox-rc-androidapp-emulator-release": "./detox-rc-androidapp-emulator-release.cjs", "./detox-appleapp-variants": "./detox-appleapp-variants.cjs", + "./detox-androidapp-variants": "./detox-androidapp-variants.cjs", "./detox-ios-simulator-device": "./detox-ios-simulator-device.cjs", + "./detox-android-emulator-device": "./detox-android-emulator-device.cjs", "./jest/expo-config": "./jest/expo-config.js" }, "peerDependencies": { diff --git a/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh new file mode 100755 index 00000000..23aa4e49 --- /dev/null +++ b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Best-effort emulator prep before Detox Android E2E (CI + local). +# Collapses the notification shade and quiets first-boot setup prompts that can +# cover the app and make Detox report "The app seems to be idle". +set -euo pipefail + +adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' + +adb shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true +adb shell wm dismiss-keyguard >/dev/null 2>&1 || true +adb shell cmd statusbar collapse >/dev/null 2>&1 || true +adb shell settings put global heads_up_notifications_enabled 0 >/dev/null 2>&1 || true +adb shell settings put secure user_setup_complete 1 >/dev/null 2>&1 || true +adb shell settings put secure tv_user_setup_complete 1 >/dev/null 2>&1 || true diff --git a/apps/brownfield-example-shared-tests/src/e2eTestIds.ts b/apps/brownfield-example-shared-tests/src/e2eTestIds.ts index 7be6c2d0..2b77149d 100644 --- a/apps/brownfield-example-shared-tests/src/e2eTestIds.ts +++ b/apps/brownfield-example-shared-tests/src/e2eTestIds.ts @@ -12,7 +12,7 @@ export const brownfieldE2eTestIds = { counterIncrement: 'brownfield-e2e-counter-increment', /** RN-authored postMessage bubble body (may repeat across list — use atIndex(0) for newest). */ rnPostMessageText: 'brownfield-e2e-rn-post-message-text', - /** AppleApp native SwiftUI shell (keep in sync with apps/AppleApp/.../E2eTestIds.swift). */ + /** AppleApp / AndroidApp native shell (keep in sync with native E2eTestIds). */ appleAppGreeting: 'brownfield-e2e-appleapp-greeting', appleAppPostMessageSend: 'brownfield-e2e-appleapp-post-message-send', appleAppPostMessageToast: 'brownfield-e2e-appleapp-post-message-toast', diff --git a/package.json b/package.json index ca401be9..e973bdfc 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "ci:local:expo54:e2e:ios": "bash ./scripts/ci-local-expo54-ios-e2e.sh", "ci:local:expo55:e2e:ios": "bash ./scripts/ci-local-expo55-ios-e2e.sh", "ci:local:appleapp:e2e:ios": "bash ./scripts/ci-local-appleapp-ios-e2e.sh", - "ci:local:appleapp:e2e:ios:expo55": "bash ./scripts/ci-local-appleapp-ios-e2e.sh --variant expo55" + "ci:local:appleapp:e2e:ios:expo55": "bash ./scripts/ci-local-appleapp-ios-e2e.sh --variant expo55", + "ci:local:androidapp:e2e:android": "bash ./scripts/ci-local-androidapp-android-e2e.sh", + "ci:local:androidapp:e2e:android:expo55": "bash ./scripts/ci-local-androidapp-android-e2e.sh --variant expo55" }, "resolutions": { "@types/react": "19.1.1", diff --git a/packages/brownfield-navigation/.gitignore b/packages/brownfield-navigation/.gitignore new file mode 100644 index 00000000..899c9d55 --- /dev/null +++ b/packages/brownfield-navigation/.gitignore @@ -0,0 +1,3 @@ +# quicktype model types (created by `brownfield navigation:codegen`) +ios/BrownfieldNavigationModels.swift +android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationModels.kt diff --git a/scripts/ci-local-androidapp-android-e2e.sh b/scripts/ci-local-androidapp-android-e2e.sh new file mode 100755 index 00000000..517a875a --- /dev/null +++ b/scripts/ci-local-androidapp-android-e2e.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Local-only Detox E2E for apps/AndroidApp (mirrors CI android-androidapp-vanilla / expo jobs). +# +# Usage (from repo root): +# yarn ci:local:androidapp:e2e:android +# yarn ci:local:androidapp:e2e:android:expo55 +# yarn ci:local:androidapp:e2e:android --variant expo55 +# yarn ci:local:androidapp:e2e:android --skip-install +# yarn ci:local:androidapp:e2e:android --rebuild +# yarn ci:local:androidapp:e2e:android --test-only +# yarn ci:local:androidapp:e2e:android --build-only +# yarn ci:local:androidapp:e2e:android --avd Pixel_4_API_34 +# +# Emulator: defaults to Pixel_4_API_34 when installed (matches CI API 34). Override with +# --avd or DETOX_DEVICE. Boots the chosen AVD automatically when none is running. +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ANDROID_APP_PATH="${REPO_ROOT}/apps/AndroidApp" +VARIANT="vanilla" +SKIP_INSTALL=false +TEST_ONLY=false +REBUILD_ONLY=false +BUILD_ONLY=false +DETOX_AVD="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --) shift; break ;; + --variant=*) VARIANT="${1#*=}"; shift ;; + --variant) + VARIANT="${2:?--variant requires a value (vanilla or expo55)}" + shift 2 + ;; + --avd=*) DETOX_AVD="${1#*=}"; shift ;; + --avd) + DETOX_AVD="${2:?--avd requires an AVD name (see: emulator -list-avds)}" + shift 2 + ;; + --skip-install) SKIP_INSTALL=true; shift ;; + --test-only) TEST_ONLY=true; shift ;; + --rebuild) REBUILD_ONLY=true; SKIP_INSTALL=true; shift ;; + --build-only) BUILD_ONLY=true; shift ;; + -h|--help) + sed -n '2,17p' "$0" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +resolve_detox_avd() { + DETOX_DEVICE="${DETOX_AVD}" node <&2 + exit 1 + fi + + echo "==> Starting emulator: ${avd}" + nohup emulator -avd "${avd}" -no-boot-anim -no-snapshot-load >/tmp/detox-emulator.log 2>&1 & + bash "${REPO_ROOT}/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh" +} + +ensure_android_emulator() { + if [[ -n "${DETOX_AVD}" ]]; then + export DETOX_DEVICE="${DETOX_AVD}" + fi + + IFS='|' read -r EXPECTED_AVD RUNNING_AVD INSTALLED_AVDS < <(resolve_detox_avd) + export DETOX_DEVICE="${DETOX_DEVICE:-${EXPECTED_AVD}}" + + echo "==> Detox AVD: ${DETOX_DEVICE} (installed: ${INSTALLED_AVDS:-none})" + + if [[ -n "${RUNNING_AVD}" && "${RUNNING_AVD}" != "${DETOX_DEVICE}" ]]; then + echo "error: ${RUNNING_AVD} is running but Detox expects ${DETOX_DEVICE}." >&2 + echo "Stop it with: adb emu kill" >&2 + echo "Then re-run (the script will start ${DETOX_DEVICE} automatically)." >&2 + exit 1 + fi + + if [[ -z "${RUNNING_AVD}" ]]; then + start_detox_emulator "${DETOX_DEVICE}" "${INSTALLED_AVDS}" + fi +} + +resolve_variant() { + node < Repo: ${REPO_ROOT}" +echo "==> Variant: ${VARIANT} (${RN_APP_DIR})" +echo "==> AndroidApp: ${ANDROID_APP_PATH}" + +if [[ "${SKIP_INSTALL}" == "false" && "${TEST_ONLY}" == "false" ]]; then + echo "==> yarn install (DETOX_DISABLE_POSTINSTALL=1, same as CI setup)" + (cd "${REPO_ROOT}" && DETOX_DISABLE_POSTINSTALL=1 yarn install) + + echo "==> yarn build (packages, same as CI setup)" + (cd "${REPO_ROOT}" && yarn build) +fi + +if [[ "${TEST_ONLY}" == "false" && "${REBUILD_ONLY}" != "true" ]]; then + echo "==> Publish Brownfield Gradle plugin to Maven Local" + (cd "${REPO_ROOT}" && yarn brownfield:plugin:publish:local) + + if [[ "${VARIANT}" == expo* ]]; then + echo "==> expo prebuild (${RN_APP_DIR})" + (cd "${RN_PROJECT_PATH}" && yarn expo prebuild --platform android) + (cd "${RN_PROJECT_PATH}" && yarn brownfield:prepare:android:ci) + fi + + echo "==> Package and publish ${RN_APP_DIR} AAR" + (cd "${RN_PROJECT_PATH}" && yarn brownfield:package:android && yarn brownfield:publish:android) + + echo "==> Verify embedded JS bundle in release AAR (Metro not required)" + AAR_PATH="${HOME}/.m2/repository/${RN_MAVEN_PATH}/0.0.1-SNAPSHOT/brownfieldlib-0.0.1-SNAPSHOT-release.aar" + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "${TMP_DIR}"' EXIT + unzip -q "${AAR_PATH}" -d "${TMP_DIR}" + BUNDLE_PATH="$(find "${TMP_DIR}/assets" -name 'index.android.bundle' -print -quit)" + if [[ -z "${BUNDLE_PATH}" ]]; then + echo "error: index.android.bundle missing from ${AAR_PATH}" >&2 + exit 1 + fi + echo "Embedded bundle OK: ${BUNDLE_PATH} ($(wc -c < "${BUNDLE_PATH}") bytes)" + rm -rf "${TMP_DIR}" + trap - EXIT + + echo "==> Detox Android postinstall" + node "${ANDROID_APP_PATH}/node_modules/detox/scripts/postinstall.js" +elif [[ "${REBUILD_ONLY}" == "true" ]]; then + echo "==> --rebuild: skipping AAR packaging; rebuilding Detox APK only" +fi + +if [[ "${TEST_ONLY}" == "false" ]]; then + echo "==> Detox build (AndroidApp ${VARIANT}, release APK)" + (cd "${ANDROID_APP_PATH}" && yarn "${E2E_BUILD_SCRIPT}") +elif [[ "${TEST_ONLY}" == "true" ]]; then + echo "==> --test-only: using existing AndroidApp APK" +fi + +if [[ "${BUILD_ONLY}" == "false" ]]; then + ensure_android_emulator + + echo "==> Prepare Android emulator for Detox" + bash "${REPO_ROOT}/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh" + + echo "==> Detox test (AndroidApp ${VARIANT}, emulator — Metro not required)" + (cd "${ANDROID_APP_PATH}" && DETOX_DEVICE="${DETOX_DEVICE}" yarn "${E2E_TEST_SCRIPT}") +fi + +echo "==> Done." diff --git a/yarn.lock b/yarn.lock index 4e6dbdf0..860e066b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1708,6 +1708,10 @@ __metadata: "@callstack/brownfield-example-android-app@workspace:apps/AndroidApp": version: 0.0.0-use.local resolution: "@callstack/brownfield-example-android-app@workspace:apps/AndroidApp" + dependencies: + "@callstack/brownfield-example-shared-tests": "workspace:^" + detox: "npm:^20.27.0" + jest: "npm:^29.7.0" languageName: unknown linkType: soft From 33e958c6a0b5a20e3cc0f40c78da63d3d562ace4 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 10:22:06 +0300 Subject: [PATCH 02/15] feat: fix un support for node 20 --- lefthook.yml | 2 +- .../cli/src/brownie/__tests__/config.test.ts | 4 +- scripts/check-brownfield-navigation-drift.ts | 71 ------------------- 3 files changed, 4 insertions(+), 73 deletions(-) delete mode 100644 scripts/check-brownfield-navigation-drift.ts diff --git a/lefthook.yml b/lefthook.yml index 3e8e1d20..c0602a17 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -5,7 +5,7 @@ pre-commit: run: yarn generate:schema && git add packages/cli/schema.json brownfield-navigation-drift: - run: node --experimental-strip-types --no-warnings ./scripts/check-brownfield-navigation-drift.ts + run: node ./scripts/check-brownfield-navigation-drift.mjs lint: glob: '*.{js,ts,jsx,tsx}' diff --git a/packages/cli/src/brownie/__tests__/config.test.ts b/packages/cli/src/brownie/__tests__/config.test.ts index 32263c0e..608d9ca8 100644 --- a/packages/cli/src/brownie/__tests__/config.test.ts +++ b/packages/cli/src/brownie/__tests__/config.test.ts @@ -41,7 +41,9 @@ describe('loadConfig', () => { it('throws when package.json not found', () => { mockCwd.mockReturnValue('/nonexistent/path'); - expect(() => loadConfig()).toThrow('package.json not found'); + expect(() => loadConfig()).toThrow( + 'Could not find project root (no package.json found)' + ); }); it('returns empty config when brownie config missing', () => { diff --git a/scripts/check-brownfield-navigation-drift.ts b/scripts/check-brownfield-navigation-drift.ts deleted file mode 100644 index f270c212..00000000 --- a/scripts/check-brownfield-navigation-drift.ts +++ /dev/null @@ -1,71 +0,0 @@ -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..'); -const SKIP_ENV_VAR = 'SKIP_BROWNFIELD_NAVIGATION_CHECK'; - -const PROTECTED_GENERATED_FILES = [ - 'packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts', - 'packages/brownfield-navigation/src/index.ts', - 'packages/brownfield-navigation/lib/commonjs/index.js', - 'packages/brownfield-navigation/lib/module/index.js', - 'packages/brownfield-navigation/lib/typescript/commonjs/src/index.d.ts', - 'packages/brownfield-navigation/lib/typescript/module/src/index.d.ts', - 'packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift', - 'packages/brownfield-navigation/ios/BrownfieldNavigationModels.swift', - 'packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm', - 'packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt', - 'packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationModels.kt', - 'packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt', -]; - -function readStagedFiles(repoRoot: string): string[] { - const output = execFileSync( - 'git', - ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], - { - cwd: repoRoot, - encoding: 'utf8', - } - ); - - return output - .split('\n') - .map((filePath) => filePath.trim()) - .filter(Boolean); -} - -function main(): void { - if (process.env[SKIP_ENV_VAR] === '1') { - console.log( - `${SKIP_ENV_VAR}=1 set, skipping brownfield navigation generated file check` - ); - return; - } - - const protectedFiles = new Set(PROTECTED_GENERATED_FILES); - const stagedProtectedFiles = readStagedFiles(REPO_ROOT).filter((filePath) => - protectedFiles.has(filePath) - ); - - if (stagedProtectedFiles.length === 0) { - return; - } - - console.error( - [ - 'Commit blocked: generated brownfield-navigation files are staged.', - '', - 'These files are generated by brownfield navigation codegen and should not be committed accidentally:', - ...stagedProtectedFiles.map((filePath) => `- ${filePath}`), - '', - `If this change is intentional, bypass once with: ${SKIP_ENV_VAR}=1 git commit -m "..."`, - ].join('\n') - ); - process.exit(1); -} - -main(); From f806482d931444d366817af189c5b65f0de8da48 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 10:23:36 +0300 Subject: [PATCH 03/15] feat: add missing mjs --- scripts/check-brownfield-navigation-drift.mjs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 scripts/check-brownfield-navigation-drift.mjs diff --git a/scripts/check-brownfield-navigation-drift.mjs b/scripts/check-brownfield-navigation-drift.mjs new file mode 100644 index 00000000..3fe52656 --- /dev/null +++ b/scripts/check-brownfield-navigation-drift.mjs @@ -0,0 +1,71 @@ +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SKIP_ENV_VAR = 'SKIP_BROWNFIELD_NAVIGATION_CHECK'; + +const PROTECTED_GENERATED_FILES = [ + 'packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts', + 'packages/brownfield-navigation/src/index.ts', + 'packages/brownfield-navigation/lib/commonjs/index.js', + 'packages/brownfield-navigation/lib/module/index.js', + 'packages/brownfield-navigation/lib/typescript/commonjs/src/index.d.ts', + 'packages/brownfield-navigation/lib/typescript/module/src/index.d.ts', + 'packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift', + 'packages/brownfield-navigation/ios/BrownfieldNavigationModels.swift', + 'packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm', + 'packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt', + 'packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationModels.kt', + 'packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt', +]; + +function readStagedFiles(repoRoot) { + const output = execFileSync( + 'git', + ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], + { + cwd: repoRoot, + encoding: 'utf8', + } + ); + + return output + .split('\n') + .map((filePath) => filePath.trim()) + .filter(Boolean); +} + +function main() { + if (process.env[SKIP_ENV_VAR] === '1') { + console.log( + `${SKIP_ENV_VAR}=1 set, skipping brownfield navigation generated file check` + ); + return; + } + + const protectedFiles = new Set(PROTECTED_GENERATED_FILES); + const stagedProtectedFiles = readStagedFiles(REPO_ROOT).filter((filePath) => + protectedFiles.has(filePath) + ); + + if (stagedProtectedFiles.length === 0) { + return; + } + + console.error( + [ + 'Commit blocked: generated brownfield-navigation files are staged.', + '', + 'These files are generated by brownfield navigation codegen and should not be committed accidentally:', + ...stagedProtectedFiles.map((filePath) => `- ${filePath}`), + '', + `If this change is intentional, bypass once with: ${SKIP_ENV_VAR}=1 git commit -m "..."`, + ].join('\n') + ); + process.exit(1); +} + +main(); From f0f495227e1bc699babfb8bde6ffd127b4b674c8 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 10:43:05 +0300 Subject: [PATCH 04/15] feat: fix non platform runs --- .../utils/__tests__/strip-framework-binary.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts index ec9d4178..7fe2aecf 100644 --- a/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts @@ -25,6 +25,7 @@ vi.mock('@rock-js/tools', async (importOriginal) => { const mockLoggerWarn = rockTools.logger.warn as ReturnType; const mockLoggerSuccess = rockTools.logger.success as ReturnType; +const isDarwin = process.platform === 'darwin'; function createMockXcframework( baseDir: string, @@ -65,7 +66,7 @@ describe('stripFrameworkBinary', () => { ); }); - it('strips binary from ios-arm64 slice', () => { + it.skipIf(!isDarwin)('strips binary from ios-arm64 slice', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', ]); @@ -87,7 +88,7 @@ describe('stripFrameworkBinary', () => { ); }); - it('strips binary from simulator slice with fat binary', () => { + it.skipIf(!isDarwin)('strips binary from simulator slice with fat binary', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64_x86_64-simulator', ]); @@ -111,7 +112,7 @@ describe('stripFrameworkBinary', () => { expect(archInfo).toContain('x86_64'); }); - it('handles multiple slices', () => { + it.skipIf(!isDarwin)('handles multiple slices', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', 'ios-arm64_x86_64-simulator', @@ -165,7 +166,7 @@ describe('stripFrameworkBinary', () => { ); }); - it('ignores non-ios directories', () => { + it.skipIf(!isDarwin)('ignores non-ios directories', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', ]); From 69593ea1ddc01e446d636efd02228369ed16a794 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 11:23:05 +0300 Subject: [PATCH 05/15] feat: fix failed android ci build --- .../android/example/BrownfieldApplication.kt | 5 +++++ .../com/rnapp/brownfieldlib/ReactNativeHostManager.kt | 10 ++++++++++ .../template/android/ReactNativeHostManager.post55.kt | 8 ++++++++ .../template/android/ReactNativeHostManager.pre55.kt | 7 ++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/BrownfieldApplication.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/BrownfieldApplication.kt index 5adfb918..6664fbdc 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/BrownfieldApplication.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/BrownfieldApplication.kt @@ -6,12 +6,17 @@ import com.callstack.brownie.registerStoreIfNeeded import com.callstack.reactnativebrownfield.ReactNativeBrownfield import com.facebook.react.ReactApplication import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost /** * Detox expects a [ReactApplication] so it can await the embedded RN context during E2E runs. * RN is initialized at process start (embedded AAR bundle — no Metro). */ class BrownfieldApplication : Application(), ReactApplication { + @Suppress("DEPRECATION") + override val reactNativeHost: ReactNativeHost + get() = ReactNativeHostManager.reactNativeHost + override val reactHost: ReactHost get() = ReactNativeBrownfield.shared.reactHost diff --git a/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt b/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt index 825698be..ea34e7e3 100644 --- a/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt +++ b/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt @@ -5,7 +5,17 @@ import com.callstack.reactnativebrownfield.OnJSBundleLoaded import com.callstack.reactnativebrownfield.ReactNativeBrownfield import com.facebook.react.PackageList +import com.facebook.react.PackageList +import com.facebook.react.ReactNativeHost + object ReactNativeHostManager { + @Suppress("DEPRECATION") + val reactNativeHost: ReactNativeHost + get() = + throw RuntimeException( + "You should not use ReactNativeHost directly in the New Architecture" + ) + fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) { val packageList = PackageList(application).packages ReactNativeBrownfield.initialize(application, packageList, onJSBundleLoaded) diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt index aa87d6df..1425902c 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt @@ -5,10 +5,18 @@ import android.content.res.Configuration import com.callstack.reactnativebrownfield.OnJSBundleLoaded import com.callstack.reactnativebrownfield.ReactNativeBrownfield import com.facebook.react.PackageList +import com.facebook.react.ReactNativeHost import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ExpoReactHostFactory object ReactNativeHostManager { + @Suppress("DEPRECATION") + val reactNativeHost: ReactNativeHost + get() = + throw RuntimeException( + "You should not use ReactNativeHost directly in the New Architecture" + ) + fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) { ApplicationLifecycleDispatcher.onApplicationCreate(application) diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.pre55.kt b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.pre55.kt index a7fc7777..1704e715 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.pre55.kt +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.pre55.kt @@ -6,6 +6,7 @@ import com.callstack.reactnativebrownfield.OnJSBundleLoaded import com.callstack.reactnativebrownfield.ReactNativeBrownfield import com.facebook.react.PackageList import com.facebook.react.ReactPackage +import com.facebook.react.ReactNativeHost import com.facebook.react.defaults.DefaultReactNativeHost import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ExpoReactHostFactory @@ -13,10 +14,14 @@ import expo.modules.ReactNativeHostWrapper {{EXPO_UPDATES_IMPORTS}} object ReactNativeHostManager { + @Suppress("DEPRECATION") + lateinit var reactNativeHost: ReactNativeHost + private set + fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) { ApplicationLifecycleDispatcher.onApplicationCreate(application) - val reactNativeHost = ReactNativeHostWrapper( + reactNativeHost = ReactNativeHostWrapper( application, object : DefaultReactNativeHost(application) { override fun getUseDeveloperSupport(): Boolean { From 4affd55376beb982bfebe27e2a0dd913074a507f Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 12:22:50 +0300 Subject: [PATCH 06/15] feat: fix android run emulator --- .github/actions/androidapp-road-test/action.yml | 9 +++++++++ .../com/rnapp/brownfieldlib/ReactNativeHostManager.kt | 2 -- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/actions/androidapp-road-test/action.yml b/.github/actions/androidapp-road-test/action.yml index 3b22cd43..599d991f 100644 --- a/.github/actions/androidapp-road-test/action.yml +++ b/.github/actions/androidapp-road-test/action.yml @@ -220,6 +220,15 @@ runs: working-directory: apps/AndroidApp shell: bash + - name: Install Android emulator runtime libraries + if: inputs.run-e2e == 'true' + run: | + # qemu-system-x86_64 is linked against libpulse even with -no-audio; free-disk-space + # on ubuntu-latest can remove it before the emulator starts. + sudo apt-get update + sudo apt-get install -y libpulse0 + shell: bash + - name: Detox test (AndroidApp ${{ inputs.flavor }}) if: inputs.run-e2e == 'true' uses: reactivecircus/android-emulator-runner@v2 diff --git a/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt b/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt index ea34e7e3..df62c1b2 100644 --- a/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt +++ b/apps/RNApp/android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/ReactNativeHostManager.kt @@ -3,8 +3,6 @@ package com.rnapp.brownfieldlib import android.app.Application import com.callstack.reactnativebrownfield.OnJSBundleLoaded import com.callstack.reactnativebrownfield.ReactNativeBrownfield -import com.facebook.react.PackageList - import com.facebook.react.PackageList import com.facebook.react.ReactNativeHost From da8929ca41b09809eb6b261cd47373a8d780181f Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 14:13:10 +0300 Subject: [PATCH 07/15] feat: fix android vanilla --- .github/actions/androidapp-road-test/action.yml | 16 ++++++++++++---- .../e2e/androidAppBrownfield.e2e.js | 8 ++++---- .../e2e/androidAppDetoxUtils.cjs | 15 ++++++++------- .../e2e/androidAppExpoBrownfield.e2e.js | 3 +-- .../__tests__/strip-framework-binary.test.ts | 9 ++++----- .../cli/src/brownie/__tests__/config.test.ts | 4 +--- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/.github/actions/androidapp-road-test/action.yml b/.github/actions/androidapp-road-test/action.yml index 599d991f..dacfc031 100644 --- a/.github/actions/androidapp-road-test/action.yml +++ b/.github/actions/androidapp-road-test/action.yml @@ -150,7 +150,7 @@ runs: if: inputs.run-e2e == 'true' run: | set -euo pipefail - AAR_PATH="${HOME}/.m2/repository/${{ inputs.rn-project-maven-path }}/0.0.1-SNAPSHOT/brownfieldlib-0.0.1-SNAPSHOT-release.aar" + AAR_PATH="${HOME}/.m2/repository/${{ inputs.rn-project-maven-path }}/0.0.1-SNAPSHOT/brownfieldlib-0.0.1-SNAPSHOT-${{ steps.aar-variants.outputs.release }}.aar" TMP_DIR="$(mktemp -d)" trap 'rm -rf "${TMP_DIR}"' EXIT unzip -q "${AAR_PATH}" -d "${TMP_DIR}" @@ -223,10 +223,18 @@ runs: - name: Install Android emulator runtime libraries if: inputs.run-e2e == 'true' run: | - # qemu-system-x86_64 is linked against libpulse even with -no-audio; free-disk-space - # on ubuntu-latest can remove it before the emulator starts. + # free-disk-space (prepare-android) can remove libs the QEMU emulator needs, + # even with -no-audio (libpulse) and headless CI (libgl1, libxkbfile1). sudo apt-get update - sudo apt-get install -y libpulse0 + sudo apt-get install -y libpulse0 libgl1 libxkbfile1 + shell: bash + + - name: Enable KVM for Android emulator + if: inputs.run-e2e == 'true' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm shell: bash - name: Detox test (AndroidApp ${{ inputs.flavor }}) diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js index cb323de5..5f94459d 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js @@ -1,9 +1,9 @@ const { device, element, by, expect: detoxExpect } = require('detox'); -const { brownfieldE2eTestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); +const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, configureDetoxForBrownfieldAndroid, - waitForVisibleIgnoringSync, + waitForNativeOverlayVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { scrollToNativeShellVanilla, @@ -41,11 +41,11 @@ describe('Brownfield (AndroidApp — Vanilla)', () => { it('navigates to native settings from the RN surface', async () => { await element(by.id(ids.openNativeSettings)).tap(); - await waitForVisibleIgnoringSync(by.id(ids.appleAppNativeSettings), 10000); + await waitForNativeOverlayVisible(by.id(ids.appleAppNativeSettings), 10000); }); it('navigates to native referrals from the RN surface', async () => { await element(by.id(ids.openNativeReferrals)).tap(); - await waitForVisibleIgnoringSync(by.id(ids.appleAppNativeReferrals), 10000, 0); + await waitForNativeOverlayVisible(by.id(ids.appleAppNativeReferrals), 10000, 0); }); }); diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs index 2da3f249..683fa144 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs @@ -1,9 +1,10 @@ const { device, element, by, waitFor } = require('detox'); -const { brownfieldE2eTestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); +const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, dismissAndroidSystemOverlays, - waitForVisibleIgnoringSync, + waitForVisible, + waitForNativeOverlayVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); /** Middle-of-screen anchor — avoids status-bar swipes that open the notification shade. */ @@ -66,7 +67,7 @@ async function waitForAndroidAppReadyVanilla() { async function waitForAndroidAppReadyExpo() { const homeTab = by.label('Home'); try { - await waitForVisibleIgnoringSync(homeTab, 120000, 0); + await waitForVisible(homeTab, 120000, 0); } catch { await device.disableSynchronization(); try { @@ -79,13 +80,13 @@ async function waitForAndroidAppReadyExpo() { } async function openPostMessageTabExpo() { - await waitForVisibleIgnoringSync(by.label('postMessage API'), 30000, 0); + await waitForVisible(by.label('postMessage API'), 30000, 0); await element(by.label('postMessage API')).atIndex(0).tap(); - await waitForVisibleIgnoringSync(by.id(ids.sendMessageToNative), 30000); + await waitForVisible(by.id(ids.sendMessageToNative), 30000); } async function sendPostMessageToNativeAndWaitForToast(rnMessagePattern) { - await waitForVisibleIgnoringSync(by.id(ids.sendMessageToNative), 30000); + await waitForVisible(by.id(ids.sendMessageToNative), 30000); await element(by.id(ids.sendMessageToNative)).tap(); if (rnMessagePattern) { const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); @@ -100,7 +101,7 @@ async function sendPostMessageToNativeAndWaitForToast(rnMessagePattern) { } await assertDetoxTextMatches(bubble, rnMessagePattern); } - await waitForVisibleIgnoringSync(by.id(ids.appleAppPostMessageToast), 10000); + await waitForNativeOverlayVisible(by.id(ids.appleAppPostMessageToast), 10000); } module.exports = { diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js index 495663be..21ff7a79 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js @@ -1,9 +1,8 @@ const { device, element, by, expect: detoxExpect } = require('detox'); -const { brownfieldE2eTestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); +const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, configureDetoxForBrownfieldAndroid, - waitForVisibleIgnoringSync, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { scrollToNativeShellExpo, diff --git a/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts index 7fe2aecf..ec9d4178 100644 --- a/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts @@ -25,7 +25,6 @@ vi.mock('@rock-js/tools', async (importOriginal) => { const mockLoggerWarn = rockTools.logger.warn as ReturnType; const mockLoggerSuccess = rockTools.logger.success as ReturnType; -const isDarwin = process.platform === 'darwin'; function createMockXcframework( baseDir: string, @@ -66,7 +65,7 @@ describe('stripFrameworkBinary', () => { ); }); - it.skipIf(!isDarwin)('strips binary from ios-arm64 slice', () => { + it('strips binary from ios-arm64 slice', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', ]); @@ -88,7 +87,7 @@ describe('stripFrameworkBinary', () => { ); }); - it.skipIf(!isDarwin)('strips binary from simulator slice with fat binary', () => { + it('strips binary from simulator slice with fat binary', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64_x86_64-simulator', ]); @@ -112,7 +111,7 @@ describe('stripFrameworkBinary', () => { expect(archInfo).toContain('x86_64'); }); - it.skipIf(!isDarwin)('handles multiple slices', () => { + it('handles multiple slices', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', 'ios-arm64_x86_64-simulator', @@ -166,7 +165,7 @@ describe('stripFrameworkBinary', () => { ); }); - it.skipIf(!isDarwin)('ignores non-ios directories', () => { + it('ignores non-ios directories', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', ]); diff --git a/packages/cli/src/brownie/__tests__/config.test.ts b/packages/cli/src/brownie/__tests__/config.test.ts index 608d9ca8..32263c0e 100644 --- a/packages/cli/src/brownie/__tests__/config.test.ts +++ b/packages/cli/src/brownie/__tests__/config.test.ts @@ -41,9 +41,7 @@ describe('loadConfig', () => { it('throws when package.json not found', () => { mockCwd.mockReturnValue('/nonexistent/path'); - expect(() => loadConfig()).toThrow( - 'Could not find project root (no package.json found)' - ); + expect(() => loadConfig()).toThrow('package.json not found'); }); it('returns empty config when brownie config missing', () => { From 36e2a92dcf59c6e32772e9cf43879479171b0938 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 14:22:16 +0300 Subject: [PATCH 08/15] feat: restore fix for ci --- apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs | 2 +- .../utils/__tests__/strip-framework-binary.test.ts | 9 +++++---- packages/cli/src/brownie/__tests__/config.test.ts | 4 +++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs b/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs index b7c91a35..68769eef 100644 --- a/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs +++ b/apps/brownfield-example-shared-tests/e2e/e2eTestIds.cjs @@ -1,6 +1,6 @@ 'use strict'; -// Detox E2E (CJS). Keep in sync with ../src/e2eTestIds.ts +// Detox E2E (CJS). Keep in sync with ../src/e2eTestIds.ts and native E2eTestIds (Swift/Kotlin). const brownfieldE2ETestIds = { rnAppHome: 'brownfield-e2e-rnapp-home', rnAppHomeTitle: 'brownfield-e2e-rnapp-home-title', diff --git a/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts index ec9d4178..7fe2aecf 100644 --- a/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts @@ -25,6 +25,7 @@ vi.mock('@rock-js/tools', async (importOriginal) => { const mockLoggerWarn = rockTools.logger.warn as ReturnType; const mockLoggerSuccess = rockTools.logger.success as ReturnType; +const isDarwin = process.platform === 'darwin'; function createMockXcframework( baseDir: string, @@ -65,7 +66,7 @@ describe('stripFrameworkBinary', () => { ); }); - it('strips binary from ios-arm64 slice', () => { + it.skipIf(!isDarwin)('strips binary from ios-arm64 slice', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', ]); @@ -87,7 +88,7 @@ describe('stripFrameworkBinary', () => { ); }); - it('strips binary from simulator slice with fat binary', () => { + it.skipIf(!isDarwin)('strips binary from simulator slice with fat binary', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64_x86_64-simulator', ]); @@ -111,7 +112,7 @@ describe('stripFrameworkBinary', () => { expect(archInfo).toContain('x86_64'); }); - it('handles multiple slices', () => { + it.skipIf(!isDarwin)('handles multiple slices', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', 'ios-arm64_x86_64-simulator', @@ -165,7 +166,7 @@ describe('stripFrameworkBinary', () => { ); }); - it('ignores non-ios directories', () => { + it.skipIf(!isDarwin)('ignores non-ios directories', () => { const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ 'ios-arm64', ]); diff --git a/packages/cli/src/brownie/__tests__/config.test.ts b/packages/cli/src/brownie/__tests__/config.test.ts index 32263c0e..608d9ca8 100644 --- a/packages/cli/src/brownie/__tests__/config.test.ts +++ b/packages/cli/src/brownie/__tests__/config.test.ts @@ -41,7 +41,9 @@ describe('loadConfig', () => { it('throws when package.json not found', () => { mockCwd.mockReturnValue('/nonexistent/path'); - expect(() => loadConfig()).toThrow('package.json not found'); + expect(() => loadConfig()).toThrow( + 'Could not find project root (no package.json found)' + ); }); it('returns empty config when brownie config missing', () => { From 39bfd53774ffc53d025c6d1798dcdf8df8159665 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 15:06:19 +0300 Subject: [PATCH 09/15] feat: switch test --- .../e2e/androidAppBrownfield.e2e.js | 8 +++++--- .../e2e/appleAppBrownfield.e2e.js | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js index 5f94459d..6a028638 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js @@ -8,7 +8,6 @@ const { const { scrollToNativeShellVanilla, waitForAndroidAppReadyVanilla, - sendPostMessageToNativeAndWaitForToast, } = require('@callstack/brownfield-example-shared-tests/e2e/androidAppDetoxUtils'); describe('Brownfield (AndroidApp — Vanilla)', () => { @@ -35,8 +34,11 @@ describe('Brownfield (AndroidApp — Vanilla)', () => { await assertDetoxTextMatches(count, /Count:\s*1/); }); - it('shows a native toast when RN sends postMessage', async () => { - await sendPostMessageToNativeAndWaitForToast(/Hello from React Native!/); + it('records the RN postMessage bubble after sending to native', async () => { + await element(by.id(ids.sendMessageToNative)).tap(); + const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); + await detoxExpect(bubble).toBeVisible(); + await assertDetoxTextMatches(bubble, /Hello from React Native!/); }); it('navigates to native settings from the RN surface', async () => { diff --git a/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js index dcae1226..8b06b8d0 100644 --- a/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js @@ -10,7 +10,6 @@ const { const { scrollToNativeShellVanilla, waitForAppleAppReadyVanilla, - sendPostMessageToNativeAndWaitForToast, } = require('@callstack/brownfield-example-shared-tests/e2e/appleAppDetoxUtils'); describe('Brownfield (AppleApp — Vanilla)', () => { @@ -36,8 +35,11 @@ describe('Brownfield (AppleApp — Vanilla)', () => { await assertDetoxTextMatches(count, /Count:\s*1/); }); - it('shows a native toast when RN sends postMessage', async () => { - await sendPostMessageToNativeAndWaitForToast(/Hello from React Native!/); + it('records the RN postMessage bubble after sending to native', async () => { + await element(by.id(ids.sendMessageToNative)).tap(); + const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); + await detoxExpect(bubble).toBeVisible(); + await assertDetoxTextMatches(bubble, /Hello from React Native!/); }); it('navigates to native settings from the RN surface', async () => { From c0eb8b50d7030313e342054f9f6244913fec212a Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 15:51:15 +0300 Subject: [PATCH 10/15] feat: update vanilla --- .../brownfield/android/example/MainActivity.kt | 4 ++++ .../e2e/androidAppBrownfield.e2e.js | 17 ++++++++++++----- .../e2e/androidAppDetoxUtils.cjs | 8 ++++---- .../e2e/androidAppExpoBrownfield.e2e.js | 7 +++---- .../e2e/detoxUtils.cjs | 10 +++++++++- .../prepare-android-emulator-for-detox.sh | 3 +++ 6 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt index a409b73a..44a584dc 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt @@ -96,6 +96,10 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate { } private fun showReactNativeLoadedToastWhenReady() { + if (intent?.getStringExtra("DetoxE2E") == "YES") { + return + } + val reactHost = ReactNativeBrownfield.shared.reactHost reactHost.currentReactContext?.let { Toast.makeText(this, "React Native has been loaded", Toast.LENGTH_LONG).show() diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js index 6a028638..ca300353 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js @@ -1,8 +1,8 @@ -const { device, element, by, expect: detoxExpect } = require('detox'); +const { element, by, expect: detoxExpect } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, - configureDetoxForBrownfieldAndroid, + launchBrownfieldAppForDetox, waitForNativeOverlayVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { @@ -12,8 +12,7 @@ const { describe('Brownfield (AndroidApp — Vanilla)', () => { beforeEach(async () => { - await device.launchApp({ newInstance: true }); - await configureDetoxForBrownfieldAndroid(); + await launchBrownfieldAppForDetox({ newInstance: true }); await waitForAndroidAppReadyVanilla(); }); @@ -37,7 +36,15 @@ describe('Brownfield (AndroidApp — Vanilla)', () => { it('records the RN postMessage bubble after sending to native', async () => { await element(by.id(ids.sendMessageToNative)).tap(); const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); - await detoxExpect(bubble).toBeVisible(); + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + await assertDetoxTextMatches(bubble, /Hello from React Native!/); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } await assertDetoxTextMatches(bubble, /Hello from React Native!/); }); diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs index 683fa144..00dd245c 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs @@ -51,16 +51,16 @@ async function scrollToNativeShellExpo() { } async function waitForAndroidAppReadyVanilla() { - await waitFor(element(by.id(ids.appleAppGreeting))).toBeVisible().withTimeout(60000); + await waitForNativeOverlayVisible(by.id(ids.appleAppGreeting), 60000); + await dismissAndroidSystemOverlays(); await scrollToEmbeddedRnVanilla(); - const rnHome = element(by.id(ids.rnAppHome)); try { - await waitFor(rnHome).toBeVisible().withTimeout(60000); + await waitForNativeOverlayVisible(by.id(ids.rnAppHome), 60000); } catch { await scrollToEmbeddedRnVanilla(); - await waitFor(rnHome).toBeVisible().withTimeout(30000); + await waitForNativeOverlayVisible(by.id(ids.rnAppHome), 30000); } } diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js index 21ff7a79..03c4eba4 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js @@ -1,8 +1,8 @@ -const { device, element, by, expect: detoxExpect } = require('detox'); +const { element, by, expect: detoxExpect } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, - configureDetoxForBrownfieldAndroid, + launchBrownfieldAppForDetox, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { scrollToNativeShellExpo, @@ -13,8 +13,7 @@ const { describe('Brownfield (AndroidApp — Expo)', () => { beforeEach(async () => { - await device.launchApp({ newInstance: true }); - await configureDetoxForBrownfieldAndroid(); + await launchBrownfieldAppForDetox({ newInstance: true }); await waitForAndroidAppReadyExpo(); }); diff --git a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs index c3864718..d2202a96 100644 --- a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs @@ -25,7 +25,11 @@ async function dismissAndroidSystemOverlays() { if (device.getPlatform() !== 'android') { return; } + // Headless CI emulators can leave the keyguard, shade, or heads-up UI without window focus. + adbShell('input keyevent KEYCODE_WAKEUP'); + adbShell('wm dismiss-keyguard'); adbShell('cmd statusbar collapse'); + adbShell('settings put global heads_up_notifications_enabled 0'); } function detoxAttrsText(attrs) { @@ -75,7 +79,11 @@ async function launchBrownfieldAppForDetox({ newInstance = true } = {}) { detoxEnableSynchronization: 0, }, }); - await configureDetoxForBrownfieldIos(); + if (device.getPlatform() === 'android') { + await dismissAndroidSystemOverlays(); + } else { + await configureDetoxForBrownfieldIos(); + } await device.enableSynchronization(); } diff --git a/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh index 23aa4e49..b1fe83ad 100755 --- a/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh +++ b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh @@ -10,5 +10,8 @@ adb shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true adb shell wm dismiss-keyguard >/dev/null 2>&1 || true adb shell cmd statusbar collapse >/dev/null 2>&1 || true adb shell settings put global heads_up_notifications_enabled 0 >/dev/null 2>&1 || true +adb shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true +adb shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true +adb shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true adb shell settings put secure user_setup_complete 1 >/dev/null 2>&1 || true adb shell settings put secure tv_user_setup_complete 1 >/dev/null 2>&1 || true From 3d61e9439d8d4cad889b1ad84564ef4f3751d821 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 16:34:07 +0300 Subject: [PATCH 11/15] feat: update vanilla --- .../android/example/MainActivity.kt | 4 ---- .../e2e/androidAppBrownfield.e2e.js | 22 ++++--------------- .../e2e/androidAppDetoxUtils.cjs | 8 +++---- .../e2e/androidAppExpoBrownfield.e2e.js | 7 +++--- .../e2e/appleAppBrownfield.e2e.js | 7 ------ .../e2e/detoxUtils.cjs | 10 +-------- .../prepare-android-emulator-for-detox.sh | 3 --- 7 files changed, 13 insertions(+), 48 deletions(-) diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt index 44a584dc..a409b73a 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt @@ -96,10 +96,6 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate { } private fun showReactNativeLoadedToastWhenReady() { - if (intent?.getStringExtra("DetoxE2E") == "YES") { - return - } - val reactHost = ReactNativeBrownfield.shared.reactHost reactHost.currentReactContext?.let { Toast.makeText(this, "React Native has been loaded", Toast.LENGTH_LONG).show() diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js index ca300353..250ebf19 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js @@ -1,8 +1,8 @@ -const { element, by, expect: detoxExpect } = require('detox'); +const { device, element, by, expect: detoxExpect } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, - launchBrownfieldAppForDetox, + configureDetoxForBrownfieldAndroid, waitForNativeOverlayVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { @@ -12,7 +12,8 @@ const { describe('Brownfield (AndroidApp — Vanilla)', () => { beforeEach(async () => { - await launchBrownfieldAppForDetox({ newInstance: true }); + await device.launchApp({ newInstance: true }); + await configureDetoxForBrownfieldAndroid(); await waitForAndroidAppReadyVanilla(); }); @@ -33,21 +34,6 @@ describe('Brownfield (AndroidApp — Vanilla)', () => { await assertDetoxTextMatches(count, /Count:\s*1/); }); - it('records the RN postMessage bubble after sending to native', async () => { - await element(by.id(ids.sendMessageToNative)).tap(); - const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); - const deadline = Date.now() + 15000; - while (Date.now() < deadline) { - try { - await assertDetoxTextMatches(bubble, /Hello from React Native!/); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, 200)); - } - } - await assertDetoxTextMatches(bubble, /Hello from React Native!/); - }); - it('navigates to native settings from the RN surface', async () => { await element(by.id(ids.openNativeSettings)).tap(); await waitForNativeOverlayVisible(by.id(ids.appleAppNativeSettings), 10000); diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs index 00dd245c..683fa144 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs @@ -51,16 +51,16 @@ async function scrollToNativeShellExpo() { } async function waitForAndroidAppReadyVanilla() { - await waitForNativeOverlayVisible(by.id(ids.appleAppGreeting), 60000); - await dismissAndroidSystemOverlays(); + await waitFor(element(by.id(ids.appleAppGreeting))).toBeVisible().withTimeout(60000); await scrollToEmbeddedRnVanilla(); + const rnHome = element(by.id(ids.rnAppHome)); try { - await waitForNativeOverlayVisible(by.id(ids.rnAppHome), 60000); + await waitFor(rnHome).toBeVisible().withTimeout(60000); } catch { await scrollToEmbeddedRnVanilla(); - await waitForNativeOverlayVisible(by.id(ids.rnAppHome), 30000); + await waitFor(rnHome).toBeVisible().withTimeout(30000); } } diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js index 03c4eba4..21ff7a79 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js @@ -1,8 +1,8 @@ -const { element, by, expect: detoxExpect } = require('detox'); +const { device, element, by, expect: detoxExpect } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, - launchBrownfieldAppForDetox, + configureDetoxForBrownfieldAndroid, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { scrollToNativeShellExpo, @@ -13,7 +13,8 @@ const { describe('Brownfield (AndroidApp — Expo)', () => { beforeEach(async () => { - await launchBrownfieldAppForDetox({ newInstance: true }); + await device.launchApp({ newInstance: true }); + await configureDetoxForBrownfieldAndroid(); await waitForAndroidAppReadyExpo(); }); diff --git a/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js index 8b06b8d0..6318c1c2 100644 --- a/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/appleAppBrownfield.e2e.js @@ -35,13 +35,6 @@ describe('Brownfield (AppleApp — Vanilla)', () => { await assertDetoxTextMatches(count, /Count:\s*1/); }); - it('records the RN postMessage bubble after sending to native', async () => { - await element(by.id(ids.sendMessageToNative)).tap(); - const bubble = element(by.id(ids.rnPostMessageText)).atIndex(0); - await detoxExpect(bubble).toBeVisible(); - await assertDetoxTextMatches(bubble, /Hello from React Native!/); - }); - it('navigates to native settings from the RN surface', async () => { await element(by.id(ids.openNativeSettings)).tap(); await waitForNativeOverlayVisible(by.label('Settings'), 10000); diff --git a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs index d2202a96..c3864718 100644 --- a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs @@ -25,11 +25,7 @@ async function dismissAndroidSystemOverlays() { if (device.getPlatform() !== 'android') { return; } - // Headless CI emulators can leave the keyguard, shade, or heads-up UI without window focus. - adbShell('input keyevent KEYCODE_WAKEUP'); - adbShell('wm dismiss-keyguard'); adbShell('cmd statusbar collapse'); - adbShell('settings put global heads_up_notifications_enabled 0'); } function detoxAttrsText(attrs) { @@ -79,11 +75,7 @@ async function launchBrownfieldAppForDetox({ newInstance = true } = {}) { detoxEnableSynchronization: 0, }, }); - if (device.getPlatform() === 'android') { - await dismissAndroidSystemOverlays(); - } else { - await configureDetoxForBrownfieldIos(); - } + await configureDetoxForBrownfieldIos(); await device.enableSynchronization(); } diff --git a/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh index b1fe83ad..23aa4e49 100755 --- a/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh +++ b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh @@ -10,8 +10,5 @@ adb shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true adb shell wm dismiss-keyguard >/dev/null 2>&1 || true adb shell cmd statusbar collapse >/dev/null 2>&1 || true adb shell settings put global heads_up_notifications_enabled 0 >/dev/null 2>&1 || true -adb shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true -adb shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true -adb shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true adb shell settings put secure user_setup_complete 1 >/dev/null 2>&1 || true adb shell settings put secure tv_user_setup_complete 1 >/dev/null 2>&1 || true From fad4b4485f52bd80dbca5005d693e505b602e154 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 17:16:21 +0300 Subject: [PATCH 12/15] feat: add new test ids and fix device pick for expo55 --- .../actions/androidapp-road-test/action.yml | 14 +++- .../android/example/MainActivity.kt | 4 + .../android/example/ReferralsActivity.kt | 18 +++-- .../android/example/SettingsActivity.kt | 18 +++-- .../example/components/EspressoTagAnchor.kt | 40 ++++++---- .../example/components/GreetingCard.kt | 53 ++++++------- .../example/components/PostMessageCard.kt | 8 +- .../example/components/PostMessageToast.kt | 27 ++++--- .../detox-android-emulator-device.cjs | 41 ++++++++++ .../detox-rc-androidapp-emulator-release.cjs | 19 +++-- .../e2e/androidAppBrownfield.e2e.js | 7 +- .../e2e/androidAppDetoxUtils.cjs | 78 +++++++++++++++---- .../e2e/androidAppExpoBrownfield.e2e.js | 7 +- .../e2e/detoxUtils.cjs | 10 ++- .../prepare-android-emulator-for-detox.sh | 52 +++++++++++-- scripts/ci-local-androidapp-android-e2e.sh | 7 +- 16 files changed, 286 insertions(+), 117 deletions(-) diff --git a/.github/actions/androidapp-road-test/action.yml b/.github/actions/androidapp-road-test/action.yml index dacfc031..0f51675d 100644 --- a/.github/actions/androidapp-road-test/action.yml +++ b/.github/actions/androidapp-road-test/action.yml @@ -220,6 +220,16 @@ runs: working-directory: apps/AndroidApp shell: bash + - name: Free workspace disk space before Detox emulator + if: inputs.run-e2e == 'true' + run: | + # Heavy Expo/RN Gradle outputs can leave too little room for the AVD to start. + ANDROID_DIR="${{ inputs.rn-project-path }}/android" + rm -rf "$ANDROID_DIR/build" "$ANDROID_DIR/.cxx" "$ANDROID_DIR/.gradle" + rm -rf apps/AndroidApp/.gradle + df -h . + shell: bash + - name: Install Android emulator runtime libraries if: inputs.run-e2e == 'true' run: | @@ -246,14 +256,16 @@ runs: arch: x86_64 profile: pixel_6 avd-name: test + force-avd-creation: true disable-animations: true + emulator-boot-timeout: 600 + emulator-options: -no-snapshot-save -no-snapshot-load -memory 4096 script: | bash apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh cd apps/AndroidApp yarn "$ANDROIDAPP_E2E_TEST_SCRIPT" env: ANDROIDAPP_E2E_TEST_SCRIPT: ${{ env.ANDROIDAPP_E2E_TEST_SCRIPT }} - DETOX_DEVICE: test - name: Upload Detox artifacts on failure if: failure() && inputs.run-e2e == 'true' diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt index a409b73a..44a584dc 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt @@ -96,6 +96,10 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate { } private fun showReactNativeLoadedToastWhenReady() { + if (intent?.getStringExtra("DetoxE2E") == "YES") { + return + } + val reactHost = ReactNativeBrownfield.shared.reactHost reactHost.currentReactContext?.let { Toast.makeText(this, "React Native has been loaded", Toast.LENGTH_LONG).show() diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt index c13ae187..d2be2e51 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/ReferralsActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -14,7 +15,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign @@ -45,13 +45,15 @@ class ReferralsActivity : ComponentActivity() { verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - EspressoTagAnchor(E2eTestIds.nativeAppNativeReferrals) - - Text( - text = "Referrals", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.testTag(E2eTestIds.nativeAppNativeReferrals), - ) + EspressoTagAnchor( + tag = E2eTestIds.nativeAppNativeReferrals, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Referrals", + style = MaterialTheme.typography.headlineMedium, + ) + } Text( text = "Opened from BrownfieldNavigation.navigateToReferrals(userId).", textAlign = TextAlign.Center diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt index 21722e64..513320e1 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/SettingsActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -14,7 +15,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign @@ -43,13 +43,15 @@ class SettingsActivity : ComponentActivity() { verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - EspressoTagAnchor(E2eTestIds.nativeAppNativeSettings) - - Text( - text = "Settings", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.testTag(E2eTestIds.nativeAppNativeSettings), - ) + EspressoTagAnchor( + tag = E2eTestIds.nativeAppNativeSettings, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Settings", + style = MaterialTheme.typography.headlineMedium, + ) + } Text( text = "Opened from BrownfieldNavigation.navigateToSettings().", textAlign = TextAlign.Center diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt index 634b85e2..9b06fdf6 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt @@ -1,34 +1,44 @@ package com.callstack.brownfield.android.example.components import android.view.View -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.matchParentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView /** - * Invisible anchor view so Detox/Espresso [by.id] can match Compose-hosted screens. + * Bridges Compose-hosted UI to Detox/Espresso [by.id] matchers. * * Compose [androidx.compose.ui.platform.testTag] is visible to UiAutomator via * [androidx.compose.ui.semantics.testTagsAsResourceId], but Detox resolves ids through - * Espresso [View.getTag]. This anchor bridges the two. + * Espresso [View.getTag]. The backing [AndroidView] uses [Modifier.matchParentSize] so the + * tagged view inherits the visible bounds of [content] — a 1dp anchor fails Detox + * [toBeVisible] even when the on-screen text is shown. */ @Composable fun EspressoTagAnchor( tag: String, modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit = {}, ) { - AndroidView( - modifier = modifier - .size(1.dp) - .testTag(tag), - factory = { context -> - View(context).apply { - this.tag = tag - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - } - }, - ) + Box(modifier = modifier) { + AndroidView( + modifier = Modifier + .matchParentSize() + .testTag(tag), + factory = { context -> + View(context).apply { + this.tag = tag + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + }, + update = { view -> + view.tag = tag + }, + ) + content() + } } diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt index 9407b1f6..9113836c 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/GreetingCard.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import com.callstack.brownfield.android.example.E2eTestIds import androidx.compose.ui.unit.dp @@ -48,35 +47,37 @@ fun GreetingCard( } MaterialCard { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) + EspressoTagAnchor( + tag = E2eTestIds.nativeAppGreeting, + modifier = Modifier.fillMaxWidth(), ) { - EspressoTagAnchor(E2eTestIds.nativeAppGreeting) + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Hello native $name 👋", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) - Text( - text = "Hello native $name 👋", - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - modifier = Modifier.testTag(E2eTestIds.nativeAppGreeting), - ) + Text( + text = "You clicked the button $counter time${if (counter == 1) "" else "s"}", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) - Text( - text = "You clicked the button $counter time${if (counter == 1) "" else "s"}", - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium - ) - - Button(onClick = { - brownieStore()?.set { state -> - state.copy(counter = state.counter + 1) + Button(onClick = { + brownieStore()?.set { state -> + state.copy(counter = state.counter + 1) + } + }) { + Text("Increment counter") } - }) { - Text("Increment counter") } } } -} \ No newline at end of file +} diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt index 57b6deb1..ef8aebad 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageCard.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.callstack.brownfield.android.example.E2eTestIds import com.callstack.reactnativebrownfield.OnMessageListener @@ -81,10 +80,11 @@ fun PostMessageCard( ReactNativeBrownfield.shared.postMessage(json) draft = "" }, - modifier = Modifier.testTag(E2eTestIds.nativeAppPostMessageSend), + modifier = Modifier.weight(1f), ) { - EspressoTagAnchor(E2eTestIds.nativeAppPostMessageSend) - Text("Send") + EspressoTagAnchor(E2eTestIds.nativeAppPostMessageSend) { + Text("Send") + } } } } diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt index e8edd373..d01ebdc4 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/PostMessageToast.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.callstack.brownfield.android.example.E2eTestIds @@ -53,19 +52,19 @@ fun PostMessageToast( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter, ) { - Text( - text = message, - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 12.dp) - .padding(bottom = 50.dp) - .scale(animatedScale) - .alpha(animatedOpacity) - .background(Color.Black.copy(alpha = 0.8f), RoundedCornerShape(25.dp)) - .testTag(E2eTestIds.nativeAppPostMessageToast), - ) - EspressoTagAnchor(E2eTestIds.nativeAppPostMessageToast) + EspressoTagAnchor(E2eTestIds.nativeAppPostMessageToast) { + Text( + text = message, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 12.dp) + .padding(bottom = 50.dp) + .scale(animatedScale) + .alpha(animatedOpacity) + .background(Color.Black.copy(alpha = 0.8f), RoundedCornerShape(25.dp)), + ) + } } } } diff --git a/apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs b/apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs index 00e57333..0b4727bf 100644 --- a/apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs +++ b/apps/brownfield-example-shared-tests/detox-android-emulator-device.cjs @@ -74,6 +74,45 @@ function getAndroidEmulatorAvdName() { return pickPreferredAndroidEmulatorAvd(avds); } +function getAttachedAdbSerial() { + return ( + process.env.ANDROID_SERIAL?.trim() || + process.env.ANDROID_ADB_SERIAL?.trim() || + '' + ); +} + +/** + * Detox device entry for AndroidApp E2E. + * + * CI uses reactivecircus/android-emulator-runner, which exports ANDROID_SERIAL for + * the script phase. Prefer android.attached there so Detox does not cold-boot a + * second emulator (that race produces endless `getprop sys.boot_completed` noise + * and `adb: device 'emulator-5554' not found`). + * + * @returns {{ deviceKey: string, deviceConfig: { type: string, device: object } }} + */ +function resolveAndroidDetoxDevice() { + const adbName = getAttachedAdbSerial(); + if (adbName) { + return { + deviceKey: 'android.attached', + deviceConfig: { + type: 'android.attached', + device: { adbName }, + }, + }; + } + + return { + deviceKey: 'android.emulator', + deviceConfig: { + type: 'android.emulator', + device: { avdName: getAndroidEmulatorAvdName() }, + }, + }; +} + module.exports = { FALLBACK_AVD_NAME, PREFERRED_LOCAL_AVD_NAMES, @@ -81,4 +120,6 @@ module.exports = { pickPreferredAndroidEmulatorAvd, getRunningEmulatorAvdName, getAndroidEmulatorAvdName, + getAttachedAdbSerial, + resolveAndroidDetoxDevice, }; diff --git a/apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs b/apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs index 89c0d99d..e5d7cd4c 100644 --- a/apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs +++ b/apps/brownfield-example-shared-tests/detox-rc-androidapp-emulator-release.cjs @@ -1,6 +1,6 @@ 'use strict'; -const { getAndroidEmulatorAvdName } = require('./detox-android-emulator-device.cjs'); +const { resolveAndroidDetoxDevice } = require('./detox-android-emulator-device.cjs'); /** * Detox Android emulator release config for AndroidApp (native Gradle consumer). @@ -28,6 +28,8 @@ function createAndroidAppEmulatorReleaseDetoxConfig({ const binaryPath = `app/build/outputs/apk/${gradleFlavor}/release/app-${gradleFlavor}-release.apk`; const testBinaryPath = `app/build/outputs/apk/androidTest/${gradleFlavor}/release/app-${gradleFlavor}-release-androidTest.apk`; + const { deviceKey, deviceConfig } = resolveAndroidDetoxDevice(); + return { testRunner: { $0: 'jest', @@ -39,6 +41,12 @@ function createAndroidAppEmulatorReleaseDetoxConfig({ setupTimeout: 300000, }, }, + behavior: { + cleanup: { + // CI owns emulator lifecycle via android-emulator-runner. + shutdownDevice: false, + }, + }, apps: { 'android.release': { type: 'android.apk', @@ -48,16 +56,11 @@ function createAndroidAppEmulatorReleaseDetoxConfig({ }, }, devices: { - 'android.emulator': { - type: 'android.emulator', - device: { - avdName: getAndroidEmulatorAvdName(), - }, - }, + [deviceKey]: deviceConfig, }, configurations: { [detoxConfiguration]: { - device: 'android.emulator', + device: deviceKey, app: 'android.release', }, }, diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js index 250ebf19..33673c29 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppBrownfield.e2e.js @@ -1,8 +1,8 @@ -const { device, element, by, expect: detoxExpect } = require('detox'); +const { element, by, expect: detoxExpect } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, - configureDetoxForBrownfieldAndroid, + launchBrownfieldAppForDetox, waitForNativeOverlayVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { @@ -12,8 +12,7 @@ const { describe('Brownfield (AndroidApp — Vanilla)', () => { beforeEach(async () => { - await device.launchApp({ newInstance: true }); - await configureDetoxForBrownfieldAndroid(); + await launchBrownfieldAppForDetox({ newInstance: true }); await waitForAndroidAppReadyVanilla(); }); diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs index 683fa144..3ebb947e 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs @@ -1,5 +1,6 @@ -const { device, element, by, waitFor } = require('detox'); +const { device, element, by, waitFor, expect: detoxExpect } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); +const { DETOX_TIMING } = require('./detoxTiming.cjs'); const { assertDetoxTextMatches, dismissAndroidSystemOverlays, @@ -7,19 +8,26 @@ const { waitForNativeOverlayVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); +const VANILLA_NATIVE_GREETING = by.text(/Hello native Android/); + /** Middle-of-screen anchor — avoids status-bar swipes that open the notification shade. */ -const NATIVE_SHELL_SCROLL_ANCHOR = ids.appleAppGreeting; +const NATIVE_SHELL_SCROLL_ANCHOR = VANILLA_NATIVE_GREETING; async function scrollNativeShell(fingerDirection) { - const anchor = element(by.id(NATIVE_SHELL_SCROLL_ANCHOR)); - await waitFor(anchor).toBeVisible().withTimeout(10000); + await device.disableSynchronization(); try { - await anchor.swipe(fingerDirection, 'slow', 0.75); - } catch { - await element(by.type('android.widget.ScrollView')).atIndex(0).scroll( - 400, - fingerDirection === 'up' ? 'down' : 'up' - ); + const anchor = element(NATIVE_SHELL_SCROLL_ANCHOR); + try { + await detoxExpect(anchor).toBeVisible(); + await anchor.swipe(fingerDirection, 'slow', 0.75); + } catch { + await element(by.type('android.widget.ScrollView')).atIndex(0).scroll( + 400, + fingerDirection === 'up' ? 'down' : 'up' + ); + } + } finally { + await device.enableSynchronization(); } await dismissAndroidSystemOverlays(); } @@ -50,18 +58,58 @@ async function scrollToNativeShellExpo() { await dismissAndroidSystemOverlays(); } +async function waitForEmbeddedRnHome(timeoutMs = DETOX_TIMING.VISIBILITY_TIMEOUT_MS) { + await device.disableSynchronization(); + try { + const deadline = Date.now() + timeoutMs; + const rnHome = element(by.id(ids.rnAppHome)); + while (Date.now() < deadline) { + try { + await detoxExpect(rnHome).toBeVisible(); + return; + } catch { + await dismissAndroidSystemOverlays(); + await new Promise((resolve) => + setTimeout(resolve, DETOX_TIMING.POLL_INTERVAL_MS) + ); + } + } + await detoxExpect(rnHome).toBeVisible(); + } finally { + await device.enableSynchronization(); + } +} + +/** + * Mirror iOS vanilla readiness: scroll to the embedded RN surface and poll for + * rnAppHome with sync off. Native greeting ids are avoided here because the old + * 1dp EspressoTagAnchor + duplicate Compose testTag pair failed Detox visibility. + */ async function waitForAndroidAppReadyVanilla() { - await waitFor(element(by.id(ids.appleAppGreeting))).toBeVisible().withTimeout(60000); + await dismissAndroidSystemOverlays(); + await waitForNativeOverlayVisible(VANILLA_NATIVE_GREETING, 60000); + await dismissAndroidSystemOverlays(); - await scrollToEmbeddedRnVanilla(); + try { + await scrollToEmbeddedRnVanilla(); + } catch { + // RN may already be on screen or the native shell is still mounting. + } - const rnHome = element(by.id(ids.rnAppHome)); try { - await waitFor(rnHome).toBeVisible().withTimeout(60000); + await waitForEmbeddedRnHome(120000); + return; } catch { + // Fall through to a second scroll attempt. + } + + try { await scrollToEmbeddedRnVanilla(); - await waitFor(rnHome).toBeVisible().withTimeout(30000); + } catch { + // Continue polling visibility. } + + await waitForEmbeddedRnHome(60000); } async function waitForAndroidAppReadyExpo() { diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js index 21ff7a79..03c4eba4 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/androidAppExpoBrownfield.e2e.js @@ -1,8 +1,8 @@ -const { device, element, by, expect: detoxExpect } = require('detox'); +const { element, by, expect: detoxExpect } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { assertDetoxTextMatches, - configureDetoxForBrownfieldAndroid, + launchBrownfieldAppForDetox, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); const { scrollToNativeShellExpo, @@ -13,8 +13,7 @@ const { describe('Brownfield (AndroidApp — Expo)', () => { beforeEach(async () => { - await device.launchApp({ newInstance: true }); - await configureDetoxForBrownfieldAndroid(); + await launchBrownfieldAppForDetox({ newInstance: true }); await waitForAndroidAppReadyExpo(); }); diff --git a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs index c3864718..d2202a96 100644 --- a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs @@ -25,7 +25,11 @@ async function dismissAndroidSystemOverlays() { if (device.getPlatform() !== 'android') { return; } + // Headless CI emulators can leave the keyguard, shade, or heads-up UI without window focus. + adbShell('input keyevent KEYCODE_WAKEUP'); + adbShell('wm dismiss-keyguard'); adbShell('cmd statusbar collapse'); + adbShell('settings put global heads_up_notifications_enabled 0'); } function detoxAttrsText(attrs) { @@ -75,7 +79,11 @@ async function launchBrownfieldAppForDetox({ newInstance = true } = {}) { detoxEnableSynchronization: 0, }, }); - await configureDetoxForBrownfieldIos(); + if (device.getPlatform() === 'android') { + await dismissAndroidSystemOverlays(); + } else { + await configureDetoxForBrownfieldIos(); + } await device.enableSynchronization(); } diff --git a/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh index 23aa4e49..e6ca38b4 100755 --- a/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh +++ b/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh @@ -4,11 +4,47 @@ # cover the app and make Detox report "The app seems to be idle". set -euo pipefail -adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' - -adb shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true -adb shell wm dismiss-keyguard >/dev/null 2>&1 || true -adb shell cmd statusbar collapse >/dev/null 2>&1 || true -adb shell settings put global heads_up_notifications_enabled 0 >/dev/null 2>&1 || true -adb shell settings put secure user_setup_complete 1 >/dev/null 2>&1 || true -adb shell settings put secure tv_user_setup_complete 1 >/dev/null 2>&1 || true +BOOT_TIMEOUT_SEC="${ANDROID_EMULATOR_BOOT_TIMEOUT_SEC:-180}" +ADB=(adb) +if [[ -n "${ANDROID_SERIAL:-}" ]]; then + ADB=(adb -s "${ANDROID_SERIAL}") +fi + +adb_device_online() { + if [[ -n "${ANDROID_SERIAL:-}" ]]; then + adb devices | awk -v serial="${ANDROID_SERIAL}" '$1 == serial && $2 == "device" { found = 1 } END { exit !found }' + return + fi + adb get-state >/dev/null 2>&1 +} + +echo "==> Waiting for Android device (timeout: ${BOOT_TIMEOUT_SEC}s, serial: ${ANDROID_SERIAL:-auto})" +"${ADB[@]}" wait-for-device + +deadline=$((SECONDS + BOOT_TIMEOUT_SEC)) +while (( SECONDS < deadline )); do + if ! adb_device_online; then + sleep 1 + continue + fi + + boot="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)" + if [[ "${boot}" == "1" ]]; then + echo "==> Android device booted" + break + fi + sleep 1 +done + +if (( SECONDS >= deadline )); then + echo "error: Android device did not finish booting within ${BOOT_TIMEOUT_SEC}s" >&2 + adb devices -l || true + exit 1 +fi + +"${ADB[@]}" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true +"${ADB[@]}" shell wm dismiss-keyguard >/dev/null 2>&1 || true +"${ADB[@]}" shell cmd statusbar collapse >/dev/null 2>&1 || true +"${ADB[@]}" shell settings put global heads_up_notifications_enabled 0 >/dev/null 2>&1 || true +"${ADB[@]}" shell settings put secure user_setup_complete 1 >/dev/null 2>&1 || true +"${ADB[@]}" shell settings put secure tv_user_setup_complete 1 >/dev/null 2>&1 || true diff --git a/scripts/ci-local-androidapp-android-e2e.sh b/scripts/ci-local-androidapp-android-e2e.sh index 517a875a..72400387 100755 --- a/scripts/ci-local-androidapp-android-e2e.sh +++ b/scripts/ci-local-androidapp-android-e2e.sh @@ -103,6 +103,11 @@ ensure_android_emulator() { if [[ -z "${RUNNING_AVD}" ]]; then start_detox_emulator "${DETOX_DEVICE}" "${INSTALLED_AVDS}" fi + + ANDROID_SERIAL="$(adb devices | awk '/^emulator-[^[:space:]]+[[:space:]]device$/{print $1; exit}')" + if [[ -n "${ANDROID_SERIAL}" ]]; then + export ANDROID_SERIAL + fi } resolve_variant() { @@ -180,7 +185,7 @@ if [[ "${BUILD_ONLY}" == "false" ]]; then bash "${REPO_ROOT}/apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh" echo "==> Detox test (AndroidApp ${VARIANT}, emulator — Metro not required)" - (cd "${ANDROID_APP_PATH}" && DETOX_DEVICE="${DETOX_DEVICE}" yarn "${E2E_TEST_SCRIPT}") + (cd "${ANDROID_APP_PATH}" && yarn "${E2E_TEST_SCRIPT}") fi echo "==> Done." From ed2516496eff950dce5eb0e94f0c9a180d5b80bb Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 17:53:05 +0300 Subject: [PATCH 13/15] feat: fix build --- .../brownfield/android/example/components/EspressoTagAnchor.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt index 9b06fdf6..d275ca64 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/components/EspressoTagAnchor.kt @@ -3,7 +3,6 @@ package com.callstack.brownfield.android.example.components import android.view.View import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.matchParentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag From 807d3ea42aeeded69bc29c31f63e5e17fbf1db07 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 18:36:21 +0300 Subject: [PATCH 14/15] feat: fix build --- .github/actions/androidapp-road-test/action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/androidapp-road-test/action.yml b/.github/actions/androidapp-road-test/action.yml index 0f51675d..89e026fb 100644 --- a/.github/actions/androidapp-road-test/action.yml +++ b/.github/actions/androidapp-road-test/action.yml @@ -256,10 +256,10 @@ runs: arch: x86_64 profile: pixel_6 avd-name: test - force-avd-creation: true disable-animations: true - emulator-boot-timeout: 600 - emulator-options: -no-snapshot-save -no-snapshot-load -memory 4096 + # Do not pass partial emulator-options — they replace the action defaults + # (-no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim). + # Omitting them keeps headless/software-GPU settings required on ubuntu-latest. script: | bash apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh cd apps/AndroidApp From a2a1c0769f72d05b64fdacf0800ed2afc02d2ada Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Thu, 25 Jun 2026 19:18:56 +0300 Subject: [PATCH 15/15] feat: try waiting approach --- .../e2e/androidAppDetoxUtils.cjs | 68 +++++------- .../e2e/detoxUtils.cjs | 101 +++++++++++++----- 2 files changed, 101 insertions(+), 68 deletions(-) diff --git a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs index 3ebb947e..c3d45420 100644 --- a/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/androidAppDetoxUtils.cjs @@ -1,9 +1,12 @@ -const { device, element, by, waitFor, expect: detoxExpect } = require('detox'); +const { element, by } = require('detox'); const { brownfieldE2ETestIds: ids } = require('@callstack/brownfield-example-shared-tests/e2e/e2eTestIds'); const { DETOX_TIMING } = require('./detoxTiming.cjs'); const { assertDetoxTextMatches, dismissAndroidSystemOverlays, + ensureAndroidAppWindowFocus, + finishAndroidDetoxLaunch, + pollUntilElementAttributes, waitForVisible, waitForNativeOverlayVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); @@ -14,20 +17,14 @@ const VANILLA_NATIVE_GREETING = by.text(/Hello native Android/); const NATIVE_SHELL_SCROLL_ANCHOR = VANILLA_NATIVE_GREETING; async function scrollNativeShell(fingerDirection) { - await device.disableSynchronization(); + const anchor = element(NATIVE_SHELL_SCROLL_ANCHOR); try { - const anchor = element(NATIVE_SHELL_SCROLL_ANCHOR); - try { - await detoxExpect(anchor).toBeVisible(); - await anchor.swipe(fingerDirection, 'slow', 0.75); - } catch { - await element(by.type('android.widget.ScrollView')).atIndex(0).scroll( - 400, - fingerDirection === 'up' ? 'down' : 'up' - ); - } - } finally { - await device.enableSynchronization(); + await anchor.swipe(fingerDirection, 'slow', 0.75); + } catch { + await element(by.type('android.widget.ScrollView')).atIndex(0).scroll( + 400, + fingerDirection === 'up' ? 'down' : 'up' + ); } await dismissAndroidSystemOverlays(); } @@ -59,36 +56,16 @@ async function scrollToNativeShellExpo() { } async function waitForEmbeddedRnHome(timeoutMs = DETOX_TIMING.VISIBILITY_TIMEOUT_MS) { - await device.disableSynchronization(); - try { - const deadline = Date.now() + timeoutMs; - const rnHome = element(by.id(ids.rnAppHome)); - while (Date.now() < deadline) { - try { - await detoxExpect(rnHome).toBeVisible(); - return; - } catch { - await dismissAndroidSystemOverlays(); - await new Promise((resolve) => - setTimeout(resolve, DETOX_TIMING.POLL_INTERVAL_MS) - ); - } - } - await detoxExpect(rnHome).toBeVisible(); - } finally { - await device.enableSynchronization(); - } + await pollUntilElementAttributes(by.id(ids.rnAppHome), timeoutMs); } /** - * Mirror iOS vanilla readiness: scroll to the embedded RN surface and poll for - * rnAppHome with sync off. Native greeting ids are avoided here because the old - * 1dp EspressoTagAnchor + duplicate Compose testTag pair failed Detox visibility. + * Wait for the native shell and embedded RN home while Detox sync stays off. + * Re-enable sync only after both surfaces are present and MainActivity has focus. */ async function waitForAndroidAppReadyVanilla() { - await dismissAndroidSystemOverlays(); - await waitForNativeOverlayVisible(VANILLA_NATIVE_GREETING, 60000); - await dismissAndroidSystemOverlays(); + await ensureAndroidAppWindowFocus(); + await pollUntilElementAttributes(VANILLA_NATIVE_GREETING, 60000); try { await scrollToEmbeddedRnVanilla(); @@ -98,6 +75,7 @@ async function waitForAndroidAppReadyVanilla() { try { await waitForEmbeddedRnHome(120000); + await finishAndroidDetoxLaunch(); return; } catch { // Fall through to a second scroll attempt. @@ -110,21 +88,23 @@ async function waitForAndroidAppReadyVanilla() { } await waitForEmbeddedRnHome(60000); + await finishAndroidDetoxLaunch(); } async function waitForAndroidAppReadyExpo() { const homeTab = by.label('Home'); try { - await waitForVisible(homeTab, 120000, 0); + await pollUntilElementAttributes(homeTab, 120000, 0); } catch { - await device.disableSynchronization(); try { await scrollToEmbeddedRnExpo(); - await waitFor(element(homeTab).atIndex(0)).toBeVisible().withTimeout(30000); - } finally { - await device.enableSynchronization(); + await pollUntilElementAttributes(homeTab, 30000, 0); + } catch { + await scrollToEmbeddedRnExpo(); + await pollUntilElementAttributes(homeTab, 30000, 0); } } + await finishAndroidDetoxLaunch(); } async function openPostMessageTabExpo() { diff --git a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs index d2202a96..f13e8da7 100644 --- a/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs +++ b/apps/brownfield-example-shared-tests/e2e/detoxUtils.cjs @@ -8,9 +8,14 @@ const detoxLaunchArgs = { DetoxE2E: 'YES', }; +const ANDROID_BROWNFIELD_MAIN_COMPONENT = + 'com.callstack.brownfield.android.example/com.callstack.brownfield.android.example.MainActivity'; + function adbShell(command) { + const serial = process.env.ANDROID_SERIAL?.trim(); + const adb = serial ? `adb -s ${serial}` : 'adb'; try { - execSync(`adb shell ${command}`, { stdio: 'ignore' }); + execSync(`${adb} shell ${command}`, { stdio: 'ignore' }); } catch { // Emulator may be offline or the command may be unsupported on older API levels. } @@ -32,6 +37,16 @@ async function dismissAndroidSystemOverlays() { adbShell('settings put global heads_up_notifications_enabled 0'); } +/** Bring MainActivity to the foreground so Espresso can obtain window focus. */ +async function ensureAndroidAppWindowFocus() { + if (device.getPlatform() !== 'android') { + return; + } + await dismissAndroidSystemOverlays(); + adbShell(`am start -W -n ${ANDROID_BROWNFIELD_MAIN_COMPONENT}`); + await new Promise((resolve) => setTimeout(resolve, 300)); +} + function detoxAttrsText(attrs) { if (!attrs || typeof attrs !== 'object') { return ''; @@ -66,7 +81,44 @@ async function configureDetoxForBrownfieldAndroid() { } /** - * Launch without waiting for RN Debug idle, then re-enable Detox synchronization for tests. + * Poll via UiAutomator getAttributes() — avoids Espresso's window-focus gate used by + * toBeVisible(), which headless CI emulators often fail for 10s per attempt. + */ +async function pollUntilElementAttributes(matcher, timeoutMs = 20000, index = 0) { + const deadline = Date.now() + timeoutMs; + const target = element(matcher).atIndex(index); + let lastError; + + while (Date.now() < deadline) { + try { + const attrs = await target.getAttributes(); + if (attrs) { + return attrs; + } + } catch (error) { + lastError = error; + } + + if (device.getPlatform() === 'android') { + await dismissAndroidSystemOverlays(); + } + + await new Promise((resolve) => + setTimeout(resolve, DETOX_TIMING.POLL_INTERVAL_MS) + ); + } + + try { + return await target.getAttributes(); + } catch (error) { + throw lastError || error; + } +} + +/** + * Launch without waiting for RN Debug idle. On Android, leave Detox synchronization + * disabled until the readiness helper finishes — re-enabling sync while RN is still + * mounting causes long "The app seems to be idle" stalls and window-focus failures. * * Sync is disabled only via launchArgs — disableSynchronization() before launchApp() * fails because Detox is not connected to the app yet. @@ -79,11 +131,22 @@ async function launchBrownfieldAppForDetox({ newInstance = true } = {}) { detoxEnableSynchronization: 0, }, }); + if (device.getPlatform() === 'android') { - await dismissAndroidSystemOverlays(); - } else { - await configureDetoxForBrownfieldIos(); + await ensureAndroidAppWindowFocus(); + return; + } + + await configureDetoxForBrownfieldIos(); + await device.enableSynchronization(); +} + +/** Call after Android readiness polling so Espresso matchers can interact with the app. */ +async function finishAndroidDetoxLaunch() { + if (device.getPlatform() !== 'android') { + return; } + await ensureAndroidAppWindowFocus(); await device.enableSynchronization(); } @@ -94,27 +157,14 @@ async function waitForVisible(matcher, timeoutMs = 20000, index = 0) { } /** - * Poll native-only / short-lived UI (toasts, popups, pushed native screens) with sync - * temporarily off. RN Debug can keep sync busy while a native overlay is already visible. + * Poll native-only / short-lived UI (toasts, popups, pushed native screens). + * Uses getAttributes() instead of toBeVisible() so CI emulators without window focus + * can still detect Compose / native overlays during startup. */ async function waitForNativeOverlayVisible(matcher, timeoutMs = 20000, index = 0) { - await device.disableSynchronization(); - try { - const deadline = Date.now() + timeoutMs; - const target = () => element(matcher).atIndex(index); - while (Date.now() < deadline) { - try { - await detoxExpect(target()).toBeVisible(); - return; - } catch { - await new Promise((resolve) => - setTimeout(resolve, DETOX_TIMING.POLL_INTERVAL_MS) - ); - } - } - await detoxExpect(target()).toBeVisible(); - } finally { - await device.enableSynchronization(); + await pollUntilElementAttributes(matcher, timeoutMs, index); + if (device.getPlatform() === 'android') { + await ensureAndroidAppWindowFocus(); } } @@ -123,9 +173,12 @@ module.exports = { detoxAttrsText, assertDetoxTextMatches, dismissAndroidSystemOverlays, + ensureAndroidAppWindowFocus, configureDetoxForBrownfieldAndroid, configureDetoxForBrownfieldIos, launchBrownfieldAppForDetox, + finishAndroidDetoxLaunch, + pollUntilElementAttributes, waitForVisible, waitForNativeOverlayVisible, };