diff --git a/.nx/version-plans/version-plan-1768297433994.md b/.nx/version-plans/version-plan-1768297433994.md new file mode 100644 index 0000000..8594723 --- /dev/null +++ b/.nx/version-plans/version-plan-1768297433994.md @@ -0,0 +1,5 @@ +--- +__default__: prerelease +--- + +Added native crash detection during test execution that automatically detects when the app crashes, skips the current test file, and continues with the next test file after restarting the app. diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index 943a676..af45378 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -19,6 +19,7 @@ export type BridgeServerOptions = { export type BridgeServerEvents = { ready: (device: DeviceDescriptor) => void; event: (event: BridgeEvents) => void; + disconnect: () => void; }; export type BridgeServer = { @@ -76,6 +77,7 @@ export const getBridgeServer = async ({ // TODO: Remove channel when connection is closed. clients.delete(ws); + emitter.emit('disconnect'); }); group.updateChannels((channels) => { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 820b520..66937c6 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -28,6 +28,12 @@ export const ConfigSchema = z unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false), unstable__enableMetroCache: z.boolean().optional().default(false), + detectNativeCrashes: z.boolean().optional().default(true), + crashDetectionInterval: z + .number() + .min(100, 'Crash detection interval must be at least 100ms') + .default(500), + // Deprecated property - used for migration detection include: z.array(z.string()).optional(), }) diff --git a/packages/jest/src/crash-monitor.ts b/packages/jest/src/crash-monitor.ts new file mode 100644 index 0000000..d481b68 --- /dev/null +++ b/packages/jest/src/crash-monitor.ts @@ -0,0 +1,109 @@ +import { HarnessPlatformRunner } from '@react-native-harness/platforms'; +import { BridgeServer } from '@react-native-harness/bridge/server'; +import { NativeCrashError } from './errors.js'; +import { logger } from '@react-native-harness/tools'; + +export type CrashMonitor = { + startMonitoring(testFilePath: string): Promise; + stopMonitoring(): void; + markIntentionalRestart(): void; + clearIntentionalRestart(): void; + dispose(): void; +}; + +export type CrashMonitorOptions = { + interval: number; + platformRunner: HarnessPlatformRunner; + bridgeServer: BridgeServer; +}; + +export const createCrashMonitor = ({ + interval, + platformRunner, + bridgeServer, +}: CrashMonitorOptions): CrashMonitor => { + let pollingInterval: NodeJS.Timeout | null = null; + let isIntentionalRestart = false; + let currentTestFilePath: string | null = null; + let rejectFn: ((error: NativeCrashError) => void) | null = null; + + const handleDisconnect = () => { + // Verify if it's actually a crash by checking if app is still running + if (!isIntentionalRestart && currentTestFilePath) { + // Capture the value to avoid it being null when setTimeout callback runs + const testFilePath = currentTestFilePath; + logger.debug('Bridge disconnected, checking if app crashed'); + // Use a slight delay to allow the OS to clean up the process + setTimeout(async () => { + const isRunning = await platformRunner.isAppRunning(); + if (!isRunning && !isIntentionalRestart && rejectFn) { + logger.debug(`Native crash detected during: ${testFilePath}`); + rejectFn(new NativeCrashError(testFilePath)); + } + }, 100); + } + }; + + const startMonitoring = (testFilePath: string): Promise => { + currentTestFilePath = testFilePath; + + return new Promise((_, reject) => { + rejectFn = reject; + + // Listen for bridge disconnect as early indicator + bridgeServer.on('disconnect', handleDisconnect); + + // Poll for app running status + pollingInterval = setInterval(async () => { + // Skip check during intentional restarts + if (isIntentionalRestart) { + return; + } + + try { + const isRunning = await platformRunner.isAppRunning(); + + if (!isRunning && currentTestFilePath) { + logger.debug( + `Native crash detected during: ${currentTestFilePath}` + ); + stopMonitoring(); + reject(new NativeCrashError(currentTestFilePath)); + } + } catch (error) { + logger.error('Error checking app status:', error); + } + }, interval); + }); + }; + + const stopMonitoring = () => { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } + bridgeServer.off('disconnect', handleDisconnect); + currentTestFilePath = null; + rejectFn = null; + }; + + const markIntentionalRestart = () => { + isIntentionalRestart = true; + }; + + const clearIntentionalRestart = () => { + isIntentionalRestart = false; + }; + + const dispose = () => { + stopMonitoring(); + }; + + return { + startMonitoring, + stopMonitoring, + markIntentionalRestart, + clearIntentionalRestart, + dispose, + }; +}; diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index e5bf075..a5a17a7 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -30,3 +30,13 @@ export class MaxAppRestartsError extends HarnessError { this.name = 'MaxAppRestartsError'; } } + +export class NativeCrashError extends HarnessError { + constructor( + public readonly testFilePath: string, + public readonly lastKnownTest?: string + ) { + super('The native app crashed during test execution.'); + this.name = 'NativeCrashError'; + } +} diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 2a0468b..536516a 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -14,11 +14,13 @@ import { } from '@react-native-harness/bundler-metro'; import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; +import { createCrashMonitor, CrashMonitor } from './crash-monitor.js'; export type Harness = { runTests: BridgeClientFunctions['runTests']; restart: () => Promise; dispose: () => Promise; + crashMonitor: CrashMonitor; }; export const waitForAppReady = async (options: { @@ -135,6 +137,12 @@ const getHarnessInternal = async ( ]); }; + const crashMonitor = createCrashMonitor({ + interval: config.crashDetectionInterval, + platformRunner: platformInstance, + bridgeServer: serverBridge, + }); + if (signal.aborted) { await dispose(); @@ -157,7 +165,11 @@ const getHarnessInternal = async ( const restart = () => new Promise((resolve, reject) => { - serverBridge.once('ready', () => resolve()); + crashMonitor.markIntentionalRestart(); + serverBridge.once('ready', () => { + crashMonitor.clearIntentionalRestart(); + resolve(); + }); platformInstance.restartApp().catch(reject); }); @@ -173,6 +185,7 @@ const getHarnessInternal = async ( }, restart, dispose, + crashMonitor, }; }; diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index 4963af9..aeae142 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -17,6 +17,7 @@ import { teardown } from './teardown.js'; import { HarnessError } from '@react-native-harness/tools'; import { getErrorMessage } from './logs.js'; import { DeviceNotRespondingError } from '@react-native-harness/bridge'; +import { NativeCrashError } from './errors.js'; class CancelRun extends Error { constructor(message?: string) { @@ -104,17 +105,52 @@ export default class JestHarness implements CallbackTestRunnerInterface { } isFirstTest = false; - return onStart(test).then(() => - runHarnessTestFile({ - testPath: test.path, - harness, - globalConfig: this.#globalConfig, - projectConfig: test.context.config, - }) - ); + return onStart(test).then(async () => { + if (!harnessConfig.detectNativeCrashes) { + return runHarnessTestFile({ + testPath: test.path, + harness, + globalConfig: this.#globalConfig, + projectConfig: test.context.config, + }); + } + + // Start crash monitoring + const crashPromise = harness.crashMonitor.startMonitoring( + test.path + ); + + try { + const result = await Promise.race([ + runHarnessTestFile({ + testPath: test.path, + harness, + globalConfig: this.#globalConfig, + projectConfig: test.context.config, + }), + crashPromise, + ]); + + return result; + } finally { + harness.crashMonitor.stopMonitoring(); + } + }); }) .then((result) => onResult(test, result)) - .catch((err) => { + .catch(async (err) => { + if (err instanceof NativeCrashError) { + onFailure(test, { + message: err.message, + stack: '', + }); + + // Restart the app for the next test file + await harness.restart(); + + return; + } + if (err instanceof DeviceNotRespondingError) { onFailure(test, { message: err.message, diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 4b9e6f1..48d56c2 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -21,7 +21,13 @@ export const reversePort = async ( port: number, hostPort: number = port ): Promise => { - await spawn('adb', ['-s', adbId, 'reverse', `tcp:${port}`, `tcp:${hostPort}`]); + await spawn('adb', [ + '-s', + adbId, + 'reverse', + `tcp:${port}`, + `tcp:${hostPort}`, + ]); }; export const stopApp = async ( @@ -102,3 +108,17 @@ export const isBootCompleted = async (adbId: string): Promise => { export const stopEmulator = async (adbId: string): Promise => { await spawn('adb', ['-s', adbId, 'emu', 'kill']); }; + +export const isAppRunning = async ( + adbId: string, + bundleId: string +): Promise => { + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'pidof', + bundleId, + ]); + return stdout.trim() !== ''; +}; diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index 419c109..cc866c3 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -60,6 +60,9 @@ const getAndroidRunner = async ( dispose: async () => { await adb.stopApp(adbId, parsedConfig.bundleId); }, + isAppRunning: async () => { + return await adb.isAppRunning(adbId, parsedConfig.bundleId); + }, }; }; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index abfeb61..46711b2 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -64,6 +64,9 @@ export const getAppleSimulatorPlatformInstance = async ( dispose: async () => { await simctl.stopApp(udid, config.bundleId); }, + isAppRunning: async () => { + return await simctl.isAppRunning(udid, config.bundleId); + }, }; }; @@ -101,5 +104,8 @@ export const getApplePhysicalDevicePlatformInstance = async ( dispose: async () => { await devicectl.stopApp(deviceId, config.bundleId); }, + isAppRunning: async () => { + return await devicectl.isAppRunning(deviceId, config.bundleId); + }, }; }; diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index 1a87197..fe53e57 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -151,3 +151,19 @@ export const getDeviceId = async (name: string): Promise => { return device?.identifier ?? null; }; + +export const isAppRunning = async ( + identifier: string, + bundleId: string +): Promise => { + const appInfo = await getAppInfo(identifier, bundleId); + + if (!appInfo) { + return false; + } + + const processes = await getProcesses(identifier); + return processes.some((process) => + process.executable.startsWith(appInfo.url) + ); +}; diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index fc36bb6..71588c7 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -120,3 +120,21 @@ export const getSimulatorId = async ( return simulator?.udid ?? null; }; + +export const isAppRunning = async ( + udid: string, + bundleId: string +): Promise => { + try { + const { stdout } = await spawn('xcrun', [ + 'simctl', + 'spawn', + udid, + 'launchctl', + 'list', + ]); + return stdout.includes(bundleId); + } catch { + return false; + } +}; diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index d93bb5c..ff4d882 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -38,6 +38,9 @@ const getVegaRunner = async ( dispose: async () => { await kepler.stopApp(deviceId, bundleId); }, + isAppRunning: async () => { + return await kepler.isAppRunning(deviceId, bundleId); + }, }; }; diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 05a124f..b6d3f5f 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -3,6 +3,7 @@ export type HarnessPlatformRunner = { restartApp: () => Promise; stopApp: () => Promise; dispose: () => Promise; + isAppRunning: () => Promise; }; export type HarnessPlatform> = {