From b61c0841c979bb56e25ef049190eff0c0ea6e57a Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Wed, 17 Jun 2026 12:12:47 +0300 Subject: [PATCH] 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