@react-native-harness/jest 1.0.0 → 1.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ import {
2
+ type AppMonitor,
3
+ type AppCrashDetails,
4
+ type AppMonitorEvent,
5
+ type AppMonitorListener,
6
+ type HarnessPlatformRunner,
7
+ } from '@react-native-harness/platforms';
8
+ import {
9
+ NativeCrashError,
10
+ type NativeCrashDetails,
11
+ type NativeCrashPhase,
12
+ } from './errors.js';
13
+ import { logger } from '@react-native-harness/tools';
14
+
15
+ export type CrashSupervisorState =
16
+ | 'idle'
17
+ | 'launching'
18
+ | 'ready'
19
+ | 'running'
20
+ | 'disposing';
21
+
22
+ export type CrashSupervisor = {
23
+ setActiveTestFile: (testFilePath: string | null) => void;
24
+ beginLaunch: (testFilePath: string) => void;
25
+ markReady: () => void;
26
+ beginTestRun: (testFilePath: string) => void;
27
+ stop: () => Promise<void>;
28
+ start: () => Promise<void>;
29
+ waitForCrash: (testFilePath: string) => Promise<never>;
30
+ isReady: () => boolean;
31
+ cancelCrashWaiters: () => void;
32
+ reset: () => void;
33
+ dispose: () => Promise<void>;
34
+ };
35
+
36
+ export type CrashSupervisorOptions = {
37
+ appMonitor: AppMonitor;
38
+ platformRunner: HarnessPlatformRunner;
39
+ };
40
+
41
+ type CrashDetailsProvider = {
42
+ getCrashDetails?: (
43
+ options: {
44
+ processName?: string;
45
+ pid?: number;
46
+ occurredAt: number;
47
+ }
48
+ ) => Promise<AppCrashDetails | null>;
49
+ };
50
+
51
+ const getCrashPhase = (state: CrashSupervisorState): NativeCrashPhase =>
52
+ state === 'running' ? 'execution' : 'startup';
53
+
54
+ const mergeCrashDetails = (
55
+ phase: NativeCrashPhase,
56
+ initialDetails?: AppCrashDetails,
57
+ enrichedDetails?: AppCrashDetails | null,
58
+ fallbackSummary?: string
59
+ ): NativeCrashDetails => ({
60
+ phase,
61
+ source: enrichedDetails?.source ?? initialDetails?.source,
62
+ summary:
63
+ enrichedDetails?.summary ?? initialDetails?.summary ?? fallbackSummary,
64
+ signal: enrichedDetails?.signal ?? initialDetails?.signal,
65
+ exceptionType: enrichedDetails?.exceptionType ?? initialDetails?.exceptionType,
66
+ processName: enrichedDetails?.processName ?? initialDetails?.processName,
67
+ pid: enrichedDetails?.pid ?? initialDetails?.pid,
68
+ stackTrace: enrichedDetails?.stackTrace ?? initialDetails?.stackTrace,
69
+ rawLines: enrichedDetails?.rawLines ?? initialDetails?.rawLines,
70
+ artifactType: enrichedDetails?.artifactType ?? initialDetails?.artifactType,
71
+ artifactPath: enrichedDetails?.artifactPath ?? initialDetails?.artifactPath,
72
+ });
73
+
74
+ export const createCrashSupervisor = ({
75
+ appMonitor,
76
+ platformRunner,
77
+ }: CrashSupervisorOptions): CrashSupervisor => {
78
+ let state: CrashSupervisorState = 'idle';
79
+ let activeTestFilePath: string | null = null;
80
+ let crashRejectors = new Set<(error: NativeCrashError) => void>();
81
+ let disposed = false;
82
+ let monitoring = true;
83
+ let isResolvingCrash = false;
84
+
85
+ const getCrashDetailsProvider = (): CrashDetailsProvider | null => {
86
+ if ('getCrashDetails' in appMonitor) {
87
+ return appMonitor as AppMonitor & CrashDetailsProvider;
88
+ }
89
+
90
+ if (platformRunner.getCrashDetails) {
91
+ return platformRunner;
92
+ }
93
+
94
+ return null;
95
+ };
96
+
97
+ const rejectCrashWaiters = (testFilePath: string, details: NativeCrashDetails) => {
98
+ const error = new NativeCrashError(testFilePath, details);
99
+
100
+ for (const reject of crashRejectors) {
101
+ reject(error);
102
+ }
103
+
104
+ crashRejectors = new Set();
105
+ };
106
+
107
+ const handleCrash = async (reason: string, details?: AppCrashDetails) => {
108
+ if (
109
+ isResolvingCrash ||
110
+ (state !== 'launching' && state !== 'running' && state !== 'ready')
111
+ ) {
112
+ return;
113
+ }
114
+
115
+ if (!activeTestFilePath) {
116
+ logger.debug(`Ignoring crash signal without active test: ${reason}`);
117
+ return;
118
+ }
119
+
120
+ isResolvingCrash = true;
121
+ logger.debug(`Native crash detected during ${activeTestFilePath} (state: ${state}, reason: ${reason || '(none)'})`);
122
+
123
+ for (const line of details?.rawLines ?? []) {
124
+ logger.debug(line);
125
+ }
126
+
127
+ const phase = getCrashPhase(state);
128
+ const testFilePath = activeTestFilePath;
129
+ state = 'idle';
130
+
131
+ try {
132
+ const enrichedDetails = await getCrashDetailsProvider()?.getCrashDetails?.({
133
+ processName: details?.processName,
134
+ pid: details?.pid,
135
+ occurredAt: Date.now(),
136
+ });
137
+
138
+ const mergedDetails = mergeCrashDetails(phase, details, enrichedDetails, reason);
139
+ logger.debug('Crash details:', {
140
+ phase: mergedDetails.phase,
141
+ source: mergedDetails.source,
142
+ summary: mergedDetails.summary,
143
+ signal: mergedDetails.signal,
144
+ exceptionType: mergedDetails.exceptionType,
145
+ processName: mergedDetails.processName,
146
+ pid: mergedDetails.pid,
147
+ });
148
+ rejectCrashWaiters(testFilePath, mergedDetails);
149
+ } finally {
150
+ isResolvingCrash = false;
151
+ }
152
+ };
153
+
154
+ const confirmCrash = async (reason: string, details?: AppCrashDetails) => {
155
+ if (disposed || !monitoring || state === 'disposing') {
156
+ return;
157
+ }
158
+
159
+ try {
160
+ const isRunning = await platformRunner.isAppRunning();
161
+
162
+ if (!isRunning) {
163
+ handleCrash(reason, details);
164
+ }
165
+ } catch (error) {
166
+ logger.debug('Crash confirmation failed', error);
167
+ }
168
+ };
169
+
170
+ const appMonitorListener: AppMonitorListener = (event: AppMonitorEvent) => {
171
+ if (disposed || !monitoring) {
172
+ return;
173
+ }
174
+
175
+ if (event.type === 'app_started') {
176
+ return;
177
+ }
178
+
179
+ if (event.type === 'app_exited') {
180
+ const details = {
181
+ source: event.crashDetails?.source ?? event.source,
182
+ summary: event.crashDetails?.summary,
183
+ signal: event.crashDetails?.signal,
184
+ exceptionType: event.crashDetails?.exceptionType,
185
+ processName: event.crashDetails?.processName,
186
+ pid: event.crashDetails?.pid ?? event.pid,
187
+ stackTrace: event.crashDetails?.stackTrace,
188
+ rawLines:
189
+ event.crashDetails?.rawLines ??
190
+ (event.line ? [event.line] : undefined),
191
+ };
192
+
193
+ if (event.isConfirmed ?? event.source === 'polling') {
194
+ void handleCrash('', details);
195
+ } else {
196
+ void confirmCrash('', details);
197
+ }
198
+ return;
199
+ }
200
+
201
+ if (event.type === 'possible_crash') {
202
+ const details = {
203
+ source: event.crashDetails?.source ?? event.source,
204
+ summary: event.crashDetails?.summary,
205
+ signal: event.crashDetails?.signal,
206
+ exceptionType: event.crashDetails?.exceptionType,
207
+ processName: event.crashDetails?.processName,
208
+ pid: event.crashDetails?.pid ?? event.pid,
209
+ stackTrace: event.crashDetails?.stackTrace,
210
+ rawLines:
211
+ event.crashDetails?.rawLines ??
212
+ (event.line ? [event.line] : undefined),
213
+ };
214
+
215
+ if (event.isConfirmed) {
216
+ void handleCrash(
217
+ `possible crash signal (${event.source ?? 'unknown'})`,
218
+ details
219
+ );
220
+ } else {
221
+ void confirmCrash(
222
+ `possible crash signal (${event.source ?? 'unknown'})`,
223
+ details
224
+ );
225
+ }
226
+ }
227
+ };
228
+
229
+ appMonitor.addListener(appMonitorListener);
230
+
231
+ const setActiveTestFile = (testFilePath: string | null) => {
232
+ activeTestFilePath = testFilePath;
233
+ };
234
+
235
+ const beginLaunch = (testFilePath: string) => {
236
+ activeTestFilePath = testFilePath;
237
+ state = 'launching';
238
+ };
239
+
240
+ const markReady = () => {
241
+ if (state !== 'disposing') {
242
+ state = 'ready';
243
+ }
244
+ };
245
+
246
+ const beginTestRun = (testFilePath: string) => {
247
+ activeTestFilePath = testFilePath;
248
+ state = 'running';
249
+ };
250
+
251
+ const stop = async () => {
252
+ monitoring = false;
253
+ await appMonitor.stop();
254
+ };
255
+
256
+ const start = async () => {
257
+ monitoring = true;
258
+ await appMonitor.start();
259
+ };
260
+
261
+ const waitForCrash = (testFilePath: string): Promise<never> =>
262
+ new Promise<never>((_, reject) => {
263
+ if (disposed) {
264
+ reject(
265
+ new NativeCrashError(testFilePath, {
266
+ phase: 'startup',
267
+ source: 'polling',
268
+ summary: 'Crash supervisor disposed while waiting for app startup.',
269
+ })
270
+ );
271
+ return;
272
+ }
273
+
274
+ crashRejectors.add(reject);
275
+ });
276
+
277
+ const isReady = () => state === 'ready' || state === 'running';
278
+
279
+ const cancelCrashWaiters = () => {
280
+ crashRejectors = new Set();
281
+ };
282
+
283
+ const reset = () => {
284
+ cancelCrashWaiters();
285
+ isResolvingCrash = false;
286
+ if (state !== 'disposing') {
287
+ state = 'idle';
288
+ }
289
+ };
290
+
291
+ const dispose = async () => {
292
+ disposed = true;
293
+ monitoring = false;
294
+ state = 'disposing';
295
+ crashRejectors = new Set();
296
+ isResolvingCrash = false;
297
+ appMonitor.removeListener(appMonitorListener);
298
+ await appMonitor.dispose();
299
+ };
300
+
301
+ return {
302
+ setActiveTestFile,
303
+ beginLaunch,
304
+ markReady,
305
+ beginTestRun,
306
+ stop,
307
+ start,
308
+ waitForCrash,
309
+ isReady,
310
+ cancelCrashWaiters,
311
+ reset,
312
+ dispose,
313
+ };
314
+ };
package/src/errors.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { HarnessError } from '@react-native-harness/tools';
2
+ import type { AppCrashDetails } from '@react-native-harness/platforms';
2
3
 
3
4
  export class NoRunnerSpecifiedError extends HarnessError {
4
5
  constructor() {
@@ -21,22 +22,76 @@ export class InitializationTimeoutError extends HarnessError {
21
22
  }
22
23
  }
23
24
 
24
- export class MaxAppRestartsError extends HarnessError {
25
- constructor(attempts: number) {
26
- super(
27
- `App failed to start after ${attempts} attempts. ` +
28
- `No bundling activity detected within timeout period.`
25
+ export type NativeCrashPhase = 'startup' | 'execution';
26
+
27
+ export type NativeCrashDetails = AppCrashDetails & {
28
+ phase: NativeCrashPhase;
29
+ };
30
+
31
+ const buildNativeCrashMessage = ({
32
+ phase,
33
+ summary,
34
+ signal,
35
+ exceptionType,
36
+ processName,
37
+ pid,
38
+ stackTrace,
39
+ artifactType,
40
+ }: NativeCrashDetails) => {
41
+ const lines = [
42
+ phase === 'startup'
43
+ ? 'The native app crashed while preparing to run this test file.'
44
+ : 'The native app crashed during test execution.',
45
+ ];
46
+ const hasCrashBlock = summary?.includes('\n') ?? false;
47
+ const shouldRenderSummary =
48
+ Boolean(summary) &&
49
+ !(
50
+ !hasCrashBlock &&
51
+ artifactType === 'ios-crash-report'
29
52
  );
30
- this.name = 'MaxAppRestartsError';
53
+
54
+ if (shouldRenderSummary && summary) {
55
+ lines.push('');
56
+ lines.push(summary);
57
+ }
58
+
59
+ if (!hasCrashBlock && signal) {
60
+ lines.push(`Signal: ${signal}`);
61
+ }
62
+
63
+ if (!hasCrashBlock && exceptionType) {
64
+ lines.push(`Exception: ${exceptionType}`);
31
65
  }
32
- }
66
+
67
+ if (!hasCrashBlock && processName && pid !== undefined) {
68
+ lines.push(`Process: ${processName} (pid ${pid})`);
69
+ } else if (!hasCrashBlock && processName) {
70
+ lines.push(`Process: ${processName}`);
71
+ } else if (!hasCrashBlock && pid !== undefined) {
72
+ lines.push(`PID: ${pid}`);
73
+ }
74
+
75
+ if (!hasCrashBlock && stackTrace && stackTrace.length > 0) {
76
+ lines.push('');
77
+ lines.push(...stackTrace.map((line) => ` ${line}`));
78
+ }
79
+
80
+ return lines.join('\n');
81
+ };
33
82
 
34
83
  export class NativeCrashError extends HarnessError {
35
84
  constructor(
36
85
  public readonly testFilePath: string,
86
+ public readonly details: NativeCrashDetails,
37
87
  public readonly lastKnownTest?: string
38
88
  ) {
39
- super('The native app crashed during test execution.');
89
+ super(buildNativeCrashMessage(details));
40
90
  this.name = 'NativeCrashError';
91
+ this.stack = `${this.name}: ${this.message.split('\n')[0]}`;
92
+ }
93
+
94
+ get phase() {
95
+ return this.details.phase;
41
96
  }
42
97
  }
package/src/harness.ts CHANGED
@@ -8,18 +8,21 @@ import {
8
8
  TestSuiteResult,
9
9
  } from '@react-native-harness/bridge';
10
10
  import {
11
+ type AppLaunchOptions,
11
12
  HarnessPlatform,
12
13
  HarnessPlatformRunner,
13
14
  } from '@react-native-harness/platforms';
14
15
  import {
15
16
  getMetroInstance,
16
17
  prewarmMetroBundle,
17
- Reporter,
18
- ReportableEvent,
19
18
  } from '@react-native-harness/bundler-metro';
20
- import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js';
19
+ import { createCrashArtifactWriter } from '@react-native-harness/tools';
20
+ import { InitializationTimeoutError } from './errors.js';
21
21
  import { Config as HarnessConfig } from '@react-native-harness/config';
22
- import { createCrashMonitor, CrashMonitor } from './crash-monitor.js';
22
+ import {
23
+ createCrashSupervisor,
24
+ type CrashSupervisor,
25
+ } from './crash-supervisor.js';
23
26
  import { createClientLogListener } from './client-log-handler.js';
24
27
  import { logMetroPrewarmCompleted } from './logs.js';
25
28
 
@@ -31,97 +34,65 @@ export type Harness = {
31
34
  path: string,
32
35
  options: HarnessRunTestsOptions
33
36
  ) => Promise<TestSuiteResult>;
34
- restart: () => Promise<void>;
37
+ ensureAppReady: (testFilePath: string) => Promise<void>;
38
+ restart: (testFilePath?: string) => Promise<void>;
35
39
  dispose: () => Promise<void>;
36
- crashMonitor: CrashMonitor;
40
+ crashSupervisor: CrashSupervisor;
37
41
  };
38
42
 
39
43
  export const waitForAppReady = async (options: {
40
- metroEvents: Reporter;
41
44
  serverBridge: BridgeServer;
42
45
  platformInstance: HarnessPlatformRunner;
43
- bundleStartTimeout: number;
44
- maxRestarts: number;
45
- signal: AbortSignal;
46
+ bridgeTimeout: number;
47
+ testFilePath: string;
48
+ crashSupervisor: CrashSupervisor;
49
+ appLaunchOptions?: AppLaunchOptions;
46
50
  }): Promise<void> => {
47
51
  const {
48
- metroEvents,
49
52
  serverBridge,
50
53
  platformInstance,
51
- bundleStartTimeout,
52
- maxRestarts,
53
- signal,
54
+ bridgeTimeout,
55
+ testFilePath,
56
+ crashSupervisor,
57
+ appLaunchOptions,
54
58
  } = options;
55
59
 
56
- let restartCount = 0;
57
- let isBundling = false;
58
- let bundleTimeoutId: NodeJS.Timeout | null = null;
59
-
60
- const clearBundleTimeout = () => {
61
- if (bundleTimeoutId) {
62
- clearTimeout(bundleTimeoutId);
63
- bundleTimeoutId = null;
64
- }
65
- };
60
+ const signal = AbortSignal.timeout(bridgeTimeout);
66
61
 
67
62
  return new Promise<void>((resolve, reject) => {
68
- // Handle abort signal
69
- signal.addEventListener('abort', () => {
70
- clearBundleTimeout();
71
- reject(new DOMException('The operation was aborted', 'AbortError'));
72
- });
73
-
74
- // Start/restart the bundle timeout
75
- const startBundleTimeout = () => {
76
- clearBundleTimeout();
77
- bundleTimeoutId = setTimeout(() => {
78
- if (isBundling) return; // Don't restart while bundling
79
-
80
- if (restartCount >= maxRestarts) {
81
- cleanup();
82
- reject(new MaxAppRestartsError(restartCount + 1));
83
- return;
84
- }
85
-
86
- restartCount++;
87
- platformInstance.restartApp().catch(reject);
88
- startBundleTimeout(); // Reset timer for next attempt
89
- }, bundleStartTimeout);
90
- };
91
-
92
- // Metro event listener
93
- const onMetroEvent = (event: ReportableEvent) => {
94
- if (event.type === 'bundle_build_started') {
95
- isBundling = true;
96
- clearBundleTimeout(); // Cancel restart timer while bundling
97
- } else if (
98
- event.type === 'bundle_build_done' ||
99
- event.type === 'bundle_build_failed'
100
- ) {
101
- isBundling = false;
102
- startBundleTimeout(); // Reset timer after bundle completes
103
- }
63
+ const launchApp = async () => {
64
+ crashSupervisor.beginLaunch(testFilePath);
65
+ await platformInstance.restartApp(appLaunchOptions);
104
66
  };
105
67
 
106
- // Bridge ready listener
107
68
  const onReady = () => {
69
+ crashSupervisor.markReady();
108
70
  cleanup();
109
71
  resolve();
110
72
  };
111
73
 
74
+ const onAbort = () => {
75
+ cleanup();
76
+ reject(new DOMException('The operation was aborted', 'AbortError'));
77
+ };
78
+
112
79
  const cleanup = () => {
113
- clearBundleTimeout();
114
- metroEvents.removeListener(onMetroEvent);
115
80
  serverBridge.off('ready', onReady);
81
+ signal.removeEventListener('abort', onAbort);
82
+ crashSupervisor.cancelCrashWaiters();
116
83
  };
117
84
 
118
- // Setup listeners
119
- metroEvents.addListener(onMetroEvent);
85
+ signal.addEventListener('abort', onAbort);
120
86
  serverBridge.once('ready', onReady);
87
+ void crashSupervisor.waitForCrash(testFilePath).catch((error) => {
88
+ cleanup();
89
+ reject(error);
90
+ });
121
91
 
122
- // Start the app and timeout
123
- platformInstance.restartApp().catch(reject);
124
- startBundleTimeout();
92
+ void launchApp().catch((error) => {
93
+ cleanup();
94
+ reject(error);
95
+ });
125
96
  });
126
97
  };
127
98
 
@@ -146,9 +117,24 @@ const getHarnessInternal = async (
146
117
  context,
147
118
  }),
148
119
  ]);
120
+ const crashArtifactWriter = createCrashArtifactWriter({
121
+ runnerName: platform.name,
122
+ platformId: platform.platformId,
123
+ });
124
+ const appMonitor = platformInstance.createAppMonitor({
125
+ crashArtifactWriter,
126
+ });
127
+ const appLaunchOptions = (
128
+ platform.config as { appLaunchOptions?: AppLaunchOptions }
129
+ ).appLaunchOptions;
149
130
 
150
- // Forward client logs to console if enabled
151
131
  const clientLogListener = createClientLogListener();
132
+ const crashSupervisor = createCrashSupervisor({
133
+ appMonitor,
134
+ platformRunner: platformInstance,
135
+ });
136
+
137
+ serverBridge.on('ready', crashSupervisor.markReady);
152
138
 
153
139
  if (config.forwardClientLogs) {
154
140
  metroInstance.events.addListener(clientLogListener);
@@ -158,19 +144,15 @@ const getHarnessInternal = async (
158
144
  if (config.forwardClientLogs) {
159
145
  metroInstance.events.removeListener(clientLogListener);
160
146
  }
147
+ serverBridge.off('ready', crashSupervisor.markReady);
161
148
  await Promise.all([
149
+ crashSupervisor.dispose(),
162
150
  serverBridge.dispose(),
163
151
  platformInstance.dispose(),
164
152
  metroInstance.dispose(),
165
153
  ]);
166
154
  };
167
155
 
168
- const crashMonitor = createCrashMonitor({
169
- interval: config.crashDetectionInterval,
170
- platformRunner: platformInstance,
171
- bridgeServer: serverBridge,
172
- });
173
-
174
156
  if (signal.aborted) {
175
157
  await dispose();
176
158
 
@@ -187,29 +169,46 @@ const getHarnessInternal = async (
187
169
  signal,
188
170
  });
189
171
  logMetroPrewarmCompleted(platform);
190
-
191
- await waitForAppReady({
192
- metroEvents: metroInstance.events,
193
- serverBridge,
194
- platformInstance: platformInstance as HarnessPlatformRunner,
195
- bundleStartTimeout: config.bundleStartTimeout,
196
- maxRestarts: config.maxAppRestarts,
197
- signal,
198
- });
172
+ await appMonitor.start();
199
173
  } catch (error) {
200
174
  await dispose();
201
175
  throw error;
202
176
  }
203
177
 
204
- const restart = () =>
205
- new Promise<void>((resolve, reject) => {
206
- crashMonitor.markIntentionalRestart();
207
- serverBridge.once('ready', () => {
208
- crashMonitor.clearIntentionalRestart();
209
- resolve();
210
- });
211
- platformInstance.restartApp().catch(reject);
178
+ const ensureAppReady = async (testFilePath: string) => {
179
+ crashSupervisor.setActiveTestFile(testFilePath);
180
+
181
+ if (crashSupervisor.isReady() && (await platformInstance.isAppRunning())) {
182
+ return;
183
+ }
184
+
185
+ crashSupervisor.reset();
186
+ await waitForAppReady({
187
+ serverBridge,
188
+ platformInstance: platformInstance as HarnessPlatformRunner,
189
+ bridgeTimeout: config.bridgeTimeout,
190
+ testFilePath,
191
+ crashSupervisor,
192
+ appLaunchOptions,
212
193
  });
194
+ };
195
+
196
+ const restart = async (testFilePath?: string) => {
197
+ await crashSupervisor.stop();
198
+
199
+ if (testFilePath) {
200
+ await platformInstance.stopApp();
201
+ } else {
202
+ await platformInstance.restartApp(appLaunchOptions);
203
+ }
204
+
205
+ crashSupervisor.reset();
206
+ await crashSupervisor.start();
207
+
208
+ if (testFilePath) {
209
+ await ensureAppReady(testFilePath);
210
+ }
211
+ };
213
212
 
214
213
  return {
215
214
  context,
@@ -225,9 +224,10 @@ const getHarnessInternal = async (
225
224
  runner: platform.runner,
226
225
  });
227
226
  },
227
+ ensureAppReady,
228
228
  restart,
229
229
  dispose,
230
- crashMonitor,
230
+ crashSupervisor,
231
231
  };
232
232
  };
233
233