Skip to content

Commit 59daea0

Browse files
committed
feat: improve functionality
1 parent 55c4b53 commit 59daea0

13 files changed

Lines changed: 254 additions & 11 deletions

File tree

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
};

packages/platform-ios/src/xcrun/devicectl.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,19 @@ export const getDeviceId = async (name: string): Promise<string | null> => {
151151

152152
return device?.identifier ?? null;
153153
};
154+
155+
export const isAppRunning = async (
156+
identifier: string,
157+
bundleId: string
158+
): Promise<boolean> => {
159+
const appInfo = await getAppInfo(identifier, bundleId);
160+
161+
if (!appInfo) {
162+
return false;
163+
}
164+
165+
const processes = await getProcesses(identifier);
166+
return processes.some((process) =>
167+
process.executable.startsWith(appInfo.url)
168+
);
169+
};

0 commit comments

Comments
 (0)