diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12856dfb..f93e3470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 481bc448..20e8ad7d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -26,10 +26,10 @@ jobs: e2e-android: name: E2E Android runs-on: ubuntu-22.04 + 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 == 'android')) }} env: HARNESS_DEBUG: true - steps: - name: Checkout code uses: actions/checkout@v4 @@ -108,6 +108,7 @@ jobs: e2e-ios: name: E2E iOS runs-on: macos-latest + 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')) }} steps: - name: Checkout code @@ -194,10 +195,10 @@ jobs: e2e-web: name: E2E Web runs-on: ubuntu-22.04 + 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 == 'web')) }} env: HARNESS_DEBUG: true - steps: - name: Checkout code uses: actions/checkout@v4 @@ -237,3 +238,221 @@ jobs: with: runner: chromium projectRoot: apps/playground + + crash-validate-android: + name: Crash Validation Android + runs-on: ubuntu-22.04 + timeout-minutes: 30 + if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} + env: + HARNESS_DEBUG: true + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Reclaim disk space + uses: AdityaGarg8/remove-unwanted-software@v5 + with: + remove-dotnet: true + remove-haskell: true + remove-codeql: true + remove-docker-images: true + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Metro cache + uses: actions/cache@v4 + with: + path: apps/playground/node_modules/.cache/rn-harness/metro-cache + key: metro-cache-${{ hashFiles('apps/playground/node_modules/.cache/rn-harness/metro-cache/**/*') }} + restore-keys: | + metro-cache + + - name: Install dependencies + run: | + pnpm install + + - name: Build packages + run: | + pnpm nx run-many -t build --projects="packages/*" + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Restore APK from cache + id: cache-apk-restore + uses: actions/cache/restore@v4 + with: + path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + key: apk-playground + + - name: Build Android app + if: steps.cache-apk-restore.outputs.cache-hit != 'true' + working-directory: apps/playground + run: | + pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug + + - name: Save APK to cache + if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + key: apk-playground + + - name: Run React Native Harness (expect crash) + id: crash-test + continue-on-error: true + uses: ./actions/android + with: + app: android/app/build/outputs/apk/debug/app-debug.apk + runner: android-crash-pre-rn + projectRoot: apps/playground + harnessArgs: --testPathPattern smoke + + - name: Verify crash was detected + shell: bash + run: | + if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then + echo "ERROR: Expected harness to fail (crash not detected)" + exit 1 + fi + echo "Crash was correctly detected by the harness" + + - name: Verify crash artifacts exist + shell: bash + run: | + CRASH_DIR="apps/playground/.harness/crash-reports" + if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then + echo "Crash report artifacts found:" + ls -la "$CRASH_DIR" + else + echo "ERROR: No crash report artifacts found in $CRASH_DIR" + exit 1 + fi + + crash-validate-ios: + name: Crash Validation iOS + runs-on: macos-latest + timeout-minutes: 30 + if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Metro cache + uses: actions/cache@v4 + with: + path: apps/playground/node_modules/.cache/rn-harness/metro-cache + key: metro-cache-${{ hashFiles('apps/playground/node_modules/.cache/rn-harness/metro-cache/**/*') }} + restore-keys: | + metro-cache + + - name: Install Watchman + run: brew install watchman + + - name: Install dependencies + run: | + pnpm install + + - name: Build packages + run: | + pnpm nx run-many -t build --projects="packages/*" + + - name: Restore app from cache + id: cache-app-restore + uses: actions/cache/restore@v4 + with: + path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + key: ios-app-playground + + - name: CocoaPods cache + if: steps.cache-app-restore.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: | + ./apps/playground/ios/Pods + ~/Library/Caches/CocoaPods + ~/.cocoapods + key: playground-${{ runner.os }}-pods-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} + restore-keys: | + playground-${{ runner.os }}-pods- + + - name: Install CocoaPods + if: steps.cache-app-restore.outputs.cache-hit != 'true' + working-directory: apps/playground/ios + run: | + pod install + + - name: Build iOS app + if: steps.cache-app-restore.outputs.cache-hit != 'true' + working-directory: apps/playground + run: | + pnpm react-native build-ios --buildFolder ./build --verbose + + - name: Save app to cache + if: steps.cache-app-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + key: ios-app-playground + + - name: Run React Native Harness (expect crash) + id: crash-test + continue-on-error: true + uses: ./actions/ios + with: + app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + runner: ios-crash-pre-rn + projectRoot: apps/playground + harnessArgs: --testPathPattern smoke + + - name: Verify crash was detected + shell: bash + run: | + if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then + echo "ERROR: Expected harness to fail (crash not detected)" + exit 1 + fi + echo "Crash was correctly detected by the harness" + + - name: Verify crash artifacts exist + shell: bash + run: | + CRASH_DIR="apps/playground/.harness/crash-reports" + if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then + echo "Crash report artifacts found:" + ls -la "$CRASH_DIR" + else + echo "ERROR: No crash report artifacts found in $CRASH_DIR" + exit 1 + fi diff --git a/.gitignore b/.gitignore index a40566a6..a3957e87 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ yarn-error.log # testing /coverage +.harness/ vite.config.*.timestamp* vitest.config.*.timestamp* @@ -128,4 +129,3 @@ npm-debug.* *.orig.* web-build/ cache/ - diff --git a/actions/android/action.yml b/actions/android/action.yml index 8970d951..ead4f8f5 100644 --- a/actions/android/action.yml +++ b/actions/android/action.yml @@ -17,6 +17,11 @@ inputs: required: false type: boolean default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' runs: using: 'composite' steps: @@ -136,7 +141,7 @@ runs: script: | echo $(pwd) adb install -r ${{ inputs.app }} - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -146,3 +151,10 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-android + path: ${{ inputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/actions/ios/action.yml b/actions/ios/action.yml index 6ac89d61..9e4db50c 100644 --- a/actions/ios/action.yml +++ b/actions/ios/action.yml @@ -17,6 +17,11 @@ inputs: required: false type: boolean default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' runs: using: 'composite' steps: @@ -63,7 +68,7 @@ runs: - name: Run E2E tests shell: bash working-directory: ${{ inputs.projectRoot }} - run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} + run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -73,3 +78,10 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-ios + path: ${{ inputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 228c06cf..6794be6f 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -647,8 +647,8 @@ function getErrorMap() { // ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js var makeIssue = (params) => { - const { data, path: path5, errorMaps, issueData } = params; - const fullPath = [...path5, ...issueData.path || []]; + const { data, path: path6, errorMaps, issueData } = params; + const fullPath = [...path6, ...issueData.path || []]; const fullIssue = { ...issueData, path: fullPath @@ -764,11 +764,11 @@ var errorUtil; // ../../node_modules/zod/dist/esm/v3/types.js var ParseInputLazyPath = class { - constructor(parent, value, path5, key) { + constructor(parent, value, path6, key) { this._cachedPath = []; this.parent = parent; this.data = value; - this._path = path5; + this._path = path6; this._key = key; } get path() { @@ -4220,10 +4220,13 @@ var ConfigSchema = external_exports.object({ appRegistryComponentName: external_exports.string().min(1, "App registry component name is required"), runners: external_exports.array(RunnerSchema).min(1, "At least one runner is required"), defaultRunner: external_exports.string().optional(), + host: external_exports.string().min(1, "Host is required").optional(), webSocketPort: external_exports.number().optional().default(3001), 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), - maxAppRestarts: external_exports.number().min(0, "Max app restarts must be non-negative").default(2), + /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ + bundleStartTimeout: external_exports.number().optional(), + /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ + maxAppRestarts: external_exports.number().optional(), resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), unstable__enableMetroCache: external_exports.boolean().optional().default(false), @@ -4334,6 +4337,11 @@ var HarnessError = class extends Error { var import_node_path3 = __toESM(require("path"), 1); var import_node_fs3 = __toESM(require("fs"), 1); +// ../tools/dist/crash-artifacts.js +var import_node_fs4 = __toESM(require("fs"), 1); +var import_node_path4 = __toESM(require("path"), 1); +var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join(process.cwd(), ".harness", "crash-reports"); + // ../config/dist/errors.js var ConfigValidationError = class extends HarnessError { filePath; @@ -4401,16 +4409,30 @@ var ConfigLoadError = class extends HarnessError { }; // ../config/dist/reader.js -var import_node_path4 = __toESM(require("path"), 1); -var import_node_fs4 = __toESM(require("fs"), 1); +var import_node_path5 = __toESM(require("path"), 1); +var import_node_fs5 = __toESM(require("fs"), 1); var import_node_module2 = require("module"); var import_meta = {}; +var DEPRECATED_PROPERTIES = { + bundleStartTimeout: '"bundleStartTimeout" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.', + maxAppRestarts: '"maxAppRestarts" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.' +}; +var warnDeprecatedProperties = (rawConfig) => { + if (typeof rawConfig !== "object" || rawConfig === null) { + return; + } + for (const [key, message] of Object.entries(DEPRECATED_PROPERTIES)) { + if (key in rawConfig) { + console.warn(`[react-native-harness] Deprecation warning: ${message}`); + } + } +}; var extensions = [".js", ".mjs", ".cjs", ".json"]; var importUp = async (dir, name) => { - const filePath = import_node_path4.default.join(dir, name); + const filePath = import_node_path5.default.join(dir, name); for (const ext of extensions) { const filePathWithExt = `${filePath}${ext}`; - if (import_node_fs4.default.existsSync(filePathWithExt)) { + if (import_node_fs5.default.existsSync(filePathWithExt)) { let rawConfig; try { if (ext === ".mjs") { @@ -4423,13 +4445,14 @@ var importUp = async (dir, name) => { throw new ConfigLoadError(filePathWithExt, error instanceof Error ? error : void 0); } try { + warnDeprecatedProperties(rawConfig); const config = ConfigSchema.parse(rawConfig); return { config, filePathWithExt, configDir: dir }; } catch (error) { if (error instanceof ZodError) { const validationErrors = error.errors.map((err) => { - const path5 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : ""; - return `${err.message}${path5}`; + const path6 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : ""; + return `${err.message}${path6}`; }); throw new ConfigValidationError(filePathWithExt, validationErrors); } @@ -4437,7 +4460,7 @@ var importUp = async (dir, name) => { } } } - const parentDir = import_node_path4.default.dirname(dir); + const parentDir = import_node_path5.default.dirname(dir); if (parentDir === dir) { throw new ConfigNotFoundError(dir); } @@ -4452,8 +4475,8 @@ var getConfig = async (dir) => { }; // src/shared/index.ts -var import_node_path5 = __toESM(require("path")); -var import_node_fs5 = __toESM(require("fs")); +var import_node_path6 = __toESM(require("path")); +var import_node_fs6 = __toESM(require("fs")); var run = async () => { try { const projectRootInput = process.env.INPUT_PROJECTROOT; @@ -4461,7 +4484,7 @@ var run = async () => { if (!runnerInput) { throw new Error("Runner input is required"); } - const projectRoot = projectRootInput ? import_node_path5.default.resolve(projectRootInput) : process.cwd(); + const projectRoot = projectRootInput ? import_node_path6.default.resolve(projectRootInput) : process.cwd(); console.info(`Loading React Native Harness config from: ${projectRoot}`); const { config } = await getConfig(projectRoot); const runner = config.runners.find((runner2) => runner2.name === runnerInput); @@ -4474,7 +4497,7 @@ var run = async () => { } const output = `config=${JSON.stringify(runner)} `; - import_node_fs5.default.appendFileSync(githubOutput, output); + import_node_fs6.default.appendFileSync(githubOutput, output); } catch (error) { if (error instanceof Error) { console.error(error.message); diff --git a/actions/web/action.yml b/actions/web/action.yml index bb18c85c..c7fea330 100644 --- a/actions/web/action.yml +++ b/actions/web/action.yml @@ -14,6 +14,11 @@ inputs: required: false type: boolean default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' runs: using: 'composite' steps: @@ -51,7 +56,7 @@ runs: - name: Run E2E tests shell: bash working-directory: ${{ inputs.projectRoot }} - run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} + run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -61,3 +66,10 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-web + path: ${{ inputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/apps/playground/android/app/src/main/java/com/harnessplayground/MainActivity.kt b/apps/playground/android/app/src/main/java/com/harnessplayground/MainActivity.kt index 630cca2e..11d40ec8 100644 --- a/apps/playground/android/app/src/main/java/com/harnessplayground/MainActivity.kt +++ b/apps/playground/android/app/src/main/java/com/harnessplayground/MainActivity.kt @@ -1,11 +1,44 @@ package com.harnessplayground +import android.os.Bundle +import android.os.Handler +import android.os.Looper import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate class MainActivity : ReactActivity() { + private fun crashMode(): String? = intent?.getStringExtra("harness_crash_mode") + + private fun crashIfRequestedBeforeReact() { + if (!BuildConfig.DEBUG) { + return + } + + if (crashMode() == "pre_rn") { + error("Intentional pre-RN startup crash") + } + } + + private fun scheduleDelayedCrashIfRequested() { + if (!BuildConfig.DEBUG) { + return + } + + if (crashMode() == "delayed_pre_ready") { + Handler(Looper.getMainLooper()).postDelayed( + { error("Intentional delayed startup crash") }, + 1000, + ) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + crashIfRequestedBeforeReact() + super.onCreate(savedInstanceState) + scheduleDelayedCrashIfRequested() + } /** * Returns the name of the main component registered from JavaScript. This is used to schedule diff --git a/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj b/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj index f31363f3..76a53d22 100644 --- a/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj +++ b/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj @@ -376,7 +376,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; USE_HERMES = true; @@ -444,7 +444,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; VALIDATE_PRODUCT = YES; diff --git a/apps/playground/ios/HarnessPlayground/AppDelegate.swift b/apps/playground/ios/HarnessPlayground/AppDelegate.swift index d48ea36b..14f6820a 100644 --- a/apps/playground/ios/HarnessPlayground/AppDelegate.swift +++ b/apps/playground/ios/HarnessPlayground/AppDelegate.swift @@ -10,6 +10,35 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var reactNativeDelegate: ReactNativeDelegate? var reactNativeFactory: RCTReactNativeFactory? + #if DEBUG + private func startupCrashMode() -> String { + let processInfo = ProcessInfo.processInfo + + if let mode = processInfo.environment["HARNESS_CRASH_MODE"], !mode.isEmpty { + return mode + } + + if let argument = processInfo.arguments.first(where: { $0.hasPrefix("--harness-crash-mode=") }) { + return String(argument.dropFirst("--harness-crash-mode=".count)) + } + + return "none" + } + + private func crashIfRequested() { + switch startupCrashMode() { + case "pre_rn": + fatalError("Intentional pre-RN startup crash") + case "delayed_pre_ready": + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + fatalError("Intentional delayed startup crash") + } + default: + break + } + } + #endif + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -23,6 +52,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { window = UIWindow(frame: UIScreen.main.bounds) + #if DEBUG + crashIfRequested() + #endif + factory.startReactNative( withModuleName: "HarnessPlayground", in: window, diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock index fc9837c6..1d738d36 100644 --- a/apps/playground/ios/Podfile.lock +++ b/apps/playground/ios/Podfile.lock @@ -5,7 +5,7 @@ PODS: - FBLazyVector (0.82.1) - fmt (11.0.2) - glog (0.3.5) - - HarnessUI (1.0.0-alpha.24): + - HarnessUI (1.0.0): - boost - DoubleConversion - fast_float @@ -2353,82 +2353,82 @@ PODS: - Yoga (0.0.0) DEPENDENCIES: - - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) - - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) - - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`) + - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) + - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) + - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) + - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) + - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - "HarnessUI (from `../node_modules/@react-native-harness/ui`)" - - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - - RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) - - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) - - React (from `../../../node_modules/react-native/`) - - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) - - React-Core (from `../../../node_modules/react-native/`) - - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) - - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) - - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) - - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`) - - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) - - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) - - React-Fabric (from `../../../node_modules/react-native/ReactCommon`) - - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`) - - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`) - - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`) - - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) - - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`) - - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`) - - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) - - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`) - - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) - - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) - - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`) - - React-jsinspectorcdp (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) - - React-jsinspectornetwork (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/network`) - - React-jsinspectortracing (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) - - React-jsitooling (from `../../../node_modules/react-native/ReactCommon/jsitooling`) - - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`) - - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) - - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) - - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - - React-oscompat (from `../../../node_modules/react-native/ReactCommon/oscompat`) - - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) - - React-performancecdpmetrics (from `../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) - - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`) - - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) - - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) - - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) - - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) - - React-RCTFabric (from `../../../node_modules/react-native/React`) - - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`) - - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) - - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) - - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`) - - React-RCTRuntime (from `../../../node_modules/react-native/React/Runtime`) - - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`) - - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`) - - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`) - - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`) - - React-renderercss (from `../../../node_modules/react-native/ReactCommon/react/renderer/css`) - - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`) - - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) - - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`) - - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) - - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`) - - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`) - - React-webperformancenativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) + - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../node_modules/react-native/Libraries/Required`) + - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../node_modules/react-native/`) + - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../node_modules/react-native/`) + - React-Core/RCTWebSocket (from `../node_modules/react-native/`) + - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) + - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) + - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) - ReactAppDependencyProvider (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`) - - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) + - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - SocketRocket (~> 0.7.1) - - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) + - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: trunk: @@ -2436,154 +2436,154 @@ SPEC REPOS: EXTERNAL SOURCES: boost: - :podspec: "../../../node_modules/react-native/third-party-podspecs/boost.podspec" + :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" DoubleConversion: - :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" + :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" fast_float: - :podspec: "../../../node_modules/react-native/third-party-podspecs/fast_float.podspec" + :podspec: "../node_modules/react-native/third-party-podspecs/fast_float.podspec" FBLazyVector: - :path: "../../../node_modules/react-native/Libraries/FBLazyVector" + :path: "../node_modules/react-native/Libraries/FBLazyVector" fmt: - :podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec" + :podspec: "../node_modules/react-native/third-party-podspecs/fmt.podspec" glog: - :podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec" + :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" HarnessUI: :path: "../node_modules/@react-native-harness/ui" hermes-engine: - :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101 RCT-Folly: - :podspec: "../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" + :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: - :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: - :path: "../../../node_modules/react-native/Libraries/Required" + :path: "../node_modules/react-native/Libraries/Required" RCTTypeSafety: - :path: "../../../node_modules/react-native/Libraries/TypeSafety" + :path: "../node_modules/react-native/Libraries/TypeSafety" React: - :path: "../../../node_modules/react-native/" + :path: "../node_modules/react-native/" React-callinvoker: - :path: "../../../node_modules/react-native/ReactCommon/callinvoker" + :path: "../node_modules/react-native/ReactCommon/callinvoker" React-Core: - :path: "../../../node_modules/react-native/" + :path: "../node_modules/react-native/" React-CoreModules: - :path: "../../../node_modules/react-native/React/CoreModules" + :path: "../node_modules/react-native/React/CoreModules" React-cxxreact: - :path: "../../../node_modules/react-native/ReactCommon/cxxreact" + :path: "../node_modules/react-native/ReactCommon/cxxreact" React-debug: - :path: "../../../node_modules/react-native/ReactCommon/react/debug" + :path: "../node_modules/react-native/ReactCommon/react/debug" React-defaultsnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" React-domnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" React-Fabric: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-FabricComponents: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-FabricImage: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-featureflags: - :path: "../../../node_modules/react-native/ReactCommon/react/featureflags" + :path: "../node_modules/react-native/ReactCommon/react/featureflags" React-featureflagsnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" React-graphics: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics" + :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" React-hermes: - :path: "../../../node_modules/react-native/ReactCommon/hermes" + :path: "../node_modules/react-native/ReactCommon/hermes" React-idlecallbacksnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" React-ImageManager: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" React-jserrorhandler: - :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler" + :path: "../node_modules/react-native/ReactCommon/jserrorhandler" React-jsi: - :path: "../../../node_modules/react-native/ReactCommon/jsi" + :path: "../node_modules/react-native/ReactCommon/jsi" React-jsiexecutor: - :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor" + :path: "../node_modules/react-native/ReactCommon/jsiexecutor" React-jsinspector: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" React-jsinspectorcdp: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" React-jsinspectornetwork: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/network" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" React-jsinspectortracing: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" React-jsitooling: - :path: "../../../node_modules/react-native/ReactCommon/jsitooling" + :path: "../node_modules/react-native/ReactCommon/jsitooling" React-jsitracing: - :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/" + :path: "../node_modules/react-native/ReactCommon/hermes/executor/" React-logger: - :path: "../../../node_modules/react-native/ReactCommon/logger" + :path: "../node_modules/react-native/ReactCommon/logger" React-Mapbuffer: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" React-NativeModulesApple: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: - :path: "../../../node_modules/react-native/ReactCommon/oscompat" + :path: "../node_modules/react-native/ReactCommon/oscompat" React-perflogger: - :path: "../../../node_modules/react-native/ReactCommon/reactperflogger" + :path: "../node_modules/react-native/ReactCommon/reactperflogger" React-performancecdpmetrics: - :path: "../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" + :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" React-performancetimeline: - :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline" + :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" React-RCTActionSheet: - :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" + :path: "../node_modules/react-native/Libraries/ActionSheetIOS" React-RCTAnimation: - :path: "../../../node_modules/react-native/Libraries/NativeAnimation" + :path: "../node_modules/react-native/Libraries/NativeAnimation" React-RCTAppDelegate: - :path: "../../../node_modules/react-native/Libraries/AppDelegate" + :path: "../node_modules/react-native/Libraries/AppDelegate" React-RCTBlob: - :path: "../../../node_modules/react-native/Libraries/Blob" + :path: "../node_modules/react-native/Libraries/Blob" React-RCTFabric: - :path: "../../../node_modules/react-native/React" + :path: "../node_modules/react-native/React" React-RCTFBReactNativeSpec: - :path: "../../../node_modules/react-native/React" + :path: "../node_modules/react-native/React" React-RCTImage: - :path: "../../../node_modules/react-native/Libraries/Image" + :path: "../node_modules/react-native/Libraries/Image" React-RCTLinking: - :path: "../../../node_modules/react-native/Libraries/LinkingIOS" + :path: "../node_modules/react-native/Libraries/LinkingIOS" React-RCTNetwork: - :path: "../../../node_modules/react-native/Libraries/Network" + :path: "../node_modules/react-native/Libraries/Network" React-RCTRuntime: - :path: "../../../node_modules/react-native/React/Runtime" + :path: "../node_modules/react-native/React/Runtime" React-RCTSettings: - :path: "../../../node_modules/react-native/Libraries/Settings" + :path: "../node_modules/react-native/Libraries/Settings" React-RCTText: - :path: "../../../node_modules/react-native/Libraries/Text" + :path: "../node_modules/react-native/Libraries/Text" React-RCTVibration: - :path: "../../../node_modules/react-native/Libraries/Vibration" + :path: "../node_modules/react-native/Libraries/Vibration" React-rendererconsistency: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency" + :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" React-renderercss: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/css" + :path: "../node_modules/react-native/ReactCommon/react/renderer/css" React-rendererdebug: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug" + :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" React-RuntimeApple: - :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" React-RuntimeCore: - :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + :path: "../node_modules/react-native/ReactCommon/react/runtime" React-runtimeexecutor: - :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor" + :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" React-RuntimeHermes: - :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + :path: "../node_modules/react-native/ReactCommon/react/runtime" React-runtimescheduler: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" React-timing: - :path: "../../../node_modules/react-native/ReactCommon/react/timing" + :path: "../node_modules/react-native/ReactCommon/react/timing" React-utils: - :path: "../../../node_modules/react-native/ReactCommon/react/utils" + :path: "../node_modules/react-native/ReactCommon/react/utils" React-webperformancenativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" ReactAppDependencyProvider: :path: build/generated/ios ReactCodegen: :path: build/generated/ios ReactCommon: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" Yoga: - :path: "../../../node_modules/react-native/ReactCommon/yoga" + :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 @@ -2592,7 +2592,7 @@ SPEC CHECKSUMS: FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - HarnessUI: 9593f42f9c8f68200ccd07a6ed64d02de42637b1 + HarnessUI: 23b272c7d3a0a3628479d1287c1d4bd59b562636 hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a @@ -2657,7 +2657,7 @@ SPEC CHECKSUMS: React-utils: abf37b162f560cd0e3e5d037af30bb796512246d React-webperformancenativemodule: 50a57c713a90d27ae3ab947a6c9c8859bcb49709 ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176 - ReactCodegen: 65ae48ae967a383859da021028e6e8dd7b2d97d1 + ReactCodegen: 878add6c7d8ff8cea87697c44d29c03b79b6f2d9 ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 1bb0097c..fd30256a 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -33,6 +33,36 @@ export default { }), bundleId: 'com.harnessplayground', }), + androidPlatform({ + name: 'android-crash-pre-rn', + device: androidEmulator('Pixel_8_API_35', { + apiLevel: 35, + profile: 'pixel_6', + diskSize: '1G', + heapSize: '1G', + }), + bundleId: 'com.harnessplayground', + appLaunchOptions: { + extras: { + harness_crash_mode: 'pre_rn', + }, + }, + }), + androidPlatform({ + name: 'android-crash-delayed', + device: androidEmulator('Pixel_8_API_35', { + apiLevel: 35, + profile: 'pixel_6', + diskSize: '1G', + heapSize: '1G', + }), + bundleId: 'com.harnessplayground', + appLaunchOptions: { + extras: { + harness_crash_mode: 'delayed_pre_ready', + }, + }, + }), androidPlatform({ name: 'moto-g72', device: physicalAndroidDevice('Motorola', 'Moto G72'), @@ -48,6 +78,26 @@ export default { device: appleSimulator('iPhone 16 Pro', '18.6'), bundleId: 'com.harnessplayground', }), + applePlatform({ + name: 'ios-crash-pre-rn', + device: appleSimulator('iPhone 16 Pro', '18.6'), + bundleId: 'com.harnessplayground', + appLaunchOptions: { + environment: { + HARNESS_CRASH_MODE: 'pre_rn', + }, + }, + }), + applePlatform({ + name: 'ios-crash-delayed', + device: appleSimulator('iPhone 16 Pro', '18.6'), + bundleId: 'com.harnessplayground', + appLaunchOptions: { + environment: { + HARNESS_CRASH_MODE: 'delayed_pre_ready', + }, + }, + }), vegaPlatform({ name: 'vega', device: vegaEmulator('VegaTV_1'), diff --git a/eslint.config.mjs b/eslint.config.mjs index 419f172e..8bb89de8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,12 +6,16 @@ export default [ ...nx.configs['flat/javascript'], { ignores: [ + '.nx/**', '**/dist', '**/vite.config.*.timestamp*', '**/vitest.config.*.timestamp*', '**/doc_build', '**/build', '**/out-tsc', + 'actions/**/*.cjs', + 'packages/runtime/assets/**/*.js', + '**/*.tsbuildinfo', ], }, { diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index ab4b97f5..e5b2687f 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -169,6 +169,9 @@ export const getBridgeServer = async ({ }); const dispose = () => { + for (const client of wss.clients) { + client.terminate(); + } wss.close(); emitter.removeAllListeners(); binaryStore.dispose(); diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 30915f06..a5df53e6 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -93,6 +93,7 @@ export const getMetroInstance = async ( dispose: () => new Promise((resolve) => { server.close(() => resolve()); + server.closeAllConnections(); }), }; }; diff --git a/packages/cli/tsconfig.tsbuildinfo b/packages/cli/tsconfig.tsbuildinfo index 0b69c0ad..7cf2f681 100644 --- a/packages/cli/tsconfig.tsbuildinfo +++ b/packages/cli/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUnusedLocals":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.8.3"} \ No newline at end of file +{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUnusedLocals":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.9.3"} \ No newline at end of file diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index 83183787..a54ab0e6 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -9,6 +9,25 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import { ZodError } from 'zod'; +const DEPRECATED_PROPERTIES: Record = { + bundleStartTimeout: + '"bundleStartTimeout" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.', + maxAppRestarts: + '"maxAppRestarts" is no longer used and can be removed from your config. Startup crash detection is now handled automatically by the crash supervisor.', +}; + +const warnDeprecatedProperties = (rawConfig: unknown) => { + if (typeof rawConfig !== 'object' || rawConfig === null) { + return; + } + + for (const [key, message] of Object.entries(DEPRECATED_PROPERTIES)) { + if (key in rawConfig) { + console.warn(`[react-native-harness] Deprecation warning: ${message}`); + } + } +}; + const extensions = ['.js', '.mjs', '.cjs', '.json']; const importUp = async ( @@ -43,6 +62,7 @@ const importUp = async ( } try { + warnDeprecatedProperties(rawConfig); const config = ConfigSchema.parse(rawConfig); return { config, filePathWithExt, configDir: dir }; } catch (error) { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index e94d934b..7cd40514 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -28,15 +28,10 @@ export const ConfigSchema = z .min(1000, 'Bridge timeout must be at least 1 second') .default(60000), - bundleStartTimeout: z - .number() - .min(1000, 'Bundle start timeout must be at least 1 second') - .default(15000), - - maxAppRestarts: z - .number() - .min(0, 'Max app restarts must be non-negative') - .default(2), + /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ + bundleStartTimeout: z.number().optional(), + /** @deprecated Removed in favor of crash supervisor. Accepted for backwards compatibility. */ + maxAppRestarts: z.number().optional(), resetEnvironmentBetweenTestFiles: z.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false), diff --git a/packages/github-action/README.md b/packages/github-action/README.md index bc34b692..082eda0e 100644 --- a/packages/github-action/README.md +++ b/packages/github-action/README.md @@ -27,6 +27,7 @@ Runs React Native Harness tests on Android emulators. This action handles: - `app` (required): Path to your built Android app (`.apk` file) - `runner` (required): The runner name from your configuration - `projectRoot` (optional): The project root directory (defaults to repository root) +- Crash artifacts persisted to `.harness/crash-reports/` are uploaded automatically when present **Requirements:** @@ -60,6 +61,7 @@ Runs React Native Harness tests on iOS simulators. This action handles: - `app` (required): Path to your built iOS app (`.app` bundle) - `runner` (required): The runner name from your configuration - `projectRoot` (optional): The project root directory (defaults to repository root) +- Crash artifacts persisted to `.harness/crash-reports/` are uploaded automatically when present **Requirements:** diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 8970d951..ead4f8f5 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -17,6 +17,11 @@ inputs: required: false type: boolean default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' runs: using: 'composite' steps: @@ -136,7 +141,7 @@ runs: script: | echo $(pwd) adb install -r ${{ inputs.app }} - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -146,3 +151,10 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-android + path: ${{ inputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/packages/github-action/src/ios/action.yml b/packages/github-action/src/ios/action.yml index 6ac89d61..9e4db50c 100644 --- a/packages/github-action/src/ios/action.yml +++ b/packages/github-action/src/ios/action.yml @@ -17,6 +17,11 @@ inputs: required: false type: boolean default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' runs: using: 'composite' steps: @@ -63,7 +68,7 @@ runs: - name: Run E2E tests shell: bash working-directory: ${{ inputs.projectRoot }} - run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} + run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -73,3 +78,10 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-ios + path: ${{ inputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/packages/github-action/src/web/action.yml b/packages/github-action/src/web/action.yml index bb18c85c..c7fea330 100644 --- a/packages/github-action/src/web/action.yml +++ b/packages/github-action/src/web/action.yml @@ -14,6 +14,11 @@ inputs: required: false type: boolean default: 'true' + harnessArgs: + description: Additional arguments to pass to the Harness CLI + required: false + type: string + default: '' runs: using: 'composite' steps: @@ -51,7 +56,7 @@ runs: - name: Run E2E tests shell: bash working-directory: ${{ inputs.projectRoot }} - run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} + run: ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 @@ -61,3 +66,10 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Upload crash report artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-crash-reports-web + path: ${{ inputs.projectRoot }}/.harness/crash-reports/**/* + if-no-files-found: ignore diff --git a/packages/jest/src/__tests__/crash-supervisor.test.ts b/packages/jest/src/__tests__/crash-supervisor.test.ts new file mode 100644 index 00000000..9f341e1d --- /dev/null +++ b/packages/jest/src/__tests__/crash-supervisor.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + type AppMonitor, + type AppCrashDetails, + type AppMonitorEvent, + type AppMonitorListener, + type HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import { createCrashSupervisor } from '../crash-supervisor.js'; + +type MockCrashDetailsGetter = ( + options: { + processName?: string; + pid?: number; + occurredAt: number; + } +) => Promise; + +const createMockAppMonitor = (options?: { + getCrashDetails?: MockCrashDetailsGetter; +}) => { + const listeners = new Set(); + const noop = async () => undefined; + + const appMonitor: AppMonitor & { + getCrashDetails?: MockCrashDetailsGetter; + } = { + start: noop, + stop: noop, + dispose: noop, + addListener: (listener) => { + listeners.add(listener); + }, + removeListener: (listener) => { + listeners.delete(listener); + }, + }; + + if (options?.getCrashDetails) { + appMonitor.getCrashDetails = options.getCrashDetails; + } + + const emit = (event: AppMonitorEvent) => { + for (const listener of listeners) { + listener(event); + } + }; + + return { + appMonitor, + emit, + }; +}; + +const createPlatformRunner = ({ + isAppRunning, + getCrashDetails, +}: { + isAppRunning: () => Promise; + getCrashDetails?: HarnessPlatformRunner['getCrashDetails']; +}): HarnessPlatformRunner => ({ + startApp: async () => undefined, + restartApp: async () => undefined, + stopApp: async () => undefined, + dispose: async () => undefined, + isAppRunning, + createAppMonitor: () => { + throw new Error('Not used in unit tests'); + }, + getCrashDetails, +}); + +describe('crash-supervisor', () => { + it('rejects the active test when the app exits during launch', async () => { + const monitor = createMockAppMonitor(); + const supervisor = createCrashSupervisor({ + appMonitor: monitor.appMonitor, + platformRunner: createPlatformRunner({ + isAppRunning: vi.fn().mockResolvedValue(false), + }), + }); + + supervisor.beginLaunch('/tmp/startup.harness.ts'); + + const crashPromise = supervisor.waitForCrash('/tmp/startup.harness.ts'); + const expectation = + expect(crashPromise).rejects.toMatchObject({ + name: 'NativeCrashError', + phase: 'startup', + details: { + signal: 'SIGSEGV', + processName: 'com.harnessplayground', + }, + }); + monitor.emit({ + type: 'app_exited', + source: 'logs', + crashDetails: { + source: 'logs', + signal: 'SIGSEGV', + processName: 'com.harnessplayground', + rawLines: ['Fatal signal 11 (SIGSEGV)'], + }, + }); + + await expectation; + await supervisor.dispose(); + }); + + it('treats polling exit events as immediately confirmed crashes', async () => { + const monitor = createMockAppMonitor(); + const isAppRunning = vi.fn().mockResolvedValue(true); + const supervisor = createCrashSupervisor({ + appMonitor: monitor.appMonitor, + platformRunner: createPlatformRunner({ + isAppRunning, + }), + }); + + supervisor.beginLaunch('/tmp/polling-exit.harness.ts'); + + const crashPromise = supervisor.waitForCrash('/tmp/polling-exit.harness.ts'); + monitor.emit({ type: 'app_exited', source: 'polling' }); + + await expect(crashPromise).rejects.toMatchObject({ + name: 'NativeCrashError', + phase: 'startup', + }); + expect(isAppRunning).not.toHaveBeenCalled(); + await supervisor.dispose(); + }); + + it('does not reject when monitoring is stopped', async () => { + const monitor = createMockAppMonitor(); + const supervisor = createCrashSupervisor({ + appMonitor: monitor.appMonitor, + platformRunner: createPlatformRunner({ + isAppRunning: vi.fn().mockResolvedValue(false), + }), + }); + + supervisor.beginTestRun('/tmp/restart.harness.ts'); + await supervisor.stop(); + + const reject = vi.fn(); + void supervisor.waitForCrash('/tmp/restart.harness.ts').catch(reject); + + monitor.emit({ type: 'app_exited', source: 'polling' }); + await Promise.resolve(); + + expect(reject).not.toHaveBeenCalled(); + await supervisor.dispose(); + }); + + it('prefers crash details from the started app monitor over the runner', async () => { + const monitorGetCrashDetails = vi.fn().mockResolvedValue({ + source: 'logs', + summary: 'full crash block', + rawLines: ['full crash block'], + }); + const runnerGetCrashDetails = vi.fn().mockResolvedValue({ + source: 'logs', + summary: 'runner details', + rawLines: ['runner details'], + }); + const monitor = createMockAppMonitor({ + getCrashDetails: monitorGetCrashDetails, + }); + const supervisor = createCrashSupervisor({ + appMonitor: monitor.appMonitor, + platformRunner: createPlatformRunner({ + isAppRunning: vi.fn().mockResolvedValue(false), + getCrashDetails: runnerGetCrashDetails, + }), + }); + + supervisor.beginTestRun('/tmp/monitor-details.harness.ts'); + + const crashPromise = supervisor.waitForCrash('/tmp/monitor-details.harness.ts'); + monitor.emit({ + type: 'possible_crash', + source: 'logs', + crashDetails: { + source: 'logs', + processName: 'com.harnessplayground', + pid: 1234, + rawLines: ['partial line'], + }, + }); + + await expect(crashPromise).rejects.toMatchObject({ + details: { + summary: 'full crash block', + }, + }); + expect(monitorGetCrashDetails).toHaveBeenCalled(); + expect(runnerGetCrashDetails).not.toHaveBeenCalled(); + await supervisor.dispose(); + }); +}); diff --git a/packages/jest/src/__tests__/errors.test.ts b/packages/jest/src/__tests__/errors.test.ts new file mode 100644 index 00000000..02ab758d --- /dev/null +++ b/packages/jest/src/__tests__/errors.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { NativeCrashError } from '../errors.js'; + +describe('NativeCrashError', () => { + it('formats the extracted stack trace in the error message', () => { + const error = new NativeCrashError('/tmp/crash.harness.ts', { + phase: 'execution', + processName: 'HarnessPlayground', + pid: 1234, + signal: 'SIGTRAP', + exceptionType: 'EXC_BREAKPOINT', + stackTrace: [ + '0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)', + '1 AppDelegate.application(_:didFinishLaunchingWithOptions:) (AppDelegate.swift:56)', + ], + }); + + expect(error.message).toContain( + ' 0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)' + ); + expect(error.message).toContain( + ' 1 AppDelegate.application(_:didFinishLaunchingWithOptions:) (AppDelegate.swift:56)' + ); + }); + + it('omits single-line iOS summaries from the rendered error message', () => { + const error = new NativeCrashError('/tmp/crash.harness.ts', { + phase: 'startup', + artifactType: 'ios-crash-report', + summary: + '2026-03-12 13:46:18.154 Df HarnessPlayground[18007:65e716] [com.apple.dt.xctest:Default] notify_get_state check indicated test daemon not ready.', + processName: 'HarnessPlayground', + pid: 18007, + signal: 'SIGABRT', + exceptionType: 'EXC_CRASH', + }); + + 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)'); + }); + + it('does not duplicate stack frames when the summary already contains a crash block', () => { + const frame = + '03-13 07:59:44.943 20373 20373 E AndroidRuntime: \tat com.harnessplayground.MainActivity.onCreate(MainActivity.kt:38)'; + const error = new NativeCrashError('/tmp/crash.harness.ts', { + phase: 'startup', + summary: [ + '--------- beginning of crash', + '03-13 07:59:44.943 20373 20373 E AndroidRuntime: FATAL EXCEPTION: main', + frame, + ].join('\n'), + stackTrace: [frame], + }); + + 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', () => { + const error = new NativeCrashError('/tmp/crash.harness.ts', { + phase: 'startup', + summary: ['line one', 'line two'].join('\n'), + }); + + 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 new file mode 100644 index 00000000..9c4bb46c --- /dev/null +++ b/packages/jest/src/__tests__/harness.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { waitForAppReady } from '../harness.js'; +import type { BridgeServer } from '@react-native-harness/bridge/server'; +import type { + AppMonitor, + AppMonitorListener, + HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import { createCrashSupervisor } from '../crash-supervisor.js'; + +const createBridgeServer = () => { + const emitter = new EventEmitter(); + + return { + serverBridge: { + on: emitter.on.bind(emitter), + once: emitter.once.bind(emitter), + off: emitter.off.bind(emitter), + } as unknown as BridgeServer, + emitReady: () => { + emitter.emit('ready'); + }, + }; +}; + +const createAppMonitor = (): AppMonitor => { + const listeners = new Set(); + + return { + start: async () => undefined, + stop: async () => undefined, + dispose: async () => undefined, + addListener: (listener) => { + listeners.add(listener); + }, + removeListener: (listener) => { + listeners.delete(listener); + }, + }; +}; + +const createPlatformRunner = ( + restartApp: HarnessPlatformRunner['restartApp'] +): HarnessPlatformRunner => ({ + startApp: async () => undefined, + restartApp, + stopApp: async () => undefined, + dispose: async () => undefined, + isAppRunning: async () => true, + createAppMonitor: createAppMonitor, +}); + +describe('waitForAppReady', () => { + it('passes launch options to the initial launch', async () => { + const { serverBridge, emitReady } = createBridgeServer(); + const restartApp = vi.fn().mockResolvedValue(undefined); + const platformInstance = createPlatformRunner(restartApp); + const crashSupervisor = createCrashSupervisor({ + appMonitor: createAppMonitor(), + platformRunner: platformInstance, + }); + + const promise = waitForAppReady({ + serverBridge, + platformInstance, + bridgeTimeout: 5000, + testFilePath: '/tmp/test.harness.ts', + crashSupervisor, + appLaunchOptions: { + extras: { + mode: 'startup', + }, + }, + }); + + await Promise.resolve(); + expect(restartApp).toHaveBeenCalledWith({ + extras: { + mode: 'startup', + }, + }); + + emitReady(); + await promise; + await crashSupervisor.dispose(); + }); + + it('does not retry launch when the app never becomes ready', async () => { + vi.useFakeTimers(); + + const { serverBridge } = createBridgeServer(); + const restartApp = vi.fn().mockResolvedValue(undefined); + const platformInstance = createPlatformRunner(restartApp); + const crashSupervisor = createCrashSupervisor({ + appMonitor: createAppMonitor(), + platformRunner: platformInstance, + }); + + const promise = waitForAppReady({ + serverBridge, + platformInstance, + bridgeTimeout: 1000, + testFilePath: '/tmp/test.harness.ts', + crashSupervisor, + appLaunchOptions: { + extras: { + mode: 'startup', + }, + }, + }); + + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1000); + + await expect(promise).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(restartApp).toHaveBeenCalledTimes(1); + expect(restartApp).toHaveBeenCalledWith({ + extras: { + mode: 'startup', + }, + }); + + await crashSupervisor.dispose(); + vi.useRealTimers(); + }); +}); diff --git a/packages/jest/src/crash-monitor.ts b/packages/jest/src/crash-monitor.ts deleted file mode 100644 index d481b686..00000000 --- a/packages/jest/src/crash-monitor.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { HarnessPlatformRunner } from '@react-native-harness/platforms'; -import { BridgeServer } from '@react-native-harness/bridge/server'; -import { NativeCrashError } from './errors.js'; -import { logger } from '@react-native-harness/tools'; - -export type CrashMonitor = { - startMonitoring(testFilePath: string): Promise; - stopMonitoring(): void; - markIntentionalRestart(): void; - clearIntentionalRestart(): void; - dispose(): void; -}; - -export type CrashMonitorOptions = { - interval: number; - platformRunner: HarnessPlatformRunner; - bridgeServer: BridgeServer; -}; - -export const createCrashMonitor = ({ - interval, - platformRunner, - bridgeServer, -}: CrashMonitorOptions): CrashMonitor => { - let pollingInterval: NodeJS.Timeout | null = null; - let isIntentionalRestart = false; - let currentTestFilePath: string | null = null; - let rejectFn: ((error: NativeCrashError) => void) | null = null; - - const handleDisconnect = () => { - // Verify if it's actually a crash by checking if app is still running - if (!isIntentionalRestart && currentTestFilePath) { - // Capture the value to avoid it being null when setTimeout callback runs - const testFilePath = currentTestFilePath; - logger.debug('Bridge disconnected, checking if app crashed'); - // Use a slight delay to allow the OS to clean up the process - setTimeout(async () => { - const isRunning = await platformRunner.isAppRunning(); - if (!isRunning && !isIntentionalRestart && rejectFn) { - logger.debug(`Native crash detected during: ${testFilePath}`); - rejectFn(new NativeCrashError(testFilePath)); - } - }, 100); - } - }; - - const startMonitoring = (testFilePath: string): Promise => { - currentTestFilePath = testFilePath; - - return new Promise((_, reject) => { - rejectFn = reject; - - // Listen for bridge disconnect as early indicator - bridgeServer.on('disconnect', handleDisconnect); - - // Poll for app running status - pollingInterval = setInterval(async () => { - // Skip check during intentional restarts - if (isIntentionalRestart) { - return; - } - - try { - const isRunning = await platformRunner.isAppRunning(); - - if (!isRunning && currentTestFilePath) { - logger.debug( - `Native crash detected during: ${currentTestFilePath}` - ); - stopMonitoring(); - reject(new NativeCrashError(currentTestFilePath)); - } - } catch (error) { - logger.error('Error checking app status:', error); - } - }, interval); - }); - }; - - const stopMonitoring = () => { - if (pollingInterval) { - clearInterval(pollingInterval); - pollingInterval = null; - } - bridgeServer.off('disconnect', handleDisconnect); - currentTestFilePath = null; - rejectFn = null; - }; - - const markIntentionalRestart = () => { - isIntentionalRestart = true; - }; - - const clearIntentionalRestart = () => { - isIntentionalRestart = false; - }; - - const dispose = () => { - stopMonitoring(); - }; - - return { - startMonitoring, - stopMonitoring, - markIntentionalRestart, - clearIntentionalRestart, - dispose, - }; -}; diff --git a/packages/jest/src/crash-supervisor.ts b/packages/jest/src/crash-supervisor.ts new file mode 100644 index 00000000..bcafd022 --- /dev/null +++ b/packages/jest/src/crash-supervisor.ts @@ -0,0 +1,314 @@ +import { + type AppMonitor, + type AppCrashDetails, + type AppMonitorEvent, + type AppMonitorListener, + type HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import { + NativeCrashError, + type NativeCrashDetails, + type NativeCrashPhase, +} from './errors.js'; +import { logger } from '@react-native-harness/tools'; + +export type CrashSupervisorState = + | 'idle' + | 'launching' + | 'ready' + | 'running' + | 'disposing'; + +export type CrashSupervisor = { + setActiveTestFile: (testFilePath: string | null) => void; + beginLaunch: (testFilePath: string) => void; + markReady: () => void; + beginTestRun: (testFilePath: string) => void; + stop: () => Promise; + start: () => Promise; + waitForCrash: (testFilePath: string) => Promise; + isReady: () => boolean; + cancelCrashWaiters: () => void; + reset: () => void; + dispose: () => Promise; +}; + +export type CrashSupervisorOptions = { + appMonitor: AppMonitor; + platformRunner: HarnessPlatformRunner; +}; + +type CrashDetailsProvider = { + getCrashDetails?: ( + options: { + processName?: string; + pid?: number; + occurredAt: number; + } + ) => Promise; +}; + +const getCrashPhase = (state: CrashSupervisorState): NativeCrashPhase => + state === 'running' ? 'execution' : 'startup'; + +const mergeCrashDetails = ( + phase: NativeCrashPhase, + initialDetails?: AppCrashDetails, + enrichedDetails?: AppCrashDetails | null, + fallbackSummary?: string +): NativeCrashDetails => ({ + phase, + source: enrichedDetails?.source ?? initialDetails?.source, + summary: + enrichedDetails?.summary ?? initialDetails?.summary ?? fallbackSummary, + signal: enrichedDetails?.signal ?? initialDetails?.signal, + exceptionType: enrichedDetails?.exceptionType ?? initialDetails?.exceptionType, + processName: enrichedDetails?.processName ?? initialDetails?.processName, + pid: enrichedDetails?.pid ?? initialDetails?.pid, + stackTrace: enrichedDetails?.stackTrace ?? initialDetails?.stackTrace, + rawLines: enrichedDetails?.rawLines ?? initialDetails?.rawLines, + artifactType: enrichedDetails?.artifactType ?? initialDetails?.artifactType, + artifactPath: enrichedDetails?.artifactPath ?? initialDetails?.artifactPath, +}); + +export const createCrashSupervisor = ({ + appMonitor, + platformRunner, +}: CrashSupervisorOptions): CrashSupervisor => { + let state: CrashSupervisorState = 'idle'; + let activeTestFilePath: string | null = null; + let crashRejectors = new Set<(error: NativeCrashError) => void>(); + let disposed = false; + let monitoring = true; + let isResolvingCrash = false; + + const getCrashDetailsProvider = (): CrashDetailsProvider | null => { + if ('getCrashDetails' in appMonitor) { + return appMonitor as AppMonitor & CrashDetailsProvider; + } + + if (platformRunner.getCrashDetails) { + return platformRunner; + } + + return null; + }; + + const rejectCrashWaiters = (testFilePath: string, details: NativeCrashDetails) => { + const error = new NativeCrashError(testFilePath, details); + + for (const reject of crashRejectors) { + reject(error); + } + + crashRejectors = new Set(); + }; + + const handleCrash = async (reason: string, details?: AppCrashDetails) => { + if ( + isResolvingCrash || + (state !== 'launching' && state !== 'running' && state !== 'ready') + ) { + return; + } + + if (!activeTestFilePath) { + logger.debug(`Ignoring crash signal without active test: ${reason}`); + return; + } + + isResolvingCrash = true; + logger.debug(`Native crash detected during ${activeTestFilePath} (state: ${state}, reason: ${reason || '(none)'})`); + + for (const line of details?.rawLines ?? []) { + logger.debug(line); + } + + const phase = getCrashPhase(state); + const testFilePath = activeTestFilePath; + state = 'idle'; + + try { + const enrichedDetails = await getCrashDetailsProvider()?.getCrashDetails?.({ + processName: details?.processName, + pid: details?.pid, + occurredAt: Date.now(), + }); + + const mergedDetails = mergeCrashDetails(phase, details, enrichedDetails, reason); + logger.debug('Crash details:', { + phase: mergedDetails.phase, + source: mergedDetails.source, + summary: mergedDetails.summary, + signal: mergedDetails.signal, + exceptionType: mergedDetails.exceptionType, + processName: mergedDetails.processName, + pid: mergedDetails.pid, + }); + rejectCrashWaiters(testFilePath, mergedDetails); + } finally { + isResolvingCrash = false; + } + }; + + const confirmCrash = async (reason: string, details?: AppCrashDetails) => { + if (disposed || !monitoring || state === 'disposing') { + return; + } + + try { + const isRunning = await platformRunner.isAppRunning(); + + if (!isRunning) { + handleCrash(reason, details); + } + } catch (error) { + logger.debug('Crash confirmation failed', error); + } + }; + + const appMonitorListener: AppMonitorListener = (event: AppMonitorEvent) => { + if (disposed || !monitoring) { + return; + } + + if (event.type === 'app_started') { + return; + } + + if (event.type === 'app_exited') { + const details = { + source: event.crashDetails?.source ?? event.source, + summary: event.crashDetails?.summary, + signal: event.crashDetails?.signal, + exceptionType: event.crashDetails?.exceptionType, + processName: event.crashDetails?.processName, + pid: event.crashDetails?.pid ?? event.pid, + stackTrace: event.crashDetails?.stackTrace, + rawLines: + event.crashDetails?.rawLines ?? + (event.line ? [event.line] : undefined), + }; + + if (event.isConfirmed ?? event.source === 'polling') { + void handleCrash('', details); + } else { + void confirmCrash('', details); + } + return; + } + + if (event.type === 'possible_crash') { + const details = { + source: event.crashDetails?.source ?? event.source, + summary: event.crashDetails?.summary, + signal: event.crashDetails?.signal, + exceptionType: event.crashDetails?.exceptionType, + processName: event.crashDetails?.processName, + pid: event.crashDetails?.pid ?? event.pid, + stackTrace: event.crashDetails?.stackTrace, + rawLines: + event.crashDetails?.rawLines ?? + (event.line ? [event.line] : undefined), + }; + + if (event.isConfirmed) { + void handleCrash( + `possible crash signal (${event.source ?? 'unknown'})`, + details + ); + } else { + void confirmCrash( + `possible crash signal (${event.source ?? 'unknown'})`, + details + ); + } + } + }; + + appMonitor.addListener(appMonitorListener); + + const setActiveTestFile = (testFilePath: string | null) => { + activeTestFilePath = testFilePath; + }; + + const beginLaunch = (testFilePath: string) => { + activeTestFilePath = testFilePath; + state = 'launching'; + }; + + const markReady = () => { + if (state !== 'disposing') { + state = 'ready'; + } + }; + + const beginTestRun = (testFilePath: string) => { + activeTestFilePath = testFilePath; + state = 'running'; + }; + + const stop = async () => { + monitoring = false; + await appMonitor.stop(); + }; + + const start = async () => { + monitoring = true; + await appMonitor.start(); + }; + + const waitForCrash = (testFilePath: string): Promise => + new Promise((_, reject) => { + if (disposed) { + reject( + new NativeCrashError(testFilePath, { + phase: 'startup', + source: 'polling', + summary: 'Crash supervisor disposed while waiting for app startup.', + }) + ); + return; + } + + crashRejectors.add(reject); + }); + + const isReady = () => state === 'ready' || state === 'running'; + + const cancelCrashWaiters = () => { + crashRejectors = new Set(); + }; + + const reset = () => { + cancelCrashWaiters(); + isResolvingCrash = false; + if (state !== 'disposing') { + state = 'idle'; + } + }; + + const dispose = async () => { + disposed = true; + monitoring = false; + state = 'disposing'; + crashRejectors = new Set(); + isResolvingCrash = false; + appMonitor.removeListener(appMonitorListener); + await appMonitor.dispose(); + }; + + return { + setActiveTestFile, + beginLaunch, + markReady, + beginTestRun, + stop, + start, + waitForCrash, + isReady, + cancelCrashWaiters, + reset, + dispose, + }; +}; diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index a5a17a7d..a7f5c7ed 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -1,4 +1,5 @@ import { HarnessError } from '@react-native-harness/tools'; +import type { AppCrashDetails } from '@react-native-harness/platforms'; export class NoRunnerSpecifiedError extends HarnessError { constructor() { @@ -21,22 +22,76 @@ export class InitializationTimeoutError extends HarnessError { } } -export class MaxAppRestartsError extends HarnessError { - constructor(attempts: number) { - super( - `App failed to start after ${attempts} attempts. ` + - `No bundling activity detected within timeout period.` +export type NativeCrashPhase = 'startup' | 'execution'; + +export type NativeCrashDetails = AppCrashDetails & { + phase: NativeCrashPhase; +}; + +const buildNativeCrashMessage = ({ + phase, + summary, + signal, + exceptionType, + processName, + pid, + stackTrace, + artifactType, +}: NativeCrashDetails) => { + const lines = [ + phase === 'startup' + ? 'The native app crashed while preparing to run this test file.' + : 'The native app crashed during test execution.', + ]; + const hasCrashBlock = summary?.includes('\n') ?? false; + const shouldRenderSummary = + Boolean(summary) && + !( + !hasCrashBlock && + artifactType === 'ios-crash-report' ); - this.name = 'MaxAppRestartsError'; + + if (shouldRenderSummary && summary) { + lines.push(''); + lines.push(summary); + } + + if (!hasCrashBlock && signal) { + lines.push(`Signal: ${signal}`); + } + + if (!hasCrashBlock && exceptionType) { + lines.push(`Exception: ${exceptionType}`); } -} + + if (!hasCrashBlock && processName && pid !== undefined) { + lines.push(`Process: ${processName} (pid ${pid})`); + } else if (!hasCrashBlock && processName) { + lines.push(`Process: ${processName}`); + } else if (!hasCrashBlock && pid !== undefined) { + lines.push(`PID: ${pid}`); + } + + if (!hasCrashBlock && stackTrace && stackTrace.length > 0) { + lines.push(''); + lines.push(...stackTrace.map((line) => ` ${line}`)); + } + + return lines.join('\n'); +}; export class NativeCrashError extends HarnessError { constructor( public readonly testFilePath: string, + public readonly details: NativeCrashDetails, public readonly lastKnownTest?: string ) { - super('The native app crashed during test execution.'); + super(buildNativeCrashMessage(details)); this.name = 'NativeCrashError'; + this.stack = `${this.name}: ${this.message.split('\n')[0]}`; + } + + get phase() { + return this.details.phase; } } diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 3ed8ff5f..23472d1c 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -8,18 +8,21 @@ import { TestSuiteResult, } from '@react-native-harness/bridge'; import { + type AppLaunchOptions, HarnessPlatform, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { getMetroInstance, prewarmMetroBundle, - Reporter, - ReportableEvent, } from '@react-native-harness/bundler-metro'; -import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js'; +import { createCrashArtifactWriter } from '@react-native-harness/tools'; +import { InitializationTimeoutError } from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; -import { createCrashMonitor, CrashMonitor } from './crash-monitor.js'; +import { + createCrashSupervisor, + type CrashSupervisor, +} from './crash-supervisor.js'; import { createClientLogListener } from './client-log-handler.js'; import { logMetroPrewarmCompleted } from './logs.js'; @@ -31,97 +34,65 @@ export type Harness = { path: string, options: HarnessRunTestsOptions ) => Promise; - restart: () => Promise; + ensureAppReady: (testFilePath: string) => Promise; + restart: (testFilePath?: string) => Promise; dispose: () => Promise; - crashMonitor: CrashMonitor; + crashSupervisor: CrashSupervisor; }; export const waitForAppReady = async (options: { - metroEvents: Reporter; serverBridge: BridgeServer; platformInstance: HarnessPlatformRunner; - bundleStartTimeout: number; - maxRestarts: number; - signal: AbortSignal; + bridgeTimeout: number; + testFilePath: string; + crashSupervisor: CrashSupervisor; + appLaunchOptions?: AppLaunchOptions; }): Promise => { const { - metroEvents, serverBridge, platformInstance, - bundleStartTimeout, - maxRestarts, - signal, + bridgeTimeout, + testFilePath, + crashSupervisor, + appLaunchOptions, } = options; - let restartCount = 0; - let isBundling = false; - let bundleTimeoutId: NodeJS.Timeout | null = null; - - const clearBundleTimeout = () => { - if (bundleTimeoutId) { - clearTimeout(bundleTimeoutId); - bundleTimeoutId = null; - } - }; + const signal = AbortSignal.timeout(bridgeTimeout); return new Promise((resolve, reject) => { - // Handle abort signal - signal.addEventListener('abort', () => { - clearBundleTimeout(); - reject(new DOMException('The operation was aborted', 'AbortError')); - }); - - // Start/restart the bundle timeout - const startBundleTimeout = () => { - clearBundleTimeout(); - bundleTimeoutId = setTimeout(() => { - if (isBundling) return; // Don't restart while bundling - - if (restartCount >= maxRestarts) { - cleanup(); - reject(new MaxAppRestartsError(restartCount + 1)); - return; - } - - restartCount++; - platformInstance.restartApp().catch(reject); - startBundleTimeout(); // Reset timer for next attempt - }, bundleStartTimeout); - }; - - // Metro event listener - const onMetroEvent = (event: ReportableEvent) => { - if (event.type === 'bundle_build_started') { - isBundling = true; - clearBundleTimeout(); // Cancel restart timer while bundling - } else if ( - event.type === 'bundle_build_done' || - event.type === 'bundle_build_failed' - ) { - isBundling = false; - startBundleTimeout(); // Reset timer after bundle completes - } + const launchApp = async () => { + crashSupervisor.beginLaunch(testFilePath); + await platformInstance.restartApp(appLaunchOptions); }; - // Bridge ready listener const onReady = () => { + crashSupervisor.markReady(); cleanup(); resolve(); }; + const onAbort = () => { + cleanup(); + reject(new DOMException('The operation was aborted', 'AbortError')); + }; + const cleanup = () => { - clearBundleTimeout(); - metroEvents.removeListener(onMetroEvent); serverBridge.off('ready', onReady); + signal.removeEventListener('abort', onAbort); + crashSupervisor.cancelCrashWaiters(); }; - // Setup listeners - metroEvents.addListener(onMetroEvent); + signal.addEventListener('abort', onAbort); serverBridge.once('ready', onReady); + void crashSupervisor.waitForCrash(testFilePath).catch((error) => { + cleanup(); + reject(error); + }); - // Start the app and timeout - platformInstance.restartApp().catch(reject); - startBundleTimeout(); + void launchApp().catch((error) => { + cleanup(); + reject(error); + }); }); }; @@ -146,9 +117,24 @@ const getHarnessInternal = async ( context, }), ]); + const crashArtifactWriter = createCrashArtifactWriter({ + runnerName: platform.name, + platformId: platform.platformId, + }); + const appMonitor = platformInstance.createAppMonitor({ + crashArtifactWriter, + }); + const appLaunchOptions = ( + platform.config as { appLaunchOptions?: AppLaunchOptions } + ).appLaunchOptions; - // Forward client logs to console if enabled const clientLogListener = createClientLogListener(); + const crashSupervisor = createCrashSupervisor({ + appMonitor, + platformRunner: platformInstance, + }); + + serverBridge.on('ready', crashSupervisor.markReady); if (config.forwardClientLogs) { metroInstance.events.addListener(clientLogListener); @@ -158,19 +144,15 @@ const getHarnessInternal = async ( if (config.forwardClientLogs) { metroInstance.events.removeListener(clientLogListener); } + serverBridge.off('ready', crashSupervisor.markReady); await Promise.all([ + crashSupervisor.dispose(), serverBridge.dispose(), platformInstance.dispose(), metroInstance.dispose(), ]); }; - const crashMonitor = createCrashMonitor({ - interval: config.crashDetectionInterval, - platformRunner: platformInstance, - bridgeServer: serverBridge, - }); - if (signal.aborted) { await dispose(); @@ -187,29 +169,46 @@ const getHarnessInternal = async ( signal, }); logMetroPrewarmCompleted(platform); - - await waitForAppReady({ - metroEvents: metroInstance.events, - serverBridge, - platformInstance: platformInstance as HarnessPlatformRunner, - bundleStartTimeout: config.bundleStartTimeout, - maxRestarts: config.maxAppRestarts, - signal, - }); + await appMonitor.start(); } catch (error) { await dispose(); throw error; } - const restart = () => - new Promise((resolve, reject) => { - crashMonitor.markIntentionalRestart(); - serverBridge.once('ready', () => { - crashMonitor.clearIntentionalRestart(); - resolve(); - }); - platformInstance.restartApp().catch(reject); + const ensureAppReady = async (testFilePath: string) => { + crashSupervisor.setActiveTestFile(testFilePath); + + if (crashSupervisor.isReady() && (await platformInstance.isAppRunning())) { + return; + } + + crashSupervisor.reset(); + await waitForAppReady({ + serverBridge, + platformInstance: platformInstance as HarnessPlatformRunner, + bridgeTimeout: config.bridgeTimeout, + testFilePath, + crashSupervisor, + appLaunchOptions, }); + }; + + const restart = async (testFilePath?: string) => { + await crashSupervisor.stop(); + + if (testFilePath) { + await platformInstance.stopApp(); + } else { + await platformInstance.restartApp(appLaunchOptions); + } + + crashSupervisor.reset(); + await crashSupervisor.start(); + + if (testFilePath) { + await ensureAppReady(testFilePath); + } + }; return { context, @@ -225,9 +224,10 @@ const getHarnessInternal = async ( runner: platform.runner, }); }, + ensureAppReady, restart, dispose, - crashMonitor, + crashSupervisor, }; }; diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index b087d399..d3e25dbd 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -97,16 +97,17 @@ export default class JestHarness implements CallbackTestRunnerInterface { throw new CancelRun(); } - if ( - harnessConfig.resetEnvironmentBetweenTestFiles && - !isFirstTest - ) { - await harness.restart(); - } - isFirstTest = false; + if ( + harnessConfig.resetEnvironmentBetweenTestFiles && + !isFirstTest + ) { + await harness.restart(test.path); + } + isFirstTest = false; return onStart(test).then(async () => { if (!harnessConfig.detectNativeCrashes) { + await harness.ensureAppReady(test.path); return runHarnessTestFile({ testPath: test.path, harness, @@ -115,10 +116,9 @@ export default class JestHarness implements CallbackTestRunnerInterface { }); } - // Start crash monitoring - const crashPromise = harness.crashMonitor.startMonitoring( - test.path - ); + await harness.ensureAppReady(test.path); + harness.crashSupervisor.beginTestRun(test.path); + const crashPromise = harness.crashSupervisor.waitForCrash(test.path); try { const result = await Promise.race([ @@ -133,21 +133,19 @@ export default class JestHarness implements CallbackTestRunnerInterface { return result; } finally { - harness.crashMonitor.stopMonitoring(); + harness.crashSupervisor.cancelCrashWaiters(); } }); }) .then((result) => onResult(test, result)) .catch(async (err) => { if (err instanceof NativeCrashError) { + harness.crashSupervisor.reset(); onFailure(test, { message: err.message, stack: '', }); - // Restart the app for the next test file - await harness.restart(); - return; } diff --git a/packages/jest/src/setup.ts b/packages/jest/src/setup.ts index 18ee880d..78e69846 100644 --- a/packages/jest/src/setup.ts +++ b/packages/jest/src/setup.ts @@ -48,11 +48,6 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => { // Do not setup again if HARNESS is already initialized // This is useful when running tests in watch mode - if (harnessConfig.resetEnvironmentBetweenTestFiles) { - // In watch mode, we want to restart the environment before each test run - await global.HARNESS.restart(); - } - return; } diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts new file mode 100644 index 00000000..6618e81e --- /dev/null +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getAppUid, getLogcatTimestamp, getStartAppArgs } from '../adb.js'; +import * as tools from '@react-native-harness/tools'; + +describe('getStartAppArgs', () => { + it('maps supported extras to adb am start flags', () => { + expect( + getStartAppArgs('com.example.app', '.MainActivity', { + extras: { + feature_flag: true, + user_id: 42, + mode: 'debug', + }, + }) + ).toEqual([ + 'shell', + 'am', + 'start', + '-a', + 'android.intent.action.MAIN', + '-c', + 'android.intent.category.LAUNCHER', + '-n', + 'com.example.app/.MainActivity', + '--ez', + 'feature_flag', + 'true', + '--ei', + 'user_id', + '42', + '--es', + 'mode', + 'debug', + ]); + }); + + it('rejects unsafe integer extras', () => { + expect(() => + getStartAppArgs('com.example.app', '.MainActivity', { + extras: { + count: Number.MAX_SAFE_INTEGER + 1, + }, + }) + ).toThrow('must be a safe integer'); + }); + + 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>); + + await expect(getAppUid('emulator-5554', 'com.example.app')).resolves.toBe( + 10234 + ); + + expect(spawnSpy).toHaveBeenCalledWith('adb', [ + '-s', + 'emulator-5554', + 'shell', + 'pm', + 'list', + 'packages', + '-U', + ]); + }); + + 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>); + + await expect(getLogcatTimestamp('emulator-5554')).resolves.toBe( + '03-12 11:35:08.000' + ); + + expect(spawnSpy).toHaveBeenCalledWith('adb', [ + '-s', + 'emulator-5554', + 'shell', + 'date', + "+'%m-%d %H:%M:%S.000'", + ]); + }); +}); diff --git a/packages/platform-android/src/__tests__/app-monitor.test.ts b/packages/platform-android/src/__tests__/app-monitor.test.ts new file mode 100644 index 00000000..4caa3f1a --- /dev/null +++ b/packages/platform-android/src/__tests__/app-monitor.test.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { createAndroidAppMonitor, createAndroidLogEvent } from '../app-monitor.js'; +import * as tools from '@react-native-harness/tools'; +import { createCrashArtifactWriter } from '@react-native-harness/tools'; + +const createMockSubprocess = (): tools.Subprocess => + ({ + nodeChildProcess: Promise.resolve({ + kill: vi.fn(), + }), + [Symbol.asyncIterator]: async function* () {}, + }) as unknown as tools.Subprocess; + +const createStreamingSubprocess = ( + chunks: Array<{ line: string; delayMs?: number }> +): tools.Subprocess => + ({ + nodeChildProcess: Promise.resolve({ + kill: vi.fn(), + }), + [Symbol.asyncIterator]: async function* () { + for (const { line, delayMs = 0 } of chunks) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + yield line; + } + }, + }) as unknown as tools.Subprocess; + +const artifactRoot = fs.mkdtempSync( + path.join(tmpdir(), 'rn-harness-android-monitor-artifacts-') +); + +afterEach(() => { + fs.rmSync(artifactRoot, { recursive: true, force: true }); + fs.mkdirSync(artifactRoot, { recursive: true }); +}); + +describe('createAndroidLogEvent', () => { + it('extracts crash details from fatal signal log lines', () => { + const event = createAndroidLogEvent( + '03-12 11:35:08.000 1234 1234 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR) in tid 1234 (com.harnessplayground), pid 1234 (com.harnessplayground)', + 'com.harnessplayground' + ); + + expect(event).toMatchObject({ + type: 'possible_crash', + source: 'logs', + crashDetails: { + source: 'logs', + signal: 'SIGSEGV', + summary: + '03-12 11:35:08.000 1234 1234 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR) in tid 1234 (com.harnessplayground), pid 1234 (com.harnessplayground)', + }, + }); + }); + + it('extracts process and pid when AndroidRuntime reports a crash', () => { + const event = createAndroidLogEvent( + '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + 'com.harnessplayground' + ); + + expect(event).toMatchObject({ + type: 'possible_crash', + pid: 1234, + crashDetails: { + processName: 'com.harnessplayground', + pid: 1234, + }, + }); + }); + + it('starts logcat from the current device timestamp', async () => { + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy.mockImplementation( + ((file: string, args?: readonly string[]) => { + if (file === 'adb' && args?.includes('date')) { + return { + stdout: '03-12 11:35:08.000\n', + } as Awaited>; + } + + return createMockSubprocess(); + }) as typeof tools.spawn + ); + + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + }); + + await monitor.start(); + await monitor.stop(); + + expect(spawnSpy).toHaveBeenNthCalledWith(2, 'adb', [ + '-s', + 'emulator-5554', + 'logcat', + '-v', + 'threadtime', + '-b', + 'crash', + '--uid=10234', + '-T', + '03-12 11:35:08.000', + ], { + stdout: 'pipe', + stderr: 'pipe', + }); + }); + + it('hydrates crash details with stack lines that arrive after the first crash event', async () => { + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy.mockImplementation( + ((file: string, args?: readonly string[]) => { + if (file === 'adb' && args?.includes('date')) { + return { + stdout: '03-12 10:44:40.000\n', + } as Awaited>; + } + + return createStreamingSubprocess([ + { line: '--------- beginning of crash' }, + { + line: '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861', + }, + { + line: '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom', + delayMs: 25, + }, + { + line: '03-12 10:44:40.422 13861 13861 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)', + delayMs: 25, + }, + ]); + }) as typeof tools.spawn + ); + + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const details = await monitor.getCrashDetails({ + pid: 13861, + occurredAt: Date.now(), + }); + + await monitor.stop(); + + expect(details?.rawLines).toEqual([ + '--------- beginning of crash', + '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861', + '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom', + '03-12 10:44:40.422 13861 13861 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)', + ]); + }); + + it('persists resolved Android crash blocks into .harness', async () => { + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy.mockImplementation( + ((file: string, args?: readonly string[]) => { + if (file === 'adb' && args?.includes('date')) { + return { + stdout: '03-12 10:44:40.000\n', + } as Awaited>; + } + + return createStreamingSubprocess([ + { line: '--------- beginning of crash' }, + { + line: '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861', + }, + { + line: '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom', + delayMs: 20, + }, + ]); + }) as typeof tools.spawn + ); + + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + crashArtifactWriter: createCrashArtifactWriter({ + runnerName: 'android', + platformId: 'android', + rootDir: path.join(artifactRoot, '.harness', 'crash-reports'), + runTimestamp: '2026-03-12T11-35-08-000Z', + }), + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const details = await monitor.getCrashDetails({ + pid: 13861, + occurredAt: Date.now(), + }); + + await monitor.stop(); + + expect(details?.artifactPath).toContain('/.harness/crash-reports/'); + expect(fs.readFileSync(details!.artifactPath!, 'utf8')).toContain( + 'RuntimeException: boom' + ); + }); + + it('can be started again after timestamp lookup fails', async () => { + const spawnSpy = vi.spyOn(tools, 'spawn'); + const timestampError = new Error('date failed'); + + spawnSpy.mockImplementation( + ((file: string, args?: readonly string[]) => { + if (file === 'adb' && args?.includes('date')) { + if ( + spawnSpy.mock.calls.filter( + ([calledFile, calledArgs]) => + calledFile === 'adb' && + Array.isArray(calledArgs) && + calledArgs.includes('date') + ).length === 1 + ) { + throw timestampError; + } + + return { + stdout: '03-12 11:35:08.000\n', + } as Awaited>; + } + + return createMockSubprocess(); + }) as typeof tools.spawn + ); + + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + }); + + await expect(monitor.start()).rejects.toThrow(timestampError); + await expect(monitor.start()).resolves.toBeUndefined(); + await monitor.stop(); + + expect( + spawnSpy.mock.calls.some( + ([file, args]) => + file === 'adb' && + Array.isArray(args) && + args.includes('logcat') && + args.includes('--uid=10234') + ) + ).toBe(true); + }); +}); diff --git a/packages/platform-android/src/__tests__/crash-parser.test.ts b/packages/platform-android/src/__tests__/crash-parser.test.ts new file mode 100644 index 00000000..90d368fc --- /dev/null +++ b/packages/platform-android/src/__tests__/crash-parser.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { androidCrashParser } from '../crash-parser.js'; + +describe('androidCrashParser.parse', () => { + it('parses an AndroidRuntime crash block into a crash details object', () => { + expect( + androidCrashParser.parse({ + contents: [ + '--------- beginning of crash', + '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + '03-12 11:35:09.001 1234 1234 E AndroidRuntime: java.lang.RuntimeException: boom', + '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)', + ].join('\n'), + bundleId: 'com.harnessplayground', + }) + ).toEqual({ + source: 'logs', + summary: [ + '--------- beginning of crash', + '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + '03-12 11:35:09.001 1234 1234 E AndroidRuntime: java.lang.RuntimeException: boom', + '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)', + ].join('\n'), + signal: undefined, + exceptionType: 'java.lang.RuntimeException: boom', + processName: 'com.harnessplayground', + pid: 1234, + rawLines: [ + '--------- beginning of crash', + '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + '03-12 11:35:09.001 1234 1234 E AndroidRuntime: java.lang.RuntimeException: boom', + '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)', + ], + stackTrace: [ + '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)', + ], + }); + }); + + it('extracts fatal signals from a native crash block', () => { + expect( + androidCrashParser.parse({ + contents: + '03-12 11:35:08.000 1234 1234 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR) in tid 1234 (com.harnessplayground), pid 1234 (com.harnessplayground)', + bundleId: 'com.harnessplayground', + }) + ).toMatchObject({ + signal: 'SIGSEGV', + processName: 'com.harnessplayground', + }); + }); +}); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index c4f2b235..11c40bb6 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,4 +1,47 @@ -import { spawn } from '@react-native-harness/tools'; +import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; +import { spawn, SubprocessError } from '@react-native-harness/tools'; + +export const getStartAppArgs = ( + bundleId: string, + activityName: string, + options?: AndroidAppLaunchOptions +): string[] => { + const args = [ + 'shell', + 'am', + 'start', + '-a', + 'android.intent.action.MAIN', + '-c', + 'android.intent.category.LAUNCHER', + '-n', + `${bundleId}/${activityName}`, + ]; + + const extras = options?.extras ?? {}; + + for (const [key, value] of Object.entries(extras)) { + if (typeof value === 'string') { + args.push('--es', key, value); + continue; + } + + if (typeof value === 'boolean') { + args.push('--ez', key, value ? 'true' : 'false'); + continue; + } + + if (!Number.isSafeInteger(value)) { + throw new Error( + `Android app launch option "${key}" must be a safe integer.` + ); + } + + args.push('--ei', key, value.toString()); + } + + return args; +}; export const isAppInstalled = async ( adbId: string, @@ -40,21 +83,10 @@ export const stopApp = async ( export const startApp = async ( adbId: string, bundleId: string, - activityName: string + activityName: string, + options?: AndroidAppLaunchOptions ): Promise => { - await spawn('adb', [ - '-s', - adbId, - 'shell', - 'am', - 'start', - '-a', - 'android.intent.action.MAIN', - '-c', - 'android.intent.category.LAUNCHER', - '-n', - `${bundleId}/${activityName}`, - ]); + await spawn('adb', ['-s', adbId, ...getStartAppArgs(bundleId, activityName, options)]); }; export const getDeviceIds = async (): Promise => { @@ -113,14 +145,75 @@ export const isAppRunning = async ( adbId: string, bundleId: string ): Promise => { + try { + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'pidof', + bundleId, + ]); + return stdout.trim() !== ''; + } catch (error) { + if (error instanceof SubprocessError && error.exitCode === 1) { + return false; + } + + throw error; + } +}; + +export const getAppUid = async ( + adbId: string, + bundleId: string +): Promise => { const { stdout } = await spawn('adb', [ '-s', adbId, 'shell', - 'pidof', - bundleId, + 'pm', + 'list', + 'packages', + '-U', ]); - return stdout.trim() !== ''; + const line = stdout + .split('\n') + .find((entry) => entry.includes(`package:${bundleId}`)); + const match = line?.match(/\buid:(\d+)\b/); + + if (!match) { + throw new Error(`Failed to resolve Android app UID for "${bundleId}".`); + } + + return Number(match[1]); +}; + +export const setHideErrorDialogs = async ( + adbId: string, + hide: boolean +): Promise => { + await spawn('adb', [ + '-s', + adbId, + 'shell', + 'settings', + 'put', + 'global', + 'hide_error_dialogs', + hide ? '1' : '0', + ]); +}; + +export const getLogcatTimestamp = async (adbId: string): Promise => { + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'date', + "+'%m-%d %H:%M:%S.000'", + ]); + + return stdout.trim().replace(/^'+|'+$/g, ''); }; export const getAvds = async (): Promise => { diff --git a/packages/platform-android/src/app-monitor.ts b/packages/platform-android/src/app-monitor.ts new file mode 100644 index 00000000..45e3720d --- /dev/null +++ b/packages/platform-android/src/app-monitor.ts @@ -0,0 +1,542 @@ +import { + type AppMonitor, + type AppCrashDetails, + type CrashArtifactWriter, + type CrashDetailsLookupOptions, + type AppMonitorEvent, + type AppMonitorListener, +} from '@react-native-harness/platforms'; +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 getLogcatArgs = (uid: number, fromTime: string) => + ['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; + +const startProcPattern = (bundleId: string) => + new RegExp(`Start proc (\\d+):${escapeRegExp(bundleId)}(?:/|\\s)`); + +const processPattern = (bundleId: string) => + new RegExp(`Process:\\s*${escapeRegExp(bundleId)},\\s*PID:\\s*(\\d+)`); + +const nativeCrashPattern = (bundleId: string) => + new RegExp(`>>>\\s*${escapeRegExp(bundleId)}\\s*<<<`); + +const processDiedPattern = (bundleId: string) => + new RegExp( + `Process\\s+${escapeRegExp(bundleId)}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, + 'i' + ); + +const getSignal = (line: string) => { + const namedSignalMatch = line.match(/\b(SIG[A-Z0-9]+)\b/); + + if (namedSignalMatch) { + return namedSignalMatch[1]; + } + + const signalNumberMatch = line.match(/signal\s+(\d+)/i); + + if (signalNumberMatch) { + return `signal ${signalNumberMatch[1]}`; + } + + return undefined; +}; + +const getAndroidLogLineCrashDetails = ({ + line, + bundleId, + pid, +}: { + line: string; + bundleId: string; + pid?: number; +}): AppCrashDetails => { + const fatalExceptionMatch = line.match(/FATAL EXCEPTION:\s*(.+)$/i); + const processMatch = line.match(processPattern(bundleId)); + + return { + source: 'logs', + summary: line.trim(), + signal: getSignal(line), + exceptionType: fatalExceptionMatch?.[1]?.trim(), + processName: processMatch ? bundleId : line.includes(bundleId) ? bundleId : undefined, + pid: pid ?? (processMatch ? Number(processMatch[1]) : undefined), + rawLines: [line], + }; +}; + +type TimedLogLine = { + line: string; + occurredAt: number; +}; + +type AndroidCrashArtifact = AppCrashDetails & { + occurredAt: number; + triggerLine: string; + triggerOccurredAt?: number; +}; + +const CRASH_BLOCK_HEADER = '--------- beginning of crash'; + +const getLatestCrashBlock = (recentLogLines: TimedLogLine[]) => { + const lines = recentLogLines.map(({ line }) => line); + let latestCrashHeaderIndex = -1; + + for (let index = lines.length - 1; index >= 0; index -= 1) { + if (/FATAL EXCEPTION:|Process:\s+.+,\s+PID:/i.test(lines[index])) { + latestCrashHeaderIndex = index; + break; + } + } + + const blockStartIndex = Math.max( + lines.lastIndexOf(CRASH_BLOCK_HEADER), + latestCrashHeaderIndex + ); + + if (blockStartIndex === -1) { + return lines; + } + + return lines.slice(blockStartIndex); +}; + +const getCrashBlockForArtifact = ({ + artifact, + recentLogLines, +}: { + artifact: AndroidCrashArtifact; + recentLogLines: TimedLogLine[]; +}): string[] => { + const targetIndex = recentLogLines.findIndex( + ({ line, occurredAt }) => + line === artifact.triggerLine && + (artifact.triggerOccurredAt === undefined || + occurredAt === artifact.triggerOccurredAt) + ); + + if (targetIndex === -1) { + return artifact.rawLines ?? []; + } + + let blockStartIndex = targetIndex; + + for (let index = targetIndex; index >= 0; index -= 1) { + const { line } = recentLogLines[index]; + + if (line === CRASH_BLOCK_HEADER) { + blockStartIndex = index; + break; + } + } + + let blockEndIndex = recentLogLines.length; + + for (let index = targetIndex + 1; index < recentLogLines.length; index += 1) { + if (recentLogLines[index].line === CRASH_BLOCK_HEADER) { + blockEndIndex = index; + break; + } + } + + return recentLogLines + .slice(blockStartIndex, blockEndIndex) + .map(({ line }) => line); +}; + +const hydrateCrashArtifact = ({ + artifact, + recentLogLines, +}: { + artifact: AndroidCrashArtifact; + recentLogLines: TimedLogLine[]; +}): AppCrashDetails => { + const rawLines = getCrashBlockForArtifact({ artifact, recentLogLines }); + + if (rawLines.length === 0) { + return artifact; + } + + const parsedDetails = androidCrashParser.parse({ + contents: rawLines.join('\n'), + bundleId: artifact.processName ?? '', + pid: artifact.pid, + }); + + return { + ...artifact, + ...parsedDetails, + artifactType: artifact.artifactType, + artifactPath: artifact.artifactPath, + rawLines, + }; +}; + +const createCrashArtifact = ({ + details, + recentLogLines, +}: { + details: AppCrashDetails; + recentLogLines: TimedLogLine[]; +}): AndroidCrashArtifact => { + const occurredAt = Date.now(); + const rawLines = getLatestCrashBlock(recentLogLines); + const triggerOccurredAt = [...recentLogLines] + .reverse() + .find(({ line }) => line === details.summary)?.occurredAt; + const contents = + rawLines.length > 0 + ? rawLines.join('\n') + : (details.rawLines ?? []).join('\n'); + const parsedDetails = + details.processName !== undefined + ? androidCrashParser.parse({ + contents, + bundleId: details.processName, + pid: details.pid, + }) + : details; + + return { + ...parsedDetails, + occurredAt, + triggerLine: details.summary ?? '', + triggerOccurredAt, + artifactType: 'logcat', + rawLines: + rawLines.length > 0 ? rawLines : parsedDetails.rawLines ?? details.rawLines, + }; +}; + +const persistCrashArtifact = ({ + details, + crashArtifactWriter, +}: { + details: AppCrashDetails; + crashArtifactWriter?: CrashArtifactWriter; +}): AppCrashDetails => { + if (!crashArtifactWriter || details.artifactType !== 'logcat') { + return details; + } + + const artifactBody = details.rawLines?.join('\n'); + + if (!artifactBody) { + return details; + } + + return { + ...details, + artifactPath: crashArtifactWriter.persistArtifact({ + artifactKind: details.artifactType, + source: { + kind: 'text', + fileName: 'logcat.txt', + text: `${artifactBody}\n`, + }, + }), + }; +}; + +const getLatestCrashArtifact = ({ + crashArtifacts, + recentLogLines, + processName, + pid, + occurredAt, +}: CrashDetailsLookupOptions & { + crashArtifacts: AndroidCrashArtifact[]; + recentLogLines: TimedLogLine[]; +}): AppCrashDetails | null => { + const matchingByPid = pid + ? crashArtifacts.filter((artifact) => artifact.pid === pid) + : []; + const matchingByProcess = processName + ? crashArtifacts.filter((artifact) => artifact.processName === processName) + : []; + const candidates = + matchingByPid.length > 0 + ? matchingByPid + : matchingByProcess.length > 0 + ? matchingByProcess + : crashArtifacts; + const sortedCandidates = [...candidates].sort( + (left, right) => + Math.abs(left.occurredAt - occurredAt) - Math.abs(right.occurredAt - occurredAt) + ); + + const artifact = sortedCandidates[0]; + + if (!artifact) { + return null; + } + + return hydrateCrashArtifact({ + artifact, + recentLogLines, + }); +}; + +const createAndroidLogEvent = ( + line: string, + bundleId: string +): AppMonitorEvent | null => { + const startMatch = line.match(startProcPattern(bundleId)); + + if (startMatch) { + return { + type: 'app_started', + pid: Number(startMatch[1]), + source: 'logs', + line, + }; + } + + const processMatch = line.match(processPattern(bundleId)); + + if (processMatch) { + return { + type: 'possible_crash', + pid: Number(processMatch[1]), + source: 'logs', + line, + crashDetails: getAndroidLogLineCrashDetails({ + line, + bundleId, + pid: Number(processMatch[1]), + }), + }; + } + + if (nativeCrashPattern(bundleId).test(line)) { + return { + type: 'possible_crash', + source: 'logs', + line, + crashDetails: getAndroidLogLineCrashDetails({ + line, + bundleId, + }), + }; + } + + const diedMatch = line.match(processDiedPattern(bundleId)); + + if (diedMatch) { + return { + type: 'app_exited', + pid: Number(diedMatch[1]), + source: 'logs', + line, + crashDetails: getAndroidLogLineCrashDetails({ + line, + bundleId, + pid: Number(diedMatch[1]), + }), + }; + } + + if ( + line.includes(bundleId) && + /fatal|crash|signal 11|signal 6|backtrace/i.test(line) + ) { + return { + type: 'possible_crash', + source: 'logs', + line, + crashDetails: getAndroidLogLineCrashDetails({ + line, + bundleId, + }), + }; + } + + return null; +}; + +export const createAndroidAppMonitor = ({ + adbId, + bundleId, + appUid, + crashArtifactWriter, +}: { + adbId: string; + bundleId: string; + appUid: number; + crashArtifactWriter?: CrashArtifactWriter; +}): AndroidAppMonitor => { + const emitter = getEmitter(); + + let isStarted = false; + let logcatProcess: Subprocess | null = null; + let logTask: Promise | null = null; + let recentLogLines: TimedLogLine[] = []; + let recentCrashArtifacts: AndroidCrashArtifact[] = []; + + const emit = (event: AppMonitorEvent) => { + emitter.emit(event); + }; + + const recordLogLine = (line: string) => { + recentLogLines = [...recentLogLines, { line, occurredAt: Date.now() }].slice( + -MAX_RECENT_LOG_LINES + ); + }; + + const recordCrashArtifact = (details?: AppCrashDetails) => { + if (!details) { + return; + } + + recentCrashArtifacts = [ + ...recentCrashArtifacts, + createCrashArtifact({ + details, + recentLogLines, + }), + ].slice(-MAX_RECENT_CRASH_ARTIFACTS); + }; + + const stopProcess = async (child: Subprocess | null) => { + if (!child) { + return; + } + + try { + (await child.nodeChildProcess).kill(); + } catch { + // Ignore termination failures for background monitors. + } + }; + + const startLogcat = async () => { + const logcatTimestamp = await adb.getLogcatTimestamp(adbId); + + logcatProcess = spawn('adb', ['-s', adbId, ...getLogcatArgs(appUid, logcatTimestamp)], { + stdout: 'pipe', + stderr: 'pipe', + }); + + const currentProcess = logcatProcess; + + if (!currentProcess) { + return; + } + + logTask = (async () => { + try { + for await (const line of currentProcess) { + recordLogLine(line); + emit({ type: 'log', source: 'logs', line }); + + const event = createAndroidLogEvent(line, bundleId); + + if (event) { + if (event.type === 'possible_crash' || event.type === 'app_exited') { + recordCrashArtifact(event.crashDetails); + } + emit(event); + } + } + } catch (error) { + if (!(error instanceof SubprocessError && error.signalName === 'SIGTERM')) { + logger.debug('Android logcat monitor stopped', error); + } + } + })(); + }; + + const start = async () => { + if (isStarted) { + return; + } + + try { + await startLogcat(); + isStarted = true; + } catch (error) { + const currentProcess = logcatProcess; + const currentTask = logTask; + + logcatProcess = null; + logTask = null; + + await stopProcess(currentProcess); + await currentTask; + + throw error; + } + }; + + const stop = async () => { + if (!isStarted) { + return; + } + + isStarted = false; + + const currentProcess = logcatProcess; + const currentTask = logTask; + + logcatProcess = null; + logTask = null; + + await stopProcess(currentProcess); + await currentTask; + }; + + const dispose = async () => { + await stop(); + emitter.clearAllListeners(); + recentLogLines = []; + recentCrashArtifacts = []; + }; + + const addListener = (listener: AppMonitorListener) => { + emitter.addListener(listener); + }; + + const removeListener = (listener: AppMonitorListener) => { + emitter.removeListener(listener); + }; + + return { + start, + stop, + dispose, + addListener, + removeListener, + getCrashDetails: async (options: CrashDetailsLookupOptions) => { + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) + ); + + const details = getLatestCrashArtifact({ + crashArtifacts: recentCrashArtifacts, + recentLogLines, + ...options, + }); + + if (!details) { + return null; + } + + return persistCrashArtifact({ + details, + crashArtifactWriter, + }); + }, + } satisfies AndroidAppMonitor; +}; + +export { createAndroidLogEvent }; +export type AndroidAppMonitor = AppMonitor & { + getCrashDetails: ( + options: CrashDetailsLookupOptions + ) => Promise; +}; diff --git a/packages/platform-android/src/config.ts b/packages/platform-android/src/config.ts index 4a88c99c..71f8b5fa 100644 --- a/packages/platform-android/src/config.ts +++ b/packages/platform-android/src/config.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; +export const AndroidAppLaunchOptionsSchema = z.object({ + extras: z + .record(z.union([z.string(), z.boolean(), z.number().int().safe()])) + .optional(), +}); + export const AndroidEmulatorAVDConfigSchema = z.object({ apiLevel: z.number().min(1, 'API level is required'), profile: z.string().min(1, 'Profile is required'), @@ -32,12 +38,16 @@ export const AndroidPlatformConfigSchema = z.object({ .string() .min(1, 'Activity name is required') .default('.MainActivity'), + appLaunchOptions: AndroidAppLaunchOptionsSchema.optional(), }); export type AndroidEmulator = z.infer; export type PhysicalAndroidDevice = z.infer; export type AndroidDevice = z.infer; export type AndroidPlatformConfig = z.infer; +export type AndroidAppLaunchOptions = z.infer< + typeof AndroidAppLaunchOptionsSchema +>; export type AndroidEmulatorAVDConfig = z.infer< typeof AndroidEmulatorAVDConfigSchema >; diff --git a/packages/platform-android/src/crash-parser.ts b/packages/platform-android/src/crash-parser.ts new file mode 100644 index 00000000..d37988e3 --- /dev/null +++ b/packages/platform-android/src/crash-parser.ts @@ -0,0 +1,66 @@ +import type { AppCrashDetails } from '@react-native-harness/platforms'; +import { escapeRegExp } from '@react-native-harness/tools'; + +type ParseAndroidCrashReportOptions = { + contents: string; + bundleId: string; + pid?: number; +}; + +const getSignal = (contents: string) => { + const namedSignalMatch = contents.match(/\b(SIG[A-Z0-9]+)\b/); + + if (namedSignalMatch) { + return namedSignalMatch[1]; + } + + const signalNumberMatch = contents.match(/signal\s+(\d+)/i); + + if (signalNumberMatch) { + return `signal ${signalNumberMatch[1]}`; + } + + return undefined; +}; + +const getStackTrace = (rawLines: string[]) => { + const frames = rawLines.filter((line) => + /^\S.*(?:\s+at\s+|\s+#\d+\s+pc\s+)/.test(line.trim()) || + /^\S.*AndroidRuntime:\s+at\s+/.test(line.trim()) || + /^\S.*AndroidRuntime:\s+Caused by:/.test(line.trim()) + ); + + return frames.length > 0 ? frames : undefined; +}; + +export const androidCrashParser = { + parse({ + contents, + bundleId, + pid, + }: ParseAndroidCrashReportOptions): AppCrashDetails { + const rawLines = contents.split(/\r?\n/); + const processPattern = new RegExp( + `Process:\\s*${escapeRegExp(bundleId)},\\s*PID:\\s*(\\d+)` + ); + const fatalExceptionMatch = contents.match(/FATAL EXCEPTION:\s*(.+)$/im); + const processMatch = contents.match(processPattern); + const runtimeExceptionLine = rawLines.find((line) => + /AndroidRuntime: (?:java\.|kotlin\.|[\w$.]+(?:Exception|Error):)/.test(line) + ); + const exceptionType = + fatalExceptionMatch?.[1]?.trim() ?? + runtimeExceptionLine?.match(/AndroidRuntime:\s+(.+)$/)?.[1]?.trim(); + + return { + source: 'logs', + summary: contents.trim(), + signal: getSignal(contents), + exceptionType, + processName: processMatch ? bundleId : contents.includes(bundleId) ? bundleId : undefined, + pid: pid ?? (processMatch ? Number(processMatch[1]) : undefined), + rawLines, + stackTrace: getStackTrace(rawLines), + }; + }, +}; diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index cc866c3b..cafc1e4e 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -1,6 +1,7 @@ import { DeviceNotFoundError, AppNotInstalledError, + CreateAppMonitorOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { Config } from '@react-native-harness/config'; @@ -11,6 +12,7 @@ import { import { getAdbId } from './adb-id.js'; import * as adb from './adb.js'; import { getDeviceName } from './utils.js'; +import { createAndroidAppMonitor } from './app-monitor.js'; const getAndroidRunner = async ( config: AndroidPlatformConfig, @@ -36,22 +38,28 @@ const getAndroidRunner = async ( adb.reversePort(adbId, 8081), adb.reversePort(adbId, 8080), adb.reversePort(adbId, harnessConfig.webSocketPort), + adb.setHideErrorDialogs(adbId, true), ]); + const appUid = await adb.getAppUid(adbId, parsedConfig.bundleId); return { - startApp: async () => { + startApp: async (options) => { await adb.startApp( adbId, parsedConfig.bundleId, - parsedConfig.activityName + parsedConfig.activityName, + (options as typeof parsedConfig.appLaunchOptions | undefined) ?? + parsedConfig.appLaunchOptions ); }, - restartApp: async () => { + restartApp: async (options) => { await adb.stopApp(adbId, parsedConfig.bundleId); await adb.startApp( adbId, parsedConfig.bundleId, - parsedConfig.activityName + parsedConfig.activityName, + (options as typeof parsedConfig.appLaunchOptions | undefined) ?? + parsedConfig.appLaunchOptions ); }, stopApp: async () => { @@ -59,10 +67,18 @@ const getAndroidRunner = async ( }, dispose: async () => { await adb.stopApp(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, + }), }; }; diff --git a/packages/platform-android/tsconfig.tsbuildinfo b/packages/platform-android/tsconfig.tsbuildinfo new file mode 100644 index 00000000..7cf2f681 --- /dev/null +++ b/packages/platform-android/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUnusedLocals":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.9.3"} \ No newline at end of file diff --git a/packages/platform-ios/README.md b/packages/platform-ios/README.md index 12d60950..bc4fb32e 100644 --- a/packages/platform-ios/README.md +++ b/packages/platform-ios/README.md @@ -79,9 +79,12 @@ Creates a physical Apple device configuration. ## Requirements - macOS with Xcode installed +- `libimobiledevice` installed and available in `PATH` (`idevicesyslog`, `idevicecrashreport`, and `idevice_id`) for physical-device crash diagnostics - iOS Simulator or physical device connected - React Native project configured for iOS +Harness uses `simctl` for simulator crash monitoring and `libimobiledevice` for physical-device crash diagnostics. Simulator crash details are best-effort from recent log blocks, while physical devices can additionally attach pulled `.crash` artifacts when available. + ## Made with ❤️ at Callstack `react-native-harness` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! diff --git a/packages/platform-ios/package.json b/packages/platform-ios/package.json index 96d59d42..94b868a3 100644 --- a/packages/platform-ios/package.json +++ b/packages/platform-ios/package.json @@ -18,6 +18,7 @@ "dependencies": { "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", + "@react-native-harness/config": "workspace:*", "zod": "^3.25.67", "tslib": "^2.3.0" }, diff --git a/packages/platform-ios/src/__tests__/app-monitor.test.ts b/packages/platform-ios/src/__tests__/app-monitor.test.ts new file mode 100644 index 00000000..4b29f004 --- /dev/null +++ b/packages/platform-ios/src/__tests__/app-monitor.test.ts @@ -0,0 +1,555 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + createIosDeviceAppMonitor, + createIosSimulatorAppMonitor, + createUnifiedLogEvent, +} from '../app-monitor.js'; +import * as simctl from '../xcrun/simctl.js'; +import * as devicectl from '../xcrun/devicectl.js'; +import * as libimobiledevice from '../libimobiledevice.js'; +import * as tools from '@react-native-harness/tools'; +import { createCrashArtifactWriter } from '@react-native-harness/tools'; +import type { Subprocess } from '@react-native-harness/tools'; + +const createStreamingSubprocess = ( + chunks: Array<{ line: string; delayMs?: number }> +): Subprocess => + ({ + nodeChildProcess: Promise.resolve({ + kill: vi.fn(), + }), + [Symbol.asyncIterator]: async function* () { + for (const { line, delayMs = 0 } of chunks) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + yield line; + } + }, + }) as unknown as Subprocess; + +const artifactRoot = fs.mkdtempSync( + join(tmpdir(), 'rn-harness-ios-monitor-artifacts-') +); + +describe('createUnifiedLogEvent', () => { + it('extracts crash details from simulator log lines', () => { + const event = createUnifiedLogEvent({ + line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', + processNames: ['HarnessPlayground', 'com.harnessplayground'], + }); + + expect(event).toMatchObject({ + type: 'possible_crash', + source: 'logs', + isConfirmed: true, + crashDetails: { + source: 'logs', + processName: 'HarnessPlayground', + pid: 1234, + exceptionType: 'NSInternalInconsistencyException', + }, + }); + }); + + it('detects Swift fatal errors from idevicesyslog with library-qualified process name', () => { + const event = createUnifiedLogEvent({ + line: 'Mar 13 12:27:13.724837 HarnessPlayground(libswiftCore.dylib)[21675] : HarnessPlayground/AppDelegate.swift:31: Fatal error: Intentional pre-RN startup crash', + processNames: ['HarnessPlayground', 'com.harnessplayground'], + }); + + expect(event).toMatchObject({ + type: 'possible_crash', + source: 'logs', + isConfirmed: true, + crashDetails: { + source: 'logs', + processName: 'HarnessPlayground', + pid: 21675, + }, + }); + }); + + it('detects Swift fatal errors from simulator logs', () => { + const event = createUnifiedLogEvent({ + line: '2026-03-13 10:29:13.868 Df HarnessPlayground[34784:8f92b3] (libswiftCore.dylib) HarnessPlayground/AppDelegate.swift:31: Fatal error: Intentional pre-RN startup crash', + processNames: ['HarnessPlayground', 'com.harnessplayground'], + }); + + expect(event).toMatchObject({ + type: 'possible_crash', + source: 'logs', + isConfirmed: true, + crashDetails: { + source: 'logs', + processName: 'HarnessPlayground', + pid: 34784, + }, + }); + }); + + it('ignores unrelated lines that only mention the bundle identifier', () => { + const event = createUnifiedLogEvent({ + line: '2026-03-12 11:35:08.000 runningboardd[55:aaaa] Acquiring assertion for com.harnessplayground', + processNames: ['HarnessPlayground', 'com.harnessplayground'], + }); + + expect(event).toBeNull(); + }); +}); + +afterEach(() => { + fs.rmSync(artifactRoot, { recursive: true, force: true }); + fs.mkdirSync(artifactRoot, { recursive: true }); +}); + +describe('createIosSimulatorAppMonitor', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('starts simctl log stream', async () => { + const spawnSpy = vi.spyOn(tools, 'spawn').mockReturnValue( + createStreamingSubprocess([]) + ); + + vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ + Bundle: 'com.harnessplayground', + CFBundleIdentifier: 'com.harnessplayground', + CFBundleExecutable: 'HarnessPlayground', + CFBundleName: 'HarnessPlayground', + CFBundleDisplayName: 'Harness Playground', + Path: '/tmp/HarnessPlayground.app', + }); + + const monitor = createIosSimulatorAppMonitor({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + }); + + await monitor.start(); + await monitor.stop(); + + expect(spawnSpy).toHaveBeenCalledWith( + 'xcrun', + [ + 'simctl', + 'spawn', + 'sim-udid', + 'log', + 'stream', + '--style', + 'compact', + '--level', + 'info', + '--predicate', + 'process == "HarnessPlayground" OR process == "com.harnessplayground"', + ], + { + stdout: 'pipe', + stderr: 'pipe', + } + ); + }); + + it('returns best-effort simulator crash details from recent log blocks', async () => { + vi.spyOn(tools, 'spawn').mockReturnValue( + createStreamingSubprocess([ + { + line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', + }, + { + line: '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] *** First throw call stack:', + delayMs: 10, + }, + ]) + ); + vi.spyOn(simctl, 'collectCrashReports').mockResolvedValue([]); + vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ + Bundle: 'com.harnessplayground', + CFBundleIdentifier: 'com.harnessplayground', + CFBundleExecutable: 'HarnessPlayground', + CFBundleName: 'HarnessPlayground', + CFBundleDisplayName: 'Harness Playground', + Path: '/tmp/HarnessPlayground.app', + }); + + const monitor = createIosSimulatorAppMonitor({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const details = await monitor.getCrashDetails({ + pid: 1234, + occurredAt: Date.now(), + }); + + await monitor.stop(); + + expect(details).toMatchObject({ + processName: 'HarnessPlayground', + pid: 1234, + exceptionType: 'NSInternalInconsistencyException', + }); + expect(details?.artifactType).toBeUndefined(); + expect(details?.artifactPath).toBeUndefined(); + expect(details?.rawLines).toEqual([ + '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', + '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] *** First throw call stack:', + ]); + }); + + it('prefers a matched simulator crash report when one is found', async () => { + vi.spyOn(tools, 'spawn').mockReturnValue( + createStreamingSubprocess([ + { + line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', + }, + ]) + ); + const sourcePath = join(artifactRoot, 'HarnessPlayground-2026-03-12-122756.ips'); + fs.writeFileSync(sourcePath, 'simulator crash report', 'utf8'); + vi.spyOn(simctl, 'collectCrashReports').mockImplementation( + async ({ crashArtifactWriter }) => [ + { + artifactType: 'ios-crash-report', + artifactPath: + crashArtifactWriter?.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path: sourcePath, + }, + }) ?? sourcePath, + occurredAt: Date.now(), + processName: 'HarnessPlayground', + pid: 1234, + signal: 'SIGTRAP', + exceptionType: 'EXC_BREAKPOINT', + summary: 'simulator crash report', + rawLines: ['simulator crash report'], + }, + ] + ); + vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ + Bundle: 'com.harnessplayground', + CFBundleIdentifier: 'com.harnessplayground', + CFBundleExecutable: 'HarnessPlayground', + CFBundleName: 'HarnessPlayground', + CFBundleDisplayName: 'Harness Playground', + Path: '/tmp/HarnessPlayground.app', + }); + + const monitor = createIosSimulatorAppMonitor({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + crashArtifactWriter: createCrashArtifactWriter({ + runnerName: 'ios-simulator', + platformId: 'ios', + rootDir: join(artifactRoot, '.harness', 'crash-reports'), + runTimestamp: '2026-03-12T11-35-08-000Z', + }), + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const details = await monitor.getCrashDetails({ + pid: 1234, + occurredAt: Date.now(), + }); + + await monitor.stop(); + + expect(details).toMatchObject({ + artifactType: 'ios-crash-report', + summary: 'simulator crash report', + }); + expect(details?.artifactPath).toContain('/.harness/crash-reports/'); + expect(fs.existsSync(details!.artifactPath!)).toBe(true); + }); + + it('waits for a simulator crash report to appear before falling back to logs', async () => { + vi.spyOn(tools, 'spawn').mockReturnValue( + createStreamingSubprocess([ + { + line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', + }, + ]) + ); + vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ + Bundle: 'com.harnessplayground', + CFBundleIdentifier: 'com.harnessplayground', + CFBundleExecutable: 'HarnessPlayground', + CFBundleName: 'HarnessPlayground', + CFBundleDisplayName: 'Harness Playground', + Path: '/tmp/HarnessPlayground.app', + }); + + let calls = 0; + vi.spyOn(simctl, 'collectCrashReports').mockImplementation(async () => { + calls += 1; + + if (calls === 1) { + return []; + } + + return [ + { + artifactType: 'ios-crash-report', + artifactPath: '/tmp/HarnessPlayground.ips', + occurredAt: Date.now(), + processName: 'HarnessPlayground', + pid: 1234, + signal: 'SIGTRAP', + exceptionType: 'EXC_BREAKPOINT', + stackTrace: ['0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)'], + rawLines: ['simulator crash report'], + }, + ]; + }); + + const monitor = createIosSimulatorAppMonitor({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const details = await monitor.getCrashDetails({ + pid: 1234, + occurredAt: Date.now(), + }); + + await monitor.stop(); + + expect(calls).toBe(2); + expect(details).toMatchObject({ + artifactType: 'ios-crash-report', + stackTrace: ['0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)'], + }); + }); + + it('does not emit generic simulator log noise', async () => { + vi.spyOn(tools, 'spawn').mockReturnValue( + createStreamingSubprocess([ + { + line: '2026-03-12 11:35:08.000 runningboardd[55:aaaa] Acquiring assertion for com.harnessplayground', + }, + { + line: '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] app-specific log line', + delayMs: 10, + }, + ]) + ); + vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ + Bundle: 'com.harnessplayground', + CFBundleIdentifier: 'com.harnessplayground', + CFBundleExecutable: 'HarnessPlayground', + CFBundleName: 'HarnessPlayground', + CFBundleDisplayName: 'Harness Playground', + Path: '/tmp/HarnessPlayground.app', + }); + + const lines: string[] = []; + const monitor = createIosSimulatorAppMonitor({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + }); + monitor.addListener((event) => { + if (event.type === 'log') { + lines.push(event.line); + } + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 25)); + await monitor.stop(); + + expect(lines).toEqual([ + '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] app-specific log line', + ]); + }); + + it('cleans up the background simctl process on stop', async () => { + const kill = vi.fn(); + vi.spyOn(tools, 'spawn').mockReturnValue({ + nodeChildProcess: Promise.resolve({ kill }), + [Symbol.asyncIterator]: async function* () {}, + } as unknown as Subprocess); + vi.spyOn(simctl, 'getAppInfo').mockResolvedValue(null); + + const monitor = createIosSimulatorAppMonitor({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + }); + + await monitor.start(); + await monitor.stop(); + + expect(kill).toHaveBeenCalled(); + }); +}); + +describe('createIosDeviceAppMonitor', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('uses libimobiledevice for physical device log streaming', async () => { + const syslogSpy = vi + .spyOn(libimobiledevice, 'createSyslogProcess') + .mockReturnValue(createStreamingSubprocess([])); + const targetSpy = vi + .spyOn(libimobiledevice, 'assertLibimobiledeviceTargetAvailable') + .mockResolvedValue(undefined); + vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ + bundleIdentifier: 'com.harnessplayground', + name: 'HarnessPlayground', + version: '1.0', + url: '/private/var/HarnessPlayground.app', + }); + + const monitor = createIosDeviceAppMonitor({ + deviceId: 'device-udid', + libimobiledeviceUdid: 'hardware-udid', + bundleId: 'com.harnessplayground', + }); + + await monitor.start(); + await monitor.stop(); + + expect(targetSpy).toHaveBeenCalledWith('hardware-udid'); + expect(syslogSpy).toHaveBeenCalledWith({ + targetId: 'hardware-udid', + processNames: ['com.harnessplayground', 'HarnessPlayground'], + }); + }); + + it('detects idevicesyslog crash lines with library-qualified process names', async () => { + vi.spyOn(libimobiledevice, 'assertLibimobiledeviceTargetAvailable').mockResolvedValue( + undefined + ); + vi.spyOn(libimobiledevice, 'createSyslogProcess').mockReturnValue( + createStreamingSubprocess([ + { + line: 'Mar 13 12:27:13.724837 HarnessPlayground(libswiftCore.dylib)[21675] : HarnessPlayground/AppDelegate.swift:31: Fatal error: Intentional pre-RN startup crash', + }, + ]) + ); + vi.spyOn(libimobiledevice, 'collectCrashReports').mockResolvedValue([]); + vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ + bundleIdentifier: 'com.harnessplayground', + name: 'HarnessPlayground', + version: '1.0', + url: '/private/var/HarnessPlayground.app', + }); + + const events: Array<{ type: string }> = []; + const monitor = createIosDeviceAppMonitor({ + deviceId: 'device-udid', + libimobiledeviceUdid: 'hardware-udid', + bundleId: 'com.harnessplayground', + }); + monitor.addListener((event) => { + events.push(event); + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const details = await monitor.getCrashDetails({ + pid: 21675, + occurredAt: Date.now(), + }); + + await monitor.stop(); + + expect(events.some((event) => event.type === 'possible_crash')).toBe(true); + expect(details).toMatchObject({ + source: 'logs', + processName: 'HarnessPlayground', + pid: 21675, + }); + }); + + it('still enriches device crashes with pulled crash reports', async () => { + vi.spyOn(libimobiledevice, 'assertLibimobiledeviceTargetAvailable').mockResolvedValue( + undefined + ); + vi.spyOn(libimobiledevice, 'createSyslogProcess').mockReturnValue( + createStreamingSubprocess([ + { + line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', + }, + ]) + ); + const sourcePath = join(artifactRoot, 'HarnessPlayground.crash'); + fs.writeFileSync(sourcePath, 'full crash report', 'utf8'); + vi.spyOn(libimobiledevice, 'collectCrashReports').mockImplementation( + async ({ crashArtifactWriter }) => [ + { + artifactType: 'ios-crash-report', + artifactPath: + crashArtifactWriter?.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path: sourcePath, + }, + }) ?? sourcePath, + occurredAt: Date.now(), + processName: 'HarnessPlayground', + pid: 1234, + signal: 'SIGABRT', + exceptionType: 'NSInternalInconsistencyException', + summary: 'full crash report', + rawLines: ['full crash report'], + }, + ] + ); + vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ + bundleIdentifier: 'com.harnessplayground', + name: 'HarnessPlayground', + version: '1.0', + url: '/private/var/HarnessPlayground.app', + }); + + const monitor = createIosDeviceAppMonitor({ + deviceId: 'device-udid', + libimobiledeviceUdid: 'hardware-udid', + bundleId: 'com.harnessplayground', + crashArtifactWriter: createCrashArtifactWriter({ + runnerName: 'ios-device', + platformId: 'ios', + rootDir: join(artifactRoot, '.harness', 'crash-reports'), + runTimestamp: '2026-03-12T11-35-08-000Z', + }), + }); + + await monitor.start(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const details = await monitor.getCrashDetails({ + pid: 1234, + occurredAt: Date.now(), + }); + + await monitor.stop(); + + expect(details).toMatchObject({ + artifactType: 'ios-crash-report', + summary: 'full crash report', + }); + expect(details?.artifactPath).toContain('/.harness/crash-reports/'); + expect(fs.existsSync(details!.artifactPath!)).toBe(true); + }); +}); diff --git a/packages/platform-ios/src/__tests__/crash-parser.test.ts b/packages/platform-ios/src/__tests__/crash-parser.test.ts new file mode 100644 index 00000000..bbadefdd --- /dev/null +++ b/packages/platform-ios/src/__tests__/crash-parser.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import { iosCrashParser } from '../crash-parser.js'; +import fs from 'node:fs'; + +describe('iosCrashParser.parse', () => { + it('parses .crash report contents into a crash details object', () => { + const statSpy = vi.spyOn(fs, 'statSync').mockReturnValue({ + mtimeMs: 123456, + } as fs.Stats); + + expect( + iosCrashParser.parse({ + path: '/tmp/HarnessPlayground.crash', + contents: [ + 'Process: HarnessPlayground [1234]', + 'Exception Type: EXC_CRASH (SIGABRT)', + 'Triggered by Thread: 0', + '', + 'Thread 0 Crashed:', + '0 HarnessPlayground 0x0000000100000000 AppDelegate.crashIfRequested() + 20', + '', + ].join('\n'), + }) + ).toEqual({ + occurredAt: 123456, + signal: 'SIGABRT', + exceptionType: 'EXC_CRASH (SIGABRT)', + processName: 'HarnessPlayground', + pid: 1234, + rawLines: expect.any(Array), + stackTrace: [ + '0 HarnessPlayground 0x0000000100000000 AppDelegate.crashIfRequested() + 20', + ], + }); + + statSpy.mockRestore(); + }); + + it('parses .ips report contents into a crash details object', () => { + const statSpy = vi.spyOn(fs, 'statSync').mockReturnValue({ + mtimeMs: 7890, + } as fs.Stats); + + expect( + iosCrashParser.parse({ + path: '/tmp/HarnessPlayground.ips', + contents: [ + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + name: 'HarnessPlayground', + }), + JSON.stringify({ + pid: 1234, + procName: 'HarnessPlayground', + faultingThread: 0, + threads: [ + { + frames: [ + { + symbol: 'AppDelegate.crashIfRequested()', + sourceFile: 'AppDelegate.swift', + sourceLine: 31, + imageIndex: 0, + }, + ], + }, + ], + usedImages: [{ name: 'HarnessPlayground' }], + exception: { + type: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + }, + }), + ].join('\n'), + }) + ).toMatchObject({ + occurredAt: 7890, + signal: 'SIGTRAP', + exceptionType: 'EXC_BREAKPOINT', + processName: 'HarnessPlayground', + pid: 1234, + }); + + statSpy.mockRestore(); + }); +}); diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts new file mode 100644 index 00000000..cba91aa9 --- /dev/null +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + getApplePhysicalDevicePlatformInstance, + getAppleSimulatorPlatformInstance, +} from '../instance.js'; +import * as simctl from '../xcrun/simctl.js'; +import * as devicectl from '../xcrun/devicectl.js'; +import * as libimobiledevice from '../libimobiledevice.js'; + +describe('iOS platform instance dependency validation', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('does not validate libimobiledevice before creating a simulator instance', async () => { + const assertInstalled = vi + .spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + + const config = { + name: 'ios', + device: { type: 'simulator' as const, name: 'iPhone 16 Pro', systemVersion: '18.0' }, + bundleId: 'com.harnessplayground', + }; + + await expect( + getAppleSimulatorPlatformInstance(config) + ).resolves.toBeDefined(); + expect(assertInstalled).not.toHaveBeenCalled(); + }); + + it('validates libimobiledevice before creating a physical device instance', async () => { + const assertInstalled = vi + .spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled') + .mockRejectedValue(new Error('missing')); + + const config = { + name: 'ios-device', + device: { type: 'physical' as const, name: 'My iPhone' }, + bundleId: 'com.harnessplayground', + }; + + await expect( + getApplePhysicalDevicePlatformInstance(config) + ).rejects.toThrow('missing'); + expect(assertInstalled).toHaveBeenCalled(); + }); + + 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(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + + const config = { + name: 'ios', + device: { type: 'simulator' as const, name: 'iPhone 16 Pro', systemVersion: '18.0' }, + bundleId: 'com.harnessplayground', + }; + + await expect( + getAppleSimulatorPlatformInstance(config) + ).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') + ); + const getDeviceId = vi.spyOn(devicectl, 'getDeviceId'); + + const config = { + name: 'ios-device', + device: { type: 'physical' as const, name: 'My iPhone' }, + bundleId: 'com.harnessplayground', + }; + + await expect( + getApplePhysicalDevicePlatformInstance(config) + ).rejects.toThrow('missing'); + expect(getDeviceId).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-ios/src/__tests__/launch-options.test.ts b/packages/platform-ios/src/__tests__/launch-options.test.ts new file mode 100644 index 00000000..955ab29b --- /dev/null +++ b/packages/platform-ios/src/__tests__/launch-options.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + getDeviceCtlLaunchArgs, +} from '../xcrun/devicectl.js'; +import { getSimctlChildEnvironment } from '../xcrun/simctl.js'; + +describe('Apple app launch options', () => { + it('maps simulator environment to SIMCTL_CHILD variables', () => { + expect( + getSimctlChildEnvironment({ + environment: { + FEATURE_X: '1', + HARNESS_MODE: 'startup', + }, + }) + ).toEqual({ + SIMCTL_CHILD_FEATURE_X: '1', + SIMCTL_CHILD_HARNESS_MODE: 'startup', + }); + }); + + it('maps device arguments and environment to devicectl launch args', () => { + expect( + getDeviceCtlLaunchArgs('device-id', 'com.example.app', { + arguments: ['--mode=test', '--retry=1'], + environment: { + FEATURE_X: '1', + }, + }) + ).toEqual([ + 'process', + 'launch', + '--device', + 'device-id', + '--environment-variables', + '{"FEATURE_X":"1"}', + 'com.example.app', + '--mode=test', + '--retry=1', + ]); + }); +}); diff --git a/packages/platform-ios/src/__tests__/libimobiledevice.test.ts b/packages/platform-ios/src/__tests__/libimobiledevice.test.ts new file mode 100644 index 00000000..0e4c2fce --- /dev/null +++ b/packages/platform-ios/src/__tests__/libimobiledevice.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import * as tools from '@react-native-harness/tools'; +import { createCrashArtifactWriter } from '@react-native-harness/tools'; +import { + assertLibimobiledeviceInstalled, + collectCrashReports, +} from '../libimobiledevice.js'; + +describe('assertLibimobiledeviceInstalled', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('passes when all required binaries are present', async () => { + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: '/opt/homebrew/bin/tool\n', + } as Awaited>); + + await expect(assertLibimobiledeviceInstalled()).resolves.toBeUndefined(); + }); + + it('throws when any required binary is missing', async () => { + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy + .mockResolvedValueOnce({ + stdout: '/opt/homebrew/bin/idevicesyslog\n', + } as Awaited>) + .mockRejectedValueOnce(new Error('missing')); + + await expect(assertLibimobiledeviceInstalled()).rejects.toMatchObject({ + name: 'DependencyNotFoundError', + dependencyName: 'libimobiledevice', + }); + }); +}); + +describe('collectCrashReports', () => { + const workDir = fs.mkdtempSync(join(tmpdir(), 'rn-harness-ios-crash-tests-')); + const artifactRoot = fs.mkdtempSync(join(tmpdir(), 'rn-harness-ios-crash-artifacts-')); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(workDir, { recursive: true, force: true }); + fs.mkdirSync(workDir, { recursive: true }); + fs.rmSync(artifactRoot, { recursive: true, force: true }); + fs.mkdirSync(artifactRoot, { recursive: true }); + }); + + it('extracts matching crash reports with artifact metadata', async () => { + vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); + const spawnSpy = vi.spyOn(tools, 'spawn').mockImplementation( + (async (file: string, args?: readonly string[]) => { + if (file === 'idevicecrashreport') { + const targetDir = args?.[args.length - 1]; + + if (!targetDir) { + throw new Error('missing target dir'); + } + + fs.writeFileSync( + join(targetDir, 'HarnessPlayground-2026-03-12-113508.crash'), + [ + 'Process: HarnessPlayground [1234]', + 'Identifier: com.harnessplayground', + 'Exception Type: EXC_CRASH (SIGABRT)', + 'Triggered by Thread: 0', + '', + 'Thread 0 Crashed:', + '0 HarnessPlayground 0x0000000100000000 AppDelegate.crashIfRequested() + 20', + '1 HarnessPlayground 0x0000000100000014 AppDelegate.application(_:didFinishLaunchingWithOptions:) + 40', + '', + ].join('\n') + ); + } + + return { + stdout: '', + } as Awaited>; + }) as typeof tools.spawn + ); + + const reports = await collectCrashReports({ + targetId: 'device-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + }); + + expect(spawnSpy).toHaveBeenCalledWith('idevicecrashreport', [ + '-u', + 'device-udid', + '--keep', + '--extract', + '--filter', + 'HarnessPlayground', + expect.any(String), + ]); + expect(reports).toHaveLength(1); + expect(reports[0]).toMatchObject({ + artifactType: 'ios-crash-report', + processName: 'HarnessPlayground', + pid: 1234, + signal: 'SIGABRT', + exceptionType: 'EXC_CRASH (SIGABRT)', + stackTrace: [ + '0 HarnessPlayground 0x0000000100000000 AppDelegate.crashIfRequested() + 20', + '1 HarnessPlayground 0x0000000100000014 AppDelegate.application(_:didFinishLaunchingWithOptions:) + 40', + ], + }); + }); + + it('filters by executable name rather than bundle id', async () => { + vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); + const spawnSpy = vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: '', + } as Awaited>); + + await collectCrashReports({ + targetId: 'device-udid', + bundleId: 'com.harnessplayground', + processNames: ['com.harnessplayground', 'HarnessPlayground'], + }); + + expect(spawnSpy).toHaveBeenCalledWith('idevicecrashreport', [ + '-u', + 'device-udid', + '--keep', + '--extract', + '--filter', + 'HarnessPlayground', + expect.any(String), + ]); + }); + + it('parses .ips crash reports from the device', async () => { + vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); + vi.spyOn(tools, 'spawn').mockImplementation( + (async (file: string, args?: readonly string[]) => { + if (file === 'idevicecrashreport') { + const targetDir = args?.[args.length - 1]; + + if (!targetDir) { + throw new Error('missing target dir'); + } + + const header = JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + }); + const body = JSON.stringify({ + pid: 21675, + procName: 'HarnessPlayground', + faultingThread: 0, + exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP' }, + threads: [ + { + frames: [ + { imageIndex: 0, symbol: 'AppDelegate.crashIfRequested()', symbolLocation: 20 }, + ], + }, + ], + usedImages: [{ name: 'HarnessPlayground' }], + }); + + fs.writeFileSync( + join(targetDir, 'HarnessPlayground-2026-03-12-113508.ips'), + `${header}\n${body}` + ); + } + + return { + stdout: '', + } as Awaited>; + }) as typeof tools.spawn + ); + + const reports = await collectCrashReports({ + targetId: 'device-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + }); + + expect(reports).toHaveLength(1); + expect(reports[0]).toMatchObject({ + artifactType: 'ios-crash-report', + processName: 'HarnessPlayground', + pid: 21675, + signal: 'SIGTRAP', + exceptionType: 'EXC_BREAKPOINT', + stackTrace: ['0 AppDelegate.crashIfRequested() (+ 20)'], + }); + }); + + it('persists pulled crash reports before temporary cleanup', async () => { + vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); + vi.spyOn(tools, 'spawn').mockImplementation( + (async (file: string, args?: readonly string[]) => { + if (file === 'idevicecrashreport') { + const targetDir = args?.[args.length - 1]; + + if (!targetDir) { + throw new Error('missing target dir'); + } + + fs.writeFileSync( + join(targetDir, 'HarnessPlayground-2026-03-12-113508.crash'), + [ + 'Process: HarnessPlayground [1234]', + 'Identifier: com.harnessplayground', + 'Exception Type: EXC_CRASH (SIGABRT)', + ].join('\n') + ); + } + + return { + stdout: '', + } as Awaited>; + }) as typeof tools.spawn + ); + const crashReportDir = join(artifactRoot, '.harness', 'crash-reports'); + const writer = createCrashArtifactWriter({ + runnerName: 'ios-device', + platformId: 'ios', + rootDir: crashReportDir, + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const reports = await collectCrashReports({ + targetId: 'device-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + crashArtifactWriter: writer, + }); + + expect(reports[0]?.artifactPath).toContain('/.harness/crash-reports/'); + expect(fs.existsSync(reports[0]!.artifactPath)).toBe(true); + expect(fs.existsSync(workDir)).toBe(false); + }); + + it('returns an empty list when no matching crash reports are found', async () => { + vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: '', + } as Awaited>); + + const reports = await collectCrashReports({ + targetId: 'device-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + }); + + expect(reports).toEqual([]); + }); + + it('ignores crash reports older than the current run window', async () => { + vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); + vi.spyOn(tools, 'spawn').mockImplementation( + (async (file: string, args?: readonly string[]) => { + if (file === 'idevicecrashreport') { + const targetDir = args?.[args.length - 1]; + + if (!targetDir) { + throw new Error('missing target dir'); + } + + fs.writeFileSync( + join(targetDir, 'old.crash'), + [ + 'Process: HarnessPlayground [1234]', + 'Identifier: com.harnessplayground', + 'Date/Time: 2026-03-12 11:30:08.000 +0000', + ].join('\n') + ); + fs.writeFileSync( + join(targetDir, 'new.crash'), + [ + 'Process: HarnessPlayground [1235]', + 'Identifier: com.harnessplayground', + 'Date/Time: 2026-03-12 11:40:08.000 +0000', + ].join('\n') + ); + } + + return { + stdout: '', + } as Awaited>; + }) as typeof tools.spawn + ); + + const reports = await collectCrashReports({ + targetId: 'device-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + minOccurredAt: Date.parse('2026-03-12T11:35:08.000Z'), + }); + + expect(reports).toHaveLength(1); + expect(reports[0]?.pid).toBe(1235); + }); +}); diff --git a/packages/platform-ios/src/__tests__/simctl.test.ts b/packages/platform-ios/src/__tests__/simctl.test.ts new file mode 100644 index 00000000..240e3609 --- /dev/null +++ b/packages/platform-ios/src/__tests__/simctl.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +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'; + +describe('simctl collectCrashReports', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('extracts matching simulator .ips crash reports', async () => { + const diagnosticReportsDir = join( + homedir(), + 'Library', + 'Logs', + 'DiagnosticReports' + ); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readdirSync').mockReturnValue([ + 'HarnessPlayground-2026-03-12-122756.ips', + 'OtherApp-2026-03-12-122756.ips', + ] as unknown as ReturnType); + vi.spyOn(fs, 'readFileSync').mockImplementation(((path: fs.PathOrFileDescriptor) => { + const filePath = String(path); + + if (filePath.includes('HarnessPlayground')) { + return [ + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + name: 'HarnessPlayground', + }), + JSON.stringify({ + pid: 1234, + procName: 'HarnessPlayground', + faultingThread: 0, + threads: [ + { + frames: [ + { + symbol: '_assertionFailure(_:_:file:line:flags:)', + symbolLocation: 156, + imageIndex: 1, + }, + { + symbol: 'AppDelegate.crashIfRequested()', + sourceFile: 'AppDelegate.swift', + sourceLine: 31, + imageIndex: 1, + }, + ], + }, + ], + usedImages: [{ name: 'dyld' }, { name: 'HarnessPlayground' }], + procPath: + `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + exception: { + type: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + }, + }), + ].join('\n'); + } + + return [ + JSON.stringify({ + app_name: 'OtherApp', + bundleID: 'com.other.app', + }), + JSON.stringify({ + procName: 'OtherApp', + procPath: + `${homedir()}/Library/Developer/CoreSimulator/Devices/other-udid/data/Containers/Bundle/Application/DEF/OtherApp.app/OtherApp`, + }), + ].join('\n'); + }) as typeof fs.readFileSync); + vi.spyOn(fs, 'statSync').mockReturnValue({ + mtimeMs: 123456, + } as fs.Stats); + + const reports = await collectCrashReports({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + }); + + expect(reports).toEqual([ + { + artifactType: 'ios-crash-report', + artifactPath: join( + diagnosticReportsDir, + 'HarnessPlayground-2026-03-12-122756.ips' + ), + occurredAt: 123456, + processName: 'HarnessPlayground', + pid: 1234, + signal: 'SIGTRAP', + exceptionType: 'EXC_BREAKPOINT', + stackTrace: [ + '0 _assertionFailure(_:_:file:line:flags:) (+ 156)', + '1 AppDelegate.crashIfRequested() (AppDelegate.swift:31)', + ], + rawLines: expect.any(Array), + }, + ]); + }); + + it('copies matched simulator reports into .harness when a writer is provided', async () => { + const tempRoot = fs.mkdtempSync( + join(tmpdir(), 'rn-harness-simctl-artifacts-') + ); + const artifactRoot = join(tempRoot, '.harness', 'crash-reports'); + const diagnosticReportsDir = join( + homedir(), + 'Library', + 'Logs', + 'DiagnosticReports' + ); + + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readdirSync').mockReturnValue([ + 'HarnessPlayground-2026-03-12-122756.ips', + ] as unknown as ReturnType); + vi.spyOn(fs, 'readFileSync').mockReturnValue( + [ + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + name: 'HarnessPlayground', + }), + JSON.stringify({ + pid: 1234, + procName: 'HarnessPlayground', + procPath: + `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + exception: { + type: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + }, + }), + ].join('\n') as ReturnType + ); + vi.spyOn(fs, 'statSync').mockReturnValue({ + mtimeMs: 123456, + } as fs.Stats); + const copyFileSyncSpy = vi.spyOn(fs, 'copyFileSync').mockImplementation(() => undefined); + const writer = createCrashArtifactWriter({ + runnerName: 'ios-sim', + platformId: 'ios', + rootDir: artifactRoot, + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const reports = await collectCrashReports({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + crashArtifactWriter: writer, + }); + + expect(reports[0]?.artifactPath).toContain('/.harness/crash-reports/'); + expect(copyFileSyncSpy).toHaveBeenCalledWith( + join(diagnosticReportsDir, 'HarnessPlayground-2026-03-12-122756.ips'), + reports[0]?.artifactPath + ); + }); + + it('ignores simulator reports older than the current run window', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readdirSync').mockReturnValue([ + 'old.ips', + 'new.ips', + ] as unknown as ReturnType); + vi.spyOn(fs, 'readFileSync').mockImplementation(((input: fs.PathOrFileDescriptor) => { + const filePath = String(input); + + return [ + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + name: 'HarnessPlayground', + }), + JSON.stringify({ + pid: filePath.includes('old') ? 1234 : 1235, + procName: 'HarnessPlayground', + procPath: + `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + exception: { + type: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + }, + }), + ].join('\n'); + }) as typeof fs.readFileSync); + vi.spyOn(fs, 'statSync').mockImplementation(((input: fs.PathLike) => ({ + mtimeMs: String(input).includes('old') + ? Date.parse('2026-03-12T11:30:08.000Z') + : Date.parse('2026-03-12T11:40:08.000Z'), + })) as typeof fs.statSync); + + const reports = await collectCrashReports({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + processNames: ['HarnessPlayground'], + minOccurredAt: Date.parse('2026-03-12T11:35:08.000Z'), + }); + + expect(reports).toHaveLength(1); + expect(reports[0]?.pid).toBe(1235); + }); +}); diff --git a/packages/platform-ios/src/app-monitor.ts b/packages/platform-ios/src/app-monitor.ts new file mode 100644 index 00000000..bd3b5963 --- /dev/null +++ b/packages/platform-ios/src/app-monitor.ts @@ -0,0 +1,589 @@ +import { + type AppMonitor, + type AppCrashDetails, + type AppMonitorEvent, + type AppMonitorListener, + type CrashArtifactWriter, + type CrashDetailsLookupOptions, +} from '@react-native-harness/platforms'; +import { escapeRegExp, getEmitter, logger, spawn, type Subprocess } from '@react-native-harness/tools'; +import * as devicectl from './xcrun/devicectl.js'; +import * as simctl from './xcrun/simctl.js'; +import * as libimobiledevice from './libimobiledevice.js'; + +const MAX_RECENT_LOG_LINES = 200; +const MAX_RECENT_CRASH_ARTIFACTS = 10; +const CRASH_ARTIFACT_SETTLE_DELAY_MS = 100; +const CRASH_ARTIFACT_WAIT_TIMEOUT_MS = 2000; +const CRASH_ARTIFACT_POLL_INTERVAL_MS = 100; + +type TimedLogLine = { + line: string; + occurredAt: number; +}; + +type IosCrashArtifact = AppCrashDetails & { + occurredAt: number; +}; + +const getSignal = (line: string) => { + const namedSignalMatch = line.match(/\b(SIG[A-Z0-9]+)\b/); + + if (namedSignalMatch) { + return namedSignalMatch[1]; + } + + const signalNumberMatch = line.match(/signal\s+(\d+)/i); + + if (signalNumberMatch) { + return `signal ${signalNumberMatch[1]}`; + } + + const exceptionTypeMatch = line.match(/\b(EXC_[A-Z_]+)\b/); + + if (exceptionTypeMatch) { + return exceptionTypeMatch[1]; + } + + return undefined; +}; + +const getProcessName = (line: string, processNames: string[]) => + processNames.find((processName) => + new RegExp(`\\b${escapeRegExp(processName)}\\b`).test(line) + ); + +const getPid = (line: string, processNames: string[]) => { + for (const processName of processNames) { + const match = line.match( + new RegExp(`\\b${escapeRegExp(processName)}(?:\\([^)]*\\))?\\[(\\d+)(?::[^\\]]+)?\\]`) + ); + + if (match) { + return Number(match[1]); + } + } + + const genericMatch = line.match(/\[(\d+)\]/); + + if (genericMatch) { + return Number(genericMatch[1]); + } + + return undefined; +}; + +const isRelevantProcessLine = (line: string, processNames: string[]) => + processNames.some((processName) => + new RegExp(`\\b${escapeRegExp(processName)}(?:\\[|\\b)`).test(line) + ); + +const isRelevantProcessLogLine = (line: string, processNames: string[]) => + processNames.some((processName) => + new RegExp(`\\b${escapeRegExp(processName)}(?:\\([^)]*\\))?\\[`).test(line) + ); + +const isCrashSignal = (line: string) => + /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason/i.test( + line + ) || /\bSIG[A-Z]{2,}\b/.test(line); + +const getIosLogCrashDetails = ({ + line, + processNames, +}: { + line: string; + processNames: string[]; +}): AppCrashDetails => { + const exceptionMatch = line.match(/exception[^:]*:\s*([^,]+)/i); + + return { + source: 'logs', + summary: line.trim(), + signal: getSignal(line), + exceptionType: exceptionMatch?.[1]?.trim(), + processName: getProcessName(line, processNames), + pid: getPid(line, processNames), + rawLines: [line], + }; +}; + +export const createUnifiedLogEvent = ({ + line, + processNames, +}: { + line: string; + processNames: string[]; +}): AppMonitorEvent | null => { + if (!isRelevantProcessLine(line, processNames)) { + return null; + } + + if (isCrashSignal(line)) { + return { + type: 'possible_crash', + source: 'logs', + line, + isConfirmed: true, + crashDetails: getIosLogCrashDetails({ + line, + processNames, + }), + }; + } + + return null; +}; + +const createAppMonitorBase = () => { + const emitter = getEmitter(); + let isStarted = false; + let recentLogLines: TimedLogLine[] = []; + let recentCrashArtifacts: IosCrashArtifact[] = []; + + const emit = (event: AppMonitorEvent) => { + emitter.emit(event); + }; + + const recordLogLine = (line: string) => { + recentLogLines = [...recentLogLines, { line, occurredAt: Date.now() }].slice( + -MAX_RECENT_LOG_LINES + ); + }; + + const recordCrashArtifact = (details: AppCrashDetails) => { + recentCrashArtifacts = [ + ...recentCrashArtifacts, + { + ...details, + occurredAt: Date.now(), + }, + ].slice(-MAX_RECENT_CRASH_ARTIFACTS); + }; + + const getLatestCrashArtifact = ( + options: CrashDetailsLookupOptions + ): AppCrashDetails | null => { + const matchingByPid = options.pid + ? recentCrashArtifacts.filter((artifact) => artifact.pid === options.pid) + : []; + const matchingByProcess = options.processName + ? recentCrashArtifacts.filter( + (artifact) => artifact.processName === options.processName + ) + : []; + const candidates = + matchingByPid.length > 0 + ? matchingByPid + : matchingByProcess.length > 0 + ? matchingByProcess + : recentCrashArtifacts; + const preferredCandidates = candidates.filter( + (artifact) => + artifact.artifactType === 'ios-crash-report' + ); + const prioritizedCandidates = + preferredCandidates.length > 0 ? preferredCandidates : candidates; + + return ( + [...prioritizedCandidates].sort( + (left, right) => + Math.abs(left.occurredAt - options.occurredAt) - + Math.abs(right.occurredAt - options.occurredAt) + )[0] ?? null + ); + }; + + const handleLogEvent = (line: string, processNames: string[]) => { + if (!isRelevantProcessLogLine(line, processNames)) { + return; + } + + recordLogLine(line); + emit({ type: 'log', source: 'logs', line }); + + const event = createUnifiedLogEvent({ + line, + processNames, + }); + + if (!event) { + return; + } + + if ( + (event.type === 'possible_crash' || event.type === 'app_exited') && + event.crashDetails + ) { + recordCrashArtifact(event.crashDetails); + } + + emit(event); + }; + + const stopProcess = async (child: Subprocess | null) => { + if (!child) { + return; + } + + try { + (await child.nodeChildProcess).kill(); + } catch { + // Ignore termination failures for background monitors. + } + }; + + const createLifecycle = ({ + startLogMonitor, + stopLogMonitor, + getCrashDetails, + }: { + startLogMonitor: (startedAt: number) => Promise; + stopLogMonitor: () => Promise; + getCrashDetails: ( + options: CrashDetailsLookupOptions + ) => Promise; + }): IosAppMonitor => { + const start = async () => { + if (isStarted) { + return; + } + + const startedAt = Date.now(); + + try { + await startLogMonitor(startedAt); + isStarted = true; + } catch (error) { + await stopLogMonitor(); + throw error; + } + }; + + const stop = async () => { + if (!isStarted) { + return; + } + + isStarted = false; + await stopLogMonitor(); + }; + + const dispose = async () => { + await stop(); + emitter.clearAllListeners(); + recentLogLines = []; + recentCrashArtifacts = []; + }; + + const addListener = (listener: AppMonitorListener) => { + emitter.addListener(listener); + }; + + const removeListener = (listener: AppMonitorListener) => { + emitter.removeListener(listener); + }; + + return { + start, + stop, + dispose, + addListener, + removeListener, + getCrashDetails, + }; + }; + + return { + createLifecycle, + handleLogEvent, + recordCrashArtifact, + getLatestCrashArtifact, + getRecentLogLines: () => recentLogLines, + stopProcess, + }; +}; + +const getRecentLogBlock = ({ + recentLogLines, + occurredAt, +}: { + recentLogLines: TimedLogLine[]; + occurredAt: number; +}) => { + const nearbyLines = recentLogLines.filter( + (line) => Math.abs(line.occurredAt - occurredAt) <= 1000 + ); + + return nearbyLines.map((line) => line.line); +}; + +export const createIosSimulatorAppMonitor = ({ + udid, + bundleId, + crashArtifactWriter, +}: { + udid: string; + bundleId: string; + crashArtifactWriter?: CrashArtifactWriter; +}): IosAppMonitor => { + const base = createAppMonitorBase(); + let logProcess: Subprocess | null = null; + let logTask: Promise | null = null; + let processNames = [bundleId]; + let monitorStartedAt = 0; + + const startLogMonitor = async (startedAt: number) => { + monitorStartedAt = startedAt; + const appInfo = await simctl.getAppInfo(udid, bundleId); + processNames = [...new Set([ + appInfo?.CFBundleExecutable, + appInfo?.CFBundleName, + bundleId, + ].filter((value): value is string => Boolean(value)))]; + + const predicate = processNames + .map((name) => `process == "${name}"`) + .join(' OR '); + + logProcess = spawn( + 'xcrun', + [ + 'simctl', + 'spawn', + udid, + 'log', + 'stream', + '--style', + 'compact', + '--level', + 'info', + '--predicate', + predicate, + ], + { + stdout: 'pipe', + stderr: 'pipe', + } + ); + + const currentProcess = logProcess; + + if (!currentProcess) { + return; + } + + logTask = (async () => { + try { + for await (const line of currentProcess) { + base.handleLogEvent(line, processNames); + } + } catch (error) { + logger.debug('iOS simulator log monitor stopped', error); + } + })(); + }; + + const stopLogMonitor = async () => { + const currentProcess = logProcess; + const currentTask = logTask; + + logProcess = null; + logTask = null; + + await base.stopProcess(currentProcess); + await currentTask; + }; + + const waitForCrashArtifact = async ( + options: CrashDetailsLookupOptions + ): Promise => { + let fallbackArtifact: AppCrashDetails | null = null; + const deadline = Date.now() + CRASH_ARTIFACT_WAIT_TIMEOUT_MS; + + do { + const collectedArtifacts = await simctl.collectCrashReports({ + udid, + bundleId, + processNames, + crashArtifactWriter, + minOccurredAt: monitorStartedAt, + }); + + for (const artifact of collectedArtifacts) { + base.recordCrashArtifact(artifact); + } + + const artifact = base.getLatestCrashArtifact(options); + + if (artifact) { + if (artifact.artifactType === 'ios-crash-report') { + return artifact; + } + + fallbackArtifact = artifact; + } + + if (Date.now() >= deadline) { + return fallbackArtifact; + } + + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS) + ); + } while (true); + }; + + return base.createLifecycle({ + startLogMonitor, + stopLogMonitor, + getCrashDetails: async (options) => { + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) + ); + + const artifact = await waitForCrashArtifact(options); + + if (!artifact) { + return null; + } + + if (artifact.artifactType === 'ios-crash-report') { + return artifact; + } + + const relatedLogLines = getRecentLogBlock({ + recentLogLines: base.getRecentLogLines(), + occurredAt: options.occurredAt, + }); + + return { + ...artifact, + summary: + relatedLogLines.length > 0 + ? relatedLogLines.join('\n') + : artifact.summary, + rawLines: + relatedLogLines.length > 0 ? relatedLogLines : artifact.rawLines, + artifactType: undefined, + artifactPath: undefined, + }; + }, + }); +}; + +export const createIosDeviceAppMonitor = ({ + deviceId, + libimobiledeviceUdid, + bundleId, + crashArtifactWriter, +}: { + deviceId: string; + libimobiledeviceUdid: string; + bundleId: string; + crashArtifactWriter?: CrashArtifactWriter; +}): IosAppMonitor => { + const base = createAppMonitorBase(); + let logProcess: Subprocess | null = null; + let logTask: Promise | null = null; + let processNames = [bundleId]; + let monitorStartedAt = 0; + + const startLogMonitor = async (startedAt: number) => { + monitorStartedAt = startedAt; + const appInfo = await devicectl.getAppInfo(deviceId, bundleId); + processNames = [bundleId, appInfo?.name].filter( + (value): value is string => Boolean(value) + ); + + await libimobiledevice.assertLibimobiledeviceTargetAvailable(libimobiledeviceUdid); + logProcess = libimobiledevice.createSyslogProcess({ + targetId: libimobiledeviceUdid, + processNames, + }); + + const currentProcess = logProcess; + + if (!currentProcess) { + return; + } + + logTask = (async () => { + try { + for await (const line of currentProcess) { + base.handleLogEvent(line, processNames); + } + } catch (error) { + logger.debug('iOS libimobiledevice log monitor stopped', error); + } + })(); + }; + + const stopLogMonitor = async () => { + const currentProcess = logProcess; + const currentTask = logTask; + + logProcess = null; + logTask = null; + + await base.stopProcess(currentProcess); + await currentTask; + }; + + const waitForCrashArtifact = async ( + options: CrashDetailsLookupOptions + ): Promise => { + let fallbackArtifact: AppCrashDetails | null = null; + const deadline = Date.now() + CRASH_ARTIFACT_WAIT_TIMEOUT_MS; + + do { + const collectedArtifacts = await libimobiledevice.collectCrashReports({ + targetId: libimobiledeviceUdid, + bundleId, + processNames, + crashArtifactWriter, + minOccurredAt: monitorStartedAt, + }); + + for (const artifact of collectedArtifacts) { + base.recordCrashArtifact(artifact); + } + + const artifact = base.getLatestCrashArtifact(options); + + if (artifact) { + if (artifact.artifactType === 'ios-crash-report') { + return artifact; + } + + fallbackArtifact = artifact; + } + + if (Date.now() >= deadline) { + return fallbackArtifact; + } + + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS) + ); + } while (true); + }; + + return base.createLifecycle({ + startLogMonitor, + stopLogMonitor, + getCrashDetails: async (options) => { + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) + ); + + return waitForCrashArtifact(options); + }, + }); +}; + +export type IosAppMonitor = AppMonitor & { + getCrashDetails: ( + options: CrashDetailsLookupOptions + ) => Promise; +}; diff --git a/packages/platform-ios/src/config.ts b/packages/platform-ios/src/config.ts index a08c3246..cce81256 100644 --- a/packages/platform-ios/src/config.ts +++ b/packages/platform-ios/src/config.ts @@ -1,5 +1,10 @@ import { z } from 'zod'; +export const AppleAppLaunchOptionsSchema = z.object({ + arguments: z.array(z.string()).optional(), + environment: z.record(z.string()).optional(), +}); + export const AppleSimulatorSchema = z.object({ type: z.literal('simulator'), name: z.string().min(1, 'Name is required'), @@ -20,12 +25,14 @@ export const ApplePlatformConfigSchema = z.object({ name: z.string().min(1, 'Name is required'), device: AppleDeviceSchema, bundleId: z.string().min(1, 'Bundle ID is required'), + appLaunchOptions: AppleAppLaunchOptionsSchema.optional(), }); export type AppleSimulator = z.infer; export type ApplePhysicalDevice = z.infer; export type AppleDevice = z.infer; export type ApplePlatformConfig = z.infer; +export type AppleAppLaunchOptions = z.infer; export const isAppleDeviceSimulator = ( device: AppleDevice diff --git a/packages/platform-ios/src/crash-parser.ts b/packages/platform-ios/src/crash-parser.ts new file mode 100644 index 00000000..8dbe0dce --- /dev/null +++ b/packages/platform-ios/src/crash-parser.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs'; +import type { AppCrashDetails } from '@react-native-harness/platforms'; + +type ParseIosCrashReportOptions = { + path: string; + contents: string; +}; + +export type ParsedIosCrashReport = AppCrashDetails & { + occurredAt: number; +}; + +const getSignal = (contents: string) => { + const namedSignalMatch = contents.match(/\b(SIG[A-Z0-9]+)\b/); + + if (namedSignalMatch) { + return namedSignalMatch[1]; + } + + const exceptionTypeMatch = contents.match(/\b(EXC_[A-Z_]+)\b/); + + if (exceptionTypeMatch) { + return exceptionTypeMatch[1]; + } + + return undefined; +}; + +const getOccurredAt = ({ + path, + contents, +}: ParseIosCrashReportOptions) => { + const dateTimeMatch = contents.match(/^Date\/Time:\s+(.+)$/m); + + if (dateTimeMatch) { + const normalizedValue = dateTimeMatch[1] + .trim() + .replace(/^(\d{4}-\d{2}-\d{2})\s+/, '$1T') + .replace(/\s+([+-]\d{2})(\d{2})$/, '$1:$2'); + const parsedDate = Date.parse(normalizedValue); + + if (!Number.isNaN(parsedDate)) { + return parsedDate; + } + } + + return fs.statSync(path).mtimeMs; +}; + +const getCrashThreadFrames = (rawLines: string[], threadId: string) => { + const threadHeader = `Thread ${threadId} Crashed:`; + const threadHeaderIndex = rawLines.findIndex((line) => line.trim() === threadHeader); + + if (threadHeaderIndex === -1) { + return undefined; + } + + const frames: string[] = []; + + for (const line of rawLines.slice(threadHeaderIndex + 1)) { + if (line.trim().length === 0) { + if (frames.length > 0) { + break; + } + + continue; + } + + if (!/^\d+\s+/.test(line.trim())) { + if (frames.length > 0) { + break; + } + + continue; + } + + frames.push(line.trim()); + } + + return frames.length > 0 ? frames : undefined; +}; + +const parseCrashTextReport = ({ + path, + contents, +}: ParseIosCrashReportOptions): ParsedIosCrashReport => { + const rawLines = contents.split(/\r?\n/); + const processMatch = contents.match(/^Process:\s+(.+?)\s+\[(\d+)\]$/m); + const exceptionMatch = contents.match(/^Exception Type:\s+(.+)$/m); + const triggeredThreadMatch = contents.match(/^Triggered by Thread:\s+(\d+)$/m); + + return { + occurredAt: getOccurredAt({ path, contents }), + rawLines, + processName: processMatch?.[1]?.trim(), + pid: processMatch ? Number(processMatch[2]) : undefined, + signal: getSignal(contents), + exceptionType: exceptionMatch?.[1]?.trim(), + stackTrace: triggeredThreadMatch + ? getCrashThreadFrames(rawLines, triggeredThreadMatch[1]) + : undefined, + }; +}; + +const parseIpsCrashReport = ({ + path, + contents, +}: ParseIosCrashReportOptions): ParsedIosCrashReport | null => { + const [headerLine, ...bodyLines] = contents.split(/\r?\n/); + + if (!headerLine || bodyLines.length === 0) { + return null; + } + + try { + const header = JSON.parse(headerLine) as { + app_name?: string; + bundleID?: string; + name?: string; + }; + const body = JSON.parse(bodyLines.join('\n')) as { + pid?: number; + procName?: string; + faultingThread?: number; + threads?: Array<{ + frames?: Array<{ + imageIndex?: number; + imageOffset?: number; + symbol?: string; + symbolLocation?: number; + sourceFile?: string; + sourceLine?: number; + }>; + }>; + usedImages?: Array<{ + name?: string; + }>; + exception?: { + type?: string; + signal?: string; + }; + termination?: { + indicator?: string; + }; + }; + const stackFrames = + body.faultingThread !== undefined + ? body.threads?.[body.faultingThread]?.frames ?? [] + : []; + const stackTrace = stackFrames + .map((frame, index) => { + const imageName = + frame.imageIndex !== undefined + ? body.usedImages?.[frame.imageIndex]?.name + : undefined; + const location = + frame.sourceFile && frame.sourceLine + ? `${frame.sourceFile}:${frame.sourceLine}` + : frame.symbolLocation !== undefined + ? `+ ${frame.symbolLocation}` + : frame.imageOffset !== undefined + ? `+ ${frame.imageOffset}` + : undefined; + const symbol = frame.symbol ?? imageName ?? ''; + + return `${index} ${symbol}${location ? ` (${location})` : ''}`; + }) + .filter((line) => line.trim().length > 0); + + return { + occurredAt: fs.statSync(path).mtimeMs, + rawLines: contents.split(/\r?\n/), + processName: body.procName ?? header.app_name ?? header.name, + pid: body.pid, + signal: body.exception?.signal ?? getSignal(contents), + exceptionType: + body.exception?.type ?? body.termination?.indicator ?? getSignal(contents), + stackTrace: stackTrace.length > 0 ? stackTrace : undefined, + }; + } catch { + return null; + } +}; + +export const iosCrashParser = { + parse(options: ParseIosCrashReportOptions): ParsedIosCrashReport | null { + if (options.path.endsWith('.ips')) { + return parseIpsCrashReport(options); + } + + return parseCrashTextReport(options); + }, +}; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 6e4098fa..81a44140 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -1,5 +1,6 @@ import { AppNotInstalledError, + CreateAppMonitorOptions, DeviceNotFoundError, HarnessPlatformRunner, } from '@react-native-harness/platforms'; @@ -11,6 +12,11 @@ import { import * as simctl from './xcrun/simctl.js'; import * as devicectl from './xcrun/devicectl.js'; import { getDeviceName } from './utils.js'; +import { + createIosDeviceAppMonitor, + createIosSimulatorAppMonitor, +} from './app-monitor.js'; +import { assertLibimobiledeviceInstalled } from './libimobiledevice.js'; export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig @@ -42,12 +48,22 @@ export const getAppleSimulatorPlatformInstance = async ( } return { - startApp: async () => { - await simctl.startApp(udid, config.bundleId); + startApp: async (options) => { + await simctl.startApp( + udid, + config.bundleId, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); }, - restartApp: async () => { + restartApp: async (options) => { await simctl.stopApp(udid, config.bundleId); - await simctl.startApp(udid, config.bundleId); + await simctl.startApp( + udid, + config.bundleId, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); }, stopApp: async () => { await simctl.stopApp(udid, config.bundleId); @@ -58,6 +74,12 @@ export const getAppleSimulatorPlatformInstance = async ( isAppRunning: async () => { return await simctl.isAppRunning(udid, config.bundleId); }, + createAppMonitor: (options?: CreateAppMonitorOptions) => + createIosSimulatorAppMonitor({ + udid, + bundleId: config.bundleId, + crashArtifactWriter: options?.crashArtifactWriter, + }), }; }; @@ -65,13 +87,17 @@ export const getApplePhysicalDevicePlatformInstance = async ( config: ApplePlatformConfig ): Promise => { assertAppleDevicePhysical(config.device); + await assertLibimobiledeviceInstalled(); - const deviceId = await devicectl.getDeviceId(config.device.name); + const device = await devicectl.getDevice(config.device.name); - if (!deviceId) { + if (!device) { throw new DeviceNotFoundError(getDeviceName(config.device)); } + const deviceId = device.identifier; + const hardwareUdid = device.hardwareProperties.udid; + const isAvailable = await devicectl.isAppInstalled(deviceId, config.bundleId); if (!isAvailable) { @@ -82,12 +108,22 @@ export const getApplePhysicalDevicePlatformInstance = async ( } return { - startApp: async () => { - await devicectl.startApp(deviceId, config.bundleId); + startApp: async (options) => { + await devicectl.startApp( + deviceId, + config.bundleId, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); }, - restartApp: async () => { + restartApp: async (options) => { await devicectl.stopApp(deviceId, config.bundleId); - await devicectl.startApp(deviceId, config.bundleId); + await devicectl.startApp( + deviceId, + config.bundleId, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); }, stopApp: async () => { await devicectl.stopApp(deviceId, config.bundleId); @@ -98,5 +134,12 @@ export const getApplePhysicalDevicePlatformInstance = async ( isAppRunning: async () => { return await devicectl.isAppRunning(deviceId, config.bundleId); }, + createAppMonitor: (options?: CreateAppMonitorOptions) => + createIosDeviceAppMonitor({ + deviceId, + libimobiledeviceUdid: hardwareUdid, + bundleId: config.bundleId, + crashArtifactWriter: options?.crashArtifactWriter, + }), }; }; diff --git a/packages/platform-ios/src/libimobiledevice.ts b/packages/platform-ios/src/libimobiledevice.ts new file mode 100644 index 00000000..399cf183 --- /dev/null +++ b/packages/platform-ios/src/libimobiledevice.ts @@ -0,0 +1,184 @@ +import { + DependencyNotFoundError, + type CrashArtifactWriter, +} from '@react-native-harness/platforms'; +import { escapeRegExp, spawn, type Subprocess } from '@react-native-harness/tools'; +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { iosCrashParser } from './crash-parser.js'; + +const REQUIRED_BINARIES = [ + 'idevicesyslog', + 'idevicecrashreport', + 'idevice_id', +] as const; + +const INSTALL_INSTRUCTIONS = + 'Install libimobiledevice and ensure idevicesyslog, idevicecrashreport, and idevice_id are available in PATH.'; + +export type IosCrashArtifact = { + artifactType: 'ios-crash-report'; + artifactPath: string; + summary?: string; + rawLines: string[]; + processName?: string; + pid?: number; + signal?: string; + exceptionType?: string; + stackTrace?: string[]; + occurredAt: number; +}; + +const shouldIncludeCrashReport = ({ + path, + contents, + bundleId, + processNames, +}: { + path: string; + contents: string; + bundleId: string; + processNames: string[]; +}) => { + if (contents.includes(bundleId) || path.includes(bundleId)) { + return true; + } + + return processNames.some((processName) => { + const processPattern = new RegExp(`\\b${escapeRegExp(processName)}\\b`); + + return processPattern.test(contents) || processPattern.test(path); + }); +}; + +export const assertLibimobiledeviceInstalled = async (): Promise => { + for (const binary of REQUIRED_BINARIES) { + try { + await spawn('which', [binary]); + } catch { + throw new DependencyNotFoundError('libimobiledevice', INSTALL_INSTRUCTIONS); + } + } +}; + +export const assertLibimobiledeviceTargetAvailable = async ( + targetId: string +): Promise => { + try { + await spawn('idevicesyslog', ['-u', targetId, 'pidlist']); + } catch (error) { + throw new Error( + `libimobiledevice could not attach to iOS target "${targetId}". ${error instanceof Error ? error.message : ''}`.trim() + ); + } +}; + +export const createSyslogProcess = ({ + targetId, + processNames, +}: { + targetId: string; + processNames: string[]; +}): Subprocess => + spawn( + 'idevicesyslog', + ['-u', targetId, '--exit', '--process', processNames.join('|')], + { + stdout: 'pipe', + stderr: 'pipe', + } + ); + +const getCrashReportFilterName = ( + processNames: string[], + bundleId: string +) => processNames.find((name) => name !== bundleId) ?? processNames[0]; + +const isCrashReportFile = (entry: string) => + entry.endsWith('.crash') || entry.endsWith('.ips'); + +export const collectCrashReports = async ({ + targetId, + bundleId, + processNames, + crashArtifactWriter, + minOccurredAt, +}: { + targetId: string; + bundleId: string; + processNames: string[]; + crashArtifactWriter?: CrashArtifactWriter; + minOccurredAt?: number; +}): Promise => { + const crashDir = fs.mkdtempSync(join(tmpdir(), 'rn-harness-ios-crashes-')); + + try { + const filterName = getCrashReportFilterName(processNames, bundleId); + + await spawn('idevicecrashreport', [ + '-u', + targetId, + '--keep', + '--extract', + ...(filterName ? ['--filter', filterName] : []), + crashDir, + ]); + + const reportPaths = fs + .readdirSync(crashDir) + .filter(isCrashReportFile) + .map((entry) => join(crashDir, entry)); + + return reportPaths + .map((path) => ({ + path, + contents: fs.readFileSync(path, 'utf8'), + })) + .filter(({ path, contents }) => + shouldIncludeCrashReport({ + path, + contents, + bundleId, + processNames, + }) + ) + .map(({ path, contents }) => { + const report = iosCrashParser.parse({ + path, + contents, + }); + + if (!report) { + return null; + } + + if (minOccurredAt !== undefined && report.occurredAt < minOccurredAt) { + return null; + } + + if (!crashArtifactWriter) { + return { + artifactType: 'ios-crash-report', + artifactPath: path, + ...report, + }; + } + + return { + artifactType: 'ios-crash-report', + ...report, + artifactPath: crashArtifactWriter.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path, + }, + }), + }; + }) + .filter((report): report is IosCrashArtifact => report !== null); + } finally { + fs.rmSync(crashDir, { recursive: true, force: true }); + } +}; diff --git a/packages/platform-ios/src/runner.ts b/packages/platform-ios/src/runner.ts index c7094fdc..b6e6baf4 100644 --- a/packages/platform-ios/src/runner.ts +++ b/packages/platform-ios/src/runner.ts @@ -1,4 +1,5 @@ import { HarnessPlatformRunner } from '@react-native-harness/platforms'; +import { Config } from '@react-native-harness/config'; import { ApplePlatformConfigSchema, type ApplePlatformConfig, @@ -10,7 +11,8 @@ import { } from './instance.js'; const getAppleRunner = async ( - config: ApplePlatformConfig + config: ApplePlatformConfig, + harnessConfig: Config ): Promise => { const parsedConfig = ApplePlatformConfigSchema.parse(config); diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index fe53e57d..bc3ddff1 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -1,3 +1,4 @@ +import { type AppleAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn } from '@react-native-harness/tools'; import fs from 'node:fs'; import { tmpdir } from 'node:os'; @@ -87,15 +88,27 @@ export const isAppInstalled = async ( export const startApp = async ( identifier: string, - bundleId: string + bundleId: string, + options?: AppleAppLaunchOptions ): Promise => { - await devicectl('device', [ - 'process', - 'launch', - '--device', - identifier, - bundleId, - ]); + await devicectl('device', getDeviceCtlLaunchArgs(identifier, bundleId, options)); +}; + +export const getDeviceCtlLaunchArgs = ( + identifier: string, + bundleId: string, + options?: AppleAppLaunchOptions +): string[] => { + const args = ['process', 'launch', '--device', identifier]; + const environment = options?.environment; + + if (environment && Object.keys(environment).length > 0) { + args.push('--environment-variables', JSON.stringify(environment)); + } + + args.push(bundleId, ...(options?.arguments ?? [])); + + return args; }; export type AppleProcessInfo = { @@ -143,12 +156,17 @@ export const stopApp = async ( ]); }; -export const getDeviceId = async (name: string): Promise => { +export const getDevice = async ( + name: string +): Promise => { const devices = await listDevices(); - const device = devices.find( - (device) => device.deviceProperties.name === name + return ( + devices.find((device) => device.deviceProperties.name === name) ?? null ); +}; +export const getDeviceId = async (name: string): Promise => { + const device = await getDevice(name); return device?.identifier ?? null; }; diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index df532d71..63d58868 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -1,22 +1,128 @@ -import { spawn, spawnAndForget } from '@react-native-harness/tools'; +import { + type AppleAppLaunchOptions, + type CrashArtifactWriter, +} from '@react-native-harness/platforms'; +import { escapeRegExp, spawn, spawnAndForget } from '@react-native-harness/tools'; +import fs from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { iosCrashParser } from '../crash-parser.js'; -const plistToJson = async (plistOutput: string): Promise => { +const plistToJson = async ( + plistOutput: string +): Promise> => { const { stdout: jsonOutput } = await spawn( 'plutil', ['-convert', 'json', '-o', '-', '-'], { stdin: { string: plistOutput } } ); - return JSON.parse(jsonOutput); + return JSON.parse(jsonOutput) as Record; }; export type AppleAppInfo = { Bundle: string; CFBundleIdentifier: string; + CFBundleExecutable: string; CFBundleName: string; CFBundleDisplayName: string; Path: string; }; +export type AppleSimulatorCrashReport = { + artifactType: 'ios-crash-report'; + artifactPath: string; + occurredAt: number; + summary?: string; + rawLines: string[]; + processName?: string; + pid?: number; + signal?: string; + exceptionType?: string; + stackTrace?: string[]; +}; + +const getDiagnosticReportsDir = () => + join(homedir(), 'Library', 'Logs', 'DiagnosticReports'); + +export const collectCrashReports = async ({ + udid, + bundleId, + processNames, + crashArtifactWriter, + minOccurredAt, +}: { + udid: string; + bundleId: string; + processNames: string[]; + crashArtifactWriter?: CrashArtifactWriter; + minOccurredAt?: number; +}): Promise => { + const diagnosticReportsDir = getDiagnosticReportsDir(); + + if (!fs.existsSync(diagnosticReportsDir)) { + return []; + } + + return fs + .readdirSync(diagnosticReportsDir) + .filter((entry) => entry.endsWith('.ips')) + .map((entry) => join(diagnosticReportsDir, entry)) + .map((path) => ({ + path, + contents: fs.readFileSync(path, 'utf8'), + })) + .filter(({ path, contents }) => { + if (!contents.includes(bundleId) && !path.includes(bundleId)) { + const matchesProcessName = processNames.some((processName) => + new RegExp(`\\b${escapeRegExp(processName)}\\b`).test(contents) + ); + + if (!matchesProcessName) { + return false; + } + } + + return contents.includes(udid); + }) + .map(({ path, contents }) => ({ + path, + report: iosCrashParser.parse({ + path, + contents, + }), + })) + .filter( + ( + entry + ): entry is { path: string; report: Omit } => + entry.report !== null + ) + .filter( + ({ report }) => minOccurredAt === undefined || report.occurredAt >= minOccurredAt + ) + .map(({ path, report }) => { + if (!crashArtifactWriter) { + return { + artifactType: 'ios-crash-report', + artifactPath: path, + ...report, + }; + } + + return { + artifactType: 'ios-crash-report', + ...report, + artifactPath: crashArtifactWriter.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path, + }, + }), + }; + }); +}; + export const getAppInfo = async ( udid: string, bundleId: string @@ -37,7 +143,7 @@ export const getAppInfo = async ( return null; } - return json; + return json as AppleAppInfo; }; export const isAppInstalled = async ( @@ -94,11 +200,27 @@ export const getSimulatorStatus = async ( return simulator.state; }; +export const getSimctlChildEnvironment = ( + options?: AppleAppLaunchOptions +): Record => + Object.fromEntries( + Object.entries(options?.environment ?? {}).map(([key, value]) => [ + `SIMCTL_CHILD_${key}`, + value, + ]) + ); + export const startApp = async ( udid: string, - bundleId: string + bundleId: string, + options?: AppleAppLaunchOptions ): Promise => { - await spawn('xcrun', ['simctl', 'launch', udid, bundleId]); + const environment = getSimctlChildEnvironment(options); + const argumentsList = options?.arguments ?? []; + + await spawn('xcrun', ['simctl', 'launch', udid, bundleId, ...argumentsList], { + env: environment, + }); }; export const stopApp = async ( diff --git a/packages/platform-ios/tsconfig.json b/packages/platform-ios/tsconfig.json index 56b5cd95..879e5151 100644 --- a/packages/platform-ios/tsconfig.json +++ b/packages/platform-ios/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../config" + }, { "path": "../tools" }, diff --git a/packages/platform-ios/tsconfig.lib.json b/packages/platform-ios/tsconfig.lib.json index aa622b9a..d6cee886 100644 --- a/packages/platform-ios/tsconfig.lib.json +++ b/packages/platform-ios/tsconfig.lib.json @@ -12,6 +12,9 @@ }, "include": ["src/**/*.ts"], "references": [ + { + "path": "../config/tsconfig.lib.json" + }, { "path": "../tools/tsconfig.lib.json" }, diff --git a/packages/platform-vega/src/config.ts b/packages/platform-vega/src/config.ts index 4375c154..5438c8dc 100644 --- a/packages/platform-vega/src/config.ts +++ b/packages/platform-vega/src/config.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +export const VegaAppLaunchOptionsSchema = z.object({}); + export const VegaEmulatorSchema = z.object({ type: z.literal('emulator'), deviceId: z @@ -18,6 +20,7 @@ export const VegaPlatformConfigSchema = z.object({ name: z.string().min(1, 'Name is required'), device: VegaDeviceSchema, bundleId: z.string().min(1, 'Bundle ID is required'), + appLaunchOptions: VegaAppLaunchOptionsSchema.optional(), }); export type VegaEmulator = z.infer; diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index ff4d8820..bac8146e 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -1,11 +1,71 @@ import { + type AppMonitor, + type AppMonitorEvent, DeviceNotFoundError, AppNotInstalledError, + type CreateAppMonitorOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; +import { getEmitter } from '@react-native-harness/tools'; import { VegaPlatformConfigSchema, type VegaPlatformConfig } from './config.js'; import * as kepler from './kepler.js'; +const createPollingAppMonitor = ({ + interval, + isAppRunning, +}: { + interval: number; + isAppRunning: () => Promise; +}): AppMonitor => { + const emitter = getEmitter(); + let timer: NodeJS.Timeout | null = null; + let started = false; + let wasRunning = false; + + const start = async () => { + if (started) { + return; + } + + started = true; + wasRunning = await isAppRunning(); + + timer = setInterval(async () => { + const running = await isAppRunning(); + + if (running && !wasRunning) { + emitter.emit({ type: 'app_started', source: 'polling' }); + } else if (!running && wasRunning) { + emitter.emit({ type: 'app_exited', source: 'polling' }); + } + + wasRunning = running; + }, interval); + }; + + const stop = async () => { + started = false; + + if (timer) { + clearInterval(timer); + timer = null; + } + }; + + const dispose = async () => { + await stop(); + emitter.clearAllListeners(); + }; + + return { + start, + stop, + dispose, + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; +}; + const getVegaRunner = async ( config: VegaPlatformConfig ): Promise => { @@ -41,6 +101,11 @@ const getVegaRunner = async ( isAppRunning: async () => { return await kepler.isAppRunning(deviceId, bundleId); }, + createAppMonitor: (_options?: CreateAppMonitorOptions) => + createPollingAppMonitor({ + interval: 250, + isAppRunning: () => kepler.isAppRunning(deviceId, bundleId), + }), }; }; diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index 1296756b..6e7ec420 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@react-native-harness/platforms": "workspace:*", + "@react-native-harness/tools": "workspace:*", "playwright": "^1.50.0", "zod": "^3.25.67", "tslib": "^2.3.0" diff --git a/packages/platform-web/src/config.ts b/packages/platform-web/src/config.ts index 5138ea80..b9c80897 100644 --- a/packages/platform-web/src/config.ts +++ b/packages/platform-web/src/config.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +export const WebAppLaunchOptionsSchema = z.object({}); + export const WebBrowserConfigSchema = z.object({ type: z.enum(['chromium', 'firefox', 'webkit']), url: z.string().url('A valid URL is required'), @@ -11,6 +13,7 @@ export const WebBrowserConfigSchema = z.object({ export const WebPlatformConfigSchema = z.object({ name: z.string().min(1, 'Name is required'), browser: WebBrowserConfigSchema, + appLaunchOptions: WebAppLaunchOptionsSchema.optional(), }); export type WebBrowserConfig = z.infer; diff --git a/packages/platform-web/src/runner.ts b/packages/platform-web/src/runner.ts index f59dd7a1..f77a1660 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -1,7 +1,69 @@ -import { HarnessPlatformRunner } from '@react-native-harness/platforms'; +import { + type AppMonitor, + type AppMonitorEvent, + type CreateAppMonitorOptions, + HarnessPlatformRunner, +} from '@react-native-harness/platforms'; import { chromium, firefox, webkit, type Browser, type Page } from 'playwright'; +import { getEmitter } from '@react-native-harness/tools'; import { WebPlatformConfigSchema, type WebPlatformConfig } from './config.js'; +const createPollingAppMonitor = ({ + interval, + isAppRunning, +}: { + interval: number; + isAppRunning: () => Promise; +}): AppMonitor => { + const emitter = getEmitter(); + let timer: NodeJS.Timeout | null = null; + let started = false; + let wasRunning = false; + + const start = async () => { + if (started) { + return; + } + + started = true; + wasRunning = await isAppRunning(); + + timer = setInterval(async () => { + const running = await isAppRunning(); + + if (running && !wasRunning) { + emitter.emit({ type: 'app_started', source: 'polling' }); + } else if (!running && wasRunning) { + emitter.emit({ type: 'app_exited', source: 'polling' }); + } + + wasRunning = running; + }, interval); + }; + + const stop = async () => { + started = false; + + if (timer) { + clearInterval(timer); + timer = null; + } + }; + + const dispose = async () => { + await stop(); + emitter.clearAllListeners(); + }; + + return { + start, + stop, + dispose, + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; +}; + const getWebRunner = async ( config: WebPlatformConfig ): Promise => { @@ -144,6 +206,12 @@ const getWebRunner = async ( isAppRunning: async () => { return browser !== null && page !== null && !page.isClosed(); }, + createAppMonitor: (_options?: CreateAppMonitorOptions) => + createPollingAppMonitor({ + interval: 250, + isAppRunning: async () => + browser !== null && page !== null && !page.isClosed(), + }), }; }; diff --git a/packages/platform-web/tsconfig.json b/packages/platform-web/tsconfig.json index 9f9888e0..56b5cd95 100644 --- a/packages/platform-web/tsconfig.json +++ b/packages/platform-web/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../tools" + }, { "path": "../platforms" }, diff --git a/packages/platform-web/tsconfig.lib.json b/packages/platform-web/tsconfig.lib.json index 595d0aa3..362f35d8 100644 --- a/packages/platform-web/tsconfig.lib.json +++ b/packages/platform-web/tsconfig.lib.json @@ -12,6 +12,9 @@ }, "include": ["src/**/*.ts"], "references": [ + { + "path": "../tools/tsconfig.lib.json" + }, { "path": "../platforms/tsconfig.lib.json" } diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 53b08378..35369eb7 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -1,7 +1,20 @@ export type { + AndroidAppLaunchOptions, + AppleAppLaunchOptions, + AppCrashDetails, + AppMonitor, + AppMonitorEvent, + AppMonitorListener, + AppLaunchOptions, + CrashDetailsLookupOptions, + CrashArtifactSource, + CrashArtifactWriter, + CreateAppMonitorOptions, HarnessPlatform, HarnessPlatformRunner, RunTarget, + VegaAppLaunchOptions, + WebAppLaunchOptions, } from './types.js'; export { AppNotInstalledError, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 6050fc48..d61cb108 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -1,9 +1,115 @@ +export type AppCrashDetails = { + source?: 'polling' | 'logs' | 'bridge'; + summary?: string; + signal?: string; + exceptionType?: string; + processName?: string; + pid?: number; + stackTrace?: string[]; + rawLines?: string[]; + artifactType?: + | 'logcat' + | 'ios-crash-report'; + artifactPath?: string; +}; + +export type CrashArtifactSource = + | { + kind: 'file'; + path: string; + } + | { + kind: 'text'; + fileName: string; + text: string; + }; + +export type CrashArtifactWriter = { + runTimestamp: string; + persistArtifact: (options: { + artifactKind: string; + source: CrashArtifactSource; + }) => string; +}; + +export type CreateAppMonitorOptions = { + crashArtifactWriter?: CrashArtifactWriter; +}; + +export type CrashDetailsLookupOptions = { + processName?: string; + pid?: number; + occurredAt: number; +}; + +export type AppMonitorEvent = + | { + type: 'app_started'; + pid?: number; + source?: 'polling' | 'logs'; + line?: string; + } + | { + type: 'app_exited'; + pid?: number; + source?: 'polling' | 'logs'; + line?: string; + isConfirmed?: boolean; + crashDetails?: AppCrashDetails; + } + | { + type: 'possible_crash'; + pid?: number; + source?: 'polling' | 'logs'; + line?: string; + isConfirmed?: boolean; + crashDetails?: AppCrashDetails; + } + | { + type: 'log'; + source?: 'polling' | 'logs'; + line: string; + }; + +export type AppMonitorListener = (event: AppMonitorEvent) => void; + +export type AppMonitor = { + start: () => Promise; + stop: () => Promise; + dispose: () => Promise; + addListener: (listener: AppMonitorListener) => void; + removeListener: (listener: AppMonitorListener) => void; +}; + +export type AndroidAppLaunchOptions = { + extras?: Record; +}; + +export type AppleAppLaunchOptions = { + arguments?: string[]; + environment?: Record; +}; + +export type WebAppLaunchOptions = Record; + +export type VegaAppLaunchOptions = Record; + +export type AppLaunchOptions = + | AndroidAppLaunchOptions + | AppleAppLaunchOptions + | WebAppLaunchOptions + | VegaAppLaunchOptions; + export type HarnessPlatformRunner = { - startApp: () => Promise; - restartApp: () => Promise; + startApp: (options?: AppLaunchOptions) => Promise; + restartApp: (options?: AppLaunchOptions) => Promise; stopApp: () => Promise; dispose: () => Promise; isAppRunning: () => Promise; + createAppMonitor: (options?: CreateAppMonitorOptions) => AppMonitor; + getCrashDetails?: ( + options: CrashDetailsLookupOptions + ) => Promise; }; export type HarnessPlatform> = { @@ -13,10 +119,61 @@ export type HarnessPlatform> = { platformId: string; }; -export type RunTarget = { - type: 'emulator' | 'physical' | 'browser'; +export type AndroidEmulatorRunTarget = { + type: 'emulator'; + name: string; + platform: 'android'; + description?: string; + device: { + name: string; + }; +}; + +export type AndroidPhysicalRunTarget = { + type: 'physical'; + name: string; + platform: 'android'; + description?: string; + device: { + manufacturer: string; + model: string; + }; +}; + +export type AppleSimulatorRunTarget = { + type: 'emulator'; name: string; - platform: string; + platform: 'ios'; description?: string; - device: Record; + device: { + name: string; + systemVersion: string; + }; }; + +export type ApplePhysicalRunTarget = { + type: 'physical'; + name: string; + platform: 'ios'; + description?: string; + device: { + name: string; + }; +}; + +export type WebRunTarget = { + type: 'browser'; + name: string; + platform: 'web'; + description?: string; + device: { + browserType: 'chromium' | 'firefox' | 'webkit'; + }; +}; + +export type RunTarget = + | AndroidEmulatorRunTarget + | AndroidPhysicalRunTarget + | AppleSimulatorRunTarget + | ApplePhysicalRunTarget + | WebRunTarget; diff --git a/packages/tools/src/__tests__/crash-artifacts.test.ts b/packages/tools/src/__tests__/crash-artifacts.test.ts new file mode 100644 index 00000000..e91a74d1 --- /dev/null +++ b/packages/tools/src/__tests__/crash-artifacts.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { createCrashArtifactWriter } from '../crash-artifacts.js'; + +describe('createCrashArtifactWriter', () => { + const rootDir = fs.mkdtempSync( + path.join(tmpdir(), 'rn-harness-crash-artifacts-') + ); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + fs.mkdirSync(rootDir, { recursive: true }); + }); + + it('uses a shared run timestamp and preserves useful file extensions', () => { + const sourcePath = path.join(rootDir, 'Harness Playground 01.crash'); + fs.writeFileSync(sourcePath, 'crash data', 'utf8'); + + const writer = createCrashArtifactWriter({ + runnerName: 'ios simulator', + platformId: 'ios', + rootDir, + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const persistedPath = writer.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path: sourcePath, + }, + }); + + expect(path.basename(persistedPath)).toBe( + '2026-03-12T11-35-08-000Z--ios-simulator--ios--ios-crash-report--Harness-Playground-01.crash' + ); + expect(fs.readFileSync(persistedPath, 'utf8')).toBe('crash data'); + expect(writer.runTimestamp).toBe('2026-03-12T11-35-08-000Z'); + }); + + it('creates the artifact directory lazily and writes text artifacts', () => { + const artifactRoot = path.join(rootDir, '.harness', 'crash-reports'); + const writer = createCrashArtifactWriter({ + runnerName: 'android', + platformId: 'android', + rootDir: artifactRoot, + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const persistedPath = writer.persistArtifact({ + artifactKind: 'logcat', + source: { + kind: 'text', + fileName: 'logcat.txt', + text: '--------- beginning of crash\nRuntimeException: boom\n', + }, + }); + + expect(fs.existsSync(artifactRoot)).toBe(true); + expect(fs.readFileSync(persistedPath, 'utf8')).toContain('RuntimeException'); + }); + + it('deduplicates repeated persistence requests within one run', () => { + const sourcePath = path.join(rootDir, 'duplicate.crash'); + fs.writeFileSync(sourcePath, 'same crash', 'utf8'); + + const writer = createCrashArtifactWriter({ + runnerName: 'ios', + platformId: 'ios', + rootDir, + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const firstPath = writer.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path: sourcePath, + }, + }); + const secondPath = writer.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path: sourcePath, + }, + }); + + expect(firstPath).toBe(secondPath); + expect(fs.readdirSync(rootDir)).toHaveLength(2); + }); +}); diff --git a/packages/tools/src/crash-artifacts.ts b/packages/tools/src/crash-artifacts.ts new file mode 100644 index 00000000..a82d9220 --- /dev/null +++ b/packages/tools/src/crash-artifacts.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_ARTIFACT_ROOT = path.join( + process.cwd(), + '.harness', + 'crash-reports' +); + +const sanitizePathSegment = (value: string) => + value + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'artifact'; + +const formatRunTimestamp = (value: Date) => + value.toISOString().replace(/[:.]/g, '-'); + +const getTargetFileName = ({ + runTimestamp, + runnerName, + platformId, + artifactKind, + source, +}: { + runTimestamp: string; + runnerName: string; + platformId: string; + artifactKind: string; + source: + | { + kind: 'file'; + path: string; + } + | { + kind: 'text'; + fileName: string; + }; +}) => { + const originalName = + source.kind === 'file' ? path.basename(source.path) : source.fileName; + + return [ + sanitizePathSegment(runTimestamp), + sanitizePathSegment(runnerName), + sanitizePathSegment(platformId), + sanitizePathSegment(artifactKind), + sanitizePathSegment(originalName), + ].join('--'); +}; + +const getDeduplicationKey = ({ + platformId, + artifactKind, + source, +}: { + platformId: string; + artifactKind: string; + source: + | { + kind: 'file'; + path: string; + } + | { + kind: 'text'; + fileName: string; + text: string; + }; +}) => { + if (source.kind === 'file') { + return `file:${platformId}:${artifactKind}:${path.resolve(source.path)}`; + } + + return `text:${platformId}:${artifactKind}:${source.fileName}:${source.text}`; +}; + +export const createCrashArtifactWriter = ({ + runnerName, + platformId, + rootDir = DEFAULT_ARTIFACT_ROOT, + runTimestamp = formatRunTimestamp(new Date()), +}: { + runnerName: string; + platformId: string; + rootDir?: string; + runTimestamp?: string; +}) => { + const persistedArtifacts = new Map(); + + return { + runTimestamp, + persistArtifact: (options: { + artifactKind: string; + source: + | { + kind: 'file'; + path: string; + } + | { + kind: 'text'; + fileName: string; + text: string; + }; + }) => { + const deduplicationKey = getDeduplicationKey({ + platformId, + artifactKind: options.artifactKind, + source: options.source, + }); + const existingPath = persistedArtifacts.get(deduplicationKey); + + if (existingPath) { + return existingPath; + } + + fs.mkdirSync(rootDir, { recursive: true }); + + const targetPath = path.join( + rootDir, + getTargetFileName({ + runTimestamp, + runnerName, + platformId, + artifactKind: options.artifactKind, + source: options.source, + }) + ); + + if (options.source.kind === 'file') { + fs.copyFileSync(options.source.path, targetPath); + } else { + fs.writeFileSync(targetPath, options.source.text, 'utf8'); + } + + persistedArtifacts.set(deduplicationKey, targetPath); + + return targetPath; + }, + }; +}; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 3530c80f..9ac0de1d 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -6,4 +6,6 @@ export * from './spawn.js'; export * from './react-native.js'; export * from './error.js'; export * from './events.js'; -export * from './packages.js'; \ No newline at end of file +export * from './packages.js'; +export * from './crash-artifacts.js'; +export * from './regex.js'; diff --git a/packages/tools/src/regex.ts b/packages/tools/src/regex.ts new file mode 100644 index 00000000..92492192 --- /dev/null +++ b/packages/tools/src/regex.ts @@ -0,0 +1,2 @@ +export const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/packages/tools/src/spawn.ts b/packages/tools/src/spawn.ts index 47e88b0a..fca38e94 100644 --- a/packages/tools/src/spawn.ts +++ b/packages/tools/src/spawn.ts @@ -33,6 +33,43 @@ export const spawnAndForget = async (file: string, args?: readonly string[], opt export { Subprocess, SubprocessError }; +const activeChildProcesses = new Set(); +let isProcessCleanupInstalled = false; + +const terminateActiveChildren = async () => { + const children = [...activeChildProcesses]; + + await Promise.allSettled( + children.map(async (childProcess) => { + try { + (await childProcess.nodeChildProcess).kill(); + } catch { + // Ignore cleanup failures while shutting down. + } + }) + ); +}; + +const installProcessCleanup = () => { + if (isProcessCleanupInstalled) { + return; + } + + isProcessCleanupInstalled = true; + + const terminate = async () => { + await terminateActiveChildren(); + process.exit(1); + }; + + process.on('SIGINT', () => { + void terminate(); + }); + process.on('SIGTERM', () => { + void terminate(); + }); +}; + const setupChildProcessCleanup = (childProcess: Subprocess) => { // https://stackoverflow.com/questions/53049939/node-daemon-wont-start-with-process-stdin-setrawmodetrue/53050098#53050098 if (process.stdin.isTTY) { @@ -41,24 +78,11 @@ const setupChildProcessCleanup = (childProcess: Subprocess) => { process.stdin.setRawMode(false); } - const terminate = async () => { - try { - (await childProcess.nodeChildProcess).kill(); - process.exit(1); - } catch { - // ignore - } - }; - - const sigintHandler = () => terminate(); - const sigtermHandler = () => terminate(); - - process.on('SIGINT', sigintHandler); - process.on('SIGTERM', sigtermHandler); + installProcessCleanup(); + activeChildProcesses.add(childProcess); const cleanup = () => { - process.off('SIGINT', sigintHandler); - process.off('SIGTERM', sigtermHandler); + activeChildProcesses.delete(childProcess); }; childProcess.nodeChildProcess.finally(cleanup); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f165728b..ae0c996e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: packages/platform-ios: dependencies: + '@react-native-harness/config': + specifier: workspace:* + version: link:../config '@react-native-harness/platforms': specifier: workspace:* version: link:../platforms @@ -448,6 +451,9 @@ importers: '@react-native-harness/platforms': specifier: workspace:* version: link:../platforms + '@react-native-harness/tools': + specifier: workspace:* + version: link:../tools playwright: specifier: ^1.50.0 version: 1.57.0 @@ -5018,24 +5024,27 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-modules@1.0.0: resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} diff --git a/website/src/docs/api/test-environment.mdx b/website/src/docs/api/test-environment.mdx index d634978c..dfc32b78 100644 --- a/website/src/docs/api/test-environment.mdx +++ b/website/src/docs/api/test-environment.mdx @@ -79,7 +79,7 @@ Testing native modules often involves calling code that might cause the host app Harness includes a built-in **Native Crash Monitor**: -- **`detectNativeCrashes`**: (Default: `true`) When enabled, Harness actively monitors the native process. If the app crashes, Harness catches the failure, reports a `NativeCrashError` for the current test, and automatically restarts the app to continue with the remaining test files. +- **`detectNativeCrashes`**: (Default: `true`) When enabled, Harness actively monitors the native process during both startup and test execution. If the app crashes, Harness reports a `NativeCrashError` for the current test file, restarts the app, and continues with the remaining test files. - **`crashDetectionInterval`**: (Default: `500ms`) How often the CLI polls the device to ensure the app is still alive. --- diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 2c12a27f..8bb6f8e0 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -27,11 +27,19 @@ const config = { name: 'android', device: androidEmulator('Pixel_8_API_35'), bundleId: 'com.yourapp', + appLaunchOptions: { + extras: { + launch_mode: 'test', + }, + }, }), applePlatform({ name: 'ios', device: appleSimulator('iPhone 16 Pro', '18.0'), bundleId: 'com.yourapp', + appLaunchOptions: { + arguments: ['--mode=test'], + }, }), ], }; @@ -86,11 +94,9 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `defaultRunner` | Default runner to use when none specified. | | `host` | Hostname or IP address to bind the Metro server to (default: Metro default). | | `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | -| `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `15000`). | -| `maxAppRestarts` | Maximum number of app restarts when app fails to report ready (default: `2`). | | `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | | `webSocketPort` | Web socket port for bridge communication (default: `3001`). | -| `detectNativeCrashes` | Detect native app crashes during test execution (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. | @@ -101,6 +107,9 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` A test runner defines how tests are executed on a specific platform. React Native Harness uses platform-specific packages to create runners with type-safe configurations. +Runner-specific launch options belong inside each runner config via `appLaunchOptions`. +They are passed whenever Harness launches or relaunches the app for that runner. + For detailed installation and configuration instructions, please refer to the platform-specific guides: - [**Android**](/docs/platforms/android) @@ -167,39 +176,6 @@ Increase this value if you experience timeout errors, especially on: - Slower devices or simulators - Complex test suites with heavy setup -## Bundle Start Timeout - -The bundle start timeout controls how long React Native Harness waits for Metro to start bundling after the app is restarted. This timeout is used in conjunction with the app restart mechanism to detect when an app has failed to report ready. - -```javascript -{ - bundleStartTimeout: 30000, // 30 seconds in milliseconds -} -``` - -**Default:** 15000 (15 seconds) -**Minimum:** 1000 (1 second) - -This timeout works with the `maxAppRestarts` setting to automatically restart the app when it fails to communicate with the test harness. If no bundling activity is detected within this timeout period, the app will be restarted automatically. - -## Maximum App Restarts - -The maximum app restarts setting controls how many times React Native Harness will attempt to restart the app when it fails to report ready within the configured timeout periods. - -```javascript -{ - maxAppRestarts: 3, // Allow up to 3 restart attempts -} -``` - -**Default:** 2 -**Minimum:** 0 - -When set to 0, automatic app restarting is disabled. Higher values provide more resilience against flaky test environments but may increase test execution time. The app will be restarted when: - -- No bundling activity is detected within the `bundleStartTimeout` period -- The bridge fails to establish communication within the `bridgeTimeout` period - ## Web Socket Port The port used for the WebSocket bridge communication between the CLI and the device. diff --git a/website/src/docs/guides/ci-cd.md b/website/src/docs/guides/ci-cd.md index 94a77134..ee85fea1 100644 --- a/website/src/docs/guides/ci-cd.md +++ b/website/src/docs/guides/ci-cd.md @@ -29,6 +29,7 @@ Both actions automatically: - Set up and configure the emulator/simulator based on your config - Install your app - Run the tests +- Upload persisted crash artifacts from `.harness/crash-reports` when the run produces them The actions read your `rn-harness.config.mjs` file to determine the device configuration, so you don't need to hardcode emulator settings in your workflow. @@ -40,6 +41,12 @@ Both actions accept the following inputs: - `runner` (required): The runner name (e.g., `"android"` or `"ios"`) - `projectRoot` (optional): The project root directory (defaults to the repository root) +## Crash Artifacts + +When Harness resolves a crash artifact, it persists a copy under `.harness/crash-reports/` in the current working directory. Filenames include the Harness run timestamp and selected runner name so CI downloads stay easy to correlate. + +The official GitHub Actions upload `.harness/crash-reports/**/*` automatically with `if-no-files-found: ignore`, so crash reports are available as workflow artifacts whenever a run produces them. + ## GitHub Actions Example The example workflow shared below is designed for **React Native Community CLI** setups. If you're using **Expo** or **Rock**, the workflow will be simpler as these frameworks provide their own build and deployment mechanisms that integrate seamlessly with CI/CD environments. diff --git a/website/src/docs/platforms/android.mdx b/website/src/docs/platforms/android.mdx index 558ec348..64ace030 100644 --- a/website/src/docs/platforms/android.mdx +++ b/website/src/docs/platforms/android.mdx @@ -37,6 +37,12 @@ androidPlatform({ name: 'android', device: androidEmulator('Pixel_8_API_35'), bundleId: 'com.yourapp.debug', + appLaunchOptions: { + extras: { + launch_mode: 'test', + feature_enabled: true, + }, + }, }) ``` @@ -61,6 +67,11 @@ androidPlatform({ name: 'android-device', device: physicalAndroidDevice('Motorola', 'Moto G72'), bundleId: 'com.yourapp.debug', + appLaunchOptions: { + extras: { + launch_mode: 'test', + }, + }, }) ``` @@ -70,3 +81,13 @@ The first argument is the manufacturer, and the second is the model name. These - **ADB**: The Android Debug Bridge (`adb`) must be installed and in your system PATH. - **Development Build**: You must have a debug build of your app installed on the device/emulator (`adb install ...`). Harness does not build your app; it injects the test bundle into the existing installed app. + +## App Launch Options + +Android runners support `appLaunchOptions.extras`, which are passed as primitive extras to `adb shell am start`. + +Supported extra value types in v1: + +- `string` +- `boolean` +- safe integers diff --git a/website/src/docs/platforms/ios.mdx b/website/src/docs/platforms/ios.mdx index e4ea5771..fb913120 100644 --- a/website/src/docs/platforms/ios.mdx +++ b/website/src/docs/platforms/ios.mdx @@ -37,6 +37,12 @@ applePlatform({ name: 'ios', device: appleSimulator('iPhone 16 Pro', '18.0'), bundleId: 'com.yourapp', + appLaunchOptions: { + arguments: ['--mode=test'], + environment: { + FEATURE_X: '1', + }, + }, }) ``` @@ -60,6 +66,12 @@ applePlatform({ name: 'ios-device', device: applePhysicalDevice('iPhone (Your Name)'), bundleId: 'com.yourapp', + appLaunchOptions: { + arguments: ['--mode=test'], + environment: { + FEATURE_X: '1', + }, + }, }) ``` @@ -67,4 +79,23 @@ applePlatform({ - **Xcode**: Xcode must be installed on your system. - **xcrun**: Harness uses `xcrun simctl` for simulators and `xcrun devicectl` for physical devices. +- **libimobiledevice**: `idevicesyslog`, `idevicecrashreport`, and `idevice_id` must be installed and available in `PATH` for physical-device crash diagnostics. - **Development Build**: A debug build of your app must be installed on the simulator or device. + +## App Launch Options + +Apple runners support `appLaunchOptions.arguments` and `appLaunchOptions.environment`. + +Harness maps them differently depending on the target: + +- iOS Simulator: arguments are passed to `simctl launch`, and environment variables are passed through `SIMCTL_CHILD_*` +- iOS physical device: arguments are passed to `devicectl device process launch`, and environment variables are passed with `--environment-variables` + +## Native Crash Details + +Harness uses separate crash-monitoring implementations by target: + +- iOS simulators use `simctl` for live log streaming and also scan local crash reports in `~/Library/Logs/DiagnosticReports`. When Harness finds a matching simulator `.ips` report, it attaches the parsed crash metadata and the crashing thread stack trace. +- iOS physical devices use `libimobiledevice` to stream logs and pull `.crash` reports from the device. + +Native crash details are attached to startup and execution failures when Harness can match the failing process from these sources. When the report contains it, Harness prints the extracted crashing-thread stack trace in the failure output.