diff --git a/.changeset/three-impalas-brush.md b/.changeset/three-impalas-brush.md new file mode 100644 index 00000000..8e3299fc --- /dev/null +++ b/.changeset/three-impalas-brush.md @@ -0,0 +1,4 @@ +--- +--- + +Add Detox E2E coverage for AppleApp brownfield iOS workflow. diff --git a/.github/actions/appleapp-road-test/action.yml b/.github/actions/appleapp-road-test/action.yml index 7c128092..7a807f5c 100644 --- a/.github/actions/appleapp-road-test/action.yml +++ b/.github/actions/appleapp-road-test/action.yml @@ -1,15 +1,25 @@ name: Apple road test (selected RN app & AppleApp) -description: Package the given RN app as XCFramework, and build the corresponding AppleApp variant +description: Package the given RN app as XCFramework, build the corresponding AppleApp variant, and optionally run Detox E2E inputs: variant: - description: 'AppleApp yarn command variant to run (expo or vanilla)' + description: 'AppleApp yarn command variant to run (vanilla, expo54, or expo55)' required: true rn-project-path: description: 'Path to the RN project to build' required: true + run-e2e: + description: 'Run Detox E2E after packaging (uses Debug build instead of Release road-test build)' + required: false + default: 'false' + + e2e-artifact-name: + description: 'Name prefix for Detox artifacts uploaded on failure' + required: false + default: 'detox-appleapp' + runs: using: composite steps: @@ -34,6 +44,13 @@ runs: run: brew install ccache shell: bash + - name: Install applesimutils + if: inputs.run-e2e == 'true' + run: | + brew tap wix/brew + brew install applesimutils + shell: bash + - name: Enable ccache run: echo "$(brew --prefix)/opt/ccache/libexec" >> $GITHUB_PATH shell: bash @@ -58,8 +75,27 @@ runs: restore-keys: | ${{ runner.os }}-rnapp-${{ inputs.variant }}-ios-pods- + - name: Brownfield codegen (RN ${{ inputs.variant }} app) + if: inputs.variant == 'vanilla' && inputs.run-e2e == 'true' + run: yarn codegen + working-directory: ${{ inputs.rn-project-path }} + shell: bash + + # RN 0.84+ defaults to prebuilt RNCore during pod install. For the Detox packaging + # step we build RN from source so native deps (e.g. react-native-screens) link against + # the same headers as the Debug simulator XCFramework. Road-test builds without E2E + # keep the default prebuilt path for faster CI. + - name: Install pods (RN ${{ inputs.variant }} app, E2E) + if: inputs.variant == 'vanilla' && inputs.run-e2e == 'true' + env: + RCT_USE_PREBUILT_RNCORE: '0' + run: | + cd ${{ inputs.rn-project-path }}/ios + pod install + shell: bash + - name: Install pods (RN ${{ inputs.variant }} app) - if: inputs.variant == 'vanilla' + if: inputs.variant == 'vanilla' && inputs.run-e2e != 'true' run: | cd ${{ inputs.rn-project-path }}/ios pod install @@ -82,10 +118,90 @@ runs: # == AppleApp == - name: Build Brownfield iOS native app (${{ inputs.variant }}) + if: inputs.run-e2e != 'true' run: | yarn run build:example:ios-consumer:${{ inputs.variant }} shell: bash + - name: Resolve AppleApp E2E settings + if: inputs.run-e2e == 'true' + run: | + node <<'NODE' + const { getAppleAppDetoxVariant } = require('./apps/brownfield-example-shared-tests/detox-appleapp-variants.cjs'); + const variant = getAppleAppDetoxVariant(process.env.APPLEAPP_VARIANT); + const append = (key, value) => { + const fs = require('node:fs'); + fs.appendFileSync(process.env.GITHUB_ENV, `${key}=${value}\n`); + }; + append('APPLEAPP_XCFRAMEWORK_APP', variant.xcframeworkApp); + append('APPLEAPP_E2E_CONFIGURATION', variant.configuration); + append('APPLEAPP_E2E_BUILD_SCRIPT', variant.e2eBuildScript); + append('APPLEAPP_E2E_TEST_SCRIPT', variant.e2eTestScript); + NODE + env: + APPLEAPP_VARIANT: ${{ inputs.variant }} + shell: bash + + - name: Copy XCFrameworks into AppleApp + if: inputs.run-e2e == 'true' + run: node prepareXCFrameworks.js --appName "$APPLEAPP_XCFRAMEWORK_APP" + working-directory: apps/AppleApp + shell: bash + + - name: Restore Detox build cache (AppleApp) + if: inputs.run-e2e == 'true' + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + with: + path: apps/AppleApp/build + key: ${{ runner.os }}-e2e-appleapp-${{ inputs.variant }}-build-${{ hashFiles(format('{0}/ios/Podfile.lock', inputs.rn-project-path), 'apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj', 'apps/brownfield-example-shared-tests/e2e/**') }} + restore-keys: | + ${{ runner.os }}-e2e-appleapp-${{ inputs.variant }}-build- + + - name: Install Detox iOS artifacts + if: inputs.run-e2e == 'true' + run: node node_modules/detox/scripts/postinstall.js + working-directory: apps/AppleApp + shell: bash + + - name: Detox build (AppleApp ${{ inputs.variant }}) + if: inputs.run-e2e == 'true' + run: yarn "$APPLEAPP_E2E_BUILD_SCRIPT" + working-directory: apps/AppleApp + shell: bash + + - name: Verify embedded JS bundle in BrownfieldLib (E2E) + if: inputs.run-e2e == 'true' + run: | + set -euo pipefail + PRODUCTS_DIR="apps/AppleApp/build/Build/Products/${APPLEAPP_E2E_CONFIGURATION}-iphonesimulator" + APP_PATH="$(find "$PRODUCTS_DIR" -maxdepth 1 -name '*.app' -print -quit)" + if [[ -z "$APP_PATH" ]]; then + echo "error: no .app under $PRODUCTS_DIR" >&2 + exit 1 + fi + BUNDLE_PATH="$APP_PATH/Frameworks/BrownfieldLib.framework/main.jsbundle" + if [[ ! -f "$BUNDLE_PATH" ]]; then + echo "error: $BUNDLE_PATH missing — E2E needs the packaged ${APPLEAPP_XCFRAMEWORK_APP} bundle, not Metro." >&2 + echo "Re-run: yarn brownfield:package:ios (${APPLEAPP_XCFRAMEWORK_APP}) && node prepareXCFrameworks.js --appName ${APPLEAPP_XCFRAMEWORK_APP}" >&2 + exit 1 + fi + echo "Embedded bundle OK: $BUNDLE_PATH ($(wc -c < "$BUNDLE_PATH") bytes)" + shell: bash + + - name: Detox test (AppleApp ${{ inputs.variant }}) + if: inputs.run-e2e == 'true' + run: yarn "$APPLEAPP_E2E_TEST_SCRIPT" + working-directory: apps/AppleApp + shell: bash + + - 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.variant }}-ios + path: apps/AppleApp/artifacts + if-no-files-found: ignore + # ============== - name: Log ccache stats diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 0ad48b82..b74ae79b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -17,6 +17,10 @@ runs: cache: 'yarn' - name: Install dependencies + env: + # Monorepo has detox in multiple workspaces; parallel postinstalls race on + # $HOME/Library/Detox/ios/framework. E2E jobs run postinstall once later. + DETOX_DISABLE_POSTINSTALL: '1' run: yarn install shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c728c56e..a64f3e5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,16 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: concurrency: - group: pr-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + actions: write + jobs: filter: name: Detect changed paths @@ -35,14 +40,18 @@ jobs: - 'packages/**' rnapp: - 'apps/RNApp/**' + - 'apps/brownfield-example-shared-tests/**' expo54: - 'apps/ExpoApp54/**' + - 'apps/brownfield-example-shared-tests/**' expo55: - 'apps/ExpoApp55/**' + - 'apps/brownfield-example-shared-tests/**' androidapp: - 'apps/AndroidApp/**' appleapp: - 'apps/AppleApp/**' + - 'apps/brownfield-example-shared-tests/**' ci: - '.github/**' @@ -168,8 +177,9 @@ jobs: rn-project-maven-path: com/rnapp/brownfieldlib ios-appleapp-vanilla: - name: iOS road test (AppleApp - Vanilla) + name: iOS road test & E2E (AppleApp - Vanilla) runs-on: macos-26 + timeout-minutes: 90 needs: [filter, build-lint] if: | always() && @@ -185,15 +195,18 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Run RNApp -> AppleApp road test (Vanilla) + - name: Run RNApp -> AppleApp road test & Detox E2E (Vanilla) uses: ./.github/actions/appleapp-road-test with: variant: vanilla rn-project-path: apps/RNApp + run-e2e: 'true' + e2e-artifact-name: detox-appleapp-vanilla ios-appleapp-expo: - name: iOS road test (AppleApp - Expo ${{ matrix.version }}) + name: iOS road test${{ matrix.run-e2e == 'true' && ' & E2E' || '' }} (AppleApp - Expo ${{ matrix.version }}) runs-on: macos-26 + timeout-minutes: ${{ matrix.run-e2e == 'true' && 90 || 60 }} needs: [filter, build-lint] if: | always() && @@ -209,14 +222,19 @@ jobs: matrix: include: - version: '54' + run-e2e: 'false' - version: '55' + run-e2e: 'true' steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Run ExpoApp -> AppleApp road test (Expo ${{ matrix.version }}) + - name: Run ExpoApp -> AppleApp road test${{ matrix.run-e2e == 'true' && ' & Detox E2E' || '' }} (Expo ${{ matrix.version }}) uses: ./.github/actions/appleapp-road-test with: variant: expo${{ matrix.version }} rn-project-path: apps/ExpoApp${{ matrix.version }} + run-e2e: ${{ matrix.run-e2e }} + e2e-artifact-name: detox-appleapp-expo${{ matrix.version }} + diff --git a/apps/AppleApp/.detoxrc.cjs b/apps/AppleApp/.detoxrc.cjs new file mode 100644 index 00000000..3e8caa1f --- /dev/null +++ b/apps/AppleApp/.detoxrc.cjs @@ -0,0 +1,10 @@ +const { + createAppleAppIosSimDebugDetoxConfig, +} = require('../brownfield-example-shared-tests/detox-rc-appleapp-ios-sim-debug.cjs'); + +/** @type {import('detox').DetoxConfig} */ +module.exports = createAppleAppIosSimDebugDetoxConfig({ + scheme: 'Brownfield Apple App Vanilla', + configuration: 'Debug Vanilla', + appBinaryName: 'Brownfield Apple App (RNApp)', +}); diff --git a/apps/AppleApp/.detoxrc.expo55.cjs b/apps/AppleApp/.detoxrc.expo55.cjs new file mode 100644 index 00000000..6193df8d --- /dev/null +++ b/apps/AppleApp/.detoxrc.expo55.cjs @@ -0,0 +1,17 @@ +const { + createAppleAppIosSimDebugDetoxConfig, +} = require('../brownfield-example-shared-tests/detox-rc-appleapp-ios-sim-debug.cjs'); +const { + getAppleAppDetoxVariant, +} = require('../brownfield-example-shared-tests/detox-appleapp-variants.cjs'); + +const variant = getAppleAppDetoxVariant('expo55'); + +/** @type {import('detox').DetoxConfig} */ +module.exports = createAppleAppIosSimDebugDetoxConfig({ + scheme: variant.scheme, + configuration: variant.configuration, + appBinaryName: variant.appBinaryName, + detoxConfiguration: variant.detoxConfiguration, + jestConfigPath: 'e2e/jest.config.expo55.cjs', +}); diff --git a/apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj b/apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj index bfe6263d..a1c332cd 100644 --- a/apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj +++ b/apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj @@ -112,6 +112,41 @@ 79B8BE9B2FB7273600B94C6F /* Brownfield Apple App (ExpoApp55).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Brownfield Apple App (ExpoApp55).app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 79B8BE822FB7270F00B94C6F /* Exceptions for "Brownfield Apple App" folder in "Brownfield Apple App (ExpoApp54)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + BrownfieldAppleApp.swift, + E2eTestIds.swift, + components/ContentView.swift, + components/GreetingCard.swift, + components/MaterialCard.swift, + components/MessagesView.swift, + components/ReferralsScreen.swift, + components/SettingsScreen.swift, + components/Toast.swift, + ); + target = 79B8BE682FB7270E00B94C6F /* Brownfield Apple App (ExpoApp54) */; + }; + 79B8BE9C2FB7273600B94C6F /* Exceptions for "Brownfield Apple App" folder in "Brownfield Apple App (ExpoApp55)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + BrownfieldAppleApp.swift, + E2eTestIds.swift, + components/ContentView.swift, + components/GreetingCard.swift, + components/MaterialCard.swift, + components/MessagesView.swift, + components/ReferralsScreen.swift, + components/SettingsScreen.swift, + components/Toast.swift, + ); + target = 79B8BE832FB7273600B94C6F /* Brownfield Apple App (ExpoApp55) */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 793C76A92EEBF938008A2A34 /* Brownfield Apple App */ = { isa = PBXFileSystemSynchronizedRootGroup; diff --git a/apps/AppleApp/Brownfield Apple App/E2eTestIds.swift b/apps/AppleApp/Brownfield Apple App/E2eTestIds.swift new file mode 100644 index 00000000..c4fe83d8 --- /dev/null +++ b/apps/AppleApp/Brownfield Apple App/E2eTestIds.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Keep in sync with `@callstack/brownfield-example-shared-tests` `e2eTestIds`. +enum E2eTestIds { + static let appleAppGreeting = "brownfield-e2e-appleapp-greeting" + static let appleAppPostMessageSend = "brownfield-e2e-appleapp-post-message-send" + static let appleAppPostMessageToast = "brownfield-e2e-appleapp-post-message-toast" + static let appleAppNativeSettings = "brownfield-e2e-appleapp-native-settings" + static let appleAppNativeReferrals = "brownfield-e2e-appleapp-native-referrals" +} diff --git a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift index 62c9efc0..0b2ceb83 100644 --- a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift +++ b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift @@ -24,28 +24,65 @@ private let hostAppName = "iOS Vanilla" // for both the plain React Native and Expo example apps. private let reactNativeModuleName = "RNApp" +private func brownfieldPostMessageText(from raw: String) -> String { + if let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let text = json["text"] as? String + { + return text + } + return raw +} + struct ContentView: View { + @State private var messageObserver: NSObjectProtocol? + @State private var showPostMessageToast = false + @State private var postMessageToastText = "" + var body: some View { NavigationView { + ZStack { + ScrollView { + VStack(spacing: 16) { + GreetingCard(name: hostAppName) + + MessagesView() - VStack(spacing: 16) { - GreetingCard(name: hostAppName) - - MessagesView() - - ReactNativeView( - moduleName: reactNativeModuleName, - initialProperties: [ - "nativeOsVersionLabel": - "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" - ] - ) - .navigationBarHidden(true) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .background(Color(UIColor.systemBackground)) + ReactNativeView( + moduleName: reactNativeModuleName, + initialProperties: [ + "nativeOsVersionLabel": + "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" + ] + ) + .navigationBarHidden(true) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .background(Color(UIColor.systemBackground)) + .frame(minHeight: 520) + } + .frame(maxWidth: .infinity) + .padding(16) + } + + if showPostMessageToast { + Toast( + message: postMessageToastText, + isShowing: $showPostMessageToast + ) + } + } + } + .onAppear { + messageObserver = ReactNativeBrownfield.shared.onMessage { raw in + postMessageToastText = brownfieldPostMessageText(from: raw) + showPostMessageToast = true + } + } + .onDisappear { + if let observer = messageObserver { + NotificationCenter.default.removeObserver(observer) + messageObserver = nil } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(16) } } } diff --git a/apps/AppleApp/Brownfield Apple App/components/GreetingCard.swift b/apps/AppleApp/Brownfield Apple App/components/GreetingCard.swift index 125e4858..d82f3768 100644 --- a/apps/AppleApp/Brownfield Apple App/components/GreetingCard.swift +++ b/apps/AppleApp/Brownfield Apple App/components/GreetingCard.swift @@ -6,30 +6,32 @@ struct GreetingCard: View { let name: String @UseStore(\BrownfieldStore.counter) var counter + private var counterText: String { + "You clicked the button \(Int(counter)) time\(counter == 1 ? "" : "s")" + } + var body: some View { MaterialCard { Text("Hello native \(name) 👋") .font(.title3) .multilineTextAlignment(.center) + .accessibilityIdentifier(E2eTestIds.appleAppGreeting) + + Text(counterText) + .multilineTextAlignment(.center) + .font(.body) - Text( - "You clicked the button \(Int(counter)) time\(counter == 1 ? "" : "s")" - ) - .multilineTextAlignment(.center) - .font(.body) + HStack { + Button("Increment counter") { + $counter.set { $0 + 1 } + } + .buttonStyle(.borderedProminent) - - HStack { - Button("Increment counter") { - $counter.set { $0 + 1 } - } - .buttonStyle(.borderedProminent) - Button("Stop RN") { ReactNativeBrownfield.shared.stopReactNative() } .buttonStyle(.borderedProminent) - } + } } } } diff --git a/apps/AppleApp/Brownfield Apple App/components/MessagesView.swift b/apps/AppleApp/Brownfield Apple App/components/MessagesView.swift index dc94639c..4ba000a0 100644 --- a/apps/AppleApp/Brownfield Apple App/components/MessagesView.swift +++ b/apps/AppleApp/Brownfield Apple App/components/MessagesView.swift @@ -4,9 +4,6 @@ import SwiftUI struct MessagesView: View { @State private var draft: String = "" @State private var nextId: Int = 0 - @State private var observer: NSObjectProtocol? - @State private var showToast = false - @State private var toastText = "" var body: some View { MaterialCard { @@ -30,33 +27,9 @@ struct MessagesView: View { draft = "" } .buttonStyle(.borderedProminent) + .accessibilityIdentifier(E2eTestIds.appleAppPostMessageSend) } } .padding() - .onAppear { - observer = ReactNativeBrownfield.shared.onMessage { raw in - var text = raw - if let data = raw.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) - as? [String: Any], - let t = json["text"] as? String - { - text = t - } - toastText = text - showToast = true - } - } - .onDisappear { - if let obs = observer { - NotificationCenter.default.removeObserver(obs) - observer = nil - } - } - .overlay( - showToast - ? Toast(message: toastText, isShowing: $showToast) - .padding(.bottom, 50) : nil - ) } } diff --git a/apps/AppleApp/Brownfield Apple App/components/ReferralsScreen.swift b/apps/AppleApp/Brownfield Apple App/components/ReferralsScreen.swift index e1f37d1b..ca55c76b 100644 --- a/apps/AppleApp/Brownfield Apple App/components/ReferralsScreen.swift +++ b/apps/AppleApp/Brownfield Apple App/components/ReferralsScreen.swift @@ -24,5 +24,7 @@ struct ReferralsScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .navigationTitle("Referrals") + .accessibilityIdentifier(E2eTestIds.appleAppNativeReferrals) + .accessibilityElement() } } \ No newline at end of file diff --git a/apps/AppleApp/Brownfield Apple App/components/SettingsScreen.swift b/apps/AppleApp/Brownfield Apple App/components/SettingsScreen.swift index f8eab904..56261882 100644 --- a/apps/AppleApp/Brownfield Apple App/components/SettingsScreen.swift +++ b/apps/AppleApp/Brownfield Apple App/components/SettingsScreen.swift @@ -18,5 +18,7 @@ struct SettingsScreen: View { } } .navigationTitle("Settings") + .accessibilityIdentifier(E2eTestIds.appleAppNativeSettings) + .accessibilityElement() } } diff --git a/apps/AppleApp/Brownfield Apple App/components/Toast.swift b/apps/AppleApp/Brownfield Apple App/components/Toast.swift index 974b1a9f..c63f2a75 100644 --- a/apps/AppleApp/Brownfield Apple App/components/Toast.swift +++ b/apps/AppleApp/Brownfield Apple App/components/Toast.swift @@ -16,16 +16,15 @@ struct Toast: View { .background(Color.black.opacity(0.8)) .cornerRadius(25) .multilineTextAlignment(.center) + .accessibilityIdentifier(E2eTestIds.appleAppPostMessageToast) .scaleEffect(scale) .opacity(opacity) .onAppear { - // Scale-in bounce - withAnimation(.interpolatingSpring(stiffness: 300, damping: 15)) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { scale = 1.0 opacity = 1.0 } - // Hide after 2 seconds with scale out DispatchQueue.main.asyncAfter(deadline: .now() + 2) { withAnimation(.easeInOut(duration: 0.3)) { scale = 0.5 diff --git a/apps/AppleApp/e2e/jest.config.cjs b/apps/AppleApp/e2e/jest.config.cjs new file mode 100644 index 00000000..db7e9b2a --- /dev/null +++ b/apps/AppleApp/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/appleAppBrownfield.e2e.js' + ), + ], +}); diff --git a/apps/AppleApp/e2e/jest.config.expo55.cjs b/apps/AppleApp/e2e/jest.config.expo55.cjs new file mode 100644 index 00000000..78cbd4e9 --- /dev/null +++ b/apps/AppleApp/e2e/jest.config.expo55.cjs @@ -0,0 +1,19 @@ +const path = require('node:path'); +const { + createDetoxJestConfig, +} = require('../../brownfield-example-shared-tests/e2e/createDetoxJestConfig.cjs'); +const { + getAppleAppDetoxVariant, +} = require('../../brownfield-example-shared-tests/detox-appleapp-variants.cjs'); + +const variant = getAppleAppDetoxVariant('expo55'); + +module.exports = createDetoxJestConfig({ + e2eDir: __dirname, + testMatch: [ + path.join( + __dirname, + `../../brownfield-example-shared-tests/e2e/${variant.e2eTestFile}` + ), + ], +}); diff --git a/apps/AppleApp/package.json b/apps/AppleApp/package.json index 832bdbee..6a81503e 100644 --- a/apps/AppleApp/package.json +++ b/apps/AppleApp/package.json @@ -8,9 +8,18 @@ "build:example:ios-consumer:expo54": "node prepareXCFrameworks.js --appName ExpoApp54 && yarn internal::build::common -scheme \"Brownfield Apple App Expo 54\" -configuration Release", "build:example:ios-consumer:expo55": "node prepareXCFrameworks.js --appName ExpoApp55 && yarn internal::build::common -scheme \"Brownfield Apple App Expo 55\" -configuration Release", "build:example:ios-consumer:vanilla": "node prepareXCFrameworks.js --appName RNApp && yarn internal::build::common -scheme \"Brownfield Apple App Vanilla\" -configuration \"Release Vanilla\"", - "internal::build::common": "xcodebuild -project \"Brownfield Apple App.xcodeproj\" -sdk iphonesimulator build CODE_SIGNING_ALLOWED=NO -derivedDataPath ./build" + "internal::build::common": "xcodebuild -project \"Brownfield Apple App.xcodeproj\" -sdk iphonesimulator build CODE_SIGNING_ALLOWED=NO -derivedDataPath ./build", + "e2e:build:ios": "detox build --configuration ios.sim.debug", + "e2e:test:ios": "detox test --configuration ios.sim.debug", + "e2e:build:ios:expo55": "detox build --config-path .detoxrc.expo55.cjs --configuration ios.sim.debug.expo55", + "e2e:test:ios:expo55": "detox test --config-path .detoxrc.expo55.cjs --configuration ios.sim.debug.expo55", + "ci:local:e2e:ios": "bash ../../scripts/ci-local-appleapp-ios-e2e.sh", + "ci:local:e2e:ios:expo55": "bash ../../scripts/ci-local-appleapp-ios-e2e.sh --variant expo55" }, "devDependencies": { - "@rock-js/tools": "^0.13.3" + "@callstack/brownfield-example-shared-tests": "workspace:^", + "@rock-js/tools": "^0.13.3", + "detox": "^20.27.0", + "jest": "^29.7.0" } } diff --git a/apps/AppleApp/prepareXCFrameworks.js b/apps/AppleApp/prepareXCFrameworks.js index a3d94b46..c7a0efd1 100644 --- a/apps/AppleApp/prepareXCFrameworks.js +++ b/apps/AppleApp/prepareXCFrameworks.js @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; @@ -39,6 +40,11 @@ const sourcePackagePath = path.join( const targetPackagePath = path.join(__dirname, 'package'); +const prebuiltRnCoreArtifacts = [ + 'React.xcframework', + 'ReactNativeDependencies.xcframework', +]; + /** * The Xcode project is configured to link the following frameworks: * - BrownfieldLib (constant) @@ -112,6 +118,21 @@ if (!hermesArtifactFound) { throw new Error('Hermes artifact not found'); } +for (const artifact of prebuiltRnCoreArtifacts) { + const xcframeworkPath = path.join(targetPackagePath, artifact); + if (!fs.existsSync(xcframeworkPath)) { + continue; + } + + // RN prebuilts ship with a sealed signature that CocoaPods/brownfield packaging + // can invalidate (module.modulemap drift). Re-sign locally so Xcode can embed them. + logger.info(`Re-signing ${artifact} for AppleApp consumer build`); + execFileSync('codesign', ['--force', '--sign', '-', '--deep', xcframeworkPath], { + stdio: 'inherit', + }); + logger.success(`${artifact} re-signed`); +} + for (const file of fs.readdirSync(targetPackagePath)) { if (!validNames.includes(file)) { throw new Error(`Invalid file name: ${file}`); diff --git a/apps/ExpoApp54/.detoxrc.cjs b/apps/ExpoApp54/.detoxrc.cjs new file mode 100644 index 00000000..ff9deffb --- /dev/null +++ b/apps/ExpoApp54/.detoxrc.cjs @@ -0,0 +1,13 @@ +const { + createIosSimDebugDetoxConfig, +} = require('../brownfield-example-shared-tests/detox-rc-ios-sim-debug.cjs'); + +/** + * Requires a native tree from `expo prebuild` / `expo run:ios` (ios/ + Pods). + * @type {import('detox').DetoxConfig} + */ +module.exports = createIosSimDebugDetoxConfig({ + workspace: 'ExpoApp54', + scheme: 'ExpoApp54', + appBinaryName: 'ExpoApp54', +}); diff --git a/apps/ExpoApp54/.gitignore b/apps/ExpoApp54/.gitignore index f8c6c2e8..2eed6de9 100644 --- a/apps/ExpoApp54/.gitignore +++ b/apps/ExpoApp54/.gitignore @@ -38,6 +38,9 @@ yarn-error.* app-example +# Detox +artifacts/ + # generated native folders /ios /android diff --git a/apps/ExpoApp54/app/(tabs)/postMessage.tsx b/apps/ExpoApp54/app/(tabs)/postMessage.tsx index e5efac2d..86b75e1b 100644 --- a/apps/ExpoApp54/app/(tabs)/postMessage.tsx +++ b/apps/ExpoApp54/app/(tabs)/postMessage.tsx @@ -1,6 +1,7 @@ import { StyleSheet, FlatList, TouchableOpacity } from 'react-native'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { brownfieldE2eTestIds } from '@callstack/brownfield-example-shared-tests/e2eTestIds'; import ReactNativeBrownfield from '@callstack/react-native-brownfield'; import type { MessageEvent } from '@callstack/react-native-brownfield'; @@ -51,6 +52,7 @@ export default function PostMessageTab() { return ( {isFromNative ? 'From Native' : 'From RN'} - {item.text} + + {item.text} + ); } diff --git a/apps/ExpoApp54/e2e/jest.config.cjs b/apps/ExpoApp54/e2e/jest.config.cjs new file mode 100644 index 00000000..9d780af8 --- /dev/null +++ b/apps/ExpoApp54/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/expoPostMessageBrownfield.e2e.js' + ), + ], +}); diff --git a/apps/ExpoApp54/entry.tsx b/apps/ExpoApp54/entry.tsx index 86abd0e1..3c70f46c 100644 --- a/apps/ExpoApp54/entry.tsx +++ b/apps/ExpoApp54/entry.tsx @@ -1,12 +1,11 @@ import { ExpoRoot } from 'expo-router'; import { AppRegistry } from 'react-native'; -import RNApp from './RNApp'; function App() { const ctx = require.context('./app'); return ; } -AppRegistry.registerComponent('RNApp', () => RNApp); -// Keep compatibility with Expo's default app key. +// AppleApp brownfield embeds the module named `RNApp`; mount the full Expo Router tree. +AppRegistry.registerComponent('RNApp', () => App); AppRegistry.registerComponent('main', () => App); diff --git a/apps/ExpoApp54/package.json b/apps/ExpoApp54/package.json index e40f5430..2cb01b4f 100644 --- a/apps/ExpoApp54/package.json +++ b/apps/ExpoApp54/package.json @@ -10,6 +10,9 @@ "web": "expo start --web", "lint": "expo lint --no-cache", "test": "jest --config jest.config.js", + "e2e:build:ios": "detox build --configuration ios.sim.debug", + "e2e:test:ios": "detox test --configuration ios.sim.debug", + "ci:local:e2e:ios": "bash ../../scripts/ci-local-expo54-ios-e2e.sh", "prebuild": "expo prebuild", "brownfield:prepare:android:ci": "cd .. && node --experimental-strip-types --no-warnings ./scripts/prepare-android-build-gradle-for-ci.ts ExpoApp54", "brownfield:package:android": "brownfield package:android --module-name brownfieldlib --variant release --verbose", @@ -18,6 +21,7 @@ "eas:stg": "EXPO_TOKEN=$EAS_TOKEN eas update --channel production --message 'testing 1st stg channel update' --platform android" }, "dependencies": { + "@callstack/brownfield-example-shared-tests": "workspace:^", "@callstack/brownfield-navigation": "workspace:^", "@callstack/brownie": "workspace:^", "@callstack/react-native-brownfield": "workspace:^", @@ -44,10 +48,10 @@ "react-native-worklets": "0.5.1" }, "devDependencies": { - "@callstack/brownfield-example-shared-tests": "workspace:^", "@testing-library/react-native": "^13.3.3", "@types/jest": "^30.0.0", "@types/react": "~19.1.10", + "detox": "^20.27.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "jest": "^29.7.0", diff --git a/apps/ExpoApp55/.detoxrc.cjs b/apps/ExpoApp55/.detoxrc.cjs new file mode 100644 index 00000000..079382a9 --- /dev/null +++ b/apps/ExpoApp55/.detoxrc.cjs @@ -0,0 +1,13 @@ +const { + createIosSimDebugDetoxConfig, +} = require('../brownfield-example-shared-tests/detox-rc-ios-sim-debug.cjs'); + +/** + * Requires a native tree from `expo prebuild` / `expo run:ios` (ios/ + Pods). + * @type {import('detox').DetoxConfig} + */ +module.exports = createIosSimDebugDetoxConfig({ + workspace: 'ExpoApp55', + scheme: 'ExpoApp55', + appBinaryName: 'ExpoApp55', +}); diff --git a/apps/ExpoApp55/.gitignore b/apps/ExpoApp55/.gitignore index f8c6c2e8..2eed6de9 100644 --- a/apps/ExpoApp55/.gitignore +++ b/apps/ExpoApp55/.gitignore @@ -38,6 +38,9 @@ yarn-error.* app-example +# Detox +artifacts/ + # generated native folders /ios /android diff --git a/apps/ExpoApp55/e2e/jest.config.cjs b/apps/ExpoApp55/e2e/jest.config.cjs new file mode 100644 index 00000000..9d780af8 --- /dev/null +++ b/apps/ExpoApp55/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/expoPostMessageBrownfield.e2e.js' + ), + ], +}); diff --git a/apps/ExpoApp55/entry.tsx b/apps/ExpoApp55/entry.tsx index 852ce9ba..87dd9e5e 100644 --- a/apps/ExpoApp55/entry.tsx +++ b/apps/ExpoApp55/entry.tsx @@ -1,12 +1,11 @@ import { ExpoRoot } from 'expo-router'; import { AppRegistry } from 'react-native'; -import RNApp from './RNApp'; function App() { const ctx = require.context('./src/app'); return ; } -AppRegistry.registerComponent('RNApp', () => RNApp); -// Keep compatibility with Expo's default app key. +// AppleApp brownfield embeds the module named `RNApp`; mount the full Expo Router tree. +AppRegistry.registerComponent('RNApp', () => App); AppRegistry.registerComponent('main', () => App); diff --git a/apps/ExpoApp55/package.json b/apps/ExpoApp55/package.json index af003688..63375638 100644 --- a/apps/ExpoApp55/package.json +++ b/apps/ExpoApp55/package.json @@ -9,6 +9,9 @@ "web": "expo start --web", "lint": "expo lint --no-cache", "test": "jest --config jest.config.js", + "e2e:build:ios": "detox build --configuration ios.sim.debug", + "e2e:test:ios": "detox test --configuration ios.sim.debug", + "ci:local:e2e:ios": "bash ../../scripts/ci-local-expo55-ios-e2e.sh", "prebuild": "expo prebuild", "brownfield:prepare:android:ci": "cd .. && node --experimental-strip-types --no-warnings ./scripts/prepare-android-build-gradle-for-ci.ts ExpoApp55", "brownfield:package:android": "brownfield package:android --module-name brownfieldlib --variant release --verbose", @@ -17,6 +20,7 @@ "eas:stg": "EXPO_TOKEN=$EAS_TOKEN eas update --channel production --message 'testing 1st stg channel update' --platform ios --environment staging" }, "dependencies": { + "@callstack/brownfield-example-shared-tests": "workspace:^", "@callstack/brownfield-navigation": "workspace:^", "@callstack/brownie": "workspace:^", "@callstack/react-native-brownfield": "workspace:^", @@ -49,10 +53,10 @@ "react-native-worklets": "0.7.4" }, "devDependencies": { - "@callstack/brownfield-example-shared-tests": "workspace:^", "@testing-library/react-native": "^13.3.3", "@types/jest": "^30.0.0", "@types/react": "~19.2.10", + "detox": "^20.27.0", "eslint": "^9.25.0", "eslint-config-expo": "~55.0.0", "globals": "^17.6.0", diff --git a/apps/ExpoApp55/src/app/postMessage.tsx b/apps/ExpoApp55/src/app/postMessage.tsx index e5efac2d..86b75e1b 100644 --- a/apps/ExpoApp55/src/app/postMessage.tsx +++ b/apps/ExpoApp55/src/app/postMessage.tsx @@ -1,6 +1,7 @@ import { StyleSheet, FlatList, TouchableOpacity } from 'react-native'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { brownfieldE2eTestIds } from '@callstack/brownfield-example-shared-tests/e2eTestIds'; import ReactNativeBrownfield from '@callstack/react-native-brownfield'; import type { MessageEvent } from '@callstack/react-native-brownfield'; @@ -51,6 +52,7 @@ export default function PostMessageTab() { return ( {isFromNative ? 'From Native' : 'From RN'} - {item.text} + + {item.text} + ); } diff --git a/apps/RNApp/.detoxrc.cjs b/apps/RNApp/.detoxrc.cjs new file mode 100644 index 00000000..ee023bfb --- /dev/null +++ b/apps/RNApp/.detoxrc.cjs @@ -0,0 +1,10 @@ +const { + createIosSimDebugDetoxConfig, +} = require('../brownfield-example-shared-tests/detox-rc-ios-sim-debug.cjs'); + +/** @type {import('detox').DetoxConfig} */ +module.exports = createIosSimDebugDetoxConfig({ + workspace: 'RNApp', + scheme: 'RNApp', + appBinaryName: 'RNApp', +}); diff --git a/apps/RNApp/.gitignore b/apps/RNApp/.gitignore index 60141bd4..ce0a2d5f 100644 --- a/apps/RNApp/.gitignore +++ b/apps/RNApp/.gitignore @@ -24,7 +24,6 @@ DerivedData # Android/IntelliJ # -build/ .idea .gradle local.properties @@ -65,6 +64,7 @@ yarn-error.log # testing /coverage +/artifacts # Yarn .yarn/* diff --git a/apps/RNApp/e2e/jest.config.cjs b/apps/RNApp/e2e/jest.config.cjs new file mode 100644 index 00000000..cc481de2 --- /dev/null +++ b/apps/RNApp/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/rnAppBrownfield.e2e.js' + ), + ], +}); diff --git a/apps/RNApp/package.json b/apps/RNApp/package.json index ee760f79..aa4a40c4 100644 --- a/apps/RNApp/package.json +++ b/apps/RNApp/package.json @@ -3,8 +3,8 @@ "version": "0.0.1", "private": true, "scripts": { - "android": "react-native run-android", - "ios": "react-native run-ios", + "android": "yarn brownfield:package:android && brownfield codegen && react-native run-android", + "ios": "yarn brownfield:package:ios && brownfield codegen && react-native run-ios", "build:example:android-rn": "react-native build-android", "build:example:ios-rn": "react-native build-ios", "brownfield:package:android": "brownfield package:android --module-name :BrownfieldLib --variant release --verbose", @@ -13,10 +13,14 @@ "lint": "eslint .", "start": "react-native start", "test": "jest --config jest.config.js", + "e2e:build:ios": "detox build --configuration ios.sim.debug", + "e2e:test:ios": "detox test --configuration ios.sim.debug", + "ci:local:e2e:ios": "bash ../../scripts/ci-local-rnapp-ios-e2e.sh", "codegen": "brownfield codegen", "codegen:navigation": "brownfield navigation:codegen brownfield.navigation.ts" }, "dependencies": { + "@callstack/brownfield-example-shared-tests": "workspace:^", "@callstack/brownfield-navigation": "workspace:^", "@callstack/brownie": "workspace:^", "@callstack/react-native-brownfield": "workspace:^", @@ -31,7 +35,6 @@ "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", - "@callstack/brownfield-example-shared-tests": "workspace:^", "@react-native-community/cli": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", @@ -44,6 +47,7 @@ "@types/jest": "^30.0.0", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", + "detox": "^20.27.0", "eslint": "^9.39.3", "jest": "^29.7.0", "prettier": "^3.8.1", diff --git a/apps/RNApp/src/HomeScreen.tsx b/apps/RNApp/src/HomeScreen.tsx index 159b5b7a..c0d20c29 100644 --- a/apps/RNApp/src/HomeScreen.tsx +++ b/apps/RNApp/src/HomeScreen.tsx @@ -10,6 +10,7 @@ import { } from 'react-native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import ReactNativeBrownfield from '@callstack/react-native-brownfield'; +import { brownfieldE2eTestIds } from '@callstack/brownfield-example-shared-tests/e2eTestIds'; import BrownfieldNavigation from '@callstack/brownfield-navigation'; import { getRandomTheme } from './utils'; @@ -61,7 +62,14 @@ function MessageBubble({ item, color }: { item: Message; color: string }) { {isFromNative ? 'From Native' : 'From RN'} - {item.text} + + {item.text} + ); } @@ -119,8 +127,15 @@ export function HomeScreen({ }, []); return ( - - + + React Native Screen @@ -135,8 +150,36 @@ export function HomeScreen({ + +