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..6c22a4e8 100644 --- a/.github/actions/appleapp-road-test/action.yml +++ b/.github/actions/appleapp-road-test/action.yml @@ -1,5 +1,5 @@ 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 (vanilla only) inputs: variant: @@ -10,6 +10,16 @@ inputs: description: 'Path to the RN project to build' required: true + run-e2e: + description: 'Run Detox E2E after packaging (vanilla variant only; 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' && inputs.variant == 'vanilla' + 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,23 @@ 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 + + - 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 +114,71 @@ runs: # == AppleApp == - name: Build Brownfield iOS native app (${{ inputs.variant }}) + if: inputs.run-e2e != 'true' || inputs.variant != 'vanilla' run: | yarn run build:example:ios-consumer:${{ inputs.variant }} shell: bash + - name: Copy XCFrameworks into AppleApp + if: inputs.run-e2e == 'true' && inputs.variant == 'vanilla' + run: node prepareXCFrameworks.js --appName RNApp + working-directory: apps/AppleApp + shell: bash + + - name: Restore Detox build cache (AppleApp) + if: inputs.run-e2e == 'true' && inputs.variant == 'vanilla' + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + with: + path: apps/AppleApp/build + key: ${{ runner.os }}-e2e-appleapp-build-${{ hashFiles('apps/RNApp/ios/Podfile.lock', 'apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj', 'apps/brownfield-example-shared-tests/e2e/**') }} + restore-keys: | + ${{ runner.os }}-e2e-appleapp-build- + + - name: Install Detox iOS artifacts + if: inputs.run-e2e == 'true' && inputs.variant == 'vanilla' + run: node node_modules/detox/scripts/postinstall.js + working-directory: apps/AppleApp + shell: bash + + - name: Detox build (AppleApp Vanilla) + if: inputs.run-e2e == 'true' && inputs.variant == 'vanilla' + run: yarn e2e:build:ios + working-directory: apps/AppleApp + shell: bash + + - name: Verify embedded JS bundle in BrownfieldLib (E2E) + if: inputs.run-e2e == 'true' && inputs.variant == 'vanilla' + run: | + set -euo pipefail + PRODUCTS_DIR="apps/AppleApp/build/Build/Products/Debug Vanilla-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 RNApp bundle, not Metro." >&2 + echo "Re-run: yarn brownfield:package:ios (RNApp) && node prepareXCFrameworks.js --appName RNApp" >&2 + exit 1 + fi + echo "Embedded bundle OK: $BUNDLE_PATH ($(wc -c < "$BUNDLE_PATH") bytes)" + shell: bash + + - name: Detox test (AppleApp Vanilla) + if: inputs.run-e2e == 'true' && inputs.variant == 'vanilla' + run: yarn e2e:test:ios + working-directory: apps/AppleApp + shell: bash + + - name: Upload Detox artifacts on failure + if: failure() && inputs.run-e2e == 'true' && inputs.variant == 'vanilla' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ inputs.e2e-artifact-name }}-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..6c9cb09d 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,11 +195,13 @@ 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 ios-appleapp-expo: name: iOS road test (AppleApp - Expo ${{ matrix.version }}) @@ -220,3 +232,4 @@ jobs: with: variant: expo${{ matrix.version }} rn-project-path: apps/ExpoApp${{ 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/Brownfield Apple App.xcodeproj/project.pbxproj b/apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj index 7b9e9c19..ebcbec3a 100644 --- a/apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj +++ b/apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj @@ -118,6 +118,8 @@ membershipExceptions = ( Assets.xcassets, BrownfieldAppleApp.swift, + E2eTestIds.swift, + E2eAccessibility.swift, components/ContentView.swift, components/GreetingCard.swift, components/MaterialCard.swift, @@ -133,6 +135,8 @@ membershipExceptions = ( Assets.xcassets, BrownfieldAppleApp.swift, + E2eTestIds.swift, + E2eAccessibility.swift, components/ContentView.swift, components/GreetingCard.swift, components/MaterialCard.swift, diff --git a/apps/AppleApp/Brownfield Apple App/E2eAccessibility.swift b/apps/AppleApp/Brownfield Apple App/E2eAccessibility.swift new file mode 100644 index 00000000..37c63178 --- /dev/null +++ b/apps/AppleApp/Brownfield Apple App/E2eAccessibility.swift @@ -0,0 +1,96 @@ +import SwiftUI +import UIKit + +enum E2eRuntime { + static var isDetoxRun: Bool { + ProcessInfo.processInfo.arguments.contains("-DetoxE2E") + } +} + +struct DetoxE2eButton: UIViewRepresentable { + let title: String + let accessibilityId: String + let action: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + func makeUIView(context: Context) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.accessibilityIdentifier = accessibilityId + button.accessibilityLabel = title + button.addTarget( + context.coordinator, + action: #selector(Coordinator.tap), + for: .touchUpInside + ) + return button + } + + func updateUIView(_ uiView: UIButton, context: Context) { + uiView.setTitle(title, for: .normal) + uiView.accessibilityIdentifier = accessibilityId + uiView.accessibilityLabel = title + } + + final class Coordinator: NSObject { + let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func tap() { + action() + } + } +} + +struct DetoxE2eLabel: UIViewRepresentable { + let text: String + let accessibilityId: String + + func makeUIView(context: Context) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.font = .preferredFont(forTextStyle: .body) + label.text = text + label.accessibilityIdentifier = accessibilityId + label.accessibilityLabel = text + label.isAccessibilityElement = true + return label + } + + func updateUIView(_ uiView: UILabel, context: Context) { + uiView.text = text + uiView.accessibilityLabel = text + uiView.accessibilityIdentifier = accessibilityId + } +} + +struct DetoxE2eToast: UIViewRepresentable { + let message: String + let accessibilityId: String + + func makeUIView(context: Context) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .white + label.backgroundColor = UIColor.black.withAlphaComponent(0.8) + label.layer.cornerRadius = 25 + label.clipsToBounds = true + label.text = message + label.accessibilityIdentifier = accessibilityId + label.accessibilityLabel = accessibilityId + label.isAccessibilityElement = true + return label + } + + func updateUIView(_ uiView: UILabel, context: Context) { + uiView.text = message + } +} diff --git a/apps/AppleApp/Brownfield Apple App/E2eTestIds.swift b/apps/AppleApp/Brownfield Apple App/E2eTestIds.swift new file mode 100644 index 00000000..8c94ee25 --- /dev/null +++ b/apps/AppleApp/Brownfield Apple App/E2eTestIds.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Keep in sync with `@callstack/brownfield-example-shared-tests` `e2eTestIds`. +enum E2eTestIds { + static let appleAppGreeting = "brownfield-e2e-appleapp-greeting" + static let appleAppNativeCounter = "brownfield-e2e-appleapp-native-counter" + static let appleAppNativeIncrement = "brownfield-e2e-appleapp-native-increment" + 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 1375dd08..a2fb00b2 100644 --- a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift +++ b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift @@ -14,28 +14,65 @@ let initialState = BrownfieldStore( user: User(name: "Username") ) +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: "iOS Vanilla") + + MessagesView() - VStack(spacing: 16) { - GreetingCard(name: "iOS Vanilla") - - MessagesView() - - ReactNativeView( - moduleName: "RNApp", - initialProperties: [ - "nativeOsVersionLabel": - "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" - ] - ) - .navigationBarHidden(true) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .background(Color(UIColor.systemBackground)) + ReactNativeView( + moduleName: "RNApp", + 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..0f7a381a 100644 --- a/apps/AppleApp/Brownfield Apple App/components/GreetingCard.swift +++ b/apps/AppleApp/Brownfield Apple App/components/GreetingCard.swift @@ -6,30 +6,54 @@ 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) + if E2eRuntime.isDetoxRun { + DetoxE2eLabel( + text: "Hello native \(name) 👋", + accessibilityId: E2eTestIds.appleAppGreeting + ) + DetoxE2eLabel( + text: counterText, + accessibilityId: E2eTestIds.appleAppNativeCounter + ) + HStack { + DetoxE2eButton( + title: "Increment counter", + accessibilityId: E2eTestIds.appleAppNativeIncrement + ) { + $counter.set { $0 + 1 } + } + Button("Stop RN") { + ReactNativeBrownfield.shared.stopReactNative() + } + .buttonStyle(.borderedProminent) + } + } else { + Text("Hello native \(name) 👋") + .font(.title3) + .multilineTextAlignment(.center) + + 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() + Button("Stop RN") { + ReactNativeBrownfield.shared.stopReactNative() + } + .buttonStyle(.borderedProminent) } - .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..123a39db 100644 --- a/apps/AppleApp/Brownfield Apple App/components/Toast.swift +++ b/apps/AppleApp/Brownfield Apple App/components/Toast.swift @@ -9,36 +9,50 @@ struct Toast: View { var body: some View { if isShowing { - Text(message) - .foregroundColor(.white) - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background(Color.black.opacity(0.8)) - .cornerRadius(25) - .multilineTextAlignment(.center) - .scaleEffect(scale) - .opacity(opacity) - .onAppear { - // Scale-in bounce - withAnimation(.interpolatingSpring(stiffness: 300, damping: 15)) { - scale = 1.0 - opacity = 1.0 + Group { + if E2eRuntime.isDetoxRun { + DetoxE2eToast( + message: message, + accessibilityId: E2eTestIds.appleAppPostMessageToast + ) + } else { + Text(message) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.black.opacity(0.8)) + .cornerRadius(25) + .multilineTextAlignment(.center) + .scaleEffect(scale) + .opacity(opacity) + } + } + .onAppear { + if E2eRuntime.isDetoxRun { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + isShowing = false } + return + } - // Hide after 2 seconds with scale out - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - withAnimation(.easeInOut(duration: 0.3)) { - scale = 0.5 - opacity = 0.0 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isShowing = false - } + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { + scale = 1.0 + opacity = 1.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation(.easeInOut(duration: 0.3)) { + scale = 0.5 + opacity = 0.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isShowing = false } } - .transition(.scale.combined(with: .opacity)) - .padding(.bottom, 50) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .transition(.scale.combined(with: .opacity)) + .padding(.bottom, 50) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } } 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/package.json b/apps/AppleApp/package.json index 832bdbee..e6d9ebba 100644 --- a/apps/AppleApp/package.json +++ b/apps/AppleApp/package.json @@ -8,9 +8,15 @@ "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", + "ci:local:e2e:ios": "bash ../../scripts/ci-local-appleapp-ios-e2e.sh" }, "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/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/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/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({ + +