Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
113 changes: 112 additions & 1 deletion .github/actions/androidapp-road-test/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -19,6 +19,16 @@ inputs:
required: false
default: '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:
Expand Down Expand Up @@ -136,7 +146,24 @@ runs:
run: echo "::group::AndroidApp — assemble consumer app"
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-${{ steps.aar-variants.outputs.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

- name: Build native Android Brownfield app
if: inputs.run-e2e != 'true'
run: yarn run build:example:android-consumer:${{ inputs.flavor }}
shell: bash

Expand All @@ -163,3 +190,87 @@ runs:
- name: '::endgroup:: Save ccache & summary'
run: echo "::endgroup::"
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: Free workspace disk space before Detox emulator
if: inputs.run-e2e == 'true'
run: |
# Heavy Expo/RN Gradle outputs can leave too little room for the AVD to start.
ANDROID_DIR="${{ inputs.rn-project-path }}/android"
rm -rf "$ANDROID_DIR/build" "$ANDROID_DIR/.cxx" "$ANDROID_DIR/.gradle"
rm -rf apps/AndroidApp/.gradle
df -h .
shell: bash

- name: Install Android emulator runtime libraries
if: inputs.run-e2e == 'true'
run: |
# free-disk-space (prepare-android) can remove libs the QEMU emulator needs,
# even with -no-audio (libpulse) and headless CI (libgl1, libxkbfile1).
sudo apt-get update
sudo apt-get install -y libpulse0 libgl1 libxkbfile1
shell: bash

- name: Enable KVM for Android emulator
if: inputs.run-e2e == 'true'
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
shell: bash

- name: Detox test (AndroidApp ${{ inputs.flavor }})
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
# Do not pass partial emulator-options — they replace the action defaults
# (-no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim).
# Omitting them keeps headless/software-GPU settings required on ubuntu-latest.
script: |
bash apps/brownfield-example-shared-tests/scripts/prepare-android-emulator-for-detox.sh
cd apps/AndroidApp
yarn "$ANDROIDAPP_E2E_TEST_SCRIPT"
env:
ANDROIDAPP_E2E_TEST_SCRIPT: ${{ env.ANDROIDAPP_E2E_TEST_SCRIPT }}

- 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
17 changes: 13 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down Expand Up @@ -124,8 +125,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() &&
Expand All @@ -141,22 +143,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() &&
Expand All @@ -172,12 +179,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)
Expand Down
8 changes: 8 additions & 0 deletions apps/AndroidApp/.detoxrc.cjs
Original file line number Diff line number Diff line change
@@ -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',
});
10 changes: 10 additions & 0 deletions apps/AndroidApp/.detoxrc.expo55.cjs
Original file line number Diff line number Diff line change
@@ -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',
});
8 changes: 8 additions & 0 deletions apps/AndroidApp/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ android {
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testBuildType = System.getProperty("testBuildType", "debug")
missingDimensionStrategy("env", "dev")
}

Expand All @@ -47,6 +48,7 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isDebuggable = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
Expand Down Expand Up @@ -84,8 +86,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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

This file was deleted.

1 change: 1 addition & 0 deletions apps/AndroidApp/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".BrownfieldApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.callstack.brownfield.android.example

import android.app.Application
import android.content.res.Configuration
import com.callstack.brownie.registerStoreIfNeeded
import com.callstack.reactnativebrownfield.ReactNativeBrownfield
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost

/**
* Detox expects a [ReactApplication] so it can await the embedded RN context during E2E runs.
* RN is initialized at process start (embedded AAR bundle — no Metro).
*/
class BrownfieldApplication : Application(), ReactApplication {
@Suppress("DEPRECATION")
override val reactNativeHost: ReactNativeHost
get() = ReactNativeHostManager.reactNativeHost

override val reactHost: ReactHost
get() = ReactNativeBrownfield.shared.reactHost

override fun onCreate() {
super.onCreate()

ReactNativeHostManager.initialize(this)

registerStoreIfNeeded(
storeName = BrownfieldStore.STORE_NAME
) {
BrownfieldStore(
counter = 0.0,
user = User(name = "Username")
)
}
}

override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ReactNativeHostManager.onConfigurationChanged(this, newConfig)
}
}
Loading