diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 35b0ea15..d6dba344 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -57,14 +57,6 @@ jobs: node-version: '24.10.0' cache: 'pnpm' - - name: Metro cache - uses: actions/cache@v4 - with: - path: apps/playground/.harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} - restore-keys: | - ${{ runner.os }}-metro-cache- - - name: Install dependencies run: | pnpm install @@ -100,7 +92,7 @@ jobs: key: apk-playground - name: Run React Native Harness - uses: ./actions/android + uses: ./ with: app: android/app/build/outputs/apk/debug/app-debug.apk runner: android @@ -131,14 +123,6 @@ jobs: node-version: '24.10.0' cache: 'pnpm' - - name: Metro cache - uses: actions/cache@v4 - with: - path: apps/playground/.harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} - restore-keys: | - ${{ runner.os }}-metro-cache- - - name: Install Watchman run: brew install watchman @@ -189,7 +173,7 @@ jobs: key: ios-app-playground - name: Run React Native Harness - uses: ./actions/ios + uses: ./ with: app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app runner: ios @@ -221,14 +205,6 @@ jobs: node-version: '24.10.0' cache: 'pnpm' - - name: Metro cache - uses: actions/cache@v4 - with: - path: apps/playground/.harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} - restore-keys: | - ${{ runner.os }}-metro-cache- - - name: Install dependencies run: | pnpm install @@ -238,7 +214,7 @@ jobs: pnpm nx run-many -t build --projects="packages/*" - name: Run React Native Harness - uses: ./actions/web + uses: ./ with: runner: chromium projectRoot: apps/playground @@ -278,14 +254,6 @@ jobs: node-version: '24.10.0' cache: 'pnpm' - - name: Metro cache - uses: actions/cache@v4 - with: - path: apps/playground/.harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} - restore-keys: | - ${{ runner.os }}-metro-cache- - - name: Install dependencies run: | pnpm install @@ -323,7 +291,7 @@ jobs: - name: Run React Native Harness (expect crash) id: crash-test continue-on-error: true - uses: ./actions/android + uses: ./ with: app: android/app/build/outputs/apk/debug/app-debug.apk runner: android-crash-pre-rn @@ -376,14 +344,6 @@ jobs: node-version: '24.10.0' cache: 'pnpm' - - name: Metro cache - uses: actions/cache@v4 - with: - path: apps/playground/.harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} - restore-keys: | - ${{ runner.os }}-metro-cache- - - name: Install Watchman run: brew install watchman @@ -436,7 +396,7 @@ jobs: - name: Run React Native Harness (expect crash) id: crash-test continue-on-error: true - uses: ./actions/ios + uses: ./ with: app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app runner: ios-crash-pre-rn diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..91a938ff --- /dev/null +++ b/action.yml @@ -0,0 +1,253 @@ +name: React Native Harness +description: Run React Native Harness tests on iOS, Android or Web +inputs: + runner: + description: The runner to use (must match a runner name defined in your harness config) + required: true + type: string + app: + description: The path to the app (.app for iOS, .apk for Android). Not required for web. + required: false + type: string + projectRoot: + description: The project root directory + required: false + type: string + uploadVisualTestArtifacts: + description: Whether to upload visual test diff and actual images as artifacts + required: false + type: boolean + default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' + packageManager: + description: Package manager to use instead of auto-detection (npm, yarn, pnpm, bun, or deno) + required: false + type: string + default: '' + cacheAvd: + description: Whether to cache the AVD + required: false + type: boolean + default: 'true' +runs: + using: 'composite' + steps: + - name: Load React Native Harness configuration + id: load-config + shell: bash + env: + INPUT_RUNNER: ${{ inputs.runner }} + INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + run: | + node ${{ github.action_path }}/actions/shared/index.cjs + - name: Verify native app input + if: fromJson(steps.load-config.outputs.config).platformId != 'web' + shell: bash + run: | + if [ -z "${{ inputs.app }}" ]; then + echo "Error: app input is required for native runners" + echo "Please provide the path to the built app (.apk for Android, .app for iOS)" + exit 1 + fi + - name: Metro cache + uses: actions/cache@v4 + with: + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/metro-cache + key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} + restore-keys: | + ${{ runner.os }}-metro-cache- + + # ── iOS ────────────────────────────────────────────────────────────────── + - uses: futureware-tech/simulator-action@v4 + if: fromJson(steps.load-config.outputs.config).platformId == 'ios' + with: + model: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} + os: iOS + os_version: ${{ fromJson(steps.load-config.outputs.config).config.device.systemVersion }} + wait_for_boot: true + erase_before_boot: false + - name: Install app + if: fromJson(steps.load-config.outputs.config).platformId == 'ios' + shell: bash + working-directory: ${{ steps.load-config.outputs.projectRoot }} + run: | + xcrun simctl install booted ${{ inputs.app }} + + # ── Android ────────────────────────────────────────────────────────────── + - name: Verify Android config + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + shell: bash + run: | + CONFIG='${{ steps.load-config.outputs.config }}' + if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + echo "Error: AVD config is required for Android emulators" + echo "Please define the 'avd' property in the runner config" + exit 1 + fi + - name: Get architecture of the runner + id: arch + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + shell: bash + run: | + case "${{ runner.arch }}" in + X64) + echo "arch=x86_64" >> $GITHUB_OUTPUT + ;; + ARM64) + echo "arch=arm64-v8a" >> $GITHUB_OUTPUT + ;; + ARM32) + echo "arch=armeabi-v7a" >> $GITHUB_OUTPUT + ;; + *) + echo "arch=x86_64" >> $GITHUB_OUTPUT + ;; + esac + - name: Enable KVM group perms + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + shell: bash + 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 + ls /dev/kvm + - name: Compute AVD cache key + id: avd-key + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + shell: bash + run: | + CONFIG='${{ steps.load-config.outputs.config }}' + AVD_CONFIG=$(echo "$CONFIG" | jq -c '.config.device.avd') + AVD_CONFIG_HASH=$(echo "$AVD_CONFIG" | sha256sum | cut -d' ' -f1) + ARCH="${{ steps.arch.outputs.arch }}" + CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT + - name: Restore AVD cache + uses: actions/cache/restore@v4 + id: avd-cache + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} + - name: Create AVD and generate snapshot for caching + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} + arch: ${{ steps.arch.outputs.arch }} + profile: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.profile }} + disk-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.diskSize }} + heap-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.heapSize }} + force-avd-creation: false + avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: echo "Generated AVD snapshot for caching." + - name: Save AVD cache + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} + + # ── Web ────────────────────────────────────────────────────────────────── + - name: Install Playwright Browsers + if: fromJson(steps.load-config.outputs.config).platformId == 'web' + shell: bash + run: npx playwright install --with-deps chromium + + # ── Shared ─────────────────────────────────────────────────────────────── + - name: Detect Package Manager + id: detect-pm + shell: bash + working-directory: ${{ steps.load-config.outputs.projectRoot }} + run: | + if [ -n "${{ inputs.packageManager }}" ]; then + case "${{ inputs.packageManager }}" in + pnpm) + echo "manager=pnpm" >> $GITHUB_OUTPUT + echo "runner=pnpm exec " >> $GITHUB_OUTPUT + ;; + yarn) + echo "manager=yarn" >> $GITHUB_OUTPUT + echo "runner=yarn " >> $GITHUB_OUTPUT + ;; + bun) + echo "manager=bun" >> $GITHUB_OUTPUT + echo "runner=bunx " >> $GITHUB_OUTPUT + ;; + deno) + echo "manager=deno" >> $GITHUB_OUTPUT + echo "runner=deno run -A npm:" >> $GITHUB_OUTPUT + ;; + npm) + echo "manager=npm" >> $GITHUB_OUTPUT + echo "runner=npx " >> $GITHUB_OUTPUT + ;; + *) + echo "Error: Unsupported packageManager '${{ inputs.packageManager }}'" + echo "Supported values: npm, yarn, pnpm, bun, deno" + exit 1 + ;; + esac + elif [ -f "pnpm-lock.yaml" ]; then + echo "manager=pnpm" >> $GITHUB_OUTPUT + echo "runner=pnpm exec " >> $GITHUB_OUTPUT + elif [ -f "yarn.lock" ]; then + echo "manager=yarn" >> $GITHUB_OUTPUT + echo "runner=yarn " >> $GITHUB_OUTPUT + elif [ -f "bun.lock" ] || [ -f "bun.lockb" ]; then + echo "manager=bun" >> $GITHUB_OUTPUT + echo "runner=bunx " >> $GITHUB_OUTPUT + elif [ -f "deno.lock" ]; then + echo "manager=deno" >> $GITHUB_OUTPUT + echo "runner=deno run -A npm:" >> $GITHUB_OUTPUT + else + echo "manager=npm" >> $GITHUB_OUTPUT + echo "runner=npx " >> $GITHUB_OUTPUT + fi + - name: Run E2E tests + if: fromJson(steps.load-config.outputs.config).platformId != 'android' + shell: bash + working-directory: ${{ steps.load-config.outputs.projectRoot }} + run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + - name: Run E2E tests + id: run-tests + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + uses: reactivecircus/android-emulator-runner@v2 + with: + working-directory: ${{ steps.load-config.outputs.projectRoot }} + api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} + arch: ${{ steps.arch.outputs.arch }} + force-avd-creation: false + avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} + disable-animations: true + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: | + echo $(pwd) + adb install -r ${{ inputs.app }} + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + - name: Upload visual test artifacts + if: always() && inputs.uploadVisualTestArtifacts == 'true' + uses: actions/upload-artifact@v4 + with: + name: visual-test-diffs-${{ fromJson(steps.load-config.outputs.config).platformId }} + path: | + ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png + ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png + if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-${{ fromJson(steps.load-config.outputs.config).platformId }} + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/actions/android/action.yml b/actions/android/action.yml index 39a014ad..7f1d0aaf 100644 --- a/actions/android/action.yml +++ b/actions/android/action.yml @@ -1,5 +1,5 @@ name: React Native Harness for Android -description: Run React Native Harness tests on Android +description: '[Deprecated] Use callstackincubator/react-native-harness instead. Run React Native Harness tests on Android' inputs: app: description: The path to the Android app (.apk) diff --git a/actions/ios/action.yml b/actions/ios/action.yml index c531ddcb..61f3b257 100644 --- a/actions/ios/action.yml +++ b/actions/ios/action.yml @@ -1,5 +1,5 @@ name: React Native Harness for iOS -description: Run React Native Harness tests on iOS +description: '[Deprecated] Use callstackincubator/react-native-harness instead. Run React Native Harness tests on iOS' inputs: app: description: The path to the iOS app (.app) diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 89a15568..3b3ff600 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4471,7 +4471,7 @@ var run = async () => { } const projectRoot = projectRootInput ? import_node_path6.default.resolve(projectRootInput) : process.cwd(); console.info(`Loading React Native Harness config from: ${projectRoot}`); - const { config } = await getConfig(projectRoot); + const { config, projectRoot: resolvedProjectRoot } = await getConfig(projectRoot); const runner = config.runners.find((runner2) => runner2.name === runnerInput); if (!runner) { throw new Error(`Runner ${runnerInput} not found in config`); @@ -4480,7 +4480,9 @@ var run = async () => { if (!githubOutput) { throw new Error("GITHUB_OUTPUT environment variable is not set"); } + const relativeProjectRoot = import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || "."; const output = `config=${JSON.stringify(runner)} +projectRoot=${relativeProjectRoot} `; import_node_fs6.default.appendFileSync(githubOutput, output); } catch (error) { diff --git a/actions/web/action.yml b/actions/web/action.yml index bb1b6dea..05220053 100644 --- a/actions/web/action.yml +++ b/actions/web/action.yml @@ -1,5 +1,5 @@ name: React Native Harness for Web -description: Run React Native Harness tests on Web +description: '[Deprecated] Use callstackincubator/react-native-harness instead. Run React Native Harness tests on Web' inputs: runner: description: The runner to use diff --git a/packages/github-action/README.md b/packages/github-action/README.md index 082eda0e..e5455f2c 100644 --- a/packages/github-action/README.md +++ b/packages/github-action/README.md @@ -1,87 +1,93 @@ ![harness-banner](https://react-native-harness.dev/harness-banner.jpg) -### GitHub Actions for React Native Harness +### GitHub Action for React Native Harness [![mit licence][license-badge]][license] [![Chat][chat-badge]][chat] [![PRs Welcome][prs-welcome-badge]][prs-welcome] -GitHub Actions that simplify running React Native Harness tests in CI/CD environments. These actions handle the complex setup of emulators, simulators, and test execution automatically. +GitHub Action that simplifies running React Native Harness tests in CI/CD environments. It lives at the repository root and handles the setup of emulators, simulators, browsers, and test execution automatically based on the selected Harness runner. -## Available Actions +## Action -This package provides two GitHub Actions: +Use: -### `android` - -Runs React Native Harness tests on Android emulators. This action handles: +```yaml +- uses: callstackincubator/react-native-harness@main +``` -- Loading and validating your Harness configuration -- Setting up Android emulator with proper architecture detection -- Caching AVD snapshots for faster subsequent runs -- Installing your app on the emulator -- Running the Harness tests +The action reads your `rn-harness.config.mjs` file, resolves the `runner` you pass in, and uses that runner's `platformId` to decide which platform-specific setup to execute. -**Inputs:** +## Inputs -- `app` (required): Path to your built Android app (`.apk` file) -- `runner` (required): The runner name from your configuration +- `runner` (required): The runner name from your Harness configuration +- `app` (optional): Path to your built app. Required for native runs (`.apk` for Android, `.app` for iOS), not needed for web - `projectRoot` (optional): The project root directory (defaults to repository root) +- `uploadVisualTestArtifacts` (optional): Whether to upload visual test diff and actual images as artifacts +- `harnessArgs` (optional): Additional arguments to pass to the Harness CLI +- `packageManager` (optional): Override package manager auto-detection. Supported values: `npm`, `yarn`, `pnpm`, `bun`, `deno` +- `cacheAvd` (optional, Android only): Whether to cache the Android Virtual Device snapshot. Defaults to `true` - Crash artifacts persisted to `.harness/crash-reports/` are uploaded automatically when present +- Metro cache persisted to `.harness/metro-cache/` is restored and saved automatically when present -**Requirements:** +## Behavior -- Your runner configuration must include an `avd` property with: - - `apiLevel`: Android API level - - `profile`: AVD profile name - - `diskSize`: Disk size for the AVD - - `heapSize`: Heap size for the emulator +Depending on the selected runner, the action: -**Example:** +- For Android runners, loads and validates your Harness configuration, restores Metro cache, sets up the Android emulator with architecture detection, caches AVD snapshots, installs your app on the emulator, and runs the Harness tests +- For iOS runners, loads and validates your Harness configuration, restores Metro cache, sets up the iOS simulator, installs your app on the simulator, and runs the Harness tests +- For web runners, loads and validates your Harness configuration, restores Metro cache, installs Playwright Chromium, and runs the Harness tests -```yaml -- uses: callstackincubator/react-native-harness/actions/android@main - with: - app: './android/app/build/outputs/apk/debug/app-debug.apk' - runner: 'android' - projectRoot: './apps/my-app' -``` +Runner configuration requirements: -### `ios` +- Android runners must include an `avd` property with: -Runs React Native Harness tests on iOS simulators. This action handles: +- `apiLevel` +- `profile` +- `diskSize` +- `heapSize` -- Loading and validating your Harness configuration -- Setting up iOS simulator with the specified device and OS version -- Installing your app on the simulator -- Running the Harness tests +- iOS runners must include a `device` property with: -**Inputs:** +- `name` +- `systemVersion` -- `app` (required): Path to your built iOS app (`.app` bundle) -- `runner` (required): The runner name from your configuration -- `projectRoot` (optional): The project root directory (defaults to repository root) -- Crash artifacts persisted to `.harness/crash-reports/` are uploaded automatically when present +## Examples -**Requirements:** +### Android runner -- Your runner configuration must include a `device` property with: - - `name`: Simulator device name (e.g., "iPhone 15") - - `systemVersion`: iOS version (e.g., "17.0") +```yaml +- uses: callstackincubator/react-native-harness@main + with: + app: './android/app/build/outputs/apk/debug/app-debug.apk' + runner: 'android' + projectRoot: './apps/my-app' + packageManager: 'pnpm' + cacheAvd: false +``` -**Example:** +### iOS runner ```yaml -- uses: callstackincubator/react-native-harness/actions/ios@main +- uses: callstackincubator/react-native-harness@main with: app: './ios/build/Build/Products/Debug-iphonesimulator/MyApp.app' runner: 'ios' projectRoot: './apps/my-app' ``` +### Web runner + +```yaml +- uses: callstackincubator/react-native-harness@main + with: + runner: 'chromium' + projectRoot: './apps/my-app' +``` + ## Usage -These actions are designed to work with your existing React Native Harness configuration. They automatically read your `rn-harness.config.mjs` file to determine device settings, so you don't need to hardcode emulator or simulator configurations in your workflow files. +The action is designed to work with your existing React Native Harness configuration. It automatically reads `rn-harness.config.mjs` to determine device and platform settings, so you don't need to hardcode emulator or simulator configuration in workflow files. For complete workflow examples, see the [CI/CD documentation](https://react-native-harness.dev/docs/guides/ci-cd). diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml new file mode 100644 index 00000000..91a938ff --- /dev/null +++ b/packages/github-action/src/action.yml @@ -0,0 +1,253 @@ +name: React Native Harness +description: Run React Native Harness tests on iOS, Android or Web +inputs: + runner: + description: The runner to use (must match a runner name defined in your harness config) + required: true + type: string + app: + description: The path to the app (.app for iOS, .apk for Android). Not required for web. + required: false + type: string + projectRoot: + description: The project root directory + required: false + type: string + uploadVisualTestArtifacts: + description: Whether to upload visual test diff and actual images as artifacts + required: false + type: boolean + default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' + packageManager: + description: Package manager to use instead of auto-detection (npm, yarn, pnpm, bun, or deno) + required: false + type: string + default: '' + cacheAvd: + description: Whether to cache the AVD + required: false + type: boolean + default: 'true' +runs: + using: 'composite' + steps: + - name: Load React Native Harness configuration + id: load-config + shell: bash + env: + INPUT_RUNNER: ${{ inputs.runner }} + INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + run: | + node ${{ github.action_path }}/actions/shared/index.cjs + - name: Verify native app input + if: fromJson(steps.load-config.outputs.config).platformId != 'web' + shell: bash + run: | + if [ -z "${{ inputs.app }}" ]; then + echo "Error: app input is required for native runners" + echo "Please provide the path to the built app (.apk for Android, .app for iOS)" + exit 1 + fi + - name: Metro cache + uses: actions/cache@v4 + with: + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/metro-cache + key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} + restore-keys: | + ${{ runner.os }}-metro-cache- + + # ── iOS ────────────────────────────────────────────────────────────────── + - uses: futureware-tech/simulator-action@v4 + if: fromJson(steps.load-config.outputs.config).platformId == 'ios' + with: + model: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} + os: iOS + os_version: ${{ fromJson(steps.load-config.outputs.config).config.device.systemVersion }} + wait_for_boot: true + erase_before_boot: false + - name: Install app + if: fromJson(steps.load-config.outputs.config).platformId == 'ios' + shell: bash + working-directory: ${{ steps.load-config.outputs.projectRoot }} + run: | + xcrun simctl install booted ${{ inputs.app }} + + # ── Android ────────────────────────────────────────────────────────────── + - name: Verify Android config + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + shell: bash + run: | + CONFIG='${{ steps.load-config.outputs.config }}' + if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + echo "Error: AVD config is required for Android emulators" + echo "Please define the 'avd' property in the runner config" + exit 1 + fi + - name: Get architecture of the runner + id: arch + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + shell: bash + run: | + case "${{ runner.arch }}" in + X64) + echo "arch=x86_64" >> $GITHUB_OUTPUT + ;; + ARM64) + echo "arch=arm64-v8a" >> $GITHUB_OUTPUT + ;; + ARM32) + echo "arch=armeabi-v7a" >> $GITHUB_OUTPUT + ;; + *) + echo "arch=x86_64" >> $GITHUB_OUTPUT + ;; + esac + - name: Enable KVM group perms + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + shell: bash + 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 + ls /dev/kvm + - name: Compute AVD cache key + id: avd-key + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + shell: bash + run: | + CONFIG='${{ steps.load-config.outputs.config }}' + AVD_CONFIG=$(echo "$CONFIG" | jq -c '.config.device.avd') + AVD_CONFIG_HASH=$(echo "$AVD_CONFIG" | sha256sum | cut -d' ' -f1) + ARCH="${{ steps.arch.outputs.arch }}" + CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT + - name: Restore AVD cache + uses: actions/cache/restore@v4 + id: avd-cache + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} + - name: Create AVD and generate snapshot for caching + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} + arch: ${{ steps.arch.outputs.arch }} + profile: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.profile }} + disk-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.diskSize }} + heap-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.heapSize }} + force-avd-creation: false + avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: echo "Generated AVD snapshot for caching." + - name: Save AVD cache + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} + + # ── Web ────────────────────────────────────────────────────────────────── + - name: Install Playwright Browsers + if: fromJson(steps.load-config.outputs.config).platformId == 'web' + shell: bash + run: npx playwright install --with-deps chromium + + # ── Shared ─────────────────────────────────────────────────────────────── + - name: Detect Package Manager + id: detect-pm + shell: bash + working-directory: ${{ steps.load-config.outputs.projectRoot }} + run: | + if [ -n "${{ inputs.packageManager }}" ]; then + case "${{ inputs.packageManager }}" in + pnpm) + echo "manager=pnpm" >> $GITHUB_OUTPUT + echo "runner=pnpm exec " >> $GITHUB_OUTPUT + ;; + yarn) + echo "manager=yarn" >> $GITHUB_OUTPUT + echo "runner=yarn " >> $GITHUB_OUTPUT + ;; + bun) + echo "manager=bun" >> $GITHUB_OUTPUT + echo "runner=bunx " >> $GITHUB_OUTPUT + ;; + deno) + echo "manager=deno" >> $GITHUB_OUTPUT + echo "runner=deno run -A npm:" >> $GITHUB_OUTPUT + ;; + npm) + echo "manager=npm" >> $GITHUB_OUTPUT + echo "runner=npx " >> $GITHUB_OUTPUT + ;; + *) + echo "Error: Unsupported packageManager '${{ inputs.packageManager }}'" + echo "Supported values: npm, yarn, pnpm, bun, deno" + exit 1 + ;; + esac + elif [ -f "pnpm-lock.yaml" ]; then + echo "manager=pnpm" >> $GITHUB_OUTPUT + echo "runner=pnpm exec " >> $GITHUB_OUTPUT + elif [ -f "yarn.lock" ]; then + echo "manager=yarn" >> $GITHUB_OUTPUT + echo "runner=yarn " >> $GITHUB_OUTPUT + elif [ -f "bun.lock" ] || [ -f "bun.lockb" ]; then + echo "manager=bun" >> $GITHUB_OUTPUT + echo "runner=bunx " >> $GITHUB_OUTPUT + elif [ -f "deno.lock" ]; then + echo "manager=deno" >> $GITHUB_OUTPUT + echo "runner=deno run -A npm:" >> $GITHUB_OUTPUT + else + echo "manager=npm" >> $GITHUB_OUTPUT + echo "runner=npx " >> $GITHUB_OUTPUT + fi + - name: Run E2E tests + if: fromJson(steps.load-config.outputs.config).platformId != 'android' + shell: bash + working-directory: ${{ steps.load-config.outputs.projectRoot }} + run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + - name: Run E2E tests + id: run-tests + if: fromJson(steps.load-config.outputs.config).platformId == 'android' + uses: reactivecircus/android-emulator-runner@v2 + with: + working-directory: ${{ steps.load-config.outputs.projectRoot }} + api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} + arch: ${{ steps.arch.outputs.arch }} + force-avd-creation: false + avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} + disable-animations: true + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: | + echo $(pwd) + adb install -r ${{ inputs.app }} + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + - name: Upload visual test artifacts + if: always() && inputs.uploadVisualTestArtifacts == 'true' + uses: actions/upload-artifact@v4 + with: + name: visual-test-diffs-${{ fromJson(steps.load-config.outputs.config).platformId }} + path: | + ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png + ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png + if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-${{ fromJson(steps.load-config.outputs.config).platformId }} + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 39a014ad..7f1d0aaf 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -1,5 +1,5 @@ name: React Native Harness for Android -description: Run React Native Harness tests on Android +description: '[Deprecated] Use callstackincubator/react-native-harness instead. Run React Native Harness tests on Android' inputs: app: description: The path to the Android app (.apk) diff --git a/packages/github-action/src/ios/action.yml b/packages/github-action/src/ios/action.yml index c531ddcb..61f3b257 100644 --- a/packages/github-action/src/ios/action.yml +++ b/packages/github-action/src/ios/action.yml @@ -1,5 +1,5 @@ name: React Native Harness for iOS -description: Run React Native Harness tests on iOS +description: '[Deprecated] Use callstackincubator/react-native-harness instead. Run React Native Harness tests on iOS' inputs: app: description: The path to the iOS app (.app) diff --git a/packages/github-action/src/shared/index.ts b/packages/github-action/src/shared/index.ts index 9e405e01..46e4d2d3 100644 --- a/packages/github-action/src/shared/index.ts +++ b/packages/github-action/src/shared/index.ts @@ -17,7 +17,8 @@ const run = async (): Promise => { console.info(`Loading React Native Harness config from: ${projectRoot}`); - const { config } = await getConfig(projectRoot); + const { config, projectRoot: resolvedProjectRoot } = + await getConfig(projectRoot); const runner = config.runners.find((runner) => runner.name === runnerInput); @@ -30,7 +31,9 @@ const run = async (): Promise => { throw new Error('GITHUB_OUTPUT environment variable is not set'); } - const output = `config=${JSON.stringify(runner)}\n`; + const relativeProjectRoot = + path.relative(process.cwd(), resolvedProjectRoot) || '.'; + const output = `config=${JSON.stringify(runner)}\nprojectRoot=${relativeProjectRoot}\n`; fs.appendFileSync(githubOutput, output); } catch (error) { if (error instanceof Error) { diff --git a/packages/github-action/src/web/action.yml b/packages/github-action/src/web/action.yml index bb1b6dea..05220053 100644 --- a/packages/github-action/src/web/action.yml +++ b/packages/github-action/src/web/action.yml @@ -1,5 +1,5 @@ name: React Native Harness for Web -description: Run React Native Harness tests on Web +description: '[Deprecated] Use callstackincubator/react-native-harness instead. Run React Native Harness tests on Web' inputs: runner: description: The runner to use diff --git a/packages/github-action/tsup.config.mts b/packages/github-action/tsup.config.mts index 8d15b463..db836be2 100644 --- a/packages/github-action/tsup.config.mts +++ b/packages/github-action/tsup.config.mts @@ -31,5 +31,9 @@ export default defineConfig({ path.resolve(OUT_DIR, `./${target}/action.yml`) ); }); + fs.copyFileSync( + path.resolve('./src/action.yml'), + path.resolve(OUT_DIR, '../action.yml') + ); }, }); diff --git a/website/src/docs/guides/ci-cd.md b/website/src/docs/guides/ci-cd.md index 5f0c3b36..d81480e2 100644 --- a/website/src/docs/guides/ci-cd.md +++ b/website/src/docs/guides/ci-cd.md @@ -10,20 +10,19 @@ The amount of time needed to run Harness tests is typically around **5 minutes** React Native Harness doesn't require you to constantly rebuild the app from scratch. You can reuse the same debug build as long as your native modules stay the same, significantly reducing CI execution time through intelligent caching. ::: -## Official GitHub Actions +## Official GitHub Action -React Native Harness provides official GitHub Actions that simplify running tests in CI/CD environments. These actions handle the complex setup of emulators, simulators, and test execution automatically. +React Native Harness provides an official GitHub Action that simplifies running tests in CI/CD environments. It handles the setup of emulators, simulators, browsers, and test execution automatically. -### Available Actions +### Action -- **Android Action**: `callstackincubator/react-native-harness/actions/android` -- **iOS Action**: `callstackincubator/react-native-harness/actions/ios` +- `callstackincubator/react-native-harness` :::tip Versioning You can pin to a specific version by appending `@` to the action path (e.g., `@main`, `@v1.0.0`). For production use, we recommend pinning to a specific release tag once available. ::: -Both actions automatically: +The action automatically: - Load your React Native Harness configuration - Set up and configure the emulator/simulator based on your config @@ -31,15 +30,19 @@ Both actions automatically: - Run the tests - Upload crash reports from `.harness/crash-reports/` as workflow artifacts whenever a run produces them -The actions read your `rn-harness.config.mjs` file to determine the device configuration, so you don't need to hardcode emulator settings in your workflow. +The action reads your `rn-harness.config.mjs` file to determine the selected runner's platform and device configuration, so you don't need to hardcode emulator or simulator settings in your workflow. ### Action Inputs -Both actions accept the following inputs: +The action accepts the following inputs: -- `app` (required): Path to your built app (`.apk` for Android, `.app` for iOS) -- `runner` (required): The runner name (e.g., `"android"` or `"ios"`) +- `app` (optional): Path to your built app (`.apk` for Android, `.app` for iOS). Not needed for web runners +- `runner` (required): The runner name from your Harness config (for example `"android"`, `"ios"`, or `"chromium"`) - `projectRoot` (optional): The project root directory (defaults to the repository root) +- `uploadVisualTestArtifacts` (optional): Whether to upload visual test diff and actual images as artifacts +- `harnessArgs` (optional): Additional arguments to pass to the Harness CLI +- `packageManager` (optional): Override package manager auto-detection. Supported values: `npm`, `yarn`, `pnpm`, `bun`, `deno` +- `cacheAvd` (optional, Android only): Whether to cache the Android Virtual Device snapshot. Defaults to `true` ## Crash Artifacts @@ -47,7 +50,7 @@ Harness monitors native crashes throughout the entire test lifecycle — includi Crash reports are persisted under `.harness/crash-reports/` in the current working directory. Filenames include the Harness run timestamp and selected runner name so CI downloads are easy to correlate with a specific workflow run. -The official GitHub Actions upload `.harness/crash-reports/**/*` automatically (with `if-no-files-found: ignore`), so crash reports appear as downloadable workflow artifacts whenever a run produces them — no extra configuration needed. +The official GitHub Action uploads `.harness/crash-reports/**/*` automatically (with `if-no-files-found: ignore`), so crash reports appear as downloadable workflow artifacts whenever a run produces them — no extra configuration needed. :::tip Startup crashes are treated as a first-class failure. If your app crashes before the bridge connects, Harness immediately reports it with the native crash details rather than timing out. @@ -57,7 +60,7 @@ Startup crashes are treated as a first-class failure. If your app crashes before The example workflow shared below is designed for **React Native Community CLI** setups. If you're using **Expo** or **Rock**, the workflow will be simpler as these frameworks provide their own build and deployment mechanisms that integrate seamlessly with CI/CD environments. -Here's a complete GitHub Actions workflow that demonstrates how to run React Native Harness tests on both Android and iOS platforms using the official actions: +Here's a complete GitHub Actions workflow that demonstrates how to run React Native Harness tests on both Android and iOS platforms using the official action: ### Complete Workflow Configuration @@ -134,10 +137,12 @@ jobs: # Step 3: Run Harness tests - name: Run React Native Harness - uses: callstackincubator/react-native-harness/actions/android@main + uses: callstackincubator/react-native-harness@main with: app: android/app/build/outputs/apk/debug/app-debug.apk runner: android + packageManager: pnpm + cacheAvd: false test-ios: name: Test iOS @@ -205,27 +210,17 @@ jobs: # Step 3: Run Harness tests - name: Run React Native Harness - uses: callstackincubator/react-native-harness/actions/ios@main + uses: callstackincubator/react-native-harness@main with: app: ios/build/Build/Products/Debug-iphonesimulator/YourApp.app runner: ios ``` -## Metro cache (optional) +## Metro cache -React Native Harness can persist Metro's transformation cache under `.harness/metro-cache` in your project root. Enabling it in config (`unstable__enableMetroCache: true`) speeds up repeated Metro runs. In CI, you can cache this directory to avoid re-transforming unchanged files between workflow runs: +React Native Harness can persist Metro's transformation cache under `.harness/metro-cache` in your project root. Enabling it in config (`unstable__enableMetroCache: true`) speeds up repeated Metro runs. -```yaml -- name: Metro cache - uses: actions/cache@v4 - with: - path: .harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/pnpm-lock.yaml', '**/yarn.lock', '**/package-lock.json', '**/metro.config.js', '**/metro.config.mjs', '**/babel.config.js', '**/babel.config.mjs') }} - restore-keys: | - ${{ runner.os }}-metro-cache- -``` - -Use a key that includes your lockfile and Metro/Babel config paths so the cache invalidates when dependencies or bundler config change. +When you use the `callstackincubator/react-native-harness` GitHub Action, Metro cache restoration and saving is handled automatically for the resolved `projectRoot`. You do not need to add a separate `actions/cache` step for `.harness/metro-cache`. ## Build Artifact Caching diff --git a/website/src/docs/platforms/android.mdx b/website/src/docs/platforms/android.mdx index 433e9465..ac771ba7 100644 --- a/website/src/docs/platforms/android.mdx +++ b/website/src/docs/platforms/android.mdx @@ -96,4 +96,4 @@ Supported extra value types in v1: Harness monitors logcat output during both app startup and test execution, looking for fatal exceptions and ANRs in the process matching your `bundleId`. When a native crash is detected, Harness attaches the parsed crash information — including the exception type, message, and full stack trace — to the failing test or startup error. -Crash reports are also persisted under `.harness/crash-reports/` so they remain available for inspection after the test run, and the [official GitHub Actions](../guides/ci-cd) upload them automatically as workflow artifacts. +Crash reports are also persisted under `.harness/crash-reports/` so they remain available for inspection after the test run, and the [official GitHub Action](../guides/ci-cd) uploads them automatically as workflow artifacts.