diff --git a/.github/actions/ios-build-xcode/action.yml b/.github/actions/ios-build-xcode/action.yml index 96e8705c..67856b27 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,22 +34,21 @@ 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" - if xcodebuild \ + if xcodebuild \ CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ -derivedDataPath build -UseModernBuildSystem=YES \ -workspace "${WORKSPACE_NAME}.xcworkspace" \ -scheme "${SCHEME_NAME}" \ -sdk iphonesimulator \ -configuration "${{ inputs.mode }}" \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ build \ CODE_SIGNING_ALLOWED=NO | xcpretty; then echo "xcodebuild succeeded on attempt $attempt" 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 8f5200af..4006039a 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -68,15 +68,16 @@ jobs: node lib/cli/index.js --help node lib/cli/index.js create --help + - name: Run E2E tests + run: bun run test:e2e + define-matrix: name: Define Package Matrices 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 +185,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 +245,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 +307,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,194 +348,25 @@ 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@v7 - - - 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@v7 - - - 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: + AVD_DISK_SIZE: 1G + AVD_HEAP_SIZE: 1G + AVD_NAME: Pixel_7_API_36 + DEVICE_API_LEVEL: '36' + DEVICE_ARCH: x86_64 + DEVICE_PROFILE: pixel_7 WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository uses: actions/checkout@v7 - - name: Enable KVM (Android emulator) - if: runner.os == 'Linux' - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - name: Create working directory run: mkdir -p ${{ env.WORKING_DIR }} @@ -546,10 +376,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: @@ -582,58 +408,52 @@ jobs: java-version: '17' cache: 'gradle' - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - - name: Install Maestro CLI - uses: ./.github/actions/setup-maestro + - name: Build Android app + working-directory: ${{ env.WORKING_DIR }}/example/android + run: | + chmod +x ./gradlew + ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=${{ env.DEVICE_ARCH }} - - name: Run Android Emulator and E2E Tests - uses: reactivecircus/android-emulator-runner@v2 + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.2.0 with: - api-level: 35 - target: google_apis - arch: x86_64 - profile: Galaxy Nexus - emulator-options: -no-snapshot -memory 4096 -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - 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 }} - - e2e-ios: - name: iOS E2E - ${{ matrix.package_type }} - ${{ matrix.scenario }} - needs: [test-ios-build, define-matrix] - runs-on: macOS-15 + app: android/app/build/outputs/apk/debug/app-debug.apk + runner: android + projectRoot: ${{ env.WORKING_DIR }}/example + packageManager: ${{ matrix.pm }} + + - name: Stop Gradle + if: always() + working-directory: ${{ env.WORKING_DIR }}/example/android + run: | + chmod +x ./gradlew + ./gradlew --stop + + harness-ios: + name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} + needs: [generate-packages, define-matrix] + runs-on: macos-26 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 uses: actions/checkout@v7 - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.5.app/Contents/Developer - 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 +467,7 @@ jobs: restore-keys: | ${{ runner.os }}-bun- - - name: Install Dependencies + - name: Install dependencies uses: ./.github/actions/install-deps with: pm: ${{ matrix.pm }} @@ -671,44 +491,27 @@ 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- + ios-dir: ${{ env.WORKING_DIR }}/example/ios + mode: ${{ matrix.mode }} - - name: Configure ccache + - name: Run iOS Harness Tests + working-directory: ${{ env.WORKING_DIR }}/example + shell: bash 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 }} + IOS_SIMULATOR="$(xcrun simctl list devices available --json | IOS_VERSION=26.5 node -e 'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const desiredVersion = process.env.IOS_VERSION; const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\d+(?:-\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null || (desiredVersion != null && version !== desiredVersion)) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);' || true)" + if [ -z "$IOS_SIMULATOR" ]; then + echo "Unable to resolve an available iOS simulator." + xcrun simctl list devices available + exit 1 + fi - - name: Print ccache statistics - if: always() - run: ccache --show-stats + APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name '*.app' | head -1)" + if [ -z "$APP_PATH" ]; then + echo "No built iOS app bundle found in ios/build/Build/Products/Debug-iphonesimulator" + exit 1 + fi - - 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 + DEVICE_MODEL="${IOS_SIMULATOR%%|*}" IOS_VERSION="${IOS_SIMULATOR#*|}" HARNESS_APP_PATH="$APP_PATH" ${{ 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/assets/template/package.json b/assets/template/package.json index f4a6da2e..44f29696 100644 --- a/assets/template/package.json +++ b/assets/template/package.json @@ -58,8 +58,8 @@ "react": "19.2.3", "react-native": "0.86.0", "react-native-builder-bob": "^0.40.18", - "react-native-nitro-modules": "^0.35.2", - "conventional-changelog-conventionalcommits": "^9.3.1", + "react-native-nitro-modules": "^0.36.1", + "conventional-changelog-conventionalcommits": "^10.2.1", "semantic-release": "^25.0.3", "typescript": "^5.8.3" }, 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/e2e/harness-workflows.test.ts b/e2e/harness-workflows.test.ts new file mode 100644 index 00000000..cf32b915 --- /dev/null +++ b/e2e/harness-workflows.test.ts @@ -0,0 +1,193 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +type GeneratedProject = { + readonly packageName: string + readonly rootDir: string +} + +type WorkflowExpectation = { + readonly androidBuildWorkflowPath: string + readonly iosBuildWorkflowPath: string + readonly harnessWorkflowPath: string + readonly rootDir: string +} + +const execFileAsync = promisify(execFile) +const generatedRoots: string[] = [] + +const createProject = async ( + packageName: string, + monorepo: boolean +): Promise => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'nitro-cli-e2e-')) + generatedRoots.push(rootDir) + const gitConfigPath = path.join(rootDir, 'gitconfig') + + await writeFile( + gitConfigPath, + [ + '[user]', + ' name = Nitro CLI E2E', + ' email = e2e@example.com', + '', + ].join('\n'), + { encoding: 'utf8' } + ) + + const args = [ + 'lib/cli/index.js', + packageName, + '--module-dir', + rootDir, + '--platforms', + 'ios,android', + '--langs', + 'swift,kotlin', + '--include-harness', + '--skip-install', + '--ci', + ] + + if (monorepo) { + args.push('--monorepo') + } + + await execFileAsync('node', args, { + cwd: path.resolve(import.meta.dir, '..'), + env: { + ...process.env, + CI: 'true', + GIT_CONFIG_GLOBAL: gitConfigPath, + GIT_CONFIG_NOSYSTEM: '1', + }, + maxBuffer: 1024 * 1024 * 20, + }) + + return { + packageName, + rootDir: path.join(rootDir, `react-native-${packageName}`), + } +} + +const readText = async (filePath: string): Promise => + await readFile(filePath, { encoding: 'utf8' }) + +const assertWorkflowFiles = async (rootDir: string): Promise => { + const workflowDir = path.join(rootDir, '.github', 'workflows') + const workflowFiles = await readdir(workflowDir) + + expect(workflowFiles.toSorted()).toEqual([ + 'android-build.yml', + 'harness-android.yml', + 'harness-ios.yml', + 'ios-build.yml', + 'release.yml', + ]) +} + +const assertHarnessScripts = async (rootDir: string): Promise => { + const examplePackageJson = JSON.parse( + await readText(path.join(rootDir, 'example', 'package.json')) + ) as { readonly scripts?: Record } + const scripts = examplePackageJson.scripts + + expect(scripts?.['test:harness']).toBe('react-native-harness') + expect(scripts?.['test:harness:android']).toContain( + 'chmod +x android/gradlew' + ) + expect(scripts?.['test:harness:android']).toContain( + 'react-native-harness --harnessRunner android' + ) + expect(scripts?.['test:harness:ios']).toContain( + 'react-native-harness --harnessRunner ios' + ) +} + +const assertHarnessWorkflowContent = async ( + expectation: WorkflowExpectation +): Promise => { + const androidBuildWorkflow = await readText( + path.join( + expectation.rootDir, + '.github', + 'workflows', + 'android-build.yml' + ) + ) + const iosBuildWorkflow = await readText( + path.join(expectation.rootDir, '.github', 'workflows', 'ios-build.yml') + ) + const androidWorkflow = await readText( + path.join( + expectation.rootDir, + '.github', + 'workflows', + 'harness-android.yml' + ) + ) + const iosWorkflow = await readText( + path.join( + expectation.rootDir, + '.github', + 'workflows', + 'harness-ios.yml' + ) + ) + + expect(androidWorkflow).toContain( + 'uses: callstackincubator/react-native-harness@v1.2.0' + ) + expect(androidWorkflow).toContain('chmod +x ./gradlew') + expect(androidWorkflow).toContain('projectRoot: example') + expect(iosWorkflow).toContain( + 'uses: callstackincubator/react-native-harness@v1.0.0' + ) + expect(iosWorkflow).toContain('projectRoot: example') + expect(androidWorkflow).toContain(expectation.harnessWorkflowPath) + expect(iosWorkflow).toContain(expectation.harnessWorkflowPath) + expect(androidBuildWorkflow).toContain(expectation.androidBuildWorkflowPath) + expect(iosBuildWorkflow).toContain(expectation.iosBuildWorkflowPath) + expect(iosBuildWorkflow).not.toContain('$$exampleApp$$') +} + +afterAll(async () => { + await Promise.all( + generatedRoots.map(rootDir => + rm(rootDir, { recursive: true, force: true }) + ) + ) +}) + +describe('React Native Harness workflow generation', () => { + test('generates build and harness workflows for the default project layout', async () => { + const project = await createProject('rootharness', false) + + await assertWorkflowFiles(project.rootDir) + await assertHarnessScripts(project.rootDir) + await assertHarnessWorkflowContent({ + androidBuildWorkflowPath: 'android/**', + harnessWorkflowPath: 'src/**', + iosBuildWorkflowPath: 'ios/**', + rootDir: project.rootDir, + }) + }, 120_000) + + test('generates build and harness workflows for the monorepo project layout', async () => { + const project = await createProject('monoharness', true) + const packagePath = `packages/react-native-${project.packageName}` + + await assertWorkflowFiles(project.rootDir) + await assertHarnessScripts(project.rootDir) + await assertHarnessWorkflowContent({ + androidBuildWorkflowPath: `${packagePath}/android/**`, + harnessWorkflowPath: `${packagePath}/src/**`, + iosBuildWorkflowPath: `${packagePath}/ios/**`, + rootDir: project.rootDir, + }) + }, 120_000) +}) 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..bc064c47 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,14 @@ "scripts": { "dev": "bun typecheck && bun src/cli/index.ts", "build": "rm -rf lib && bun typecheck && tsup src", + "test:e2e": "bun test e2e", + "test:e2e:build": "bun run build && bun run test:e2e", "typecheck": "tsc --noEmit", "prerelease": "rm -rf ./lib/assets", "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", @@ -84,6 +84,6 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=22.14.0" + "node": ">=24.3.0" } } 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..b3eea886 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, @@ -104,9 +105,11 @@ export const ${toPascalCase(moduleName)} = getHostComponent<${toPascalCase(modul export type ${toPascalCase(moduleName)}Ref = HybridRef<${toPascalCase(moduleName)}Props, ${toPascalCase(moduleName)}Methods> ` -export const metroConfig = `const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +export const metroConfig = ( + packageRelativePath = '..' +) => `const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); const path = require('path'); -const root = path.resolve(__dirname, '..'); +const root = path.resolve(__dirname, '${packageRelativePath}'); /** * Metro configuration @@ -120,8 +123,10 @@ const config = { module.exports = mergeConfig(getDefaultConfig(__dirname), config);` -export const babelConfig = `const path = require('path'); -const pak = require('../package.json'); +export const babelConfig = ( + packageRelativePath = '..' +) => `const path = require('path'); +const pak = require('${packageRelativePath}/package.json'); module.exports = api => { api.cache(true); @@ -133,7 +138,7 @@ module.exports = api => { { extensions: ['.js', '.ts', '.json', '.jsx', '.tsx'], alias: { - [pak.name]: path.join(__dirname, '../', pak.source), + [pak.name]: path.join(__dirname, '${packageRelativePath}', pak.source), }, }, ], @@ -141,7 +146,32 @@ module.exports = api => { }; };` -export const exampleTsConfig = (finalModuleName: string) => `{ +export const exampleReactNativeConfig = ( + packageRelativePath = '..' +) => `const path = require('path') +const pkg = require('${packageRelativePath}/package.json') + +/** + * @type {import('@react-native-community/cli-types').Config} + */ +module.exports = { + project: { + ios: { + automaticPodsInstallation: true, + }, + }, + dependencies: { + [pkg.name]: { + root: path.join(__dirname, '${packageRelativePath}'), + }, + }, +} +` + +export const exampleTsConfig = ( + finalModuleName: string, + packageRelativePath = '..' +) => `{ "extends": "@react-native/typescript-config", "include": ["**/*.ts", "**/*.tsx"], "exclude": ["**/node_modules", "**/Pods"], @@ -149,11 +179,337 @@ export const exampleTsConfig = (finalModuleName: string) => `{ "strict": true, "baseUrl": ".", "paths": { - "${finalModuleName}": ["../src"] + "${finalModuleName}": ["${packageRelativePath}/src"] } } }` +type HarnessConfigParams = { + androidBundleId: string | null + appRegistryComponentName: string + defaultRunner: SupportedPlatform + entryPoint: string + iosBundleId: string | null +} + +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 + ? [] + : [ + `androidPlatform({ + name: 'android', + device: androidEmulator(process.env.AVD_NAME ?? 'Pixel_7_API_36', { + apiLevel: Number(process.env.DEVICE_API_LEVEL ?? '36'), + profile: process.env.DEVICE_PROFILE ?? 'pixel_7', + diskSize: process.env.AVD_DISK_SIZE ?? '1G', + heapSize: process.env.AVD_HEAP_SIZE ?? '1G', + snapshot: { + enabled: process.env.CI === 'true', + }, + }), + bundleId: '${androidBundleId}', + })`, + ]), + ...(iosBundleId == null + ? [] + : [ + `applePlatform({ + name: 'ios', + device: appleSimulator( + process.env.DEVICE_MODEL ?? 'iPhone 17 Pro', + process.env.IOS_VERSION ?? '26.5' + ), + bundleId: '${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 getHarnessCodegenBuildStep = ( + packageManager: PackageManager, + monorepo: boolean +) => { + if (!monorepo) { + return '' + } + + return ` + - name: Run codegen and build + run: ${getPackageManagerRunCommand(packageManager, 'codegen')} && ${getPackageManagerRunCommand(packageManager, 'build')} +` +} + +const getHarnessJobCode = ( + exampleAppName: string, + packageManager: PackageManager, + platform: SupportedPlatform, + monorepo = false +) => { + if (platform === SupportedPlatform.ANDROID) { + return ` test: + name: Test Android Harness + runs-on: ubuntu-latest + env: + AVD_DISK_SIZE: 1G + AVD_HEAP_SIZE: 1G + AVD_NAME: Pixel_7_API_36 + DEVICE_API_LEVEL: '36' + DEVICE_ARCH: x86_64 + DEVICE_PROFILE: pixel_7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' +${getPackageManagerSetupStep(packageManager)} + + - name: Install dependencies + run: ${packageManager} install +${getHarnessCodegenBuildStep(packageManager, monorepo)} + - 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: | + chmod +x ./gradlew + ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=\${{ env.DEVICE_ARCH }} + + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.2.0 + with: + app: android/app/build/outputs/apk/debug/app-debug.apk + runner: android + projectRoot: example + packageManager: ${packageManager} + + - name: Stop Gradle + if: always() + working-directory: example/android + run: | + chmod +x ./gradlew + ./gradlew --stop` + } + + return ` test: + name: Test iOS Harness + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' +${getPackageManagerSetupStep(packageManager)} + + - name: Install dependencies + run: ${packageManager} install +${getHarnessCodegenBuildStep(packageManager, monorepo)} + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.5.app/Contents/Developer + + - 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 \ + 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, + monorepo = false +) => `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, monorepo)} +` + export const postScript = (moduleName: string, isHybridView: boolean) => `/** * @file This script is auto-generated by create-nitro-module and should not be edited. * @@ -212,7 +568,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..1e2f2c2d 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'] @@ -33,6 +33,27 @@ export const packagesToRemoveFromExampleApp = [ export const foldersToRemoveFromExampleApp = ['__tests__'] +const getHarnessInstructions = ( + monorepo: boolean, + pm: string, + platforms: SupportedPlatform[] +) => { + if (monorepo) { + const commands = [ + ...(platforms.includes(SupportedPlatform.ANDROID) + ? [`${pm} run test:harness:android`] + : []), + ...(platforms.includes(SupportedPlatform.IOS) + ? [`${pm} run test:harness:ios`] + : []), + ] + + return commands.map(command => ` ${kleur.green(command)}`).join('\n') + } + + return ` ${kleur.green('cd example')}\n ${kleur.green(`${pm} run test:harness`)}` +} + export const NITRO_GRAPHIC = ` โ”Œโ”€โ”€โ”€โ”€โ”€โ” โ”‚ โฒ๏ธ | @@ -47,8 +68,12 @@ export const NITRO_GRAPHIC = ` โ””โ”€โ”˜` export const generateInstructions = ({ - moduleName, + includeHarness, + monorepo, + modulePath, + packagePath, pm, + platforms, skipInstall, skipExample, }: InstructionsParams) => ` @@ -56,7 +81,7 @@ ${kleur.cyan().bold(NITRO_GRAPHIC)} ${kleur.red().bold('Next steps:')} -${kleur.green(`cd ${moduleName}`)} +${kleur.green(`cd ${modulePath}`)} ${ !skipInstall ? '' @@ -68,13 +93,13 @@ ${ 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 @@ -86,6 +111,14 @@ ${ ${kleur.green(`${pm} run ios|android`)} ${kleur.dim('# Run your example app')}` } +${ + skipExample || !includeHarness + ? '' + : `\n\nRun your React Native Harness tests: + +${getHarnessInstructions(monorepo, pm, platforms)}` +} + ${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 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..267a0732 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -9,7 +9,12 @@ import { androidSettingsGradleCode } from './code-snippets/code.kotlin' import { appExampleCode, babelConfig, + exampleReactNativeConfig, exampleTsConfig, + harnessConfigCode, + harnessJestConfigCode, + harnessTestCode, + harnessWorkflowCode, metroConfig, } from './code-snippets/code.js' import { @@ -49,9 +54,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 +82,27 @@ 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 + } + + private get exampleDir(): string { + return this.config.monorepo + ? path.join(this.workspaceRoot, 'example') + : path.join(this.config.cwd, 'example') + } + + private get packageRelativeFromExample(): string { + return this.config.monorepo + ? path.posix.join('..', 'packages', this.config.finalPackageName) + : '..' } async createNitroModule() { @@ -110,13 +140,25 @@ 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.monorepo) { + await this.configureMonorepoReleaseConfig() + } if (!this.config.skipExample) { this.config.spinner.message(messages.generating) 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 +170,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 +227,113 @@ 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 run --cwd ${packageWorkspacePath} ${scriptName}` + } + + private getExampleRunCommand(scriptName: string): string { + if (this.config.pm === 'yarn') { + return `yarn --cwd example ${scriptName}` + } + + if (this.config.pm === 'pnpm') { + return `pnpm --dir example run ${scriptName}` + } + + if (this.config.pm === 'npm') { + return `npm --prefix example run ${scriptName}` + } + + return `bun run --cwd example ${scriptName}` + } + + private async createWorkspaceRoot(): Promise { + const workspaces = this.config.skipExample + ? ['packages/*'] + : ['packages/*', 'example'] + const rootPackageJson: PackageJson = { + name: this.config.finalPackageName, + private: true, + scripts: { + build: this.getWorkspaceRunCommand('build'), + codegen: this.getWorkspaceRunCommand('codegen'), + release: this.getWorkspaceRunCommand('release'), + ...(this.config.includeHarness && !this.config.skipExample + ? { + 'test:harness': + this.getExampleRunCommand('test:harness'), + ...(this.config.platforms.includes( + SupportedPlatform.ANDROID + ) + ? { + 'test:harness:android': + this.getExampleRunCommand( + 'test:harness:android' + ), + } + : {}), + ...(this.config.platforms.includes( + SupportedPlatform.IOS + ) + ? { + 'test:harness:ios': + this.getExampleRunCommand( + 'test:harness:ios' + ), + } + : {}), + } + : {}), + }, + 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'), @@ -171,11 +361,12 @@ export class NitroModuleFactory { } private getPostCodegenScript() { - let script = `${this.config.pm} --cwd example pod` + const examplePath = this.config.monorepo ? '../example' : 'example' + let script = `${this.config.pm} --cwd ${examplePath} pod` if (this.config.pm === 'npm') { - script = `${this.config.pm} --prefix example run pod` + script = `${this.config.pm} --prefix ${examplePath} run pod` } else if (this.config.pm === 'pnpm') { - script = `pnpm --filter ./example pod` + script = `pnpm --dir ${examplePath} run pod` } return script } @@ -210,24 +401,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 +422,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( @@ -307,6 +492,28 @@ export class NitroModuleFactory { }) } + private async configureMonorepoReleaseConfig(): Promise { + const releaseConfigPath = path.join( + this.config.cwd, + 'release.config.cjs' + ) + const releaseConfig = await readFile(releaseConfigPath, { + encoding: 'utf8', + }) + const gitAssets = this.config.skipExample + ? "['package.json', 'CHANGELOG.md']" + : "['package.json', 'CHANGELOG.md', '../example/package.json']" + + await writeFile( + releaseConfigPath, + releaseConfig.replace( + /assets:\s*\[[^\]]+\]/, + `assets: ${gitAssets}` + ), + { encoding: 'utf8' } + ) + } + private async copyNitroTemplateFiles() { const filesToCopy = [ '.watchmanconfig', @@ -330,8 +537,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) } @@ -351,10 +573,12 @@ export class NitroModuleFactory { --package-name com.${replaceHyphen(this.config.packageName)}example \ --directory example --skip-install --skip-git-init --version ${reactNativeVersion}` - await execAsync(args, { cwd: this.config.cwd }) + await execAsync(args, { + cwd: this.config.monorepo ? this.workspaceRoot : this.config.cwd, + }) // Setup App.tsx - const appPath = path.join(this.config.cwd, 'example', 'App.tsx') + const appPath = path.join(this.exampleDir, 'App.tsx') await writeFile( appPath, appExampleCode( @@ -368,21 +592,18 @@ export class NitroModuleFactory { } private async configureExamplePackageJson() { - const packageJsonPath = path.join( - this.config.cwd, - 'example', - 'package.json' - ) + const packageJsonPath = path.join(this.exampleDir, 'package.json') const examplePackageJsonStr = await readFile(packageJsonPath, { encoding: 'utf8', }) const exampleAppPackageJson = JSON.parse(examplePackageJsonStr) + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` exampleAppPackageJson.name = `${this.config.finalPackageName}-example` exampleAppPackageJson.scripts = { ...exampleAppPackageJson.scripts, - ios: "react-native run-ios --simulator='iPhone 16'", + ios: 'react-native run-ios', start: 'react-native start --reset-cache', pod: 'bundle install && bundle exec pod install --project-directory=ios', } @@ -390,6 +611,9 @@ export class NitroModuleFactory { const nitroKey = `react-native-nitro-modules` exampleAppPackageJson.dependencies = { ...exampleAppPackageJson.dependencies, + ...(this.config.monorepo + ? { [this.config.finalPackageName]: '*' } + : {}), [nitroKey]: this.nitroModulesVersion ?? '*', } @@ -398,6 +622,107 @@ 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', + ...(this.config.platforms.includes(SupportedPlatform.ANDROID) + ? { + 'test:harness:android': [ + 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', + ' set -euo pipefail', + ' chmod +x android/gradlew', + ' (cd android && ./gradlew assembleDebug --no-daemon --build-cache)', + ' HARNESS_APP_PATH="$(find android/app/build/outputs/apk/debug -maxdepth 1 -type f -name "*.apk" | head -1)"', + ' if [ -z "${HARNESS_APP_PATH}" ]; then', + ' echo "Unable to locate the built Android app bundle."', + ' exit 1', + ' fi', + ' export HARNESS_APP_PATH', + 'fi', + 'react-native-harness --harnessRunner android', + ].join('\n'), + } + : {}), + ...(this.config.platforms.includes(SupportedPlatform.IOS) + ? { + 'test:harness:ios': [ + 'set -euo pipefail', + 'if [ -z "${DEVICE_MODEL:-}" ] || [ -z "${IOS_VERSION:-}" ]; then', + ' IOS_VERSION="${IOS_VERSION:-26.5}"', + ' export IOS_VERSION', + ' IOS_SIMULATOR="$(xcrun simctl list devices available --json | node -e \'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const desiredVersion = process.env.IOS_VERSION; const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\\d+(?:-\\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null || (desiredVersion != null && version !== desiredVersion)) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);\' || true)"', + ' if [ -z "${IOS_SIMULATOR}" ]; then', + ' echo "Unable to resolve an available iOS simulator."', + ' xcrun simctl list devices available', + ' exit 1', + ' fi', + ' DEVICE_MODEL="${IOS_SIMULATOR%%|*}"', + ' IOS_VERSION="${IOS_SIMULATOR#*|}"', + ' export DEVICE_MODEL IOS_VERSION', + 'fi', + 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', + ` xcodebuild CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -derivedDataPath build -UseModernBuildSystem=YES -workspace ${exampleAppName}.xcworkspace -scheme ${exampleAppName} -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`, + ' HARNESS_APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name "*.app" | head -1)"', + ' if [ -z "${HARNESS_APP_PATH}" ]; then', + ' echo "Unable to locate the built iOS app bundle."', + ' exit 1', + ' fi', + ' export HARNESS_APP_PATH', + 'fi', + 'react-native-harness --harnessRunner ios', + ].join('\n'), + } + : {}), + } + + 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] }) @@ -410,50 +735,25 @@ export class NitroModuleFactory { } private async syncExampleAppConfigurations() { + const packageRelativePath = this.packageRelativeFromExample const reactNativeConfigPath = path.join( - this.config.cwd, - 'example', + this.exampleDir, 'react-native.config.js' ) - const replacements = { - [JS_PACKAGE_NAME_TAG]: this.config.finalPackageName, - } - - const reactNativeConfig = await replacePlaceholder({ - filePath: path.join( - __dirname, - '..', - 'assets', - 'react-native.config.js' - ), - replacements, - }) + const reactNativeConfig = exampleReactNativeConfig(packageRelativePath) // Setup metro.config.js - const metroConfigPath = path.join( - this.config.cwd, - 'example', - 'metro.config.js' - ) + const metroConfigPath = path.join(this.exampleDir, 'metro.config.js') // Setup babel.config.js - const babelConfigPath = path.join( - this.config.cwd, - 'example', - 'babel.config.js' - ) + const babelConfigPath = path.join(this.exampleDir, 'babel.config.js') // Setup tsconfig.json - const tsConfigPath = path.join( - this.config.cwd, - 'example', - 'tsconfig.json' - ) + const tsConfigPath = path.join(this.exampleDir, 'tsconfig.json') const androidSettingsGradlePath = path.join( - this.config.cwd, - 'example', + this.exampleDir, 'android', 'settings.gradle' ) @@ -465,8 +765,7 @@ export class NitroModuleFactory { ) const androidBuildGradlePath = path.join( - this.config.cwd, - 'example', + this.exampleDir, 'android', 'app', 'build.gradle' @@ -476,7 +775,7 @@ export class NitroModuleFactory { encoding: 'utf8', }) - const gradleReplacements = { + const gradleReplacements: Record = { '// reactNativeDir = file("../../node_modules/react-native")': 'reactNativeDir = file("../../../node_modules/react-native")', '// codegenDir = file("../../node_modules/@react-native/codegen")': @@ -494,11 +793,14 @@ export class NitroModuleFactory { const filesToWrite = [ { saveTo: reactNativeConfigPath, data: reactNativeConfig }, - { saveTo: metroConfigPath, data: metroConfig }, - { saveTo: babelConfigPath, data: babelConfig }, + { saveTo: metroConfigPath, data: metroConfig(packageRelativePath) }, + { saveTo: babelConfigPath, data: babelConfig(packageRelativePath) }, { saveTo: tsConfigPath, - data: exampleTsConfig(this.config.finalPackageName), + data: exampleTsConfig( + this.config.finalPackageName, + packageRelativePath + ), }, { saveTo: androidSettingsGradlePath, @@ -516,15 +818,97 @@ export class NitroModuleFactory { ) for (const folder of foldersToRemoveFromExampleApp) { - await rm(path.join(this.config.cwd, 'example', folder), { + await rm(path.join(this.exampleDir, folder), { recursive: true, force: true, }) } } + private async getExampleIOSBundleId() { + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const projectFilePath = path.join( + this.exampleDir, + '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.exampleDir, '__tests__') + + await Promise.all([ + writeFile( + path.join(this.exampleDir, 'rn-harness.config.mjs'), + harnessConfigCode({ + androidBundleId, + appRegistryComponentName: exampleAppName, + defaultRunner, + entryPoint: './index.js', + iosBundleId, + }), + { encoding: 'utf8' } + ), + writeFile( + path.join(this.exampleDir, 'jest.config.js'), + harnessJestConfigCode(), + { encoding: 'utf8' } + ), + writeFile( + path.join( + this.exampleDir, + '__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,36 +916,129 @@ 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 iosBuildReplacements = { - $$exampleApp$$: `${toPascalCase(this.config.packageName)}Example`, - } + const [iosBuildWorkflow, androidBuildWorkflow] = await Promise.all([ + readFile(iosBuildWorkflowPath, { encoding: 'utf8' }), + readFile(androidBuildWorkflowPath, { encoding: 'utf8' }), + ]) + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` const iosBuildWorkflowContent = await replacePlaceholder({ data: iosBuildWorkflow, - replacements: iosBuildReplacements, + replacements: { + $$exampleApp$$: exampleAppName, + }, }) - 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 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, + this.config.monorepo + ) + ), + { + encoding: 'utf8', + } + ) + ) + + await Promise.all(workflowWrites) + } + + private getWorkflowContent(content: string): string { + if (!this.config.monorepo) { + return content + } + + const packagePath = `packages/${this.config.finalPackageName}` + const packagePathReplacements: Record = { + 'cpp/**': `${packagePath}/cpp/**`, + 'android/**': `${packagePath}/android/**`, + 'ios/**': `${packagePath}/ios/**`, + 'src/**': `${packagePath}/src/**`, + 'nitrogen/**': `${packagePath}/nitrogen/**`, + 'nitrogen/generated/shared/**': `${packagePath}/nitrogen/generated/shared/**`, + 'nitrogen/generated/android/**': `${packagePath}/nitrogen/generated/android/**`, + 'nitrogen/generated/ios/**': `${packagePath}/nitrogen/generated/ios/**`, + '*.podspec': `${packagePath}/*.podspec`, + 'package.json': `${packagePath}/package.json`, + 'react-native.config.js': `${packagePath}/react-native.config.js`, + 'nitro.json': `${packagePath}/nitro.json`, + } + + return content + .split('\n') + .map(line => { + const pathMatch = line.match(/^\s*-\s+'([^']+)'\s*$/) + if (pathMatch == null) { + return line + } + + const replacement = packagePathReplacements[pathMatch[1]] + if (replacement == null) { + return line + } + + return line.replace(pathMatch[1], replacement) + }) + .join('\n') } } diff --git a/src/types.ts b/src/types.ts index e0fed772..0aee006e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ -import * as p from '@clack/prompts' -import { detectPackageManager } from './utils' +import type * as p from '@clack/prompts' +import type { detectPackageManager } from './utils' export type PlatformLangMap = Partial> @@ -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}"