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