diff --git a/.github/actions/ios-build-xcode/action.yml b/.github/actions/ios-build-xcode/action.yml index 96e8705c..c053b42c 100644 --- a/.github/actions/ios-build-xcode/action.yml +++ b/.github/actions/ios-build-xcode/action.yml @@ -18,7 +18,7 @@ runs: set -euo pipefail gem install xcpretty cd "${{ inputs.ios-dir }}" - + set -o pipefail WORKSPACE_NAME="$(find . -maxdepth 1 -name '*.xcworkspace' ! -name 'Pods.xcworkspace' -exec basename {} .xcworkspace \; | head -1)" @@ -34,10 +34,10 @@ runs: fi echo "Building workspace ${WORKSPACE_NAME}.xcworkspace with scheme ${SCHEME_NAME}" - + max_retries=3 attempt=1 - + while [ "$attempt" -le "$max_retries" ] do echo "xcodebuild attempt $attempt of $max_retries" diff --git a/.github/actions/setup-maestro/action.yml b/.github/actions/setup-maestro/action.yml deleted file mode 100644 index fedd25a4..00000000 --- a/.github/actions/setup-maestro/action.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: 'Setup Maestro' -description: 'Installs Maestro CLI' -runs: - using: 'composite' - steps: - - run: | - echo "Installing Maestro CLI..." - export MAESTRO_VERSION=1.40.0; curl -Ls "https://get.maestro.mobile.dev" | bash - - # Add Maestro to PATH for subsequent steps - export PATH="$PATH":"$HOME/.maestro/bin" - echo "$HOME/.maestro/bin" >> $GITHUB_PATH - - # Verify installation - maestro --version || echo "Maestro installation verification failed" - - echo "Maestro installation complete!" - shell: bash diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index f50cb386..829f3bc9 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -73,10 +73,8 @@ jobs: runs-on: ubuntu-latest outputs: generation: ${{ steps.set-matrix.outputs.generation }} - ios-build: ${{ steps.set-matrix.outputs.ios_build }} - android-build: ${{ steps.set-matrix.outputs.android_build }} - ios-e2e: ${{ steps.set-matrix.outputs.ios_e2e }} - android-e2e: ${{ steps.set-matrix.outputs.android_e2e }} + ios-harness: ${{ steps.set-matrix.outputs.ios_harness }} + android-harness: ${{ steps.set-matrix.outputs.android_harness }} steps: - name: Build workflow matrices id: set-matrix @@ -184,36 +182,16 @@ jobs: const generation = pms.flatMap(pm => scenarios.map(item => enrich(item, { pm })) ) - const iosBuild = pms.flatMap(pm => - scenarios - .filter(item => item.runs_ios) - .flatMap(item => - ['Debug', 'Release'].map(mode => - enrich(item, { pm, mode }) - ) - ) - ) - const androidBuild = pms.flatMap(pm => - scenarios - .filter(item => item.runs_android) - .flatMap(item => - ['Debug', 'Release'].map(mode => - enrich(item, { pm, mode }) - ) - ) - ) - const iosE2E = scenarios + const iosHarness = scenarios .filter(item => item.runs_ios) - .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) - const androidE2E = scenarios + .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) + const androidHarness = scenarios .filter(item => item.runs_android) - .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) + .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) console.log(`generation=${JSON.stringify({ include: generation })}`) - console.log(`ios_build=${JSON.stringify({ include: iosBuild })}`) - console.log(`android_build=${JSON.stringify({ include: androidBuild })}`) - console.log(`ios_e2e=${JSON.stringify({ include: iosE2E })}`) - console.log(`android_e2e=${JSON.stringify({ include: androidE2E })}`) + console.log(`ios_harness=${JSON.stringify({ include: iosHarness })}`) + console.log(`android_harness=${JSON.stringify({ include: androidHarness })}`) NODE generate-packages: @@ -264,6 +242,7 @@ jobs: run: | ${{ matrix.pm }} create nitro-module test-${{ matrix.package_type }}-${{ matrix.scenario }} \ --skip-install \ + --include-harness \ --ci \ --package-type ${{ matrix.package_type }} \ --platforms ${{ matrix.platforms }} \ @@ -325,6 +304,23 @@ jobs: exit 1 fi + - name: Verify generated harness scripts + shell: bash + run: | + cd "${{ matrix.package_dir }}" + + if [ "${{ matrix.runs_android }}" = "true" ] && ! grep -q '"test:harness:android"' example/package.json; then + echo "Missing Android Harness script in example/package.json" + cat example/package.json + exit 1 + fi + + if [ "${{ matrix.runs_ios }}" = "true" ] && ! grep -q '"test:harness:ios"' example/package.json; then + echo "Missing iOS Harness script in example/package.json" + cat example/package.json + exit 1 + fi + - name: Test package.json content shell: bash run: | @@ -349,181 +345,13 @@ jobs: if-no-files-found: error retention-days: 7 - test-ios-build: - name: Test iOS Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) - needs: [generate-packages, define-matrix] - runs-on: macOS-latest - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.ios-build) }} - env: - WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Create working directory - run: mkdir -p ${{ env.WORKING_DIR }} - - - name: Download generated package - uses: actions/download-artifact@v8 - with: - name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} - path: ${{ env.WORKING_DIR }} - - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "Package structure:" - find . -type f -name "*.json" -o -name "*.js" -o -name "*.ts" | head -20 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 - - - name: Setup Ruby and CocoaPods - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - - - name: Setup Node.js - if: matrix.pm == 'yarn' - uses: actions/setup-node@v6 - with: - node-version: 22.x - - - name: Setup Yarn - if: matrix.pm == 'yarn' - uses: ./.github/actions/setup-yarn - with: - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Bun.js - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install package dependencies - uses: ./.github/actions/install-deps - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Run codegen and build - uses: ./.github/actions/run-codegen-build - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Cache CocoaPods - uses: actions/cache@v5 - with: - path: | - ~/.cocoapods/repos - ${{ env.WORKING_DIR }}/example/ios/Pods - key: ${{ runner.os }}-pods-${{ hashFiles(format('{0}/example/ios/Podfile.lock', env.WORKING_DIR)) }} - restore-keys: | - ${{ runner.os }}-pods- - - - name: Install CocoaPods dependencies - working-directory: ${{ env.WORKING_DIR }}/example - run: ${{ matrix.pm }} pod - - - name: Build iOS project - uses: ./.github/actions/ios-build-xcode - with: - ios-dir: ${{ env.WORKING_DIR }}/example/ios - mode: ${{ matrix.mode }} - - test-android-build: - name: Test Android Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) + harness-android: + name: Android Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} needs: [generate-packages, define-matrix] runs-on: ubuntu-latest strategy: fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.android-build) }} - env: - WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Create working directory - run: mkdir -p ${{ env.WORKING_DIR }} - - - name: Download generated package - uses: actions/download-artifact@v8 - with: - name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} - path: ${{ env.WORKING_DIR }} - - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "Package structure:" - find . -type f -name "*.json" -o -name "*.js" -o -name "*.ts" | head -20 - - - name: Setup Node.js - if: matrix.pm == 'yarn' - uses: actions/setup-node@v6 - with: - node-version: 22.x - - - name: Setup Yarn - if: matrix.pm == 'yarn' - uses: ./.github/actions/setup-yarn - with: - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Bun.js - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install package dependencies - uses: ./.github/actions/install-deps - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Run codegen and build - uses: ./.github/actions/run-codegen-build - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Java for Android builds - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: '17' - cache: 'gradle' - - - name: Cache Gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles(format('{0}/example/android/**/*.gradle*', env.WORKING_DIR)) }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Build Android project - uses: ./.github/actions/android-gradle-build - with: - android-dir: ${{ env.WORKING_DIR }}/example/android - mode: ${{ matrix.mode }} - - e2e-android: - name: Android E2E - ${{ matrix.package_type }} - ${{ matrix.scenario }} - needs: [test-android-build, define-matrix] - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.android-e2e) }} + matrix: ${{ fromJson(needs.define-matrix.outputs.android-harness) }} env: WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: @@ -546,10 +374,6 @@ jobs: name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: find . -type f | head -30 - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -585,10 +409,13 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Install Maestro CLI - uses: ./.github/actions/setup-maestro + - name: Build Android app + uses: ./.github/actions/android-gradle-build + with: + android-dir: ${{ env.WORKING_DIR }}/example/android + mode: ${{ matrix.mode }} - - name: Run Android Emulator and E2E Tests + - name: Run Android Emulator and Harness Tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 @@ -599,18 +426,17 @@ jobs: disable-animations: true script: | adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' - ${{ matrix.pm }} android:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package_type }} + cd "${{ env.WORKING_DIR }}/example" + ${{ matrix.pm }} run test:harness:android - e2e-ios: - name: iOS E2E - ${{ matrix.package_type }} - ${{ matrix.scenario }} - needs: [test-ios-build, define-matrix] + harness-ios: + name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} + needs: [generate-packages, define-matrix] runs-on: macOS-15 strategy: fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.ios-e2e) }} + matrix: ${{ fromJson(needs.define-matrix.outputs.ios-harness) }} env: - MAESTRO_DRIVER_STARTUP_TIMEOUT: 300_000 - MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: true WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository @@ -624,16 +450,12 @@ jobs: - name: Create working directory run: mkdir -p ${{ env.WORKING_DIR }} - - name: Download generated module + - name: Download generated package uses: actions/download-artifact@v8 with: name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: find . -type f | head -30 - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -647,7 +469,7 @@ jobs: restore-keys: | ${{ runner.os }}-bun- - - name: Install Dependencies + - name: Install dependencies uses: ./.github/actions/install-deps with: pm: ${{ matrix.pm }} @@ -671,44 +493,12 @@ jobs: working-directory: ${{ env.WORKING_DIR }}/example/ios run: pod install - - name: Setup ccache - run: | - brew install ccache - ccache --version - ccache --zero-stats - - - name: Cache ccache - uses: actions/cache@v5 + - name: Build iOS app + uses: ./.github/actions/ios-build-xcode with: - path: ~/Library/Caches/ccache - key: ${{ runner.os }}-ccache-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-ccache-${{ matrix.package_type }}-${{ matrix.scenario }}- - ${{ runner.os }}-ccache- - - - name: Configure ccache - run: | - ccache --set-config=max_size=2G - ccache --set-config=compression=true - ccache --set-config=compression_level=6 - - - name: Install Maestro CLI - uses: ./.github/actions/setup-maestro - - - name: Run tests - env: - USE_CCACHE: 1 - CCACHE_DIR: ~/Library/Caches/ccache - run: ${{ matrix.pm }} ios:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package_type }} - - - name: Print ccache statistics - if: always() - run: ccache --show-stats + ios-dir: ${{ env.WORKING_DIR }}/example/ios + mode: ${{ matrix.mode }} - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: maestro-artifacts-ios-${{ matrix.package_type }}-${{ matrix.scenario }} - path: e2e-artifacts - include-hidden-files: true + - name: Run iOS Harness Tests + working-directory: ${{ env.WORKING_DIR }}/example + run: ${{ matrix.pm }} run test:harness:ios diff --git a/README.md b/README.md index 4a248f79..3eb26e16 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A CLI tool that simplifies creating React Native modules powered by Nitro Module - ๐Ÿ“š TypeScript support out of the box - ๐Ÿ”ง Zero configuration required - โš™๏ธ Automated ios/android build with GitHub Actions +- ๐Ÿงช Optional React Native Harness setup for native Android and iOS tests - ๐Ÿ“ฆ Semantic Release ## ๐Ÿ“– Documentation diff --git a/assets/template/.github/workflows/ios-build.yml b/assets/template/.github/workflows/ios-build.yml index ddc8b3cd..c63c0e1f 100644 --- a/assets/template/.github/workflows/ios-build.yml +++ b/assets/template/.github/workflows/ios-build.yml @@ -94,6 +94,6 @@ jobs: -scheme $$exampleApp$$ \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ build \ CODE_SIGNING_ALLOWED=NO | xcpretty diff --git a/assets/template/gitignore b/assets/template/gitignore index 9d7c7e7a..a717acae 100644 --- a/assets/template/gitignore +++ b/assets/template/gitignore @@ -71,7 +71,8 @@ android/keystores/debug.keystore # Expo .expo/ +.harness # generated by bob lib/ -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo diff --git a/docs/docs/commands.md b/docs/docs/commands.md index 738a9573..a33ed563 100644 --- a/docs/docs/commands.md +++ b/docs/docs/commands.md @@ -25,6 +25,7 @@ Options: --platforms comma-separated platforms to target --langs comma-separated languages to generate -d, --module-dir directory to create the module in + --include-harness include React Native Harness setup in the example app -e, --skip-example skip example app generation -i, --skip-install skip installing dependencies --ci run in CI mode diff --git a/docs/docs/usage/create-a-nitro-module.md b/docs/docs/usage/create-a-nitro-module.md index 3b266845..6b64e30b 100644 --- a/docs/docs/usage/create-a-nitro-module.md +++ b/docs/docs/usage/create-a-nitro-module.md @@ -36,6 +36,35 @@ To create a Nitro Module along with an example app, use the following command. T +## With React Native Harness + +If you want the generated example app to include React Native Harness for native Android and iOS tests, pass the `--include-harness` flag. + + + + ```bash + bun create nitro-module@latest my-awesome-module --include-harness + ``` + + + ```bash + npx create-nitro-module@latest my-awesome-module --include-harness + ``` + + + ```bash + yarn create nitro-module@latest my-awesome-module --include-harness + ``` + + + ```bash + pnpm create nitro-module@latest my-awesome-module --include-harness + ``` + + + +The generated `example` app will include Harness config, sample native test files, package scripts, and a GitHub Actions workflow for the selected platforms. + ## Without example app If you prefer to create a Nitro Module without an example app, use the following command. This will generate only the module, without any additional example app. diff --git a/e2e-tests/module.e2e.yaml b/e2e-tests/module.e2e.yaml deleted file mode 100644 index 235eb3a0..00000000 --- a/e2e-tests/module.e2e.yaml +++ /dev/null @@ -1,4 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp -- assertVisible: '3' diff --git a/e2e-tests/view.e2e.yaml b/e2e-tests/view.e2e.yaml deleted file mode 100644 index 7c083a93..00000000 --- a/e2e-tests/view.e2e.yaml +++ /dev/null @@ -1,5 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp -- assertVisible: - id: ${MODULE_ID} diff --git a/eslint.config.js b/eslint.config.js index 39365f5b..3c38a45e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ export default defineConfig([ js.configs.recommended, nodePlugin.configs['flat/recommended-script'], { - files: ['src/*.ts'], + files: ['src/**/*.ts'], languageOptions: { parser: tsParser, parserOptions: { diff --git a/package.json b/package.json index 42e4239c..91e21a87 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,7 @@ "release": "bun run build && bun semantic-release", "lint": "eslint --fix ", "format": "prettier --write ", - "commitlint": "commitlint --edit", - "ios:e2e": "bash scripts/e2e-maestro.sh ios", - "android:e2e": "bash scripts/e2e-maestro.sh android" + "commitlint": "commitlint --edit" }, "files": [ "lib", diff --git a/scripts/e2e-maestro.sh b/scripts/e2e-maestro.sh deleted file mode 100755 index 0fa39461..00000000 --- a/scripts/e2e-maestro.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/bin/bash - -trap 'exit' INT - -# Save the script directory (project root) -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" - -PLATFORM=${1:-} -EXAMPLE_DIR=${2:-} -PACKAGE_TYPE=${3:-} - -echo "๐Ÿš€ Running e2e video recording for $PLATFORM" -echo "๐Ÿ“‚ Project root: $SCRIPT_DIR" - -# Validate passed platform -case $PLATFORM in - ios | android ) - ;; - - *) - echo "Error! You must pass either 'android' or 'ios'" - echo "" - exit 1 - ;; -esac - - -APP_ID="" -SCHEME="" - -if [ "$PACKAGE_TYPE" != "module" ] && [ "$PACKAGE_TYPE" != "view" ]; then - echo "Error! You must pass either 'module' or 'view'" - echo "" - exit 1 -fi - -PACKAGE_ROOT_NAME="$(basename "$(cd "$EXAMPLE_DIR/.." && pwd)")" -PACKAGE_NAME="${PACKAGE_ROOT_NAME#react-native-}" -APP_ID="com.${PACKAGE_NAME//-/}example" - -to_pascal_case() { - local input="$1" - local result="" - local word="" - - input="${input//-/ }" - input="${input//_/ }" - - for word in $input; do - local lower_word="${word,,}" - result+="${lower_word^}" - done - - printf '%s' "$result" -} - -SCHEME="$(to_pascal_case "$PACKAGE_NAME")Example" - -if [ "$PLATFORM" == "ios" ]; then - cd "$EXAMPLE_DIR/ios" - - WORKSPACE_NAME="$(find . -maxdepth 1 -name "*.xcworkspace" ! -name "Pods.xcworkspace" -exec basename {} .xcworkspace \; | head -1)" - if [ -n "$WORKSPACE_NAME" ]; then - SCHEME="$WORKSPACE_NAME" - fi - - # Get iPhone 16 simulator ID dynamically - iphone16Id=$(xcrun simctl list devices | grep "iPhone 16 (" | grep -E '\(Booted\)|\(Shutdown\)' | head -1 | grep -E -o '\([0-9A-F-]{36}\)' | tr -d '()') - echo "๐Ÿ“ฑ Using iPhone 16 simulator with ID: $iphone16Id" - - # Build the app with optimizations and pretty output - export USE_CCACHE=1 - # Configure ccache if available (optional optimization) - if command -v ccache >/dev/null 2>&1; then - export CCACHE_DIR="${CCACHE_DIR:-$HOME/Library/Caches/ccache}" - mkdir -p "$CCACHE_DIR" - export PATH="/opt/homebrew/bin:$PATH" - echo "โœ… ccache is available" - echo "๐Ÿ“ฆ ccache directory: $CCACHE_DIR" - ccache --max-size=2G 2>/dev/null || true - else - echo "โš ๏ธ ccache not found (optional). Install with: brew install ccache" - fi - - buildCmd="xcodebuild \ - -workspace $SCHEME.xcworkspace \ - -scheme $SCHEME \ - -configuration Release \ - -destination id=$iphone16Id \ - -derivedDataPath build \ - -jobs $(sysctl -n hw.ncpu) \ - ONLY_ACTIVE_ARCH=YES \ - ARCHS=arm64 \ - VALID_ARCHS=arm64 \ - EXCLUDED_ARCHS=x86_64 \ - CODE_SIGNING_ALLOWED=NO" - - echo "๐Ÿ”จ Building iOS app..." - echo $buildCmd - # Check if xcpretty is available - if command -v xcpretty >/dev/null 2>&1; then - set -o pipefail && $buildCmd | xcpretty - if [ $? -ne 0 ]; then - echo "โŒ iOS build failed!" - exit 1 - fi - else - echo "โš ๏ธ xcpretty not found. Install with: gem install xcpretty" - $buildCmd - if [ $? -ne 0 ]; then - echo "โŒ iOS build failed!" - exit 1 - fi - fi - - # Launch the simulator if not already booted - if ! xcrun simctl list devices | grep "$iphone16Id" | grep -q "Booted"; then - echo "๐Ÿš€ Booting simulator..." - xcrun simctl boot $iphone16Id - else - echo "โœ… Simulator already booted" - fi - # Wait for 10 seconds - sleep 10 - - # Find and install the built app - APP_PATH=$(find build/Build/Products/Release-iphonesimulator -name "*.app" | head -1) - echo "๐Ÿ“ฒ Installing app from: $APP_PATH" - xcrun simctl install $iphone16Id "$APP_PATH" - - # Return to project root - cd "$SCRIPT_DIR" -else - cd "$EXAMPLE_DIR/android" - chmod +x ./gradlew - - # Build with optimizations and pretty output - echo "๐Ÿ”จ Building Android app..." - ./gradlew assembleRelease --no-daemon --build-cache --parallel --console=rich - if [ $? -ne 0 ]; then - echo "โŒ Android build failed!" - exit 1 - fi - APK_PATH="app/build/outputs/apk/release/app-release.apk" - - # Install the APK - echo "๐Ÿ“ฒ Installing APK: $APK_PATH" - adb install -r $APK_PATH - - # Stop Gradle daemon to free up memory - echo "๐Ÿงน Stopping Gradle daemon..." - ./gradlew --stop - - # Return to project root - cd "$SCRIPT_DIR" -fi - -echo "๐Ÿ“‚ Script directory: $(pwd)" -echo "" - -test_file="e2e-tests/$PACKAGE_TYPE.e2e.yaml" - -echo "๐ŸŽฌ Using flow file for recording: $test_file" - -if [ ! -f "$test_file" ]; then - echo "โŒ Error! Flow file not found: $test_file" - echo "" - exit 1 -fi - -# Create output directory for videos -mkdir -p e2e-artifacts - -recordCmd="maestro record \"$test_file\" -e APP_ID=$APP_ID -e MODULE_ID=$PACKAGE_NAME --local" -echo "๐ŸŽฏ Recording test video: $recordCmd" -echo "๐Ÿ“ฑ APP_ID: $APP_ID" -echo "๐Ÿ†” MODULE_ID: $PACKAGE_NAME" - - -if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE"; then - echo "Recording ${test_file} failed. Retrying in 30 seconds..." - sleep 30 - if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE-retry-1"; then - echo "Recording ${test_file} failed again. Retrying for the last time in 120 seconds..." - sleep 120 - if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE-retry-2"; then - echo "Recording ${test_file} failed again. Exiting..." - exit 1 - fi - fi -fi diff --git a/src/cli/create.ts b/src/cli/create.ts index 7a45302d..27a4e325 100644 --- a/src/cli/create.ts +++ b/src/cli/create.ts @@ -170,6 +170,22 @@ const parsePlatformLangsOption = ( return getPlatformLangMap(platforms, langs) } +const getFinalPackageName = (packageName: string) => + `react-native-${packageName.toLowerCase()}` + +const getTargetModulePath = (moduleBaseDir: string, packageName: string) => + path.resolve(moduleBaseDir, getFinalPackageName(packageName)) + +const getInstructionsModulePath = (modulePath: string) => { + const relativePath = path.relative(process.cwd(), modulePath) + + if (relativePath.length === 0 || relativePath.startsWith('..')) { + return modulePath + } + + return relativePath +} + export const createModule = async ( packageName: string, options: CreateModuleOptions @@ -177,15 +193,24 @@ export const createModule = async ( let packageType = Nitro.Module let moduleFactory: NitroModuleFactory | null = null let spinnerStarted = false + let shouldCleanupModulePath = false + let targetModulePath: string | null = null const spinner = p.spinner() try { if (options.moduleDir) { - const moduleDirExists = await dirExist(options.moduleDir) + const moduleDirPath = path.resolve(options.moduleDir) + const moduleDirExists = await dirExist(moduleDirPath) if (!moduleDirExists) { - mkdirSync(options.moduleDir, { recursive: true }) + mkdirSync(moduleDirPath, { recursive: true }) } } + if (options.skipExample && options.includeHarness) { + throw new Error( + 'React Native Harness requires the generated example app. Remove --skip-example or omit --include-harness.' + ) + } + if ( options.packageType && ![Nitro.Module, Nitro.View].includes(options.packageType) @@ -202,6 +227,12 @@ export const createModule = async ( const answers = await getUserAnswers(packageName, usedPm, options) packageName = answers.packageName packageType = answers.packageType + const finalPackageName = getFinalPackageName(packageName) + const moduleBaseDir = + options.moduleDir != null + ? path.resolve(options.moduleDir) + : process.cwd() + targetModulePath = getTargetModulePath(moduleBaseDir, packageName) moduleFactory = new NitroModuleFactory({ description: answers.description, @@ -209,21 +240,25 @@ export const createModule = async ( packageName, platforms: answers.platforms, pm: answers.pm, - cwd: options.moduleDir || process.cwd(), + cwd: targetModulePath, spinner, packageType, - finalPackageName: 'react-native-' + packageName.toLowerCase(), + finalPackageName, + includeHarness: answers.includeHarness, + monorepo: answers.monorepo, skipInstall: options.skipInstall, skipExample: options.skipExample, }) - const modulePath = path.join( - process.cwd(), - `react-native-${packageName.toLowerCase()}` - ) - const dirExists = await dirExist(modulePath) + const dirExists = await dirExist(targetModulePath) if (dirExists) { + if (options.ci) { + throw new Error( + `Target directory already exists: ${targetModulePath}. Remove it or choose a different module name or --module-dir.` + ) + } + const confirm = await p.confirm({ message: 'Looks like the directory with the same name already exists.' + @@ -238,11 +273,14 @@ export const createModule = async ( if (p.isCancel(confirm)) { process.exit(1) } else if (confirm) { - rmSync(modulePath, { recursive: true, force: true }) + rmSync(targetModulePath, { recursive: true, force: true }) + shouldCleanupModulePath = true } else { console.log(kleur.red('Cancelled')) process.exit(1) } + } else { + shouldCleanupModulePath = true } spinner.start( @@ -254,8 +292,20 @@ export const createModule = async ( console.log( generateInstructions({ - moduleName: `react-native-${packageName.toLowerCase()}`, + includeHarness: answers.includeHarness, + monorepo: answers.monorepo, + modulePath: getInstructionsModulePath(targetModulePath), + packagePath: getInstructionsModulePath( + answers.monorepo + ? path.join( + targetModulePath, + 'packages', + finalPackageName + ) + : targetModulePath + ), pm: answers.pm, + platforms: answers.platforms, skipExample: options.skipExample, skipInstall: options.skipInstall, }) @@ -267,12 +317,8 @@ export const createModule = async ( ) ) } catch (error) { - if (packageName) { - const modulePath = path.join( - process.cwd(), - `react-native-${packageName.toLowerCase()}` - ) - rmSync(modulePath, { recursive: true, force: true }) + if (shouldCleanupModulePath && targetModulePath != null) { + rmSync(targetModulePath, { recursive: true, force: true }) } if (spinnerStarted) { spinner.stop( @@ -350,6 +396,8 @@ const getUserAnswers = async ( description: `${kleur.yellow(`react-native-${name}`)} is a react native package built with Nitro`, platforms, packageType, + monorepo: options.monorepo === true, + includeHarness: options.includeHarness === true, platformLangs: parsePlatformLangsOption( options.langs, platforms, @@ -429,6 +477,20 @@ const getUserAnswers = async ( initialValue: Nitro.Module, }) }, + monorepo: async () => { + if (options?.monorepo === true) { + return true + } + + return p.confirm({ + message: kleur.cyan( + 'Use a packages/ workspace layout for a monorepo?' + ), + initialValue: false, + active: 'yes', + inactive: 'no', + }) + }, platformLangs: async ({ results }) => { if (!results.platforms || !results.packageType) { throw new Error('Missing required selections') @@ -479,6 +541,22 @@ const getUserAnswers = async ( ], }) }, + includeHarness: async () => { + if (options?.skipExample) { + return false + } + + if (options?.includeHarness === true) { + return true + } + + return p.confirm({ + message: kleur.cyan( + 'Include React Native Harness for native Android and iOS tests?' + ), + initialValue: false, + }) + }, packageNameConfirmation: async ({ results }) => { const packageName = results.packageName if (!packageName) { @@ -509,8 +587,10 @@ const getUserAnswers = async ( return { packageName: group.packageName, packageType: group.packageType, + monorepo: group.monorepo as boolean, platforms: group.platforms, platformLangs: group.platformLangs as PlatformLangMap, + includeHarness: group.includeHarness as boolean, pm: group.pm, description: group.description as string, } diff --git a/src/cli/index.ts b/src/cli/index.ts index 6afdb16a..958fa116 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,6 +17,14 @@ program '-d, --module-dir ', 'directory to create the module in' ) + .option( + '--include-harness', + 'include React Native Harness setup in the example app' + ) + .option( + '--monorepo', + 'create a monorepo workspace with the package inside packages/' + ) .option('-e, --skip-example', 'skip example app generation') .option('-i, --skip-install', 'skip installing dependencies') .option('--ci', 'run in CI mode') diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index bbc3e4fc..3861b4e6 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -1,4 +1,5 @@ import { toPascalCase } from '../utils' +import { Nitro, type PackageManager, SupportedPlatform } from '../types' export const appExampleCode = ( moduleName: string, @@ -154,6 +155,320 @@ export const exampleTsConfig = (finalModuleName: string) => `{ } }` +type HarnessConfigParams = { + androidBundleId: string | null + appRegistryComponentName: string + defaultRunner: SupportedPlatform + entryPoint: string + iosBundleId: string | null +} + +const getHarnessRunnerConfig = ( + platform: SupportedPlatform, + androidBundleId: string | null, + iosBundleId: string | null +): string => { + if (platform === SupportedPlatform.ANDROID) { + if (androidBundleId == null) { + throw new Error('Android bundle id is required for Harness config') + } + + return `androidPlatform({ + name: 'android', + device: androidEmulator('Pixel_8_API_35'), + bundleId: '${androidBundleId}', + })` + } + + if (iosBundleId == null) { + throw new Error('iOS bundle id is required for Harness config') + } + + return `applePlatform({ + name: 'ios', + device: appleSimulator('iPhone 16', '18.0'), + bundleId: '${iosBundleId}', + })` +} + +export const harnessConfigCode = ({ + androidBundleId, + appRegistryComponentName, + defaultRunner, + entryPoint, + iosBundleId, +}: HarnessConfigParams): string => { + const imports = [ + ...(androidBundleId == null + ? [] + : [ + "import { androidEmulator, androidPlatform } from '@react-native-harness/platform-android'", + ]), + ...(iosBundleId == null + ? [] + : [ + "import { applePlatform, appleSimulator } from '@react-native-harness/platform-apple'", + ]), + ].join('\n') + const runners = [ + ...(androidBundleId == null + ? [] + : [ + getHarnessRunnerConfig( + SupportedPlatform.ANDROID, + androidBundleId, + iosBundleId + ), + ]), + ...(iosBundleId == null + ? [] + : [ + getHarnessRunnerConfig( + SupportedPlatform.IOS, + androidBundleId, + iosBundleId + ), + ]), + ].join(',\n ') + + return `${imports} + +const config = { + entryPoint: '${entryPoint}', + appRegistryComponentName: '${appRegistryComponentName}', + runners: [ + ${runners} + ], + defaultRunner: '${defaultRunner}', + bridgeTimeout: 300000, +} + +export default config +` +} + +export const harnessJestConfigCode = () => `module.exports = { + projects: [ + { + displayName: 'react-native-harness', + preset: 'react-native-harness', + testMatch: [ + '/__tests__/**/*.(test|spec|harness).(js|jsx|ts|tsx)', + ], + }, + ], +} +` + +export const harnessTestCode = ( + moduleName: string, + finalModuleName: string, + funcName: string, + packageType: Nitro +) => { + if (packageType === Nitro.Module) { + return `import { describe, it, expect } from 'react-native-harness' +import { ${toPascalCase(moduleName)} } from '${finalModuleName}' + +describe('${toPascalCase(moduleName)}', () => { + it('calls the native implementation', () => { + expect(${toPascalCase(moduleName)}.${funcName}(1, 2)).toBe(3) + }) +}) +` + } + + return `import React from 'react' +import { StyleSheet } from 'react-native' +import { describe, it, expect, render } from 'react-native-harness' +import { screen } from '@react-native-harness/ui' +import { ${toPascalCase(moduleName)} } from '${finalModuleName}' + +describe('${toPascalCase(moduleName)}', () => { + it('renders the native view', async () => { + await render( + <${toPascalCase(moduleName)} + isRed={true} + style={styles.view} + testID="${moduleName}" + /> + ) + + const view = await screen.findByTestId('${moduleName}') + + expect(view.nativeId).toBeDefined() + }) +}) + +const styles = StyleSheet.create({ + view: { + width: 200, + height: 200, + }, +}) +` +} + +const getPackageManagerRunCommand = ( + packageManager: PackageManager, + scriptName: string +) => { + if (packageManager === 'yarn') { + return `yarn ${scriptName}` + } + + return `${packageManager} run ${scriptName}` +} + +const getPackageManagerSetupStep = (packageManager: PackageManager) => { + if (packageManager !== 'bun') { + return '' + } + + return ` - uses: oven-sh/setup-bun@v2 +` +} + +const getHarnessJobCode = ( + exampleAppName: string, + packageManager: PackageManager, + platform: SupportedPlatform +) => { + if (platform === SupportedPlatform.ANDROID) { + return ` test: + name: Test Android Harness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' +${getPackageManagerSetupStep(packageManager)} + + - name: Install dependencies + run: ${packageManager} install + + - name: Setup JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Build Android app + working-directory: example/android + run: ./gradlew assembleDebug --no-daemon --build-cache + + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.0.0 + with: + app: example/android/app/build/outputs/apk/debug/app-debug.apk + runner: android + projectRoot: example + packageManager: ${packageManager}` + } + + return ` test: + name: Test iOS Harness + runs-on: macOS-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' +${getPackageManagerSetupStep(packageManager)} + + - name: Install dependencies + run: ${packageManager} install + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.4 + + - name: Install Pods + working-directory: example + run: ${getPackageManagerRunCommand(packageManager, 'pod')} + + - name: Build iOS app + working-directory: example/ios + run: | + set -o pipefail && xcodebuild \ + CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ + -derivedDataPath build -UseModernBuildSystem=YES \ + -workspace ${exampleAppName}.xcworkspace \ + -scheme ${exampleAppName} \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + build \ + CODE_SIGNING_ALLOWED=NO + + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.0.0 + with: + app: example/ios/build/Build/Products/Debug-iphonesimulator/${exampleAppName}.app + runner: ios + projectRoot: example + packageManager: ${packageManager}` +} + +export const harnessWorkflowCode = ( + exampleAppName: string, + packageManager: PackageManager, + platform: SupportedPlatform +) => `name: Run React Native Harness ${platform === SupportedPlatform.ANDROID ? 'Android' : 'iOS'} + +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - '.github/workflows/harness-${platform}.yml' + - 'example/**' + - 'android/**' + - 'ios/**' + - 'cpp/**' + - 'src/**' + - 'nitrogen/**' + - '*.podspec' + - 'package.json' + - 'bun.lock' + - 'pnpm-lock.yaml' + - 'package-lock.json' + - 'yarn.lock' + - 'react-native.config.js' + - 'nitro.json' + pull_request: + paths: + - '.github/workflows/harness-${platform}.yml' + - 'example/**' + - 'android/**' + - 'ios/**' + - 'cpp/**' + - 'src/**' + - 'nitrogen/**' + - '*.podspec' + - 'package.json' + - 'bun.lock' + - 'pnpm-lock.yaml' + - 'package-lock.json' + - 'yarn.lock' + - 'react-native.config.js' + - 'nitro.json' + workflow_dispatch: + +concurrency: + group: \${{ github.workflow }}-\${{ github.ref }} + cancel-in-progress: true + +jobs: +${getHarnessJobCode(exampleAppName, packageManager, platform)} +` + export const postScript = (moduleName: string, isHybridView: boolean) => `/** * @file This script is auto-generated by create-nitro-module and should not be edited. * @@ -212,7 +527,7 @@ const androidWorkaround = async () => { ) if (res.some((r) => r.status === 'rejected')) { - throw new Error(\`Error updating view manager files: \$\{res\}\`) + throw new Error('Error updating view manager files: ' + JSON.stringify(res)) } ` : '' diff --git a/src/constants.ts b/src/constants.ts index 2c456958..e71c4d76 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,5 @@ import kleur from 'kleur' -import type { InstructionsParams } from './types' +import { SupportedPlatform, type InstructionsParams } from './types' export const SUPPORTED_PLATFORMS = ['ios', 'android'] @@ -47,8 +47,12 @@ export const NITRO_GRAPHIC = ` โ””โ”€โ”˜` export const generateInstructions = ({ - moduleName, + includeHarness, + monorepo, + modulePath, + packagePath, pm, + platforms, skipInstall, skipExample, }: InstructionsParams) => ` @@ -56,7 +60,7 @@ ${kleur.cyan().bold(NITRO_GRAPHIC)} ${kleur.red().bold('Next steps:')} -${kleur.green(`cd ${moduleName}`)} +${kleur.green(`cd ${modulePath}`)} ${ !skipInstall ? '' @@ -68,27 +72,36 @@ ${ Begin development: ${kleur.cyan('Define your module:')} - ${kleur.white('src/specs/')} ${kleur.dim('# Define your module specifications. e.g. src/specs/myModule.nitro.ts')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}src/specs/`)} ${kleur.dim('# Define your module specifications. e.g. src/specs/myModule.nitro.ts')} ${kleur.green(`${pm} run codegen`)} ${kleur.dim('# Generates native interfaces from TypeScript definitions')} ${kleur.cyan('Implement native code:')} - ${kleur.white('ios/')} ${kleur.dim('# iOS native implementation using swift')} - ${kleur.white('android/')} ${kleur.dim('# Android native implementation using kotlin')} - ${kleur.white('cpp/')} ${kleur.dim('# C++ native implementation. Shareable between iOS and Android (Will be generated if c++ was selected)')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}ios/`)} ${kleur.dim('# iOS native implementation using swift')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}android/`)} ${kleur.dim('# Android native implementation using kotlin')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}cpp/`)} ${kleur.dim('# C++ native implementation. Shareable between iOS and Android (Will be generated if c++ was selected)')} ${ skipExample ? '' : `Run your example app to test the package: - ${kleur.green('cd example')} + ${kleur.green(`cd ${monorepo ? `${packagePath}/example` : 'example'}`)} ${kleur.green(`${pm} run pod`)} ${kleur.dim('# Install CocoaPods dependencies (iOS)')} ${kleur.green(`${pm} run ios|android`)} ${kleur.dim('# Run your example app')}` } +${ + skipExample || !includeHarness + ? '' + : `\n\nRun your React Native Harness tests: + + ${kleur.green(`cd ${monorepo ? `${packagePath}/example` : 'example'}`)} + ${kleur.green(`${pm} run test:harness`)} ${kleur.dim(`# Run native tests with the ${platforms.includes(SupportedPlatform.ANDROID) ? SupportedPlatform.ANDROID : SupportedPlatform.IOS} runner`)}` +} + ${kleur.yellow('Pro Tips:')} -${kleur.dim('โ€ข iOS:')} Open ${kleur.green('example/ios/example.xcworkspace')} in Xcode for native debugging. Make sure to run ${kleur.green(`${pm} pod`)} first in the example directory -${kleur.dim('โ€ข Android:')} Open ${kleur.green('example/android')} in Android Studio +${kleur.dim('โ€ข iOS:')} Open ${kleur.green(`${monorepo ? `${packagePath}/` : ''}example/ios/example.xcworkspace`)} in Xcode for native debugging. Make sure to run ${kleur.green(`${pm} pod`)} first in the example directory +${kleur.dim('โ€ข Android:')} Open ${kleur.green(`${monorepo ? `${packagePath}/` : ''}example/android`)} in Android Studio ${kleur.dim('โ€ข Metro:')} Clear cache with ${kleur.green(`${pm} start`)} if needed ${kleur.yellow('Need help?')} Create an issue: ${kleur.blue().underline('https://github.com/patrickkabwe/create-nitro-module/issues')} diff --git a/src/file-generators/cpp-file-generator.ts b/src/file-generators/cpp-file-generator.ts index d026c8bd..563e6b67 100644 --- a/src/file-generators/cpp-file-generator.ts +++ b/src/file-generators/cpp-file-generator.ts @@ -14,7 +14,6 @@ export class CppFileGenerator implements FileGenerator { constructor(private fileGenerators: FileGenerator[]) {} async generate(config: GenerateModuleConfig): Promise { - await createFolder(config.cwd, 'cpp') await this.generateCppCodeFiles(config) for (const generator of this.fileGenerators) { diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 175d52b4..599aef77 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -10,6 +10,10 @@ import { appExampleCode, babelConfig, exampleTsConfig, + harnessConfigCode, + harnessJestConfigCode, + harnessTestCode, + harnessWorkflowCode, metroConfig, } from './code-snippets/code.js' import { @@ -49,9 +53,17 @@ import { const execAsync = util.promisify(exec) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +type NitroDependencyName = 'nitrogen' | 'react-native-nitro-modules' +type PackageJson = Record & { + name?: string + private?: boolean + scripts?: Record + workspaces?: string[] +} export class NitroModuleFactory { private nitroModulesVersion: string | null = null + private workspaceRoot: string private androidGenerator: AndroidFileGenerator private iosGenerator: IOSFileGenerator @@ -69,10 +81,15 @@ export class NitroModuleFactory { this.config.funcName = 'sum' this.config.prefix = 'react-native-' this.config.finalPackageName = `${this.config.prefix}${this.config.packageName}` - this.config.cwd = path.join( - this.config.cwd, - this.config.finalPackageName - ) + this.workspaceRoot = this.config.cwd + const packageDir = this.config.monorepo + ? path.join( + this.config.cwd, + 'packages', + this.config.finalPackageName + ) + : this.config.cwd + this.config.cwd = packageDir } async createNitroModule() { @@ -110,6 +127,12 @@ export class NitroModuleFactory { await this.copyNitroTemplateFiles() await this.replaceNitroJsonPlaceholders() await this.updatePackageJsonConfig(this.config.skipExample) + if (this.config.monorepo) { + await this.createWorkspaceRoot() + if (this.config.pm === 'yarn') { + await this.configureYarnWorkspace() + } + } await this.updateTemplateFiles() if (!this.config.skipExample) { @@ -117,6 +140,9 @@ export class NitroModuleFactory { await this.createExampleApp() await this.configureExamplePackageJson() await this.syncExampleAppConfigurations() + if (this.config.includeHarness) { + await this.setupReactNativeHarness() + } await this.setupWorkflows() await this.gitInit() this.config.spinner.stop(kleur.cyan(messages.generating + 'Done')) @@ -128,6 +154,47 @@ export class NitroModuleFactory { } } + private getTemplateDevDependencyVersion(pkg: NitroDependencyName): string { + const version = templatePackageJson.devDependencies[pkg] + + if (typeof version !== 'string' || version.length === 0) { + throw new Error( + `Missing template devDependency version for package "${pkg}"` + ) + } + + return version + } + + private async resolveNitroDependencyVersions(): Promise<{ + nitrogenVersion: string + nitroModulesVersion: string + }> { + const defaultNitrogenVersion = + this.getTemplateDevDependencyVersion('nitrogen') + const defaultNitroModulesVersion = this.getTemplateDevDependencyVersion( + 'react-native-nitro-modules' + ) + + if (this.config.skipInstall) { + return { + nitrogenVersion: defaultNitrogenVersion, + nitroModulesVersion: defaultNitroModulesVersion, + } + } + + const [nitroModulesVersion, nitrogenVersion] = await Promise.all([ + this.getLatestVersion('react-native-nitro-modules'), + this.getLatestVersion('nitrogen'), + ]) + + return { + nitrogenVersion: nitrogenVersion ?? defaultNitrogenVersion, + nitroModulesVersion: + nitroModulesVersion ?? defaultNitroModulesVersion, + } + } + private async getLatestVersion(pkg: string): Promise { try { const { stdout } = await execAsync(`npm view ${pkg} version`) @@ -144,6 +211,70 @@ export class NitroModuleFactory { } } + private getWorkspaceRunCommand(scriptName: string): string { + const packageWorkspacePath = `packages/${this.config.finalPackageName}` + + if (this.config.pm === 'yarn') { + return `yarn --cwd ${packageWorkspacePath} ${scriptName}` + } + + if (this.config.pm === 'pnpm') { + return `pnpm --dir ${packageWorkspacePath} run ${scriptName}` + } + + if (this.config.pm === 'npm') { + return `npm --prefix ${packageWorkspacePath} run ${scriptName}` + } + + return `bun --cwd ${packageWorkspacePath} run ${scriptName}` + } + + private async createWorkspaceRoot(): Promise { + const workspaces = this.config.skipExample + ? ['packages/*'] + : ['packages/*', 'packages/*/example'] + const rootPackageJson: PackageJson = { + name: `${this.config.finalPackageName}-monorepo`, + private: true, + scripts: { + build: this.getWorkspaceRunCommand('build'), + codegen: this.getWorkspaceRunCommand('codegen'), + }, + workspaces, + } + + await writeFile( + path.join(this.workspaceRoot, 'package.json'), + JSON.stringify(rootPackageJson, null, 2), + { encoding: 'utf8' } + ) + + if (this.config.pm !== 'pnpm') { + return + } + + await writeFile( + path.join(this.workspaceRoot, 'pnpm-workspace.yaml'), + `packages:\n${workspaces.map(workspace => ` - ${workspace}`).join('\n')}\n`, + { encoding: 'utf8' } + ) + } + + private async configureYarnWorkspace(): Promise { + const yarnCwd = this.config.monorepo + ? this.workspaceRoot + : this.config.cwd + await execAsync('corepack enable', { cwd: yarnCwd }) + await execAsync('yarn set version 4.6.0', { cwd: yarnCwd }) + await execAsync('yarn config set enableImmutableInstalls false', { + cwd: yarnCwd, + }) + await execAsync('yarn config set nodeLinker node-modules', { + cwd: yarnCwd, + }) + await execAsync('corepack disable', { cwd: yarnCwd }) + } + private async replaceNitroJsonPlaceholders() { const nitroJsonContent = await readFile( path.join(this.config.cwd, 'nitro.json'), @@ -210,24 +341,19 @@ export class NitroModuleFactory { : undefined, } - // Resolve and pin latest Nitro tools to concrete versions const nitrogen = 'nitrogen' const nitroModules = 'react-native-nitro-modules' - const [nitroModulesVersion, nitrogenVersion] = await Promise.all([ - this.getLatestVersion(nitroModules), - this.getLatestVersion(nitrogen), - ]) + const { nitrogenVersion, nitroModulesVersion } = + await this.resolveNitroDependencyVersions() this.nitroModulesVersion = nitroModulesVersion newWorkspacePackageJsonFile.devDependencies = { ...newWorkspacePackageJsonFile.devDependencies, [nitroModules]: nitroModulesVersion ?? - newWorkspacePackageJsonFile.devDependencies?.[nitroModules] ?? - templatePackageJson.devDependencies[nitroModules], + newWorkspacePackageJsonFile.devDependencies?.[nitroModules], [nitrogen]: nitrogenVersion ?? - newWorkspacePackageJsonFile.devDependencies?.[nitrogen] ?? - templatePackageJson.devDependencies[nitrogen], + newWorkspacePackageJsonFile.devDependencies?.[nitrogen], } newWorkspacePackageJsonFile.keywords = [ @@ -236,35 +362,34 @@ export class NitroModuleFactory { ] if (this.config.pm === 'yarn') { - await execAsync('corepack enable', { cwd: this.config.cwd }) - await execAsync('yarn set version 4.6.0', { cwd: this.config.cwd }) - await execAsync('yarn config set enableImmutableInstalls false', { - cwd: this.config.cwd, - }) - await execAsync('yarn config set nodeLinker node-modules', { - cwd: this.config.cwd, - }) - await execAsync('corepack disable', { cwd: this.config.cwd }) + if (!this.config.monorepo) { + await this.configureYarnWorkspace() + } } else if (this.config.pm === 'pnpm') { const workspaceDirs = ['example'] const yamlContent = `packages:\n${workspaceDirs.map(d => ` - ${d}`).join('\n')}\n` - const WORKSPACE_FILENAME = 'pnpm-workspace.yaml' - await writeFile( - path.join(this.config.cwd, WORKSPACE_FILENAME), - yamlContent, - { encoding: 'utf8' } - ) + if (!this.config.monorepo) { + const WORKSPACE_FILENAME = 'pnpm-workspace.yaml' + await writeFile( + path.join(this.config.cwd, WORKSPACE_FILENAME), + yamlContent, + { encoding: 'utf8' } + ) + } const NPMRC_FILENAME = '.npmrc' await writeFile( - path.join(this.config.cwd, NPMRC_FILENAME), + path.join( + this.config.monorepo ? this.workspaceRoot : this.config.cwd, + NPMRC_FILENAME + ), 'node-linker=hoisted', { encoding: 'utf8' } ) delete newWorkspacePackageJsonFile.workspaces } - if (skipExample) { + if (skipExample || this.config.monorepo) { delete newWorkspacePackageJsonFile.workspaces } await writeFile( @@ -330,8 +455,23 @@ export class NitroModuleFactory { [__dirname, '..', 'assets', 'template'], filesToCopy ) + if (this.config.monorepo) { + await rename( + path.join(this.config.cwd, '.github'), + path.join(this.workspaceRoot, '.github') + ) + if (this.config.pm === 'bun') { + await rename( + path.join(this.config.cwd, 'bunfig.toml'), + path.join(this.workspaceRoot, 'bunfig.toml') + ) + } + } const oldGitIgnorePath = path.join(this.config.cwd, 'gitignore') - const newGitIgnorePath = path.join(this.config.cwd, '.gitignore') + const newGitIgnorePath = path.join( + this.config.monorepo ? this.workspaceRoot : this.config.cwd, + '.gitignore' + ) await rename(oldGitIgnorePath, newGitIgnorePath) } @@ -398,6 +538,59 @@ export class NitroModuleFactory { 'babel-plugin-module-resolver': '^5.0.2', } + if (this.config.includeHarness) { + const [ + reactNativeHarnessVersion, + androidHarnessVersion, + appleHarnessVersion, + uiHarnessVersion, + ] = await Promise.all([ + this.getLatestVersion('react-native-harness'), + this.getLatestVersion('@react-native-harness/platform-android'), + this.getLatestVersion('@react-native-harness/platform-apple'), + this.config.packageType === Nitro.View + ? this.getLatestVersion('@react-native-harness/ui') + : Promise.resolve(null), + ]) + + exampleAppPackageJson.scripts = { + ...exampleAppPackageJson.scripts, + 'test:harness': 'react-native-harness', + } + + exampleAppPackageJson.devDependencies = { + ...exampleAppPackageJson.devDependencies, + 'react-native-harness': + reactNativeHarnessVersion != null + ? `^${reactNativeHarnessVersion}` + : '^1.0.0', + ...(this.config.platforms.includes(SupportedPlatform.ANDROID) + ? { + '@react-native-harness/platform-android': + androidHarnessVersion != null + ? `^${androidHarnessVersion}` + : '^1.0.0', + } + : {}), + ...(this.config.platforms.includes(SupportedPlatform.IOS) + ? { + '@react-native-harness/platform-apple': + appleHarnessVersion != null + ? `^${appleHarnessVersion}` + : '^1.0.0', + } + : {}), + ...(this.config.packageType === Nitro.View + ? { + '@react-native-harness/ui': + uiHarnessVersion != null + ? `^${uiHarnessVersion}` + : '^1.0.0', + } + : {}), + } + } + packagesToRemoveFromExampleApp.forEach(pkg => { delete exampleAppPackageJson.devDependencies[pkg] }) @@ -523,8 +716,92 @@ export class NitroModuleFactory { } } + private async getExampleIOSBundleId() { + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const projectFilePath = path.join( + this.config.cwd, + 'example', + 'ios', + `${exampleAppName}.xcodeproj`, + 'project.pbxproj' + ) + const projectFileContent = await readFile(projectFilePath, { + encoding: 'utf8', + }) + const bundleIdMatch = projectFileContent.match( + /PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/ + ) + + if (bundleIdMatch == null) { + throw new Error( + `Failed to resolve iOS bundle identifier for React Native Harness from ${projectFilePath}` + ) + } + + return bundleIdMatch[1].trim().replaceAll('"', '') + } + + private async setupReactNativeHarness() { + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const androidBundleId = this.config.platforms.includes( + SupportedPlatform.ANDROID + ) + ? `com.${replaceHyphen(this.config.packageName)}example` + : null + const iosBundleId = this.config.platforms.includes( + SupportedPlatform.IOS + ) + ? await this.getExampleIOSBundleId() + : null + const defaultRunner = this.config.platforms.includes( + SupportedPlatform.ANDROID + ) + ? SupportedPlatform.ANDROID + : SupportedPlatform.IOS + + await createFolder(this.config.cwd, path.join('example', '__tests__')) + + await Promise.all([ + writeFile( + path.join(this.config.cwd, 'example', 'rn-harness.config.mjs'), + harnessConfigCode({ + androidBundleId, + appRegistryComponentName: exampleAppName, + defaultRunner, + entryPoint: './index.js', + iosBundleId, + }), + { encoding: 'utf8' } + ), + writeFile( + path.join(this.config.cwd, 'example', 'jest.config.js'), + harnessJestConfigCode(), + { encoding: 'utf8' } + ), + writeFile( + path.join( + this.config.cwd, + 'example', + '__tests__', + `${this.config.packageName}.harness.${ + this.config.packageType === Nitro.View ? 'tsx' : 'ts' + }` + ), + harnessTestCode( + this.config.packageName, + this.config.finalPackageName, + `${this.config.funcName}`, + this.config.packageType + ), + { encoding: 'utf8' } + ), + ]) + } + private async installDependenciesAndRunCodegen() { - await execAsync(`${this.config.pm} install`, { cwd: this.config.cwd }) + await execAsync(`${this.config.pm} install`, { + cwd: this.config.monorepo ? this.workspaceRoot : this.config.cwd, + }) const packageManager = this.config.pm === 'npm' ? 'npx --yes' : this.config.pm const codegenCommand = `${packageManager} nitrogen --logLevel="debug" && ${this.config.pm} run build${Object.values(this.config.platformLangs).includes(SupportedLang.KOTLIN) ? ' && node post-script.js' : ''}` @@ -532,24 +809,37 @@ export class NitroModuleFactory { } private async gitInit() { - await execAsync('git init', { cwd: this.config.cwd }) - await execAsync('git add .', { cwd: this.config.cwd }) + const gitCwd = this.config.monorepo + ? this.workspaceRoot + : this.config.cwd + await execAsync('git init', { cwd: gitCwd }) + await execAsync('git add .', { cwd: gitCwd }) await execAsync('git commit -m "initial commit"', { - cwd: this.config.cwd, + cwd: gitCwd, }) } private async setupWorkflows() { + const workflowRoot = this.config.monorepo + ? this.workspaceRoot + : this.config.cwd const iosBuildWorkflowPath = path.join( - this.config.cwd, + workflowRoot, '.github', 'workflows', 'ios-build.yml' ) + const androidBuildWorkflowPath = path.join( + workflowRoot, + '.github', + 'workflows', + 'android-build.yml' + ) - const iosBuildWorkflow = await readFile(iosBuildWorkflowPath, { - encoding: 'utf8', - }) + const [iosBuildWorkflow, androidBuildWorkflow] = await Promise.all([ + readFile(iosBuildWorkflowPath, { encoding: 'utf8' }), + readFile(androidBuildWorkflowPath, { encoding: 'utf8' }), + ]) const iosBuildReplacements = { $$exampleApp$$: `${toPascalCase(this.config.packageName)}Example`, @@ -560,8 +850,79 @@ export class NitroModuleFactory { replacements: iosBuildReplacements, }) - await writeFile(iosBuildWorkflowPath, iosBuildWorkflowContent, { - encoding: 'utf8', - }) + await Promise.all([ + writeFile( + iosBuildWorkflowPath, + this.getWorkflowContent(iosBuildWorkflowContent), + { + encoding: 'utf8', + } + ), + writeFile( + androidBuildWorkflowPath, + this.getWorkflowContent(androidBuildWorkflow), + { + encoding: 'utf8', + } + ), + ]) + + if (!this.config.includeHarness) { + return + } + + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const workflowDirectoryPath = path.join( + workflowRoot, + '.github', + 'workflows' + ) + + const workflowWrites = this.config.platforms.map(platform => + writeFile( + path.join(workflowDirectoryPath, `harness-${platform}.yml`), + this.getWorkflowContent( + harnessWorkflowCode( + exampleAppName, + this.config.pm, + platform + ) + ), + { + encoding: 'utf8', + } + ) + ) + + await Promise.all(workflowWrites) + } + + private getWorkflowContent(content: string): string { + if (!this.config.monorepo) { + return content + } + + const packagePath = `packages/${this.config.finalPackageName}` + const replacements: Record = { + 'example/': `${packagePath}/example/`, + 'cpp/**': `${packagePath}/cpp/**`, + 'android/**': `${packagePath}/android/**`, + 'ios/**': `${packagePath}/ios/**`, + 'src/**': `${packagePath}/src/**`, + 'nitrogen/**': `${packagePath}/nitrogen/**`, + "'*.podspec'": `'${packagePath}/*.podspec'`, + "'package.json'": `'${packagePath}/package.json'`, + "'react-native.config.js'": `'${packagePath}/react-native.config.js'`, + "'nitro.json'": `'${packagePath}/nitro.json'`, + 'working-directory: example': `working-directory: ${packagePath}/example`, + 'projectRoot: example': `projectRoot: ${packagePath}/example`, + 'app: example/': `app: ${packagePath}/example/`, + } + + return Object.entries(replacements).reduce( + (workflowContent, [search, value]) => + workflowContent.replaceAll(search, value), + content + ) } } diff --git a/src/types.ts b/src/types.ts index e0fed772..4b0b77ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,8 @@ export interface UserAnswers { platforms: SupportedPlatform[] packageType: Nitro platformLangs: PlatformLangMap + includeHarness: boolean + monorepo: boolean pm: PackageManager } @@ -31,12 +33,14 @@ export type PlatformLang = { export type CreateModuleOptions = { ci?: boolean + includeHarness?: boolean langs?: string moduleDir?: string platforms?: string skipExample?: boolean skipInstall?: boolean packageType?: Nitro + monorepo?: boolean } export type PackageManager = Exclude< @@ -61,7 +65,8 @@ export type GenerateModuleConfig = { packageType: Nitro packageName: string finalPackageName: string -} & Omit + monorepo: boolean +} & Omit export interface FileGenerator { /** @@ -79,8 +84,12 @@ export const PLATFORM_LANGUAGE_MAP: Record = } export type InstructionsParams = { - moduleName: string + includeHarness?: boolean + monorepo: boolean + modulePath: string + packagePath: string pm: string + platforms: SupportedPlatform[] skipInstall?: boolean skipExample?: boolean } diff --git a/src/utils.ts b/src/utils.ts index aa3eb0b4..5b0f4a6e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,8 +24,6 @@ type AutolinkingConfig = { [key: string]: AutolinkingEntry } -export const LANGS = ['c++', 'swift', 'kotlin'] as const - export const validatePackageName = (input: string): string => { if (input.length === 0) { return 'Package name is required' @@ -116,10 +114,6 @@ export const generateAutolinking = ( return { [moduleName]: entry } } -export const validateTemplate = (answer: string[]) => { - return answer.length > 0 || 'You must choose at least one template' -} - export const dirExist = async (dir: string) => { try { await access(dir) diff --git a/test-local.sh b/test-local.sh index 90ed67f4..a0c4665c 100755 --- a/test-local.sh +++ b/test-local.sh @@ -53,6 +53,9 @@ sleep 1 send \x20 send \r +# Module type (Default to Nitro Module) +expect "๐Ÿ“ฆ Select module type:" {send \r} + # Language selection expect "๐Ÿ’ป Select programming languages:" sleep 1 @@ -66,9 +69,8 @@ send \r # Package manager expect "๐Ÿ“ฆ Select package manager:" {send \r} - -# Module type (Default to Nitro Module) -expect "๐Ÿ“ฆ Select module type:" {send \r} +# React Native Harness (Default to no) +expect "Include React Native Harness for native Android and iOS tests?" {send "n\r"} # Confirm package name expect "โœจ Your package name will be called:" {send "y\r"} @@ -97,7 +99,7 @@ if [ -d "react-native-test-module" ]; then -scheme TestModuleExample \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16' build + -destination 'platform=iOS Simulator,name=iPhone 17' build cd ../android ./gradlew assembleDebug --no-daemon @@ -111,4 +113,4 @@ else fi cleanup -echo -e "${GREEN}โœ… Test completed${NC}" \ No newline at end of file +echo -e "${GREEN}โœ… Test completed${NC}"