Skip to content

Commit 989d211

Browse files
authored
feat: detect native crashes (#56)
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.
1 parent 55c4b53 commit 989d211

File tree

14 files changed

+259
-11
lines changed

14 files changed

+259
-11
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
__default__: prerelease
3+
---
4+
5+
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.

packages/bridge/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type BridgeServerOptions = {
1919
export type BridgeServerEvents = {
2020
ready: (device: DeviceDescriptor) => void;
2121
event: (event: BridgeEvents) => void;
22+
disconnect: () => void;
2223
};
2324

2425
export type BridgeServer = {
@@ -76,6 +77,7 @@ export const getBridgeServer = async ({
7677

7778
// TODO: Remove channel when connection is closed.
7879
clients.delete(ws);
80+
emitter.emit('disconnect');
7981
});
8082

8183
group.updateChannels((channels) => {

packages/config/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export const ConfigSchema = z
2828
unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false),
2929
unstable__enableMetroCache: z.boolean().optional().default(false),
3030

31+
detectNativeCrashes: z.boolean().optional().default(true),
32+
crashDetectionInterval: z
33+
.number()
34+
.min(100, 'Crash detection interval must be at least 100ms')
35+
.default(500),
36+
3137
// Deprecated property - used for migration detection
3238
include: z.array(z.string()).optional(),
3339
})

packages/jest/src/crash-monitor.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { HarnessPlatformRunner } from '@react-native-harness/platforms';
2+
import { BridgeServer } from '@react-native-harness/bridge/server';
3+
import { NativeCrashError } from './errors.js';
4+
import { logger } from '@react-native-harness/tools';
5+
6+
export type CrashMonitor = {
7+
startMonitoring(testFilePath: string): Promise<never>;
8+
stopMonitoring(): void;
9+
markIntentionalRestart(): void;
10+
clearIntentionalRestart(): void;
11+
dispose(): void;
12+
};
13+
14+
export type CrashMonitorOptions = {
15+
interval: number;
16+
platformRunner: HarnessPlatformRunner;
17+
bridgeServer: BridgeServer;
18+
};
19+
20+
export const createCrashMonitor = ({
21+
interval,
22+
platformRunner,
23+
bridgeServer,
24+
}: CrashMonitorOptions): CrashMonitor => {
25+
let pollingInterval: NodeJS.Timeout | null = null;
26+
let isIntentionalRestart = false;
27+
let currentTestFilePath: string | null = null;
28+
let rejectFn: ((error: NativeCrashError) => void) | null = null;
29+
30+
const handleDisconnect = () => {
31+
// Verify if it's actually a crash by checking if app is still running
32+
if (!isIntentionalRestart && currentTestFilePath) {
33+
// Capture the value to avoid it being null when setTimeout callback runs
34+
const testFilePath = currentTestFilePath;
35+
logger.debug('Bridge disconnected, checking if app crashed');
36+
// Use a slight delay to allow the OS to clean up the process
37+
setTimeout(async () => {
38+
const isRunning = await platformRunner.isAppRunning();
39+
if (!isRunning && !isIntentionalRestart && rejectFn) {
40+
logger.debug(`Native crash detected during: ${testFilePath}`);
41+
rejectFn(new NativeCrashError(testFilePath));
42+
}
43+
}, 100);
44+
}
45+
};
46+
47+
const startMonitoring = (testFilePath: string): Promise<never> => {
48+
currentTestFilePath = testFilePath;
49+
50+
return new Promise<never>((_, reject) => {
51+
rejectFn = reject;
52+
53+
// Listen for bridge disconnect as early indicator
54+
bridgeServer.on('disconnect', handleDisconnect);
55+
56+
// Poll for app running status
57+
pollingInterval = setInterval(async () => {
58+
// Skip check during intentional restarts
59+
if (isIntentionalRestart) {
60+
return;
61+
}
62+
63+
try {
64+
const isRunning = await platformRunner.isAppRunning();
65+
66+
if (!isRunning && currentTestFilePath) {
67+
logger.debug(
68+
`Native crash detected during: ${currentTestFilePath}`
69+
);
70+
stopMonitoring();
71+
reject(new NativeCrashError(currentTestFilePath));
72+
}
73+
} catch (error) {
74+
logger.error('Error checking app status:', error);
75+
}
76+
}, interval);
77+
});
78+
};
79+
80+
const stopMonitoring = () => {
81+
if (pollingInterval) {
82+
clearInterval(pollingInterval);
83+
pollingInterval = null;
84+
}
85+
bridgeServer.off('disconnect', handleDisconnect);
86+
currentTestFilePath = null;
87+
rejectFn = null;
88+
};
89+
90+
const markIntentionalRestart = () => {
91+
isIntentionalRestart = true;
92+
};
93+
94+
const clearIntentionalRestart = () => {
95+
isIntentionalRestart = false;
96+
};
97+
98+
const dispose = () => {
99+
stopMonitoring();
100+
};
101+
102+
return {
103+
startMonitoring,
104+
stopMonitoring,
105+
markIntentionalRestart,
106+
clearIntentionalRestart,
107+
dispose,
108+
};
109+
};

packages/jest/src/errors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,13 @@ export class MaxAppRestartsError extends HarnessError {
3030
this.name = 'MaxAppRestartsError';
3131
}
3232
}
33+
34+
export class NativeCrashError extends HarnessError {
35+
constructor(
36+
public readonly testFilePath: string,
37+
public readonly lastKnownTest?: string
38+
) {
39+
super('The native app crashed during test execution.');
40+
this.name = 'NativeCrashError';
41+
}
42+
}

packages/jest/src/harness.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {
1414
} from '@react-native-harness/bundler-metro';
1515
import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js';
1616
import { Config as HarnessConfig } from '@react-native-harness/config';
17+
import { createCrashMonitor, CrashMonitor } from './crash-monitor.js';
1718

1819
export type Harness = {
1920
runTests: BridgeClientFunctions['runTests'];
2021
restart: () => Promise<void>;
2122
dispose: () => Promise<void>;
23+
crashMonitor: CrashMonitor;
2224
};
2325

2426
export const waitForAppReady = async (options: {
@@ -135,6 +137,12 @@ const getHarnessInternal = async (
135137
]);
136138
};
137139

140+
const crashMonitor = createCrashMonitor({
141+
interval: config.crashDetectionInterval,
142+
platformRunner: platformInstance,
143+
bridgeServer: serverBridge,
144+
});
145+
138146
if (signal.aborted) {
139147
await dispose();
140148

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

158166
const restart = () =>
159167
new Promise<void>((resolve, reject) => {
160-
serverBridge.once('ready', () => resolve());
168+
crashMonitor.markIntentionalRestart();
169+
serverBridge.once('ready', () => {
170+
crashMonitor.clearIntentionalRestart();
171+
resolve();
172+
});
161173
platformInstance.restartApp().catch(reject);
162174
});
163175

@@ -173,6 +185,7 @@ const getHarnessInternal = async (
173185
},
174186
restart,
175187
dispose,
188+
crashMonitor,
176189
};
177190
};
178191

packages/jest/src/index.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { teardown } from './teardown.js';
1717
import { HarnessError } from '@react-native-harness/tools';
1818
import { getErrorMessage } from './logs.js';
1919
import { DeviceNotRespondingError } from '@react-native-harness/bridge';
20+
import { NativeCrashError } from './errors.js';
2021

2122
class CancelRun extends Error {
2223
constructor(message?: string) {
@@ -104,17 +105,52 @@ export default class JestHarness implements CallbackTestRunnerInterface {
104105
}
105106
isFirstTest = false;
106107

107-
return onStart(test).then(() =>
108-
runHarnessTestFile({
109-
testPath: test.path,
110-
harness,
111-
globalConfig: this.#globalConfig,
112-
projectConfig: test.context.config,
113-
})
114-
);
108+
return onStart(test).then(async () => {
109+
if (!harnessConfig.detectNativeCrashes) {
110+
return runHarnessTestFile({
111+
testPath: test.path,
112+
harness,
113+
globalConfig: this.#globalConfig,
114+
projectConfig: test.context.config,
115+
});
116+
}
117+
118+
// Start crash monitoring
119+
const crashPromise = harness.crashMonitor.startMonitoring(
120+
test.path
121+
);
122+
123+
try {
124+
const result = await Promise.race([
125+
runHarnessTestFile({
126+
testPath: test.path,
127+
harness,
128+
globalConfig: this.#globalConfig,
129+
projectConfig: test.context.config,
130+
}),
131+
crashPromise,
132+
]);
133+
134+
return result;
135+
} finally {
136+
harness.crashMonitor.stopMonitoring();
137+
}
138+
});
115139
})
116140
.then((result) => onResult(test, result))
117-
.catch((err) => {
141+
.catch(async (err) => {
142+
if (err instanceof NativeCrashError) {
143+
onFailure(test, {
144+
message: err.message,
145+
stack: '',
146+
});
147+
148+
// Restart the app for the next test file
149+
await harness.restart();
150+
151+
return;
152+
}
153+
118154
if (err instanceof DeviceNotRespondingError) {
119155
onFailure(test, {
120156
message: err.message,

packages/platform-android/src/adb.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ export const reversePort = async (
2121
port: number,
2222
hostPort: number = port
2323
): Promise<void> => {
24-
await spawn('adb', ['-s', adbId, 'reverse', `tcp:${port}`, `tcp:${hostPort}`]);
24+
await spawn('adb', [
25+
'-s',
26+
adbId,
27+
'reverse',
28+
`tcp:${port}`,
29+
`tcp:${hostPort}`,
30+
]);
2531
};
2632

2733
export const stopApp = async (
@@ -102,3 +108,17 @@ export const isBootCompleted = async (adbId: string): Promise<boolean> => {
102108
export const stopEmulator = async (adbId: string): Promise<void> => {
103109
await spawn('adb', ['-s', adbId, 'emu', 'kill']);
104110
};
111+
112+
export const isAppRunning = async (
113+
adbId: string,
114+
bundleId: string
115+
): Promise<boolean> => {
116+
const { stdout } = await spawn('adb', [
117+
'-s',
118+
adbId,
119+
'shell',
120+
'pidof',
121+
bundleId,
122+
]);
123+
return stdout.trim() !== '';
124+
};

packages/platform-android/src/runner.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ const getAndroidRunner = async (
6060
dispose: async () => {
6161
await adb.stopApp(adbId, parsedConfig.bundleId);
6262
},
63+
isAppRunning: async () => {
64+
return await adb.isAppRunning(adbId, parsedConfig.bundleId);
65+
},
6366
};
6467
};
6568

packages/platform-ios/src/instance.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export const getAppleSimulatorPlatformInstance = async (
6464
dispose: async () => {
6565
await simctl.stopApp(udid, config.bundleId);
6666
},
67+
isAppRunning: async () => {
68+
return await simctl.isAppRunning(udid, config.bundleId);
69+
},
6770
};
6871
};
6972

@@ -101,5 +104,8 @@ export const getApplePhysicalDevicePlatformInstance = async (
101104
dispose: async () => {
102105
await devicectl.stopApp(deviceId, config.bundleId);
103106
},
107+
isAppRunning: async () => {
108+
return await devicectl.isAppRunning(deviceId, config.bundleId);
109+
},
104110
};
105111
};

0 commit comments

Comments
 (0)