Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1768297433994.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/bridge/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type BridgeServerOptions = {
export type BridgeServerEvents = {
ready: (device: DeviceDescriptor) => void;
event: (event: BridgeEvents) => void;
disconnect: () => void;
};

export type BridgeServer = {
Expand Down Expand Up @@ -76,6 +77,7 @@ export const getBridgeServer = async ({

// TODO: Remove channel when connection is closed.
clients.delete(ws);
emitter.emit('disconnect');
});

group.updateChannels((channels) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
109 changes: 109 additions & 0 deletions packages/jest/src/crash-monitor.ts
Original file line number Diff line number Diff line change
@@ -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<never>;
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<never> => {
currentTestFilePath = testFilePath;

return new Promise<never>((_, 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,
};
};
10 changes: 10 additions & 0 deletions packages/jest/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
15 changes: 14 additions & 1 deletion packages/jest/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
dispose: () => Promise<void>;
crashMonitor: CrashMonitor;
};

export const waitForAppReady = async (options: {
Expand Down Expand Up @@ -135,6 +137,12 @@ const getHarnessInternal = async (
]);
};

const crashMonitor = createCrashMonitor({
interval: config.crashDetectionInterval,
platformRunner: platformInstance,
bridgeServer: serverBridge,
});

if (signal.aborted) {
await dispose();

Expand All @@ -157,7 +165,11 @@ const getHarnessInternal = async (

const restart = () =>
new Promise<void>((resolve, reject) => {
serverBridge.once('ready', () => resolve());
crashMonitor.markIntentionalRestart();
serverBridge.once('ready', () => {
crashMonitor.clearIntentionalRestart();
resolve();
});
platformInstance.restartApp().catch(reject);
});

Expand All @@ -173,6 +185,7 @@ const getHarnessInternal = async (
},
restart,
dispose,
crashMonitor,
};
};

Expand Down
54 changes: 45 additions & 9 deletions packages/jest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion packages/platform-android/src/adb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ export const reversePort = async (
port: number,
hostPort: number = port
): Promise<void> => {
await spawn('adb', ['-s', adbId, 'reverse', `tcp:${port}`, `tcp:${hostPort}`]);
await spawn('adb', [
'-s',
adbId,
'reverse',
`tcp:${port}`,
`tcp:${hostPort}`,
]);
};

export const stopApp = async (
Expand Down Expand Up @@ -102,3 +108,17 @@ export const isBootCompleted = async (adbId: string): Promise<boolean> => {
export const stopEmulator = async (adbId: string): Promise<void> => {
await spawn('adb', ['-s', adbId, 'emu', 'kill']);
};

export const isAppRunning = async (
adbId: string,
bundleId: string
): Promise<boolean> => {
const { stdout } = await spawn('adb', [
'-s',
adbId,
'shell',
'pidof',
bundleId,
]);
return stdout.trim() !== '';
};
3 changes: 3 additions & 0 deletions packages/platform-android/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ const getAndroidRunner = async (
dispose: async () => {
await adb.stopApp(adbId, parsedConfig.bundleId);
},
isAppRunning: async () => {
return await adb.isAppRunning(adbId, parsedConfig.bundleId);
},
};
};

Expand Down
6 changes: 6 additions & 0 deletions packages/platform-ios/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
};
};

Expand Down Expand Up @@ -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);
},
};
};
16 changes: 16 additions & 0 deletions packages/platform-ios/src/xcrun/devicectl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,19 @@ export const getDeviceId = async (name: string): Promise<string | null> => {

return device?.identifier ?? null;
};

export const isAppRunning = async (
identifier: string,
bundleId: string
): Promise<boolean> => {
const appInfo = await getAppInfo(identifier, bundleId);

if (!appInfo) {
return false;
}

const processes = await getProcesses(identifier);
return processes.some((process) =>
process.executable.startsWith(appInfo.url)
);
};
18 changes: 18 additions & 0 deletions packages/platform-ios/src/xcrun/simctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,21 @@ export const getSimulatorId = async (

return simulator?.udid ?? null;
};

export const isAppRunning = async (
udid: string,
bundleId: string
): Promise<boolean> => {
try {
const { stdout } = await spawn('xcrun', [
'simctl',
'spawn',
udid,
'launchctl',
'list',
]);
return stdout.includes(bundleId);
} catch {
return false;
}
};
3 changes: 3 additions & 0 deletions packages/platform-vega/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const getVegaRunner = async (
dispose: async () => {
await kepler.stopApp(deviceId, bundleId);
},
isAppRunning: async () => {
return await kepler.isAppRunning(deviceId, bundleId);
},
};
};

Expand Down
1 change: 1 addition & 0 deletions packages/platforms/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type HarnessPlatformRunner = {
restartApp: () => Promise<void>;
stopApp: () => Promise<void>;
dispose: () => Promise<void>;
isAppRunning: () => Promise<boolean>;
};

export type HarnessPlatform<TConfig = Record<string, unknown>> = {
Expand Down