From 59daea0377c98951401b3fd01e57346dee90164a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Jan 2026 10:43:30 +0100 Subject: [PATCH 1/2] feat: improve functionality --- packages/bridge/src/server.ts | 2 + packages/config/src/types.ts | 6 + packages/jest/src/crash-monitor.ts | 109 +++++++++++++++++++ packages/jest/src/errors.ts | 10 ++ packages/jest/src/harness.ts | 15 ++- packages/jest/src/index.ts | 54 +++++++-- packages/platform-android/src/adb.ts | 22 +++- packages/platform-android/src/runner.ts | 3 + packages/platform-ios/src/instance.ts | 6 + packages/platform-ios/src/xcrun/devicectl.ts | 16 +++ packages/platform-ios/src/xcrun/simctl.ts | 18 +++ packages/platform-vega/src/runner.ts | 3 + packages/platforms/src/types.ts | 1 + 13 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 packages/jest/src/crash-monitor.ts 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> = { From 27ccefd387ee1269889f2abd614d61529457db9b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 13 Jan 2026 10:44:53 +0100 Subject: [PATCH 2/2] chore: add version plan --- .nx/version-plans/version-plan-1768297433994.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/version-plan-1768297433994.md 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.