diff --git a/.nx/version-plans/version-plan-1779367800000.md b/.nx/version-plans/version-plan-1779367800000.md new file mode 100644 index 00000000..0a19872a --- /dev/null +++ b/.nx/version-plans/version-plan-1779367800000.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Native crash monitoring was rebuilt around platform-owned app lifecycle monitors, improving crash correlation and artifact collection on Android and iOS while keeping physical-device crash detection reliable without extra app-side dependencies. diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index ff5bc14b..9a49038b 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4422,7 +4422,6 @@ var ConfigSchema = external_exports.object({ unstable__enableMetroCache: external_exports.boolean().optional().default(false), permissions: external_exports.boolean().optional().default(false).describe("Enable platform-specific permission prompt automation. When false, Harness does not start permission-handling helpers such as the iOS XCTest agent."), detectNativeCrashes: external_exports.boolean().optional().default(true), - crashDetectionInterval: external_exports.number().min(100, "Crash detection interval must be at least 100ms").default(500), disableViewFlattening: external_exports.boolean().optional().default(false).describe("Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine."), coverage: external_exports.object({ root: external_exports.string().optional().describe(`Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.`), diff --git a/apps/playground/android/app/build.gradle b/apps/playground/android/app/build.gradle index 78efe8d5..0d59afe8 100644 --- a/apps/playground/android/app/build.gradle +++ b/apps/playground/android/app/build.gradle @@ -8,8 +8,7 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '../..' - // root = file("../../") + root = file("../../") // The folder where the react-native NPM package is. Default is ../../node_modules/react-native reactNativeDir = file("../../../../node_modules/react-native") // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen diff --git a/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt b/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt index cfa2bec2..d0632ee7 100644 --- a/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt +++ b/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt @@ -14,8 +14,7 @@ class MainApplication : Application(), ReactApplication { context = applicationContext, packageList = PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + add(PlaygroundCrashPackage()) }, ) } diff --git a/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashModule.kt b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashModule.kt new file mode 100644 index 00000000..bf891d58 --- /dev/null +++ b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashModule.kt @@ -0,0 +1,34 @@ +package com.harnessplayground + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule + +@ReactModule(name = PlaygroundCrashModule.NAME) +class PlaygroundCrashModule( + reactContext: ReactApplicationContext, +) : NativePlaygroundCrashSpec(reactContext) { + override fun getName(): String = NAME + + override fun crash(message: String) { + val exception = IllegalStateException( + message.ifEmpty { "Intentional PlaygroundCrash crash" }, + ) + + Thread( + { throw exception }, + "PlaygroundCrash", + ).start() + + Thread.sleep(10_000) + } + + override fun crashHandled(message: String): Boolean { + throw IllegalStateException( + message.ifEmpty { "Intentional PlaygroundCrash handled error" }, + ) + } + + companion object { + const val NAME = "PlaygroundCrash" + } +} diff --git a/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashPackage.kt b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashPackage.kt new file mode 100644 index 00000000..0dda7af9 --- /dev/null +++ b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashPackage.kt @@ -0,0 +1,34 @@ +package com.harnessplayground + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class PlaygroundCrashPackage : BaseReactPackage() { + override fun getModule( + name: String, + reactContext: ReactApplicationContext, + ): NativeModule? = + if (name == PlaygroundCrashModule.NAME) { + PlaygroundCrashModule(reactContext) + } else { + null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = + ReactModuleInfoProvider { + mapOf( + PlaygroundCrashModule.NAME to + ReactModuleInfo( + PlaygroundCrashModule.NAME, + PlaygroundCrashModule.NAME, + false, + false, + false, + true, + ), + ) + } +} diff --git a/apps/playground/harness-logging-plugin.mjs b/apps/playground/harness-logging-plugin.mjs index 2c3e77b3..26c3ac9b 100644 --- a/apps/playground/harness-logging-plugin.mjs +++ b/apps/playground/harness-logging-plugin.mjs @@ -73,8 +73,20 @@ export const harnessLoggingPlugin = () => ({ 'app.exited', (ctx) => `runId=${ctx.runId} testFile=${ctx.testFile ?? 'n/a'}` ), - possibleCrash: logWithDetails( - 'app.possibleCrash', + crashSuspected: logWithDetails( + 'app.crashSuspected', + (ctx) => `runId=${ctx.runId} testFile=${ctx.testFile ?? 'n/a'}` + ), + crashConfirmed: logWithDetails( + 'app.crashConfirmed', + (ctx) => `runId=${ctx.runId} testFile=${ctx.testFile ?? 'n/a'}` + ), + crashReportReady: logWithDetails( + 'app.crashReportReady', + (ctx) => `runId=${ctx.runId} artifact=${ctx.artifactPath ?? 'n/a'}` + ), + monitorWarning: logWithDetails( + 'app.monitorWarning', (ctx) => `runId=${ctx.runId} testFile=${ctx.testFile ?? 'n/a'}` ), }, diff --git a/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj b/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj index 331591b5..141263d0 100644 --- a/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj +++ b/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 7B16895B86EC58090B0C2218 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + PGCRASH0032CA45674006654EE /* PlaygroundCrash.mm in Sources */ = {isa = PBXBuildFile; fileRef = PGCRASH0022CA45674006654EE /* PlaygroundCrash.mm */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -25,6 +26,8 @@ 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HarnessPlayground/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = HarnessPlayground/LaunchScreen.storyboard; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + PGCRASH0012CA45674006654EE /* PlaygroundCrash.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = PlaygroundCrash.h; path = HarnessPlayground/PlaygroundCrash.h; sourceTree = ""; }; + PGCRASH0022CA45674006654EE /* PlaygroundCrash.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = PlaygroundCrash.mm; path = HarnessPlayground/PlaygroundCrash.mm; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -44,6 +47,8 @@ children = ( 13B07FB51A68108700A75B9A /* Images.xcassets */, 761780EC2CA45674006654EE /* AppDelegate.swift */, + PGCRASH0012CA45674006654EE /* PlaygroundCrash.h */, + PGCRASH0022CA45674006654EE /* PlaygroundCrash.mm */, 13B07FB61A68108700A75B9A /* Info.plist */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, @@ -247,6 +252,7 @@ buildActionMask = 2147483647; files = ( 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + PGCRASH0032CA45674006654EE /* PlaygroundCrash.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -260,7 +266,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(HARNESS_PLAYGROUND_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = HarnessPlayground/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -289,7 +295,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(HARNESS_PLAYGROUND_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = HarnessPlayground/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/apps/playground/ios/HarnessPlayground/PlaygroundCrash.h b/apps/playground/ios/HarnessPlayground/PlaygroundCrash.h new file mode 100644 index 00000000..56519fee --- /dev/null +++ b/apps/playground/ios/HarnessPlayground/PlaygroundCrash.h @@ -0,0 +1,5 @@ +#import + +@interface PlaygroundCrash : NSObject + +@end diff --git a/apps/playground/ios/HarnessPlayground/PlaygroundCrash.mm b/apps/playground/ios/HarnessPlayground/PlaygroundCrash.mm new file mode 100644 index 00000000..012bec83 --- /dev/null +++ b/apps/playground/ios/HarnessPlayground/PlaygroundCrash.mm @@ -0,0 +1,47 @@ +#import "PlaygroundCrash.h" + +#import + +@implementation PlaygroundCrash + +- (void)crash:(NSString *)message +{ + NSString *reason = + message.length > 0 ? message : @"Intentional PlaygroundCrash crash"; + RCTLogInfo(@"[PlaygroundCrash] %@", reason); + + NSThread *thread = [[NSThread alloc] initWithBlock:^{ + @throw [NSException exceptionWithName:@"PlaygroundCrash" + reason:reason + userInfo:nil]; + }]; + thread.name = @"PlaygroundCrash"; + [thread start]; + + [NSThread sleepForTimeInterval:10.0]; +} + +- (NSNumber *)crashHandled:(NSString *)message +{ + NSString *reason = message.length > 0 + ? message + : @"Intentional PlaygroundCrash handled error"; + RCTLogInfo(@"[PlaygroundCrash] handled %@", reason); + + @throw [NSException exceptionWithName:@"PlaygroundCrash" + reason:reason + userInfo:nil]; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"PlaygroundCrash"; +} + +@end diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock index 1a535f22..75de26b6 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.1.0): + - HarnessUI (1.2.0): - boost - DoubleConversion - fast_float @@ -2691,7 +2691,7 @@ SPEC CHECKSUMS: FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - HarnessUI: 01740b858c62c55d42995d4ca459ead036b96c9a + HarnessUI: c5f2b106cfb3944569b791515e304b5e96d63bb6 hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 NitroImage: dfec7a8d5e6ba8228ed780bc70041e762cbbbd0b NitroModules: b24827b7772f5a030aef074547a2393a6e03579e @@ -2758,7 +2758,7 @@ SPEC CHECKSUMS: React-utils: abf37b162f560cd0e3e5d037af30bb796512246d React-webperformancenativemodule: 50a57c713a90d27ae3ab947a6c9c8859bcb49709 ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176 - ReactCodegen: 65ae48ae967a383859da021028e6e8dd7b2d97d1 + ReactCodegen: 7cbd647ef54597eb03252f261ce11338f72c1576 ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 VisionCamera: 889238ad98665463fcc2fa44385614979263cfc7 diff --git a/apps/playground/package.json b/apps/playground/package.json index 0daa879f..71478b8a 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -2,6 +2,19 @@ "name": "@react-native-harness/playground", "version": "0.0.1", "private": true, + "codegenConfig": { + "name": "PlaygroundCrashSpec", + "type": "modules", + "jsSrcsDir": "src/specs", + "android": { + "javaPackageName": "com.harnessplayground" + }, + "ios": { + "modulesProvider": { + "PlaygroundCrash": "PlaygroundCrash" + } + } + }, "scripts": { "test:harness": "jest --selectProjects react-native-harness" }, diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 0b278b46..559f19f8 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -79,7 +79,7 @@ export default { }), applePlatform({ name: 'ios', - device: appleSimulator('iPhone 17 Pro', '26.4'), + device: appleSimulator('iPhone 17 Pro', '26.2'), bundleId: 'com.harnessplayground', }), applePlatform({ diff --git a/apps/playground/src/__tests__/crash-monitor/crash-during-evaluation.harness.ts b/apps/playground/src/__tests__/crash-monitor/crash-during-evaluation.harness.ts new file mode 100644 index 00000000..2ff19d9a --- /dev/null +++ b/apps/playground/src/__tests__/crash-monitor/crash-during-evaluation.harness.ts @@ -0,0 +1,13 @@ +import { describe, it } from 'react-native-harness'; +import { Platform } from 'react-native'; +import PlaygroundCrash from '../../native/PlaygroundCrash'; + +PlaygroundCrash.crash( + `HARNESS_CRASH_MONITOR_EVALUATION_CRASH platform=${Platform.OS}` +); + +describe('Crash monitor: crash during test file evaluation', () => { + it('should never run because the test file crashes before registration', () => { + throw new Error('This test should not run after an evaluation crash'); + }); +}); diff --git a/apps/playground/src/__tests__/crash-monitor/crash-during-it.harness.ts b/apps/playground/src/__tests__/crash-monitor/crash-during-it.harness.ts new file mode 100644 index 00000000..ae28a417 --- /dev/null +++ b/apps/playground/src/__tests__/crash-monitor/crash-during-it.harness.ts @@ -0,0 +1,13 @@ +import { describe, it } from 'react-native-harness'; +import { Platform } from 'react-native'; +import PlaygroundCrash from '../../native/PlaygroundCrash'; + +describe('Crash monitor: crash during test execution', () => { + it('crashes the native app from inside an it clause', () => { + PlaygroundCrash.crash( + `HARNESS_CRASH_MONITOR_IT_CRASH platform=${Platform.OS}` + ); + + throw new Error('This line should not run after the native crash'); + }); +}); diff --git a/apps/playground/src/__tests__/logbox-disabled.harness.ts b/apps/playground/src/__tests__/logbox-disabled.harness.ts new file mode 100644 index 00000000..87c97db7 --- /dev/null +++ b/apps/playground/src/__tests__/logbox-disabled.harness.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, isLogBoxSuppressed } from 'react-native-harness'; +import { + LogBox, + Platform, + type TurboModule, + TurboModuleRegistry, +} from 'react-native'; +import PlaygroundCrash from '../native/PlaygroundCrash'; + +const HANDLED_ERROR_MARKER = 'HARNESS_LOGBOX_HANDLED_NATIVE_ERROR'; +const CONSOLE_PROBE_MARKER = 'HARNESS_LOGBOX_CONSOLE_PROBE'; + +type NativeLogBoxModule = TurboModule & { + show: () => void; + hide: () => void; +}; + +type HarnessLogBox = typeof LogBox & { + addException: (error: unknown) => void; +}; + +describe('LogBox disabled for harness', () => { + it('suppresses LogBox UI during harness runs', () => { + expect(isLogBoxSuppressed()).toBe(true); + }); + + it('noops LogBox.addException so errors are not shown in-app', () => { + const harnessLogBox = LogBox as HarnessLogBox; + + expect(() => + harnessLogBox.addException({ + message: 'LogBox probe — should not open UI', + stack: [], + id: 0, + isFatal: true, + extraData: {}, + }), + ).not.toThrow(); + }); + + it('noops the native LogBox TurboModule when linked', () => { + const nativeLogBox = + TurboModuleRegistry.get('LogBox'); + + if (nativeLogBox == null) { + return; + } + + expect(() => nativeLogBox.show()).not.toThrow(); + expect(() => nativeLogBox.hide()).not.toThrow(); + }); + + it('surfaces handled native errors on the sync Turbo Module path', () => { + const marker = `${HANDLED_ERROR_MARKER} platform=${Platform.OS}`; + + expect(() => PlaygroundCrash.crashHandled(marker)).toThrow( + new RegExp(HANDLED_ERROR_MARKER), + ); + }); + + it('still forwards explicit console.error calls (Metro client_log path)', () => { + const consoleErrors: unknown[][] = []; + const originalConsoleError = console.error; + + console.error = (...args: unknown[]) => { + consoleErrors.push(args); + originalConsoleError.apply(console, args); + }; + + console.error(CONSOLE_PROBE_MARKER); + console.error = originalConsoleError; + + expect( + consoleErrors.some((args) => + String(args[0] ?? '').includes(CONSOLE_PROBE_MARKER), + ), + ).toBe(true); + }); +}); diff --git a/apps/playground/src/native/PlaygroundCrash.ts b/apps/playground/src/native/PlaygroundCrash.ts new file mode 100644 index 00000000..597e2048 --- /dev/null +++ b/apps/playground/src/native/PlaygroundCrash.ts @@ -0,0 +1 @@ +export { default } from '../specs/NativePlaygroundCrash'; diff --git a/apps/playground/src/specs/NativePlaygroundCrash.ts b/apps/playground/src/specs/NativePlaygroundCrash.ts new file mode 100644 index 00000000..7653302d --- /dev/null +++ b/apps/playground/src/specs/NativePlaygroundCrash.ts @@ -0,0 +1,10 @@ +import { TurboModuleRegistry, type TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + /** Uncaught native crash on a background thread; void Turbo Module returns before the throw. */ + crash(message: string): void; + /** Throws on the sync Turbo Module path (RN → JS error), without a background crash. */ + crashHandled(message: string): boolean; +} + +export default TurboModuleRegistry.getEnforcing('PlaygroundCrash'); diff --git a/docs/adr/0001-app-lifecycle-crash-monitor.md b/docs/adr/0001-app-lifecycle-crash-monitor.md new file mode 100644 index 00000000..21edf353 --- /dev/null +++ b/docs/adr/0001-app-lifecycle-crash-monitor.md @@ -0,0 +1,1430 @@ +# ADR 0001: App Lifecycle Crash Monitor + +Date: 2026-05-20 + +Status: Proposed + +## Context + +React Native Harness needs to re-implement native crash detection from scratch. The replacement must be cross-platform, but it must not treat Android, iOS Simulator, and physical iOS devices as equivalent systems. The platform tooling provides different evidence, different latency, and different levels of confidence. + +The current Harness launch architecture already has the right high-level shape: + +- `packages/jest` orchestrates Metro, platform runner creation, app readiness, app restarts, test execution, crash races, and teardown. +- `packages/platforms` defines the shared `HarnessPlatformRunner` and monitor-facing types. +- `packages/platform-android` owns Android target resolution and app launch through `adb`. +- `packages/platform-ios` owns iOS Simulator launch through `simctl` and physical-device launch through `devicectl`. + +The new crash monitor should preserve this ownership. The Jest layer may know that an app launch, restart, stop, or test execution phase is happening, but it must not know how `adb`, `simctl`, or `devicectl` work. Platform packages may know platform tools, but they should expose them through a small shared monitor contract. + +We also agreed to avoid optional monitor metadata that forces orchestration code to branch. Instead, every platform runner should provide an `AppLifecycleMonitor` implementation. When monitoring is disabled or unsupported, the platform should return a noop monitor implementing the same interface. + +## Decision + +Implement crash detection as a platform-specific set of `AppLifecycleMonitor` implementations behind one shared interface. + +`packages/jest` will drive the monitor linearly: + +1. Create the platform runner. +2. Create the app monitor. +3. Start the monitor before the run. +4. Notify the monitor around app launch, restart, and stop operations. +5. Race test execution and startup readiness against `monitor.watch(...)`. +6. Stop or reset the monitor around controlled restarts. +7. Dispose the monitor during session teardown. + +Platform packages will return concrete monitor implementations: + +- Android: `AndroidAppLifecycleMonitor` +- iOS Simulator: `IosSimulatorAppLifecycleMonitor` +- iOS physical device: `IosDeviceAppLifecycleMonitor` +- Disabled or unsupported: `NoopAppLifecycleMonitor` + +The shared monitor contract will be non-optional. The orchestration layer always talks to a monitor. Some monitors observe nothing. + +## Goals + +- Detect native and runtime crashes with low latency when platform tooling supports it. +- Preserve high-confidence confirmation through process state and crash artifacts. +- Keep `packages/jest` platform-neutral. +- Keep platform command details inside platform packages. +- Keep monitor usage linear, with no optional `monitorTarget` checks. +- Improve testability by separating lifecycle orchestration, evidence collection, correlation, and artifact persistence. +- Preserve `detectNativeCrashes` as the user-facing toggle, but implement it by choosing a real or noop monitor. + +## Non-Goals + +- Do not build a single generic "process died means crash" engine. +- Do not make physical iOS devices pretend to have Android-style realtime crash streams. +- Do not require native app instrumentation for the baseline implementation. +- Do not couple Jest orchestration to `adb`, `simctl`, `devicectl`, or platform command arguments. +- Do not use the old crash monitor implementation as a design constraint. +- Do not upload raw crash artifacts to external services by default. +- Do not rely on native app changes such as Android `ApplicationExitInfo` bridges or iOS Darwin notification heartbeats in the baseline. The app should be treated as a black box from the native side. +- Do not perform symbolication in v1. Persist raw platform artifacts and summarize them enough to identify the crash. + +## Architecture + +```mermaid +flowchart TD + A["packages/jest Harness session"] --> B["HarnessPlatformRunner"] + A --> C["AppLifecycleMonitor interface"] + + B --> D["platform-android runner"] + B --> E["platform-ios simulator runner"] + B --> F["platform-ios device runner"] + + D --> G["AndroidAppLifecycleMonitor"] + E --> H["IosSimulatorAppLifecycleMonitor"] + F --> I["IosDeviceAppLifecycleMonitor"] + + G --> J["adb logcat"] + G --> K["adb shell pidof"] + G --> L["tombstone / ANR artifact fetch"] + + H --> M["simctl spawn log stream"] + H --> N["simulator process state"] + H --> O["host DiagnosticReports watcher"] + + I --> P["devicectl JSON commands"] + I --> Q["device process state"] + I --> R["systemCrashLogs artifact fetch"] + I --> S["sysdiagnose fallback"] + + C --> T["shared correlator"] + T --> U["crash events"] + U --> V["CrashArtifactWriter"] +``` + +The shared interface is intentionally small. It models lifecycle notifications, crash watches, and resource management. It does not expose platform-specific command handles. + +## Shared Interfaces + +The exact names may change during implementation, but the shape should remain stable. + +```ts +export type AppLifecyclePhase = 'startup' | 'execution'; + +export type AppLifecycleEventBase = { + launchId: string; + at: number; +}; + +export type LaunchRequestedEvent = AppLifecycleEventBase & { + type: 'launch_requested'; + reason: 'start' | 'restart' | 'ensure_ready'; +}; + +export type LaunchCompletedEvent = AppLifecycleEventBase & { + type: 'launch_completed'; + reason: 'start' | 'restart' | 'ensure_ready'; +}; + +export type LaunchFailedEvent = AppLifecycleEventBase & { + type: 'launch_failed'; + reason: 'start' | 'restart' | 'ensure_ready'; + error: unknown; +}; + +export type StopRequestedEvent = { + type: 'stop_requested'; + at: number; + reason: 'restart' | 'dispose' | 'coverage' | 'manual'; +}; + +export type StopCompletedEvent = { + type: 'stop_completed'; + at: number; + reason: 'restart' | 'dispose' | 'coverage' | 'manual'; +}; + +export type CrashWatch = { + promise: Promise; + cancel: () => void; +}; + +export type AppLifecycleMonitor = { + start: () => Promise; + stop: () => Promise; + dispose: () => Promise; + + launchRequested: (event: LaunchRequestedEvent) => void; + launchCompleted: (event: LaunchCompletedEvent) => void; + launchFailed: (event: LaunchFailedEvent) => void; + stopRequested: (event: StopRequestedEvent) => void; + stopCompleted: (event: StopCompletedEvent) => void; + + watch: (testFilePath: string, phase: AppLifecyclePhase) => CrashWatch; + reset: () => void; + isAlive: () => boolean; +}; +``` + +The noop implementation must implement the same contract: + +```ts +export const createNoopAppLifecycleMonitor = (): AppLifecycleMonitor => ({ + start: async () => undefined, + stop: async () => undefined, + dispose: async () => undefined, + + launchRequested: () => undefined, + launchCompleted: () => undefined, + launchFailed: () => undefined, + stopRequested: () => undefined, + stopCompleted: () => undefined, + + watch: () => ({ + promise: new Promise(() => undefined), + cancel: () => undefined, + }), + + reset: () => undefined, + isAlive: () => true, +}); +``` + +The platform runner interface should keep a single creation method: + +```ts +export type CreateAppMonitorOptions = { + crashArtifactWriter?: CrashArtifactWriter; +}; + +export type HarnessPlatformRunner = { + startApp: (options?: AppLaunchOptions) => Promise; + restartApp: (options?: AppLaunchOptions) => Promise; + stopApp: () => Promise; + dispose: () => Promise; + isAppRunning: () => Promise; + createAppMonitor: (options?: CreateAppMonitorOptions) => AppLifecycleMonitor; +}; +``` + +There should be no optional `monitorTarget` property. Target information is captured by the platform implementation when it constructs the monitor. + +## Platform Monitor Construction + +Android platform instances already resolve: + +- `adbId` +- `bundleId` +- `activityName` +- `appUid` + +They should construct either: + +```ts +createAndroidAppLifecycleMonitor({ + adbId, + bundleId, + appUid, + isAppRunning: () => adb.isAppRunning(adbId, bundleId), + crashArtifactWriter, +}); +``` + +or a noop monitor when `detectNativeCrashes === false`. + +iOS Simulator platform instances already resolve: + +- `udid` +- `bundleId` + +They should construct either: + +```ts +createIosSimulatorAppLifecycleMonitor({ + udid, + bundleId, + isAppRunning: () => simctl.isAppRunning(udid, bundleId), + crashArtifactWriter, +}); +``` + +or a noop monitor when disabled. + +iOS physical-device platform instances already resolve: + +- CoreDevice `deviceId` +- hardware `udid` +- `bundleId` + +They should construct either: + +```ts +createIosDeviceAppLifecycleMonitor({ + deviceId, + hardwareUdid: device.hardwareProperties.udid, + bundleId, + isAppRunning: () => devicectl.isAppRunning(deviceId, bundleId), + crashArtifactWriter, +}); +``` + +or a noop monitor when disabled. + +## Jest Dependency Chain + +The Jest package should remain the lifecycle conductor. + +Current dependency chain: + +```txt +packages/jest + imports platform runner dynamically + creates platformInstance + creates crashArtifactWriter + creates appMonitor through platformInstance.createAppMonitor() + starts appMonitor before run + uses crashMonitor/watch while waiting for readiness and test execution + stops/resets around restarts + disposes monitor during session teardown +``` + +Target dependency chain: + +```txt +packages/jest + depends on: + packages/platforms types + platformInstance.createAppMonitor() + AppLifecycleMonitor methods + +packages/platforms + depends on: + shared type definitions only + +packages/platform-android + depends on: + adb helpers + Android logcat/process/artifact collectors + shared AppLifecycleMonitor type + +packages/platform-ios + depends on: + simctl helpers + devicectl helpers + iOS log/process/artifact collectors + shared AppLifecycleMonitor type + +packages/tools + depends on: + filesystem artifact persistence +``` + +`packages/jest` must not import `adb`, `simctl`, or `devicectl`. + +## Jest Orchestration Rules + +The app lifecycle monitor should receive notifications around all controlled app lifecycle operations. + +For direct launch: + +```ts +const launchId = randomUUID(); +monitor.launchRequested({ + type: 'launch_requested', + launchId, + at: Date.now(), + reason: 'start', +}); + +try { + await platformInstance.startApp(appLaunchOptions); + monitor.launchCompleted({ + type: 'launch_completed', + launchId, + at: Date.now(), + reason: 'start', + }); +} catch (error) { + monitor.launchFailed({ + type: 'launch_failed', + launchId, + at: Date.now(), + reason: 'start', + error, + }); + throw error; +} +``` + +For restart: + +```ts +await monitor.stop(); + +monitor.stopRequested({ + type: 'stop_requested', + at: Date.now(), + reason: 'restart', +}); + +await platformInstance.stopApp(); + +monitor.stopCompleted({ + type: 'stop_completed', + at: Date.now(), + reason: 'restart', +}); + +monitor.reset(); +await monitor.start(); + +const launchId = randomUUID(); +monitor.launchRequested({ + type: 'launch_requested', + launchId, + at: Date.now(), + reason: 'restart', +}); + +await platformInstance.startApp(appLaunchOptions); + +monitor.launchCompleted({ + type: 'launch_completed', + launchId, + at: Date.now(), + reason: 'restart', +}); +``` + +When the existing `platformInstance.restartApp(...)` method is used directly, Jest should still emit one logical stop window and one logical launch window around the call: + +```ts +const launchId = randomUUID(); + +monitor.stopRequested({ type: 'stop_requested', at: Date.now(), reason: 'restart' }); +monitor.launchRequested({ type: 'launch_requested', launchId, at: Date.now(), reason: 'restart' }); + +try { + await platformInstance.restartApp(appLaunchOptions); + monitor.stopCompleted({ type: 'stop_completed', at: Date.now(), reason: 'restart' }); + monitor.launchCompleted({ type: 'launch_completed', launchId, at: Date.now(), reason: 'restart' }); +} catch (error) { + monitor.launchFailed({ type: 'launch_failed', launchId, at: Date.now(), reason: 'restart', error }); + throw error; +} +``` + +For test execution: + +```ts +const crashWatch = monitor.watch(testPath, 'execution'); +crashWatch.promise.catch(() => undefined); + +try { + return await Promise.race([ + conn.runTests(testPath, { ...options, runner: platform.runner }), + crashWatch.promise, + ]); +} finally { + crashWatch.cancel(); +} +``` + +For startup readiness: + +```ts +const crashWatch = monitor.watch(testPath, 'startup'); +crashWatch.promise.catch(() => undefined); + +try { + return await Promise.race([ + waitForBridgeReady(), + crashWatch.promise, + ]); +} finally { + crashWatch.cancel(); +} +``` + +## Shared Detection Model + +All real monitors should normalize evidence into the same conceptual model. + +```ts +type CrashSignal = { + id: string; + platform: 'android' | 'ios-simulator' | 'ios-device'; + kind: + | 'java-exception' + | 'native-crash' + | 'anr' + | 'watchdog' + | 'process-exit' + | 'crash-report' + | 'device-offline' + | 'unknown'; + confidence: 'low' | 'medium' | 'high'; + occurredAt: number; + launchId?: string; + pid?: number; + processName?: string; + summary?: string; + rawLines?: string[]; + artifactPath?: string; +}; +``` + +The monitor should emit two internal stages: + +- `crashSuspected`: first strong signal, optimized for low latency. +- `crashConfirmed`: corroborated by process exit, crash artifact, tombstone, ANR trace, or platform exit reason. + +The public `watch(...)` promise should reject with the existing runtime failure shape used by Jest, for example `NativeCrashError`, once the monitor has enough evidence to report a crash. Monitors should wait for a short 1 to 3 second correlation window before rejecting so related evidence can be gathered and attached to the failure. If a suspected crash is not corroborated, the monitor may keep it as degraded evidence or report a warning, but it should avoid failing tests on weak evidence alone except for the physical iOS policy described below. + +## Correlation Model + +Use an instance key instead of bare PID. + +Android instance key: + +```txt +(adbId, bundleId, pid, firstSeenMonotonic) +``` + +iOS Simulator instance key: + +```txt +(udid, bundleId, launchId or launchEpoch) +``` + +iOS device instance key: + +```txt +(deviceId, bundleId, launchId or crashTimestampBucket) +``` + +Correlation rules: + +1. Open a suspicion window when the first fatal signal arrives. +2. Collect related evidence for 1 to 3 seconds. +3. Confirm if process loss, restart, exit reason, crash file, tombstone, ANR trace, or device crash log appears. +4. Emit one consolidated crash failure. +5. Suppress duplicates until a new app instance starts or a cooldown expires. + +Controlled stops must not be reported as crashes. Jest should call `stopRequested` before controlled stops, and monitors should suppress process-exit evidence during that window. + +## Android Commands + +The Android launcher currently starts apps with: + +```bash +adb -s shell am start \ + -a android.intent.action.MAIN \ + -c android.intent.category.LAUNCHER \ + -n / +``` + +Launch extras: + +```bash +--es +--ez true|false +--ei +``` + +Controlled stop: + +```bash +adb -s shell am force-stop +``` + +Process check: + +```bash +adb -s shell pidof +``` + +Realtime crash stream: + +```bash +adb -s logcat -b crash -b main -b system -v threadtime +``` + +Optional event buffer: + +```bash +adb -s logcat -b crash -b main -b system -b events -v threadtime +``` + +Native tombstone retrieval when accessible: + +```bash +adb -s shell ls -t /data/tombstones +adb -s pull /data/tombstones/ +``` + +ANR retrieval when accessible: + +```bash +adb -s shell ls -t /data/anr +adb -s pull /data/anr/ +``` + +## Android Detection Logic + +The Android monitor should start a long-lived logcat subprocess and a process poller. + +Recommended collectors: + +- `LogcatSession` +- `AndroidProcessPoller` +- `AndroidArtifactFetcher` +- optional `ApplicationExitInfo` app-side bridge later + +High-confidence logcat signals: + +- tag `AndroidRuntime` with message containing `FATAL EXCEPTION` +- debuggerd or tombstone banners +- native abort markers +- visible ActivityManager crash markers +- ANR markers such as `am_anr` when the events buffer is enabled + +Process polling: + +- Poll `pidof ` at a modest interval, for example 250 to 500 ms during active test execution. +- Treat PID disappearance as neutral by itself. +- Treat PID disappearance inside a suspicion window as confirming evidence. +- Do not require PID disappearance to confirm a Java/Kotlin crash. On physical devices, the system crash dialog can keep the crashed app process visible after `AndroidRuntime` and `am_crash` have already identified the crash. +- Treat immediate PID replacement as a restart and use the instance key to avoid merging old and new evidence. +- Suppress PID disappearance during a controlled stop window opened by `stopRequested`. + +Suggested Android flow: + +```ts +onLogcatLine(line) { + const record = parseThreadtime(line); + ringBuffer.push(line); + + if (record.tag === 'AndroidRuntime' && record.message.includes('FATAL EXCEPTION')) { + suspectCrash({ kind: 'java-exception', pid: record.pid, confidence: 'high' }); + openEvidenceWindow({ durationMs: 1000 }); + } + + if (looksLikeActivityManagerCrash(line)) { + confirmCrash({ + reason: 'activity-manager-crash-record', + kind: 'java-exception', + confidence: 'high', + }); + fetchFastArtifacts(); + } + + if (looksLikeDebuggerdBanner(line) || looksLikeNativeAbort(line)) { + suspectCrash({ kind: 'native-crash', pid: record.pid, confidence: 'high' }); + } + + if (looksLikeAnrEvent(line)) { + suspectCrash({ kind: 'anr', confidence: 'medium' }); + } +} + +onPidPoll(nextPid) { + if (controlledStopWindow.isOpen()) { + updateCurrentPid(nextPid); + return; + } + + if (currentPid && !nextPid && suspicionWindow.hasRecentSignal()) { + confirmCrash({ reason: 'process-exit-after-fatal-signal' }); + fetchFastArtifacts(); + return; + } + + if (currentPid && !nextPid) { + recordGoneUnknown(); + fetchExitReasonFallbackIfAvailable(); + return; + } + + if (!currentPid && nextPid) { + markAppStarted({ pid: nextPid }); + } +} +``` + +Android artifact policy: + +- Always persist the relevant logcat ring buffer for confirmed crashes. +- For Java/Kotlin crashes, persist the `AndroidRuntime` stack excerpt and nearby `am_crash` / ActivityManager lines. This is v1's primary Android diagnostic artifact. +- Try tombstones for suspected native crashes. +- Try ANR traces for suspected ANRs. +- Treat artifact fetch failure as a warning, not as a monitor failure, because production devices often block `/data/anr` and `/data/tombstones`. + +## Android Emulator Crash Experiment + +The playground app has DEBUG-only Android crash modes in `apps/playground/android/app/src/main/java/com/harnessplayground/MainActivity.kt`. Unlike iOS, the mode is supplied through an intent extra: + +```bash +adb -s shell am start \ + -a android.intent.action.MAIN \ + -c android.intent.category.LAUNCHER \ + -n com.harnessplayground/.MainActivity \ + --es harness_crash_mode pre_rn +``` + +Relevant modes: + +- `pre_rn`: throws `IllegalStateException("Intentional pre-RN startup crash")` before React Native startup. +- `delayed_pre_ready`: schedules `IllegalStateException("Intentional delayed startup crash")` after roughly 1 second. + +Observed on 2026-05-20: + +- Device: Pixel_8_API_35 emulator, Android 15 / API 35, `emulator-5554`. +- App: `com.harnessplayground`, debug APK. +- Log source: `adb logcat -b crash -b main -b system -b events -v threadtime`. +- Process source: `adb shell pidof com.harnessplayground`. + +Results: + +- `pre_rn`: process first observed after 261 ms; `AndroidRuntime` `FATAL EXCEPTION` logged 404 ms after ActivityTaskManager start; PID disappeared after 531 ms. +- `delayed_pre_ready`: process first observed after 259 ms; `AndroidRuntime` `FATAL EXCEPTION` logged 1599 ms after ActivityTaskManager start; PID disappeared after 1812 ms. +- `am_crash` / `Force finishing activity` appeared in logcat for both crashes. +- No new tombstone files were created, as expected for Java/Kotlin exceptions. +- `/data/anr` and `/data/tombstones` were accessible on this emulator, but no new ANR/tombstone artifact was relevant to these Java crash cases. + +Experiment notes: + +- For Java/Kotlin crashes, logcat is the best first signal. PID disappearance follows shortly, but it should be corroborating evidence rather than the primary detector. +- The events buffer can contain older `am_crash` records if the buffer is not fully cleared or if a monitor starts mid-run. Long-lived monitoring should prefer monotonic stream position and launch-time filtering over naive "first matching line in dump" parsing. +- For test scripts that clear buffers, clear the same buffers the monitor reads, for example `adb logcat -b crash -b main -b system -b events -c`, or use `-T` / launch-time filtering. +- Android may show an app crash dialog, but unlike Simulator the fatal log evidence arrives before any human action is needed. The monitor must ignore the dialog and rely on logcat/process evidence. + +## Physical Android Crash Experiment + +The same playground Android crash modes were validated on a connected physical device. + +Observed on 2026-05-20: + +- Device: physical Android phone, Android 13 / API 33. +- App: `com.harnessplayground`, debug APK. +- Log source: `adb logcat -b crash -b main -b system -b events -v threadtime`. +- Process source: `adb shell pidof com.harnessplayground`. +- Artifact probes: `adb shell ls -t /data/anr` and `adb shell ls -t /data/tombstones`. + +Results: + +- `pre_rn`: process first observed after 418 ms; `AndroidRuntime` `FATAL EXCEPTION` logged 829 ms after ActivityTaskManager start; `am_crash` logged after 839 ms; ActivityManager process death logged after 918 ms; PID disappeared after 1138 ms. +- `delayed_pre_ready`: process first observed after 387 ms; `AndroidRuntime` `FATAL EXCEPTION` logged 1950 ms after ActivityTaskManager start; `am_crash` logged after 1955 ms; PID was still visible after the polling window. +- `pre_rn` stack excerpt included `IllegalStateException: Intentional pre-RN startup crash`, `MainActivity.kt:20`, and `MainActivity.kt:38`. +- `delayed_pre_ready` stack excerpt included `IllegalStateException: Intentional delayed startup crash` and `MainActivity.kt:31`. +- `/data/anr` was listable on this device, but no new ANR artifact was produced for these Java crashes. +- `/data/tombstones` was not accessible: `Permission denied`. +- No new tombstones were produced, as expected for Java/Kotlin exceptions. + +Experiment notes: + +- Physical Android confirmed that logcat alone can provide actionable raw stack traces without symbolication. +- `AndroidRuntime` plus `am_crash` should be confirmation-grade evidence for Java/Kotlin crashes. Waiting for PID disappearance alone is too strict because the OS crash dialog can leave the process visible. +- PID disappearance remains useful corroboration, and it remains important for unknown exits, native crashes, and restarts. +- Production-like physical devices may block tombstones, so the monitor must not depend on `/data/tombstones` access for v1 Java crash reporting. +- The v1 Android report should include the exception class/message, process/PID, app stack frames, `am_crash`, and nearby ActivityManager lines from the ring buffer. + +## iOS Simulator Commands + +Simulator discovery: + +```bash +xcrun simctl list devices --json +``` + +Boot: + +```bash +xcrun simctl boot +xcrun simctl bootstatus -b +``` + +Install: + +```bash +xcrun simctl install +``` + +Launch: + +```bash +xcrun simctl launch [...arguments] +``` + +Launch environment: + +```txt +SIMCTL_CHILD_= +``` + +Terminate: + +```bash +xcrun simctl terminate +``` + +Recommended realtime log stream: + +```bash +xcrun simctl spawn log stream \ + --style json \ + --level debug \ + --predicate 'process == "" OR process == "" OR subsystem == ""' +``` + +Fallback stream if JSON style is unavailable: + +```bash +xcrun simctl spawn log stream \ + --style compact \ + --level info \ + --predicate '' +``` + +Simulator diagnostic fallback: + +```bash +xcrun simctl diagnose --udid= --no-archive --output= -b +``` + +Host crash report location to watch: + +```txt +~/Library/Logs/DiagnosticReports +``` + +## iOS Simulator Detection Logic + +The simulator monitor should use three signal sources: + +- unified log stream through `simctl spawn log stream` +- app process state through existing simulator helpers +- host crash report file watcher + +Recommended collectors: + +- `SimctlUnifiedLogStream` +- `IosSimulatorProcessPoller` +- `DiagnosticReportsWatcher` +- optional `SimctlDiagnoseCollector` + +Signals: + +- fatal-looking unified log records +- abort/watchdog/native exception terms in logs +- app process disappearance after a fatal-looking record or immediately after a Harness-controlled launch +- new `.ips` or `.crash` report in `~/Library/Logs/DiagnosticReports` +- simulator diagnostic bundle containing a matching crash report + +Crash report matching: + +- Prefer filename prefix matching by process name when available. +- Parse `.ips` and `.crash` contents. +- Confirm reports that mention the simulator UDID or otherwise match the current launch time and process. +- Filter reports older than the current launch or current run timestamp. + +Suggested iOS Simulator flow: + +```ts +onSimLogRecord(record) { + ringBuffer.push(record); + + if (looksFatal(record) || looksAbort(record) || looksWatchdog(record)) { + suspectCrash({ + platform: 'ios-simulator', + kind: classifyIosFatal(record), + confidence: 'medium', + }); + } +} + +onCrashFileCreated(path) { + const parsed = parseCrashReport(path); + + if (!matchesBundleOrProcess(parsed)) { + return; + } + + if (!matchesCurrentSimulator(parsed, udid)) { + return; + } + + confirmCrash({ + kind: 'crash-report', + confidence: 'high', + artifactPath: persist(path), + }); +} + +onProcessPoll(isRunning) { + if (controlledStopWindow.isOpen()) { + return; + } + + if (!isRunning && suspicionWindow.hasRecentSignal()) { + confirmCrash({ reason: 'process-exit-after-fatal-signal' }); + } + + if (!isRunning && currentLaunchIsRecent()) { + suspectCrash({ + platform: 'ios-simulator', + kind: 'process-exit', + confidence: 'medium', + }); + waitForCrashReport({ graceMs: 3000 }); + } +} +``` + +The simulator path should usually be able to produce a low-latency `crashSuspected` event from process disappearance and a high-confidence `crashConfirmed` event from the host crash report. + +## iOS Simulator Artifact Experiment + +The same playground crash modes used for the physical iPhone experiment also work on Simulator through `SIMCTL_CHILD_HARNESS_CRASH_MODE`. + +Experiment command shape: + +```bash +SIMCTL_CHILD_HARNESS_CRASH_MODE=pre_rn \ + xcrun simctl launch com.harnessplayground +``` + +The experiment measured: + +- host PID returned by `simctl launch`; +- time until that PID disappeared from `ps`; +- time until a matching `HarnessPlayground-*.ips` appeared in `~/Library/Logs/DiagnosticReports`; +- whether `simctl spawn log stream` produced useful crash evidence. + +Observed on 2026-05-20: + +- Simulator: iPhone 17 Pro, iOS 26.4.1 runtime. +- Toolchain: Xcode 17E202. +- Crash modes: `pre_rn` and `delayed_pre_ready`. +- Initial post-install `pre_rn` run: crash report appeared after 10943 ms. +- Repeat `pre_rn` run 1: PID disappeared after 938 ms; crash report appeared after 2276 ms. +- Repeat `pre_rn` run 2: PID disappeared after 837 ms; crash report appeared after 2174 ms. +- `delayed_pre_ready` run: PID disappeared after 1917 ms; crash report appeared after 3269 ms. +- `pre_rn` run with the macOS Problem Reporter dialog intentionally left untouched: PID disappeared after 925 ms; crash report appeared after 1599 ms. +- All copied `.ips` files contained `bundleID` / `CFBundleIdentifier` for `com.harnessplayground`. +- `simctl spawn log stream` with process/fatal predicates did not produce useful app crash evidence in these runs. + +The user also observed the macOS Problem Reporter dialog saying the app crashed, with `Ignore` and `Report` actions. The monitor must not rely on this dialog or require a human action. It also should not assume the dialog blocks artifact creation; in these runs, valid `.ips` files appeared while the dialog was visible and while it was intentionally left untouched. + +Initial conclusion: + +- For Simulator, process disappearance is the best low-latency suspected-crash signal. +- Host DiagnosticReports is the best confirmation source. +- Use the same 3 second artifact grace window as physical iOS, but allow late `reportReady` because the first post-install run took about 11 seconds. +- Treat unified log streaming as opportunistic context, not the primary simulator crash detector, until a stronger predicate/stream source is validated. +- The implementation should document or eventually automate crash-dialog suppression for local/CI runs if it becomes disruptive. + +## iOS Physical Device Commands + +Device discovery must use JSON file output: + +```bash +xcrun devicectl list devices --json-output +``` + +Device details: + +```bash +xcrun devicectl device info details \ + --device \ + --json-output +``` + +Installed app lookup: + +```bash +xcrun devicectl device info apps \ + --device \ + --json-output +``` + +Filter the JSON result by `bundleIdentifier` in Node. Do not rely on a `--bundle-id` flag being available across Xcode versions. + +Launch: + +```bash +xcrun devicectl device process launch \ + --device \ + --json-output \ + +``` + +Launch with environment: + +```bash +xcrun devicectl device process launch \ + --device \ + --environment-variables '{"KEY":"VALUE"}' \ + --json-output \ + +``` + +Launch with arguments: + +```bash +xcrun devicectl device process launch \ + --device \ + --json-output \ + \ + +``` + +For `device process launch`, keep all `devicectl` options before ``. Values after `` are treated as app command-line arguments. + +Process list: + +```bash +xcrun devicectl device info processes \ + --device \ + --json-output +``` + +Terminate: + +```bash +xcrun devicectl device process terminate \ + --device \ + --pid \ + --json-output +``` + +List crash logs: + +```bash +xcrun devicectl device info files \ + --device \ + --domain-type systemCrashLogs \ + --recurse \ + --json-output +``` + +Copy crash log: + +```bash +xcrun devicectl device copy from \ + --device \ + --source \ + --destination \ + --domain-type systemCrashLogs \ + --json-output +``` + +For single-file crash logs, pass a full local destination filename. On the tested Xcode 17E202 / `devicectl` 518.27 toolchain, passing an existing directory as `--destination` failed with `Cannot open destination file ... Is a directory`. + +Heavy fallback: + +```bash +xcrun devicectl device sysdiagnose \ + --device \ + --json-output +``` + +The command shape should be validated against the locally installed Xcode's `xcrun devicectl help`. Scripts must consume `--json-output` files instead of scraping stdout. + +## iOS Physical Device Detection Logic + +Physical iOS device support is intentionally conservative. The monitor should not assume a stable generic realtime crash stream equivalent to Android logcat. + +Recommended collectors: + +- `DevicectlProcessPoller` +- `IosDeviceCrashLogCollector` +- optional `DevicectlLaunchResultTracker` +- optional `DarwinNotificationHeartbeat` if an app-assisted heartbeat is added later +- optional `DevicectlSysdiagnoseCollector` + +Signals: + +- app process disappears after a launch and outside a controlled stop window +- a matching crash log appears in `systemCrashLogs` +- `devicectl process launch` JSON provides launch/process metadata +- optional heartbeat disappears in app-assisted mode +- sysdiagnose contains matching crash reports after escalation + +Process matching: + +- Get app info for `bundleId`. +- Use app `url` from `devicectl device info apps`. +- List running processes. +- Match processes whose executable starts with the app `url`. + +Suggested physical iOS flow: + +```ts +onLaunchCompleted(event) { + currentLaunchId = event.launchId; + launchCompletedAt = event.at; + pollProcessSoon(); + scheduleCrashLogSweep({ afterMs: 1000 }); +} + +onProcessPoll(process) { + if (controlledStopWindow.isOpen()) { + return; + } + + if (lastKnownRunning && !process) { + suspectCrash({ + platform: 'ios-device', + kind: 'process-exit', + confidence: 'low', + }); + + collectCrashLogs({ minOccurredAt: launchCompletedAt }); + } +} + +onCrashLogCollected(report) { + if (!matchesBundleOrProcess(report)) { + return; + } + + if (report.occurredAt < launchCompletedAt - toleranceMs) { + return; + } + + confirmCrash({ + platform: 'ios-device', + kind: 'crash-report', + confidence: 'high', + artifactPath: persist(report.path), + }); +} +``` + +Physical iOS confidence rules: + +- Process disappearance alone is low confidence. +- Process disappearance plus matching crash log is high confidence. +- Matching crash log near the current launch window is high confidence even if process polling missed the transition. +- Missing crash logs should not cause an immediate failure. The monitor should wait for a crash artifact for a bounded grace period because physical-device crash log sync can lag behind process exit. +- If a physical iOS process disappears outside a controlled stop window and a matching crash artifact does not arrive before the bounded grace period expires, the monitor may fail the active watch with degraded process-exit evidence. If a matching artifact arrives later, it should be persisted and surfaced as a report update. +- Based on the initial physical iPhone experiment below, start with a 3 second artifact grace window for v1. This lines up with the shared 1 to 3 second correlation window and still leaves room for a degraded fallback if a device or Xcode version is slower. + +## Physical iPhone Artifact Experiment + +The playground app has DEBUG-only crash modes in `apps/playground/ios/HarnessPlayground/AppDelegate.swift`. The mode is read from `HARNESS_CRASH_MODE` or `--harness-crash-mode=`. + +Relevant modes: + +- `pre_rn`: calls `fatalError("Intentional pre-RN startup crash")` before React Native startup. +- `delayed_pre_ready`: schedules `fatalError("Intentional delayed startup crash")` after roughly 1 second. + +This gives Harness a useful physical-device experiment without requiring native changes in apps under test. The playground is only a test fixture; the production monitor still treats the app as a native black box. + +Experiment command shape: + +```bash +xcrun devicectl device process launch \ + --device \ + --terminate-existing \ + --environment-variables '{"HARNESS_CRASH_MODE":"pre_rn"}' \ + --json-output \ + --log-output \ + --timeout 30 \ + com.harnessplayground +``` + +Then poll: + +```bash +xcrun devicectl device info files \ + --device \ + --domain-type systemCrashLogs \ + --recurse \ + --json-output \ + --log-output \ + --timeout 30 +``` + +And copy: + +```bash +xcrun devicectl device copy from \ + --device \ + --source HarnessPlayground-.ips \ + --destination /HarnessPlayground-.ips \ + --domain-type systemCrashLogs \ + --json-output \ + --log-output \ + --timeout 60 +``` + +Observed on 2026-05-20: + +- Device: physical iPhone, iOS 26.5, app `com.harnessplayground`. +- Toolchain: Xcode 17E202, `devicectl` JSON version 3 / tool version 518.27. +- Crash mode: `HARNESS_CRASH_MODE=pre_rn`. +- Run 1: crash log listed after 1623 ms; copy to explicit filename took 194 ms. +- Run 2: crash log listed after 1271 ms; copy took 151 ms. +- Run 3: crash log listed after 1382 ms; copy took 453 ms. +- All copied `.ips` files contained `bundleID` / `CFBundleIdentifier` for `com.harnessplayground`. + +Initial conclusion: + +- Physical iOS `systemCrashLogs` can be fast enough for startup crash reporting. +- The v1 monitor should poll crash logs soon after launch and process disappearance rather than assuming physical iOS artifacts are slow. +- A 3 second grace period is a reasonable initial default before failing with degraded process-exit evidence. +- Continue surfacing late artifacts if they arrive after the watch has already failed. + +## App Black-Box Policy + +The monitor should treat the app's native layer as unavailable. Harness may influence the JavaScript side of the app through its existing runtime and launch options, but the crash detector must not require native code changes in the app under test. + +Baseline implications: + +- Do not require an Android native bridge for `ApplicationExitInfo`. +- Do not require an iOS native heartbeat or Darwin notification integration. +- Do not require native crash handlers inside the app. +- Prefer host-side platform tools and host-visible artifacts. +- Keep app-assisted hooks as future optional enhancements only if they can be introduced without changing the baseline contract. + +## Plugin Events + +The new monitor should update the plugin event surface rather than keeping crash monitor state entirely internal. + +Existing app-level hooks include: + +- `app:started` +- `app:exited` +- `app:possible-crash` + +The implementation should replace `app:possible-crash` with a clearer event set so plugins can observe monitor states without parsing thrown errors. The event payloads should include the run id, optional test file, lifecycle phase, platform, target identifier, launch id when available, confidence, source, summary, process metadata, and artifact metadata when available. + +Recommended hook model: + +- `app:started`: emitted when a new app instance is observed. +- `app:exited`: emitted when the app process exits or disappears, including whether the exit is controlled, unknown, or crash-related. +- `app:crash-suspected`: emitted for suspected crashes during the correlation window. +- `app:crash-confirmed`: emitted when the monitor has enough evidence to fail the active watch. +- `app:crash-report-ready`: emitted when a crash report, tombstone, ANR trace, or late physical-device crash artifact has been persisted. +- `app:monitor-warning`: emitted for degraded monitoring states such as inaccessible artifacts, delayed physical iOS crash logs, collector fallback, or command capability gaps. + +`app:possible-crash` should be removed from the new event contract. New code should use `app:crash-suspected`. + +Plugin events should be scheduled through the existing hook queue so crash reporting does not bypass normal plugin ordering and abort behavior. + +## Artifact Persistence + +`CrashArtifactWriter` is storage, not detection logic. + +It persists file or text evidence into `.harness/crash-reports` and returns the persisted path: + +```ts +crashArtifactWriter.persistArtifact({ + artifactKind: 'logcat', + source: { + kind: 'text', + fileName: 'android-crash.log', + text: logcatWindow, + }, +}); +``` + +```ts +crashArtifactWriter.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path: reportPath, + }, +}); +``` + +The monitor should use it for: + +- Android logcat windows +- Android tombstones +- Android ANR traces +- iOS `.ips` or `.crash` reports +- iOS diagnostic excerpts + +The monitor should be able to run without a writer, but reports will be less useful. + +## Diagnostic Detail Without Symbolication + +Symbolication is out of scope for v1, but raw stack data is still required. The goal is to point a Harness user toward the failing layer and the most likely source location, not to produce fully symbolicated production crash reports. + +For Android Java/Kotlin crashes, logcat already contains the actionable stack trace: + +- exception class and message, for example `IllegalStateException: Intentional delayed startup crash`; +- process name and PID; +- Java/Kotlin frames, including app frames such as `MainActivity.kt:31`; +- system corroboration such as `am_crash` and `Force finishing activity`. + +For Android native crashes, persist the debuggerd/tombstone text when available. Do not run `ndk-stack` in v1. Even without symbolication, tombstones usually provide signal, fault address, loaded module names, ABI, thread list, and program-counter/module offsets. + +For iOS Simulator and physical iOS device crashes, persist the raw `.ips` or `.crash` file and extract a small summary when possible: + +- app name and bundle id; +- timestamp and incident id; +- exception type / termination reason when present; +- faulting thread id; +- top frames from the faulting thread; +- source file, line, or symbol fields when the report already includes them. + +Do not require dSYMs, `.dSYM` lookup, `atos`, NDK symbols, or source maps in v1. Future work can add optional enrichment, but the first implementation should make crashes actionable using host-visible platform artifacts. + +## Error And Warning Semantics + +Monitor infrastructure failures should be separated from app crashes. + +Examples of warnings: + +- `adb logcat` restarted after disconnect. +- tombstone directory inaccessible. +- ANR directory inaccessible. +- `simctl log stream` unavailable in JSON mode, falling back to compact mode. +- physical iOS crash logs not available yet. +- `devicectl` command shape unsupported by local Xcode. + +Examples of monitor errors: + +- cannot start required baseline collector +- malformed command output from required JSON interface +- repeated collector restart failure beyond configured backoff + +App crash failures should include: + +- platform +- target identifier +- phase: `startup` or `execution` +- test file path +- summary +- signal or exception type when known +- process name and PID when known +- artifact path when available +- short raw evidence window +- extracted raw stack excerpt when available, for example Android `AndroidRuntime` frames or the iOS faulting-thread frames + +## Security And Retention + +Crash artifacts and logs may contain sensitive data. + +The implementation should: + +- store artifacts under `.harness/crash-reports` +- keep crash artifacts append-only for now +- avoid sending raw artifacts outside the local run by default +- deduplicate persisted artifacts +- redact obvious tokens before indexing or displaying summaries +- avoid logging full crash files at normal log levels +- make future retention controls possible, but do not clean or expire old crash artifacts in this implementation + +## Testing Strategy + +Testing should be split by responsibility. + +Jest orchestration tests: + +- use a fake `AppLifecycleMonitor` +- assert linear lifecycle ordering +- assert no optional monitor checks are needed +- assert startup readiness races against `monitor.watch(..., 'startup')` +- assert test execution races against `monitor.watch(..., 'execution')` +- assert controlled restarts call stop/reset/start in the right order +- assert monitor disposal happens during teardown + +Shared monitor tests: + +- suspicion window behavior +- duplicate suppression +- controlled stop suppression +- launch ID correlation +- report assembly +- artifact persistence integration through a fake writer + +Android monitor tests: + +- Java `FATAL EXCEPTION` logcat fixture +- native debuggerd/tombstone fixture +- ANR event fixture +- PID disappearance without crash signal +- PID disappearance inside suspicion window +- immediate PID replacement +- controlled `force-stop` +- inaccessible tombstone/ANR directories +- device disconnect and logcat restart + +iOS Simulator monitor tests: + +- JSON unified log fatal fixture +- compact unified log fallback fixture +- `.ips` crash report matching current simulator UDID +- stale crash report filtering +- process disappearance after fatal log +- controlled `simctl terminate` +- `simctl diagnose` fallback artifact discovery + +iOS physical-device monitor tests: + +- `devicectl` JSON command parsing +- app URL based process matching +- process disappearance without crash log +- matching `systemCrashLogs` report confirmation +- stale crash log filtering +- delayed crash log arrival +- device unplug during artifact copy +- sysdiagnose fallback trigger + +End-to-end scenarios: + +- Android Java exception +- Android native crash +- Android ANR +- Android user force-stop +- Android app restart +- iOS Simulator `fatalError` +- iOS Simulator `abort()` +- iOS Simulator fast relaunch +- physical iOS crash with fast crash log retrieval +- physical iOS crash with delayed crash log retrieval +- physical iOS unplug during retrieval +- plugin hook emission for suspected crash, confirmed crash, late report readiness, and monitor warnings + +## Migration Plan + +1. Add the new shared `AppLifecycleMonitor` interface and noop implementation in `packages/platforms`. +2. Update `packages/jest` to use the lifecycle monitor directly and linearly. +3. Preserve the existing `detectNativeCrashes` toggle by returning noop monitors when disabled. +4. Implement Android monitor from scratch using logcat, PID polling, and artifact fetchers. +5. Implement iOS Simulator monitor from scratch using `simctl` log streaming and host crash report watching. +6. Implement iOS physical-device monitor from scratch using `devicectl` JSON commands, process polling, and crash log retrieval. +7. Add platform fixtures and unit tests for collectors and correlators. +8. Add Jest orchestration tests with a fake monitor. +9. Validate on real Android emulator/device, iOS Simulator, and at least one physical iOS device. +10. Remove old monitor implementation after replacement coverage is in place. + +## Consequences + +Positive: + +- Jest orchestration remains platform-neutral. +- The monitor becomes easier to test because lifecycle events can be faked. +- Platform packages retain ownership of platform command details. +- Disabled monitoring uses the same code path through a noop implementation. +- The design can report degraded capability honestly, especially on physical iOS. + +Negative: + +- More types and lifecycle events are required up front. +- Platform monitors need separate implementations and fixtures. +- Physical iOS detection will remain less immediate than Android and iOS Simulator. +- Some artifact collection paths are best-effort on production devices. + +## Resolved Scope Decisions + +- The app should be treated as a black box from the native side. Harness can influence the JavaScript side, but the baseline monitor must not require native app code changes. +- The monitor should wait 1 to 3 seconds to gather and correlate crash evidence before failing a watch. +- Physical iOS process disappearance can eventually fail a watch, but the monitor should first wait for a matching crash artifact for a bounded grace period. Based on the initial iPhone experiment, use 3 seconds as the v1 default. If the artifact takes too long, fail with degraded process-exit evidence and surface the artifact later if it appears. +- Crash artifacts should be append-only under `.harness/crash-reports`. +- Symbolication is out of scope for v1. +- Plugin events should replace `app:possible-crash` with structured suspected, confirmed, report-ready, and warning events. + +## Remaining Open Questions + +- Should `simctl log stream` default to `--style json` immediately, with compact fallback, or keep compact first for compatibility? diff --git a/packages/cli/src/__tests__/platform-commands.test.ts b/packages/cli/src/__tests__/platform-commands.test.ts index 16dc2663..a375db9a 100644 --- a/packages/cli/src/__tests__/platform-commands.test.ts +++ b/packages/cli/src/__tests__/platform-commands.test.ts @@ -52,7 +52,6 @@ describe('platform CLI command discovery', () => { unstable__enableMetroCache: false, permissions: false, detectNativeCrashes: true, - crashDetectionInterval: 500, disableViewFlattening: false, forwardClientLogs: false, } satisfies Config, @@ -114,7 +113,6 @@ describe('platform CLI command discovery', () => { unstable__enableMetroCache: false, permissions: false, detectNativeCrashes: true, - crashDetectionInterval: 500, disableViewFlattening: false, forwardClientLogs: false, } satisfies Config, @@ -154,7 +152,6 @@ describe('platform CLI command discovery', () => { unstable__enableMetroCache: false, permissions: false, detectNativeCrashes: true, - crashDetectionInterval: 500, disableViewFlattening: false, forwardClientLogs: false, } satisfies Config, @@ -216,7 +213,6 @@ describe('platform CLI command discovery', () => { unstable__enableMetroCache: false, permissions: false, detectNativeCrashes: true, - crashDetectionInterval: 500, disableViewFlattening: false, forwardClientLogs: false, } satisfies Config, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index fc0d5143..6e83a51a 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -79,10 +79,6 @@ export const ConfigSchema = z ), detectNativeCrashes: z.boolean().optional().default(true), - crashDetectionInterval: z - .number() - .min(100, 'Crash detection interval must be at least 100ms') - .default(500), disableViewFlattening: z .boolean() diff --git a/packages/jest/src/__tests__/crash-monitor.test.ts b/packages/jest/src/__tests__/crash-monitor.test.ts deleted file mode 100644 index 92a6be3e..00000000 --- a/packages/jest/src/__tests__/crash-monitor.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { - AppCrashDetails, - AppMonitor, - AppMonitorEvent, - AppMonitorListener, - HarnessPlatformRunner, -} from '@react-native-harness/platforms'; -import { - createCrashMonitor, - CrashWatchCancelledError, -} from '../crash-monitor.js'; -import { NativeCrashError } from '../errors.js'; - -const noop = () => undefined; -const resolveUndefined = async () => undefined; - -// --------------------------------------------------------------------------- -// Test doubles -// --------------------------------------------------------------------------- - -const createAppMonitorMock = () => { - let registeredListener: AppMonitorListener | null = null; - - const monitor: AppMonitor = { - start: vi.fn(resolveUndefined), - stop: vi.fn(resolveUndefined), - dispose: vi.fn(resolveUndefined), - addListener: vi.fn((l: AppMonitorListener) => { - registeredListener = l; - }), - removeListener: vi.fn((l: AppMonitorListener) => { - if (registeredListener === l) registeredListener = null; - }), - }; - - return { - monitor, - emit: (event: AppMonitorEvent) => registeredListener?.(event), - }; -}; - -const createPlatformRunnerMock = ( - isRunning = false, - crashDetails: AppCrashDetails | null = null, -) => - ({ - isAppRunning: vi.fn(async () => isRunning), - getCrashDetails: vi.fn(async () => crashDetails), - startApp: vi.fn(resolveUndefined), - restartApp: vi.fn(resolveUndefined), - stopApp: vi.fn(resolveUndefined), - dispose: vi.fn(resolveUndefined), - createAppMonitor: vi.fn(), - }) as unknown as HarnessPlatformRunner; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('createCrashMonitor', () => { - describe('liveness', () => { - it('starts not alive', () => { - const { monitor } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - expect(cm.isAlive()).toBe(false); - }); - - it('becomes alive when the app starts', () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - emit({ type: 'app_started' }); - - expect(cm.isAlive()).toBe(true); - }); - - it('becomes not alive after a confirmed crash', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - emit({ type: 'app_started' }); - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - - emit({ type: 'app_exited', isConfirmed: true }); - await watch.promise.catch(noop); - - expect(cm.isAlive()).toBe(false); - }); - }); - - describe('watch', () => { - it('promise rejects with NativeCrashError on confirmed app_exited', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('/test/example.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); - }); - - it('attributes the crash to the file and phase passed to watch()', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('/test/example.ts', 'startup'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - const error = await watch.promise.catch((e: NativeCrashError) => e); - expect(error.testFilePath).toBe('/test/example.ts'); - expect(error.details.phase).toBe('startup'); - }); - - it('settles the promise with CrashWatchCancelledError on cancel()', async () => { - const { monitor } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('test.ts', 'execution'); - watch.cancel(); - - await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); - }); - - it('subsequent cancel() after crash is a no-op', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - await watch.promise.catch(noop); - // Second cancel should not throw or cause issues. - expect(() => watch.cancel()).not.toThrow(); - }); - }); - - describe('unconfirmed events', () => { - it('fires the crash if isAppRunning returns false', async () => { - const { monitor, emit } = createAppMonitorMock(); - const runner = createPlatformRunnerMock(false /* not running */); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: false }); - - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); - }); - - it('does not fire if isAppRunning returns true', async () => { - const { monitor, emit } = createAppMonitorMock(); - const runner = createPlatformRunnerMock(true /* still running */); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner }); - - const watch = cm.watch('test.ts', 'execution'); - const settled = vi.fn(); - watch.promise.then(settled, settled); - - emit({ type: 'app_exited', isConfirmed: false }); - await new Promise((r) => setTimeout(r, 20)); - - expect(settled).not.toHaveBeenCalled(); - watch.cancel(); - }); - - it('fires on possible_crash when confirmed', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'possible_crash', isConfirmed: true }); - - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); - }); - }); - - describe('crash detail enrichment', () => { - it('merges initial and enriched crash details', async () => { - const { monitor, emit } = createAppMonitorMock(); - const runner = createPlatformRunnerMock(false, { - processName: 'MyApp', - signal: 'SIGSEGV', - summary: 'Segmentation fault', - }); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true, crashDetails: { pid: 1234 } }); - - const error = await watch.promise.catch((e: NativeCrashError) => e); - expect(error.details.processName).toBe('MyApp'); - expect(error.details.signal).toBe('SIGSEGV'); - expect(error.details.pid).toBe(1234); - }); - }); - - describe('stop / start', () => { - it('ignores events while stopped', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - await cm.stop(); - - const watch = cm.watch('test.ts', 'execution'); - const settled = vi.fn(); - watch.promise.then(settled, settled); - - emit({ type: 'app_exited', isConfirmed: true }); - await new Promise((r) => setTimeout(r, 10)); - - expect(settled).not.toHaveBeenCalled(); - watch.cancel(); - }); - - it('resumes monitoring after start()', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - await cm.stop(); - await cm.start(); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); - }); - }); - - describe('reset', () => { - it('clears alive state and pending watchers', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - emit({ type: 'app_started' }); - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - - cm.reset(); - - expect(cm.isAlive()).toBe(false); - // The watcher was cleared; a crash fired now should not reach the old watch. - emit({ type: 'app_exited', isConfirmed: true }); - await new Promise((r) => setTimeout(r, 10)); - - // Old promise is still pending (we can verify by cancel resolving it). - watch.cancel(); - await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); - }); - }); - - describe('dispose', () => { - it('ignores events after dispose', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - - await cm.dispose(); - - emit({ type: 'app_exited', isConfirmed: true }); - await new Promise((r) => setTimeout(r, 10)); - - // After dispose watchers are cleared, so crash didn't propagate. - // The promise is still pending - cancel to settle it. - watch.cancel(); - await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); - }); - - it('calls appMonitor.dispose()', async () => { - const { monitor } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - await cm.dispose(); - - expect(monitor.dispose).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/packages/jest/src/__tests__/errors.test.ts b/packages/jest/src/__tests__/errors.test.ts index 34e4ff4f..989c88f7 100644 --- a/packages/jest/src/__tests__/errors.test.ts +++ b/packages/jest/src/__tests__/errors.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { NativeCrashError, PlatformReadyTimeoutError } from '../errors.js'; +import { NativeCrashError } from '@react-native-harness/platforms'; +import { PlatformReadyTimeoutError } from '../errors.js'; describe('PlatformReadyTimeoutError', () => { it('includes the configured timeout and config hint', () => { diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 1f5c8f28..7ac9f370 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { Config, Test, TestWatcher } from 'jest-runner'; import type { TestResult as JestTestResult } from '@jest/test-result'; import type { TestSuiteResult } from '@react-native-harness/bridge'; -import { NativeCrashError, StartupStallError } from '../errors.js'; +import { NativeCrashError } from '@react-native-harness/platforms'; +import { StartupStallError } from '../errors.js'; import { DeviceNotRespondingError } from '@react-native-harness/bridge/server'; import type { HarnessSession } from '../harness-session.js'; import { executeRun } from '../execute-run.js'; diff --git a/packages/jest/src/__tests__/metro-port.test.ts b/packages/jest/src/__tests__/metro-port.test.ts index bbf7b7f6..0c8e0ce7 100644 --- a/packages/jest/src/__tests__/metro-port.test.ts +++ b/packages/jest/src/__tests__/metro-port.test.ts @@ -16,7 +16,6 @@ const createConfig = (overrides: Partial = {}): HarnessConfig => appRegistryComponentName: 'App', bridgeTimeout: 60_000, bundleStartTimeout: 60_000, - crashDetectionInterval: 500, defaultRunner: 'ios-device', detectNativeCrashes: true, disableViewFlattening: false, diff --git a/packages/jest/src/__tests__/resource-lock.test.ts b/packages/jest/src/__tests__/resource-lock.test.ts index 1813c554..7c5ee1d1 100644 --- a/packages/jest/src/__tests__/resource-lock.test.ts +++ b/packages/jest/src/__tests__/resource-lock.test.ts @@ -1,16 +1,223 @@ import fs from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createResourceLockManager } from '../resource-lock.js'; +type ReaddirOptions = { + withFileTypes?: boolean; +}; + +const { mockFs } = vi.hoisted(() => { + const directories = new Set(); + const files = new Map(); + + const normalizePath = (value: unknown): string => { + const normalized = String(value).replace(/\/+$/g, ''); + return normalized === '' ? '/' : normalized; + }; + + const getParentDir = (filePath: string): string => { + const normalized = normalizePath(filePath); + const index = normalized.lastIndexOf('/'); + + if (index <= 0) { + return '/'; + } + + return normalized.slice(0, index); + }; + + const createFsError = (code: string, targetPath: string) => + Object.assign(new Error(`${code}: ${targetPath}`), { + code, + path: targetPath, + }); + + const ensureDirectory = (directoryPath: string): void => { + const normalized = normalizePath(directoryPath); + const parts = normalized.split('/').filter(Boolean); + let current = normalized.startsWith('/') ? '/' : ''; + + directories.add(current || '.'); + + for (const part of parts) { + current = + current === '/' || current === '' + ? `${current}${part}` + : `${current}/${part}`; + directories.add(current); + } + }; + + const listChildren = (directoryPath: string) => { + const normalized = normalizePath(directoryPath); + const prefix = normalized === '/' ? '/' : `${normalized}/`; + const childNames = new Set(); + + for (const directory of directories) { + if (directory === normalized || !directory.startsWith(prefix)) { + continue; + } + + const rest = directory.slice(prefix.length); + const [name] = rest.split('/'); + if (name) childNames.add(name); + } + + for (const filePath of files.keys()) { + if (!filePath.startsWith(prefix)) { + continue; + } + + const rest = filePath.slice(prefix.length); + const [name] = rest.split('/'); + if (name) childNames.add(name); + } + + return [...childNames].sort(); + }; + + const removePath = (targetPath: string, recursive: boolean): void => { + const normalized = normalizePath(targetPath); + + files.delete(normalized); + + if (recursive) { + const prefix = normalized === '/' ? '/' : `${normalized}/`; + for (const filePath of [...files.keys()]) { + if (filePath.startsWith(prefix)) { + files.delete(filePath); + } + } + for (const directory of [...directories]) { + if (directory === normalized || directory.startsWith(prefix)) { + directories.delete(directory); + } + } + return; + } + + directories.delete(normalized); + }; + + directories.add('/'); + + const writeFileRaw = async ( + filePath: unknown, + data: unknown, + options?: unknown + ) => { + const normalized = normalizePath(filePath); + const parentDir = getParentDir(normalized); + + if (!directories.has(parentDir)) { + throw createFsError('ENOENT', normalized); + } + + const flag = + options !== null && typeof options === 'object' && 'flag' in options + ? options.flag + : undefined; + if (flag === 'wx' && files.has(normalized)) { + throw createFsError('EEXIST', normalized); + } + + files.set(normalized, String(data)); + }; + + const api = { + reset: () => { + files.clear(); + directories.clear(); + directories.add('/'); + }, + mkdir: vi.fn(async (directoryPath: string) => { + ensureDirectory(directoryPath); + }), + readFile: vi.fn(async (filePath: string) => { + const normalized = normalizePath(filePath); + const value = files.get(normalized); + + if (value === undefined) { + throw createFsError('ENOENT', normalized); + } + + return value; + }), + writeFile: vi.fn(writeFileRaw), + writeFileRaw, + rename: vi.fn(async (source: string, destination: string) => { + const normalizedSource = normalizePath(source); + const normalizedDestination = normalizePath(destination); + const value = files.get(normalizedSource); + + if (value === undefined) { + throw createFsError('ENOENT', normalizedSource); + } + + if (!directories.has(getParentDir(normalizedDestination))) { + throw createFsError('ENOENT', normalizedDestination); + } + + files.delete(normalizedSource); + files.set(normalizedDestination, value); + }), + rm: vi.fn( + async ( + targetPath: string, + options?: { recursive?: boolean; force?: boolean } + ) => { + const normalized = normalizePath(targetPath); + const exists = files.has(normalized) || directories.has(normalized); + + if (!exists && !options?.force) { + throw createFsError('ENOENT', normalized); + } + + removePath(normalized, options?.recursive ?? false); + } + ), + readdir: vi.fn(async (directoryPath: string, options?: ReaddirOptions) => { + const normalized = normalizePath(directoryPath); + + if (!directories.has(normalized)) { + throw createFsError('ENOENT', normalized); + } + + const childNames = listChildren(normalized); + + if (options?.withFileTypes) { + return childNames.map((name) => { + const childPath = + normalized === '/' ? `/${name}` : `${normalized}/${name}`; + + return { + name, + isFile: () => files.has(childPath), + isDirectory: () => directories.has(childPath), + }; + }); + } + + return childNames; + }), + }; + + return { mockFs: api }; +}); + +vi.mock('node:fs/promises', () => ({ + default: mockFs, + ...mockFs, +})); + describe('resource lock manager', () => { let rootDir: string; - beforeEach(async () => { - rootDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-'), - ); + beforeEach(() => { + vi.restoreAllMocks(); + mockFs.reset(); + rootDir = '/tmp/react-native-harness-resource-lock-test'; }); afterEach(async () => { @@ -27,7 +234,7 @@ describe('resource lock manager', () => { const order: string[] = []; const firstLease = await manager.acquire( - 'ios:simulator:iPhone 17 Pro:26.2', + 'ios:simulator:iPhone 17 Pro:26.2' ); const secondAcquire = manager .acquire('ios:simulator:iPhone 17 Pro:26.2', { @@ -134,16 +341,19 @@ describe('resource lock manager', () => { staleLockTimeoutMs: 200, }); const key = 'ios:simulator:iPhone 17 Pro:26.2'; - const actualWriteFile = fs.writeFile.bind(fs); const writeFileSpy = vi .spyOn(fs, 'writeFile') .mockImplementation(async (file, data, options) => { // Delay atomic temp-file writes to simulate overlapping heartbeat flushes. - if (typeof file === 'string' && file.startsWith(rootDir) && file.endsWith('.tmp')) { + if ( + typeof file === 'string' && + file.startsWith(rootDir) && + file.endsWith('.tmp') + ) { await new Promise((resolve) => setTimeout(resolve, 25)); } - return await actualWriteFile(file, data, options); + return await mockFs.writeFileRaw(file, data, options); }); try { @@ -154,20 +364,21 @@ describe('resource lock manager', () => { const ownerFilePath = path.join(rootDir, keyDirName, 'owner.json'); const initialOwner = JSON.parse( - await fs.readFile(ownerFilePath, 'utf8'), + await fs.readFile(ownerFilePath, 'utf8') ) as ResourceLockOwner; await new Promise((resolve) => setTimeout(resolve, 80)); for (let index = 0; index < 5; index += 1) { const owner = JSON.parse( - await fs.readFile(ownerFilePath, 'utf8'), + await fs.readFile(ownerFilePath, 'utf8') ) as ResourceLockOwner; expect(owner.ticketId).toBe(initialOwner.ticketId); await new Promise((resolve) => setTimeout(resolve, 10)); } await lease.release(); + await new Promise((resolve) => setTimeout(resolve, 40)); } finally { writeFileSpy.mockRestore(); } diff --git a/packages/jest/src/crash-monitor.ts b/packages/jest/src/crash-monitor.ts deleted file mode 100644 index 46807d1a..00000000 --- a/packages/jest/src/crash-monitor.ts +++ /dev/null @@ -1,248 +0,0 @@ -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'; - -const crashLogger = logger.child('crash'); - -export class CrashWatchCancelledError extends Error { - constructor() { - super('Crash watch was cancelled'); - this.name = 'CrashWatchCancelledError'; - } -} - -export type CrashWatch = { - readonly promise: Promise; - cancel: () => void; -}; - -export type CrashMonitor = { - watch: (testFilePath: string, phase: NativeCrashPhase) => CrashWatch; - isAlive: () => boolean; - stop: () => Promise; - start: () => Promise; - reset: () => void; - dispose: () => Promise; -}; - -export type CrashMonitorOptions = { - appMonitor: AppMonitor; - platformRunner: HarnessPlatformRunner; -}; - -type CrashDetailsProvider = { - getCrashDetails?: (options: { - processName?: string; - pid?: number; - occurredAt: number; - }) => Promise; -}; - -const mergeCrashDetails = ( - phase: NativeCrashPhase, - initial?: AppCrashDetails, - enriched?: AppCrashDetails | null, - fallbackSummary?: string, -): NativeCrashDetails => ({ - phase, - source: enriched?.source ?? initial?.source, - summary: enriched?.summary ?? initial?.summary ?? fallbackSummary, - signal: enriched?.signal ?? initial?.signal, - exceptionType: enriched?.exceptionType ?? initial?.exceptionType, - processName: enriched?.processName ?? initial?.processName, - pid: enriched?.pid ?? initial?.pid, - stackTrace: enriched?.stackTrace ?? initial?.stackTrace, - rawLines: enriched?.rawLines ?? initial?.rawLines, - artifactType: enriched?.artifactType ?? initial?.artifactType, - artifactPath: enriched?.artifactPath ?? initial?.artifactPath, -}); - -export const createCrashMonitor = ({ - appMonitor, - platformRunner, -}: CrashMonitorOptions): CrashMonitor => { - let alive = false; - let monitoring = true; - let isResolvingCrash = false; - let disposed = false; - - // Both updated when watch() is called so crashes are attributed to the - // correct test file and lifecycle phase. - let currentTestFilePath = ''; - let currentPhase: NativeCrashPhase = 'startup'; - const watchers = new Set<(err: NativeCrashError) => void>(); - - const getCrashDetailsProvider = (): CrashDetailsProvider | null => { - if ('getCrashDetails' in appMonitor) { - return appMonitor as AppMonitor & CrashDetailsProvider; - } - if (platformRunner.getCrashDetails) { - return platformRunner; - } - return null; - }; - - const notifyCrash = (err: NativeCrashError) => { - const pending = [...watchers]; - watchers.clear(); - for (const fn of pending) fn(err); - }; - - const handleCrash = async ( - phase: NativeCrashPhase, - details?: AppCrashDetails, - fallbackSummary?: string, - ) => { - if (isResolvingCrash) return; - isResolvingCrash = true; - alive = false; - - crashLogger.debug('native crash detected (phase=%s)', phase); - for (const line of details?.rawLines ?? []) { - crashLogger.debug('%s', line); - } - - try { - const enriched = await getCrashDetailsProvider()?.getCrashDetails?.({ - processName: details?.processName, - pid: details?.pid, - occurredAt: Date.now(), - }); - const merged = mergeCrashDetails(phase, details, enriched, fallbackSummary); - crashLogger.debug('crash details: %o', { - phase: merged.phase, - source: merged.source, - summary: merged.summary, - signal: merged.signal, - exceptionType: merged.exceptionType, - processName: merged.processName, - pid: merged.pid, - }); - notifyCrash(new NativeCrashError(currentTestFilePath, merged)); - } finally { - isResolvingCrash = false; - } - }; - - const confirmAndHandleCrash = async ( - phase: NativeCrashPhase, - details?: AppCrashDetails, - fallbackSummary?: string, - ) => { - if (disposed || !monitoring) return; - try { - const isRunning = await platformRunner.isAppRunning(); - if (!isRunning) { - void handleCrash(phase, details, fallbackSummary); - } - } catch (error) { - crashLogger.debug('crash confirmation failed', error); - } - }; - - const extractCrashDetails = ( - event: Extract, - ): AppCrashDetails | undefined => - event.crashDetails - ? { - 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), - } - : undefined; - - const appMonitorListener: AppMonitorListener = (event: AppMonitorEvent) => { - if (disposed || !monitoring) return; - - if (event.type === 'app_started') { - alive = true; - return; - } - - if (event.type === 'app_exited') { - const details = extractCrashDetails(event); - if (event.isConfirmed ?? event.source === 'polling') { - void handleCrash(currentPhase, details); - } else { - void confirmAndHandleCrash(currentPhase, details); - } - return; - } - - if (event.type === 'possible_crash') { - const details = extractCrashDetails(event); - const fallback = `possible crash signal (${event.source ?? 'unknown'})`; - if (event.isConfirmed) { - void handleCrash(currentPhase, details, fallback); - } else { - void confirmAndHandleCrash(currentPhase, details, fallback); - } - } - }; - - appMonitor.addListener(appMonitorListener); - - const watch = (testFilePath: string, phase: NativeCrashPhase): CrashWatch => { - currentTestFilePath = testFilePath; - currentPhase = phase; - let rejectFn!: (err: Error) => void; - - const promise = new Promise((_, reject) => { - rejectFn = (err) => { - watchers.delete(rejectFn); - reject(err); - }; - watchers.add(rejectFn); - }); - - const cancel = () => { - rejectFn(new CrashWatchCancelledError()); - }; - - return { promise, cancel }; - }; - - return { - watch, - isAlive: () => alive, - stop: async () => { - monitoring = false; - await appMonitor.stop(); - }, - start: async () => { - monitoring = true; - await appMonitor.start(); - }, - reset: () => { - alive = false; - watchers.clear(); - isResolvingCrash = false; - currentTestFilePath = ''; - }, - dispose: async () => { - disposed = true; - monitoring = false; - watchers.clear(); - isResolvingCrash = false; - appMonitor.removeListener(appMonitorListener); - await appMonitor.dispose(); - }, - }; -}; diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index d9d0dc86..79b2bce0 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -1,5 +1,8 @@ import { HarnessError } from '@react-native-harness/tools'; -import type { AppCrashDetails } from '@react-native-harness/platforms'; +export { + NativeCrashError, + type NativeCrashDetails, +} from '@react-native-harness/platforms'; export { StartupStallError, type StartupStallCode, @@ -48,74 +51,3 @@ export class MetroPortRangeExhaustedError extends HarnessError { this.name = 'MetroPortRangeExhaustedError'; } } - -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'); - - 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(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/execute-run.ts b/packages/jest/src/execute-run.ts index 0591adf7..b9dde386 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -8,10 +8,10 @@ import type { } from 'jest-runner'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; +import { NativeCrashError } from '@react-native-harness/platforms'; import { type HarnessSession, type HarnessRunState } from './harness-session.js'; import { runHarnessTestFile } from './run.js'; import { - NativeCrashError, StartupStallError, } from './errors.js'; import { DeviceNotRespondingError } from '@react-native-harness/bridge/server'; diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index a19b2387..418efc56 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -12,6 +12,8 @@ import { } from '@react-native-harness/bridge'; import { type AppLaunchOptions, + type AppLifecycleMonitor, + type AppMonitorEvent, type HarnessPlatform, type HarnessPlatformInitOptions, type HarnessPlatformRunner, @@ -44,10 +46,10 @@ import { ConfigSchema, } from '@react-native-harness/config'; import type { Config as JestConfig } from 'jest-runner'; +import { randomUUID } from 'node:crypto'; import { preRunMessage } from 'jest-util'; import { PlatformReadyTimeoutError } from './errors.js'; import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js'; -import { createCrashMonitor, type CrashMonitor } from './crash-monitor.js'; import { createHookQueue, type HookQueue } from './hook-queue.js'; import { createClientLogCollector, @@ -152,8 +154,77 @@ type AppReadyOptions = { bundleStartTimeout: number; readyTimeout: number; maxAppRestarts: number; - crashMonitor: CrashMonitor; + appMonitor: AppLifecycleMonitor; appLaunchOptions?: AppLaunchOptions; + nextLaunchId: () => string; +}; + +type LaunchReason = 'start' | 'restart' | 'ensure_ready'; +type StopReason = 'restart' | 'dispose' | 'coverage' | 'manual'; + +const stopAppWithMonitor = async ({ + appMonitor, + platformInstance, + reason, +}: { + appMonitor: AppLifecycleMonitor; + platformInstance: HarnessPlatformRunner; + reason: StopReason; +}) => { + appMonitor.stopRequested({ + type: 'stop_requested', + at: Date.now(), + reason, + }); + await appMonitor.stop(); + await platformInstance.stopApp(); + appMonitor.stopCompleted({ + type: 'stop_completed', + at: Date.now(), + reason, + }); +}; + +const startAppWithMonitor = async ({ + appMonitor, + platformInstance, + appLaunchOptions, + nextLaunchId, + reason, +}: { + appMonitor: AppLifecycleMonitor; + platformInstance: HarnessPlatformRunner; + appLaunchOptions?: AppLaunchOptions; + nextLaunchId: () => string; + reason: LaunchReason; +}) => { + const launchId = nextLaunchId(); + + appMonitor.launchRequested({ + type: 'launch_requested', + launchId, + at: Date.now(), + reason, + }); + + try { + await platformInstance.startApp(appLaunchOptions); + appMonitor.launchCompleted({ + type: 'launch_completed', + launchId, + at: Date.now(), + reason, + }); + } catch (error) { + appMonitor.launchFailed({ + type: 'launch_failed', + launchId, + at: Date.now(), + reason, + error, + }); + throw error; + } }; const waitForAppReady = async ( @@ -168,8 +239,9 @@ const waitForAppReady = async ( bundleStartTimeout, readyTimeout, maxAppRestarts, - crashMonitor, + appMonitor, appLaunchOptions, + nextLaunchId, } = base; const logWait = (message: string, ...args: unknown[]) => @@ -184,7 +256,20 @@ const waitForAppReady = async ( signal: new AbortController().signal, startAttempt: async () => { logWait('launching app for %s', testFilePath); - await platformInstance.restartApp(appLaunchOptions); + await stopAppWithMonitor({ + appMonitor, + platformInstance, + reason: 'restart', + }); + appMonitor.reset(); + await appMonitor.start(); + await startAppWithMonitor({ + appMonitor, + platformInstance, + appLaunchOptions, + nextLaunchId, + reason: 'ensure_ready', + }); logWait('launch request completed, waiting for bridge ready'); }, waitForReady: async (signal) => { @@ -208,7 +293,7 @@ const waitForAppReady = async ( logWait('runtime ready received'); }, waitForCrash: async (signal) => { - const watch = crashMonitor.watch(testFilePath, 'startup'); + const watch = appMonitor.watch(testFilePath, 'startup'); watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race try { logWait('waiting for crash or runtime ready'); @@ -270,6 +355,68 @@ const buildBridgeHookScheduler = ( } }; +const buildAppMonitorHookScheduler = ( + hooks: HookQueue, + pluginManager: HarnessPluginManager, + getCurrentRunId: () => string | undefined, +) => (event: AppMonitorEvent) => { + const runId = getCurrentRunId(); + if (!runId) return; + + const payloadBase = { + runId, + appPlatform: event.appPlatform, + targetIdentifier: event.targetIdentifier, + testFile: event.testFile, + phase: event.phase, + launchId: event.launchId, + processName: event.processName, + pid: event.pid, + source: event.source, + summary: event.summary, + kind: event.kind, + confidence: event.confidence, + signal: event.signal, + exceptionType: event.exceptionType, + artifactType: event.artifactType, + artifactPath: event.artifactPath, + crashDetails: event.crashDetails, + }; + + switch (event.type) { + case 'app:started': + return hooks.schedule(() => + pluginManager.callHook('app:started', payloadBase), + ); + case 'app:exited': + return hooks.schedule(() => + pluginManager.callHook('app:exited', payloadBase), + ); + case 'app:crash-suspected': + return hooks.schedule(() => + pluginManager.callHook('app:crash-suspected', payloadBase), + ); + case 'app:crash-confirmed': + return hooks.schedule(() => + pluginManager.callHook('app:crash-confirmed', payloadBase), + ); + case 'app:crash-report-ready': + return hooks.schedule(() => + pluginManager.callHook('app:crash-report-ready', { + ...payloadBase, + crashDetails: event.crashDetails, + }), + ); + case 'app:monitor-warning': + return hooks.schedule(() => + pluginManager.callHook('app:monitor-warning', { + ...payloadBase, + warning: event.warning, + }), + ); + } +}; + // --------------------------------------------------------------------------- // Config loading // --------------------------------------------------------------------------- @@ -411,6 +558,11 @@ export const createHarnessSession = async ( let currentRun: HarnessRunState | null = null; const getCurrentRunId = () => currentRun?.runId; const clientLogCollector = createClientLogCollector(); + const appMonitorEventListener = buildAppMonitorHookScheduler( + hooks, + pluginManager, + getCurrentRunId, + ); const context: HarnessContext = { platform }; @@ -464,10 +616,12 @@ export const createHarnessSession = async ( runnerName: platform.name, platformId: platform.platformId, }); - const appMonitor = platformInstance.createAppMonitor({ crashArtifactWriter }); + const appMonitor = platformInstance.createAppMonitor({ + crashArtifactWriter, + eventReporter: appMonitorEventListener, + }); const appLaunchOptions = (platform.config as { appLaunchOptions?: AppLaunchOptions }).appLaunchOptions; - - const crashMonitor = createCrashMonitor({ appMonitor, platformRunner: platformInstance }); + const nextLaunchId = () => randomUUID(); // Pre-build the options that are constant across all app-ready calls; // only testFilePath varies per call. @@ -479,8 +633,9 @@ export const createHarnessSession = async ( bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000, readyTimeout: runtimeConfig.bridgeTimeout, maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2, - crashMonitor, + appMonitor, appLaunchOptions, + nextLaunchId, }; // --- Event listeners --- @@ -561,7 +716,11 @@ export const createHarnessSession = async ( const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios; if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) { try { - await platformInstance.stopApp(); + await stopAppWithMonitor({ + appMonitor, + platformInstance, + reason: 'coverage', + }); const lcovPath = await platformInstance.collectNativeCoverage({ pods: nativeCoverageConfig.pods, outputDir: projectRoot, @@ -577,7 +736,7 @@ export const createHarnessSession = async ( let cleanupError: unknown; try { await Promise.all([ - crashMonitor.dispose(), + appMonitor.dispose(), bridge.dispose(), platformInstance.dispose(), metroInstance.dispose(), @@ -634,12 +793,12 @@ export const createHarnessSession = async ( await hooks.drain(); sessionLogger.debug('ensuring app is ready for %s', testFilePath); - if (crashMonitor.isAlive() && bridge.connection !== null && await platformInstance.isAppRunning()) { + if (appMonitor.isAlive() && bridge.connection !== null && await platformInstance.isAppRunning()) { sessionLogger.debug('reusing existing ready app for %s', testFilePath); return; } - crashMonitor.reset(); + appMonitor.reset(); sessionLogger.debug('app not ready, waiting for launch and runtime readiness'); await waitForAppReady(appReadyBaseOptions, testFilePath); await hooks.drain(); @@ -648,24 +807,31 @@ export const createHarnessSession = async ( const restartApp = async (testFilePath?: string): Promise => { await hooks.drain(); - await crashMonitor.stop(); sessionLogger.debug( 'restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', testFilePath ? 'stop-and-ensure-ready' : 'direct-restart', ); - if (testFilePath) { - await platformInstance.stopApp(); - } else { - await platformInstance.restartApp(appLaunchOptions); - } + await stopAppWithMonitor({ + appMonitor, + platformInstance, + reason: 'restart', + }); - crashMonitor.reset(); - await crashMonitor.start(); + appMonitor.reset(); + await appMonitor.start(); if (testFilePath) { await ensureAppReady(testFilePath); + } else { + await startAppWithMonitor({ + appMonitor, + platformInstance, + appLaunchOptions, + nextLaunchId, + reason: 'restart', + }); } await hooks.drain(); @@ -681,13 +847,7 @@ export const createHarnessSession = async ( if (!conn) throw new Error('No active app connection'); sessionLogger.debug('running test file on client: %s', testPath); - if (!runtimeConfig.detectNativeCrashes) { - const result = await conn.runTests(testPath, { ...options, runner: platform.runner }); - await hooks.drain(); - return result; - } - - const crashWatch = crashMonitor.watch(testPath, 'execution'); + const crashWatch = appMonitor.watch(testPath, 'execution'); // Attach a handler now so the rejection is always observed, whether the // crash wins the race or cancel() is called after the test run wins. crashWatch.promise.catch(ignorePromiseRejection); @@ -703,14 +863,14 @@ export const createHarnessSession = async ( } }; - return { - config: runtimeConfig, - context, - runTestFile, - ensureAppReady, - restartApp, - resetCrashState: () => crashMonitor.reset(), - flushClientLogs, + return { + config: runtimeConfig, + context, + runTestFile, + ensureAppReady, + restartApp, + resetCrashState: () => appMonitor.reset(), + flushClientLogs, callHook: async (name, payload) => { await hooks.drain(); await pluginManager.callHook(name, payload); 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..f891c71a --- /dev/null +++ b/packages/platform-android/src/__tests__/app-monitor.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NativeCrashError } from '@react-native-harness/platforms'; +import type { Subprocess } from '@react-native-harness/tools'; +import { createAndroidAppMonitor, createAndroidLogEvent } from '../app-monitor.js'; +import * as adb from '../adb.js'; + +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; + +describe('createAndroidLogEvent', () => { + it('detects confirmation-grade am_crash events', () => { + const event = createAndroidLogEvent( + '05-20 12:00:00.200 1000 1000 I am_crash: [0,1234,com.harnessplayground,123,java.lang.IllegalStateException,boom]', + 'com.harnessplayground', + ); + + expect(event).toMatchObject({ + type: 'crash_confirmed', + crashDetails: { + platform: 'android', + kind: 'java-exception', + confidence: 'high', + source: 'logs', + }, + }); + }); +}); + +describe('createAndroidAppMonitor', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.useFakeTimers(); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('05-20 12:00:00.000'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts logcat with crash, main, system, and events buffers', async () => { + const startLogcat = vi + .spyOn(adb, 'startLogcat') + .mockReturnValue(createStreamingSubprocess([])); + + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + isAppRunning: async () => true, + }); + + await monitor.start(); + await monitor.stop(); + + expect(startLogcat).toHaveBeenCalledWith('emulator-5554', [ + 'logcat', + '-b', + 'crash', + '-b', + 'main', + '-b', + 'system', + '-b', + 'events', + '-v', + 'threadtime', + '-T', + '05-20 12:00:00.000', + ]); + }); + + it('reports app lifecycle and crash events through the reporter', async () => { + vi.spyOn(adb, 'startLogcat').mockReturnValue( + createStreamingSubprocess([ + { + line: '05-20 12:00:00.000 1234 1234 I ActivityManager: Start proc 1234:com.harnessplayground/u0a123 for activity', + }, + { + line: '05-20 12:00:00.100 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + }, + { + line: '05-20 12:00:00.200 1000 1000 I am_crash: [0,1234,com.harnessplayground,123,java.lang.IllegalStateException,boom]', + }, + ]), + ); + + const eventReporter = vi.fn(); + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + isAppRunning: async () => true, + eventReporter, + }); + const crashWatch = monitor.watch('example.test.ts', 'startup'); + crashWatch.promise.catch(() => undefined); + + monitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + + await monitor.start(); + await vi.advanceTimersByTimeAsync(100); + + expect(eventReporter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'app:started', + appPlatform: 'android', + targetIdentifier: 'emulator-5554', + launchId: 'launch-1', + }), + ); + expect(eventReporter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'app:crash-suspected', + appPlatform: 'android', + processName: 'com.harnessplayground', + }), + ); + expect(eventReporter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'app:crash-confirmed', + appPlatform: 'android', + processName: 'com.harnessplayground', + }), + ); + + await monitor.stop(); + }); + + it('rejects the watch for Java crashes when am_crash arrives even if the process still looks alive', async () => { + vi.spyOn(adb, 'startLogcat').mockReturnValue( + createStreamingSubprocess([ + { + line: '05-20 12:00:00.000 1234 1234 I ActivityManager: Start proc 1234:com.harnessplayground/u0a123 for activity', + }, + { + line: '05-20 12:00:00.100 1234 1234 E AndroidRuntime: FATAL EXCEPTION: main', + }, + { + line: '05-20 12:00:00.101 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + }, + { + line: '05-20 12:00:00.102 1234 1234 E AndroidRuntime: java.lang.IllegalStateException: Intentional pre-RN startup crash', + }, + { + line: '05-20 12:00:00.103 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)', + }, + { + line: '05-20 12:00:00.200 1000 1000 I am_crash: [0,1234,com.harnessplayground,123,java.lang.IllegalStateException,Intentional pre-RN startup crash]', + }, + ]), + ); + + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + isAppRunning: async () => true, + }); + const crashWatch = monitor.watch('example.test.ts', 'startup'); + crashWatch.promise.catch(() => undefined); + + await monitor.start(); + await vi.advanceTimersByTimeAsync(100); + + await expect(crashWatch.promise).rejects.toMatchObject({ + testFilePath: 'example.test.ts', + details: { + phase: 'startup', + platform: 'android', + kind: 'java-exception', + confidence: 'high', + processName: 'com.harnessplayground', + pid: 1234, + exceptionType: 'java.lang.IllegalStateException: Intentional pre-RN startup crash', + }, + }); + await expect(crashWatch.promise).rejects.toBeInstanceOf(NativeCrashError); + + await monitor.stop(); + }); + + it('rejects the watch when a suspected crash is followed by process exit', async () => { + vi.spyOn(adb, 'startLogcat').mockReturnValue( + createStreamingSubprocess([ + { + line: '05-20 12:00:00.101 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + }, + { + line: '05-20 12:00:00.102 1234 1234 E AndroidRuntime: java.lang.IllegalStateException: Intentional delayed startup crash', + }, + ]), + ); + + const isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(false); + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + isAppRunning, + }); + const crashWatch = monitor.watch('example.test.ts', 'execution'); + crashWatch.promise.catch(() => undefined); + + await monitor.start(); + await vi.advanceTimersByTimeAsync(350); + + await expect(crashWatch.promise).rejects.toMatchObject({ + details: { + phase: 'execution', + platform: 'android', + kind: 'java-exception', + processName: 'com.harnessplayground', + pid: 1234, + }, + }); + + await monitor.stop(); + }); + + it('does not reject the watch when the process exits without a recent crash signal', async () => { + vi.spyOn(adb, 'startLogcat').mockReturnValue(createStreamingSubprocess([])); + + const isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(false); + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + isAppRunning, + }); + const crashWatch = monitor.watch('example.test.ts', 'execution'); + let settled = false; + + const watchHandled = crashWatch.promise.catch(() => { + settled = true; + }); + + await monitor.start(); + await vi.advanceTimersByTimeAsync(600); + + expect(settled).toBe(false); + + crashWatch.cancel(); + await monitor.stop(); + await watchHandled; + }); + + it('suppresses process-exit confirmation during a controlled stop window', async () => { + vi.spyOn(adb, 'startLogcat').mockReturnValue( + createStreamingSubprocess([ + { + line: '05-20 12:00:00.101 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234', + }, + ]), + ); + + const isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(false); + const monitor = createAndroidAppMonitor({ + adbId: 'emulator-5554', + bundleId: 'com.harnessplayground', + appUid: 10234, + isAppRunning, + }); + const crashWatch = monitor.watch('example.test.ts', 'execution'); + let settled = false; + + const watchHandled = crashWatch.promise.catch(() => { + settled = true; + }); + + await monitor.start(); + monitor.stopRequested({ + type: 'stop_requested', + at: Date.now(), + reason: 'manual', + }); + await vi.advanceTimersByTimeAsync(400); + + expect(settled).toBe(false); + + crashWatch.cancel(); + await monitor.stop(); + await watchHandled; + }); +}); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 3ead8cd6..9bb0f5be 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -502,14 +502,37 @@ describe('Android platform instance', () => { init, ); - const listener = vi.fn(); const appMonitor = instance.createAppMonitor(); await expect(appMonitor.start()).resolves.toBeUndefined(); await expect(appMonitor.stop()).resolves.toBeUndefined(); await expect(appMonitor.dispose()).resolves.toBeUndefined(); - expect(appMonitor.addListener(listener)).toBeUndefined(); - expect(appMonitor.removeListener(listener)).toBeUndefined(); + expect(appMonitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + })).toBeUndefined(); + expect(appMonitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + })).toBeUndefined(); + expect(appMonitor.stopRequested({ + type: 'stop_requested', + at: Date.now(), + reason: 'manual', + })).toBeUndefined(); + expect(appMonitor.stopCompleted({ + type: 'stop_completed', + at: Date.now(), + reason: 'manual', + })).toBeUndefined(); + expect(appMonitor.reset()).toBeUndefined(); + expect(appMonitor.isAlive()).toBe(true); + const watch = appMonitor.watch('example.ts', 'startup'); + watch.cancel(); }); it('returns a noop physical device app monitor when native crash detection is disabled', async () => { @@ -556,14 +579,37 @@ describe('Android platform instance', () => { harnessConfigWithoutNativeCrashDetection, ); - const listener = vi.fn(); const appMonitor = instance.createAppMonitor(); await expect(appMonitor.start()).resolves.toBeUndefined(); await expect(appMonitor.stop()).resolves.toBeUndefined(); await expect(appMonitor.dispose()).resolves.toBeUndefined(); - expect(appMonitor.addListener(listener)).toBeUndefined(); - expect(appMonitor.removeListener(listener)).toBeUndefined(); + expect(appMonitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + })).toBeUndefined(); + expect(appMonitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + })).toBeUndefined(); + expect(appMonitor.stopRequested({ + type: 'stop_requested', + at: Date.now(), + reason: 'manual', + })).toBeUndefined(); + expect(appMonitor.stopCompleted({ + type: 'stop_completed', + at: Date.now(), + reason: 'manual', + })).toBeUndefined(); + expect(appMonitor.reset()).toBeUndefined(); + expect(appMonitor.isAlive()).toBe(true); + const watch = appMonitor.watch('example.ts', 'startup'); + watch.cancel(); }); it('grants permissions when permissions are enabled for emulator', async () => { diff --git a/packages/platform-android/src/app-monitor.ts b/packages/platform-android/src/app-monitor.ts index f618ad4d..ec5943fb 100644 --- a/packages/platform-android/src/app-monitor.ts +++ b/packages/platform-android/src/app-monitor.ts @@ -1,14 +1,17 @@ import { - type AppMonitor, + CrashWatchCancelledError, + NativeCrashError, type AppCrashDetails, + type AppLifecycleMonitor, + type AppLifecyclePhase, + type AppMonitorReporter, type CrashArtifactWriter, type CrashDetailsLookupOptions, - type AppMonitorEvent, - type AppMonitorListener, + type LaunchRequestedEvent, + type NativeCrashDetails, } from '@react-native-harness/platforms'; import { escapeRegExp, - getEmitter, logger, SubprocessError, type Subprocess, @@ -18,20 +21,27 @@ import { androidCrashParser } from './crash-parser.js'; const androidAppMonitorLogger = logger.child('android-app-monitor'); -const getLogcatArgs = (uid: number, fromTime: string) => +const getLogcatArgs = (_appUid: number, fromTime: string) => [ 'logcat', - '-v', - 'threadtime', '-b', 'crash', - `--uid=${uid}`, + '-b', + 'main', + '-b', + 'system', + '-b', + 'events', + '-v', + 'threadtime', '-T', fromTime, ] as const; -const MAX_RECENT_LOG_LINES = 200; +const MAX_RECENT_LOG_LINES = 400; const MAX_RECENT_CRASH_ARTIFACTS = 10; const CRASH_ARTIFACT_SETTLE_DELAY_MS = 100; +const CRASH_CORRELATION_WINDOW_MS = 1000; +const PROCESS_POLL_INTERVAL_MS = 250; const startProcPattern = (bundleId: string) => new RegExp(`Start proc (\\d+):${escapeRegExp(bundleId)}(?:/|\\s)`); @@ -44,12 +54,16 @@ const nativeCrashPattern = (bundleId: string) => const processDiedPattern = (bundleId: string) => new RegExp( - `Process\\s+${escapeRegExp( - bundleId - )}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, - 'i' + `Process\\s+${escapeRegExp(bundleId)}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, + 'i', ); +const amCrashPattern = /\bam_crash\b/i; +const amAnrPattern = /\bam_anr\b/i; +const forceFinishingPattern = /Force finishing activity/i; +const nativeAbortPattern = + /Fatal signal\s+\d+|Abort message:|backtrace:|signal\s+11|signal\s+6/i; + const getSignal = (line: string) => { const namedSignalMatch = line.match(/\b(SIG[A-Z0-9]+)\b/); @@ -66,19 +80,73 @@ const getSignal = (line: string) => { return undefined; }; +const mergeCrashDetails = ( + existing: AppCrashDetails | undefined, + incoming: AppCrashDetails, +): AppCrashDetails => ({ + ...existing, + ...incoming, + platform: incoming.platform ?? existing?.platform, + kind: incoming.kind ?? existing?.kind, + confidence: incoming.confidence ?? existing?.confidence, + occurredAt: incoming.occurredAt ?? existing?.occurredAt, + launchId: incoming.launchId ?? existing?.launchId, + source: incoming.source ?? existing?.source, + summary: incoming.summary ?? existing?.summary, + signal: incoming.signal ?? existing?.signal, + exceptionType: incoming.exceptionType ?? existing?.exceptionType, + processName: incoming.processName ?? existing?.processName, + pid: incoming.pid ?? existing?.pid, + stackTrace: incoming.stackTrace ?? existing?.stackTrace, + rawLines: incoming.rawLines ?? existing?.rawLines, + artifactType: incoming.artifactType ?? existing?.artifactType, + artifactPath: incoming.artifactPath ?? existing?.artifactPath, +}); + +const mergeNativeCrashDetails = ( + phase: AppLifecyclePhase, + initial?: AppCrashDetails, + enriched?: AppCrashDetails | null, + fallbackSummary?: string, +): NativeCrashDetails => ({ + phase, + platform: enriched?.platform ?? initial?.platform, + kind: enriched?.kind ?? initial?.kind, + confidence: enriched?.confidence ?? initial?.confidence, + occurredAt: enriched?.occurredAt ?? initial?.occurredAt, + launchId: enriched?.launchId ?? initial?.launchId, + source: enriched?.source ?? initial?.source, + summary: enriched?.summary ?? initial?.summary ?? fallbackSummary, + signal: enriched?.signal ?? initial?.signal, + exceptionType: enriched?.exceptionType ?? initial?.exceptionType, + processName: enriched?.processName ?? initial?.processName, + pid: enriched?.pid ?? initial?.pid, + stackTrace: enriched?.stackTrace ?? initial?.stackTrace, + rawLines: enriched?.rawLines ?? initial?.rawLines, + artifactType: enriched?.artifactType ?? initial?.artifactType, + artifactPath: enriched?.artifactPath ?? initial?.artifactPath, +}); + const getAndroidLogLineCrashDetails = ({ line, bundleId, pid, + kind, + confidence, }: { line: string; bundleId: string; pid?: number; + kind?: AppCrashDetails['kind']; + confidence?: AppCrashDetails['confidence']; }): AppCrashDetails => { const fatalExceptionMatch = line.match(/FATAL EXCEPTION:\s*(.+)$/i); const processMatch = line.match(processPattern(bundleId)); return { + platform: 'android', + kind, + confidence, source: 'logs', summary: line.trim(), signal: getSignal(line), @@ -86,8 +154,8 @@ const getAndroidLogLineCrashDetails = ({ processName: processMatch ? bundleId : line.includes(bundleId) - ? bundleId - : undefined, + ? bundleId + : undefined, pid: pid ?? (processMatch ? Number(processMatch[1]) : undefined), rawLines: [line], }; @@ -104,6 +172,19 @@ type AndroidCrashArtifact = AppCrashDetails & { triggerOccurredAt?: number; }; +type PendingCrash = { + details: AppCrashDetails; + occurredAt: number; +}; + +type AndroidMonitorSignal = + | { type: 'app_started'; pid?: number } + | { type: 'crash_suspected'; crashDetails: AppCrashDetails } + | { type: 'crash_confirmed'; crashDetails: AppCrashDetails } + | { type: 'app_exited'; crashDetails?: AppCrashDetails }; + +type WatchReject = (error: Error) => void; + const CRASH_BLOCK_HEADER = '--------- beginning of crash'; const getLatestCrashBlock = (recentLogLines: TimedLogLine[]) => { @@ -119,7 +200,7 @@ const getLatestCrashBlock = (recentLogLines: TimedLogLine[]) => { const blockStartIndex = Math.max( lines.lastIndexOf(CRASH_BLOCK_HEADER), - latestCrashHeaderIndex + latestCrashHeaderIndex, ); if (blockStartIndex === -1) { @@ -140,7 +221,7 @@ const getCrashBlockForArtifact = ({ ({ line, occurredAt }) => line === artifact.triggerLine && (artifact.triggerOccurredAt === undefined || - occurredAt === artifact.triggerOccurredAt) + occurredAt === artifact.triggerOccurredAt), ); if (targetIndex === -1) { @@ -150,9 +231,7 @@ const getCrashBlockForArtifact = ({ let blockStartIndex = targetIndex; for (let index = targetIndex; index >= 0; index -= 1) { - const { line } = recentLogLines[index]; - - if (line === CRASH_BLOCK_HEADER) { + if (recentLogLines[index].line === CRASH_BLOCK_HEADER) { blockStartIndex = index; break; } @@ -194,6 +273,11 @@ const hydrateCrashArtifact = ({ return { ...artifact, ...parsedDetails, + platform: 'android', + kind: artifact.kind, + confidence: artifact.confidence, + occurredAt: artifact.occurredAt, + launchId: artifact.launchId, artifactType: artifact.artifactType, artifactPath: artifact.artifactPath, rawLines, @@ -207,7 +291,7 @@ const createCrashArtifact = ({ details: AppCrashDetails; recentLogLines: TimedLogLine[]; }): AndroidCrashArtifact => { - const occurredAt = Date.now(); + const occurredAt = details.occurredAt ?? Date.now(); const rawLines = getLatestCrashBlock(recentLogLines); const triggerOccurredAt = [...recentLogLines] .reverse() @@ -227,6 +311,8 @@ const createCrashArtifact = ({ return { ...parsedDetails, + ...details, + platform: 'android', occurredAt, triggerLine: details.summary ?? '', triggerOccurredAt, @@ -288,12 +374,12 @@ const getLatestCrashArtifact = ({ matchingByPid.length > 0 ? matchingByPid : matchingByProcess.length > 0 - ? matchingByProcess - : crashArtifacts; + ? matchingByProcess + : crashArtifacts; const sortedCandidates = [...candidates].sort( (left, right) => Math.abs(left.occurredAt - occurredAt) - - Math.abs(right.occurredAt - occurredAt) + Math.abs(right.occurredAt - occurredAt), ); const artifact = sortedCandidates[0]; @@ -310,16 +396,14 @@ const getLatestCrashArtifact = ({ const createAndroidLogEvent = ( line: string, - bundleId: string -): AppMonitorEvent | null => { + bundleId: string, +): AndroidMonitorSignal | null => { const startMatch = line.match(startProcPattern(bundleId)); if (startMatch) { return { type: 'app_started', pid: Number(startMatch[1]), - source: 'logs', - line, }; } @@ -327,26 +411,64 @@ const createAndroidLogEvent = ( if (processMatch) { return { - type: 'possible_crash', - pid: Number(processMatch[1]), - source: 'logs', - line, + type: 'crash_suspected', crashDetails: getAndroidLogLineCrashDetails({ line, bundleId, pid: Number(processMatch[1]), + kind: 'java-exception', + confidence: 'high', + }), + }; + } + + if (line.includes(bundleId) && amCrashPattern.test(line)) { + return { + type: 'crash_confirmed', + crashDetails: getAndroidLogLineCrashDetails({ + line, + bundleId, + kind: 'java-exception', + confidence: 'high', + }), + }; + } + + if ( + line.includes(bundleId) && + (amAnrPattern.test(line) || /\bANR\b/i.test(line)) + ) { + return { + type: 'crash_confirmed', + crashDetails: getAndroidLogLineCrashDetails({ + line, + bundleId, + kind: 'anr', + confidence: 'medium', + }), + }; + } + + if (line.includes(bundleId) && forceFinishingPattern.test(line)) { + return { + type: 'crash_confirmed', + crashDetails: getAndroidLogLineCrashDetails({ + line, + bundleId, + kind: 'java-exception', + confidence: 'high', }), }; } if (nativeCrashPattern(bundleId).test(line)) { return { - type: 'possible_crash', - source: 'logs', - line, + type: 'crash_suspected', crashDetails: getAndroidLogLineCrashDetails({ line, bundleId, + kind: 'native-crash', + confidence: 'high', }), }; } @@ -356,28 +478,24 @@ const createAndroidLogEvent = ( if (diedMatch) { return { type: 'app_exited', - pid: Number(diedMatch[1]), - source: 'logs', - line, crashDetails: getAndroidLogLineCrashDetails({ line, bundleId, pid: Number(diedMatch[1]), + kind: 'process-exit', + confidence: 'medium', }), }; } - if ( - line.includes(bundleId) && - /fatal|crash|signal 11|signal 6|backtrace/i.test(line) - ) { + if (line.includes(bundleId) && nativeAbortPattern.test(line)) { return { - type: 'possible_crash', - source: 'logs', - line, + type: 'crash_suspected', crashDetails: getAndroidLogLineCrashDetails({ line, bundleId, + kind: 'native-crash', + confidence: 'high', }), }; } @@ -385,34 +503,147 @@ const createAndroidLogEvent = ( return null; }; +const waitForPollInterval = async (signal: AbortSignal): Promise => { + if (signal.aborted) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, PROCESS_POLL_INTERVAL_MS); + + const onAbort = () => { + clearTimeout(timeout); + resolve(); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + }); +}; + export const createAndroidAppMonitor = ({ adbId, bundleId, appUid, + isAppRunning, crashArtifactWriter, + eventReporter, }: { adbId: string; bundleId: string; appUid: number; + isAppRunning: () => Promise; crashArtifactWriter?: CrashArtifactWriter; -}): AndroidAppMonitor => { - const emitter = getEmitter(); - - let isStarted = false; + eventReporter?: AppMonitorReporter; +}): AppLifecycleMonitor => { let logcatProcess: Subprocess | null = null; let logTask: Promise | null = null; + let pollTask: Promise | null = null; + let pollAbortController: AbortController | null = null; let recentLogLines: TimedLogLine[] = []; let recentCrashArtifacts: AndroidCrashArtifact[] = []; + let currentLaunchId: string | undefined; + let currentTestFilePath = ''; + let currentPhase: AppLifecyclePhase = 'startup'; + let alive = false; + let monitoring = true; + let disposed = false; + let resolvingCrash = false; + let crashReported = false; + let controlledStop = false; + let startedReported = false; + let lastKnownRunning: boolean | null = null; + let pendingCrash: PendingCrash | null = null; + const watchers = new Set(); + + const reportEvent = (event: Parameters[0]) => { + try { + eventReporter?.(event); + } catch (error) { + androidAppMonitorLogger.debug('Android app monitor event reporter failed', error); + } + }; - const emit = (event: AppMonitorEvent) => { - emitter.emit(event); + const createReportedDetails = (details?: AppCrashDetails) => { + const normalizedDetails = details ? normalizeCrashDetails(details) : undefined; + + return { + timestamp: Date.now(), + appPlatform: 'android' as const, + targetIdentifier: adbId, + testFile: currentTestFilePath || undefined, + phase: currentTestFilePath ? currentPhase : undefined, + launchId: normalizedDetails?.launchId ?? currentLaunchId, + processName: normalizedDetails?.processName, + pid: normalizedDetails?.pid, + source: normalizedDetails?.source, + summary: normalizedDetails?.summary, + kind: normalizedDetails?.kind, + confidence: normalizedDetails?.confidence, + signal: normalizedDetails?.signal, + exceptionType: normalizedDetails?.exceptionType, + artifactType: normalizedDetails?.artifactType, + artifactPath: normalizedDetails?.artifactPath, + crashDetails: normalizedDetails, + }; + }; + + const reportWarning = (warning: string, details?: AppCrashDetails) => { + reportEvent({ + type: 'app:monitor-warning', + ...createReportedDetails(details), + warning, + }); + }; + + const resetTransientState = () => { + recentLogLines = []; + recentCrashArtifacts = []; + pendingCrash = null; + lastKnownRunning = null; + alive = false; + crashReported = false; + resolvingCrash = false; + controlledStop = false; + startedReported = false; + }; + + const normalizeCrashDetails = (details: AppCrashDetails): AppCrashDetails => ({ + ...details, + platform: details.platform ?? 'android', + occurredAt: details.occurredAt ?? Date.now(), + launchId: details.launchId ?? currentLaunchId, + }); + + const getActivePendingCrash = () => { + if (!pendingCrash) { + return null; + } + + if (Date.now() - pendingCrash.occurredAt > CRASH_CORRELATION_WINDOW_MS) { + pendingCrash = null; + return null; + } + + return pendingCrash; + }; + + const recordPendingCrash = (details: AppCrashDetails) => { + const normalized = normalizeCrashDetails(details); + const activePendingCrash = getActivePendingCrash(); + + pendingCrash = { + details: mergeCrashDetails(activePendingCrash?.details, normalized), + occurredAt: activePendingCrash?.occurredAt ?? normalized.occurredAt ?? Date.now(), + }; }; const recordLogLine = (line: string) => { - recentLogLines = [ - ...recentLogLines, - { line, occurredAt: Date.now() }, - ].slice(-MAX_RECENT_LOG_LINES); + recentLogLines = [...recentLogLines, { line, occurredAt: Date.now() }].slice( + -MAX_RECENT_LOG_LINES, + ); }; const recordCrashArtifact = (details?: AppCrashDetails) => { @@ -422,13 +653,19 @@ export const createAndroidAppMonitor = ({ recentCrashArtifacts = [ ...recentCrashArtifacts, - createCrashArtifact({ - details, - recentLogLines, - }), + createCrashArtifact({ details: normalizeCrashDetails(details), recentLogLines }), ].slice(-MAX_RECENT_CRASH_ARTIFACTS); }; + const notifyCrash = (error: NativeCrashError) => { + const pendingWatchers = [...watchers]; + watchers.clear(); + + for (const reject of pendingWatchers) { + reject(error); + } + }; + const stopProcess = async (child: Subprocess | null) => { if (!child) { return; @@ -441,137 +678,335 @@ export const createAndroidAppMonitor = ({ } }; - const startLogcat = async () => { - const logcatTimestamp = await adb.getLogcatTimestamp(adbId); + const resolveCrashDetails = async ( + details?: AppCrashDetails, + fallbackSummary?: string, + ) => { + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS), + ); - logcatProcess = adb.startLogcat( - adbId, - getLogcatArgs(appUid, logcatTimestamp) + const initialDetails = details ? normalizeCrashDetails(details) : undefined; + const enriched = initialDetails + ? getLatestCrashArtifact({ + crashArtifacts: recentCrashArtifacts, + recentLogLines, + processName: initialDetails.processName, + pid: initialDetails.pid, + occurredAt: initialDetails.occurredAt ?? Date.now(), + }) + : null; + + const mergedDetails = mergeNativeCrashDetails( + currentPhase, + initialDetails, + enriched, + fallbackSummary, ); - const currentProcess = logcatProcess; + return persistCrashArtifact({ + details: mergedDetails, + crashArtifactWriter, + }) as NativeCrashDetails; + }; - if (!currentProcess) { + const confirmCrash = async ( + details?: AppCrashDetails, + fallbackSummary?: string, + ) => { + if (disposed || !monitoring || crashReported || resolvingCrash) { return; } - logTask = (async () => { - try { - for await (const line of currentProcess) { - recordLogLine(line); - emit({ type: 'log', source: 'logs', line }); + resolvingCrash = true; + alive = false; + crashReported = true; - const event = createAndroidLogEvent(line, bundleId); + const initialDetails = details ? normalizeCrashDetails(details) : pendingCrash?.details; - 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') - ) { - androidAppMonitorLogger.debug( - 'Android logcat monitor stopped', - error - ); - } + reportEvent({ + type: 'app:crash-confirmed', + ...createReportedDetails(initialDetails), + }); + + try { + const resolvedDetails = await resolveCrashDetails(details, fallbackSummary); + + if (resolvedDetails.artifactType || resolvedDetails.artifactPath) { + reportEvent({ + type: 'app:crash-report-ready', + ...createReportedDetails(resolvedDetails), + crashDetails: resolvedDetails, + }); } - })(); + + notifyCrash(new NativeCrashError(currentTestFilePath, resolvedDetails)); + } finally { + resolvingCrash = false; + pendingCrash = null; + } }; - const start = async () => { - if (isStarted) { + const handleAppExit = (details?: AppCrashDetails) => { + alive = false; + startedReported = false; + + if (details) { + reportEvent({ + type: 'app:exited', + ...createReportedDetails(details), + }); + } + + if (controlledStop || disposed || !monitoring || crashReported) { return; } - try { - await startLogcat(); - isStarted = true; - } catch (error) { - const currentProcess = logcatProcess; - const currentTask = logTask; + const activePendingCrash = getActivePendingCrash(); - logcatProcess = null; - logTask = null; + if (!activePendingCrash) { + return; + } - await stopProcess(currentProcess); - await currentTask; + void confirmCrash( + mergeCrashDetails(activePendingCrash.details, { + ...details, + kind: details?.kind, + confidence: details?.confidence, + }), + ); + }; - throw error; + const handleLogEvent = (event: AndroidMonitorSignal) => { + if (disposed || !monitoring) { + return; } - }; - const stop = async () => { - if (!isStarted) { + if (event.type === 'app_started') { + if (!startedReported) { + reportEvent({ + type: 'app:started', + ...createReportedDetails({ + platform: 'android', + processName: bundleId, + pid: event.pid, + source: 'logs', + summary: `Process ${bundleId} started`, + }), + }); + startedReported = true; + } + + alive = true; + controlledStop = false; + pendingCrash = null; + crashReported = false; + lastKnownRunning = true; return; } - isStarted = false; + if (event.type === 'app_exited') { + recordCrashArtifact(event.crashDetails); + handleAppExit(event.crashDetails); + return; + } - const currentProcess = logcatProcess; - const currentTask = logTask; + recordCrashArtifact(event.crashDetails); - logcatProcess = null; - logTask = null; + if (event.type === 'crash_suspected') { + reportEvent({ + type: 'app:crash-suspected', + ...createReportedDetails(event.crashDetails), + }); + recordPendingCrash(event.crashDetails); + return; + } - await stopProcess(currentProcess); - await currentTask; - }; + recordPendingCrash(event.crashDetails); - const dispose = async () => { - await stop(); - emitter.clearAllListeners(); - recentLogLines = []; - recentCrashArtifacts = []; + const activePendingCrash = getActivePendingCrash(); + void confirmCrash(activePendingCrash?.details ?? event.crashDetails); }; - const addListener = (listener: AppMonitorListener) => { - emitter.addListener(listener); + const startPoller = () => { + const abortController = new AbortController(); + pollAbortController = abortController; + + pollTask = (async () => { + while (!abortController.signal.aborted) { + try { + const running = await isAppRunning(); + const wasRunning = lastKnownRunning; + + lastKnownRunning = running; + + if (running) { + if (!startedReported) { + reportEvent({ + type: 'app:started', + ...createReportedDetails({ + platform: 'android', + processName: bundleId, + source: 'polling', + summary: `Process ${bundleId} is running`, + }), + }); + startedReported = true; + } + + alive = true; + + if (wasRunning === false) { + controlledStop = false; + pendingCrash = null; + crashReported = false; + } + } else { + handleAppExit( + wasRunning + ? { + platform: 'android', + kind: 'process-exit', + confidence: 'medium', + source: 'polling', + processName: bundleId, + summary: `Process ${bundleId} exited`, + } + : undefined, + ); + } + } catch (error) { + if (!abortController.signal.aborted) { + androidAppMonitorLogger.debug( + 'Android process poller failed', + error, + ); + reportWarning('Android process poller failed'); + } + } + + await waitForPollInterval(abortController.signal); + } + })(); }; - const removeListener = (listener: AppMonitorListener) => { - emitter.removeListener(listener); + const stopPoller = async () => { + const abortController = pollAbortController; + const currentTask = pollTask; + + pollAbortController = null; + pollTask = null; + + abortController?.abort(); + await currentTask; }; - 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, - }); + const startCollectors = async () => { + const logcatTimestamp = await adb.getLogcatTimestamp(adbId); - if (!details) { - return null; + logcatProcess = adb.startLogcat(adbId, getLogcatArgs(appUid, logcatTimestamp)); + const currentProcess = logcatProcess; + + logTask = (async () => { + try { + for await (const line of currentProcess) { + recordLogLine(line); + + const event = createAndroidLogEvent(line, bundleId); + + if (event) { + handleLogEvent(event); + } + } + } catch (error) { + if (!(error instanceof SubprocessError && error.signalName === 'SIGTERM')) { + androidAppMonitorLogger.debug('Android logcat monitor stopped', error); + reportWarning('Android logcat monitor stopped unexpectedly'); + } } + })(); - return persistCrashArtifact({ - details, - crashArtifactWriter, + startPoller(); + }; + + const stopCollectors = async () => { + const currentProcess = logcatProcess; + const currentTask = logTask; + + logcatProcess = null; + logTask = null; + + await stopProcess(currentProcess); + await currentTask; + await stopPoller(); + }; + + return { + start: async () => { + monitoring = true; + await startCollectors(); + }, + stop: async () => { + monitoring = false; + await stopCollectors(); + }, + dispose: async () => { + disposed = true; + monitoring = false; + alive = false; + pendingCrash = null; + watchers.clear(); + await stopCollectors(); + }, + launchRequested: (event: LaunchRequestedEvent) => { + currentLaunchId = event.launchId; + alive = false; + controlledStop = false; + pendingCrash = null; + crashReported = false; + }, + launchCompleted: () => { + alive = true; + controlledStop = false; + }, + launchFailed: () => { + alive = false; + pendingCrash = null; + }, + stopRequested: () => { + controlledStop = true; + alive = false; + pendingCrash = null; + }, + stopCompleted: () => undefined, + watch: (testFilePath, phase) => { + currentTestFilePath = testFilePath; + currentPhase = phase; + let rejectFn!: WatchReject; + + const promise = new Promise((_, reject) => { + rejectFn = (error) => { + watchers.delete(rejectFn); + reject(error); + }; + watchers.add(rejectFn); }); + + return { + promise, + cancel: () => { + rejectFn(new CrashWatchCancelledError()); + }, + }; }, - } satisfies AndroidAppMonitor; + reset: () => { + currentTestFilePath = ''; + currentPhase = 'startup'; + watchers.clear(); + resetTransientState(); + }, + isAlive: () => alive, + }; }; export { createAndroidLogEvent }; -export type AndroidAppMonitor = AppMonitor & { - getCrashDetails: ( - options: CrashDetailsLookupOptions - ) => Promise; -}; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index d28eb177..47c4abf7 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -2,6 +2,7 @@ import { AppNotInstalledError, CreateAppMonitorOptions, DeviceNotFoundError, + createNoopAppLifecycleMonitor, type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; @@ -33,18 +34,9 @@ import { } from './environment.js'; import { isInteractive } from '@react-native-harness/tools'; import fs from 'node:fs'; -import type { AppMonitor } from '@react-native-harness/platforms'; const androidInstanceLogger = logger.child('android-instance'); -const createNoopAppMonitor = (): AppMonitor => ({ - start: async () => undefined, - stop: async () => undefined, - dispose: async () => undefined, - addListener: () => undefined, - removeListener: () => undefined, -}); - const getHarnessAppPath = (): string => { const appPath = process.env.HARNESS_APP_PATH; @@ -83,13 +75,45 @@ const configureAndroidRuntime = async ( await Promise.all([ adb.reversePort(adbId, metroPort), adb.reversePort(adbId, 8080), - adb.setHideErrorDialogs(adbId, true), + setHideErrorDialogsBestEffort(adbId, true), applyHarnessDebugHttpHost(adbId, config.bundleId, `localhost:${metroPort}`), ]); return adb.getAppUid(adbId, config.bundleId); }; +const setHideErrorDialogsBestEffort = async ( + adbId: string, + hide: boolean, +): Promise => { + try { + await adb.setHideErrorDialogs(adbId, hide); + } catch (error) { + androidInstanceLogger.warn( + 'failed to %s Android crash dialogs on %s: %s', + hide ? 'hide' : 'restore', + adbId, + error, + ); + } +}; + +const grantPermissionsBestEffort = async ( + adbId: string, + bundleId: string, +): Promise => { + try { + await adb.grantPermissions(adbId, bundleId); + } catch (error) { + androidInstanceLogger.warn( + 'failed to grant Android permissions for %s on %s: %s', + bundleId, + adbId, + error, + ); + } +}; + const startAndWaitForBoot = async ({ emulatorName, signal, @@ -278,7 +302,7 @@ export const getAndroidEmulatorPlatformInstance = async ( const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); if (permissionsEnabled) { - await adb.grantPermissions(adbId, config.bundleId); + await grantPermissionsBestEffort(adbId, config.bundleId); } return { @@ -307,7 +331,7 @@ export const getAndroidEmulatorPlatformInstance = async ( dispose: async () => { await adb.stopApp(adbId, config.bundleId); await clearHarnessDebugHttpHost(adbId, config.bundleId); - await adb.setHideErrorDialogs(adbId, false); + await setHideErrorDialogsBestEffort(adbId, false); if (startedByHarness) { logger.info('Shutting down Android emulator %s...', emulatorName); @@ -319,14 +343,16 @@ export const getAndroidEmulatorPlatformInstance = async ( }, createAppMonitor: (options?: CreateAppMonitorOptions) => { if (!detectNativeCrashes) { - return createNoopAppMonitor(); + return createNoopAppLifecycleMonitor(); } return createAndroidAppMonitor({ adbId, bundleId: config.bundleId, appUid, + isAppRunning: () => adb.isAppRunning(adbId, config.bundleId), crashArtifactWriter: options?.crashArtifactWriter, + eventReporter: options?.eventReporter, }); }, }; @@ -358,7 +384,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); if (permissionsEnabled) { - await adb.grantPermissions(adbId, config.bundleId); + await grantPermissionsBestEffort(adbId, config.bundleId); } return { @@ -387,21 +413,23 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( dispose: async () => { await adb.stopApp(adbId, config.bundleId); await clearHarnessDebugHttpHost(adbId, config.bundleId); - await adb.setHideErrorDialogs(adbId, false); + await setHideErrorDialogsBestEffort(adbId, false); }, isAppRunning: async () => { return await adb.isAppRunning(adbId, config.bundleId); }, createAppMonitor: (options?: CreateAppMonitorOptions) => { if (!detectNativeCrashes) { - return createNoopAppMonitor(); + return createNoopAppLifecycleMonitor(); } return createAndroidAppMonitor({ adbId, bundleId: config.bundleId, appUid, + isAppRunning: () => adb.isAppRunning(adbId, config.bundleId), crashArtifactWriter: options?.crashArtifactWriter, + eventReporter: options?.eventReporter, }); }, }; diff --git a/packages/platform-ios/src/__tests__/app-monitor.test.ts b/packages/platform-ios/src/__tests__/app-monitor.test.ts index fe50fafa..f4e55596 100644 --- a/packages/platform-ios/src/__tests__/app-monitor.test.ts +++ b/packages/platform-ios/src/__tests__/app-monitor.test.ts @@ -2,6 +2,7 @@ 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 { NativeCrashError } from '@react-native-harness/platforms'; import { createIosDeviceAppMonitor, createIosSimulatorAppMonitor, @@ -40,13 +41,15 @@ describe('createUnifiedLogEvent', () => { 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'], + platform: 'ios-simulator', }); expect(event).toMatchObject({ - type: 'possible_crash', - source: 'logs', - isConfirmed: true, + type: 'crash_suspected', crashDetails: { + platform: 'ios-simulator', + kind: 'native-crash', + confidence: 'medium', source: 'logs', processName: 'HarnessPlayground', pid: 1234, @@ -59,13 +62,15 @@ describe('createUnifiedLogEvent', () => { 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'], + platform: 'ios-simulator', }); expect(event).toMatchObject({ - type: 'possible_crash', - source: 'logs', - isConfirmed: true, + type: 'crash_suspected', crashDetails: { + platform: 'ios-simulator', + kind: 'native-crash', + confidence: 'medium', source: 'logs', processName: 'HarnessPlayground', pid: 34784, @@ -77,6 +82,7 @@ describe('createUnifiedLogEvent', () => { const event = createUnifiedLogEvent({ line: '2026-03-12 11:35:08.000 runningboardd[55:aaaa] Acquiring assertion for com.harnessplayground', processNames: ['HarnessPlayground', 'com.harnessplayground'], + platform: 'ios-simulator', }); expect(event).toBeNull(); @@ -110,6 +116,7 @@ describe('createIosSimulatorAppMonitor', () => { const monitor = createIosSimulatorAppMonitor({ udid: 'sim-udid', bundleId: 'com.harnessplayground', + isAppRunning: async () => true, }); await monitor.start(); @@ -121,7 +128,102 @@ describe('createIosSimulatorAppMonitor', () => { ); }); - it('returns best-effort simulator crash details from recent log blocks', async () => { + it('reports simulator lifecycle and crash events through the reporter', async () => { + vi.useFakeTimers(); + + const eventReporter = vi.fn(); + vi.spyOn(simctl, 'streamLogs').mockReturnValue( + createStreamingSubprocess([ + { + line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', + }, + ]) + ); + vi.spyOn(diagnostics, 'waitForCrashArtifact').mockResolvedValue({ + artifactType: 'ios-crash-report', + artifactPath: '/tmp/report.ips', + processName: 'HarnessPlayground', + pid: 1234, + 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 isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(false); + + const monitor = createIosSimulatorAppMonitor({ + udid: 'sim-udid', + bundleId: 'com.harnessplayground', + isAppRunning, + eventReporter, + }); + const crashWatch = monitor.watch('example.test.ts', 'startup'); + crashWatch.promise.catch(() => undefined); + + try { + await monitor.start(); + monitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + monitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + await vi.advanceTimersByTimeAsync(1010); + + expect(eventReporter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'app:crash-suspected', + appPlatform: 'ios-simulator', + targetIdentifier: 'sim-udid', + launchId: 'launch-1', + }), + ); + expect(eventReporter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'app:crash-confirmed', + appPlatform: 'ios-simulator', + targetIdentifier: 'sim-udid', + }), + ); + expect(eventReporter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'app:crash-report-ready', + appPlatform: 'ios-simulator', + artifactType: 'ios-crash-report', + }), + ); + expect(eventReporter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'app:exited', + appPlatform: 'ios-simulator', + }), + ); + + await monitor.stop(); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects the watch with best-effort simulator crash details from recent log blocks', async () => { + vi.useFakeTimers(); + vi.spyOn(simctl, 'streamLogs').mockReturnValue( createStreamingSubprocess([ { @@ -133,6 +235,11 @@ describe('createIosSimulatorAppMonitor', () => { }, ]) ); + const isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(false); vi.spyOn(diagnostics, 'waitForCrashArtifact').mockResolvedValue({ source: 'logs', processName: 'HarnessPlayground', @@ -156,26 +263,46 @@ describe('createIosSimulatorAppMonitor', () => { const monitor = createIosSimulatorAppMonitor({ udid: 'sim-udid', bundleId: 'com.harnessplayground', + isAppRunning, }); + const crashWatch = monitor.watch('example.test.ts', 'startup'); + crashWatch.promise.catch(() => undefined); + + try { + await monitor.start(); + monitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + monitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + await vi.advanceTimersByTimeAsync(1010); + await expect(crashWatch.promise).rejects.toBeInstanceOf(NativeCrashError); + await expect(crashWatch.promise).rejects.toMatchObject({ + testFilePath: 'example.test.ts', + details: { + phase: 'startup', + processName: 'HarnessPlayground', + pid: 1234, + exceptionType: 'NSInternalInconsistencyException', + }, + }); - 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', - }); + await monitor.stop(); + } finally { + vi.useRealTimers(); + } }); - it('prefers a matched simulator crash report when one is found', async () => { + it('rejects the watch with a matched simulator crash report when one is found', async () => { + vi.useFakeTimers(); + vi.spyOn(simctl, 'streamLogs').mockReturnValue( createStreamingSubprocess([ { @@ -183,6 +310,11 @@ describe('createIosSimulatorAppMonitor', () => { }, ]) ); + const isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(false); const sourcePath = join( artifactRoot, 'HarnessPlayground-2026-03-12-122756.ips' @@ -210,6 +342,7 @@ describe('createIosSimulatorAppMonitor', () => { const monitor = createIosSimulatorAppMonitor({ udid: 'sim-udid', bundleId: 'com.harnessplayground', + isAppRunning, crashArtifactWriter: createCrashArtifactWriter({ runnerName: 'ios-simulator', platformId: 'ios', @@ -217,18 +350,34 @@ describe('createIosSimulatorAppMonitor', () => { runTimestamp: '2026-03-12T11-35-08-000Z', }), }); - - await monitor.start(); - const details = await monitor.getCrashDetails({ - pid: 1234, - occurredAt: Date.now(), - }); - await monitor.stop(); - - expect(details).toMatchObject({ - artifactType: 'ios-crash-report', - summary: 'simulator crash report', - }); + const crashWatch = monitor.watch('example.test.ts', 'startup'); + crashWatch.promise.catch(() => undefined); + + try { + await monitor.start(); + monitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + monitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + await vi.advanceTimersByTimeAsync(1000); + await expect(crashWatch.promise).rejects.toMatchObject({ + details: { + artifactType: 'ios-crash-report', + summary: 'simulator crash report', + }, + }); + await monitor.stop(); + } finally { + vi.useRealTimers(); + } }); }); @@ -237,7 +386,7 @@ describe('createIosDeviceAppMonitor', () => { vi.restoreAllMocks(); }); - it('polls device processes and emits app_exited when the app disappears', async () => { + it('rejects the watch when the app disappears from device processes', async () => { vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ bundleIdentifier: 'com.harnessplayground', name: 'HarnessPlayground', @@ -245,6 +394,7 @@ describe('createIosDeviceAppMonitor', () => { url: '/private/var/HarnessPlayground.app', }); vi.spyOn(diagnostics, 'collectCrashArtifacts').mockResolvedValue([]); + vi.spyOn(diagnostics, 'waitForCrashArtifact').mockResolvedValue(null); const getProcesses = vi .spyOn(devicectl, 'getProcesses') .mockResolvedValueOnce([ @@ -256,31 +406,57 @@ describe('createIosDeviceAppMonitor', () => { .mockResolvedValueOnce([]) .mockResolvedValue([]); - const events: Array<{ type: string }> = []; const monitor = createIosDeviceAppMonitor({ deviceId: 'device-udid', bundleId: 'com.harnessplayground', + isAppRunning: async () => false, }); - monitor.addListener((event) => { - events.push(event); - }); + const crashWatch = monitor.watch('example.test.ts', 'execution'); + crashWatch.promise.catch(() => undefined); await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 1200)); + monitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + monitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + await expect(crashWatch.promise).rejects.toMatchObject({ + details: { + phase: 'execution', + platform: 'ios-device', + source: 'polling', + processName: 'HarnessPlayground', + pid: 4321, + }, + }); await monitor.stop(); expect(getProcesses).toHaveBeenCalled(); - expect(events.some((event) => event.type === 'app_exited')).toBe(true); }); - it('enriches device crashes with Apple-native pulled crash reports', async () => { + it('rejects the watch with Apple-native pulled crash reports for device crashes', async () => { vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ bundleIdentifier: 'com.harnessplayground', name: 'HarnessPlayground', version: '1.0', url: '/private/var/HarnessPlayground.app', }); - vi.spyOn(devicectl, 'getProcesses').mockResolvedValue([]); + vi.spyOn(devicectl, 'getProcesses') + .mockResolvedValueOnce([ + { + executable: '/private/var/HarnessPlayground.app/HarnessPlayground', + processIdentifier: 1234, + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValue([]); vi.spyOn(diagnostics, 'collectCrashArtifacts').mockResolvedValue([]); const sourcePath = join(artifactRoot, 'HarnessPlayground.crash'); @@ -299,6 +475,7 @@ describe('createIosDeviceAppMonitor', () => { const monitor = createIosDeviceAppMonitor({ deviceId: 'device-udid', bundleId: 'com.harnessplayground', + isAppRunning: async () => false, crashArtifactWriter: createCrashArtifactWriter({ runnerName: 'ios-device', platformId: 'ios', @@ -306,17 +483,28 @@ describe('createIosDeviceAppMonitor', () => { runTimestamp: '2026-03-12T11-35-08-000Z', }), }); + const crashWatch = monitor.watch('example.test.ts', 'execution'); + crashWatch.promise.catch(() => undefined); await monitor.start(); - const details = await monitor.getCrashDetails({ - pid: 1234, - occurredAt: Date.now(), + monitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', }); - await monitor.stop(); - - expect(details).toMatchObject({ - artifactType: 'ios-crash-report', - summary: 'full crash report', + monitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + }); + await expect(crashWatch.promise).rejects.toMatchObject({ + details: { + artifactType: 'ios-crash-report', + summary: 'full crash report', + }, }); + await monitor.stop(); }); }); diff --git a/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts index 6aa0cf7d..52a5a964 100644 --- a/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts +++ b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts @@ -1,49 +1,63 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +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 { createCrashArtifactWriter } from '@react-native-harness/tools'; -import { collectCrashArtifacts } from '../crash-diagnostics.js'; -import * as simctl from '../xcrun/simctl.js'; +import { + collectCrashArtifacts, + waitForCrashArtifact, +} from '../crash-diagnostics.js'; import * as devicectl from '../xcrun/devicectl.js'; +const writeIosIpsCrashReport = (path: string) => { + fs.writeFileSync( + path, + [ + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + timestamp: '2026-03-12 11:35:08 +0000', + }), + JSON.stringify({ + pid: 1234, + procName: 'HarnessPlayground', + procPath: + '/Users/me/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground', + exception: { + type: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + }, + }), + ].join('\n'), + 'utf8', + ); +}; + describe('collectCrashArtifacts', () => { + const originalDiagnosticReportsDir = + process.env.RN_HARNESS_IOS_DIAGNOSTIC_REPORTS_DIR; + let diagnosticReportsDir: string; + beforeEach(() => { vi.restoreAllMocks(); + diagnosticReportsDir = fs.mkdtempSync( + join(tmpdir(), 'rn-harness-diagnostic-reports-'), + ); + process.env.RN_HARNESS_IOS_DIAGNOSTIC_REPORTS_DIR = diagnosticReportsDir; }); - it('collects simulator crash artifacts from simctl diagnose output', async () => { - const outputRoot = fs.mkdtempSync( - join(tmpdir(), 'rn-harness-simctl-diagnose-'), - ); - const crashPath = join(outputRoot, 'HarnessPlayground.ips'); - fs.writeFileSync( - crashPath, - [ - JSON.stringify({ - app_name: 'HarnessPlayground', - bundleID: 'com.harnessplayground', - timestamp: '2026-03-12 11:35:08 +0000', - }), - JSON.stringify({ - pid: 1234, - procName: 'HarnessPlayground', - procPath: - '/Users/me/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground', - exception: { - type: 'EXC_BREAKPOINT', - signal: 'SIGTRAP', - }, - }), - ].join('\n'), - 'utf8', - ); + afterEach(() => { + if (originalDiagnosticReportsDir === undefined) { + delete process.env.RN_HARNESS_IOS_DIAGNOSTIC_REPORTS_DIR; + } else { + process.env.RN_HARNESS_IOS_DIAGNOSTIC_REPORTS_DIR = + originalDiagnosticReportsDir; + } + }); - vi.spyOn(simctl, 'diagnose').mockImplementation( - async (_udid, outputDir) => { - fs.mkdirSync(outputDir, { recursive: true }); - fs.copyFileSync(crashPath, join(outputDir, 'HarnessPlayground.ips')); - }, + it('collects simulator crash artifacts from host DiagnosticReports', async () => { + writeIosIpsCrashReport( + join(diagnosticReportsDir, 'HarnessPlayground-2026-03-12-113508.ips'), ); const artifacts = await collectCrashArtifacts({ @@ -107,43 +121,14 @@ describe('collectCrashArtifacts', () => { }); it('persists matched crash artifacts with the provided writer', async () => { - const sourceRoot = fs.mkdtempSync( - join(tmpdir(), 'rn-harness-crash-diagnostics-'), - ); - const sourcePath = join(sourceRoot, 'HarnessPlayground.ips'); - fs.writeFileSync( - sourcePath, - [ - JSON.stringify({ - app_name: 'HarnessPlayground', - bundleID: 'com.harnessplayground', - timestamp: '2026-03-12 11:35:08 +0000', - }), - JSON.stringify({ - pid: 1234, - procName: 'HarnessPlayground', - procPath: - '/Users/me/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground', - exception: { - type: 'EXC_BREAKPOINT', - signal: 'SIGTRAP', - }, - }), - ].join('\n'), - 'utf8', - ); - - vi.spyOn(simctl, 'diagnose').mockImplementation( - async (_udid, outputDir) => { - fs.mkdirSync(outputDir, { recursive: true }); - fs.copyFileSync(sourcePath, join(outputDir, 'HarnessPlayground.ips')); - }, + writeIosIpsCrashReport( + join(diagnosticReportsDir, 'HarnessPlayground-2026-03-12-113508.ips'), ); const writer = createCrashArtifactWriter({ runnerName: 'ios-sim', platformId: 'ios', - rootDir: join(sourceRoot, '.harness', 'crash-reports'), + rootDir: join(diagnosticReportsDir, '.harness', 'crash-reports'), runTimestamp: '2026-03-12T11-35-08-000Z', }); @@ -158,4 +143,40 @@ describe('collectCrashArtifacts', () => { expect(artifacts[0]?.artifactPath).toContain('/.harness/crash-reports/'); expect(fs.existsSync(artifacts[0]?.artifactPath ?? '')).toBe(true); }); + + it('returns a host crash report without waiting for device crash log lookup to finish', async () => { + writeIosIpsCrashReport( + join(diagnosticReportsDir, 'HarnessPlayground-2026-03-12-113508.ips'), + ); + + vi.spyOn(devicectl, 'listFiles').mockImplementation( + () => + new Promise(() => { + // Keep the device-side collector pending so the host lookup must win. + }), + ); + + const artifact = await waitForCrashArtifact({ + lookup: { + processName: 'HarnessPlayground', + pid: 1234, + occurredAt: Date.parse('2026-03-12T11:35:08.000Z'), + }, + options: { + targetId: 'device-udid', + targetType: 'device', + processNames: ['HarnessPlayground'], + bundleId: 'com.harnessplayground', + minOccurredAt: Date.parse('2026-03-12T11:35:07.000Z'), + }, + getFallbackArtifact: () => null, + recordArtifact: vi.fn(), + }); + + expect(artifact).toMatchObject({ + processName: 'HarnessPlayground', + pid: 1234, + signal: 'SIGTRAP', + }); + }); }); diff --git a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts index eac3fc88..4ec1b607 100644 --- a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts @@ -113,6 +113,7 @@ describe('iOS XCTest agent runner integration', () => { device: { type: 'physical', name: 'My iPhone', + codeSign: { teamId: 'TESTTEAM01' }, }, bundleId: 'com.harnessplayground', }, @@ -132,6 +133,7 @@ describe('iOS XCTest agent runner integration', () => { target: { kind: 'device', id: 'device-udid', + codeSign: { teamId: 'TESTTEAM01' }, }, }); expect(mocks.prepare).not.toHaveBeenCalled(); diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index 7dd0864f..91595e9c 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -210,14 +210,37 @@ describe('iOS platform instance dependency validation', () => { init, ); - const listener = vi.fn(); const appMonitor = instance.createAppMonitor(); await expect(appMonitor.start()).resolves.toBeUndefined(); await expect(appMonitor.stop()).resolves.toBeUndefined(); await expect(appMonitor.dispose()).resolves.toBeUndefined(); - expect(appMonitor.addListener(listener)).toBeUndefined(); - expect(appMonitor.removeListener(listener)).toBeUndefined(); + expect(appMonitor.launchRequested({ + type: 'launch_requested', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + })).toBeUndefined(); + expect(appMonitor.launchCompleted({ + type: 'launch_completed', + launchId: 'launch-1', + at: Date.now(), + reason: 'start', + })).toBeUndefined(); + expect(appMonitor.stopRequested({ + type: 'stop_requested', + at: Date.now(), + reason: 'manual', + })).toBeUndefined(); + expect(appMonitor.stopCompleted({ + type: 'stop_completed', + at: Date.now(), + reason: 'manual', + })).toBeUndefined(); + expect(appMonitor.reset()).toBeUndefined(); + expect(appMonitor.isAlive()).toBe(true); + const watch = appMonitor.watch('example.ts', 'startup'); + watch.cancel(); }); it('reuses a booted simulator and does not shut it down on dispose', async () => { diff --git a/packages/platform-ios/src/__tests__/launch-options.test.ts b/packages/platform-ios/src/__tests__/launch-options.test.ts index b5bc63c8..838da3ce 100644 --- a/packages/platform-ios/src/__tests__/launch-options.test.ts +++ b/packages/platform-ios/src/__tests__/launch-options.test.ts @@ -37,7 +37,6 @@ describe('Apple app launch options', () => { '--environment-variables', '{"FEATURE_X":"1"}', 'com.example.app', - '--', '--mode=test', '--retry=1', ]); diff --git a/packages/platform-ios/src/app-monitor.ts b/packages/platform-ios/src/app-monitor.ts index 0123669d..f38d6cca 100644 --- a/packages/platform-ios/src/app-monitor.ts +++ b/packages/platform-ios/src/app-monitor.ts @@ -1,14 +1,17 @@ import { - type AppMonitor, + CrashWatchCancelledError, + NativeCrashError, type AppCrashDetails, - type AppMonitorEvent, - type AppMonitorListener, + type AppLifecycleMonitor, + type AppLifecyclePhase, + type AppMonitorReporter, type CrashArtifactWriter, type CrashDetailsLookupOptions, + type LaunchCompletedEvent, + type NativeCrashDetails, } from '@react-native-harness/platforms'; import { escapeRegExp, - getEmitter, logger, type Subprocess, } from '@react-native-harness/tools'; @@ -24,7 +27,11 @@ const iosAppMonitorLogger = logger.child('ios-app-monitor'); const MAX_RECENT_LOG_LINES = 200; const MAX_RECENT_CRASH_ARTIFACTS = 10; const CRASH_ARTIFACT_SETTLE_DELAY_MS = 300; -const APP_EXIT_POLL_INTERVAL_MS = 1000; +const PROCESS_POLL_INTERVAL_MS = 250; +const POST_LAUNCH_CRASH_SWEEP_DELAY_MS = 1000; +const RECENT_LAUNCH_WINDOW_MS = 5000; +const SUSPICION_WINDOW_MS = 3000; +const CRASH_ARTIFACT_MIN_OCCURRED_AT_TOLERANCE_MS = 2000; type TimedLogLine = { line: string; @@ -97,20 +104,41 @@ const isRelevantProcessLogLine = (line: string, processNames: string[]) => ); const isCrashSignal = (line: string) => - /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason/i.test( + /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason|watchdog/i.test( line ) || /\bSIG[A-Z]{2,}\b/.test(line); +const classifyIosCrashKind = (line: string): AppCrashDetails['kind'] => { + if (/watchdog/i.test(line)) { + return 'watchdog'; + } + + if ( + /EXC_[A-Z_]+|SIG[A-Z]+|fatal error|uncaught exception|terminating app due to/i.test( + line + ) + ) { + return 'native-crash'; + } + + return 'unknown'; +}; + const getIosLogCrashDetails = ({ line, processNames, + platform, }: { line: string; processNames: string[]; + platform: 'ios-simulator' | 'ios-device'; }): AppCrashDetails => { const exceptionMatch = line.match(/exception[^:]*:\s*([^,]+)/i); return { + platform, + kind: classifyIosCrashKind(line), + confidence: 'medium', source: 'logs', summary: line.trim(), signal: getSignal(line), @@ -121,43 +149,42 @@ const getIosLogCrashDetails = ({ }; }; +type IosMonitorSignal = { + type: 'crash_suspected'; + crashDetails: AppCrashDetails; +}; + export const createUnifiedLogEvent = ({ line, processNames, + platform, }: { line: string; processNames: string[]; -}): AppMonitorEvent | null => { + platform: 'ios-simulator' | 'ios-device'; +}): IosMonitorSignal | null => { if (!isRelevantProcessLine(line, processNames)) { return null; } - if (isCrashSignal(line)) { - return { - type: 'possible_crash', - source: 'logs', - line, - isConfirmed: true, - crashDetails: getIosLogCrashDetails({ - line, - processNames, - }), - }; + if (!isCrashSignal(line)) { + return null; } - return null; + return { + type: 'crash_suspected', + crashDetails: getIosLogCrashDetails({ + line, + processNames, + platform, + }), + }; }; 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, @@ -170,7 +197,7 @@ const createAppMonitorBase = () => { ...recentCrashArtifacts, { ...details, - occurredAt: Date.now(), + occurredAt: details.occurredAt ?? Date.now(), }, ].slice(-MAX_RECENT_CRASH_ARTIFACTS); }; @@ -183,15 +210,15 @@ const createAppMonitorBase = () => { : []; const matchingByProcess = options.processName ? recentCrashArtifacts.filter( - (artifact) => artifact.processName === options.processName - ) + (artifact) => artifact.processName === options.processName + ) : []; const candidates = matchingByPid.length > 0 ? matchingByPid : matchingByProcess.length > 0 - ? matchingByProcess - : recentCrashArtifacts; + ? matchingByProcess + : recentCrashArtifacts; const preferredCandidates = candidates.filter( (artifact) => artifact.artifactType === 'ios-crash-report' ); @@ -207,114 +234,105 @@ const createAppMonitorBase = () => { ); }; - 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; - } + const getRecentLogLines = () => recentLogLines; - 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(); + return { + recordLogLine, + recordCrashArtifact, + getLatestCrashArtifact, + getRecentLogLines, + reset: () => { recentLogLines = []; recentCrashArtifacts = []; - }; + }, + }; +}; - const addListener = (listener: AppMonitorListener) => { - emitter.addListener(listener); - }; +const mergeCrashDetails = ( + existing?: AppCrashDetails, + incoming?: AppCrashDetails | null +): AppCrashDetails | undefined => { + if (!existing) { + return incoming ?? undefined; + } - const removeListener = (listener: AppMonitorListener) => { - emitter.removeListener(listener); - }; + if (!incoming) { + return existing; + } - return { - start, - stop, - dispose, - addListener, - removeListener, - getCrashDetails, - }; + return { + platform: incoming.platform ?? existing.platform, + kind: incoming.kind ?? existing.kind, + confidence: incoming.confidence ?? existing.confidence, + occurredAt: incoming.occurredAt ?? existing.occurredAt, + launchId: incoming.launchId ?? existing.launchId, + source: incoming.source ?? existing.source, + summary: incoming.summary ?? existing.summary, + signal: incoming.signal ?? existing.signal, + exceptionType: incoming.exceptionType ?? existing.exceptionType, + processName: incoming.processName ?? existing.processName, + pid: incoming.pid ?? existing.pid, + stackTrace: incoming.stackTrace ?? existing.stackTrace, + rawLines: incoming.rawLines ?? existing.rawLines, + artifactType: incoming.artifactType ?? existing.artifactType, + artifactPath: incoming.artifactPath ?? existing.artifactPath, }; +}; + +const mergeNativeCrashDetails = ({ + phase, + initial, + enriched, + fallbackSummary, +}: { + phase: AppLifecyclePhase; + initial?: AppCrashDetails; + enriched?: AppCrashDetails | null; + fallbackSummary?: string; +}): NativeCrashDetails => ({ + phase, + platform: enriched?.platform ?? initial?.platform, + kind: enriched?.kind ?? initial?.kind, + confidence: enriched?.confidence ?? initial?.confidence, + occurredAt: enriched?.occurredAt ?? initial?.occurredAt, + launchId: enriched?.launchId ?? initial?.launchId, + source: enriched?.source ?? initial?.source, + summary: enriched?.summary ?? initial?.summary ?? fallbackSummary, + signal: enriched?.signal ?? initial?.signal, + exceptionType: enriched?.exceptionType ?? initial?.exceptionType, + processName: enriched?.processName ?? initial?.processName, + pid: enriched?.pid ?? initial?.pid, + stackTrace: enriched?.stackTrace ?? initial?.stackTrace, + rawLines: enriched?.rawLines ?? initial?.rawLines, + artifactType: enriched?.artifactType ?? initial?.artifactType, + artifactPath: enriched?.artifactPath ?? initial?.artifactPath, +}); + +const normalizeCrashDetails = ( + details: AppCrashDetails | null | undefined, + platform: 'ios-simulator' | 'ios-device', + launchId?: string +): AppCrashDetails | undefined => { + if (!details) { + return undefined; + } return { - createLifecycle, - emit, - handleLogEvent, - recordCrashArtifact, - getLatestCrashArtifact, - getRecentLogLines: () => recentLogLines, - stopProcess, + platform: details.platform ?? platform, + kind: details.kind ?? 'unknown', + confidence: details.confidence ?? 'medium', + occurredAt: details.occurredAt ?? Date.now(), + launchId: details.launchId ?? launchId, + source: details.source, + summary: details.summary, + signal: details.signal, + exceptionType: details.exceptionType, + processName: details.processName, + pid: details.pid, + stackTrace: details.stackTrace, + rawLines: details.rawLines, + artifactType: details.artifactType, + artifactPath: details.artifactPath, }; }; @@ -361,17 +379,21 @@ const toLogOnlyDetails = ({ const createCrashDetailsLookup = ({ targetId, targetType, + platform, bundleId, - processNames, - monitorStartedAt, + getProcessNames, + getMinOccurredAt, + getCurrentLaunchId, crashArtifactWriter, base, }: { targetId: string; targetType: 'simulator' | 'device'; + platform: 'ios-simulator' | 'ios-device'; bundleId: string; - processNames: string[]; - monitorStartedAt: number; + getProcessNames: () => string[]; + getMinOccurredAt: () => number | undefined; + getCurrentLaunchId: () => string | undefined; crashArtifactWriter?: CrashArtifactWriter; base: ReturnType; }) => { @@ -386,9 +408,9 @@ const createCrashDetailsLookup = ({ targetId, targetType, bundleId, - processNames, + processNames: getProcessNames(), crashArtifactWriter, - minOccurredAt: monitorStartedAt, + minOccurredAt: getMinOccurredAt(), }, getFallbackArtifact: () => base.getLatestCrashArtifact(options), recordArtifact: (details) => base.recordCrashArtifact(details), @@ -398,207 +420,926 @@ const createCrashDetailsLookup = ({ return null; } - if (artifact.artifactType === 'ios-crash-report') { - return artifact; + const normalizedArtifact = normalizeCrashDetails( + { + ...artifact, + kind: + artifact.artifactType === 'ios-crash-report' + ? 'crash-report' + : artifact.kind, + confidence: + artifact.artifactType === 'ios-crash-report' + ? 'high' + : artifact.confidence, + }, + platform, + getCurrentLaunchId() + ); + + if (!normalizedArtifact) { + return null; + } + + if (normalizedArtifact.artifactType === 'ios-crash-report') { + return normalizedArtifact; } return toLogOnlyDetails({ - artifact, + artifact: normalizedArtifact, recentLogLines: base.getRecentLogLines(), occurredAt: options.occurredAt, }); }; }; +const waitForPollInterval = async (signal: AbortSignal) => { + if (signal.aborted) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, PROCESS_POLL_INTERVAL_MS); + + const onAbort = () => { + clearTimeout(timeout); + signal.removeEventListener('abort', onAbort); + resolve(); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + }); +}; + +const createProcessExitDetails = ({ + platform, + processName, + pid, + summary, +}: { + platform: 'ios-simulator' | 'ios-device'; + processName: string; + pid?: number; + summary: string; +}): AppCrashDetails => ({ + platform, + kind: 'process-exit', + confidence: platform === 'ios-device' ? 'low' : 'medium', + source: 'polling', + processName, + pid, + summary, +}); + +type WatchReject = (error: Error) => void; + +const createIosMonitorRuntime = ({ + platform, + targetIdentifier, + resolveCrashDetails, + eventReporter, + onReset, +}: { + platform: 'ios-simulator' | 'ios-device'; + targetIdentifier: string; + resolveCrashDetails: ( + options: CrashDetailsLookupOptions + ) => Promise; + eventReporter?: AppMonitorReporter; + onReset?: () => void; +}) => { + let alive = false; + let monitoring = false; + let disposed = false; + let resolvingCrash = false; + let crashReported = false; + let controlledStop = false; + let startedReported = false; + let currentLaunchId: string | undefined; + let launchRequestedAt: number | undefined; + let launchCompletedAt: number | undefined; + let currentTestFilePath = ''; + let currentPhase: AppLifecyclePhase = 'startup'; + let pendingCrash: + | { + at: number; + details: AppCrashDetails; + } + | undefined; + const watchers = new Set(); + + const reportEvent = (event: Parameters[0]) => { + try { + eventReporter?.(event); + } catch (error) { + iosAppMonitorLogger.debug('iOS app monitor event reporter failed', error); + } + }; + + const createReportedDetails = (details?: AppCrashDetails) => { + const normalizedDetails = normalizeCrashDetails( + details, + platform, + currentLaunchId + ); + + return { + timestamp: Date.now(), + appPlatform: platform, + targetIdentifier, + testFile: currentTestFilePath || undefined, + phase: currentTestFilePath ? currentPhase : undefined, + launchId: normalizedDetails?.launchId ?? currentLaunchId, + processName: normalizedDetails?.processName, + pid: normalizedDetails?.pid, + source: normalizedDetails?.source, + summary: normalizedDetails?.summary, + kind: normalizedDetails?.kind, + confidence: normalizedDetails?.confidence, + signal: normalizedDetails?.signal, + exceptionType: normalizedDetails?.exceptionType, + artifactType: normalizedDetails?.artifactType, + artifactPath: normalizedDetails?.artifactPath, + crashDetails: normalizedDetails, + }; + }; + + const reportWarning = (warning: string, details?: AppCrashDetails) => { + reportEvent({ + type: 'app:monitor-warning', + ...createReportedDetails(details), + warning, + }); + }; + + const setMonitoring = (nextMonitoring: boolean) => { + monitoring = nextMonitoring; + }; + + const clearCrashState = () => { + pendingCrash = undefined; + resolvingCrash = false; + crashReported = false; + }; + + const hasRecentSuspicion = () => + pendingCrash !== undefined && + Date.now() - pendingCrash.at <= SUSPICION_WINDOW_MS; + + const isLaunchRecent = () => + launchCompletedAt !== undefined && + Date.now() - launchCompletedAt <= RECENT_LAUNCH_WINDOW_MS; + + const notifyCrash = (error: NativeCrashError) => { + const pendingWatchers = [...watchers]; + watchers.clear(); + + for (const reject of pendingWatchers) { + reject(error); + } + }; + + const recordPendingCrash = (details?: AppCrashDetails) => { + const normalizedDetails = normalizeCrashDetails( + details, + platform, + currentLaunchId + ); + + if (!normalizedDetails) { + return; + } + + pendingCrash = { + at: Date.now(), + details: + mergeCrashDetails( + hasRecentSuspicion() ? pendingCrash?.details : undefined, + normalizedDetails + ) ?? normalizedDetails, + }; + }; + + const confirmCrash = async ( + details?: AppCrashDetails, + fallbackSummary?: string + ) => { + if (disposed || !monitoring || resolvingCrash || crashReported) { + return; + } + + resolvingCrash = true; + alive = false; + + const initialDetails = mergeCrashDetails( + pendingCrash?.details, + normalizeCrashDetails(details, platform, currentLaunchId) + ); + + reportEvent({ + type: 'app:crash-confirmed', + ...createReportedDetails(initialDetails), + }); + + crashReported = true; + pendingCrash = undefined; + + try { + let enrichedDetails: AppCrashDetails | null = null; + + try { + enrichedDetails = await resolveCrashDetails({ + processName: initialDetails?.processName, + pid: initialDetails?.pid, + occurredAt: initialDetails?.occurredAt ?? Date.now(), + }); + } catch (error) { + iosAppMonitorLogger.debug( + 'iOS crash artifact collection failed', + error + ); + reportWarning('iOS crash artifact collection failed', initialDetails); + } + + const mergedDetails = mergeNativeCrashDetails({ + phase: currentPhase, + initial: initialDetails, + enriched: normalizeCrashDetails( + enrichedDetails, + platform, + currentLaunchId + ), + fallbackSummary, + }); + + if (mergedDetails.artifactType || mergedDetails.artifactPath) { + reportEvent({ + type: 'app:crash-report-ready', + ...createReportedDetails(mergedDetails), + crashDetails: mergedDetails, + }); + } + + notifyCrash(new NativeCrashError(currentTestFilePath, mergedDetails)); + } finally { + resolvingCrash = false; + } + }; + + const appStarted = () => { + if (disposed || !monitoring) { + return; + } + + alive = true; + controlledStop = false; + clearCrashState(); + + if (!startedReported) { + reportEvent({ + type: 'app:started', + ...createReportedDetails(), + }); + startedReported = true; + } + }; + + const crashSuspected = (details?: AppCrashDetails) => { + if (disposed || !monitoring || controlledStop) { + return; + } + + reportEvent({ + type: 'app:crash-suspected', + ...createReportedDetails(details), + }); + + recordPendingCrash(details); + }; + + const processExited = (details?: AppCrashDetails) => { + if (disposed || !monitoring) { + return; + } + + const normalizedDetails = normalizeCrashDetails( + details, + platform, + currentLaunchId + ); + + alive = false; + startedReported = false; + + reportEvent({ + type: 'app:exited', + ...createReportedDetails(normalizedDetails), + }); + + if (controlledStop) { + return; + } + + const hasActiveExecutionWatch = + currentPhase === 'execution' && watchers.size > 0; + + if ( + !hasActiveExecutionWatch && + !hasRecentSuspicion() && + !isLaunchRecent() + ) { + return; + } + + recordPendingCrash(normalizedDetails); + void confirmCrash(normalizedDetails, normalizedDetails?.summary); + }; + + const launchRequested = (event: { launchId: string }) => { + currentLaunchId = event.launchId; + launchRequestedAt = Date.now(); + launchCompletedAt = undefined; + alive = false; + startedReported = false; + controlledStop = false; + clearCrashState(); + }; + + const launchCompleted = (event: LaunchCompletedEvent) => { + currentLaunchId = event.launchId; + launchCompletedAt = event.at; + alive = true; + controlledStop = false; + }; + + const launchFailed = () => { + alive = false; + startedReported = false; + launchRequestedAt = undefined; + launchCompletedAt = undefined; + clearCrashState(); + }; + + const stopRequested = () => { + controlledStop = true; + alive = false; + startedReported = false; + pendingCrash = undefined; + }; + + const stopCompleted = () => undefined; + + const watch = (testFilePath: string, phase: AppLifecyclePhase) => { + currentTestFilePath = testFilePath; + currentPhase = phase; + + let rejectFn!: WatchReject; + + const promise = new Promise((_, reject) => { + rejectFn = (error) => { + watchers.delete(rejectFn); + reject(error); + }; + + watchers.add(rejectFn); + }); + + return { + promise, + cancel: () => { + rejectFn(new CrashWatchCancelledError()); + }, + }; + }; + + const reset = () => { + alive = false; + controlledStop = false; + startedReported = false; + currentLaunchId = undefined; + launchRequestedAt = undefined; + launchCompletedAt = undefined; + currentTestFilePath = ''; + currentPhase = 'startup'; + watchers.clear(); + clearCrashState(); + onReset?.(); + }; + + const disposeState = () => { + disposed = true; + monitoring = false; + alive = false; + controlledStop = false; + startedReported = false; + currentLaunchId = undefined; + launchRequestedAt = undefined; + launchCompletedAt = undefined; + watchers.clear(); + clearCrashState(); + }; + + return { + setMonitoring, + disposeState, + launchRequested, + launchCompleted, + launchFailed, + stopRequested, + stopCompleted, + watch, + reset, + isAlive: () => alive, + appStarted, + crashSuspected, + processExited, + confirmCrash, + isControlledStop: () => controlledStop, + isLaunchRecent, + getCurrentLaunchId: () => currentLaunchId, + getArtifactMinOccurredAt: () => { + const minOccurredAt = launchRequestedAt ?? launchCompletedAt; + + if (minOccurredAt === undefined) { + return undefined; + } + + return Math.max( + 0, + minOccurredAt - CRASH_ARTIFACT_MIN_OCCURRED_AT_TOLERANCE_MS + ); + }, + reportWarning, + }; +}; + export const createIosSimulatorAppMonitor = ({ udid, bundleId, + isAppRunning, crashArtifactWriter, + eventReporter, }: { udid: string; bundleId: string; + isAppRunning: () => Promise; crashArtifactWriter?: CrashArtifactWriter; -}): IosAppMonitor => { + eventReporter?: AppMonitorReporter; +}): AppLifecycleMonitor => { const base = createAppMonitorBase(); let logProcess: Subprocess | null = null; let logTask: Promise | null = null; + let pollTask: Promise | null = null; + let pollAbortController: AbortController | null = null; + let launchSweepTimeout: ReturnType | 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 '); + const runtime = createIosMonitorRuntime({ + platform: 'ios-simulator', + targetIdentifier: udid, + resolveCrashDetails: createCrashDetailsLookup({ + targetId: udid, + targetType: 'simulator', + platform: 'ios-simulator', + bundleId, + getProcessNames: () => processNames, + getMinOccurredAt: () => + runtime.getArtifactMinOccurredAt() ?? monitorStartedAt, + getCurrentLaunchId: () => runtime.getCurrentLaunchId(), + crashArtifactWriter, + base, + }), + eventReporter, + onReset: () => { + base.reset(); + }, + }); - logProcess = simctl.streamLogs(udid, predicate); + const clearLaunchSweep = () => { + if (launchSweepTimeout) { + clearTimeout(launchSweepTimeout); + launchSweepTimeout = null; + } + }; - const currentProcess = logProcess; + const scheduleLaunchSweep = () => { + clearLaunchSweep(); + launchSweepTimeout = setTimeout(async () => { + launchSweepTimeout = null; - if (!currentProcess) { - return; - } + if (runtime.isControlledStop() || !runtime.isLaunchRecent()) { + return; + } - logTask = (async () => { try { - for await (const line of currentProcess) { - base.handleLogEvent(line, processNames); + if (await isAppRunning()) { + runtime.appStarted(); + return; } + + const crashDetails = createProcessExitDetails({ + platform: 'ios-simulator', + processName: processNames[0] ?? bundleId, + summary: `${processNames[0] ?? bundleId} exited on simulator`, + }); + + base.recordCrashArtifact(crashDetails); + await runtime.confirmCrash(crashDetails, crashDetails.summary); } catch (error) { - iosAppMonitorLogger.debug('iOS simulator log monitor stopped', error); + iosAppMonitorLogger.debug( + 'iOS simulator post-launch crash sweep failed', + error + ); } - })(); + }, POST_LAUNCH_CRASH_SWEEP_DELAY_MS); }; - const stopLogMonitor = async () => { - const currentProcess = logProcess; - const currentTask = logTask; + return { + start: async () => { + runtime.setMonitoring(true); + monitorStartedAt = Date.now(); + 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 = simctl.streamLogs(udid, predicate); + const currentLogProcess = logProcess; + + if (currentLogProcess) { + logTask = (async () => { + try { + for await (const line of currentLogProcess) { + if (!isRelevantProcessLogLine(line, processNames)) { + continue; + } + + base.recordLogLine(line); + + const event = createUnifiedLogEvent({ + line, + processNames, + platform: 'ios-simulator', + }); + + if (!event) { + continue; + } + + base.recordCrashArtifact(event.crashDetails); + runtime.crashSuspected(event.crashDetails); + } + } catch (error) { + iosAppMonitorLogger.debug( + 'iOS simulator log monitor stopped', + error + ); + runtime.reportWarning( + 'iOS simulator log monitor stopped unexpectedly' + ); + } + })(); + } - logProcess = null; - logTask = null; + pollAbortController = new AbortController(); + const signal = pollAbortController.signal; + pollTask = (async () => { + let lastKnownRunning = false; + + while (!signal.aborted) { + try { + const running = await isAppRunning(); + + if (running) { + clearLaunchSweep(); + runtime.appStarted(); + lastKnownRunning = true; + } else if (lastKnownRunning) { + const crashDetails = createProcessExitDetails({ + platform: 'ios-simulator', + processName: processNames[0] ?? bundleId, + summary: `${processNames[0] ?? bundleId} exited on simulator`, + }); + + base.recordCrashArtifact(crashDetails); + runtime.processExited(crashDetails); + lastKnownRunning = false; + } + } catch (error) { + iosAppMonitorLogger.debug( + 'iOS simulator process polling failed', + error + ); + runtime.reportWarning('iOS simulator process polling failed'); + } - await base.stopProcess(currentProcess); - await currentTask; - }; + await waitForPollInterval(signal); + } + })(); + }, + stop: async () => { + runtime.setMonitoring(false); + clearLaunchSweep(); - return base.createLifecycle({ - startLogMonitor, - stopLogMonitor, - getCrashDetails: (options) => - createCrashDetailsLookup({ - targetId: udid, - targetType: 'simulator', - bundleId, - processNames, - monitorStartedAt, - crashArtifactWriter, - base, - })(options), - }); + pollAbortController?.abort(); + pollAbortController = null; + + const currentLogProcess = logProcess; + const currentLogTask = logTask; + const currentPollTask = pollTask; + + logProcess = null; + logTask = null; + pollTask = null; + + if (currentLogProcess) { + try { + (await currentLogProcess.nodeChildProcess).kill(); + } catch { + // Ignore termination failures for background monitors. + } + } + + await currentLogTask; + await currentPollTask; + }, + dispose: async () => { + runtime.disposeState(); + clearLaunchSweep(); + + pollAbortController?.abort(); + pollAbortController = null; + + const currentLogProcess = logProcess; + const currentLogTask = logTask; + const currentPollTask = pollTask; + + logProcess = null; + logTask = null; + pollTask = null; + + if (currentLogProcess) { + try { + (await currentLogProcess.nodeChildProcess).kill(); + } catch { + // Ignore termination failures for background monitors. + } + } + + await currentLogTask; + await currentPollTask; + }, + launchRequested: (event) => { + clearLaunchSweep(); + runtime.launchRequested(event); + }, + launchCompleted: (event) => { + runtime.launchCompleted(event); + scheduleLaunchSweep(); + }, + launchFailed: () => { + clearLaunchSweep(); + runtime.launchFailed(); + }, + stopRequested: () => { + clearLaunchSweep(); + runtime.stopRequested(); + }, + stopCompleted: () => runtime.stopCompleted(), + watch: (testFilePath, phase) => runtime.watch(testFilePath, phase), + reset: () => { + clearLaunchSweep(); + runtime.reset(); + }, + isAlive: () => runtime.isAlive(), + }; }; export const createIosDeviceAppMonitor = ({ deviceId, bundleId, + isAppRunning, crashArtifactWriter, + eventReporter, }: { deviceId: string; bundleId: string; + isAppRunning: () => Promise; crashArtifactWriter?: CrashArtifactWriter; -}): IosAppMonitor => { + eventReporter?: AppMonitorReporter; +}): AppLifecycleMonitor => { const base = createAppMonitorBase(); let pollTask: Promise | null = null; - let stopPolling = false; + let pollAbortController: AbortController | null = null; + let launchSweepTimeout: ReturnType | null = null; let monitorStartedAt = 0; let processNames = [bundleId]; let lastKnownPid: number | undefined; - const startLogMonitor = async (startedAt: number) => { - monitorStartedAt = startedAt; - const appInfo = await devicectl.getAppInfo(deviceId, bundleId); - processNames = [ - ...new Set( - [appInfo?.name, bundleId].filter((value): value is string => - Boolean(value) - ) - ), - ]; - - stopPolling = false; - pollTask = (async () => { - let wasRunning = false; - - while (!stopPolling) { - try { - const processes = await devicectl.getProcesses(deviceId); - const matchingProcess = processes.find((process) => { - if (appInfo?.url) { - return process.executable.startsWith(appInfo.url); - } - - return processNames.some((processName) => - process.executable.includes(processName) - ); - }); - - if (matchingProcess) { - wasRunning = true; - lastKnownPid = matchingProcess.processIdentifier; - } else if (wasRunning) { - const crashDetails: AppCrashDetails = { - source: 'polling', - processName: processNames[0], - pid: lastKnownPid, - summary: `${processNames[0] ?? bundleId} exited on device`, - }; - - base.recordCrashArtifact(crashDetails); - base.emit({ - type: 'app_exited', - source: 'polling', - pid: lastKnownPid, - isConfirmed: true, - crashDetails, - }); - wasRunning = false; - } - } catch (error) { - iosAppMonitorLogger.debug('iOS device process polling failed', error); - } - - await new Promise((resolve) => - setTimeout(resolve, APP_EXIT_POLL_INTERVAL_MS) - ); - } - })(); - - const initialArtifacts = await collectCrashArtifacts({ + const runtime = createIosMonitorRuntime({ + platform: 'ios-device', + targetIdentifier: deviceId, + resolveCrashDetails: createCrashDetailsLookup({ targetId: deviceId, targetType: 'device', + platform: 'ios-device', bundleId, - processNames, + getProcessNames: () => processNames, + getMinOccurredAt: () => + runtime.getArtifactMinOccurredAt() ?? monitorStartedAt, + getCurrentLaunchId: () => runtime.getCurrentLaunchId(), crashArtifactWriter, - minOccurredAt: monitorStartedAt, - }); + base, + }), + eventReporter, + onReset: () => { + lastKnownPid = undefined; + base.reset(); + }, + }); - for (const artifact of initialArtifacts) { - base.recordCrashArtifact(artifact); + const clearLaunchSweep = () => { + if (launchSweepTimeout) { + clearTimeout(launchSweepTimeout); + launchSweepTimeout = null; } }; - const stopLogMonitor = async () => { - stopPolling = true; - await pollTask; - pollTask = null; + const scheduleLaunchSweep = () => { + clearLaunchSweep(); + launchSweepTimeout = setTimeout(async () => { + launchSweepTimeout = null; + + if (runtime.isControlledStop() || !runtime.isLaunchRecent()) { + return; + } + + try { + if (await isAppRunning()) { + runtime.appStarted(); + return; + } + + const crashDetails = createProcessExitDetails({ + platform: 'ios-device', + processName: processNames[0] ?? bundleId, + pid: lastKnownPid, + summary: `${processNames[0] ?? bundleId} exited on device`, + }); + + base.recordCrashArtifact(crashDetails); + await runtime.confirmCrash(crashDetails, crashDetails.summary); + } catch (error) { + iosAppMonitorLogger.debug( + 'iOS device post-launch crash sweep failed', + error + ); + } + }, POST_LAUNCH_CRASH_SWEEP_DELAY_MS); }; - return base.createLifecycle({ - startLogMonitor, - stopLogMonitor, - getCrashDetails: (options) => - createCrashDetailsLookup({ - targetId: deviceId, - targetType: 'device', - bundleId, - processNames, - monitorStartedAt, - crashArtifactWriter, - base, - })(options), - }); -}; + return { + start: async () => { + runtime.setMonitoring(true); + monitorStartedAt = Date.now(); + const appInfo = await devicectl.getAppInfo(deviceId, bundleId); + processNames = [ + ...new Set( + [appInfo?.name, bundleId].filter((value): value is string => + Boolean(value) + ) + ), + ]; + + pollAbortController = new AbortController(); + const signal = pollAbortController.signal; + pollTask = (async () => { + let wasRunning = false; + + while (!signal.aborted) { + try { + const processes = await devicectl.getProcesses(deviceId); + const matchingProcess = processes.find((process) => { + if (appInfo?.url) { + return process.executable.startsWith(appInfo.url); + } + + return processNames.some((processName) => + process.executable.includes(processName) + ); + }); -export type IosAppMonitor = AppMonitor & { - getCrashDetails: ( - options: CrashDetailsLookupOptions - ) => Promise; + if (matchingProcess) { + clearLaunchSweep(); + wasRunning = true; + lastKnownPid = matchingProcess.processIdentifier; + runtime.appStarted(); + } else if (wasRunning) { + const crashDetails = createProcessExitDetails({ + platform: 'ios-device', + processName: processNames[0] ?? bundleId, + pid: lastKnownPid, + summary: `${processNames[0] ?? bundleId} exited on device`, + }); + + base.recordCrashArtifact(crashDetails); + runtime.processExited(crashDetails); + wasRunning = false; + } + } catch (error) { + iosAppMonitorLogger.debug( + 'iOS device process polling failed', + error + ); + runtime.reportWarning('iOS device process polling failed'); + } + + await waitForPollInterval(signal); + } + })(); + + try { + const initialArtifacts = await collectCrashArtifacts({ + targetId: deviceId, + targetType: 'device', + bundleId, + processNames, + crashArtifactWriter, + minOccurredAt: monitorStartedAt, + }); + + for (const artifact of initialArtifacts) { + base.recordCrashArtifact( + normalizeCrashDetails( + artifact, + 'ios-device', + runtime.getCurrentLaunchId() + ) ?? artifact + ); + } + } catch (error) { + iosAppMonitorLogger.debug( + 'iOS device initial crash artifact sweep failed', + error + ); + runtime.reportWarning('iOS device initial crash artifact sweep failed'); + } + }, + stop: async () => { + runtime.setMonitoring(false); + clearLaunchSweep(); + + pollAbortController?.abort(); + pollAbortController = null; + + const currentPollTask = pollTask; + pollTask = null; + await currentPollTask; + }, + dispose: async () => { + runtime.disposeState(); + clearLaunchSweep(); + + pollAbortController?.abort(); + pollAbortController = null; + + const currentPollTask = pollTask; + pollTask = null; + await currentPollTask; + }, + launchRequested: (event) => { + clearLaunchSweep(); + runtime.launchRequested(event); + }, + launchCompleted: (event) => { + runtime.launchCompleted(event); + scheduleLaunchSweep(); + }, + launchFailed: () => { + clearLaunchSweep(); + runtime.launchFailed(); + }, + stopRequested: () => { + clearLaunchSweep(); + runtime.stopRequested(); + }, + stopCompleted: () => runtime.stopCompleted(), + watch: (testFilePath, phase) => runtime.watch(testFilePath, phase), + reset: () => { + clearLaunchSweep(); + runtime.reset(); + }, + isAlive: () => runtime.isAlive(), + }; }; diff --git a/packages/platform-ios/src/crash-diagnostics.ts b/packages/platform-ios/src/crash-diagnostics.ts index acc86989..d47a880e 100644 --- a/packages/platform-ios/src/crash-diagnostics.ts +++ b/packages/platform-ios/src/crash-diagnostics.ts @@ -10,12 +10,11 @@ import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { iosCrashParser } from './crash-parser.js'; import * as devicectl from './xcrun/devicectl.js'; -import * as simctl from './xcrun/simctl.js'; const crashDiagnosticsLogger = logger.child('ios-crash-diagnostics'); -const CRASH_ARTIFACT_WAIT_TIMEOUT_MS = 30000; -const CRASH_ARTIFACT_POLL_INTERVAL_MS = 1500; +const CRASH_ARTIFACT_WAIT_TIMEOUT_MS = 10000; +const CRASH_ARTIFACT_POLL_INTERVAL_MS = 250; type CollectIosCrashArtifactsOptions = { processNames: string[]; @@ -54,6 +53,13 @@ type WaitForCrashArtifactOptions = { recordArtifact: (artifact: AppCrashDetails) => void; }; +type CrashArtifactCollector = { + name: string; + collect: () => + | Promise + | DiagnosedCrashArtifact[]; +}; + const isCrashReportFile = (path: string) => path.endsWith('.ips') || path.endsWith('.crash'); @@ -87,6 +93,10 @@ const createTempDirectory = (prefix: string) => { return path; }; +const getDiagnosticReportsDir = () => + process.env.RN_HARNESS_IOS_DIAGNOSTIC_REPORTS_DIR ?? + join(homedir(), 'Library', 'Logs', 'DiagnosticReports'); + const scoreCrashArtifact = ({ artifact, options, @@ -222,30 +232,29 @@ const parseCrashArtifacts = ({ const collectSimulatorCrashArtifacts = async ({ targetId, - ...options + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, }: CollectSimulatorCrashArtifactsOptions) => { - const outputDir = createTempDirectory('rn-harness-simctl-diagnose'); - - try { - await simctl.diagnose(targetId, outputDir); - return parseCrashArtifacts({ - rootDir: outputDir, - options: { ...options, targetId, targetType: 'simulator' }, - }); - } finally { - fs.rmSync(outputDir, { recursive: true, force: true }); - } + // Do not fall back to `simctl diagnose` here. It collects broad simulator + // diagnostics, not just this app's crash report, and can block long enough + // to trip Harness/Jest timeouts on CI. Simulator crash reports are therefore + // best-effort from host DiagnosticReports only. + return collectCrashArtifactsFromDiagnosticReports({ + targetId, + targetType: 'simulator', + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, + }); }; const collectCrashArtifactsFromDiagnosticReports = ( options: CollectCrashArtifactsOptions, ): DiagnosedCrashArtifact[] => { - const diagnosticReportsDir = join( - homedir(), - 'Library', - 'Logs', - 'DiagnosticReports', - ); + const diagnosticReportsDir = getDiagnosticReportsDir(); if (!fs.existsSync(diagnosticReportsDir)) { return []; @@ -276,6 +285,14 @@ const collectCrashArtifactsFromDiagnosticReports = ( continue; } + if ( + options.targetType === 'simulator' && + parsed.targetId !== undefined && + parsed.targetId !== options.targetId + ) { + continue; + } + const artifactPath = options.crashArtifactWriter ? options.crashArtifactWriter.persistArtifact({ artifactKind: 'ios-crash-report', @@ -303,7 +320,7 @@ const collectCrashArtifactsFromDiagnosticReports = ( }); }; -const collectPhysicalCrashArtifacts = async ({ +const collectPhysicalCrashArtifactsFromDevice = async ({ targetId, processNames, bundleId, @@ -356,14 +373,47 @@ const collectPhysicalCrashArtifacts = async ({ fs.rmSync(crashLogsDir, { recursive: true, force: true }); } - return collectCrashArtifactsFromDiagnosticReports({ - targetId, - targetType: 'device', - processNames, - bundleId, - crashArtifactWriter, - minOccurredAt, - }); + return []; +}; + +const createCrashArtifactCollectors = ( + options: CollectCrashArtifactsOptions, +): CrashArtifactCollector[] => { + if (options.targetType === 'simulator') { + return [ + { + name: 'host DiagnosticReports', + collect: () => collectSimulatorCrashArtifacts(options), + }, + ]; + } + + return [ + { + name: 'device systemCrashLogs', + collect: () => + collectPhysicalCrashArtifactsFromDevice({ + targetId: options.targetId, + targetType: 'device', + processNames: options.processNames, + bundleId: options.bundleId, + crashArtifactWriter: options.crashArtifactWriter, + minOccurredAt: options.minOccurredAt, + }), + }, + { + name: 'host DiagnosticReports', + collect: () => + collectCrashArtifactsFromDiagnosticReports({ + targetId: options.targetId, + targetType: 'device', + processNames: options.processNames, + bundleId: options.bundleId, + crashArtifactWriter: options.crashArtifactWriter, + minOccurredAt: options.minOccurredAt, + }), + }, + ]; }; export const collectCrashArtifacts = async ( @@ -376,11 +426,34 @@ export const collectCrashArtifacts = async ( minOccurredAt: options.minOccurredAt, }); - if (options.targetType === 'simulator') { - return collectSimulatorCrashArtifacts(options); + const results = await Promise.allSettled( + createCrashArtifactCollectors(options).map(async (collector) => ({ + name: collector.name, + artifacts: await collector.collect(), + })), + ); + + const artifacts: DiagnosedCrashArtifact[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + artifacts.push(...result.value.artifacts); + continue; + } + + crashDiagnosticsLogger.debug( + 'crash artifact collector failed', + result.reason, + ); } - return collectPhysicalCrashArtifacts(options); + return artifacts.sort((left, right) => { + if ((right.score ?? 0) !== (left.score ?? 0)) { + return (right.score ?? 0) - (left.score ?? 0); + } + + return right.occurredAt - left.occurredAt; + }); }; export const waitForCrashArtifact = async ({ @@ -391,34 +464,87 @@ export const waitForCrashArtifact = async ({ }: WaitForCrashArtifactOptions): Promise => { const deadline = Date.now() + CRASH_ARTIFACT_WAIT_TIMEOUT_MS; let fallbackArtifact = getFallbackArtifact(); + let settled = false; - while (Date.now() < deadline) { - const artifacts = await collectCrashArtifacts(options); + const waitForNextPoll = async () => { + const remainingMs = deadline - Date.now(); - for (const artifact of artifacts) { - recordArtifact(artifact); + if (remainingMs <= 0) { + return; } - const matchingArtifact = getBestMatchingArtifact({ - artifacts, - options, - lookup, - }); + await new Promise((resolve) => + setTimeout(resolve, Math.min(CRASH_ARTIFACT_POLL_INTERVAL_MS, remainingMs)), + ); + }; + + const pollCollector = async ( + collector: CrashArtifactCollector, + ): Promise => { + while (!settled && Date.now() < deadline) { + try { + const artifacts = await collector.collect(); - if (matchingArtifact) { - return matchingArtifact; + for (const artifact of artifacts) { + recordArtifact(artifact); + } + + const matchingArtifact = getBestMatchingArtifact({ + artifacts, + options, + lookup, + }); + + if (matchingArtifact) { + return matchingArtifact; + } + } catch (error) { + crashDiagnosticsLogger.debug( + '%s crash artifact collector failed', + collector.name, + error, + ); + } + + fallbackArtifact = getFallbackArtifact(); + await waitForNextPoll(); } - fallbackArtifact = getFallbackArtifact(); + return null; + }; + + const collectors = createCrashArtifactCollectors(options); + const foundArtifact = new Promise((resolve) => { + let pendingCollectors = collectors.length; + + for (const collector of collectors) { + void pollCollector(collector).then((artifact) => { + if (settled) { + return; + } + + if (artifact) { + settled = true; + resolve(artifact); + return; + } + + pendingCollectors -= 1; - if (Date.now() >= deadline) { - return fallbackArtifact; + if (pendingCollectors === 0) { + settled = true; + resolve(getFallbackArtifact() ?? fallbackArtifact); + } + }); } + }); - await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS), - ); - } + const timeout = new Promise((resolve) => { + setTimeout(() => { + settled = true; + resolve(getFallbackArtifact() ?? fallbackArtifact); + }, CRASH_ARTIFACT_WAIT_TIMEOUT_MS); + }); - return getFallbackArtifact() ?? fallbackArtifact; + return Promise.race([foundArtifact, timeout]); }; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 36c62cd1..a940ac20 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -1,9 +1,9 @@ import { - AppMonitor, AppNotInstalledError, type CollectNativeCoverageOptions, CreateAppMonitorOptions, DeviceNotFoundError, + createNoopAppLifecycleMonitor, type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; @@ -46,14 +46,6 @@ const getHarnessAppPath = (): string => { return appPath; }; -const createNoopAppMonitor = (): AppMonitor => ({ - start: async () => undefined, - stop: async () => undefined, - dispose: async () => undefined, - addListener: () => undefined, - removeListener: () => undefined, -}); - export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, harnessConfig: HarnessConfig, @@ -190,13 +182,15 @@ export const getAppleSimulatorPlatformInstance = async ( }, createAppMonitor: (options?: CreateAppMonitorOptions) => { if (!detectNativeCrashes) { - return createNoopAppMonitor(); + return createNoopAppLifecycleMonitor(); } return createIosSimulatorAppMonitor({ udid, bundleId: config.bundleId, + isAppRunning: () => simctl.isAppRunning(udid, config.bundleId), crashArtifactWriter: options?.crashArtifactWriter, + eventReporter: options?.eventReporter, }); }, collectNativeCoverage: async (options: CollectNativeCoverageOptions) => { @@ -299,13 +293,15 @@ export const getApplePhysicalDevicePlatformInstance = async ( }, createAppMonitor: (options?: CreateAppMonitorOptions) => { if (!detectNativeCrashes) { - return createNoopAppMonitor(); + return createNoopAppLifecycleMonitor(); } return createIosDeviceAppMonitor({ deviceId, bundleId: config.bundleId, + isAppRunning: () => devicectl.isAppRunning(deviceId, config.bundleId), crashArtifactWriter: options?.crashArtifactWriter, + eventReporter: options?.eventReporter, }); }, }; diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index 521951e5..493be03c 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -14,18 +14,14 @@ export const devicectl = async ( args: string[] ): Promise => { const tempFile = join(tmpdir(), `devicectl-${randomUUID()}.json`); - const separatorIndex = args.indexOf('--'); - const argsWithJsonOutput = - separatorIndex === -1 - ? [...args, '--json-output', tempFile] - : [ - ...args.slice(0, separatorIndex), - '--json-output', - tempFile, - ...args.slice(separatorIndex), - ]; - - await spawn('xcrun', ['devicectl', command, ...argsWithJsonOutput]); + + await spawn('xcrun', [ + 'devicectl', + '--json-output', + tempFile, + command, + ...args, + ]); if (!fs.existsSync(tempFile)) { throw new Error(`devicectl did not produce JSON output at ${tempFile}`); @@ -178,16 +174,8 @@ export const getAppInfo = async ( identifier: string, bundleId: string ): Promise => { - const result = await devicectl<{ apps: AppleAppInfo[] }>('device', [ - 'info', - 'apps', - '--device', - identifier, - '--bundle-id', - bundleId, - ]); - - return result.apps[0] ?? null; + const apps = await listApps(identifier); + return apps.find((app) => app.bundleIdentifier === bundleId) ?? null; }; export const isAppInstalled = async ( @@ -224,7 +212,7 @@ export const getDeviceCtlLaunchArgs = ( args.push(bundleId); if (options?.arguments?.length) { - args.push('--', ...options.arguments); + args.push(...options.arguments); } return args; diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index 7f86eef8..9628db92 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -1,72 +1,14 @@ import { - type AppMonitor, - type AppMonitorEvent, DeviceNotFoundError, AppNotInstalledError, type CreateAppMonitorOptions, type HarnessPlatformInitOptions, HarnessPlatformRunner, + createNoopAppLifecycleMonitor, } 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, init?: HarnessPlatformInitOptions @@ -106,10 +48,7 @@ const getVegaRunner = async ( }, createAppMonitor: (options?: CreateAppMonitorOptions) => { void options; - return createPollingAppMonitor({ - interval: 250, - isAppRunning: () => kepler.isAppRunning(deviceId, bundleId), - }); + return createNoopAppLifecycleMonitor(); }, }; }; diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index bcf74528..84ee7ed5 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -17,7 +17,6 @@ }, "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/runner.ts b/packages/platform-web/src/runner.ts index d67cf97b..950f90b0 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -1,70 +1,12 @@ import { - type AppMonitor, - type AppMonitorEvent, type CreateAppMonitorOptions, type HarnessPlatformInitOptions, HarnessPlatformRunner, + createNoopAppLifecycleMonitor, } 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, init?: HarnessPlatformInitOptions @@ -214,11 +156,7 @@ const getWebRunner = async ( }, createAppMonitor: (options?: CreateAppMonitorOptions) => { void options; - return createPollingAppMonitor({ - interval: 250, - isAppRunning: async () => - browser !== null && page !== null && !page.isClosed(), - }); + return createNoopAppLifecycleMonitor(); }, }; }; diff --git a/packages/platform-web/tsconfig.json b/packages/platform-web/tsconfig.json index 56b5cd95..9f9888e0 100644 --- a/packages/platform-web/tsconfig.json +++ b/packages/platform-web/tsconfig.json @@ -3,9 +3,6 @@ "files": [], "include": [], "references": [ - { - "path": "../tools" - }, { "path": "../platforms" }, diff --git a/packages/platform-web/tsconfig.lib.json b/packages/platform-web/tsconfig.lib.json index 362f35d8..595d0aa3 100644 --- a/packages/platform-web/tsconfig.lib.json +++ b/packages/platform-web/tsconfig.lib.json @@ -12,9 +12,6 @@ }, "include": ["src/**/*.ts"], "references": [ - { - "path": "../tools/tsconfig.lib.json" - }, { "path": "../platforms/tsconfig.lib.json" } diff --git a/packages/platforms/src/app-lifecycle-monitor.ts b/packages/platforms/src/app-lifecycle-monitor.ts new file mode 100644 index 00000000..e5e366a7 --- /dev/null +++ b/packages/platforms/src/app-lifecycle-monitor.ts @@ -0,0 +1,25 @@ +import type { AppLifecycleMonitor } from './types.js'; + +export class CrashWatchCancelledError extends Error { + constructor() { + super('Crash watch was cancelled'); + this.name = 'CrashWatchCancelledError'; + } +} + +export const createNoopAppLifecycleMonitor = (): AppLifecycleMonitor => ({ + start: async () => undefined, + stop: async () => undefined, + dispose: async () => undefined, + launchRequested: () => undefined, + launchCompleted: () => undefined, + launchFailed: () => undefined, + stopRequested: () => undefined, + stopCompleted: () => undefined, + watch: () => ({ + promise: new Promise(() => undefined), + cancel: () => undefined, + }), + reset: () => undefined, + isAlive: () => true, +}); diff --git a/packages/platforms/src/errors.ts b/packages/platforms/src/errors.ts index 8a4c21e5..da6e2d3b 100644 --- a/packages/platforms/src/errors.ts +++ b/packages/platforms/src/errors.ts @@ -27,4 +27,75 @@ export class DependencyNotFoundError extends Error { ); this.name = 'DependencyNotFoundError'; } -} \ No newline at end of file +} + +import type { AppCrashDetails, AppLifecyclePhase } from './types.js'; + +export type NativeCrashDetails = AppCrashDetails & { + phase: AppLifecyclePhase; +}; + +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'); + + 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 Error { + constructor( + public readonly testFilePath: string, + public readonly details: NativeCrashDetails, + public readonly lastKnownTest?: string + ) { + 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/platforms/src/index.ts b/packages/platforms/src/index.ts index 043a4f30..778f0009 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -5,9 +5,24 @@ export type { AndroidAppLaunchOptions, AppleAppLaunchOptions, AppCrashDetails, - AppMonitor, + AppLifecycleMonitor, + AppLifecyclePhase, AppMonitorEvent, - AppMonitorListener, + AppMonitorEventBase, + AppMonitorReporter, + AppStartedEvent, + AppExitedEvent, + AppCrashSuspectedEvent, + AppCrashConfirmedEvent, + AppCrashReportReadyEvent, + AppMonitorWarningEvent, + AppLifecycleEventBase, + LaunchRequestedEvent, + LaunchCompletedEvent, + LaunchFailedEvent, + StopRequestedEvent, + StopCompletedEvent, + CrashWatch, AppLaunchOptions, CrashDetailsLookupOptions, CrashArtifactSource, @@ -25,4 +40,10 @@ export { AppNotInstalledError, DeviceNotFoundError, DependencyNotFoundError, + NativeCrashError, + type NativeCrashDetails, } from './errors.js'; +export { + CrashWatchCancelledError, + createNoopAppLifecycleMonitor, +} from './app-lifecycle-monitor.js'; diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index f4d16819..adda7588 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -1,4 +1,17 @@ export type AppCrashDetails = { + platform?: 'android' | 'ios-simulator' | 'ios-device' | 'web' | 'vega'; + kind?: + | 'java-exception' + | 'native-crash' + | 'anr' + | 'watchdog' + | 'process-exit' + | 'crash-report' + | 'device-offline' + | 'unknown'; + confidence?: 'low' | 'medium' | 'high'; + occurredAt?: number; + launchId?: string; source?: 'polling' | 'logs' | 'bridge'; summary?: string; signal?: string; @@ -32,6 +45,7 @@ export type CrashArtifactWriter = { export type CreateAppMonitorOptions = { crashArtifactWriter?: CrashArtifactWriter; + eventReporter?: AppMonitorReporter; }; export type CrashDetailsLookupOptions = { @@ -40,45 +54,118 @@ export type CrashDetailsLookupOptions = { 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 AppLifecyclePhase = 'startup' | 'execution'; + +export type AppLifecycleEventBase = { + launchId: string; + at: number; +}; + +export type LaunchRequestedEvent = AppLifecycleEventBase & { + type: 'launch_requested'; + reason: 'start' | 'restart' | 'ensure_ready'; +}; + +export type LaunchCompletedEvent = AppLifecycleEventBase & { + type: 'launch_completed'; + reason: 'start' | 'restart' | 'ensure_ready'; +}; + +export type LaunchFailedEvent = AppLifecycleEventBase & { + type: 'launch_failed'; + reason: 'start' | 'restart' | 'ensure_ready'; + error: unknown; +}; + +export type StopRequestedEvent = { + type: 'stop_requested'; + at: number; + reason: 'restart' | 'dispose' | 'coverage' | 'manual'; +}; + +export type StopCompletedEvent = { + type: 'stop_completed'; + at: number; + reason: 'restart' | 'dispose' | 'coverage' | 'manual'; +}; -export type AppMonitorListener = (event: AppMonitorEvent) => void; +export type CrashWatch = { + promise: Promise; + cancel: () => void; +}; -export type AppMonitor = { +export type AppLifecycleMonitor = { start: () => Promise; stop: () => Promise; dispose: () => Promise; - addListener: (listener: AppMonitorListener) => void; - removeListener: (listener: AppMonitorListener) => void; + + launchRequested: (event: LaunchRequestedEvent) => void; + launchCompleted: (event: LaunchCompletedEvent) => void; + launchFailed: (event: LaunchFailedEvent) => void; + stopRequested: (event: StopRequestedEvent) => void; + stopCompleted: (event: StopCompletedEvent) => void; + + watch: (testFilePath: string, phase: AppLifecyclePhase) => CrashWatch; + reset: () => void; + isAlive: () => boolean; }; +export type AppMonitorEventBase = { + timestamp: number; + appPlatform: NonNullable; + targetIdentifier: string; + testFile?: string; + phase?: AppLifecyclePhase; + launchId?: string; + processName?: string; + pid?: number; + source?: AppCrashDetails['source']; + summary?: string; + kind?: AppCrashDetails['kind']; + confidence?: AppCrashDetails['confidence']; + signal?: string; + exceptionType?: string; + artifactType?: AppCrashDetails['artifactType']; + artifactPath?: string; + crashDetails?: AppCrashDetails; +}; + +export type AppStartedEvent = AppMonitorEventBase & { + type: 'app:started'; +}; + +export type AppExitedEvent = AppMonitorEventBase & { + type: 'app:exited'; +}; + +export type AppCrashSuspectedEvent = AppMonitorEventBase & { + type: 'app:crash-suspected'; +}; + +export type AppCrashConfirmedEvent = AppMonitorEventBase & { + type: 'app:crash-confirmed'; +}; + +export type AppCrashReportReadyEvent = AppMonitorEventBase & { + type: 'app:crash-report-ready'; + crashDetails: AppCrashDetails; +}; + +export type AppMonitorWarningEvent = AppMonitorEventBase & { + type: 'app:monitor-warning'; + warning: string; +}; + +export type AppMonitorEvent = + | AppStartedEvent + | AppExitedEvent + | AppCrashSuspectedEvent + | AppCrashConfirmedEvent + | AppCrashReportReadyEvent + | AppMonitorWarningEvent; + +export type AppMonitorReporter = (event: AppMonitorEvent) => void; + export type AndroidAppLaunchOptions = { extras?: Record; }; @@ -109,10 +196,7 @@ export type HarnessPlatformRunner = { stopApp: () => Promise; dispose: () => Promise; isAppRunning: () => Promise; - createAppMonitor: (options?: CreateAppMonitorOptions) => AppMonitor; - getCrashDetails?: ( - options: CrashDetailsLookupOptions, - ) => Promise; + createAppMonitor: (options?: CreateAppMonitorOptions) => AppLifecycleMonitor; collectNativeCoverage?: ( options: CollectNativeCoverageOptions ) => Promise; diff --git a/packages/plugins/eslint.config.mjs b/packages/plugins/eslint.config.mjs index c334bc0b..9523a1c3 100644 --- a/packages/plugins/eslint.config.mjs +++ b/packages/plugins/eslint.config.mjs @@ -9,6 +9,7 @@ export default [ 'error', { ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['vitest'], }, ], }, diff --git a/packages/plugins/src/__tests__/types.test.ts b/packages/plugins/src/__tests__/types.test.ts new file mode 100644 index 00000000..05ef40d0 --- /dev/null +++ b/packages/plugins/src/__tests__/types.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { HARNESS_HOOKS } from '../types.js'; + +describe('plugin hook definitions', () => { + it('registers the structured app monitor hooks', () => { + expect(HARNESS_HOOKS).toEqual( + expect.arrayContaining([ + { flatName: 'app:started', path: ['app', 'started'] }, + { flatName: 'app:exited', path: ['app', 'exited'] }, + { flatName: 'app:crash-suspected', path: ['app', 'crashSuspected'] }, + { flatName: 'app:crash-confirmed', path: ['app', 'crashConfirmed'] }, + { flatName: 'app:crash-report-ready', path: ['app', 'crashReportReady'] }, + { flatName: 'app:monitor-warning', path: ['app', 'monitorWarning'] }, + ]), + ); + }); +}); diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 8ab0fea6..0ad21fbe 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -2,8 +2,11 @@ export { definePlugin, isHarnessPlugin } from './plugin.js'; export { createHarnessPluginManager } from './manager.js'; export type { HarnessPluginManager } from './manager.js'; export type { + AppCrashConfirmedContext, + AppCrashReportReadyContext, + AppCrashSuspectedContext, AppExitedContext, - AppPossibleCrashContext, + AppMonitorWarningContext, AppStartedContext, CollectionFinishedContext, CollectionStartedContext, diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts index 32560f34..c4794dbe 100644 --- a/packages/plugins/src/types.ts +++ b/packages/plugins/src/types.ts @@ -233,10 +233,16 @@ export type AppStartedContext< TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; + appPlatform: NonNullable; + targetIdentifier: string; testFile?: string; + phase?: 'startup' | 'execution'; + launchId?: string; + processName?: string; pid?: number; - source?: 'polling' | 'logs'; - line?: string; + source?: AppCrashDetails['source']; + summary?: string; + crashDetails?: AppCrashDetails; }; export type AppExitedContext< @@ -245,25 +251,118 @@ export type AppExitedContext< TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; + appPlatform: NonNullable; + targetIdentifier: string; + testFile?: string; + phase?: 'startup' | 'execution'; + launchId?: string; + processName?: string; + pid?: number; + source?: AppCrashDetails['source']; + summary?: string; + kind?: AppCrashDetails['kind']; + confidence?: AppCrashDetails['confidence']; + signal?: string; + exceptionType?: string; + artifactType?: AppCrashDetails['artifactType']; + artifactPath?: string; + crashDetails?: AppCrashDetails; +}; + +export type AppCrashSuspectedContext< + TState extends object, + TConfig, + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { + runId: string; + appPlatform: NonNullable; + targetIdentifier: string; + testFile?: string; + phase?: 'startup' | 'execution'; + launchId?: string; + processName?: string; + pid?: number; + source?: AppCrashDetails['source']; + summary?: string; + kind?: AppCrashDetails['kind']; + confidence?: AppCrashDetails['confidence']; + signal?: string; + exceptionType?: string; + artifactType?: AppCrashDetails['artifactType']; + artifactPath?: string; + crashDetails?: AppCrashDetails; +}; + +export type AppCrashConfirmedContext< + TState extends object, + TConfig, + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { + runId: string; + appPlatform: NonNullable; + targetIdentifier: string; testFile?: string; + phase?: 'startup' | 'execution'; + launchId?: string; + processName?: string; pid?: number; - source?: 'polling' | 'logs'; - line?: string; - isConfirmed?: boolean; + source?: AppCrashDetails['source']; + summary?: string; + kind?: AppCrashDetails['kind']; + confidence?: AppCrashDetails['confidence']; + signal?: string; + exceptionType?: string; + artifactType?: AppCrashDetails['artifactType']; + artifactPath?: string; crashDetails?: AppCrashDetails; }; -export type AppPossibleCrashContext< +export type AppCrashReportReadyContext< + TState extends object, + TConfig, + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { + runId: string; + appPlatform: NonNullable; + targetIdentifier: string; + testFile?: string; + phase?: 'startup' | 'execution'; + launchId?: string; + processName?: string; + pid?: number; + source?: AppCrashDetails['source']; + summary?: string; + kind?: AppCrashDetails['kind']; + confidence?: AppCrashDetails['confidence']; + signal?: string; + exceptionType?: string; + artifactType?: AppCrashDetails['artifactType']; + artifactPath?: string; + crashDetails: AppCrashDetails; +}; + +export type AppMonitorWarningContext< TState extends object, TConfig, TRunner extends HarnessPlatform -> = HarnessBaseHookContext & { +> = HarnessBaseHookContext & { runId: string; + appPlatform: NonNullable; + targetIdentifier: string; testFile?: string; + phase?: 'startup' | 'execution'; + launchId?: string; + processName?: string; pid?: number; - source?: 'polling' | 'logs'; - line?: string; - isConfirmed?: boolean; + source?: AppCrashDetails['source']; + summary?: string; + kind?: AppCrashDetails['kind']; + confidence?: AppCrashDetails['confidence']; + signal?: string; + exceptionType?: string; + artifactType?: AppCrashDetails['artifactType']; + artifactPath?: string; + warning: string; crashDetails?: AppCrashDetails; }; @@ -384,7 +483,10 @@ export type FlatHarnessHookContexts< 'metro:client-log': MetroClientLogContext; 'app:started': AppStartedContext; 'app:exited': AppExitedContext; - 'app:possible-crash': AppPossibleCrashContext; + 'app:crash-suspected': AppCrashSuspectedContext; + 'app:crash-confirmed': AppCrashConfirmedContext; + 'app:crash-report-ready': AppCrashReportReadyContext; + 'app:monitor-warning': AppMonitorWarningContext; 'collection:started': CollectionStartedContext; 'collection:finished': CollectionFinishedContext; 'test-file:started': TestFileStartedContext; @@ -452,8 +554,17 @@ export type HarnessPluginHooks< app?: { started?: HarnessHookHandler>; exited?: HarnessHookHandler>; - possibleCrash?: HarnessHookHandler< - AppPossibleCrashContext + crashSuspected?: HarnessHookHandler< + AppCrashSuspectedContext + >; + crashConfirmed?: HarnessHookHandler< + AppCrashConfirmedContext + >; + crashReportReady?: HarnessHookHandler< + AppCrashReportReadyContext + >; + monitorWarning?: HarnessHookHandler< + AppMonitorWarningContext >; }; collection?: { @@ -512,7 +623,10 @@ export const HARNESS_HOOKS = [ { flatName: 'metro:client-log', path: ['metro', 'clientLog'] }, { flatName: 'app:started', path: ['app', 'started'] }, { flatName: 'app:exited', path: ['app', 'exited'] }, - { flatName: 'app:possible-crash', path: ['app', 'possibleCrash'] }, + { flatName: 'app:crash-suspected', path: ['app', 'crashSuspected'] }, + { flatName: 'app:crash-confirmed', path: ['app', 'crashConfirmed'] }, + { flatName: 'app:crash-report-ready', path: ['app', 'crashReportReady'] }, + { flatName: 'app:monitor-warning', path: ['app', 'monitorWarning'] }, { flatName: 'collection:started', path: ['collection', 'started'] }, { flatName: 'collection:finished', path: ['collection', 'finished'] }, { flatName: 'test-file:started', path: ['testFile', 'started'] }, diff --git a/packages/runtime/src/hmr.ts b/packages/runtime/src/hmr.ts new file mode 100644 index 00000000..7b3f694a --- /dev/null +++ b/packages/runtime/src/hmr.ts @@ -0,0 +1,14 @@ +/** + * HMRClient is not on the react-native public API; keep the deep import here only. + */ +type HMRClientShape = { + disable: () => void; +}; + +export const disableHMR = (): void => { + const module = require('react-native/Libraries/Utilities/HMRClient') as + | { default: HMRClientShape } + | HMRClientShape; + const client = 'default' in module ? module.default : module; + client.disable(); +}; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index deac01f1..8a11f374 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -3,6 +3,7 @@ import './globals.js'; export { createElement } from './jsx/jsx-runtime.js'; export { UI as ReactNativeHarness } from './ui/index.js'; +export { disableLogBoxUI, isLogBoxSuppressed } from './logbox.js'; export * from './spy/index.js'; export * from './expect/index.js'; export * from './collector/index.js'; diff --git a/packages/runtime/src/initialize.ts b/packages/runtime/src/initialize.ts index 0913aab5..0b3872dc 100644 --- a/packages/runtime/src/initialize.ts +++ b/packages/runtime/src/initialize.ts @@ -1,6 +1,8 @@ import { getDeviceDescriptor } from './client/getDeviceDescriptor.js'; import { getClient } from './client/index.js'; import { disableHMRWhenReady } from './disableHMRWhenReady.js'; +import { disableHMR } from './hmr.js'; +import { disableLogBoxUI } from './logbox.js'; import { setupJestMock } from './jest-mock.js'; // Polyfill for EventTarget on runtimes that don't ship one (RN's JSC). @@ -20,20 +22,13 @@ if (typeof globalThis.EventTarget !== 'function') { // Setup jest mock to warn users about using Jest APIs setupJestMock(); -// Turn off LogBox -const { LogBox } = require('react-native'); -LogBox.ignoreAllLogs(true); - -// Turn off HMR -const HMRClientModule = require('react-native/Libraries/Utilities/HMRClient'); -const HMRClient = - 'default' in HMRClientModule ? HMRClientModule.default : HMRClientModule; +disableLogBoxUI(); // Wait for HMRClient to be initialized setTimeout(() => { void (async () => { try { - await disableHMRWhenReady(() => HMRClient.disable(), 50); + await disableHMRWhenReady(disableHMR, 50); const handle = await getClient(); const deviceDescriptor = getDeviceDescriptor(); diff --git a/packages/runtime/src/logbox.ts b/packages/runtime/src/logbox.ts new file mode 100644 index 00000000..54051b16 --- /dev/null +++ b/packages/runtime/src/logbox.ts @@ -0,0 +1,40 @@ +import { + LogBox, + type TurboModule, + TurboModuleRegistry, +} from 'react-native'; + +type NativeLogBoxModule = TurboModule & { + show: () => void; + hide: () => void; +}; + +type HarnessLogBox = typeof LogBox & { + addException: (error: unknown) => void; + addLog: (log: unknown) => void; + addConsoleLog: (level: 'warn' | 'error', ...args: unknown[]) => void; +}; + +let logBoxSuppressed = false; + +const noop = (): undefined => undefined; + +export const isLogBoxSuppressed = (): boolean => logBoxSuppressed; + +/** Hide LogBox UI while keeping console.error → Metro forwarding. */ +export const disableLogBoxUI = (): void => { + const harnessLogBox = LogBox as HarnessLogBox; + harnessLogBox.ignoreAllLogs(true); + + harnessLogBox.addException = noop; + harnessLogBox.addLog = noop; + harnessLogBox.addConsoleLog = noop; + + const nativeLogBox = TurboModuleRegistry.get('LogBox'); + if (nativeLogBox != null) { + nativeLogBox.show = noop; + nativeLogBox.hide = noop; + } + + logBoxSuppressed = true; +}; diff --git a/packages/runtime/src/react-native.d.ts b/packages/runtime/src/react-native.d.ts index ea169d86..ac5fcee4 100644 --- a/packages/runtime/src/react-native.d.ts +++ b/packages/runtime/src/react-native.d.ts @@ -1,12 +1,3 @@ -declare module 'react-native/Libraries/Core/Devtools/getDevServer' { - export type DevServerInfo = { - url: string; - fullBundleUrl?: string; - bundleLoadedFromServer: boolean; - }; - export default function getDevServer(): DevServerInfo; -} - declare module 'react-native/Libraries/Core/Devtools/parseErrorStack' { export type StackFrame = { column: number | null | undefined; diff --git a/packages/runtime/src/utils/dev-server.ts b/packages/runtime/src/utils/dev-server.ts index 35780068..5804253a 100644 --- a/packages/runtime/src/utils/dev-server.ts +++ b/packages/runtime/src/utils/dev-server.ts @@ -1,13 +1,24 @@ -import { Platform } from 'react-native'; -import getDevServer from 'react-native/Libraries/Core/Devtools/getDevServer'; +import { Platform, TurboModuleRegistry } from 'react-native'; + +type SourceCodeModule = { + getConstants: () => { + scriptURL?: string; + }; +}; + +const FALLBACK_DEV_SERVER_URL = 'http://localhost:8081/'; + +const getScriptURL = (): string | null => { + const sourceCode = TurboModuleRegistry.get('SourceCode'); + return sourceCode?.getConstants()?.scriptURL ?? null; +}; export const getDevServerUrl = (): string => { if (Platform.OS === 'web') { - // This is going to be the same as the current URL - return window.location.origin + '/'; + return `${window.location.origin}/`; } - - - const devServer = getDevServer(); - return devServer.url; + + const scriptUrl = getScriptURL(); + const match = scriptUrl?.match(/^https?:\/\/.*?\//); + return match?.[0] ?? FALLBACK_DEV_SERVER_URL; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50b36526..3ed76526 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,9 +486,6 @@ 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 diff --git a/website/src/docs/api/test-environment.mdx b/website/src/docs/api/test-environment.mdx index a289f410..8d331ed2 100644 --- a/website/src/docs/api/test-environment.mdx +++ b/website/src/docs/api/test-environment.mdx @@ -91,7 +91,6 @@ 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 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. ## Startup Failures and Retries diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index f2003098..7ea8ad0e 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -103,7 +103,6 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `permissions` | Enable platform-specific permission prompt automation (default: `false`). On iOS, this controls whether Harness starts the XCTest-based permission helper. | | `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | | `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | -| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | | `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | | `coverage` | Coverage configuration object. | | `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). |