diff --git a/.github/actions/androidapp-road-test/action.yml b/.github/actions/androidapp-road-test/action.yml index 3acb2ea0..a3dba270 100644 --- a/.github/actions/androidapp-road-test/action.yml +++ b/.github/actions/androidapp-road-test/action.yml @@ -22,23 +22,46 @@ inputs: runs: using: composite steps: + - name: '::group:: Setup & prepare Android' + run: | + echo "::group::Setup & prepare Android" + echo "flavor=${{ inputs.flavor }} rn-project=${{ inputs.rn-project-path }}" + shell: bash + - name: Setup uses: ./.github/actions/setup with: restore-turbo-cache: ${{ inputs.restore-turbo-cache }} - name: Prepare Android environment + id: prepare-android uses: ./.github/actions/prepare-android with: gradle-workflow-job-context: ${{ inputs.flavor }} + rn-project-path: ${{ inputs.rn-project-path }} + android-ccache: 'true' + + - name: '::endgroup:: Setup & prepare Android' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Brownfield Gradle plugin' + run: echo "::group::Brownfield Gradle plugin" + shell: bash - # == Brownfield Gradle Plugin == - name: Publish Brownfield Gradle Plugin to Maven Local run: | yarn run brownfield:plugin:publish:local shell: bash - # == RN app == + - name: '::endgroup:: Brownfield Gradle plugin' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: RN app — prebuild, package & publish AAR' + run: echo "::group::RN app — prebuild, package & publish AAR" + shell: bash + - name: Prebuild Expo app if: ${{ startsWith(inputs.flavor, 'expo') }} run: | @@ -73,20 +96,58 @@ runs: run: stat ~/.m2/repository/${{ inputs.rn-project-maven-path }}/0.0.1-SNAPSHOT/brownfieldlib-0.0.1-SNAPSHOT-release.aar shell: bash - # clean up build artifacts to ensure no ENOSPC - - name: Clean up local build artifacts + - name: '::endgroup:: RN app — prebuild, package & publish AAR' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Clean RN android outputs' + run: echo "::group::Clean RN android outputs" + shell: bash + + - name: Clean up local RN Android build outputs run: | - rm -rf ${{ inputs.rn-project-path }}/android/build - rm -rf ${{ inputs.rn-project-path }}/android/.cxx - rm -rf ${{ inputs.rn-project-path }}/android/.gradle - rm -rf ${{ inputs.rn-project-path }}/android/app/build - rm -rf ${{ inputs.rn-project-path }}/android/app/.cxx - rm -rf ${{ inputs.rn-project-path }}/android/app/.gradle - rm -rf ${{ inputs.rn-project-path }}/android/app/build + ANDROID_DIR="${{ inputs.rn-project-path }}/android" + rm -rf "$ANDROID_DIR/build" + rm -rf "$ANDROID_DIR/.cxx" + rm -rf "$ANDROID_DIR/.gradle" + rm -rf "$ANDROID_DIR/app/build" + rm -rf "$ANDROID_DIR/app/.cxx" + rm -rf "$ANDROID_DIR/app/.gradle" + find "$ANDROID_DIR" -maxdepth 2 -name '.cxx' -type d -prune -exec rm -rf {} + + shell: bash + + - name: '::endgroup:: Clean RN android outputs' + run: echo "::endgroup::" shell: bash - # == AndroidApp == + - name: '::group:: AndroidApp — assemble consumer app' + run: echo "::group::AndroidApp — assemble consumer app" + shell: bash - name: Build native Android Brownfield app run: yarn run build:example:android-consumer:${{ inputs.flavor }} shell: bash + + - name: '::endgroup:: AndroidApp — assemble consumer app' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Save ccache & summary' + run: echo "::group::Save ccache & summary" + shell: bash + + - name: Save Android ccache + if: steps.prepare-android.outputs.android-ccache-cache-hit != 'true' + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + with: + path: .android_ccache + key: ${{ steps.prepare-android.outputs.android-ccache-cache-primary-key }} + + - name: Log Android ccache stats + uses: ./.github/actions/ccache-summary + with: + name: Android road test (${{ inputs.flavor }}) + + - name: '::endgroup:: Save ccache & summary' + run: echo "::endgroup::" + shell: bash diff --git a/.github/actions/appleapp-road-test/action.yml b/.github/actions/appleapp-road-test/action.yml index 031b3854..36e0b5c1 100644 --- a/.github/actions/appleapp-road-test/action.yml +++ b/.github/actions/appleapp-road-test/action.yml @@ -28,6 +28,10 @@ inputs: runs: using: composite steps: + - name: '::group:: Setup & prepare iOS' + run: echo "::group::Setup & prepare iOS" + shell: bash + - name: Setup uses: ./.github/actions/setup with: @@ -36,6 +40,14 @@ runs: - name: Prepare iOS environment uses: ./.github/actions/prepare-ios + - name: '::endgroup:: Setup & prepare iOS' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: ccache & iOS build caches' + run: echo "::group::ccache & iOS build caches" + shell: bash + - name: Configure ccache environment run: | echo "USE_CCACHE=1" >> "$GITHUB_ENV" @@ -52,6 +64,7 @@ runs: if: inputs.run-e2e == 'true' run: | brew tap wix/brew + brew trust --formula wix/brew/applesimutils brew install applesimutils shell: bash @@ -60,7 +73,7 @@ runs: shell: bash - name: Restore AppleApp ccache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | .ios_ccache @@ -68,10 +81,18 @@ runs: restore-keys: | ${{ runner.os }}-rnapp-appleapp-${{ inputs.variant }}-ios-ccache- - # == RN app == + - name: '::endgroup:: ccache & iOS build caches' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: RN app — pods & package iOS XCFramework' + run: | + echo "::group::RN app — pods & package iOS XCFramework" + echo "variant=${{ inputs.variant }} rn-project=${{ inputs.rn-project-path }}" + shell: bash - name: Restore Pods cache (RN ${{ inputs.variant }} app) - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ inputs.rn-project-path }}/ios/Pods @@ -98,6 +119,13 @@ runs: pod install shell: bash + - name: Apply Brownfield Debug pod settings (E2E) + if: inputs.variant == 'vanilla' && inputs.run-e2e == 'true' + run: | + source scripts/ci-local-ios-e2e-common.sh + ci_local_e2e_apply_brownfield_debug_pod_settings "${{ github.workspace }}/${{ inputs.rn-project-path }}/ios" + shell: bash + - name: Install pods (RN ${{ inputs.variant }} app) if: inputs.variant == 'vanilla' && inputs.run-e2e != 'true' run: | @@ -106,7 +134,7 @@ runs: shell: bash - name: Restore DerivedData cache (RN ${{ inputs.variant }} app) - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ inputs.rn-project-path }}/ios/build key: ${{ runner.os }}-ios-rnapp-${{ inputs.variant }}-derived-data-${{ hashFiles(format('{0}/ios/Podfile.lock', inputs.rn-project-path), format('{0}/ios/*.xcodeproj/project.pbxproj', inputs.rn-project-path)) }} @@ -119,7 +147,14 @@ runs: yarn run brownfield:package:ios shell: bash - # == AppleApp == + - name: '::endgroup:: RN app — pods & package iOS XCFramework' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: AppleApp — Release road test build' + if: inputs.run-e2e != 'true' + run: echo "::group::AppleApp — Release road test build" + shell: bash - name: Build Brownfield iOS native app (${{ inputs.variant }}) if: inputs.run-e2e != 'true' @@ -127,6 +162,16 @@ runs: yarn run build:example:ios-consumer:${{ inputs.variant }} shell: bash + - name: '::endgroup:: AppleApp — Release road test build' + if: inputs.run-e2e != 'true' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: AppleApp — Detox E2E' + if: inputs.run-e2e == 'true' + run: echo "::group::AppleApp — Detox E2E" + shell: bash + - name: Resolve AppleApp E2E settings if: inputs.run-e2e == 'true' run: | @@ -154,7 +199,7 @@ runs: - name: Restore Detox build cache (AppleApp) if: inputs.run-e2e == 'true' - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 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/**') }} @@ -176,6 +221,7 @@ runs: - name: Verify embedded JS bundle in BrownfieldLib (E2E) if: inputs.run-e2e == 'true' run: | + echo "::group::Verify embedded JS bundle" 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)" @@ -197,28 +243,42 @@ runs: fi echo "App executable OK: $EXECUTABLE_PATH ($(wc -c < "$EXECUTABLE_PATH") bytes)" echo "Embedded bundle OK: $BUNDLE_PATH ($(wc -c < "$BUNDLE_PATH") bytes)" + echo "::endgroup::" shell: bash - name: Detox test (AppleApp ${{ inputs.variant }}) if: inputs.run-e2e == 'true' run: | + echo "::group::Detox test" rm -rf e2e-artifacts yarn "$APPLEAPP_E2E_TEST_SCRIPT" + echo "::endgroup::" working-directory: apps/AppleApp shell: bash - name: Upload Detox recordings on failure if: failure() && inputs.run-e2e == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.e2e-artifact-name }}-${{ inputs.variant }}-ios-recordings path: apps/AppleApp/e2e-artifacts if-no-files-found: warn retention-days: 5 - # ============== + - name: '::endgroup:: AppleApp — Detox E2E' + if: always() && inputs.run-e2e == 'true' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Summary' + run: echo "::group::Summary" + shell: bash - name: Log ccache stats uses: ./.github/actions/ccache-summary with: name: RN ${{ inputs.variant }} app & AppleApp + + - name: '::endgroup:: Summary' + run: echo "::endgroup::" + shell: bash diff --git a/.github/actions/ccache-summary/action.yml b/.github/actions/ccache-summary/action.yml index 52df8958..44e1744e 100644 --- a/.github/actions/ccache-summary/action.yml +++ b/.github/actions/ccache-summary/action.yml @@ -10,5 +10,10 @@ runs: using: composite steps: - name: Log ccache stats - run: ccache -s > $GITHUB_STEP_SUMMARY + run: | + { + echo "### ${{ inputs.name }}" + echo + ccache -s + } >> "$GITHUB_STEP_SUMMARY" shell: bash diff --git a/.github/actions/prepare-android/action.yml b/.github/actions/prepare-android/action.yml index 44424131..fa99af8f 100644 --- a/.github/actions/prepare-android/action.yml +++ b/.github/actions/prepare-android/action.yml @@ -12,14 +12,41 @@ inputs: required: false default: 'false' + gradle-cache: + description: 'Enable Gradle User Home cache restore/save via setup-gradle' + required: false + default: 'true' + + android-ccache: + description: 'Install, restore, and allow saving Android ccache (native JNI builds only)' + required: false + default: 'false' + gradle-workflow-job-context: description: 'Segment Gradle cache per app/project (e.g. vanilla, expo54). Falls back to github.job when empty.' required: false default: '' + rn-project-path: + description: 'Optional RN app path for native ccache key segmentation (e.g. apps/ExpoApp55)' + required: false + default: '' + +outputs: + android-ccache-cache-hit: + description: 'Whether Android ccache restore matched the primary cache key exactly' + value: ${{ steps.android-ccache-restore.outputs.cache-hit }} + android-ccache-cache-primary-key: + description: 'Primary cache key used for Android ccache restore/save' + value: ${{ steps.android-ccache-restore.outputs.cache-primary-key }} + runs: using: composite steps: + - name: '::group:: Gradle & Java' + run: echo "::group::Gradle & Java" + shell: bash + - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@6f229686ee4375cc4a86b2514c89bac4930e82c4 # v5 @@ -35,6 +62,20 @@ runs: # Validates all gradle-wrapper.jar files, caches Gradle User Home efficiently, # and captures Build Scan links. Do not combine with actions/cache on ~/.gradle. workflow-job-context: ${{ inputs.gradle-workflow-job-context != '' && inputs.gradle-workflow-job-context || github.job }} + cache-disabled: ${{ inputs.gradle-cache != 'true' }} + + # ubuntu-latest is x86_64; match the emulator ABI so CI only compiles one JNI arch. + - name: Limit native builds to runner emulator ABI + run: echo "ORG_GRADLE_PROJECT_reactNativeArchitectures=x86_64" >> "$GITHUB_ENV" + shell: bash + + - name: '::endgroup:: Gradle & Java' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Disk space & Android NDK' + run: echo "::group::Disk space & Android NDK" + shell: bash - name: Free Disk Space (Ubuntu) if: inputs.free-disk-space == 'true' @@ -55,7 +96,57 @@ runs: test -d "$ANDROID_HOME/ndk/27.0.12077973" shell: bash + - name: '::endgroup:: Disk space & Android NDK' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Android ccache' + if: inputs.android-ccache == 'true' + run: echo "::group::Android ccache" + shell: bash + + - name: Install ccache + if: inputs.android-ccache == 'true' + run: sudo apt-get update && sudo apt-get install -y ccache + shell: bash + + - name: Configure Android ccache + if: inputs.android-ccache == 'true' + run: | + mkdir -p "${GITHUB_WORKSPACE}/.android_ccache" + echo "compiler_check = content" > "${GITHUB_WORKSPACE}/.android_ccache.conf" + echo "CCACHE_CONFIGPATH=${GITHUB_WORKSPACE}/.android_ccache.conf" >> "$GITHUB_ENV" + echo "CCACHE_DIR=${GITHUB_WORKSPACE}/.android_ccache" >> "$GITHUB_ENV" + echo "CCACHE_BASEDIR=${GITHUB_WORKSPACE}" >> "$GITHUB_ENV" + echo "CCACHE_COMPRESS=1" >> "$GITHUB_ENV" + shell: bash + + - name: Restore Android ccache + id: android-ccache-restore + if: inputs.android-ccache == 'true' + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + with: + path: .android_ccache + key: ${{ runner.os }}-android-ccache-${{ inputs.gradle-workflow-job-context != '' && inputs.gradle-workflow-job-context || github.job }}-x86_64-${{ hashFiles('yarn.lock', '**/gradle-wrapper.properties', inputs.rn-project-path != '' && format('{0}/package.json', inputs.rn-project-path) || 'package.json') }} + restore-keys: | + ${{ runner.os }}-android-ccache-${{ inputs.gradle-workflow-job-context != '' && inputs.gradle-workflow-job-context || github.job }}- + + - name: '::endgroup:: Android ccache' + if: inputs.android-ccache == 'true' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Build packages' + if: inputs.run-yarn-build == 'true' + run: echo "::group::Build packages" + shell: bash + - name: Build packages if: inputs.run-yarn-build == 'true' run: yarn build shell: bash + + - name: '::endgroup:: Build packages' + if: inputs.run-yarn-build == 'true' + run: echo "::endgroup::" + shell: bash diff --git a/.github/actions/prepare-ios/action.yml b/.github/actions/prepare-ios/action.yml index 95d789cb..07d12373 100644 --- a/.github/actions/prepare-ios/action.yml +++ b/.github/actions/prepare-ios/action.yml @@ -10,18 +10,36 @@ inputs: runs: using: composite steps: + - name: '::group:: Xcode & Ruby' + run: echo "::group::Xcode & Ruby" + shell: bash + - name: Use appropriate Xcode version - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.3' - name: Setup Ruby - uses: ruby/setup-ruby@5dd816ae0186f20dfa905997a64104db9a8221c7 # v1.280.0 + uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0 with: ruby-version: '3.2' bundler-cache: true + - name: '::endgroup:: Xcode & Ruby' + run: echo "::endgroup::" + shell: bash + + - name: '::group:: Build packages' + if: inputs.run-yarn-build == 'true' + run: echo "::group::Build packages" + shell: bash + - name: Build packages if: inputs.run-yarn-build == 'true' run: yarn build shell: bash + + - name: '::endgroup:: Build packages' + if: inputs.run-yarn-build == 'true' + run: echo "::endgroup::" + shell: bash diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index b514b84d..33356cd8 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -2,6 +2,10 @@ name: Setup description: Setup Node.js and install dependencies inputs: + disable-cache: + description: 'Disable Node.js yarn cache and Turbo cache restore (use for release/publish workflows)' + required: false + default: 'false' restore-turbo-cache: description: 'Whether to restore the Turbo cache' required: false @@ -10,15 +14,23 @@ inputs: runs: using: composite steps: - - name: Setup Node.js + - name: Setup Node.js (with cache) + if: inputs.disable-cache != 'true' uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5 # v6 with: node-version: 'lts/*' cache: 'yarn' + - name: Setup Node.js (no cache) + if: inputs.disable-cache == 'true' + uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5 # v6 + with: + node-version: 'lts/*' + package-manager-cache: false + - name: Restore Turbo cache - if: inputs.restore-turbo-cache == 'true' - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + if: inputs.disable-cache != 'true' && inputs.restore-turbo-cache == 'true' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | .turbo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64f3e5b..c3c68d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Filter paths - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: filters: | @@ -237,4 +237,3 @@ jobs: rn-project-path: apps/ExpoApp${{ matrix.version }} run-e2e: ${{ matrix.run-e2e }} e2e-artifact-name: detox-appleapp-expo${{ matrix.version }} - diff --git a/.github/workflows/expo-beta-road-test.yml b/.github/workflows/expo-beta-road-test.yml index 0b4dd671..0b28d818 100644 --- a/.github/workflows/expo-beta-road-test.yml +++ b/.github/workflows/expo-beta-road-test.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: permissions: - actions: read + actions: write contents: read issues: write @@ -148,7 +148,7 @@ jobs: printf '%s\n' '${{ needs.prepare.outputs.latest_version }}' > expo-beta-test-result/version.txt - name: Upload Expo beta success marker - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: expo-beta-tested-${{ needs.prepare.outputs.latest_version }} path: expo-beta-test-result/version.txt diff --git a/.github/workflows/gradle-plugin-lint.yml b/.github/workflows/gradle-plugin-lint.yml index 2c70cc81..81298b6e 100644 --- a/.github/workflows/gradle-plugin-lint.yml +++ b/.github/workflows/gradle-plugin-lint.yml @@ -20,6 +20,7 @@ jobs: with: free-disk-space: 'false' run-yarn-build: 'false' + android-ccache: 'false' - name: Run Detekt working-directory: gradle-plugins/react/brownfield diff --git a/.github/workflows/release-brownfield-gradle-plugin.yml b/.github/workflows/release-brownfield-gradle-plugin.yml index 7cb2a4e6..3de38dd4 100644 --- a/.github/workflows/release-brownfield-gradle-plugin.yml +++ b/.github/workflows/release-brownfield-gradle-plugin.yml @@ -40,13 +40,15 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - restore-turbo-cache: 'false' + disable-cache: 'true' - name: Prepare Android environment uses: ./.github/actions/prepare-android with: free-disk-space: 'false' run-yarn-build: 'false' + gradle-cache: 'false' + android-ccache: 'false' - name: Derive release metadata id: metadata @@ -105,13 +107,13 @@ jobs: --stacktrace - name: Upload release notes artifact - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: brownfield-gradle-plugin-release-notes path: out/jreleaser/brownfield-gradle-plugin-release-notes.md - name: Upload staged Maven repository artifact - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: brownfield-gradle-plugin-staging-repo path: gradle-plugins/react/brownfield/build/staging-deploy @@ -148,7 +150,7 @@ jobs: path: gradle-plugins/react/brownfield/build/staging-deploy - name: Run JReleaser - uses: jreleaser/release-action@v2 + uses: jreleaser/release-action@90ac653bb9c79d11179e65d81499f3f34527dcd5 # v2.5.0 with: arguments: full-release env: @@ -165,7 +167,7 @@ jobs: - name: Upload JReleaser output if: always() - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: brownfield-gradle-plugin-jreleaser-output path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 847af816..c2e4e349 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - restore-turbo-cache: 'false' # in release workflow, build from scratch + disable-cache: 'true' - name: Build diff --git a/.gitignore b/.gitignore index df918d12..a05d1915 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ local.properties *.iml *.hprof **/.cxx/ +.android_ccache/ +.android_ccache.conf +.ios_ccache/ *.keystore !debug.keystore .kotlin/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0b896cc..216d4e3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ After contributing your changes, please make sure to add a [changeset](https://g ### Pre-commit guard for brownfield-navigation - This is a monorepo and the files inside `@callstack/brownfield-navigation` are auto-generated whenever `brownfield:package:*` is run. This is a desired behavior for the end user as these files will be inside the `node_modules`. However, since in this repo this package is symlinked, we see the changes in our git tree. +This is a monorepo and the files inside `@callstack/brownfield-navigation` are auto-generated whenever `brownfield:package:*` is run. This is a desired behavior for the end user as these files will be inside the `node_modules`. However, since in this repo this package is symlinked, we see the changes in our git tree. These should not be committed by accident. A `pre-commit` guard blocks commits when those generated files are staged. @@ -75,11 +75,11 @@ There are 2 brownfield host apps. For iOS, these scripts validate the legacy direct-XCFramework integration path. Each script uses the previously packaged artifacts from the respective directory (`apps/RNApp`, `apps/ExpoApp54`, or `apps/ExpoApp55`), invokes `prepareXCFrameworks.js` to copy XCFrameworks into `apps/AppleApp/package`, then runs `xcodebuild` against the matching scheme. The Xcode project reads fixed paths under `package/` (for example `package/BrownfieldLib.xcframework`). -| Yarn script | RN app | Xcode target | Scheme | Configuration | -| --- | --- | --- | --- | --- | -| `build:example:ios-consumer:vanilla` | `RNApp` | `Brownfield Apple App (RNApp)` | Brownfield Apple App Vanilla | `Release Vanilla` | -| `build:example:ios-consumer:expo54` | `ExpoApp54` | `Brownfield Apple App (ExpoApp54)` | Brownfield Apple App Expo 54 | `Release` | -| `build:example:ios-consumer:expo55` | `ExpoApp55` | `Brownfield Apple App (ExpoApp55)` | Brownfield Apple App Expo 55 | `Release` | +| Yarn script | RN app | Xcode target | Scheme | Configuration | +| ------------------------------------ | ----------- | ---------------------------------- | ---------------------------- | ----------------- | +| `build:example:ios-consumer:vanilla` | `RNApp` | `Brownfield Apple App (RNApp)` | Brownfield Apple App Vanilla | `Release Vanilla` | +| `build:example:ios-consumer:expo54` | `ExpoApp54` | `Brownfield Apple App (ExpoApp54)` | Brownfield Apple App Expo 54 | `Release` | +| `build:example:ios-consumer:expo55` | `ExpoApp55` | `Brownfield Apple App (ExpoApp55)` | Brownfield Apple App Expo 55 | `Release` | > [!IMPORTANT] > You can build and run `AppleApp` from the Xcode GUI by selecting the scheme for the variant you want. Before running, after switching schemes or re-packaging an RN app, run the matching `build:example:ios-consumer:...` script so fresh artifacts are present in `apps/AppleApp/package`. Otherwise Xcode will still link against the previous XCFrameworks. @@ -111,18 +111,82 @@ The React Native example apps share Jest utilities and test suites from `apps/br From the repository root: -| Command | Description | -| --- | --- | +| Command | Description | +| ---------------- | ----------------------------------------------------------------------- | | `yarn test:apps` | Runs `test` in all workspaces under `apps/` that define it (via Turbo). | Per example app (run from the repo root): -| Command | App | -| --- | --- | -| `yarn workspace @callstack/brownfield-example-rn-app test` | Plain React Native (`apps/RNApp`) | -| `yarn workspace @callstack/brownfield-example-expo-app-54 test` | Expo SDK 54 (`apps/ExpoApp54`) | -| `yarn workspace @callstack/brownfield-example-expo-app-55 test` | Expo SDK 55 (`apps/ExpoApp55`) | +| Command | App | +| --------------------------------------------------------------- | --------------------------------- | +| `yarn workspace @callstack/brownfield-example-rn-app test` | Plain React Native (`apps/RNApp`) | +| `yarn workspace @callstack/brownfield-example-expo-app-54 test` | Expo SDK 54 (`apps/ExpoApp54`) | +| `yarn workspace @callstack/brownfield-example-expo-app-55 test` | Expo SDK 55 (`apps/ExpoApp55`) | Package-level scripts (`yarn test` inside `apps/RNApp`, `apps/ExpoApp54`, or `apps/ExpoApp55`) invoke Jest with each app’s `jest.config.js`. The native-only sample apps (`apps/AppleApp`, `apps/AndroidApp`) use their platform test runners (Xcode / Gradle), not Jest. + +## E2E tests (Detox) + +End-to-end tests use [Detox](https://wix.github.io/Detox/) on the iOS Simulator. Shared specs and helpers live in `apps/brownfield-example-shared-tests/e2e/`; each app wires them through its own `.detoxrc.cjs` and `e2e/jest.config.cjs`. + +E2E runs without Metro: the Debug simulator build embeds `main.jsbundle` (`FORCE_BUNDLING=1`) so the app loads JS from the binary, matching CI. + +### Two integration paths + +| Path | What it exercises | Typical flow | +| ----------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| RN host app (`RNApp`, `ExpoApp54`, `ExpoApp55`) | Brownfield RN app running as the simulator target | `expo prebuild` / pods → Detox build → Detox test | +| native host app (`AppleApp`) | Native host app consuming a packaged `BrownfieldLib` XCFramework | `brownfield:package:ios` → copy XCFrameworks into `AppleApp/package` → Detox build → Detox test | + +Per-app Detox scripts (run from the app directory): + +| App | Build | Test | Shared spec | +| ------------------------- | --------------------------- | -------------------------- | ---------------------------------- | +| `RNApp` | `yarn e2e:build:ios` | `yarn e2e:test:ios` | `rnAppBrownfield.e2e.js` | +| `ExpoApp54` / `ExpoApp55` | `yarn e2e:build:ios` | `yarn e2e:test:ios` | `expoPostMessageBrownfield.e2e.js` | +| `AppleApp` (vanilla) | `yarn e2e:build:ios` | `yarn e2e:test:ios` | `appleAppBrownfield.e2e.js` | +| `AppleApp` (Expo 55) | `yarn e2e:build:ios:expo55` | `yarn e2e:test:ios:expo55` | `appleAppExpoBrownfield.e2e.js` | + +### CI + +iOS Detox E2E runs in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) via [`.github/actions/appleapp-road-test`](.github/actions/appleapp-road-test/action.yml): + +| Job | E2E | Notes | +| ----------------------------- | --- | ---------------------------------------- | +| `ios-appleapp-vanilla` | Yes | `RNApp` → package → `AppleApp` Detox | +| `ios-appleapp-expo` (Expo 54) | No | Road test only (Release build) | +| `ios-appleapp-expo` (Expo 55) | Yes | `ExpoApp55` → package → `AppleApp` Detox | + +On failure, CI uploads `apps/AppleApp/e2e-artifacts/` as a workflow artifact (`detox-appleapp-*-ios-recordings`). + +Direct host-app E2E is local-only — use the `ci:local:*` scripts below to reproduce CI-like setup on macOS. + +### Local CI scripts + +From the repo root (macOS + Xcode + Simulator required). All wrap `scripts/ci-local-ios-e2e-common.sh` and accept the same flags: + +| Command | Mirrors | +| --------------------------------------- | -------------------------------- | +| `yarn ci:local:rnapp:e2e:ios` | RN host app E2E (`apps/RNApp`) | +| `yarn ci:local:expo54:e2e:ios` | Expo 54 host app E2E | +| `yarn ci:local:expo55:e2e:ios` | Expo 55 host app E2E | +| `yarn ci:local:appleapp:e2e:ios` | CI `ios-appleapp-vanilla` | +| `yarn ci:local:appleapp:e2e:ios:expo55` | CI `ios-appleapp-expo` (Expo 55) | + +Common flags (append to any command above): + +| Flag | Effect | +| ---------------- | ------------------------------------------------------ | +| `--clean-ios` | Remove `ios/Pods` and `ios/build` before setup | +| `--skip-install` | Skip root `yarn install` / `yarn build` | +| `--rebuild` | Detox build + test only (skip install, prebuild, pods) | +| `--test-only` | Run tests against an existing build (no rebuild) | +| `--build-only` | Detox build only, skip tests | + +Host-app scripts run `yarn install`, `yarn build`, brownfield codegen, `expo prebuild`, `pod install`, Detox postinstall, then `e2e:build:ios` and `e2e:test:ios`. The AppleApp script packages the RN app and copies XCFrameworks first (same as CI). + +### `e2e-artifacts/` + +Detox writes failure diagnostics under `/e2e-artifacts/` (configured in `apps/brownfield-example-shared-tests/detox-artifacts-config.cjs`). Each run creates a timestamped subfolder, e.g. `e2e-artifacts/ios.sim.debug./`. diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt index 7d81eb7f..6113b2d8 100644 --- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt +++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt @@ -1,6 +1,7 @@ package com.callstack.brownfield.android.example import com.callstack.brownie.registerStoreIfNeeded +import android.app.Activity import android.content.Intent import android.content.res.Configuration import android.os.Build @@ -20,9 +21,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.fragment.compose.AndroidFragment @@ -137,6 +140,11 @@ private fun MainScreen(modifier: Modifier = Modifier) { fun ReactNativeView( modifier: Modifier = Modifier ) { + val activity = LocalContext.current as? Activity + val brownfieldE2E = remember(activity) { + activity?.intent?.getStringExtra("DetoxE2E") == "YES" + } + AndroidFragment( modifier = modifier, arguments = Bundle().apply { @@ -151,6 +159,7 @@ fun ReactNativeView( "nativeOsVersionLabel", "Android ${Build.VERSION.RELEASE}" ) + putBoolean("brownfieldE2E", brownfieldE2E) } ) } diff --git a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift index 0b2ceb83..035111ae 100644 --- a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift +++ b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift @@ -34,6 +34,14 @@ private func brownfieldPostMessageText(from raw: String) -> String { return raw } +private var brownfieldInitialProperties: [String: Any] { + [ + "nativeOsVersionLabel": + "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)", + "brownfieldE2E": ProcessInfo.processInfo.arguments.contains("-DetoxE2E"), + ] +} + struct ContentView: View { @State private var messageObserver: NSObjectProtocol? @State private var showPostMessageToast = false @@ -50,10 +58,7 @@ struct ContentView: View { ReactNativeView( moduleName: reactNativeModuleName, - initialProperties: [ - "nativeOsVersionLabel": - "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" - ] + initialProperties: brownfieldInitialProperties ) .navigationBarHidden(true) .clipShape(RoundedRectangle(cornerRadius: 16)) diff --git a/apps/ExpoApp54/.gitignore b/apps/ExpoApp54/.gitignore index 2eed6de9..17f9a695 100644 --- a/apps/ExpoApp54/.gitignore +++ b/apps/ExpoApp54/.gitignore @@ -39,7 +39,7 @@ yarn-error.* app-example # Detox -artifacts/ +e2e-artifacts/ # generated native folders /ios diff --git a/apps/ExpoApp54/RNApp.tsx b/apps/ExpoApp54/RNApp.tsx index 5f771364..c10c9f85 100644 --- a/apps/ExpoApp54/RNApp.tsx +++ b/apps/ExpoApp54/RNApp.tsx @@ -1,16 +1,27 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Button, StyleSheet, Text, View } from 'react-native'; +import { useEffect } from 'react'; import BrownfieldNavigation from '@callstack/brownfield-navigation'; +import { + syncBrownfieldE2EModeFromRootProps, + type BrownfieldRootProps, +} from '@callstack/brownfield-example-shared-tests/runtime'; import Counter from './components/counter'; import { checkAndFetchUpdate } from './utils/expo-rn-updates'; -type RNAppProps = { - nativeOsVersionLabel?: string; -}; +type RNAppProps = BrownfieldRootProps; + +export default function RNApp({ + nativeOsVersionLabel, + brownfieldE2E, +}: RNAppProps) { + useEffect(() => { + syncBrownfieldE2EModeFromRootProps(brownfieldE2E); + return () => syncBrownfieldE2EModeFromRootProps(undefined); + }, [brownfieldE2E]); -export default function RNApp({ nativeOsVersionLabel }: RNAppProps) { return ( Expo React Native Brownfield diff --git a/apps/ExpoApp54/app/(tabs)/postMessage.tsx b/apps/ExpoApp54/app/(tabs)/postMessage.tsx index 86b75e1b..815dd098 100644 --- a/apps/ExpoApp54/app/(tabs)/postMessage.tsx +++ b/apps/ExpoApp54/app/(tabs)/postMessage.tsx @@ -1,7 +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 { brownfieldE2ETestIds } from '@callstack/brownfield-example-shared-tests/e2eTestIds'; import ReactNativeBrownfield from '@callstack/react-native-brownfield'; import type { MessageEvent } from '@callstack/react-native-brownfield'; @@ -52,7 +52,7 @@ export default function PostMessageTab() { return ( {item.text} diff --git a/apps/ExpoApp54/entry.tsx b/apps/ExpoApp54/entry.tsx index 3c70f46c..6fe2d049 100644 --- a/apps/ExpoApp54/entry.tsx +++ b/apps/ExpoApp54/entry.tsx @@ -1,7 +1,17 @@ import { ExpoRoot } from 'expo-router'; +import { useEffect } from 'react'; import { AppRegistry } from 'react-native'; +import { + syncBrownfieldE2EModeFromRootProps, + type BrownfieldRootProps, +} from '@callstack/brownfield-example-shared-tests/runtime'; + +function App(props: BrownfieldRootProps) { + useEffect(() => { + syncBrownfieldE2EModeFromRootProps(props.brownfieldE2E); + return () => syncBrownfieldE2EModeFromRootProps(undefined); + }, [props.brownfieldE2E]); -function App() { const ctx = require.context('./app'); return ; } diff --git a/apps/ExpoApp54/utils/expo-rn-updates.ts b/apps/ExpoApp54/utils/expo-rn-updates.ts index 58eaed01..521b4719 100644 --- a/apps/ExpoApp54/utils/expo-rn-updates.ts +++ b/apps/ExpoApp54/utils/expo-rn-updates.ts @@ -1,5 +1,5 @@ +import { userAlert } from '@callstack/brownfield-example-shared-tests/runtime'; import * as Updates from 'expo-updates'; -import { Alert } from 'react-native'; export async function checkAndFetchUpdate() { try { @@ -17,9 +17,9 @@ export async function checkAndFetchUpdate() { }, }); } else { - Alert.alert('No update available'); + userAlert('No update available'); } } catch (error) { - Alert.alert('Update check failed', String(error)); + userAlert('Update check failed', String(error)); } } diff --git a/apps/ExpoApp55/.gitignore b/apps/ExpoApp55/.gitignore index 2eed6de9..17f9a695 100644 --- a/apps/ExpoApp55/.gitignore +++ b/apps/ExpoApp55/.gitignore @@ -39,7 +39,7 @@ yarn-error.* app-example # Detox -artifacts/ +e2e-artifacts/ # generated native folders /ios diff --git a/apps/ExpoApp55/RNApp.tsx b/apps/ExpoApp55/RNApp.tsx index bb470700..e8a29731 100644 --- a/apps/ExpoApp55/RNApp.tsx +++ b/apps/ExpoApp55/RNApp.tsx @@ -1,15 +1,26 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Button, StyleSheet, Text, View } from 'react-native'; +import { useEffect } from 'react'; import BrownfieldNavigation from '@callstack/brownfield-navigation'; +import { + syncBrownfieldE2EModeFromRootProps, + type BrownfieldRootProps, +} from '@callstack/brownfield-example-shared-tests/runtime'; import { checkAndFetchUpdate } from './src/utils/expo-rn-updates'; import Counter from './src/components/counter'; -type RNAppProps = { - nativeOsVersionLabel?: string; -}; +type RNAppProps = BrownfieldRootProps; + +export default function RNApp({ + nativeOsVersionLabel, + brownfieldE2E, +}: RNAppProps) { + useEffect(() => { + syncBrownfieldE2EModeFromRootProps(brownfieldE2E); + return () => syncBrownfieldE2EModeFromRootProps(undefined); + }, [brownfieldE2E]); -export default function RNApp({ nativeOsVersionLabel }: RNAppProps) { return ( Expo React Native Brownfield diff --git a/apps/ExpoApp55/__tests__/brownfield.example.test.tsx b/apps/ExpoApp55/__tests__/brownfield.example.test.tsx index eeaf54e0..893da8e6 100644 --- a/apps/ExpoApp55/__tests__/brownfield.example.test.tsx +++ b/apps/ExpoApp55/__tests__/brownfield.example.test.tsx @@ -5,8 +5,10 @@ import { runPostMessageTabSuite, runCounterSuite, runExpoRnAppSuite, + runUserAlertSuite, } from '@callstack/brownfield-example-shared-tests'; runPostMessageTabSuite('ExpoApp55', PostMessageTab); runCounterSuite('ExpoApp55', Counter); runExpoRnAppSuite('ExpoApp55', RNApp); +runUserAlertSuite('ExpoApp55'); diff --git a/apps/ExpoApp55/entry.tsx b/apps/ExpoApp55/entry.tsx index 87dd9e5e..dc735f02 100644 --- a/apps/ExpoApp55/entry.tsx +++ b/apps/ExpoApp55/entry.tsx @@ -1,7 +1,17 @@ import { ExpoRoot } from 'expo-router'; +import { useEffect } from 'react'; import { AppRegistry } from 'react-native'; +import { + syncBrownfieldE2EModeFromRootProps, + type BrownfieldRootProps, +} from '@callstack/brownfield-example-shared-tests/runtime'; + +function App(props: BrownfieldRootProps) { + useEffect(() => { + syncBrownfieldE2EModeFromRootProps(props.brownfieldE2E); + return () => syncBrownfieldE2EModeFromRootProps(undefined); + }, [props.brownfieldE2E]); -function App() { const ctx = require.context('./src/app'); return ; } diff --git a/apps/ExpoApp55/src/app/postMessage.tsx b/apps/ExpoApp55/src/app/postMessage.tsx index 86b75e1b..815dd098 100644 --- a/apps/ExpoApp55/src/app/postMessage.tsx +++ b/apps/ExpoApp55/src/app/postMessage.tsx @@ -1,7 +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 { brownfieldE2ETestIds } from '@callstack/brownfield-example-shared-tests/e2eTestIds'; import ReactNativeBrownfield from '@callstack/react-native-brownfield'; import type { MessageEvent } from '@callstack/react-native-brownfield'; @@ -52,7 +52,7 @@ export default function PostMessageTab() { return ( {item.text} diff --git a/apps/ExpoApp55/src/utils/expo-rn-updates.ts b/apps/ExpoApp55/src/utils/expo-rn-updates.ts index 58eaed01..521b4719 100644 --- a/apps/ExpoApp55/src/utils/expo-rn-updates.ts +++ b/apps/ExpoApp55/src/utils/expo-rn-updates.ts @@ -1,5 +1,5 @@ +import { userAlert } from '@callstack/brownfield-example-shared-tests/runtime'; import * as Updates from 'expo-updates'; -import { Alert } from 'react-native'; export async function checkAndFetchUpdate() { try { @@ -17,9 +17,9 @@ export async function checkAndFetchUpdate() { }, }); } else { - Alert.alert('No update available'); + userAlert('No update available'); } } catch (error) { - Alert.alert('Update check failed', String(error)); + userAlert('Update check failed', String(error)); } } diff --git a/apps/RNApp/jest.config.js b/apps/RNApp/jest.config.js index bb08fc51..4e9a7073 100644 --- a/apps/RNApp/jest.config.js +++ b/apps/RNApp/jest.config.js @@ -7,6 +7,7 @@ module.exports = { '^react$': require.resolve('react'), '^react/jsx-runtime$': require.resolve('react/jsx-runtime'), '^react/jsx-dev-runtime$': require.resolve('react/jsx-dev-runtime'), + '^react-native$': require.resolve('react-native'), '^@testing-library/react-native$': require.resolve('@testing-library/react-native'), '^@callstack/react-native-brownfield$': path.join( diff --git a/apps/RNApp/src/App.tsx b/apps/RNApp/src/App.tsx index 84ca5f7a..3e965a9b 100644 --- a/apps/RNApp/src/App.tsx +++ b/apps/RNApp/src/App.tsx @@ -1,16 +1,24 @@ import '../BrownfieldStore.brownie'; import { NavigationContainer } from '@react-navigation/native'; +import { useEffect } from 'react'; +import { + syncBrownfieldE2EModeFromRootProps, + type BrownfieldRootProps, +} from '@callstack/brownfield-example-shared-tests/runtime'; import { HomeScreen } from './HomeScreen'; import { NativeOsVersionLabelContext } from './nativeHostContext'; import { Stack } from './navigation/RootStack'; -type AppProps = { - nativeOsVersionLabel?: string; -}; +type AppProps = BrownfieldRootProps; + +export default function App({ nativeOsVersionLabel, brownfieldE2E }: AppProps) { + useEffect(() => { + syncBrownfieldE2EModeFromRootProps(brownfieldE2E); + return () => syncBrownfieldE2EModeFromRootProps(undefined); + }, [brownfieldE2E]); -export default function App({ nativeOsVersionLabel }: AppProps) { return ( diff --git a/apps/RNApp/src/HomeScreen.tsx b/apps/RNApp/src/HomeScreen.tsx index c0d20c29..d3c27d5d 100644 --- a/apps/RNApp/src/HomeScreen.tsx +++ b/apps/RNApp/src/HomeScreen.tsx @@ -10,7 +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 { brownfieldE2ETestIds } from '@callstack/brownfield-example-shared-tests/e2eTestIds'; import BrownfieldNavigation from '@callstack/brownfield-navigation'; import { getRandomTheme } from './utils'; @@ -64,7 +64,7 @@ function MessageBubble({ item, color }: { item: Message; color: string }) { @@ -128,11 +128,11 @@ export function HomeScreen({ return ( @@ -152,7 +152,7 @@ export function HomeScreen({