diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3e8bc29..c1dafb4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -110,6 +110,7 @@ jobs: timeout-minutes: 30 if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios')) }} env: + HARNESS_DEBUG: true DEBUG: 'Metro:*' steps: - name: Checkout code @@ -349,6 +350,7 @@ jobs: timeout-minutes: 30 if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} env: + HARNESS_DEBUG: true DEBUG: 'Metro:*' steps: - name: Checkout code diff --git a/action.yml b/action.yml index 9ba94c2..70784db 100644 --- a/action.yml +++ b/action.yml @@ -52,6 +52,7 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/actions/shared/index.cjs - name: Verify native app input @@ -72,34 +73,20 @@ runs: ${{ 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 }} + # iOS simulator boot and app installation are handled by Harness itself. # ── Android ────────────────────────────────────────────────────────────── - name: Verify Android config - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.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' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -117,7 +104,7 @@ runs: ;; esac - name: Enable KVM group perms - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -126,47 +113,24 @@ runs: ls /dev/kvm - name: Compute AVD cache key id: avd-key - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} 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) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$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) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} 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' @@ -225,116 +189,27 @@ runs: fi - name: Run E2E tests id: run-tests - if: fromJson(steps.load-config.outputs.config).platformId != 'android' shell: bash working-directory: ${{ steps.load-config.outputs.projectRoot }} env: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - - name: Run E2E tests - id: run-tests-android - if: fromJson(steps.load-config.outputs.config).platformId == 'android' - uses: reactivecircus/android-emulator-runner@v2 - env: - PRE_RUN_HOOK: ${{ inputs.preRunHook }} - AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} - HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - set -e - - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi - - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - 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 - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -344,6 +219,14 @@ runs: ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Save AVD cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/actions/android/action.yml b/actions/android/action.yml index 2560545..6128215 100644 --- a/actions/android/action.yml +++ b/actions/android/action.yml @@ -46,19 +46,21 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/../shared/index.cjs - name: Verify Android config + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.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).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -76,6 +78,7 @@ runs: ;; esac - name: Enable KVM group perms + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -83,18 +86,18 @@ runs: sudo udevadm trigger --name-match=kvm ls /dev/kvm - name: Compute AVD cache key - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} id: avd-key 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) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} uses: actions/cache/restore@v4 id: avd-cache with: @@ -102,22 +105,8 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Create AVD and generate snapshot for caching - if: ${{ 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(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ always() && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -146,63 +135,27 @@ runs: fi - name: Run E2E tests id: run-tests - uses: reactivecircus/android-emulator-runner@v2 + shell: bash + working-directory: ${{ inputs.projectRoot }} env: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - set -e + HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} + run: | + export HARNESS_PROJECT_ROOT="$PWD" - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi + set +e + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + harness_exit_code=$? + set -e - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi + echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - with: - working-directory: ${{ inputs.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 - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" + if [ "$harness_exit_code" -ne 0 ]; then + exit "$harness_exit_code" + fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/actions/ios/action.yml b/actions/ios/action.yml index 1512c12..6866932 100644 --- a/actions/ios/action.yml +++ b/actions/ios/action.yml @@ -43,18 +43,6 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | node ${{ github.action_path }}/../shared/index.cjs - - uses: futureware-tech/simulator-action@v4 - 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 - shell: bash - working-directory: ${{ inputs.projectRoot }} - run: | - xcrun simctl install booted ${{ inputs.app }} - name: Detect Package Manager id: detect-pm shell: bash @@ -83,48 +71,20 @@ runs: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 6ec6093..d81a627 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4196,8 +4196,11 @@ var NEVER = INVALID; // ../tools/dist/logger.js var import_node_util = __toESM(require("util"), 1); +var import_picocolors = __toESM(require_picocolors(), 1); var verbose = !!process.env.HARNESS_DEBUG; var BASE_TAG = "[harness]"; +var INFO_TAG = import_picocolors.default.isColorSupported ? import_picocolors.default.reset(import_picocolors.default.inverse(import_picocolors.default.bold(import_picocolors.default.magenta(" HARNESS ")))) : "HARNESS"; +var ERROR_TAG = import_picocolors.default.isColorSupported ? import_picocolors.default.reset(import_picocolors.default.inverse(import_picocolors.default.bold(import_picocolors.default.red(" HARNESS ")))) : "HARNESS"; var getTimestamp = () => (/* @__PURE__ */ new Date()).toISOString(); var normalizeScope = (scope) => scope.trim().replace(/^\[+|\]+$/g, "").replace(/\]\[/g, "]["); var formatPrefix = (scopes) => { @@ -4206,7 +4209,20 @@ var formatPrefix = (scopes) => { }; var mapLines = (text, prefix) => text.split("\n").map((line) => `${prefix} ${line}`).join("\n"); var writeLog = (level, scopes, messages) => { - const method = level === "warn" ? console.warn : level === "error" ? console.error : level === "debug" ? console.debug : console.info; + if (!verbose && (level === "info" || level === "log" || level === "success")) { + const output2 = import_node_util.default.format(...messages); + const tag = INFO_TAG; + process.stderr.write(`${tag} ${output2} +`); + return; + } + if (!verbose && level === "error") { + const output2 = import_node_util.default.format(...messages); + process.stderr.write(`${ERROR_TAG} ${output2} +`); + return; + } + const method = level === "warn" ? console.warn : console.debug; const output = import_node_util.default.format(...messages); const prefix = `${getTimestamp()} ${formatPrefix(scopes)}`; method(mapLines(output, prefix)); @@ -4263,7 +4279,7 @@ var _ = { actions: new Set(At), aliases: /* @__PURE__ */ new Map([["k", "up"], [ var bt = globalThis.process.platform.startsWith("win"); // ../../node_modules/@clack/prompts/dist/index.mjs -var import_picocolors = __toESM(require_picocolors(), 1); +var import_picocolors2 = __toESM(require_picocolors(), 1); var import_node_process2 = __toESM(require("process"), 1); var import_node_fs = require("fs"); var import_node_path = require("path"); @@ -4306,9 +4322,9 @@ var Ve = "["; var vt = "]"; var we = `${vt}8;;`; var Ge = new RegExp(`(?:\\${Ve}(?\\d+)m|\\${we}(?.*)${Ce})`, "y"); -var Ut = import_picocolors.default.magenta; +var Ut = import_picocolors2.default.magenta; var Ye = { light: w("\u2500", "-"), heavy: w("\u2501", "="), block: w("\u2588", "#") }; -var ze = `${import_picocolors.default.gray(h)} `; +var ze = `${import_picocolors2.default.gray(h)} `; // ../tools/dist/spawn.js var spawnLogger = logger.child("spawn"); @@ -4390,7 +4406,8 @@ var ConfigSchema = external_exports.object({ metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT), webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."), bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), - bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(15e3), + platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5), + bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4), maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), @@ -4534,6 +4551,66 @@ var getConfig = async (dir) => { // src/shared/index.ts var import_node_path6 = __toESM(require("path")); var import_node_fs6 = __toESM(require("fs")); +var getHostAndroidSystemImageArch = () => { + switch (process.arch) { + case "arm64": + return "arm64-v8a"; + case "arm": + return "armeabi-v7a"; + case "x64": + default: + return "x86_64"; + } +}; +var resolveAvdCachingEnabled = ({ + snapshotEnabled +}) => { + const override = process.env.HARNESS_AVD_CACHING; + const requestedValue = override == null ? snapshotEnabled : override.toLowerCase() === "true"; + return requestedValue === true; +}; +var getNormalizedAvdCacheConfig = ({ + emulator, + hostArch +}) => { + const avd = emulator.avd; + if (!avd) { + return null; + } + return { + name: emulator.name, + apiLevel: avd.apiLevel, + arch: hostArch, + profile: avd.profile.trim().toLowerCase(), + diskSize: avd.diskSize.trim().toLowerCase(), + heapSize: avd.heapSize.trim().toLowerCase() + }; +}; +var getResolvedRunner = (runner) => { + if (runner.platformId !== "android" || runner.config.device.type !== "emulator") { + return runner; + } + const avdCachingEnabled = resolveAvdCachingEnabled({ + snapshotEnabled: runner.config.device.avd?.snapshot?.enabled + }); + return { + ...runner, + config: { + ...runner.config, + device: { + ...runner.config.device, + avd: runner.config.device.avd + } + }, + action: { + avdCachingEnabled, + avdCacheConfig: getNormalizedAvdCacheConfig({ + emulator: runner.config.device, + hostArch: getHostAndroidSystemImageArch() + }) + } + }; +}; var run = async () => { try { const projectRootInput = process.env.INPUT_PROJECTROOT; @@ -4543,7 +4620,9 @@ 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, projectRoot: resolvedProjectRoot } = 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`); @@ -4552,8 +4631,11 @@ var run = async () => { if (!githubOutput) { throw new Error("GITHUB_OUTPUT environment variable is not set"); } + const resolvedRunner = getResolvedRunner(runner); const relativeProjectRoot = import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || "."; - const output = `config=${JSON.stringify(runner)} + const output = `config=${JSON.stringify( + resolvedRunner + )} projectRoot=${relativeProjectRoot} `; import_node_fs6.default.appendFileSync(githubOutput, output); diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 2b35e6e..5561f44 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -115,6 +115,7 @@ export default { }), ], defaultRunner: 'android', + platformReadyTimeout: 300000, bridgeTimeout: 120000, resetEnvironmentBetweenTestFiles: true, diff --git a/packages/bundler-metro/src/__tests__/startup.test.ts b/packages/bundler-metro/src/__tests__/startup.test.ts index 656164d..e292576 100644 --- a/packages/bundler-metro/src/__tests__/startup.test.ts +++ b/packages/bundler-metro/src/__tests__/startup.test.ts @@ -63,9 +63,7 @@ afterEach(() => { describe('waitForMetroBackedAppReady', () => { it('fails when Metro never becomes healthy', async () => { const metroInstance = createMetroInstance({ - waitUntilHealthy: vi.fn( - async () => 'HTTP 503: packager-status:starting' - ), + waitUntilHealthy: vi.fn(async () => 'HTTP 503: packager-status:starting'), }); const startAttempt = vi.fn(async () => undefined); @@ -144,51 +142,89 @@ describe('waitForMetroBackedAppReady', () => { expect(waitForReady).toHaveBeenCalledTimes(1); }); - it('does not miss ready events emitted before bundle-request handling moves to the ready phase', async () => { + it('does not count startAttempt duration against bundleStartTimeout', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance(); + let resolveStartAttempt!: () => void; + const startAttempt = vi.fn( + async () => + await new Promise((resolve) => { + resolveStartAttempt = resolve; + }) + ); + const waitForReady = vi.fn(async () => undefined); + + let settled = false; + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady, + waitForCrash: async (signal) => await waitForAbort(signal), + }).finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(false); + + resolveStartAttempt(); + await vi.advanceTimersByTimeAsync(0); + emitBundleRequestObserved(metroInstance, 'app'); + await promise; + + expect(startAttempt).toHaveBeenCalledTimes(1); + expect(waitForReady).toHaveBeenCalledTimes(1); + }); + + it('captures app requests emitted while startAttempt is still running', async () => { const metroInstance = createMetroInstance(); - const readyListeners = new Set<() => void>(); - let readyAlreadyReported = false; - - const emitReady = () => { - readyAlreadyReported = true; - for (const listener of readyListeners) { - listener(); - } - readyListeners.clear(); - }; - - const waitForReady = vi.fn(async (signal: AbortSignal) => { - if (readyAlreadyReported) { - return await waitForAbort(signal); - } - - return await new Promise((resolve, reject) => { - const onReady = () => { - cleanup(); - resolve(); - }; - const onAbort = () => { - cleanup(); - reject(signal.reason ?? createAbortError()); - }; - const cleanup = () => { - readyListeners.delete(onReady); - signal.removeEventListener('abort', onAbort); - }; - - readyListeners.add(onReady); - signal.addEventListener('abort', onAbort, { once: true }); - }); + let releaseStartAttempt!: () => void; + const startAttemptGate = new Promise((resolve) => { + releaseStartAttempt = resolve; }); + const startAttempt = vi.fn(async () => { + emitBundleRequestObserved(metroInstance, 'app'); + await startAttemptGate; + }); + const waitForReady = vi.fn(async () => undefined); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady, + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + releaseStartAttempt(); + await promise; + + expect(startAttempt).toHaveBeenCalledTimes(1); + expect(waitForReady).toHaveBeenCalledTimes(1); + }); + + it('does not miss ready events emitted before bundle-request handling moves to the ready phase', async () => { + const metroInstance = createMetroInstance(); + const waitForReady = vi.fn(async () => undefined); const startAttempt = vi.fn(async () => { - emitReady(); emitBundleRequestObserved(metroInstance, 'app'); }); await waitForMetroBackedAppReady({ metro: metroInstance, - platformId: 'web', + platformId: 'ios', bundleStartTimeout: 1_000, readyTimeout: 2_000, maxAppRestarts: 2, @@ -210,7 +246,9 @@ describe('waitForMetroBackedAppReady', () => { const startAttempt = vi.fn(async () => { emitBundleRequestObserved(metroInstance, 'app'); setTimeout(() => { - emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never); + emitMetroEvent(metroInstance, { + type: 'bundle_build_started', + } as never); }, 0); }); const waitForReady = vi.fn( @@ -258,7 +296,9 @@ describe('waitForMetroBackedAppReady', () => { const startAttempt = vi.fn(async () => { emitBundleRequestObserved(metroInstance, 'app'); setTimeout(() => { - emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never); + emitMetroEvent(metroInstance, { + type: 'bundle_build_started', + } as never); emitMetroEvent(metroInstance, { type: 'bundle_build_done' } as never); }, 0); }); @@ -274,15 +314,16 @@ describe('waitForMetroBackedAppReady', () => { waitForReady: async (signal) => await waitForAbort(signal), waitForCrash: async (signal) => await waitForAbort(signal), }); - - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(promise).rejects.toMatchObject({ + const rejection = expect(promise).rejects.toMatchObject({ name: 'StartupStallError', code: 'ready_not_reported', attempts: 1, }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2_000); + + await rejection; expect(startAttempt).toHaveBeenCalledTimes(1); }); @@ -305,14 +346,15 @@ describe('waitForMetroBackedAppReady', () => { waitForReady: async (signal) => await waitForAbort(signal), waitForCrash: async (signal) => await waitForAbort(signal), }); - - await vi.advanceTimersByTimeAsync(2_000); - - await expect(promise).rejects.toMatchObject({ + const rejection = expect(promise).rejects.toMatchObject({ name: 'StartupStallError', code: 'ready_not_reported', attempts: 1, }); + + await vi.advanceTimersByTimeAsync(2_000); + + await rejection; expect(startAttempt).toHaveBeenCalledTimes(1); }); @@ -368,4 +410,43 @@ describe('waitForMetroBackedAppReady', () => { }); expect(startAttempt).toHaveBeenCalledTimes(3); }); + + it('does not surface a raw bundle timeout while startAttempt is pending', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance({ + prewarm: vi.fn(async () => true), + }); + let releaseStartAttempt!: () => void; + const startAttemptGate = new Promise((resolve) => { + releaseStartAttempt = resolve; + }); + const startAttempt = vi.fn(async () => { + await startAttemptGate; + }); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 0, + signal: new AbortController().signal, + startAttempt, + waitForReady: async (signal) => await waitForAbort(signal), + waitForCrash: async (signal) => await waitForAbort(signal), + }); + const rejection = expect(promise).rejects.toMatchObject({ + name: 'StartupStallError', + code: 'bundle_request_not_observed', + attempts: 1, + sawPrewarmRequest: true, + }); + + await vi.advanceTimersByTimeAsync(5_000); + releaseStartAttempt(); + await vi.advanceTimersByTimeAsync(1_000); + + await rejection; + }); }); diff --git a/packages/bundler-metro/src/startup.ts b/packages/bundler-metro/src/startup.ts index e84a296..c5bc64f 100644 --- a/packages/bundler-metro/src/startup.ts +++ b/packages/bundler-metro/src/startup.ts @@ -1,4 +1,7 @@ -import { raceAbortSignals, withAbortTimeout } from '@react-native-harness/tools'; +import { + raceAbortSignals, + withAbortTimeout, +} from '@react-native-harness/tools'; import { StartupStallError } from './errors.js'; import type { ReportableEvent } from './reporter.js'; import type { MetroInstance } from './types.js'; @@ -6,8 +9,6 @@ import type { MetroInstance } from './types.js'; type WaitForBundleRequestOptions = { events: MetroInstance['events']; platformId: string; - timeoutMs: number; - signal: AbortSignal; initialPrewarmSeen?: boolean; }; @@ -15,9 +16,18 @@ type BundleRequestObservation = { sawPrewarmRequest: boolean; }; +type BundleRequestObserver = { + sawPrewarmRequest: () => boolean; + hasSeenAppRequest: () => boolean; + waitForAppRequest: () => Promise; + dispose: () => void; +}; + class ReadyTimeoutError extends Error { constructor() { - super('Timed out waiting for the app to become ready after Metro bundling.'); + super( + 'Timed out waiting for the app to become ready after Metro bundling.' + ); this.name = 'ReadyTimeoutError'; } } @@ -47,61 +57,90 @@ const isAbortError = (error: unknown): error is DOMException => { return error instanceof DOMException && error.name === 'AbortError'; }; -const waitForBundleRequest = async ({ +const observeBundleRequest = ({ events, platformId, - timeoutMs, - signal, initialPrewarmSeen = false, -}: WaitForBundleRequestOptions): Promise => { +}: WaitForBundleRequestOptions): BundleRequestObserver => { let sawPrewarmRequest = initialPrewarmSeen; + let sawAppRequest = false; + let settled = false; - return await new Promise((resolve, reject) => { - const requestSignal = withAbortTimeout(signal, timeoutMs); + let resolvePromise!: (value: BundleRequestObservation) => void; + const appRequestPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); - const cleanup = () => { - events.removeListener(onMetroEvent); - requestSignal.removeEventListener('abort', onAbort); - }; + const resolveOnce = () => { + if (settled) { + return; + } - const resolveOnce = () => { - cleanup(); - resolve({ - sawPrewarmRequest, - }); - }; + settled = true; + resolvePromise({ + sawPrewarmRequest, + }); + }; - const rejectOnce = (error: unknown) => { - cleanup(); - reject(error); - }; + const onMetroEvent = (event: ReportableEvent) => { + if (event.type !== 'bundle_request_observed') { + return; + } - const onAbort = () => { - if (signal.aborted) { - rejectOnce(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')); - return; - } + if (event.requestKind === 'prewarm') { + sawPrewarmRequest = true; + return; + } - rejectOnce(new BundleRequestTimeoutError(sawPrewarmRequest)); + if (event.requestKind === 'app' && event.platform === platformId) { + sawAppRequest = true; + resolveOnce(); + } + }; + + events.addListener(onMetroEvent); + + return { + sawPrewarmRequest: () => sawPrewarmRequest, + hasSeenAppRequest: () => sawAppRequest, + waitForAppRequest: async () => await appRequestPromise, + dispose: () => { + events.removeListener(onMetroEvent); + }, + }; +}; + +const waitForBundleRequestTimeout = async ({ + timeoutMs, + signal, + sawPrewarmRequest, +}: { + timeoutMs: number; + signal: AbortSignal; + sawPrewarmRequest: () => boolean; +}): Promise => { + const timeoutSignal = withAbortTimeout(signal, timeoutMs); + + return await new Promise((_, reject) => { + const cleanup = () => { + timeoutSignal.removeEventListener('abort', onAbort); }; - const onMetroEvent = (event: ReportableEvent) => { - if (event.type !== 'bundle_request_observed') { - return; - } + const onAbort = () => { + cleanup(); - if (event.requestKind === 'prewarm') { - sawPrewarmRequest = true; + if (signal.aborted) { + reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ); return; } - if (event.requestKind === 'app' && event.platform === platformId) { - resolveOnce(); - } + reject(new BundleRequestTimeoutError(sawPrewarmRequest())); }; - events.addListener(onMetroEvent); - requestSignal.addEventListener('abort', onAbort, { once: true }); + timeoutSignal.addEventListener('abort', onAbort, { once: true }); }); }; @@ -112,7 +151,8 @@ const waitForReadyAfterBundleRequest = async (options: { readyPromise: Promise; cancelReadyWait: () => void; }): Promise => { - const { events, readyTimeout, signal, readyPromise, cancelReadyWait } = options; + const { events, readyTimeout, signal, readyPromise, cancelReadyWait } = + options; return await new Promise((resolve, reject) => { let bundlingInProgress = false; @@ -161,7 +201,10 @@ const waitForReadyAfterBundleRequest = async (options: { }; const onAbort = () => { - rejectOnce(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')); + rejectOnce( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ); }; const onMetroEvent = (event: ReportableEvent) => { @@ -190,13 +233,11 @@ const waitForReadyAfterBundleRequest = async (options: { resolveOnce(); }) .catch((error) => { - if ( - error instanceof DOMException && - error.name === 'AbortError' - ) { + if (error instanceof DOMException && error.name === 'AbortError') { if (signal.aborted) { rejectOnce( - signal.reason ?? new DOMException('The operation was aborted', 'AbortError') + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') ); } return; @@ -248,27 +289,35 @@ export const waitForMetroBackedAppReady = async ({ const attemptController = new AbortController(); const attemptSignal = raceAbortSignals([signal, attemptController.signal]); const crashPromise = waitForCrash(attemptSignal); + void crashPromise.catch(() => undefined); const readyController = new AbortController(); const readyPromise = waitForReady( raceAbortSignals([attemptSignal, readyController.signal]) ); + void readyPromise.catch(() => undefined); + const bundleRequestObserver = observeBundleRequest({ + events: metro.events, + platformId, + initialPrewarmSeen: sawPrewarmRequest, + }); try { - const bundleRequestPromise = waitForBundleRequest({ - events: metro.events, - platformId, - timeoutMs: bundleStartTimeout, - signal: attemptSignal, - initialPrewarmSeen: sawPrewarmRequest, - }); - await startAttempt(); - const bundleRequestResult = await Promise.race([ - bundleRequestPromise, - crashPromise, - ]); - sawPrewarmRequest = bundleRequestResult.sawPrewarmRequest; + if (!bundleRequestObserver.hasSeenAppRequest()) { + const bundleRequestResult = await Promise.race([ + bundleRequestObserver.waitForAppRequest(), + waitForBundleRequestTimeout({ + timeoutMs: bundleStartTimeout, + signal: attemptSignal, + sawPrewarmRequest: bundleRequestObserver.sawPrewarmRequest, + }), + crashPromise, + ]); + sawPrewarmRequest = bundleRequestResult.sawPrewarmRequest; + } else { + sawPrewarmRequest = bundleRequestObserver.sawPrewarmRequest(); + } const readyAfterBundleRequestPromise = waitForReadyAfterBundleRequest({ events: metro.events, @@ -282,10 +331,12 @@ export const waitForMetroBackedAppReady = async ({ }, }); await Promise.race([readyAfterBundleRequestPromise, crashPromise]); + bundleRequestObserver.dispose(); attemptController.abort(); onAttemptReset?.(); return; } catch (error) { + bundleRequestObserver.dispose(); readyController.abort( new DOMException('The operation was aborted', 'AbortError') ); diff --git a/packages/bundler-metro/tsconfig.json b/packages/bundler-metro/tsconfig.json index 0d5c534..403a9df 100644 --- a/packages/bundler-metro/tsconfig.json +++ b/packages/bundler-metro/tsconfig.json @@ -12,9 +12,6 @@ { "path": "../tools" }, - { - "path": "../bridge" - }, { "path": "../babel-preset" }, diff --git a/packages/bundler-metro/tsconfig.lib.json b/packages/bundler-metro/tsconfig.lib.json index fa1ce34..8f6398d 100644 --- a/packages/bundler-metro/tsconfig.lib.json +++ b/packages/bundler-metro/tsconfig.lib.json @@ -21,9 +21,6 @@ { "path": "../tools/tsconfig.lib.json" }, - { - "path": "../bridge/tsconfig.lib.json" - }, { "path": "../babel-preset/tsconfig.lib.json" } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 1c5d837..0c83098 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -52,6 +52,11 @@ export const ConfigSchema = z .min(1000, 'Bridge timeout must be at least 1 second') .default(60000), + platformReadyTimeout: z + .number() + .min(1000, 'Platform ready timeout must be at least 1 second') + .default(300000), + bundleStartTimeout: z .number() .min(1000, 'Bundle start timeout must be at least 1 second') @@ -77,7 +82,7 @@ export const ConfigSchema = z .default(false) .describe( 'Disable view flattening in React Native. This will set collapsable={true} for all View components ' + - 'to ensure they are not flattened by the native layout engine.' + 'to ensure they are not flattened by the native layout engine.' ), coverage: z @@ -87,9 +92,9 @@ export const ConfigSchema = z .optional() .describe( 'Root directory for coverage instrumentation in monorepo setups. ' + - 'Specifies the directory from which coverage data should be collected. ' + - 'Use ".." for create-react-native-library projects where tests run from example/ ' + - "but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option." + 'Specifies the directory from which coverage data should be collected. ' + + 'Use ".." for create-react-native-library projects where tests run from example/ ' + + "but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option." ), }) .optional(), @@ -100,7 +105,7 @@ export const ConfigSchema = z .default(false) .describe( 'Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. ' + - 'When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error).' + 'When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error).' ), // Deprecated property - used for migration detection diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index 9ba94c2..70784db 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -52,6 +52,7 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/actions/shared/index.cjs - name: Verify native app input @@ -72,34 +73,20 @@ runs: ${{ 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 }} + # iOS simulator boot and app installation are handled by Harness itself. # ── Android ────────────────────────────────────────────────────────────── - name: Verify Android config - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.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' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -117,7 +104,7 @@ runs: ;; esac - name: Enable KVM group perms - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -126,47 +113,24 @@ runs: ls /dev/kvm - name: Compute AVD cache key id: avd-key - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} 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) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$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) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} 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' @@ -225,116 +189,27 @@ runs: fi - name: Run E2E tests id: run-tests - if: fromJson(steps.load-config.outputs.config).platformId != 'android' shell: bash working-directory: ${{ steps.load-config.outputs.projectRoot }} env: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - - name: Run E2E tests - id: run-tests-android - if: fromJson(steps.load-config.outputs.config).platformId == 'android' - uses: reactivecircus/android-emulator-runner@v2 - env: - PRE_RUN_HOOK: ${{ inputs.preRunHook }} - AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} - HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - set -e - - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi - - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - 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 - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -344,6 +219,14 @@ runs: ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Save AVD cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 2560545..444c74d 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -46,19 +46,21 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/../shared/index.cjs - name: Verify Android config + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.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).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -76,6 +78,7 @@ runs: ;; esac - name: Enable KVM group perms + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -83,18 +86,18 @@ runs: sudo udevadm trigger --name-match=kvm ls /dev/kvm - name: Compute AVD cache key - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} id: avd-key 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) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} uses: actions/cache/restore@v4 id: avd-cache with: @@ -102,28 +105,6 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Create AVD and generate snapshot for caching - if: ${{ 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(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 }} - name: Detect Package Manager id: detect-pm shell: bash @@ -146,63 +127,27 @@ runs: fi - name: Run E2E tests id: run-tests - uses: reactivecircus/android-emulator-runner@v2 + shell: bash + working-directory: ${{ inputs.projectRoot }} env: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - set -e + HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} + run: | + export HARNESS_PROJECT_ROOT="$PWD" - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi + set +e + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + harness_exit_code=$? + set -e - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi + echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - with: - working-directory: ${{ inputs.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 - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" + if [ "$harness_exit_code" -ne 0 ]; then + exit "$harness_exit_code" + fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -212,6 +157,14 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Save AVD cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/ios/action.yml b/packages/github-action/src/ios/action.yml index 1512c12..6866932 100644 --- a/packages/github-action/src/ios/action.yml +++ b/packages/github-action/src/ios/action.yml @@ -43,18 +43,6 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | node ${{ github.action_path }}/../shared/index.cjs - - uses: futureware-tech/simulator-action@v4 - 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 - shell: bash - working-directory: ${{ inputs.projectRoot }} - run: | - xcrun simctl install booted ${{ inputs.app }} - name: Detect Package Manager id: detect-pm shell: bash @@ -83,48 +71,20 @@ runs: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/shared/index.ts b/packages/github-action/src/shared/index.ts index 46e4d2d..4882783 100644 --- a/packages/github-action/src/shared/index.ts +++ b/packages/github-action/src/shared/index.ts @@ -2,6 +2,97 @@ import { getConfig } from '@react-native-harness/config'; import path from 'node:path'; import fs from 'node:fs'; +const getHostAndroidSystemImageArch = (): + | 'x86_64' + | 'arm64-v8a' + | 'armeabi-v7a' => { + switch (process.arch) { + case 'arm64': + return 'arm64-v8a'; + case 'arm': + return 'armeabi-v7a'; + case 'x64': + default: + return 'x86_64'; + } +}; + +const resolveAvdCachingEnabled = ({ + snapshotEnabled, +}: { + snapshotEnabled?: boolean; +}): boolean => { + const override = process.env.HARNESS_AVD_CACHING; + const requestedValue = + override == null ? snapshotEnabled : override.toLowerCase() === 'true'; + + return requestedValue === true; +}; + +const getNormalizedAvdCacheConfig = ({ + emulator, + hostArch, +}: { + emulator: { + name: string; + avd?: { + apiLevel: number; + profile: string; + diskSize: string; + heapSize: string; + }; + }; + hostArch: 'x86_64' | 'arm64-v8a' | 'armeabi-v7a'; +}) => { + const avd = emulator.avd; + + if (!avd) { + return null; + } + + return { + name: emulator.name, + apiLevel: avd.apiLevel, + arch: hostArch, + profile: avd.profile.trim().toLowerCase(), + diskSize: avd.diskSize.trim().toLowerCase(), + heapSize: avd.heapSize.trim().toLowerCase(), + }; +}; + +const getResolvedRunner = ( + runner: Awaited>['config']['runners'][number] +) => { + if ( + runner.platformId !== 'android' || + runner.config.device.type !== 'emulator' + ) { + return runner; + } + + const avdCachingEnabled = resolveAvdCachingEnabled({ + snapshotEnabled: runner.config.device.avd?.snapshot?.enabled, + }); + + return { + ...runner, + config: { + ...runner.config, + device: { + ...runner.config.device, + avd: runner.config.device.avd, + }, + }, + action: { + avdCachingEnabled, + avdCacheConfig: getNormalizedAvdCacheConfig({ + emulator: runner.config.device, + hostArch: getHostAndroidSystemImageArch(), + }), + }, + }; +}; + const run = async (): Promise => { try { const projectRootInput = process.env.INPUT_PROJECTROOT; @@ -17,8 +108,9 @@ const run = async (): Promise => { console.info(`Loading React Native Harness config from: ${projectRoot}`); - const { config, projectRoot: resolvedProjectRoot } = - await getConfig(projectRoot); + const { config, projectRoot: resolvedProjectRoot } = await getConfig( + projectRoot + ); const runner = config.runners.find((runner) => runner.name === runnerInput); @@ -31,9 +123,12 @@ const run = async (): Promise => { throw new Error('GITHUB_OUTPUT environment variable is not set'); } + const resolvedRunner = getResolvedRunner(runner); const relativeProjectRoot = path.relative(process.cwd(), resolvedProjectRoot) || '.'; - const output = `config=${JSON.stringify(runner)}\nprojectRoot=${relativeProjectRoot}\n`; + const output = `config=${JSON.stringify( + resolvedRunner + )}\nprojectRoot=${relativeProjectRoot}\n`; fs.appendFileSync(githubOutput, output); } catch (error) { if (error instanceof Error) { diff --git a/packages/jest/src/__tests__/errors.test.ts b/packages/jest/src/__tests__/errors.test.ts index 02ab758..34e4ff4 100644 --- a/packages/jest/src/__tests__/errors.test.ts +++ b/packages/jest/src/__tests__/errors.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { NativeCrashError } from '../errors.js'; +import { NativeCrashError, PlatformReadyTimeoutError } from '../errors.js'; + +describe('PlatformReadyTimeoutError', () => { + it('includes the configured timeout and config hint', () => { + expect(new PlatformReadyTimeoutError(300000).message).toBe( + 'The platform did not become ready within 300000ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.' + ); + }); +}); describe('NativeCrashError', () => { it('formats the extracted stack trace in the error message', () => { @@ -35,7 +43,9 @@ describe('NativeCrashError', () => { exceptionType: 'EXC_CRASH', }); - expect(error.message).not.toContain('notify_get_state check indicated test daemon not ready'); + expect(error.message).not.toContain( + 'notify_get_state check indicated test daemon not ready' + ); expect(error.message).toContain('Signal: SIGABRT'); expect(error.message).toContain('Exception: EXC_CRASH'); expect(error.message).toContain('Process: HarnessPlayground (pid 18007)'); @@ -54,9 +64,9 @@ describe('NativeCrashError', () => { stackTrace: [frame], }); - expect(error.message.match(/MainActivity\.onCreate\(MainActivity\.kt:38\)/g)).toHaveLength( - 1 - ); + expect( + error.message.match(/MainActivity\.onCreate\(MainActivity\.kt:38\)/g) + ).toHaveLength(1); }); it('collapses the native crash stack header so jest does not reprint multiline messages', () => { @@ -65,6 +75,8 @@ describe('NativeCrashError', () => { summary: ['line one', 'line two'].join('\n'), }); - expect(error.stack).toBe('NativeCrashError: The native app crashed while preparing to run this test file.'); + expect(error.stack).toBe( + 'NativeCrashError: The native app crashed while preparing to run this test file.' + ); }); }); diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 4c545e4..d2b38bb 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -29,10 +29,9 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@react-native-harness/bundler-metro', async () => { - const actual = - await vi.importActual( - '@react-native-harness/bundler-metro' - ); + const actual = await vi.importActual< + typeof import('@react-native-harness/bundler-metro') + >('@react-native-harness/bundler-metro'); return { ...actual, @@ -51,10 +50,9 @@ vi.mock('../logs.js', () => ({ })); vi.mock('@react-native-harness/tools', async () => { - const actual = - await vi.importActual( - '@react-native-harness/tools' - ); + const actual = await vi.importActual< + typeof import('@react-native-harness/tools') + >('@react-native-harness/tools'); return { ...actual, @@ -63,7 +61,7 @@ vi.mock('@react-native-harness/tools', async () => { }); import { getHarness, waitForAppReady } from '../harness.js'; -import { StartupStallError } from '../errors.js'; +import { PlatformReadyTimeoutError, StartupStallError } from '../errors.js'; const createBridgeServer = () => { const emitter = new EventEmitter(); @@ -180,13 +178,14 @@ const createHarnessConfig = ( forwardClientLogs: false, maxAppRestarts: 2, metroPort: 8081, + platformReadyTimeout: 300_000, resetEnvironmentBetweenTestFiles: true, runners: [], unstable__enableMetroCache: false, unstable__skipAlreadyIncludedModules: false, webSocketPort: 3001, ...overrides, - }) as HarnessConfig; + } as HarnessConfig); beforeEach(() => { vi.clearAllMocks(); @@ -295,6 +294,90 @@ describe('waitForAppReady', () => { }); describe('getHarness', () => { + it('fails when the platform runner does not become ready within platformReadyTimeout', async () => { + const { serverBridge } = createBridgeServer(); + const metroInstance = createMetroInstance(); + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = vi.fn( + async () => + await new Promise((_, reject) => { + setTimeout(() => { + reject(new DOMException('The operation was aborted', 'AbortError')); + }, 20); + }) + ); + + const platform: HarnessPlatform = { + config: {}, + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + await expect( + getHarness( + createHarnessConfig({ + platformReadyTimeout: 10, + }), + platform, + '/tmp/project' + ) + ).rejects.toBeInstanceOf(PlatformReadyTimeoutError); + }); + + it('passes a platform init signal to the runner factory', async () => { + const { serverBridge } = createBridgeServer(); + const appMonitor = createAppMonitor(); + const platformInstance = createPlatformRunner({ + createAppMonitor: () => appMonitor.appMonitor, + }); + const metroInstance = createMetroInstance(); + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + + const runner = vi.fn(async () => platformInstance); + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = runner; + + const platform: HarnessPlatform = { + config: {}, + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + const harness = await getHarness( + createHarnessConfig(), + platform, + '/tmp/project' + ); + + expect(runner).toHaveBeenCalledWith( + platform.config, + expect.any(Object), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ); + + await harness.dispose(); + }); + it('routes ensureAppReady through the shared Metro startup helper', async () => { const { serverBridge, emitReady } = createBridgeServer(); const appMonitor = createAppMonitor(); @@ -310,9 +393,7 @@ describe('getHarness', () => { mocks.waitForMetroBackedAppReady.mockImplementationOnce( async (options: WaitForMetroBackedAppReadyOptions) => { await options.startAttempt(); - const readyPromise = options.waitForReady( - new AbortController().signal - ); + const readyPromise = options.waitForReady(new AbortController().signal); emitReady(); await readyPromise; } @@ -340,7 +421,9 @@ describe('getHarness', () => { }; const harness = await getHarness( - createHarnessConfig(), + createHarnessConfig({ + bridgeTimeout: 1, + }), platform, '/tmp/project' ); @@ -375,9 +458,7 @@ describe('getHarness', () => { mocks.waitForMetroBackedAppReady.mockImplementationOnce( async (options: WaitForMetroBackedAppReadyOptions) => { await options.startAttempt(); - const readyPromise = options.waitForReady( - new AbortController().signal - ); + const readyPromise = options.waitForReady(new AbortController().signal); emitReady(); await readyPromise; } @@ -473,7 +554,25 @@ describe('plugins', () => { beforeCreation: (ctx) => { ctx.state.creationCount += 1; observedHooks.push( - `beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null ? 'no-launch-options' : 'launch-options'}` + `beforeCreation:${ctx.platform.platformId}:${ + ctx.appLaunchOptions == null + ? 'no-launch-options' + : 'launch-options' + }` + ); + }, + beforeRun: (ctx) => { + observedHooks.push( + `beforeRun:${ctx.platform.platformId}:${ + ctx.appLaunchOptions == null + ? 'no-launch-options' + : 'launch-options' + }` + ); + }, + afterRun: (ctx) => { + observedHooks.push( + `afterRun:${ctx.state.creationCount}:${ctx.reason}` ); }, beforeDispose: (ctx) => { @@ -484,7 +583,9 @@ describe('plugins', () => { }, runtime: { ready: (ctx) => { - observedHooks.push(`runtime.ready:${ctx.runId}:${ctx.device.platform}`); + observedHooks.push( + `runtime.ready:${ctx.runId}:${ctx.device.platform}` + ); }, disconnected: (ctx) => { observedHooks.push(`runtime.disconnected:${ctx.reason}`); @@ -547,9 +648,11 @@ describe('plugins', () => { expect(observedHooks).toEqual([ 'beforeCreation:ios:launch-options', + 'beforeRun:ios:launch-options', 'runtime.ready:run-1:ios', 'collection.started:example.harness.ts', 'runtime.disconnected:bridge-disconnected', + 'afterRun:1:normal', 'beforeDispose:1:normal', ]); }); diff --git a/packages/jest/src/action-hooks.ts b/packages/jest/src/action-hooks.ts new file mode 100644 index 0000000..c878050 --- /dev/null +++ b/packages/jest/src/action-hooks.ts @@ -0,0 +1,66 @@ +import { + definePlugin, + type HarnessPlugin, +} from '@react-native-harness/plugins'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import type { HarnessPlatform } from '@react-native-harness/platforms'; +import { spawn } from '@react-native-harness/tools'; + +type ActionHookState = { + _unused?: never; +}; + +const getInlineHookScript = ( + name: 'PRE_RUN_HOOK' | 'AFTER_RUN_HOOK' +): string | null => { + const value = process.env[name]?.trim(); + + return value ? value : null; +}; + +const runInlineHook = async (script: string): Promise => { + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] != null + ) + ); + + await spawn('bash', ['-lc', script], { + env, + cwd: process.env.HARNESS_PROJECT_ROOT, + }); +}; + +export const createActionHooksPlugin = (): HarnessPlugin< + ActionHookState, + HarnessConfig, + HarnessPlatform +> => + definePlugin({ + name: 'github-action-hooks', + createState: () => ({}), + hooks: { + harness: { + beforeRun: async () => { + const script = getInlineHookScript('PRE_RUN_HOOK'); + + if (!script) { + return; + } + + await runInlineHook(script); + }, + afterRun: async (ctx) => { + const script = getInlineHookScript('AFTER_RUN_HOOK'); + + if (!script) { + return; + } + + process.env.HARNESS_EXIT_CODE = ctx.status === 'passed' ? '0' : '1'; + + await runInlineHook(script); + }, + }, + }, + }); diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index 0c90b2c..a7502b8 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -27,6 +27,15 @@ export class InitializationTimeoutError extends HarnessError { } } +export class PlatformReadyTimeoutError extends HarnessError { + constructor(public readonly timeout: number) { + super( + `The platform did not become ready within ${timeout}ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.` + ); + this.name = 'PlatformReadyTimeoutError'; + } +} + export type NativeCrashPhase = 'startup' | 'execution'; export type NativeCrashDetails = AppCrashDetails & { @@ -51,10 +60,7 @@ const buildNativeCrashMessage = ({ const hasCrashBlock = summary?.includes('\n') ?? false; const shouldRenderSummary = Boolean(summary) && - !( - !hasCrashBlock && - artifactType === 'ios-crash-report' - ); + !(!hasCrashBlock && artifactType === 'ios-crash-report'); if (shouldRenderSummary && summary) { lines.push(''); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index bfbfe63..06e6694 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -14,6 +14,7 @@ import { type AppMonitorEvent, type AppLaunchOptions, HarnessPlatform, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { @@ -32,8 +33,13 @@ import { type HarnessRunStatus, type HarnessRunSummary, } from '@react-native-harness/plugins'; -import { logger, createCrashArtifactWriter } from '@react-native-harness/tools'; -import { InitializationTimeoutError } from './errors.js'; +import { + logger, + createCrashArtifactWriter, + getTimeoutSignal, + raceAbortSignals, +} from '@react-native-harness/tools'; +import { PlatformReadyTimeoutError } from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; import { createCrashSupervisor, @@ -78,10 +84,7 @@ export const maybeLogMetroCacheReuse = ( platform: HarnessPlatform, projectRoot: string ): void => { - if ( - config.unstable__enableMetroCache && - isMetroCacheReusable(projectRoot) - ) { + if (config.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) { logMetroCacheReused(platform); } }; @@ -105,6 +108,30 @@ const waitForAbort = (signal: AbortSignal): Promise => { }); }; +const withPlatformReadyTimeout = async (options: { + timeout: number; + signal: AbortSignal; + work: (signal: AbortSignal) => Promise; +}): Promise => { + const timeoutSignal = getTimeoutSignal(options.timeout); + const combinedSignal = raceAbortSignals([options.signal, timeoutSignal]); + + try { + return await options.work(combinedSignal); + } catch (error) { + if ( + error instanceof DOMException && + error.name === 'AbortError' && + timeoutSignal.aborted && + !options.signal.aborted + ) { + throw new PlatformReadyTimeoutError(options.timeout); + } + + throw error; + } +}; + export const waitForAppReady = async (options: { metroInstance: MetroInstance; serverBridge: BridgeServer; @@ -211,7 +238,10 @@ const getHarnessInternal = async ( ); maybeLogMetroCacheReuse(config, platform, projectRoot); const pluginAbortController = new AbortController(); - const pluginManager = createHarnessPluginManager({ + const pluginManager = createHarnessPluginManager< + HarnessConfig, + HarnessPlatform + >({ plugins: (config.plugins ?? []) as Array< HarnessPlugin >, @@ -254,7 +284,11 @@ const getHarnessInternal = async ( pendingHookPromises.add(trackedPromise); }; const scheduleHook = < - TName extends keyof FlatHarnessHookContexts, + TName extends keyof FlatHarnessHookContexts< + object, + HarnessConfig, + HarnessPlatform + > >( name: TName, payload: Omit< @@ -303,11 +337,21 @@ const getHarnessInternal = async ( harnessLogger.debug('Metro initialized'); return instance; }), - import(platform.runner).then((module) => - module.default(platform.config, config) - ).then((instance) => { - harnessLogger.debug('platform runner initialized'); - return instance; + withPlatformReadyTimeout({ + timeout: config.platformReadyTimeout, + signal, + work: async () => { + return await import(platform.runner) + .then((module) => + module.default(platform.config, config, { + signal, + } satisfies HarnessPlatformInitOptions) + ) + .then((instance) => { + harnessLogger.debug('platform runner initialized'); + return instance; + }); + }, }), ]); } catch (error) { @@ -537,6 +581,14 @@ const getHarnessInternal = async ( let hookError: unknown; try { + await flushPendingHooks(); + await pluginManager.callHook('harness:after-run', { + runId: currentRun?.runId, + reason, + summary: currentRun?.summary, + status: currentRun?.status, + error: currentRun?.error, + }); await flushPendingHooks(); await pluginManager.callHook('harness:before-dispose', { runId: currentRun?.runId, @@ -582,8 +634,13 @@ const getHarnessInternal = async ( await pluginManager.callHook('harness:before-creation', { appLaunchOptions, }); + await flushPendingHooks(); await appMonitor.start(); harnessLogger.debug('app monitor started'); + await pluginManager.callHook('harness:before-run', { + appLaunchOptions, + }); + await flushPendingHooks(); } catch (error) { const runState = currentRun as HarnessRunState | null; @@ -591,7 +648,11 @@ const getHarnessInternal = async ( runState.error = error; currentRun = runState; } - await dispose(error instanceof DOMException && error.name === 'AbortError' ? 'abort' : 'error'); + await dispose( + error instanceof DOMException && error.name === 'AbortError' + ? 'abort' + : 'error' + ); throw error; } @@ -607,7 +668,9 @@ const getHarnessInternal = async ( } crashSupervisor.reset(); - harnessLogger.debug('app not ready, waiting for launch and runtime readiness'); + harnessLogger.debug( + 'app not ready, waiting for launch and runtime readiness' + ); await waitForAppReady({ metroInstance, serverBridge, @@ -693,25 +756,15 @@ export const getHarness = async ( platform: HarnessPlatform, projectRoot: string ): Promise => { - const abortSignal = AbortSignal.timeout(config.bridgeTimeout); harnessLogger.debug( - 'creating Harness with bridge timeout %dms', - config.bridgeTimeout + 'creating Harness with platform ready timeout %dms', + config.platformReadyTimeout ); - try { - const harness = await getHarnessInternal( - config, - platform, - projectRoot, - abortSignal - ); - return harness; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new InitializationTimeoutError(); - } - - throw error; - } + return await getHarnessInternal( + config, + platform, + projectRoot, + new AbortController().signal + ); }; diff --git a/packages/jest/src/setup.ts b/packages/jest/src/setup.ts index 07d541b..ec20a6a 100644 --- a/packages/jest/src/setup.ts +++ b/packages/jest/src/setup.ts @@ -11,6 +11,7 @@ import { logTestEnvironmentReady, logTestRunHeader } from './logs.js'; import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js'; import { HarnessPlatform } from '@react-native-harness/platforms'; import { logger } from '@react-native-harness/tools'; +import { createActionHooksPlugin } from './action-hooks.js'; const setupLogger = logger.child('setup'); @@ -66,13 +67,23 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => { const cliArgs = getAdditionalCliArgs(); if (cliArgs.metroPort != null) { - setupLogger.debug('applying CLI metro port override: %d', cliArgs.metroPort); + setupLogger.debug( + 'applying CLI metro port override: %d', + cliArgs.metroPort + ); harnessConfig = ConfigSchema.parse({ ...harnessConfig, metroPort: cliArgs.metroPort, }); } + if (process.env.PRE_RUN_HOOK || process.env.AFTER_RUN_HOOK) { + harnessConfig = ConfigSchema.parse({ + ...harnessConfig, + plugins: [...(harnessConfig.plugins ?? []), createActionHooksPlugin()], + }); + } + const selectedRunner = getHarnessRunner(harnessConfig, cliArgs); if ( diff --git a/packages/platform-android/README.md b/packages/platform-android/README.md index 49a0e02..6932cca 100644 --- a/packages/platform-android/README.md +++ b/packages/platform-android/README.md @@ -32,7 +32,12 @@ const config = { runners: [ androidPlatform({ name: 'android', - device: androidEmulator('Pixel_8_API_35'), + device: androidEmulator('Pixel_8_API_35', { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }), bundleId: 'com.your.app', }), androidPlatform({ @@ -78,7 +83,9 @@ Creates a physical Android device configuration. ## Requirements -- Android SDK installed +- On macOS and Linux, Harness can resolve the SDK root from `ANDROID_HOME`, then `ANDROID_SDK_ROOT`, then the default SDK path (`~/Library/Android/sdk` on macOS or `~/Android/Sdk` on Linux) +- For emulator runners with an `avd` config, Harness reads the runner config and automatically verifies or installs missing SDK packages, including `platform-tools`, `emulator`, `platforms;android-`, and the matching system image for the host architecture +- If the SDK root does not exist yet on macOS or Linux, Harness bootstraps Android command-line tools and accepts licenses for non-interactive installs - Android emulator or physical device connected - React Native project configured for Android diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index 6618e81..27837d2 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -1,6 +1,43 @@ -import { describe, expect, it, vi } from 'vitest'; -import { getAppUid, getLogcatTimestamp, getStartAppArgs } from '../adb.js'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SubprocessError } from '@react-native-harness/tools'; +import { + createAvd, + deleteAvd, + emulatorProcess, + getAppUid, + getLogcatTimestamp, + getStartAppArgs, + hasAvd, + installApp, + startEmulator, + waitForBoot, + waitForEmulatorDisconnect, +} from '../adb.js'; import * as tools from '@react-native-harness/tools'; +import * as environment from '../environment.js'; + +const createAbortError = () => + new DOMException('The operation was aborted', 'AbortError'); + +const createMockChildProcess = () => { + const process = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + unref: ReturnType; + }; + + process.stdout = new PassThrough(); + process.stderr = new PassThrough(); + process.unref = vi.fn(); + + return process; +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); describe('getStartAppArgs', () => { it('maps supported extras to adb am start flags', () => { @@ -45,18 +82,16 @@ describe('getStartAppArgs', () => { }); it('extracts app uid from pm list packages output', async () => { - const spawnSpy = vi - .spyOn(tools, 'spawn') - .mockResolvedValueOnce({ - stdout: - 'package:com.other.app uid:10123\npackage:com.example.app uid:10234\n', - } as Awaited>); + const spawnSpy = vi.spyOn(tools, 'spawn').mockResolvedValueOnce({ + stdout: + 'package:com.other.app uid:10123\npackage:com.example.app uid:10234\n', + } as Awaited>); await expect(getAppUid('emulator-5554', 'com.example.app')).resolves.toBe( 10234 ); - expect(spawnSpy).toHaveBeenCalledWith('adb', [ + expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [ '-s', 'emulator-5554', 'shell', @@ -68,17 +103,15 @@ describe('getStartAppArgs', () => { }); it('reads the device timestamp in logcat format', async () => { - const spawnSpy = vi - .spyOn(tools, 'spawn') - .mockResolvedValueOnce({ - stdout: "'03-12 11:35:08.000'\n", - } as Awaited>); + const spawnSpy = vi.spyOn(tools, 'spawn').mockResolvedValueOnce({ + stdout: "'03-12 11:35:08.000'\n", + } as Awaited>); await expect(getLogcatTimestamp('emulator-5554')).resolves.toBe( '03-12 11:35:08.000' ); - expect(spawnSpy).toHaveBeenCalledWith('adb', [ + expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [ '-s', 'emulator-5554', 'shell', @@ -86,4 +119,375 @@ describe('getStartAppArgs', () => { "+'%m-%d %H:%M:%S.000'", ]); }); + + it('checks whether an AVD exists', async () => { + vi.spyOn(tools, 'spawn').mockResolvedValueOnce({ + stdout: 'Pixel_6_API_33\nPixel_8_API_35\n', + } as Awaited>); + + await expect(hasAvd('Pixel_8_API_35')).resolves.toBe(true); + await expect(hasAvd('Missing_AVD')).resolves.toBe(false); + }); + + it('installs the app via adb', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValueOnce({} as Awaited>); + + await installApp('emulator-5554', '/tmp/app.apk'); + + expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [ + '-s', + 'emulator-5554', + 'install', + '-r', + '/tmp/app.apk', + ]); + }); + + it('creates an AVD and appends config overrides', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); + const verifyAndroidEmulatorSdk = vi + .spyOn(environment, 'ensureAndroidSdkPackages') + .mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(environment, 'getHostAndroidSystemImageArch').mockReturnValue( + 'x86_64' + ); + + await createAvd({ + name: 'Pixel_8_API_35', + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }); + + expect(verifyAndroidEmulatorSdk).toHaveBeenCalledWith([ + 'platform-tools', + 'emulator', + 'platforms;android-35', + 'system-images;android-35;default;x86_64', + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(1, 'bash', [ + '-lc', + expect.stringContaining( + 'create avd --force --name "Pixel_8_API_35" --package "system-images;android-35;default;x86_64" --device "pixel_8"' + ), + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(2, 'bash', [ + '-lc', + expect.stringContaining( + `'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> ` + ), + ]); + }); + + it('creates an AVD with arm64 system image packages on arm64 hosts', async () => { + vi.spyOn(tools, 'spawn').mockResolvedValue( + {} as Awaited> + ); + const ensureAndroidSdkPackages = vi + .spyOn(environment, 'ensureAndroidSdkPackages') + .mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(environment, 'getHostAndroidSystemImageArch').mockReturnValue( + 'arm64-v8a' + ); + + await createAvd({ + name: 'Pixel_8_API_35', + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }); + + expect(ensureAndroidSdkPackages).toHaveBeenCalledWith([ + 'platform-tools', + 'emulator', + 'platforms;android-35', + 'system-images;android-35;default;arm64-v8a', + ]); + }); + + it.skip('deletes both AVD directory and ini file', async () => { + await deleteAvd('Pixel_8_API_35'); + }); + + it('surfaces emulator stdout when startup fails immediately', async () => { + const child = createMockChildProcess(); + let launcherReadyResolve: (() => void) | undefined; + const launcherReady = new Promise((resolve) => { + launcherReadyResolve = resolve; + }); + + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + vi.spyOn(emulatorProcess, 'startDetachedProcess').mockImplementation(() => { + launcherReadyResolve?.(); + return child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + >; + }); + + const startPromise = startEmulator('Pixel_8_API_35'); + await launcherReady; + + child.stdout.write('Unknown AVD name [Pixel_8_API_35]\n'); + child.stdout.end(); + child.stderr.end(); + child.emit('close', 1, null); + + await expect(startPromise).rejects.toThrow( + 'Unknown AVD name [Pixel_8_API_35]' + ); + }); + + it('surfaces emulator stderr when startup fails immediately', async () => { + const child = createMockChildProcess(); + let launcherReadyResolve: (() => void) | undefined; + const launcherReady = new Promise((resolve) => { + launcherReadyResolve = resolve; + }); + + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + vi.spyOn(emulatorProcess, 'startDetachedProcess').mockImplementation(() => { + launcherReadyResolve?.(); + return child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + >; + }); + + const startPromise = startEmulator('Pixel_8_API_35'); + await launcherReady; + + child.stderr.write('emulator: panic: broken config\n'); + child.stdout.end(); + child.stderr.end(); + child.emit('close', 1, null); + + await expect(startPromise).rejects.toThrow( + 'emulator: panic: broken config' + ); + }); + + it('returns after the emulator appears without waiting for process exit', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + + vi.spyOn(emulatorProcess, 'startDetachedProcess').mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator('Pixel_8_API_35'); + + await vi.runAllTimersAsync(); + + await expect(startPromise).resolves.toBeUndefined(); + expect(child.unref).toHaveBeenCalled(); + }); + + it('passes default boot args to the emulator process', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + vi.spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + const startDetachedProcess = vi + .spyOn(emulatorProcess, 'startDetachedProcess') + .mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator('Pixel_8_API_35'); + await vi.runAllTimersAsync(); + await startPromise; + + expect(startDetachedProcess).toHaveBeenCalledWith( + expect.stringMatching(/emulator$/), + expect.arrayContaining(['-no-snapshot-load', '-no-snapshot-save']) + ); + }); + + it('passes clean snapshot generation args to the emulator process', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + vi.spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + const startDetachedProcess = vi + .spyOn(emulatorProcess, 'startDetachedProcess') + .mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator( + 'Pixel_8_API_35', + 'clean-snapshot-generation' + ); + await vi.runAllTimersAsync(); + await startPromise; + + expect(startDetachedProcess).toHaveBeenCalledWith( + expect.stringMatching(/emulator$/), + expect.arrayContaining(['-no-snapshot-load']) + ); + expect(startDetachedProcess.mock.calls[0]?.[1]).not.toContain( + '-no-snapshot-save' + ); + }); + + it('passes snapshot reuse args to the emulator process', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + vi.spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + const startDetachedProcess = vi + .spyOn(emulatorProcess, 'startDetachedProcess') + .mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator('Pixel_8_API_35', 'snapshot-reuse'); + await vi.runAllTimersAsync(); + await startPromise; + + expect(startDetachedProcess).toHaveBeenCalledWith( + expect.stringMatching(/emulator$/), + expect.arrayContaining(['-no-snapshot-save']) + ); + expect(startDetachedProcess.mock.calls[0]?.[1]).not.toContain( + '-no-snapshot-load' + ); + }); + + it('aborts while waiting for an emulator to boot', async () => { + vi.useFakeTimers(); + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + const controller = new AbortController(); + const waitPromise = waitForBoot('Pixel_8_API_35', controller.signal); + + await vi.advanceTimersByTimeAsync(1000); + controller.abort(createAbortError()); + + await expect(waitPromise).rejects.toBeInstanceOf(DOMException); + }); + + it('aborts while waiting for boot completion', async () => { + vi.useFakeTimers(); + const spawnSpy = vi.spyOn(tools, 'spawn'); + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '0\n', + } as Awaited>); + const controller = new AbortController(); + const waitPromise = waitForBoot('Pixel_8_API_35', controller.signal); + + await vi.advanceTimersByTimeAsync(1000); + controller.abort(createAbortError()); + + await expect(waitPromise).rejects.toBeInstanceOf(DOMException); + }); + + it('treats transient adb shell failures as not-yet-booted', async () => { + vi.useFakeTimers(); + const spawnSpy = vi.spyOn(tools, 'spawn'); + const transientShellError = Object.assign(new Error('adb shell failed'), { + exitCode: 1, + }); + Object.setPrototypeOf(transientShellError, SubprocessError.prototype); + + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>) + .mockRejectedValueOnce(transientShellError) + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '1\n', + } as Awaited>); + + const waitPromise = waitForBoot( + 'Pixel_8_API_35', + new AbortController().signal + ); + + await vi.advanceTimersByTimeAsync(1000); + + await expect(waitPromise).resolves.toBe('emulator-5554'); + expect(spawnSpy).toHaveBeenCalledTimes(6); + }); + + it('waits for an emulator to disconnect from adb', async () => { + vi.useFakeTimers(); + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + + const waitPromise = waitForEmulatorDisconnect( + 'emulator-5554', + new AbortController().signal + ); + + await vi.advanceTimersByTimeAsync(1000); + + await expect(waitPromise).resolves.toBeUndefined(); + expect(spawnSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/platform-android/src/__tests__/avd-config.test.ts b/packages/platform-android/src/__tests__/avd-config.test.ts new file mode 100644 index 0000000..96e3d4c --- /dev/null +++ b/packages/platform-android/src/__tests__/avd-config.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import { + getNormalizedAvdCacheConfig, + isAvdCompatible, + parseAvdConfig, + resolveAvdCachingEnabled, +} from '../avd-config.js'; +import { AndroidPlatformConfigSchema } from '../config.js'; + +describe('AVD config helpers', () => { + it('parses snapshot config from Android schema', () => { + const config = AndroidPlatformConfigSchema.parse({ + name: 'android', + bundleId: 'com.example.app', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + snapshot: { + enabled: true, + }, + }, + }, + }); + + expect(config.device.type).toBe('emulator'); + if (config.device.type === 'emulator') { + expect(config.device.avd?.snapshot?.enabled).toBe(true); + } + }); + + it('lets HARNESS_AVD_CACHING override config before interactive gating', () => { + expect( + resolveAvdCachingEnabled({ + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: false }, + }, + isInteractive: false, + env: { + HARNESS_AVD_CACHING: 'true', + }, + }) + ).toBe(true); + }); + + it('disables caching for interactive sessions even when requested', () => { + expect( + resolveAvdCachingEnabled({ + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: true }, + }, + isInteractive: true, + }) + ).toBe(false); + }); + + it('parses config.ini and matches compatible AVD metadata', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=1G +vm.heapSize=512M +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toEqual({ compatible: true }); + }); + + it('accepts disk partition sizes rewritten to bytes', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=6442450944 +vm.heapSize=512M +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toEqual({ compatible: true }); + }); + + it('rejects smaller disk partitions even when sizes are normalized', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=536870912 +vm.heapSize=512M +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toMatchObject({ + compatible: false, + reason: 'Disk size mismatch: expected 1G, got 536870912.', + }); + }); + + it('reports incompatibility when AVD metadata differs', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-34/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_7 +disk.dataPartition.size=2G +vm.heapSize=1G +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toMatchObject({ + compatible: false, + }); + }); + + it('normalizes AVD cache key input with name and host arch', () => { + expect( + getNormalizedAvdCacheConfig({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: ' Pixel_8 ', + diskSize: '1G', + heapSize: '512M', + }, + }, + hostArch: 'arm64-v8a', + }) + ).toEqual({ + name: 'Pixel_8_API_35', + apiLevel: 35, + arch: 'arm64-v8a', + profile: 'pixel_8', + diskSize: '1g', + heapSize: '512m', + }); + }); +}); diff --git a/packages/platform-android/src/__tests__/ci-action.test.ts b/packages/platform-android/src/__tests__/ci-action.test.ts new file mode 100644 index 0000000..7647add --- /dev/null +++ b/packages/platform-android/src/__tests__/ci-action.test.ts @@ -0,0 +1,81 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const workspaceRoot = path.resolve(import.meta.dirname, '../../../..'); + +describe('Android GitHub action config', () => { + it('does not duplicate Android SDK verification in the action YAML', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml).not.toContain('Verify Android SDK packages'); + expect(actionYaml).toContain( + "steps.avd-cache.outputs.cache-hit != 'true'" + ); + } + }); + + it('removes the third-party emulator runner and maps cacheAvd to HARNESS_AVD_CACHING', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml).not.toContain( + 'reactivecircus/android-emulator-runner' + ); + expect(actionYaml).toContain( + 'HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }}' + ); + expect(actionYaml).toContain( + 'fromJson(steps.load-config.outputs.config).action.avdCachingEnabled' + ); + } + }); + + it('saves the AVD cache after the Harness run step', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml.indexOf('- name: Run E2E tests')).toBeLessThan( + actionYaml.indexOf('- name: Save AVD cache') + ); + } + }); + + it('uses a cache key that includes the emulator name', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml).toContain( + "AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}'" + ); + expect(actionYaml).toContain( + 'CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH"' + ); + } + }); +}); diff --git a/packages/platform-android/src/__tests__/emulator-startup.test.ts b/packages/platform-android/src/__tests__/emulator-startup.test.ts new file mode 100644 index 0000000..790ccca --- /dev/null +++ b/packages/platform-android/src/__tests__/emulator-startup.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getEmulatorStartupArgs } from '../emulator-startup.js'; + +describe('emulator startup modes', () => { + it('builds default boot args', () => { + expect(getEmulatorStartupArgs('Pixel_8_API_35', 'default-boot')).toEqual( + expect.arrayContaining([ + '@Pixel_8_API_35', + '-no-snapshot-load', + '-no-snapshot-save', + ]) + ); + }); + + it('builds clean snapshot generation args', () => { + expect( + getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation') + ).toEqual(expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-load'])); + expect( + getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation') + ).not.toContain('-no-snapshot-save'); + }); + + it('builds snapshot reuse args', () => { + expect(getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse')).toEqual( + expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-save']) + ); + expect( + getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse') + ).not.toContain('-no-snapshot-load'); + }); +}); diff --git a/packages/platform-android/src/__tests__/environment.test.ts b/packages/platform-android/src/__tests__/environment.test.ts new file mode 100644 index 0000000..3fb7c06 --- /dev/null +++ b/packages/platform-android/src/__tests__/environment.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getAndroidSdkRoot, + getAndroidSystemImagePackage, + getDefaultUnixAndroidSdkRoot, + getHostAndroidSystemImageArch, + getRequiredAndroidSdkPackages, +} from '../environment.js'; + +describe('Android environment', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('uses the default Unix SDK root when env vars are missing', () => { + expect( + getDefaultUnixAndroidSdkRoot({ + platform: 'darwin', + homeDirectory: '/Users/tester', + }) + ).toBe('/Users/tester/Library/Android/sdk'); + + expect( + getAndroidSdkRoot( + {}, + { + platform: 'linux', + homeDirectory: '/home/tester', + } + ) + ).toBe('/home/tester/Android/Sdk'); + }); + + it('prefers ANDROID_HOME and ANDROID_SDK_ROOT over default paths', () => { + expect( + getAndroidSdkRoot( + { + ANDROID_HOME: '/env/android-home', + ANDROID_SDK_ROOT: '/env/android-sdk-root', + }, + { + platform: 'darwin', + homeDirectory: '/Users/tester', + } + ) + ).toBe('/env/android-home'); + + expect( + getAndroidSdkRoot( + { + ANDROID_SDK_ROOT: '/env/android-sdk-root', + }, + { + platform: 'linux', + homeDirectory: '/home/tester', + } + ) + ).toBe('/env/android-sdk-root'); + }); + + it('selects Android packages using the host architecture', () => { + expect(getHostAndroidSystemImageArch('x64')).toBe('x86_64'); + expect(getHostAndroidSystemImageArch('arm64')).toBe('arm64-v8a'); + expect(getAndroidSystemImagePackage(35, 'x86_64')).toBe( + 'system-images;android-35;default;x86_64' + ); + expect(getAndroidSystemImagePackage(35, 'arm64-v8a')).toBe( + 'system-images;android-35;default;arm64-v8a' + ); + }); + + it('derives emulator package requirements from runner config fields', () => { + expect( + getRequiredAndroidSdkPackages({ + apiLevel: 34, + includeEmulator: true, + architecture: 'x86_64', + }) + ).toEqual([ + 'platform-tools', + 'emulator', + 'platforms;android-34', + 'system-images;android-34;default;x86_64', + ]); + }); +}); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts new file mode 100644 index 0000000..debe02d --- /dev/null +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -0,0 +1,537 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + getAndroidEmulatorPlatformInstance, + getAndroidPhysicalDevicePlatformInstance, +} from '../instance.js'; +import * as adb from '../adb.js'; +import * as avdConfig from '../avd-config.js'; +import * as sharedPrefs from '../shared-prefs.js'; +import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; + +const harnessConfig = { + metroPort: DEFAULT_METRO_PORT, +} as HarnessConfig; +const init = { + signal: new AbortController().signal, +}; + +describe('Android platform instance', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('reuses a running emulator and does not shut it down on dispose', async () => { + const ensureAndroidEmulatorEnvironment = vi + .spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ) + .mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(sharedPrefs, 'clearHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + + const instance = await getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ); + + await instance.dispose(); + + expect(ensureAndroidEmulatorEnvironment).toHaveBeenCalledWith(35); + expect(stopEmulator).not.toHaveBeenCalled(); + }); + + it('creates and boots an emulator when missing and shuts it down on dispose', async () => { + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(false); + const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + const startEmulator = vi + .spyOn(adb, 'startEmulator') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(sharedPrefs, 'clearHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + + const instance = await getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ); + + expect(createAvd).toHaveBeenCalledWith({ + name: 'Pixel_8_API_35', + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }); + expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35', undefined); + + await instance.dispose(); + + expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + }); + + it('verifies SDK assets before booting an existing AVD', async () => { + const ensureAndroidEmulatorEnvironment = vi + .spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ) + .mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); + const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + const startEmulator = vi + .spyOn(adb, 'startEmulator') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(ensureAndroidEmulatorEnvironment).toHaveBeenCalledWith(35); + expect(createAvd).not.toHaveBeenCalled(); + expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35', undefined); + }); + + it('reuses a compatible cached AVD snapshot when caching is enabled', async () => { + vi.stubEnv('HARNESS_AVD_CACHING', 'true'); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); + vi.spyOn(avdConfig, 'readAvdConfig').mockResolvedValue({ + imageSysdir1: 'system-images/android-35/default/arm64-v8a/', + abiType: 'arm64-v8a', + hwDeviceName: 'pixel_8', + diskDataPartitionSize: '1G', + vmHeapSize: '1G', + }); + vi.spyOn(adb, 'startEmulator').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: false }, + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(adb.startEmulator).toHaveBeenCalledTimes(1); + expect(adb.startEmulator).toHaveBeenCalledWith( + 'Pixel_8_API_35', + 'snapshot-reuse' + ); + }); + + it('recreates an incompatible cached AVD before the real boot', async () => { + vi.stubEnv('HARNESS_AVD_CACHING', 'true'); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); + vi.spyOn(avdConfig, 'readAvdConfig').mockResolvedValue({ + imageSysdir1: 'system-images/android-34/default/x86_64/', + abiType: 'x86_64', + hwDeviceName: 'pixel_7', + diskDataPartitionSize: '2G', + vmHeapSize: '2G', + }); + const deleteAvd = vi.spyOn(adb, 'deleteAvd').mockResolvedValue(undefined); + const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + vi.spyOn(adb, 'startEmulator').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + const waitForEmulatorDisconnect = vi + .spyOn(adb, 'waitForEmulatorDisconnect') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: true }, + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(deleteAvd).toHaveBeenCalledWith('Pixel_8_API_35'); + expect(createAvd).toHaveBeenCalled(); + expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + expect(waitForEmulatorDisconnect).toHaveBeenCalledWith( + 'emulator-5554', + init.signal + ); + expect(adb.startEmulator).toHaveBeenNthCalledWith( + 1, + 'Pixel_8_API_35', + 'clean-snapshot-generation' + ); + expect(adb.startEmulator).toHaveBeenNthCalledWith( + 2, + 'Pixel_8_API_35', + 'snapshot-reuse' + ); + }); + + it('generates a snapshot on first run before the test boot', async () => { + vi.stubEnv('HARNESS_AVD_CACHING', 'true'); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(false); + vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + const startEmulator = vi + .spyOn(adb, 'startEmulator') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + const waitForEmulatorDisconnect = vi + .spyOn(adb, 'waitForEmulatorDisconnect') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: true }, + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(startEmulator).toHaveBeenNthCalledWith( + 1, + 'Pixel_8_API_35', + 'clean-snapshot-generation' + ); + expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + expect(waitForEmulatorDisconnect).toHaveBeenCalledWith( + 'emulator-5554', + init.signal + ); + expect(startEmulator).toHaveBeenNthCalledWith( + 2, + 'Pixel_8_API_35', + 'snapshot-reuse' + ); + }); + + it('installs the app from HARNESS_APP_PATH when missing', async () => { + const appPath = path.join(os.tmpdir(), 'HarnessPlayground.apk'); + fs.writeFileSync(appPath, 'apk'); + vi.stubEnv('HARNESS_APP_PATH', appPath); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); + const installApp = vi.spyOn(adb, 'installApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(installApp).toHaveBeenCalledWith('emulator-5554', appPath); + + fs.rmSync(appPath, { force: true }); + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH points to a missing app', async () => { + vi.stubEnv('HARNESS_APP_PATH', '/tmp/missing.apk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); + + it('throws a HarnessEmulatorConfigError when the emulator is missing and avd config is absent', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).rejects.toBeInstanceOf(HarnessEmulatorConfigError); + }); + + it('keeps physical device behavior unchanged', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['012345']); + vi.spyOn(adb, 'getDeviceInfo').mockResolvedValue({ + manufacturer: 'motorola', + model: 'moto g72', + }); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidPhysicalDevicePlatformInstance( + { + name: 'android-device', + device: { + type: 'physical', + manufacturer: 'motorola', + model: 'moto g72', + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 11c40bb..05af9c7 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,5 +1,169 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; +import { spawn as nodeSpawn } from 'node:child_process'; +import type { ChildProcessByStdio } from 'node:child_process'; +import { access, rm } from 'node:fs/promises'; +import type { Readable } from 'node:stream'; +import { + ensureAndroidSdkPackages, + getAdbBinaryPath, + getAndroidSystemImagePackage, + getAvdManagerBinaryPath, + getEmulatorBinaryPath, + getHostAndroidSystemImageArch, + getRequiredAndroidSdkPackages, + getSdkManagerBinaryPath, +} from './environment.js'; +import { + getEmulatorStartupArgs, + type EmulatorBootMode, +} from './emulator-startup.js'; + +const wait = async (ms: number): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const waitForAbort = (signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.reject(signal.reason); + } + + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true } + ); + }); +}; + +const waitWithSignal = async ( + ms: number, + signal: AbortSignal +): Promise => { + if (signal.aborted) { + throw signal.reason; + } + + await Promise.race([wait(ms), waitForAbort(signal)]); +}; + +const getAvdConfigPath = (name: string): string => + `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.avd/config.ini`; + +const EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS = 5000; +const EMULATOR_OUTPUT_BUFFER_LIMIT = 16 * 1024; + +export const emulatorProcess = { + startDetachedProcess: ( + file: string, + args: readonly string[] + ): ChildProcessByStdio => + nodeSpawn(file, args, { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }), +}; + +const appendBoundedOutput = ( + output: string, + chunk: string, + limit: number = EMULATOR_OUTPUT_BUFFER_LIMIT +): string => { + const nextOutput = output + chunk; + + if (nextOutput.length <= limit) { + return nextOutput; + } + + return nextOutput.slice(-limit); +}; + +const formatEmulatorStartupError = ({ + name, + stdout, + stderr, + exitCode, + signal, + error, +}: { + name: string; + stdout: string; + stderr: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + error?: unknown; +}): Error => { + const sections = [`Failed to start Android emulator @${name}.`]; + + if (typeof exitCode === 'number') { + sections.push(`Exit code: ${exitCode}`); + } + + if (signal) { + sections.push(`Signal: ${signal}`); + } + + if (error instanceof Error) { + sections.push(`Cause: ${error.message}`); + } + + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + + if (trimmedStdout !== '') { + sections.push(`stdout:\n${trimmedStdout}`); + } + + if (trimmedStderr !== '') { + sections.push(`stderr:\n${trimmedStderr}`); + } + + return new Error(sections.join('\n\n'), { + cause: error instanceof Error ? error : undefined, + }); +}; + +const ensureEmulatorInstalled = async (): Promise => { + const emulatorBinaryPath = getEmulatorBinaryPath(); + + try { + await access(emulatorBinaryPath); + return emulatorBinaryPath; + } catch { + await spawn(getSdkManagerBinaryPath(), ['emulator']); + await access(emulatorBinaryPath); + return emulatorBinaryPath; + } +}; + +export type CreateAvdOptions = { + name: string; + apiLevel: number; + profile: string; + diskSize: string; + heapSize: string; +}; + +export const getRequiredEmulatorPackages = (apiLevel: number): string[] => { + return getRequiredAndroidSdkPackages({ + apiLevel, + includeEmulator: true, + architecture: getHostAndroidSystemImageArch(), + }); +}; + +export const verifyAndroidEmulatorSdk = async ( + apiLevel: number +): Promise => { + await ensureAndroidSdkPackages(getRequiredEmulatorPackages(apiLevel)); +}; export const getStartAppArgs = ( bundleId: string, @@ -47,7 +211,7 @@ export const isAppInstalled = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -64,7 +228,7 @@ export const reversePort = async ( port: number, hostPort: number = port ): Promise => { - await spawn('adb', [ + await spawn(getAdbBinaryPath(), [ '-s', adbId, 'reverse', @@ -77,7 +241,14 @@ export const stopApp = async ( adbId: string, bundleId: string ): Promise => { - await spawn('adb', ['-s', adbId, 'shell', 'am', 'force-stop', bundleId]); + await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'am', + 'force-stop', + bundleId, + ]); }; export const startApp = async ( @@ -86,11 +257,15 @@ export const startApp = async ( activityName: string, options?: AndroidAppLaunchOptions ): Promise => { - await spawn('adb', ['-s', adbId, ...getStartAppArgs(bundleId, activityName, options)]); + await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + ...getStartAppArgs(bundleId, activityName, options), + ]); }; export const getDeviceIds = async (): Promise => { - const { stdout } = await spawn('adb', ['devices']); + const { stdout } = await spawn(getAdbBinaryPath(), ['devices']); return stdout .split('\n') .slice(1) // Skip header @@ -101,7 +276,13 @@ export const getDeviceIds = async (): Promise => { export const getEmulatorName = async ( adbId: string ): Promise => { - const { stdout } = await spawn('adb', ['-s', adbId, 'emu', 'avd', 'name']); + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'emu', + 'avd', + 'name', + ]); return stdout.split('\n')[0].trim() || null; }; @@ -109,7 +290,7 @@ export const getShellProperty = async ( adbId: string, property: string ): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -119,6 +300,10 @@ export const getShellProperty = async ( return stdout.trim() || null; }; +const isTransientAdbShellFailure = (error: unknown): boolean => { + return error instanceof SubprocessError && error.exitCode === 1; +}; + export type DeviceInfo = { manufacturer: string | null; model: string | null; @@ -133,12 +318,233 @@ export const getDeviceInfo = async ( }; export const isBootCompleted = async (adbId: string): Promise => { - const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed'); - return bootCompleted === '1'; + try { + const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed'); + return bootCompleted === '1'; + } catch (error) { + if (isTransientAdbShellFailure(error)) { + return false; + } + + throw error; + } }; export const stopEmulator = async (adbId: string): Promise => { - await spawn('adb', ['-s', adbId, 'emu', 'kill']); + await spawn(getAdbBinaryPath(), ['-s', adbId, 'emu', 'kill']); +}; + +export const installApp = async ( + adbId: string, + appPath: string +): Promise => { + await spawn(getAdbBinaryPath(), ['-s', adbId, 'install', '-r', appPath]); +}; + +export const hasAvd = async (name: string): Promise => { + const avds = await getAvds(); + return avds.includes(name); +}; + +export const createAvd = async ({ + name, + apiLevel, + profile, + diskSize, + heapSize, +}: CreateAvdOptions): Promise => { + const systemImagePackage = getAndroidSystemImagePackage( + apiLevel, + getHostAndroidSystemImageArch() + ); + + await verifyAndroidEmulatorSdk(apiLevel); + await spawn('bash', [ + '-lc', + `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}"`, + ]); + await spawn('bash', [ + '-lc', + `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "${getAvdConfigPath( + name + )}"`, + ]); +}; + +export const deleteAvd = async (name: string): Promise => { + await rm( + `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.avd`, + { + force: true, + recursive: true, + } + ); + await rm( + `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.ini`, + { + force: true, + } + ); +}; + +export const startEmulator = async ( + name: string, + mode: EmulatorBootMode = 'default-boot' +): Promise => { + const emulatorBinaryPath = await ensureEmulatorInstalled(); + const childProcess = emulatorProcess.startDetachedProcess( + emulatorBinaryPath, + getEmulatorStartupArgs(name, mode) + ); + + let stdout = ''; + let stderr = ''; + + childProcess.stdout?.setEncoding('utf8'); + childProcess.stderr?.setEncoding('utf8'); + + const onStdout = (chunk: string | Buffer) => { + stdout = appendBoundedOutput(stdout, chunk.toString()); + }; + const onStderr = (chunk: string | Buffer) => { + stderr = appendBoundedOutput(stderr, chunk.toString()); + }; + + childProcess.stdout?.on('data', onStdout); + childProcess.stderr?.on('data', onStderr); + + const startupAbortController = new AbortController(); + const cleanup = () => { + startupAbortController.abort(); + childProcess.stdout?.off('data', onStdout); + childProcess.stderr?.off('data', onStderr); + childProcess.removeAllListeners('error'); + childProcess.removeAllListeners('close'); + }; + + const earlyExit = new Promise((_, reject) => { + childProcess.once('error', (error) => { + reject( + formatEmulatorStartupError({ + name, + stdout, + stderr, + error, + }) + ); + }); + + childProcess.once('close', (exitCode, signal) => { + reject( + formatEmulatorStartupError({ + name, + stdout, + stderr, + exitCode, + signal, + }) + ); + }); + }); + + const observedBoot = waitForEmulator(name, startupAbortController.signal) + .then(() => 'booted' as const) + .catch((error: unknown) => { + if (startupAbortController.signal.aborted) { + return 'aborted' as const; + } + + throw error; + }); + + const observationTimeout = wait(EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS).then( + () => 'timeout' as const + ); + + try { + await Promise.race([earlyExit, observedBoot, observationTimeout]); + } finally { + cleanup(); + } + + childProcess.stdout?.destroy(); + childProcess.stderr?.destroy(); + childProcess.unref(); +}; + +export const waitForEmulator = async ( + name: string, + signal: AbortSignal +): Promise => { + while (!signal.aborted) { + const adbIds = await getDeviceIds(); + + for (const adbId of adbIds) { + if (!adbId.startsWith('emulator-')) { + continue; + } + + const emulatorName = await getEmulatorName(adbId); + + if (emulatorName === name) { + return adbId; + } + } + + await waitWithSignal(1000, signal); + } + + throw signal.reason; +}; + +export const waitForEmulatorDisconnect = async ( + adbId: string, + signal: AbortSignal +): Promise => { + while (!signal.aborted) { + const adbIds = await getDeviceIds(); + + if (!adbIds.includes(adbId)) { + return; + } + + await waitWithSignal(1000, signal); + } + + throw signal.reason; +}; + +export const waitForBoot = async ( + name: string, + signal: AbortSignal +): Promise => { + while (!signal.aborted) { + const adbIds = await getDeviceIds(); + + for (const adbId of adbIds) { + if (!adbId.startsWith('emulator-')) { + continue; + } + + const emulatorName = await getEmulatorName(adbId); + + if (emulatorName !== name) { + continue; + } + + if (await isBootCompleted(adbId)) { + return adbId; + } + } + + await waitWithSignal(1000, signal); + } + + throw signal.reason; }; export const isAppRunning = async ( @@ -146,7 +552,7 @@ export const isAppRunning = async ( bundleId: string ): Promise => { try { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -167,7 +573,7 @@ export const getAppUid = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -192,7 +598,7 @@ export const setHideErrorDialogs = async ( adbId: string, hide: boolean ): Promise => { - await spawn('adb', [ + await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -205,7 +611,7 @@ export const setHideErrorDialogs = async ( }; export const getLogcatTimestamp = async (adbId: string): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -218,7 +624,8 @@ export const getLogcatTimestamp = async (adbId: string): Promise => { export const getAvds = async (): Promise => { try { - const { stdout } = await spawn('emulator', ['-list-avds']); + const emulatorBinaryPath = await ensureEmulatorInstalled(); + const { stdout } = await spawn(emulatorBinaryPath, ['-list-avds']); return stdout .split('\n') .map((line) => line.trim()) @@ -235,7 +642,7 @@ export type AdbDevice = { }; export const getConnectedDevices = async (): Promise => { - const { stdout } = await spawn('adb', ['devices', '-l']); + const { stdout } = await spawn(getAdbBinaryPath(), ['devices', '-l']); const lines = stdout.split('\n').slice(1); const devices: AdbDevice[] = []; diff --git a/packages/platform-android/src/app-monitor.ts b/packages/platform-android/src/app-monitor.ts index fffbe51..5781ccb 100644 --- a/packages/platform-android/src/app-monitor.ts +++ b/packages/platform-android/src/app-monitor.ts @@ -6,14 +6,30 @@ import { type AppMonitorEvent, type AppMonitorListener, } from '@react-native-harness/platforms'; -import { escapeRegExp, getEmitter, logger, spawn, SubprocessError, type Subprocess } from '@react-native-harness/tools'; +import { + escapeRegExp, + getEmitter, + logger, + spawn, + SubprocessError, + type Subprocess, +} from '@react-native-harness/tools'; import * as adb from './adb.js'; import { androidCrashParser } from './crash-parser.js'; const androidAppMonitorLogger = logger.child('android-app-monitor'); const getLogcatArgs = (uid: number, fromTime: string) => - ['logcat', '-v', 'threadtime', '-b', 'crash', `--uid=${uid}`, '-T', fromTime] as const; + [ + 'logcat', + '-v', + 'threadtime', + '-b', + 'crash', + `--uid=${uid}`, + '-T', + fromTime, + ] as const; const MAX_RECENT_LOG_LINES = 200; const MAX_RECENT_CRASH_ARTIFACTS = 10; const CRASH_ARTIFACT_SETTLE_DELAY_MS = 100; @@ -29,7 +45,9 @@ const nativeCrashPattern = (bundleId: string) => const processDiedPattern = (bundleId: string) => new RegExp( - `Process\\s+${escapeRegExp(bundleId)}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, + `Process\\s+${escapeRegExp( + bundleId + )}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, 'i' ); @@ -66,7 +84,11 @@ const getAndroidLogLineCrashDetails = ({ summary: line.trim(), signal: getSignal(line), exceptionType: fatalExceptionMatch?.[1]?.trim(), - processName: processMatch ? bundleId : line.includes(bundleId) ? bundleId : undefined, + processName: processMatch + ? bundleId + : line.includes(bundleId) + ? bundleId + : undefined, pid: pid ?? (processMatch ? Number(processMatch[1]) : undefined), rawLines: [line], }; @@ -211,7 +233,9 @@ const createCrashArtifact = ({ triggerOccurredAt, artifactType: 'logcat', rawLines: - rawLines.length > 0 ? rawLines : parsedDetails.rawLines ?? details.rawLines, + rawLines.length > 0 + ? rawLines + : parsedDetails.rawLines ?? details.rawLines, }; }; @@ -265,11 +289,12 @@ const getLatestCrashArtifact = ({ matchingByPid.length > 0 ? matchingByPid : matchingByProcess.length > 0 - ? matchingByProcess - : crashArtifacts; + ? matchingByProcess + : crashArtifacts; const sortedCandidates = [...candidates].sort( (left, right) => - Math.abs(left.occurredAt - occurredAt) - Math.abs(right.occurredAt - occurredAt) + Math.abs(left.occurredAt - occurredAt) - + Math.abs(right.occurredAt - occurredAt) ); const artifact = sortedCandidates[0]; @@ -385,9 +410,10 @@ export const createAndroidAppMonitor = ({ }; const recordLogLine = (line: string) => { - recentLogLines = [...recentLogLines, { line, occurredAt: Date.now() }].slice( - -MAX_RECENT_LOG_LINES - ); + recentLogLines = [ + ...recentLogLines, + { line, occurredAt: Date.now() }, + ].slice(-MAX_RECENT_LOG_LINES); }; const recordCrashArtifact = (details?: AppCrashDetails) => { @@ -419,10 +445,14 @@ export const createAndroidAppMonitor = ({ const startLogcat = async () => { const logcatTimestamp = await adb.getLogcatTimestamp(adbId); - logcatProcess = spawn('adb', ['-s', adbId, ...getLogcatArgs(appUid, logcatTimestamp)], { - stdout: 'pipe', - stderr: 'pipe', - }); + logcatProcess = spawn( + 'adb', + ['-s', adbId, ...getLogcatArgs(appUid, logcatTimestamp)], + { + stdout: 'pipe', + stderr: 'pipe', + } + ); const currentProcess = logcatProcess; @@ -439,15 +469,23 @@ export const createAndroidAppMonitor = ({ const event = createAndroidLogEvent(line, bundleId); if (event) { - if (event.type === 'possible_crash' || event.type === 'app_exited') { + if ( + event.type === 'possible_crash' || + event.type === 'app_exited' + ) { recordCrashArtifact(event.crashDetails); } emit(event); } } } catch (error) { - if (!(error instanceof SubprocessError && error.signalName === 'SIGTERM')) { - androidAppMonitorLogger.debug('Android logcat monitor stopped', error); + if ( + !(error instanceof SubprocessError && error.signalName === 'SIGTERM') + ) { + androidAppMonitorLogger.debug( + 'Android logcat monitor stopped', + error + ); } } })(); diff --git a/packages/platform-android/src/avd-config.ts b/packages/platform-android/src/avd-config.ts new file mode 100644 index 0000000..97429f9 --- /dev/null +++ b/packages/platform-android/src/avd-config.ts @@ -0,0 +1,290 @@ +import { access, readFile } from 'node:fs/promises'; +import type { AndroidSystemImageArch } from './environment.js'; +import type { AndroidEmulator, AndroidEmulatorAVDConfig } from './config.js'; + +export type AvdConfig = { + imageSysdir1?: string; + abiType?: string; + hwDeviceName?: string; + diskDataPartitionSize?: string; + vmHeapSize?: string; +}; + +export type AvdCompatibilityResult = + | { compatible: true } + | { compatible: false; reason: string }; + +export const getAvdDirectory = (name: string): string => { + return `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.avd`; +}; + +export const getAvdConfigPath = (name: string): string => { + return `${getAvdDirectory(name)}/config.ini`; +}; + +const normalizeAvdValue = (value: string | undefined): string | undefined => { + if (!value) { + return undefined; + } + + return value.trim(); +}; + +const normalizeConfigValue = (value: string): string => { + return value.trim().toLowerCase(); +}; + +const parseSizeInBytes = (value: string | undefined): number | null => { + if (!value) { + return null; + } + + const normalizedValue = value.trim().toLowerCase(); + + if (/^\d+$/.test(normalizedValue)) { + return Number(normalizedValue); + } + + const match = normalizedValue.match(/^(\d+)([kmgt])$/i); + + if (!match) { + return null; + } + + const size = Number(match[1]); + const unit = match[2]?.toLowerCase(); + + const multiplier = + unit === 'k' + ? 1024 + : unit === 'm' + ? 1024 ** 2 + : unit === 'g' + ? 1024 ** 3 + : unit === 't' + ? 1024 ** 4 + : null; + + return multiplier == null ? null : size * multiplier; +}; + +const getApiLevelFromImageSysdir = ( + value: string | undefined +): number | null => { + const match = value?.match(/android-(\d+)/i); + return match ? Number(match[1]) : null; +}; + +const normalizeProfile = (value: string | undefined): string | undefined => { + if (!value) { + return undefined; + } + + return value + .trim() + .replace(/[\r\n]+/g, ' ') + .toLowerCase(); +}; + +export const parseAvdConfig = (contents: string): AvdConfig => { + const config: AvdConfig = {}; + + for (const line of contents.split(/\r?\n/)) { + const trimmedLine = line.trim(); + + if (trimmedLine === '' || trimmedLine.startsWith('#')) { + continue; + } + + const separatorIndex = trimmedLine.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + const key = trimmedLine.slice(0, separatorIndex).trim(); + const value = trimmedLine.slice(separatorIndex + 1).trim(); + + switch (key) { + case 'image.sysdir.1': + config.imageSysdir1 = value; + break; + case 'abi.type': + config.abiType = value; + break; + case 'hw.device.name': + config.hwDeviceName = value; + break; + case 'disk.dataPartition.size': + config.diskDataPartitionSize = value; + break; + case 'vm.heapSize': + config.vmHeapSize = value; + break; + default: + break; + } + } + + return config; +}; + +export const readAvdConfig = async ( + name: string +): Promise => { + const configPath = getAvdConfigPath(name); + + try { + await access(configPath); + } catch { + return null; + } + + return parseAvdConfig(await readFile(configPath, 'utf8')); +}; + +export const isAvdCompatible = ({ + emulator, + avdConfig, + hostArch, +}: { + emulator: AndroidEmulator; + avdConfig: AvdConfig; + hostArch: AndroidSystemImageArch; +}): AvdCompatibilityResult => { + const requestedAvdConfig = emulator.avd; + + if (!requestedAvdConfig) { + return { compatible: false, reason: 'AVD config is required.' }; + } + + if (emulator.name.trim() === '') { + return { compatible: false, reason: 'AVD name is required.' }; + } + + const apiLevel = getApiLevelFromImageSysdir(avdConfig.imageSysdir1); + + if (apiLevel !== requestedAvdConfig.apiLevel) { + return { + compatible: false, + reason: `API level mismatch: expected ${ + requestedAvdConfig.apiLevel + }, got ${apiLevel ?? 'missing'}.`, + }; + } + + if (normalizeAvdValue(avdConfig.abiType) !== hostArch) { + return { + compatible: false, + reason: `ABI mismatch: expected ${hostArch}, got ${ + normalizeAvdValue(avdConfig.abiType) ?? 'missing' + }.`, + }; + } + + if ( + normalizeProfile(avdConfig.hwDeviceName) !== + normalizeProfile(requestedAvdConfig.profile) + ) { + return { + compatible: false, + reason: `Profile mismatch: expected ${requestedAvdConfig.profile}, got ${ + avdConfig.hwDeviceName ?? 'missing' + }.`, + }; + } + + if ( + (() => { + const configuredDiskSizeBytes = parseSizeInBytes( + avdConfig.diskDataPartitionSize + ); + const requestedDiskSizeBytes = parseSizeInBytes( + requestedAvdConfig.diskSize + ); + + if (configuredDiskSizeBytes != null && requestedDiskSizeBytes != null) { + return configuredDiskSizeBytes < requestedDiskSizeBytes; + } + + return ( + normalizeConfigValue(avdConfig.diskDataPartitionSize ?? '') !== + normalizeConfigValue(requestedAvdConfig.diskSize) + ); + })() + ) { + return { + compatible: false, + reason: `Disk size mismatch: expected ${ + requestedAvdConfig.diskSize + }, got ${avdConfig.diskDataPartitionSize ?? 'missing'}.`, + }; + } + + if ( + normalizeConfigValue(avdConfig.vmHeapSize ?? '') !== + normalizeConfigValue(requestedAvdConfig.heapSize) + ) { + return { + compatible: false, + reason: `Heap size mismatch: expected ${ + requestedAvdConfig.heapSize + }, got ${avdConfig.vmHeapSize ?? 'missing'}.`, + }; + } + + return { compatible: true }; +}; + +export const getNormalizedAvdCacheConfig = ({ + emulator, + hostArch, +}: { + emulator: AndroidEmulator; + hostArch: AndroidSystemImageArch; +}): { + name: string; + apiLevel: number; + arch: AndroidSystemImageArch; + profile: string; + diskSize: string; + heapSize: string; +} | null => { + const avd = emulator.avd; + + if (!avd) { + return null; + } + + return { + name: emulator.name, + apiLevel: avd.apiLevel, + arch: hostArch, + profile: avd.profile.trim().toLowerCase(), + diskSize: avd.diskSize.trim().toLowerCase(), + heapSize: avd.heapSize.trim().toLowerCase(), + }; +}; + +export const resolveAvdCachingEnabled = ({ + avd, + isInteractive, + env = process.env, +}: { + avd?: AndroidEmulatorAVDConfig; + isInteractive: boolean; + env?: NodeJS.ProcessEnv; +}): boolean => { + const override = env.HARNESS_AVD_CACHING; + const configValue = avd?.snapshot?.enabled; + const requestedValue = + override == null ? configValue : override.toLowerCase() === 'true'; + + if (!requestedValue) { + return false; + } + + return !isInteractive; +}; diff --git a/packages/platform-android/src/config.ts b/packages/platform-android/src/config.ts index 71f8b5f..7e5f281 100644 --- a/packages/platform-android/src/config.ts +++ b/packages/platform-android/src/config.ts @@ -11,6 +11,11 @@ export const AndroidEmulatorAVDConfigSchema = z.object({ profile: z.string().min(1, 'Profile is required'), diskSize: z.string().min(1, 'Disk size is required').default('1G'), heapSize: z.string().min(1, 'Heap size is required').default('1G'), + snapshot: z + .object({ + enabled: z.boolean().optional(), + }) + .optional(), }); export const AndroidEmulatorSchema = z.object({ @@ -51,6 +56,9 @@ export type AndroidAppLaunchOptions = z.infer< export type AndroidEmulatorAVDConfig = z.infer< typeof AndroidEmulatorAVDConfigSchema >; +export type AndroidEmulatorAVDSnapshotConfig = NonNullable< + AndroidEmulatorAVDConfig['snapshot'] +>; export const isAndroidDeviceEmulator = ( device: AndroidDevice diff --git a/packages/platform-android/src/emulator-startup.ts b/packages/platform-android/src/emulator-startup.ts new file mode 100644 index 0000000..d18e5e0 --- /dev/null +++ b/packages/platform-android/src/emulator-startup.ts @@ -0,0 +1,28 @@ +export type EmulatorBootMode = + | 'default-boot' + | 'clean-snapshot-generation' + | 'snapshot-reuse'; + +const COMMON_EMULATOR_ARGS = [ + '-no-window', + '-gpu', + 'swiftshader_indirect', + '-noaudio', + '-no-boot-anim', + '-camera-back', + 'none', +] as const; + +export const getEmulatorStartupArgs = ( + name: string, + mode: EmulatorBootMode +): string[] => { + const modeArgs = + mode === 'clean-snapshot-generation' + ? ['-no-snapshot-load'] + : mode === 'snapshot-reuse' + ? ['-no-snapshot-save'] + : ['-no-snapshot-load', '-no-snapshot-save']; + + return [`@${name}`, ...modeArgs, ...COMMON_EMULATOR_ARGS]; +}; diff --git a/packages/platform-android/src/environment.ts b/packages/platform-android/src/environment.ts new file mode 100644 index 0000000..62c31a5 --- /dev/null +++ b/packages/platform-android/src/environment.ts @@ -0,0 +1,510 @@ +import { spawn } from '@react-native-harness/tools'; +import { logger } from '@react-native-harness/tools'; +import { createWriteStream } from 'node:fs'; +import { access, cp, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import https from 'node:https'; + +const CMDLINE_TOOLS_PATH_SEGMENTS = ['cmdline-tools', 'latest']; +const ANDROID_REPOSITORY_INDEX_URL = + 'https://dl.google.com/android/repository/repository2-1.xml'; +const androidEnvironmentLogger = logger.child('android-environment'); + +export type AndroidSystemImageArch = 'x86_64' | 'arm64-v8a' | 'armeabi-v7a'; + +type AndroidSdkRootOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + homeDirectory?: string; +}; + +const getConfiguredAndroidSdkRoot = ( + env: NodeJS.ProcessEnv = process.env +): string | null => { + return env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? null; +}; + +export const getDefaultUnixAndroidSdkRoot = ({ + platform = process.platform, + homeDirectory = os.homedir(), +}: Omit = {}): string | null => { + if (platform === 'darwin') { + return path.join(homeDirectory, 'Library', 'Android', 'sdk'); + } + + if (platform === 'linux') { + return path.join(homeDirectory, 'Android', 'Sdk'); + } + + return null; +}; + +const canBootstrapAndroidSdk = ( + platform: NodeJS.Platform = process.platform +) => { + return platform === 'darwin' || platform === 'linux'; +}; + +const pathExists = async (filePath: string): Promise => { + try { + await access(filePath); + return true; + } catch { + return false; + } +}; + +const quoteShell = (value: string): string => { + return `'${value.replace(/'/g, `'\\''`)}'`; +}; + +const downloadText = async (url: string): Promise => { + return new Promise((resolve, reject) => { + const request = https.get(url, (response) => { + const { statusCode = 0, headers } = response; + + if ( + statusCode >= 300 && + statusCode < 400 && + typeof headers.location === 'string' + ) { + response.resume(); + resolve(downloadText(headers.location)); + return; + } + + if (statusCode !== 200) { + response.resume(); + reject( + new Error( + `Failed to download Android repository index from ${url} (status ${statusCode}).` + ) + ); + return; + } + + response.setEncoding('utf8'); + + let body = ''; + response.on('data', (chunk: string) => { + body += chunk; + }); + response.once('end', () => { + resolve(body); + }); + }); + + request.once('error', reject); + }); +}; + +const downloadFile = async ( + url: string, + destinationPath: string +): Promise => { + await new Promise((resolve, reject) => { + const request = https.get(url, (response) => { + const { statusCode = 0, headers } = response; + + if ( + statusCode >= 300 && + statusCode < 400 && + typeof headers.location === 'string' + ) { + response.resume(); + resolve(downloadFile(headers.location, destinationPath)); + return; + } + + if (statusCode !== 200) { + response.resume(); + reject( + new Error( + `Failed to download Android command-line tools from ${url} (status ${statusCode}).` + ) + ); + return; + } + + const output = createWriteStream(destinationPath); + pipeline(response, output).then(resolve).catch(reject); + }); + + request.once('error', reject); + }); +}; + +const getCommandLineToolsArchiveUrl = async ( + platform: NodeJS.Platform = process.platform +): Promise => { + const archivePlatform = + platform === 'darwin' ? 'mac' : platform === 'linux' ? 'linux' : null; + + if (!archivePlatform) { + throw new Error( + 'Automatic Android SDK bootstrap is only supported on macOS and Linux.' + ); + } + + const repositoryIndex = await downloadText(ANDROID_REPOSITORY_INDEX_URL); + const archivePattern = new RegExp( + `commandlinetools-${archivePlatform}-(\\d+)_latest\\.zip`, + 'g' + ); + const matches = [...repositoryIndex.matchAll(archivePattern)]; + + if (matches.length === 0) { + throw new Error( + `Failed to resolve Android command-line tools archive for ${archivePlatform}.` + ); + } + + const newestArchive = matches + .map((match) => ({ + fileName: match[0], + revision: Number(match[1]), + })) + .sort((left, right) => right.revision - left.revision)[0]; + + return `https://dl.google.com/android/repository/${newestArchive.fileName}`; +}; + +const ensureAndroidCommandLineTools = async ( + sdkRoot: string, + platform: NodeJS.Platform = process.platform +): Promise => { + if ( + (await pathExists(getSdkManagerBinaryPath(sdkRoot))) && + (await pathExists(getAvdManagerBinaryPath(sdkRoot))) + ) { + return; + } + + if (!canBootstrapAndroidSdk(platform)) { + throw new Error( + 'Android command-line tools are missing. Set ANDROID_HOME or ANDROID_SDK_ROOT to an initialized SDK.' + ); + } + + androidEnvironmentLogger.info( + 'Bootstrapping Android command-line tools in %s', + sdkRoot + ); + + await mkdir(sdkRoot, { recursive: true }); + + const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'android-cmdline-tools-') + ); + const archivePath = path.join(temporaryDirectory, 'cmdline-tools.zip'); + const extractedPath = path.join(temporaryDirectory, 'extracted'); + const sourceDirectory = path.join(extractedPath, 'cmdline-tools'); + const targetDirectory = path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS); + + try { + await downloadFile( + await getCommandLineToolsArchiveUrl(platform), + archivePath + ); + await spawn('unzip', ['-q', archivePath, '-d', extractedPath]); + await rm(targetDirectory, { force: true, recursive: true }); + await mkdir(path.dirname(targetDirectory), { recursive: true }); + await cp(sourceDirectory, targetDirectory, { recursive: true }); + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } +}; + +const acceptAndroidLicenses = async (sdkRoot: string): Promise => { + const sdkManagerBinaryPath = getSdkManagerBinaryPath(sdkRoot); + + await spawn( + 'bash', + [ + '-lc', + `yes | ${quoteShell(sdkManagerBinaryPath)} --sdk_root=${quoteShell( + sdkRoot + )} --licenses >/dev/null`, + ], + { + env: getAndroidProcessEnv({ + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + }), + } + ); +}; + +const getPackageVerificationPath = ( + sdkRoot: string, + packageName: string +): string | null => { + if (packageName === 'platform-tools') { + return getAdbBinaryPath(sdkRoot); + } + + if (packageName === 'emulator') { + return getEmulatorBinaryPath(sdkRoot); + } + + if (packageName.startsWith('platforms;android-')) { + return path.join(sdkRoot, packageName.replace(';', '/')); + } + + if (packageName.startsWith('system-images;android-')) { + return path.join(sdkRoot, packageName.replaceAll(';', path.sep)); + } + + return null; +}; + +const getMissingAndroidSdkPackages = async ( + sdkRoot: string, + packages: readonly string[] +): Promise => { + const missingPackages: string[] = []; + + for (const packageName of packages) { + const verificationPath = getPackageVerificationPath(sdkRoot, packageName); + + if (!verificationPath) { + continue; + } + + if (!(await pathExists(verificationPath))) { + missingPackages.push(packageName); + } + } + + return missingPackages; +}; + +const installAndroidSdkPackages = async ( + sdkRoot: string, + packages: readonly string[] +): Promise => { + if (packages.length === 0) { + return; + } + + const sdkManagerBinaryPath = getSdkManagerBinaryPath(sdkRoot); + const packageArgs = packages + .map((packageName) => quoteShell(packageName)) + .join(' '); + + androidEnvironmentLogger.info( + 'Installing missing Android SDK packages: %s', + packages.join(', ') + ); + + await acceptAndroidLicenses(sdkRoot); + await spawn( + 'bash', + [ + '-lc', + `yes | ${quoteShell(sdkManagerBinaryPath)} --sdk_root=${quoteShell( + sdkRoot + )} ${packageArgs}`, + ], + { + env: getAndroidProcessEnv({ + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + }), + } + ); +}; + +export const getAndroidSdkRoot = ( + env: NodeJS.ProcessEnv = process.env, + options: Omit = {} +): string | null => { + return ( + getConfiguredAndroidSdkRoot(env) ?? getDefaultUnixAndroidSdkRoot(options) + ); +}; + +const getRequiredAndroidSdkRoot = ( + env: NodeJS.ProcessEnv = process.env, + options: Omit = {} +): string => { + const sdkRoot = getAndroidSdkRoot(env, options); + + if (!sdkRoot) { + throw new Error( + 'Android SDK root is not configured. Set ANDROID_HOME or ANDROID_SDK_ROOT.' + ); + } + + return sdkRoot; +}; + +export const getHostAndroidSystemImageArch = ( + architecture: string = process.arch +): AndroidSystemImageArch => { + switch (architecture) { + case 'arm64': + return 'arm64-v8a'; + case 'arm': + return 'armeabi-v7a'; + case 'x64': + default: + return 'x86_64'; + } +}; + +export const getAndroidPlatformPackage = (apiLevel: number): string => { + return `platforms;android-${apiLevel}`; +}; + +export const getAndroidSystemImagePackage = ( + apiLevel: number, + architecture: AndroidSystemImageArch = getHostAndroidSystemImageArch() +): string => { + return `system-images;android-${apiLevel};default;${architecture}`; +}; + +export const getRequiredAndroidSdkPackages = ({ + apiLevel, + includeEmulator = false, + architecture = getHostAndroidSystemImageArch(), +}: { + apiLevel?: number; + includeEmulator?: boolean; + architecture?: AndroidSystemImageArch; +} = {}): string[] => { + const packages = ['platform-tools']; + + if (!includeEmulator) { + return packages; + } + + packages.push('emulator'); + + if (typeof apiLevel === 'number') { + packages.push(getAndroidPlatformPackage(apiLevel)); + packages.push(getAndroidSystemImagePackage(apiLevel, architecture)); + } + + return packages; +}; + +export const ensureAndroidSdkPackages = async ( + packages: readonly string[], + { + env = process.env, + platform = process.platform, + homeDirectory = os.homedir(), + }: AndroidSdkRootOptions = {} +): Promise => { + const sdkRoot = getRequiredAndroidSdkRoot(env, { platform, homeDirectory }); + + await mkdir(sdkRoot, { recursive: true }); + await ensureAndroidCommandLineTools(sdkRoot, platform); + + const missingPackages = await getMissingAndroidSdkPackages(sdkRoot, packages); + + if (missingPackages.length > 0) { + await installAndroidSdkPackages(sdkRoot, missingPackages); + } + + const unresolvedPackages = await getMissingAndroidSdkPackages( + sdkRoot, + packages + ); + + if (unresolvedPackages.length > 0) { + throw new Error( + `Android SDK packages are still missing after installation: ${unresolvedPackages.join( + ', ' + )}` + ); + } + + return sdkRoot; +}; + +export const ensureAndroidDiscoveryEnvironment = async (): Promise => { + initializeAndroidProcessEnv(); + + return ensureAndroidSdkPackages( + getRequiredAndroidSdkPackages({ includeEmulator: true }) + ); +}; + +export const ensureAndroidPhysicalDeviceEnvironment = + async (): Promise => { + initializeAndroidProcessEnv(); + + return ensureAndroidSdkPackages(getRequiredAndroidSdkPackages()); + }; + +export const ensureAndroidEmulatorEnvironment = async ( + apiLevel: number +): Promise => { + initializeAndroidProcessEnv(); + + return ensureAndroidSdkPackages( + getRequiredAndroidSdkPackages({ + apiLevel, + includeEmulator: true, + }) + ); +}; + +export const getAndroidProcessEnv = ( + env: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv => { + const sdkRoot = getAndroidSdkRoot(env); + + if (!sdkRoot) { + return env; + } + + const platformToolsPath = path.join(sdkRoot, 'platform-tools'); + const emulatorPath = path.join(sdkRoot, 'emulator'); + const cmdlineToolsPath = path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS); + const cmdlineToolsBinPath = path.join(cmdlineToolsPath, 'bin'); + const currentPath = env.PATH ?? ''; + const pathEntries = [ + platformToolsPath, + emulatorPath, + cmdlineToolsPath, + cmdlineToolsBinPath, + currentPath, + ].filter((entry) => entry !== ''); + + return { + ...env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + ANDROID_AVD_HOME: path.join(os.homedir(), '.android', 'avd'), + PATH: pathEntries.join(path.delimiter), + }; +}; + +export const initializeAndroidProcessEnv = (): void => { + Object.assign(process.env, getAndroidProcessEnv()); +}; + +export const getAdbBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => path.join(sdkRoot, 'platform-tools', 'adb'); + +export const getEmulatorBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => path.join(sdkRoot, 'emulator', 'emulator'); + +export const getSdkManagerBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => + path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS, 'bin', 'sdkmanager'); + +export const getAvdManagerBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => + path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS, 'bin', 'avdmanager'); diff --git a/packages/platform-android/src/errors.ts b/packages/platform-android/src/errors.ts new file mode 100644 index 0000000..aad1e0c --- /dev/null +++ b/packages/platform-android/src/errors.ts @@ -0,0 +1,19 @@ +export class HarnessAppPathError extends Error { + constructor(reason: 'missing' | 'invalid', appPath?: string) { + super( + reason === 'missing' + ? 'App is not installed on the emulator and HARNESS_APP_PATH is not set.' + : `HARNESS_APP_PATH points to a missing APK: ${appPath ?? ''}` + ); + this.name = 'HarnessAppPathError'; + } +} + +export class HarnessEmulatorConfigError extends Error { + constructor(deviceName: string) { + super( + `Android emulator "${deviceName}" is not running and no AVD config was provided. Add the "avd" property to this runner config so Harness can create and boot the emulator.` + ); + this.name = 'HarnessEmulatorConfigError'; + } +} diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index ba6b93c..1c750ee 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -4,4 +4,10 @@ export { androidPlatform, } from './factory.js'; export type { AndroidPlatformConfig } from './config.js'; -export { getRunTargets } from './targets.js'; \ No newline at end of file +export { + getNormalizedAvdCacheConfig, + resolveAvdCachingEnabled, +} from './avd-config.js'; +export { getHostAndroidSystemImageArch } from './environment.js'; +export { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; +export { getRunTargets } from './targets.js'; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts new file mode 100644 index 0000000..3f8c4bb --- /dev/null +++ b/packages/platform-android/src/instance.ts @@ -0,0 +1,359 @@ +import { + AppNotInstalledError, + CreateAppMonitorOptions, + DeviceNotFoundError, + type HarnessPlatformInitOptions, + HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { logger } from '@react-native-harness/tools'; +import { + AndroidPlatformConfig, + assertAndroidDeviceEmulator, + assertAndroidDevicePhysical, +} from './config.js'; +import { + isAvdCompatible, + readAvdConfig, + resolveAvdCachingEnabled, +} from './avd-config.js'; +import { getAdbId } from './adb-id.js'; +import * as adb from './adb.js'; +import { + applyHarnessDebugHttpHost, + clearHarnessDebugHttpHost, +} from './shared-prefs.js'; +import { getDeviceName } from './utils.js'; +import { createAndroidAppMonitor } from './app-monitor.js'; +import { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; +import { + ensureAndroidEmulatorEnvironment, + getHostAndroidSystemImageArch, +} from './environment.js'; +import { isInteractive } from '@react-native-harness/tools'; +import fs from 'node:fs'; + +const androidInstanceLogger = logger.child('android-instance'); + +const getHarnessAppPath = (): string => { + const appPath = process.env.HARNESS_APP_PATH; + + if (!appPath) { + throw new HarnessAppPathError('missing'); + } + + if (!fs.existsSync(appPath)) { + throw new HarnessAppPathError('invalid', appPath); + } + + return appPath; +}; + +const configureAndroidRuntime = async ( + adbId: string, + config: AndroidPlatformConfig, + harnessConfig: HarnessConfig +): Promise => { + const metroPort = harnessConfig.metroPort; + + await Promise.all([ + adb.reversePort(adbId, metroPort), + adb.reversePort(adbId, 8080), + adb.setHideErrorDialogs(adbId, true), + applyHarnessDebugHttpHost(adbId, config.bundleId, `localhost:${metroPort}`), + ]); + + return adb.getAppUid(adbId, config.bundleId); +}; + +const startAndWaitForBoot = async ({ + emulatorName, + signal, + mode, +}: { + emulatorName: string; + signal: AbortSignal; + mode?: Parameters[1]; +}): Promise => { + await adb.startEmulator(emulatorName, mode); + return adb.waitForBoot(emulatorName, signal); +}; + +const recreateAvd = async ({ + emulatorConfig, +}: { + emulatorConfig: Extract< + AndroidPlatformConfig['device'], + { type: 'emulator' } + >; +}): Promise => { + if (!emulatorConfig.avd) { + throw new HarnessEmulatorConfigError(emulatorConfig.name); + } + + await adb.createAvd({ + name: emulatorConfig.name, + apiLevel: emulatorConfig.avd.apiLevel, + profile: emulatorConfig.avd.profile, + diskSize: emulatorConfig.avd.diskSize, + heapSize: emulatorConfig.avd.heapSize, + }); +}; + +const prepareCachedAvd = async ({ + emulatorConfig, + signal, +}: { + emulatorConfig: Extract< + AndroidPlatformConfig['device'], + { type: 'emulator' } + >; + signal: AbortSignal; +}): Promise => { + const emulatorName = emulatorConfig.name; + const hostArch = getHostAndroidSystemImageArch(); + const hasExistingAvd = await adb.hasAvd(emulatorName); + const avdConfig = hasExistingAvd ? await readAvdConfig(emulatorName) : null; + const compatibility = + avdConfig == null + ? { compatible: false as const, reason: 'Missing AVD config.ini.' } + : isAvdCompatible({ + emulator: emulatorConfig, + avdConfig, + hostArch, + }); + + if (!hasExistingAvd || !compatibility.compatible) { + logger.info( + hasExistingAvd + ? 'Recreating incompatible Android emulator %s...' + : 'Creating Android emulator %s...', + emulatorName + ); + + if (hasExistingAvd && !compatibility.compatible) { + androidInstanceLogger.debug( + 'Android AVD %s is not reusable: %s', + emulatorName, + compatibility.reason + ); + await adb.deleteAvd(emulatorName); + } + + await recreateAvd({ emulatorConfig }); + + const generationAdbId = await startAndWaitForBoot({ + emulatorName, + signal, + mode: 'clean-snapshot-generation', + }); + + logger.info('Saving Android emulator snapshot for %s...', emulatorName); + await adb.stopEmulator(generationAdbId); + await adb.waitForEmulatorDisconnect(generationAdbId, signal); + } else { + logger.info('Using cached Android emulator %s...', emulatorName); + } + + return startAndWaitForBoot({ + emulatorName, + signal, + mode: 'snapshot-reuse', + }); +}; + +export const getAndroidEmulatorPlatformInstance = async ( + config: AndroidPlatformConfig, + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions +): Promise => { + assertAndroidDeviceEmulator(config.device); + const emulatorConfig = config.device; + const emulatorName = emulatorConfig.name; + const avdConfig = emulatorConfig.avd; + const avdCachingEnabled = resolveAvdCachingEnabled({ + avd: avdConfig, + isInteractive: isInteractive(), + }); + + let adbId = await getAdbId(emulatorConfig); + let startedByHarness = false; + + androidInstanceLogger.debug( + 'resolved Android emulator %s with adb id %s', + emulatorConfig.name, + adbId ?? 'not-found' + ); + + if (!adbId) { + if (!avdConfig) { + throw new HarnessEmulatorConfigError(emulatorConfig.name); + } + + await ensureAndroidEmulatorEnvironment(avdConfig.apiLevel); + + adbId = avdCachingEnabled + ? await prepareCachedAvd({ + emulatorConfig, + signal: init.signal, + }) + : await (async () => { + if (!(await adb.hasAvd(emulatorConfig.name))) { + logger.info('Creating Android emulator %s...', emulatorName); + androidInstanceLogger.debug( + 'creating Android AVD %s before startup', + emulatorConfig.name + ); + await recreateAvd({ emulatorConfig }); + } else { + logger.info('Using existing Android emulator %s...', emulatorName); + } + + androidInstanceLogger.debug( + 'starting Android emulator %s', + emulatorConfig.name + ); + return startAndWaitForBoot({ + emulatorName: emulatorConfig.name, + signal: init.signal, + }); + })(); + + startedByHarness = true; + + androidInstanceLogger.debug( + 'Android emulator %s connected as %s', + emulatorConfig.name, + adbId + ); + } else if (emulatorConfig.avd) { + await ensureAndroidEmulatorEnvironment(emulatorConfig.avd.apiLevel); + } + + if (!adbId) { + throw new DeviceNotFoundError(getDeviceName(emulatorConfig)); + } + + androidInstanceLogger.debug( + 'waiting for Android emulator %s to finish booting', + adbId + ); + + const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); + + if (!isInstalled) { + const appPath = getHarnessAppPath(); + await adb.installApp(adbId, appPath); + } + + const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + + return { + startApp: async (options) => { + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + restartApp: async (options) => { + await adb.stopApp(adbId, config.bundleId); + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + stopApp: async () => { + await adb.stopApp(adbId, config.bundleId); + }, + dispose: async () => { + await adb.stopApp(adbId, config.bundleId); + await clearHarnessDebugHttpHost(adbId, config.bundleId); + await adb.setHideErrorDialogs(adbId, false); + + if (startedByHarness) { + logger.info('Shutting down Android emulator %s...', emulatorName); + await adb.stopEmulator(adbId); + } + }, + isAppRunning: async () => { + return await adb.isAppRunning(adbId, config.bundleId); + }, + createAppMonitor: (options?: CreateAppMonitorOptions) => + createAndroidAppMonitor({ + adbId, + bundleId: config.bundleId, + appUid, + crashArtifactWriter: options?.crashArtifactWriter, + }), + }; +}; + +export const getAndroidPhysicalDevicePlatformInstance = async ( + config: AndroidPlatformConfig, + harnessConfig: HarnessConfig +): Promise => { + assertAndroidDevicePhysical(config.device); + + const adbId = await getAdbId(config.device); + + if (!adbId) { + throw new DeviceNotFoundError(getDeviceName(config.device)); + } + + const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); + + if (!isInstalled) { + throw new AppNotInstalledError( + config.bundleId, + getDeviceName(config.device) + ); + } + + const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + + return { + startApp: async (options) => { + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + restartApp: async (options) => { + await adb.stopApp(adbId, config.bundleId); + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + stopApp: async () => { + await adb.stopApp(adbId, config.bundleId); + }, + dispose: async () => { + await adb.stopApp(adbId, config.bundleId); + await clearHarnessDebugHttpHost(adbId, config.bundleId); + await adb.setHideErrorDialogs(adbId, false); + }, + isAppRunning: async () => { + return await adb.isAppRunning(adbId, config.bundleId); + }, + createAppMonitor: (options?: CreateAppMonitorOptions) => + createAndroidAppMonitor({ + adbId, + bundleId: config.bundleId, + appUid, + crashArtifactWriter: options?.crashArtifactWriter, + }), + }; +}; diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index a2f220c..eec611a 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -1,92 +1,47 @@ import { - DeviceNotFoundError, - AppNotInstalledError, - CreateAppMonitorOptions, HarnessPlatformRunner, + type HarnessPlatformInitOptions, } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; import { AndroidPlatformConfigSchema, type AndroidPlatformConfig, + isAndroidDeviceEmulator, } from './config.js'; -import { getAdbId } from './adb-id.js'; -import * as adb from './adb.js'; import { - applyHarnessDebugHttpHost, - clearHarnessDebugHttpHost, -} from './shared-prefs.js'; -import { getDeviceName } from './utils.js'; -import { createAndroidAppMonitor } from './app-monitor.js'; + getAndroidEmulatorPlatformInstance, + getAndroidPhysicalDevicePlatformInstance, +} from './instance.js'; +import { + ensureAndroidEmulatorEnvironment, + ensureAndroidPhysicalDeviceEnvironment, + initializeAndroidProcessEnv, +} from './environment.js'; const getAndroidRunner = async ( config: AndroidPlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions ): Promise => { const parsedConfig = AndroidPlatformConfigSchema.parse(config); - const adbId = await getAdbId(parsedConfig.device); - if (!adbId) { - throw new DeviceNotFoundError(getDeviceName(parsedConfig.device)); - } + initializeAndroidProcessEnv(); - const isInstalled = await adb.isAppInstalled(adbId, parsedConfig.bundleId); + if (isAndroidDeviceEmulator(parsedConfig.device)) { + if (parsedConfig.device.avd) { + await ensureAndroidEmulatorEnvironment(parsedConfig.device.avd.apiLevel); + } - if (!isInstalled) { - throw new AppNotInstalledError( - parsedConfig.bundleId, - getDeviceName(parsedConfig.device) + return getAndroidEmulatorPlatformInstance( + parsedConfig, + harnessConfig, + init ); } - const metroPort = harnessConfig.metroPort; - - await Promise.all([ - adb.reversePort(adbId, metroPort), - adb.reversePort(adbId, 8080), - adb.setHideErrorDialogs(adbId, true), - applyHarnessDebugHttpHost(adbId, parsedConfig.bundleId, `localhost:${metroPort}`), - ]); - const appUid = await adb.getAppUid(adbId, parsedConfig.bundleId); + await ensureAndroidPhysicalDeviceEnvironment(); - return { - startApp: async (options) => { - await adb.startApp( - adbId, - parsedConfig.bundleId, - parsedConfig.activityName, - (options as typeof parsedConfig.appLaunchOptions | undefined) ?? - parsedConfig.appLaunchOptions - ); - }, - restartApp: async (options) => { - await adb.stopApp(adbId, parsedConfig.bundleId); - await adb.startApp( - adbId, - parsedConfig.bundleId, - parsedConfig.activityName, - (options as typeof parsedConfig.appLaunchOptions | undefined) ?? - parsedConfig.appLaunchOptions - ); - }, - stopApp: async () => { - await adb.stopApp(adbId, parsedConfig.bundleId); - }, - dispose: async () => { - await adb.stopApp(adbId, parsedConfig.bundleId); - await clearHarnessDebugHttpHost(adbId, parsedConfig.bundleId); - await adb.setHideErrorDialogs(adbId, false); - }, - isAppRunning: async () => { - return await adb.isAppRunning(adbId, parsedConfig.bundleId); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => - createAndroidAppMonitor({ - adbId, - bundleId: parsedConfig.bundleId, - appUid, - crashArtifactWriter: options?.crashArtifactWriter, - }), - }; + return getAndroidPhysicalDevicePlatformInstance(parsedConfig, harnessConfig); }; export default getAndroidRunner; diff --git a/packages/platform-android/src/targets.ts b/packages/platform-android/src/targets.ts index 694435e..ea4765a 100644 --- a/packages/platform-android/src/targets.ts +++ b/packages/platform-android/src/targets.ts @@ -1,7 +1,10 @@ -import { RunTarget } from "@react-native-harness/platforms"; +import { RunTarget } from '@react-native-harness/platforms'; import * as adb from './adb.js'; +import { ensureAndroidDiscoveryEnvironment } from './environment.js'; export const getRunTargets = async (): Promise => { + await ensureAndroidDiscoveryEnvironment(); + const [avds, connectedDevices] = await Promise.all([ adb.getAvds(), adb.getConnectedDevices(), @@ -15,9 +18,9 @@ export const getRunTargets = async (): Promise => { name: avd, platform: 'android', description: 'Android emulator', - device: { - name: avd, - }, + device: { + name: avd, + }, }); } @@ -27,10 +30,10 @@ export const getRunTargets = async (): Promise => { name: `${device.manufacturer} ${device.model}`, platform: 'android', description: `Physical device (${device.id})`, - device: { - manufacturer: device.manufacturer, - model: device.model, - }, + device: { + manufacturer: device.manufacturer, + model: device.model, + }, }); } diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index 975863d..891d8a8 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -10,14 +10,22 @@ import { import * as simctl from '../xcrun/simctl.js'; import * as devicectl from '../xcrun/devicectl.js'; import * as libimobiledevice from '../libimobiledevice.js'; +import { HarnessAppPathError } from '../errors.js'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; const harnessConfig = { metroPort: DEFAULT_METRO_PORT, } as HarnessConfig; +const init = { + signal: new AbortController().signal, +}; describe('iOS platform instance dependency validation', () => { beforeEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it('does not validate libimobiledevice before creating a simulator instance', async () => { @@ -33,12 +41,16 @@ describe('iOS platform instance dependency validation', () => { const config = { name: 'ios', - device: { type: 'simulator' as const, name: 'iPhone 16 Pro', systemVersion: '18.0' }, + device: { + type: 'simulator' as const, + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, bundleId: 'com.harnessplayground', }; await expect( - getAppleSimulatorPlatformInstance(config, harnessConfig) + getAppleSimulatorPlatformInstance(config, harnessConfig, init) ).resolves.toBeDefined(); expect(assertInstalled).not.toHaveBeenCalled(); }); @@ -61,12 +73,13 @@ describe('iOS platform instance dependency validation', () => { }); it('still discovers the simulator without libimobiledevice', async () => { - vi.spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled').mockResolvedValue( - undefined - ); - const getSimulatorId = vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue( - 'sim-udid' - ); + vi.spyOn( + libimobiledevice, + 'assertLibimobiledeviceInstalled' + ).mockResolvedValue(undefined); + const getSimulatorId = vi + .spyOn(simctl, 'getSimulatorId') + .mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( @@ -75,20 +88,25 @@ describe('iOS platform instance dependency validation', () => { const config = { name: 'ios', - device: { type: 'simulator' as const, name: 'iPhone 16 Pro', systemVersion: '18.0' }, + device: { + type: 'simulator' as const, + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, bundleId: 'com.harnessplayground', }; await expect( - getAppleSimulatorPlatformInstance(config, harnessConfig) + getAppleSimulatorPlatformInstance(config, harnessConfig, init) ).resolves.toBeDefined(); expect(getSimulatorId).toHaveBeenCalled(); }); it('does not try to discover the physical device when the dependency is missing', async () => { - vi.spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled').mockRejectedValue( - new Error('missing') - ); + vi.spyOn( + libimobiledevice, + 'assertLibimobiledeviceInstalled' + ).mockRejectedValue(new Error('missing')); const getDeviceId = vi.spyOn(devicectl, 'getDeviceId'); const config = { @@ -102,4 +120,264 @@ describe('iOS platform instance dependency validation', () => { ).rejects.toThrow('missing'); expect(getDeviceId).not.toHaveBeenCalled(); }); + + it('reuses a booted simulator and does not shut it down on dispose', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + const stopApp = vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + const clearOverride = vi + .spyOn(simctl, 'clearHarnessJsLocationOverride') + .mockResolvedValue(undefined); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + const applyOverride = vi + .spyOn(simctl, 'applyHarnessJsLocationOverride') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init + ); + + expect(applyOverride).toHaveBeenCalledWith( + 'sim-udid', + 'com.harnessplayground', + 'localhost:8081' + ); + + await instance.dispose(); + + expect(stopApp).toHaveBeenCalledWith('sim-udid', 'com.harnessplayground'); + expect(clearOverride).toHaveBeenCalledWith( + 'sim-udid', + 'com.harnessplayground' + ); + expect(shutdownSimulator).not.toHaveBeenCalled(); + }); + + it('boots a shutdown simulator and shuts it down on dispose', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Shutdown'); + const bootSimulator = vi + .spyOn(simctl, 'bootSimulator') + .mockResolvedValue(undefined); + const waitForBoot = vi + .spyOn(simctl, 'waitForBoot') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init + ); + + expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid', init.signal); + + await instance.dispose(); + + expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); + }); + + it('waits for a simulator that is already booting', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booting'); + const bootSimulator = vi + .spyOn(simctl, 'bootSimulator') + .mockResolvedValue(undefined); + const waitForBoot = vi + .spyOn(simctl, 'waitForBoot') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init + ); + + expect(bootSimulator).not.toHaveBeenCalled(); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid', init.signal); + + await instance.dispose(); + + expect(shutdownSimulator).not.toHaveBeenCalled(); + }); + + it('boots and waits for other non-booted simulator states', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Creating'); + const bootSimulator = vi + .spyOn(simctl, 'bootSimulator') + .mockResolvedValue(undefined); + const waitForBoot = vi + .spyOn(simctl, 'waitForBoot') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init + ); + + expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid', init.signal); + + await instance.dispose(); + + expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); + }); + + it('installs the app from HARNESS_APP_PATH when missing', async () => { + const appDir = mkdtempSync(join(tmpdir(), 'rn-harness-ios-app-')); + const bundlePath = join(appDir, 'HarnessPlayground.app'); + mkdirSync(bundlePath); + vi.stubEnv('HARNESS_APP_PATH', bundlePath); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); + const installApp = vi + .spyOn(simctl, 'installApp') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + + try { + await expect( + getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(installApp).toHaveBeenCalledWith('sim-udid', bundlePath); + } finally { + rmSync(appDir, { force: true, recursive: true }); + } + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); + + await expect( + getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH points to a missing app', async () => { + vi.stubEnv( + 'HARNESS_APP_PATH', + join(tmpdir(), 'rn-harness-ios-missing-app', 'Missing.app') + ); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); + + await expect( + getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); }); diff --git a/packages/platform-ios/src/__tests__/simctl.test.ts b/packages/platform-ios/src/__tests__/simctl.test.ts index 4cb41d0..2f179d1 100644 --- a/packages/platform-ios/src/__tests__/simctl.test.ts +++ b/packages/platform-ios/src/__tests__/simctl.test.ts @@ -3,7 +3,29 @@ import fs from 'node:fs'; import { join } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { createCrashArtifactWriter } from '@react-native-harness/tools'; -import { collectCrashReports } from '../xcrun/simctl.js'; +import * as tools from '@react-native-harness/tools'; +import { collectCrashReports, waitForBoot } from '../xcrun/simctl.js'; + +describe('simctl startup', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('passes the abort signal to simctl bootstatus', async () => { + const signal = new AbortController().signal; + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); + + await waitForBoot('sim-udid', signal); + + expect(spawnSpy).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'bootstatus', 'sim-udid', '-b'], + { signal } + ); + }); +}); describe('simctl collectCrashReports', () => { beforeEach(() => { @@ -33,8 +55,7 @@ describe('simctl collectCrashReports', () => { JSON.stringify({ pid: 1234, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, faultingThread: 0, threads: [ { @@ -118,8 +139,7 @@ describe('simctl collectCrashReports', () => { JSON.stringify({ pid: 1234, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP', @@ -130,7 +150,9 @@ describe('simctl collectCrashReports', () => { vi.spyOn(fs, 'statSync').mockReturnValue({ mtimeMs: 123456, } as fs.Stats); - const copyFileSyncSpy = vi.spyOn(fs, 'copyFileSync').mockImplementation(() => undefined); + const copyFileSyncSpy = vi + .spyOn(fs, 'copyFileSync') + .mockImplementation(() => undefined); const writer = createCrashArtifactWriter({ runnerName: 'ios-sim', platformId: 'ios', @@ -158,7 +180,9 @@ describe('simctl collectCrashReports', () => { 'HarnessPlayground-2026-03-12-113008.ips', 'HarnessPlayground-2026-03-12-114008.ips', ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockImplementation(((input: fs.PathOrFileDescriptor) => { + vi.spyOn(fs, 'readFileSync').mockImplementation((( + input: fs.PathOrFileDescriptor + ) => { const filePath = String(input); return [ @@ -170,8 +194,7 @@ describe('simctl collectCrashReports', () => { JSON.stringify({ pid: filePath.includes('113008') ? 1234 : 1235, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP', @@ -203,19 +226,27 @@ describe('simctl collectCrashReports', () => { 'HarnessPlayground-2026-03-12-120000.ips', 'HarnessPlayground-2026-03-12-130000.ips', ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockImplementation(((input: fs.PathOrFileDescriptor) => { + vi.spyOn(fs, 'readFileSync').mockImplementation((( + input: fs.PathOrFileDescriptor + ) => { const filePath = String(input); // The newest file (130000) belongs to a different simulator; the second-newest (120000) is ours const udid = filePath.includes('130000') ? 'other-sim-udid' : 'sim-udid'; - const pid = filePath.includes('110000') ? 1001 : filePath.includes('120000') ? 1002 : 1003; + const pid = filePath.includes('110000') + ? 1001 + : filePath.includes('120000') + ? 1002 + : 1003; return [ - JSON.stringify({ app_name: 'HarnessPlayground', bundleID: 'com.harnessplayground' }), + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + }), JSON.stringify({ pid, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/${udid}/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/${udid}/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP' }, }), ].join('\n'); @@ -225,8 +256,8 @@ describe('simctl collectCrashReports', () => { const mtimeMs = filePath.includes('110000') ? Date.parse('2026-03-12T11:00:00.000Z') : filePath.includes('120000') - ? Date.parse('2026-03-12T12:00:00.000Z') - : Date.parse('2026-03-12T13:00:00.000Z'); + ? Date.parse('2026-03-12T12:00:00.000Z') + : Date.parse('2026-03-12T13:00:00.000Z'); return { mtimeMs } as fs.Stats; }) as typeof fs.statSync); diff --git a/packages/platform-ios/src/errors.ts b/packages/platform-ios/src/errors.ts new file mode 100644 index 0000000..e7f0690 --- /dev/null +++ b/packages/platform-ios/src/errors.ts @@ -0,0 +1,12 @@ +export class HarnessAppPathError extends Error { + constructor(reason: 'missing' | 'invalid', appPath?: string) { + super( + reason === 'missing' + ? 'App is not installed on the simulator and HARNESS_APP_PATH is not set.' + : `HARNESS_APP_PATH points to a missing app bundle: ${ + appPath ?? '' + }` + ); + this.name = 'HarnessAppPathError'; + } +} diff --git a/packages/platform-ios/src/index.ts b/packages/platform-ios/src/index.ts index 54998a0..555b3e0 100644 --- a/packages/platform-ios/src/index.ts +++ b/packages/platform-ios/src/index.ts @@ -4,4 +4,5 @@ export { applePhysicalDevice, } from './factory.js'; export type { ApplePlatformConfig } from './config.js'; -export { getRunTargets } from './targets.js'; \ No newline at end of file +export { HarnessAppPathError } from './errors.js'; +export { getRunTargets } from './targets.js'; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 1386466..c9500a1 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -2,6 +2,7 @@ import { AppNotInstalledError, CreateAppMonitorOptions, DeviceNotFoundError, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { @@ -21,10 +22,30 @@ import { createIosSimulatorAppMonitor, } from './app-monitor.js'; import { assertLibimobiledeviceInstalled } from './libimobiledevice.js'; +import { HarnessAppPathError } from './errors.js'; +import { logger } from '@react-native-harness/tools'; +import fs from 'node:fs'; + +const iosInstanceLogger = logger.child('ios-instance'); + +const getHarnessAppPath = (): string => { + const appPath = process.env.HARNESS_APP_PATH; + + if (!appPath) { + throw new HarnessAppPathError('missing'); + } + + if (!fs.existsSync(appPath)) { + throw new HarnessAppPathError('invalid', appPath); + } + + return appPath; +}; export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions ): Promise => { assertAppleDeviceSimulator(config.device); @@ -37,19 +58,51 @@ export const getAppleSimulatorPlatformInstance = async ( throw new DeviceNotFoundError(getDeviceName(config.device)); } - const isInstalled = await simctl.isAppInstalled(udid, config.bundleId); + const simulatorStatus = await simctl.getSimulatorStatus(udid); + let startedByHarness = false; - if (!isInstalled) { - throw new AppNotInstalledError( - config.bundleId, - getDeviceName(config.device) + iosInstanceLogger.debug( + 'resolved iOS simulator %s with status %s', + udid, + simulatorStatus + ); + + if ( + !simctl.isBootedSimulatorStatus(simulatorStatus) && + !simctl.isBootingSimulatorStatus(simulatorStatus) + ) { + logger.info('Booting iOS simulator %s...', config.device.name); + iosInstanceLogger.debug( + 'booting iOS simulator %s from status %s', + udid, + simulatorStatus ); + await simctl.bootSimulator(udid); + startedByHarness = true; } - const simulatorStatus = await simctl.getSimulatorStatus(udid); + if (simctl.isBootedSimulatorStatus(simulatorStatus)) { + logger.info('Using booted iOS simulator %s...', config.device.name); + } else if (simctl.isBootingSimulatorStatus(simulatorStatus)) { + logger.info( + 'Waiting for iOS simulator %s to finish booting...', + config.device.name + ); + } - if (simulatorStatus !== 'Booted') { - throw new Error('Simulator is not booted'); + if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { + iosInstanceLogger.debug( + 'waiting for iOS simulator %s to finish booting', + udid + ); + await simctl.waitForBoot(udid, init.signal); + } + + const isInstalled = await simctl.isAppInstalled(udid, config.bundleId); + + if (!isInstalled) { + const appPath = getHarnessAppPath(); + await simctl.installApp(udid, appPath); } await simctl.applyHarnessJsLocationOverride( @@ -82,6 +135,11 @@ export const getAppleSimulatorPlatformInstance = async ( dispose: async () => { await simctl.stopApp(udid, config.bundleId); await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); + + if (startedByHarness) { + logger.info('Shutting down iOS simulator %s...', config.device.name); + await simctl.shutdownSimulator(udid); + } }, isAppRunning: async () => { return await simctl.isAppRunning(udid, config.bundleId); diff --git a/packages/platform-ios/src/runner.ts b/packages/platform-ios/src/runner.ts index 350de6e..59b464d 100644 --- a/packages/platform-ios/src/runner.ts +++ b/packages/platform-ios/src/runner.ts @@ -1,4 +1,7 @@ -import { HarnessPlatformRunner } from '@react-native-harness/platforms'; +import { + HarnessPlatformRunner, + type HarnessPlatformInitOptions, +} from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; import { ApplePlatformConfigSchema, @@ -12,12 +15,13 @@ import { const getAppleRunner = async ( config: ApplePlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions ): Promise => { const parsedConfig = ApplePlatformConfigSchema.parse(config); if (isAppleDeviceSimulator(parsedConfig.device)) { - return getAppleSimulatorPlatformInstance(parsedConfig, harnessConfig); + return getAppleSimulatorPlatformInstance(parsedConfig, harnessConfig, init); } return getApplePhysicalDevicePlatformInstance(parsedConfig, harnessConfig); diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index fb00710..195c784 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -209,7 +209,18 @@ export const isAppInstalled = async ( return appInfo !== null; }; -export type AppleSimulatorState = 'Booted' | 'Booting' | 'Shutdown'; +export type AppleSimulatorState = + | 'Booted' + | 'Booting' + | 'Shutdown' + | (string & {}); + +export const isBootedSimulatorStatus = (status: AppleSimulatorState): boolean => + status === 'Booted'; + +export const isBootingSimulatorStatus = ( + status: AppleSimulatorState +): boolean => status === 'Booting'; export type AppleSimulatorInfo = { name: string; @@ -285,6 +296,30 @@ export const stopApp = async ( await spawnAndForget('xcrun', ['simctl', 'terminate', udid, bundleId]); }; +export const bootSimulator = async (udid: string): Promise => { + await spawn('xcrun', ['simctl', 'boot', udid]); +}; + +export const waitForBoot = async ( + udid: string, + signal: AbortSignal +): Promise => { + await spawn('xcrun', ['simctl', 'bootstatus', udid, '-b'], { + signal, + }); +}; + +export const shutdownSimulator = async (udid: string): Promise => { + await spawnAndForget('xcrun', ['simctl', 'shutdown', udid]); +}; + +export const installApp = async ( + udid: string, + appPath: string +): Promise => { + await spawn('xcrun', ['simctl', 'install', udid, appPath]); +}; + export const getSimulatorId = async ( name: string, systemVersion: string @@ -399,7 +434,11 @@ export const applyHarnessJsLocationOverride = async ( ); if (backupValue === null) { - const existingValue = await getDefaultsValue(udid, bundleId, 'RCT_jsLocation'); + const existingValue = await getDefaultsValue( + udid, + bundleId, + 'RCT_jsLocation' + ); await writeDefaultsValue( udid, bundleId, diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index 14b6b3b..5027efc 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -4,6 +4,7 @@ import { DeviceNotFoundError, AppNotInstalledError, type CreateAppMonitorOptions, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { getEmitter } from '@react-native-harness/tools'; @@ -67,7 +68,8 @@ const createPollingAppMonitor = ({ }; const getVegaRunner = async ( - config: VegaPlatformConfig + config: VegaPlatformConfig, + _init?: HarnessPlatformInitOptions ): Promise => { const parsedConfig = VegaPlatformConfigSchema.parse(config); const deviceId = parsedConfig.device.deviceId; diff --git a/packages/platform-web/src/runner.ts b/packages/platform-web/src/runner.ts index f77a166..c52e728 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -2,6 +2,7 @@ import { type AppMonitor, type AppMonitorEvent, type CreateAppMonitorOptions, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { chromium, firefox, webkit, type Browser, type Page } from 'playwright'; @@ -65,7 +66,8 @@ const createPollingAppMonitor = ({ }; const getWebRunner = async ( - config: WebPlatformConfig + config: WebPlatformConfig, + _init?: HarnessPlatformInitOptions ): Promise => { const parsedConfig = WebPlatformConfigSchema.parse(config); diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 35369eb..c9eac85 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -11,6 +11,7 @@ export type { CrashArtifactWriter, CreateAppMonitorOptions, HarnessPlatform, + HarnessPlatformInitOptions, HarnessPlatformRunner, RunTarget, VegaAppLaunchOptions, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index d61cb10..a5c58a1 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -7,9 +7,7 @@ export type AppCrashDetails = { pid?: number; stackTrace?: string[]; rawLines?: string[]; - artifactType?: - | 'logcat' - | 'ios-crash-report'; + artifactType?: 'logcat' | 'ios-crash-report'; artifactPath?: string; }; @@ -112,6 +110,10 @@ export type HarnessPlatformRunner = { ) => Promise; }; +export type HarnessPlatformInitOptions = { + signal: AbortSignal; +}; + export type HarnessPlatform> = { name: string; config: TConfig; diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index a8f2b86..8ab0fea 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -9,8 +9,10 @@ export type { CollectionStartedContext, FlatHarnessHookContexts, FlatHarnessHookName, + HarnessAfterRunContext, HarnessBaseHookContext, HarnessBeforeCreationContext, + HarnessBeforeRunContext, HarnessBeforeDisposeContext, HarnessHookHandler, HarnessHookMeta, diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts index 2b2e6e2..32560f3 100644 --- a/packages/plugins/src/types.ts +++ b/packages/plugins/src/types.ts @@ -43,7 +43,7 @@ export type HarnessBaseHookContext< TState extends object, TConfig, TRunner extends HarnessPlatform, - TName extends string, + TName extends string > = { plugin: { name: string; @@ -62,7 +62,7 @@ export type HarnessBaseHookContext< export type HarnessBeforeCreationContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext< TState, TConfig, @@ -75,7 +75,7 @@ export type HarnessBeforeCreationContext< export type HarnessBeforeDisposeContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext< TState, TConfig, @@ -89,10 +89,30 @@ export type HarnessBeforeDisposeContext< error?: unknown; }; +export type HarnessBeforeRunContext< + TState extends object, + TConfig, + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { + appLaunchOptions?: AppLaunchOptions; +}; + +export type HarnessAfterRunContext< + TState extends object, + TConfig, + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { + runId?: string; + reason?: 'normal' | 'abort' | 'error'; + summary?: HarnessRunSummary; + status?: HarnessRunStatus; + error?: unknown; +}; + export type RunStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; startTime: number; @@ -104,7 +124,7 @@ export type RunStartedContext< export type RunFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; startTime: number; @@ -118,7 +138,7 @@ export type RunFinishedContext< export type RuntimeReadyContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; device: DeviceDescriptor; @@ -127,13 +147,8 @@ export type RuntimeReadyContext< export type RuntimeDisconnectedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'runtime:disconnected' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; reason?: string; }; @@ -141,13 +156,8 @@ export type RuntimeDisconnectedContext< export type MetroInitializedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:initialized' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; port: number; host?: string; @@ -158,13 +168,8 @@ export type MetroBundleTarget = 'module' | 'setupFile'; export type MetroBundleStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:bundle-started' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; target: MetroBundleTarget; file: string; @@ -174,7 +179,7 @@ export type MetroBundleStartedContext< export type MetroBundleFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext< TState, TConfig, @@ -191,13 +196,8 @@ export type MetroBundleFinishedContext< export type MetroBundleFailedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:bundle-failed' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; target: MetroBundleTarget; file: string; @@ -220,13 +220,8 @@ export type MetroClientLogLevel = export type MetroClientLogContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:client-log' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; level: MetroClientLogLevel; data: unknown[]; @@ -235,7 +230,7 @@ export type MetroClientLogContext< export type AppStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; testFile?: string; @@ -247,7 +242,7 @@ export type AppStartedContext< export type AppExitedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; testFile?: string; @@ -261,13 +256,8 @@ export type AppExitedContext< export type AppPossibleCrashContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'app:possible-crash' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; testFile?: string; pid?: number; @@ -280,13 +270,8 @@ export type AppPossibleCrashContext< export type CollectionStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'collection:started' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; }; @@ -294,13 +279,8 @@ export type CollectionStartedContext< export type CollectionFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'collection:finished' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; duration: number; @@ -310,13 +290,8 @@ export type CollectionFinishedContext< export type TestFileStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'test-file:started' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; }; @@ -324,13 +299,8 @@ export type TestFileStartedContext< export type TestFileFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'test-file:finished' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; duration: number; @@ -341,7 +311,7 @@ export type TestFileFinishedContext< export type SuiteStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -351,7 +321,7 @@ export type SuiteStartedContext< export type SuiteFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -364,7 +334,7 @@ export type SuiteFinishedContext< export type TestStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -375,7 +345,7 @@ export type TestStartedContext< export type TestFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -389,7 +359,7 @@ export type TestFinishedContext< export type FlatHarnessHookContexts< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = { 'harness:before-creation': HarnessBeforeCreationContext< TState, @@ -401,30 +371,16 @@ export type FlatHarnessHookContexts< TConfig, TRunner >; + 'harness:before-run': HarnessBeforeRunContext; + 'harness:after-run': HarnessAfterRunContext; 'run:started': RunStartedContext; 'run:finished': RunFinishedContext; 'runtime:ready': RuntimeReadyContext; - 'runtime:disconnected': RuntimeDisconnectedContext< - TState, - TConfig, - TRunner - >; + 'runtime:disconnected': RuntimeDisconnectedContext; 'metro:initialized': MetroInitializedContext; - 'metro:bundle-started': MetroBundleStartedContext< - TState, - TConfig, - TRunner - >; - 'metro:bundle-finished': MetroBundleFinishedContext< - TState, - TConfig, - TRunner - >; - 'metro:bundle-failed': MetroBundleFailedContext< - TState, - TConfig, - TRunner - >; + 'metro:bundle-started': MetroBundleStartedContext; + 'metro:bundle-finished': MetroBundleFinishedContext; + 'metro:bundle-failed': MetroBundleFailedContext; 'metro:client-log': MetroClientLogContext; 'app:started': AppStartedContext; 'app:exited': AppExitedContext; @@ -450,26 +406,28 @@ export type HarnessHookHandler = (ctx: TContext) => Awaitable; export type HarnessPluginHooks< TState extends object = Record, TConfig = unknown, - TRunner extends HarnessPlatform = HarnessPlatform, + TRunner extends HarnessPlatform = HarnessPlatform > = { harness?: { beforeCreation?: HarnessHookHandler< HarnessBeforeCreationContext >; + beforeRun?: HarnessHookHandler< + HarnessBeforeRunContext + >; + afterRun?: HarnessHookHandler< + HarnessAfterRunContext + >; beforeDispose?: HarnessHookHandler< HarnessBeforeDisposeContext >; }; run?: { started?: HarnessHookHandler>; - finished?: HarnessHookHandler< - RunFinishedContext - >; + finished?: HarnessHookHandler>; }; runtime?: { - ready?: HarnessHookHandler< - RuntimeReadyContext - >; + ready?: HarnessHookHandler>; disconnected?: HarnessHookHandler< RuntimeDisconnectedContext >; @@ -531,7 +489,7 @@ export type HarnessPluginHooks< export type HarnessPlugin< TState extends object = Record, TConfig = unknown, - TRunner extends HarnessPlatform = HarnessPlatform, + TRunner extends HarnessPlatform = HarnessPlatform > = { name: string; hooks?: HarnessPluginHooks; @@ -540,6 +498,8 @@ export type HarnessPlugin< export const HARNESS_HOOKS = [ { flatName: 'harness:before-creation', path: ['harness', 'beforeCreation'] }, + { flatName: 'harness:before-run', path: ['harness', 'beforeRun'] }, + { flatName: 'harness:after-run', path: ['harness', 'afterRun'] }, { flatName: 'harness:before-dispose', path: ['harness', 'beforeDispose'] }, { flatName: 'run:started', path: ['run', 'started'] }, { flatName: 'run:finished', path: ['run', 'finished'] }, diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 9ac0de1..e5561b3 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -9,3 +9,4 @@ export * from './events.js'; export * from './packages.js'; export * from './crash-artifacts.js'; export * from './regex.js'; +export * from './isInteractive.js'; diff --git a/packages/tools/src/logger.ts b/packages/tools/src/logger.ts index 2bed5b8..a88a989 100644 --- a/packages/tools/src/logger.ts +++ b/packages/tools/src/logger.ts @@ -1,4 +1,5 @@ import util from 'node:util'; +import pc from 'picocolors'; let verbose = !!process.env.HARNESS_DEBUG; @@ -19,6 +20,14 @@ export type HarnessLogger = { const BASE_TAG = '[harness]'; +const INFO_TAG = pc.isColorSupported + ? pc.reset(pc.inverse(pc.bold(pc.magenta(' HARNESS ')))) + : 'HARNESS'; + +const ERROR_TAG = pc.isColorSupported + ? pc.reset(pc.inverse(pc.bold(pc.red(' HARNESS ')))) + : 'HARNESS'; + const getTimestamp = (): string => new Date().toISOString(); const normalizeScope = (scope: string): string => @@ -43,14 +52,23 @@ const writeLog = ( scopes: readonly string[], messages: Array ) => { - const method = - level === 'warn' - ? console.warn - : level === 'error' - ? console.error - : level === 'debug' - ? console.debug - : console.info; + if ( + !verbose && + (level === 'info' || level === 'log' || level === 'success') + ) { + const output = util.format(...messages); + const tag = INFO_TAG; + process.stderr.write(`${tag} ${output}\n`); + return; + } + + if (!verbose && level === 'error') { + const output = util.format(...messages); + process.stderr.write(`${ERROR_TAG} ${output}\n`); + return; + } + + const method = level === 'warn' ? console.warn : console.debug; const output = util.format(...messages); const prefix = `${getTimestamp()} ${formatPrefix(scopes)}`; method(mapLines(output, prefix)); diff --git a/website/package.json b/website/package.json index d245b1a..3b68d6d 100644 --- a/website/package.json +++ b/website/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "build": "rspress build", + "build": "rm -rf build && rspress build", "dev": "rspress dev", "preview": "rspress preview" }, diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index d57e97f..4a45f4c 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -86,22 +86,24 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` ## All Configuration Options -| Option | Description | -| :--------------------------------- | :---------------------------------------------------------------------------- | -| `entryPoint` | **Required.** Path to your React Native app's entry point file. | -| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | -| `runners` | **Required.** Array of test runners (at least one required). | -| `defaultRunner` | Default runner to use when none specified. | -| `host` | Hostname or IP address to bind the Metro server to (default: Metro default). | -| `metroPort` | Port used by Metro and Harness bridge traffic (default: `8081`). | -| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | -| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | -| `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | -| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | -| `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | -| `coverage` | Coverage configuration object. | -| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | -| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | +| Option | Description | +| :--------------------------------- | :--------------------------------------------------------------------------------------------------------- | +| `entryPoint` | **Required.** Path to your React Native app's entry point file. | +| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | +| `runners` | **Required.** Array of test runners (at least one required). | +| `defaultRunner` | Default runner to use when none specified. | +| `host` | Hostname or IP address to bind the Metro server to (default: Metro default). | +| `metroPort` | Port used by Metro and Harness bridge traffic (default: `8081`). | +| `platformReadyTimeout` | Platform-ready timeout in milliseconds (default: `300000`). | +| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | +| `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | +| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | +| `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | +| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | +| `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | +| `coverage` | Coverage configuration object. | +| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | +| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | | `unstable__enableMetroCache` | Enable Metro transformation cache under `.harness/metro-cache` and log when reusing it (default: `false`). | ## Test Runners @@ -159,9 +161,27 @@ react-native-harness --harnessRunner android react-native-harness --harnessRunner ios ``` +## Platform Ready Timeout + +The platform ready timeout controls how long React Native Harness waits for the selected device, simulator, or emulator to become usable. This includes device discovery, simulator or emulator boot, and platform runtime setup before the app is launched. + +```javascript +{ + platformReadyTimeout: 300000, // 5 minutes in milliseconds +} +``` + +**Default:** 300000 (5 minutes) +**Minimum:** 1000 (1 second) + +Increase this value if you experience startup failures while: + +- Booting cold simulators or emulators +- Starting devices in slower CI environments + ## Bridge Timeout -The bridge timeout controls how long React Native Harness waits for communication between the test runner and the React Native app. This is particularly important for slower devices or complex test setups. +The bridge timeout controls how long React Native Harness waits for the app to report runtime readiness after it has been launched. It does not include simulator or emulator boot time. ```javascript { @@ -174,8 +194,21 @@ The bridge timeout controls how long React Native Harness waits for communicatio Increase this value if you experience timeout errors, especially on: -- Slower devices or simulators - Complex test suites with heavy setup +- Slower app startup after launch + +## Bundle Start Timeout + +The bundle start timeout controls how long React Native Harness waits for the launched app to request its Metro bundle. + +```javascript +{ + bundleStartTimeout: 120000, +} +``` + +**Default:** 60000 (60 seconds) +**Minimum:** 1000 (1 second) ## Environment-Specific Configurations @@ -211,7 +244,8 @@ const config = { }), ], - bridgeTimeout: isCI ? 180000 : 60000, // Longer timeout in CI + platformReadyTimeout: isCI ? 420000 : 300000, + bridgeTimeout: isCI ? 180000 : 60000, }; export default config;