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

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.
Files changed (80) hide show
  1. package/dist/__tests__/crash-supervisor.test.d.ts +2 -0
  2. package/dist/__tests__/crash-supervisor.test.d.ts.map +1 -0
  3. package/dist/__tests__/crash-supervisor.test.js +151 -0
  4. package/dist/__tests__/errors.test.d.ts +2 -0
  5. package/dist/__tests__/errors.test.d.ts.map +1 -0
  6. package/dist/__tests__/errors.test.js +54 -0
  7. package/dist/__tests__/harness-cache.test.d.ts +2 -0
  8. package/dist/__tests__/harness-cache.test.d.ts.map +1 -0
  9. package/dist/__tests__/harness-cache.test.js +51 -0
  10. package/dist/__tests__/harness.test.d.ts +2 -0
  11. package/dist/__tests__/harness.test.d.ts.map +1 -0
  12. package/dist/__tests__/harness.test.js +399 -0
  13. package/dist/__tests__/index.test.d.ts +2 -0
  14. package/dist/__tests__/index.test.d.ts.map +1 -0
  15. package/dist/__tests__/index.test.js +42 -0
  16. package/dist/cli-args.d.ts +1 -0
  17. package/dist/cli-args.d.ts.map +1 -1
  18. package/dist/cli-args.js +5 -0
  19. package/dist/crash-supervisor.d.ts +21 -0
  20. package/dist/crash-supervisor.d.ts.map +1 -0
  21. package/dist/crash-supervisor.js +210 -0
  22. package/dist/errors.d.ts +9 -4
  23. package/dist/errors.d.ts.map +1 -1
  24. package/dist/errors.js +43 -8
  25. package/dist/harness.d.ts +30 -8
  26. package/dist/harness.d.ts.map +1 -1
  27. package/dist/harness.js +440 -98
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +205 -57
  30. package/dist/logs.d.ts +1 -0
  31. package/dist/logs.d.ts.map +1 -1
  32. package/dist/logs.js +3 -0
  33. package/dist/run.d.ts +7 -1
  34. package/dist/run.d.ts.map +1 -1
  35. package/dist/run.js +13 -8
  36. package/dist/setup.d.ts.map +1 -1
  37. package/dist/setup.js +18 -6
  38. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  39. package/package.json +7 -6
  40. package/src/__tests__/crash-supervisor.test.ts +200 -0
  41. package/src/__tests__/errors.test.ts +70 -0
  42. package/src/__tests__/harness-cache.test.ts +74 -0
  43. package/src/__tests__/harness.test.ts +547 -0
  44. package/src/__tests__/index.test.ts +61 -0
  45. package/src/cli-args.ts +6 -0
  46. package/src/crash-supervisor.ts +321 -0
  47. package/src/env.d.ts +1 -1
  48. package/src/errors.ts +68 -8
  49. package/src/harness.ts +566 -120
  50. package/src/index.ts +276 -66
  51. package/src/logs.ts +4 -0
  52. package/src/run.ts +20 -10
  53. package/src/setup.ts +22 -6
  54. package/tsconfig.json +3 -3
  55. package/tsconfig.lib.json +6 -6
  56. package/dist/cli-args.test.d.ts +0 -2
  57. package/dist/cli-args.test.d.ts.map +0 -1
  58. package/dist/cli-args.test.js +0 -1
  59. package/dist/crash-monitor.d.ts +0 -16
  60. package/dist/crash-monitor.d.ts.map +0 -1
  61. package/dist/crash-monitor.js +0 -75
  62. package/dist/event-mapper.d.ts +0 -16
  63. package/dist/event-mapper.d.ts.map +0 -1
  64. package/dist/event-mapper.js +0 -120
  65. package/dist/global-setup.d.ts +0 -3
  66. package/dist/global-setup.d.ts.map +0 -1
  67. package/dist/global-setup.js +0 -60
  68. package/dist/global-teardown.d.ts +0 -3
  69. package/dist/global-teardown.d.ts.map +0 -1
  70. package/dist/global-teardown.js +0 -11
  71. package/dist/harness-reporter.d.ts +0 -8
  72. package/dist/harness-reporter.d.ts.map +0 -1
  73. package/dist/harness-reporter.js +0 -12
  74. package/dist/jest-options.d.ts +0 -2
  75. package/dist/jest-options.d.ts.map +0 -1
  76. package/dist/jest-options.js +0 -1
  77. package/dist/types.d.ts +0 -17
  78. package/dist/types.d.ts.map +0 -1
  79. package/dist/types.js +0 -1
  80. package/src/crash-monitor.ts +0 -109
package/src/harness.ts CHANGED
@@ -4,124 +4,192 @@ import {
4
4
  } from '@react-native-harness/bridge/server';
5
5
  import {
6
6
  HarnessContext,
7
+ type BridgeEvents,
8
+ type DeviceDescriptor,
7
9
  TestExecutionOptions,
8
10
  TestSuiteResult,
9
11
  } from '@react-native-harness/bridge';
10
12
  import {
13
+ type AppMonitorEvent,
14
+ type AppLaunchOptions,
11
15
  HarnessPlatform,
12
16
  HarnessPlatformRunner,
13
17
  } from '@react-native-harness/platforms';
14
18
  import {
15
19
  getMetroInstance,
16
- prewarmMetroBundle,
17
- Reporter,
18
- ReportableEvent,
20
+ isMetroCacheReusable,
21
+ waitForMetroBackedAppReady,
22
+ type MetroInstance,
23
+ type ReportableEvent,
19
24
  } from '@react-native-harness/bundler-metro';
20
- import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js';
25
+ import {
26
+ createHarnessPluginManager,
27
+ type FlatHarnessHookContexts,
28
+ type HarnessPlugin,
29
+ type HarnessPluginManager,
30
+ type HarnessRunStatus,
31
+ type HarnessRunSummary,
32
+ } from '@react-native-harness/plugins';
33
+ import { logger, createCrashArtifactWriter } from '@react-native-harness/tools';
34
+ import { InitializationTimeoutError } from './errors.js';
21
35
  import { Config as HarnessConfig } from '@react-native-harness/config';
22
- import { createCrashMonitor, CrashMonitor } from './crash-monitor.js';
36
+ import {
37
+ createCrashSupervisor,
38
+ type CrashSupervisor,
39
+ } from './crash-supervisor.js';
23
40
  import { createClientLogListener } from './client-log-handler.js';
24
- import { logMetroPrewarmCompleted } from './logs.js';
41
+ import path from 'node:path';
42
+ import { logMetroCacheReused } from './logs.js';
43
+
44
+ const harnessLogger = logger.child('runtime');
25
45
 
26
46
  export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
27
47
 
48
+ export type HarnessRunState = {
49
+ runId: string;
50
+ startTime: number;
51
+ testFiles: string[];
52
+ watchMode: boolean;
53
+ coverageEnabled: boolean;
54
+ summary?: HarnessRunSummary;
55
+ status?: HarnessRunStatus;
56
+ error?: unknown;
57
+ };
58
+
28
59
  export type Harness = {
29
60
  context: HarnessContext;
30
61
  runTests: (
31
62
  path: string,
32
63
  options: HarnessRunTestsOptions
33
64
  ) => Promise<TestSuiteResult>;
34
- restart: () => Promise<void>;
65
+ ensureAppReady: (testFilePath: string) => Promise<void>;
66
+ restart: (testFilePath?: string) => Promise<void>;
35
67
  dispose: () => Promise<void>;
36
- crashMonitor: CrashMonitor;
68
+ crashSupervisor: CrashSupervisor;
69
+ callHook: HarnessPluginManager<HarnessConfig, HarnessPlatform>['callHook'];
70
+ setRunState: (runState: HarnessRunState | null) => void;
71
+ getRunState: () => HarnessRunState | null;
72
+ };
73
+
74
+ export const maybeLogMetroCacheReuse = (
75
+ config: HarnessConfig,
76
+ platform: HarnessPlatform,
77
+ projectRoot: string
78
+ ): void => {
79
+ if (
80
+ config.unstable__enableMetroCache &&
81
+ isMetroCacheReusable(projectRoot)
82
+ ) {
83
+ logMetroCacheReused(platform);
84
+ }
85
+ };
86
+
87
+ const createAbortError = () =>
88
+ new DOMException('The operation was aborted', 'AbortError');
89
+
90
+ const waitForAbort = (signal: AbortSignal): Promise<never> => {
91
+ if (signal.aborted) {
92
+ return Promise.reject(signal.reason ?? createAbortError());
93
+ }
94
+
95
+ return new Promise((_, reject) => {
96
+ signal.addEventListener(
97
+ 'abort',
98
+ () => {
99
+ reject(signal.reason ?? createAbortError());
100
+ },
101
+ { once: true }
102
+ );
103
+ });
37
104
  };
38
105
 
39
106
  export const waitForAppReady = async (options: {
40
- metroEvents: Reporter;
107
+ metroInstance: MetroInstance;
41
108
  serverBridge: BridgeServer;
42
109
  platformInstance: HarnessPlatformRunner;
110
+ platformId: string;
43
111
  bundleStartTimeout: number;
44
- maxRestarts: number;
45
- signal: AbortSignal;
112
+ readyTimeout: number;
113
+ maxAppRestarts: number;
114
+ testFilePath: string;
115
+ crashSupervisor: CrashSupervisor;
116
+ signal?: AbortSignal;
117
+ appLaunchOptions?: AppLaunchOptions;
118
+ launchApp?: () => Promise<void>;
46
119
  }): Promise<void> => {
47
120
  const {
48
- metroEvents,
121
+ metroInstance,
49
122
  serverBridge,
50
123
  platformInstance,
124
+ platformId,
51
125
  bundleStartTimeout,
52
- maxRestarts,
53
- signal,
126
+ readyTimeout,
127
+ maxAppRestarts,
128
+ testFilePath,
129
+ crashSupervisor,
130
+ appLaunchOptions,
131
+ launchApp = () => platformInstance.restartApp(appLaunchOptions),
54
132
  } = options;
133
+ const signal = options.signal ?? new AbortController().signal;
55
134
 
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
- }
135
+ const logWait = (message: string, ...args: Array<unknown>) => {
136
+ harnessLogger.debug(`waitForAppReady: ${message}`, ...args);
65
137
  };
138
+ return await waitForMetroBackedAppReady({
139
+ metro: metroInstance,
140
+ platformId,
141
+ bundleStartTimeout,
142
+ readyTimeout,
143
+ maxAppRestarts,
144
+ signal,
145
+ startAttempt: async () => {
146
+ logWait('launching app for %s', testFilePath);
147
+ await launchApp();
148
+ logWait('launch request completed, waiting for bridge ready');
149
+ },
150
+ waitForReady: async (signal) => {
151
+ logWait('waiting for runtime ready');
152
+ return await Promise.race([
153
+ new Promise<void>((resolve) => {
154
+ const onReady = () => {
155
+ cleanup();
156
+ crashSupervisor.markReady();
157
+ logWait('runtime ready received');
158
+ resolve();
159
+ };
160
+ const onAbort = () => {
161
+ cleanup();
162
+ };
163
+ const cleanup = () => {
164
+ serverBridge.off('ready', onReady);
165
+ signal.removeEventListener('abort', onAbort);
166
+ };
66
167
 
67
- 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
168
+ serverBridge.on('ready', onReady);
169
+ signal.addEventListener('abort', onAbort, { once: true });
170
+ }),
171
+ waitForAbort(signal),
172
+ ]);
173
+ },
174
+ waitForCrash: async (signal) => {
175
+ try {
176
+ logWait('waiting for crash or runtime ready');
177
+ return await Promise.race([
178
+ crashSupervisor.waitForCrash(testFilePath),
179
+ waitForAbort(signal),
180
+ ]);
181
+ } finally {
182
+ crashSupervisor.cancelCrashWaiters();
103
183
  }
104
- };
105
-
106
- // Bridge ready listener
107
- const onReady = () => {
108
- cleanup();
109
- resolve();
110
- };
111
-
112
- const cleanup = () => {
113
- clearBundleTimeout();
114
- metroEvents.removeListener(onMetroEvent);
115
- serverBridge.off('ready', onReady);
116
- };
117
-
118
- // Setup listeners
119
- metroEvents.addListener(onMetroEvent);
120
- serverBridge.once('ready', onReady);
121
-
122
- // Start the app and timeout
123
- platformInstance.restartApp().catch(reject);
124
- startBundleTimeout();
184
+ },
185
+ onAttemptStart: () => {
186
+ logWait('beginning launch attempt for %s', testFilePath);
187
+ crashSupervisor.beginLaunch(testFilePath);
188
+ },
189
+ onAttemptReset: () => {
190
+ logWait('resetting launch attempt state');
191
+ crashSupervisor.cancelCrashWaiters();
192
+ },
125
193
  });
126
194
  };
127
195
 
@@ -134,100 +202,474 @@ const getHarnessInternal = async (
134
202
  const context: HarnessContext = {
135
203
  platform,
136
204
  };
205
+ harnessLogger.debug(
206
+ 'creating Harness internals for runner=%s platform=%s',
207
+ platform.name,
208
+ platform.platformId
209
+ );
210
+ maybeLogMetroCacheReuse(config, platform, projectRoot);
211
+ const pluginAbortController = new AbortController();
212
+ const pluginManager = createHarnessPluginManager<HarnessConfig, HarnessPlatform>({
213
+ plugins: (config.plugins ?? []) as Array<
214
+ HarnessPlugin<object, HarnessConfig, HarnessPlatform>
215
+ >,
216
+ projectRoot,
217
+ config,
218
+ runner: platform,
219
+ abortSignal: pluginAbortController.signal,
220
+ });
221
+ let currentRun: HarnessRunState | null = null;
222
+ let activeTestFilePath: string | undefined;
223
+ const pendingHookPromises = new Set<Promise<void>>();
224
+ let pendingHookError: unknown;
225
+
226
+ const getCurrentRunId = () => currentRun?.runId;
227
+ const toRelativeTestFilePath = (testFilePath?: string) =>
228
+ testFilePath == null ? undefined : path.relative(projectRoot, testFilePath);
229
+ const setActiveTestFilePath = (testFilePath?: string) => {
230
+ activeTestFilePath = toRelativeTestFilePath(testFilePath);
231
+ };
232
+ const flushPendingHooks = async () => {
233
+ if (pendingHookPromises.size > 0) {
234
+ await Promise.allSettled([...pendingHookPromises]);
235
+ }
236
+
237
+ if (pendingHookError !== undefined) {
238
+ const error = pendingHookError;
239
+ pendingHookError = undefined;
240
+ throw error;
241
+ }
242
+ };
243
+ const trackHook = (promise: Promise<void>) => {
244
+ const trackedPromise = promise
245
+ .catch((error) => {
246
+ pendingHookError ??= error;
247
+ })
248
+ .finally(() => {
249
+ pendingHookPromises.delete(trackedPromise);
250
+ });
251
+
252
+ pendingHookPromises.add(trackedPromise);
253
+ };
254
+ const scheduleHook = <
255
+ TName extends keyof FlatHarnessHookContexts<object, HarnessConfig, HarnessPlatform>,
256
+ >(
257
+ name: TName,
258
+ payload: Omit<
259
+ FlatHarnessHookContexts<object, HarnessConfig, HarnessPlatform>[TName],
260
+ | 'plugin'
261
+ | 'logger'
262
+ | 'projectRoot'
263
+ | 'config'
264
+ | 'runner'
265
+ | 'platform'
266
+ | 'state'
267
+ | 'timestamp'
268
+ | 'abortSignal'
269
+ | 'meta'
270
+ >
271
+ ) => {
272
+ trackHook(pluginManager.callHook(name, payload));
273
+ };
137
274
 
275
+ harnessLogger.debug(
276
+ 'starting Metro, platform runner, and bridge initialization'
277
+ );
138
278
  const [metroInstance, platformInstance, serverBridge] = await Promise.all([
139
- getMetroInstance({ projectRoot, harnessConfig: config }, signal),
140
- import(platform.runner).then((module) =>
141
- module.default(platform.config, config)
279
+ getMetroInstance({ projectRoot, harnessConfig: config }, signal).then(
280
+ (instance) => {
281
+ harnessLogger.debug('Metro initialized');
282
+ return instance;
283
+ }
142
284
  ),
285
+ import(platform.runner)
286
+ .then((module) => module.default(platform.config, config))
287
+ .then((instance) => {
288
+ harnessLogger.debug('platform runner initialized');
289
+ return instance;
290
+ }),
143
291
  getBridgeServer({
144
292
  port: config.webSocketPort,
145
293
  timeout: config.bridgeTimeout,
146
294
  context,
295
+ }).then((bridge) => {
296
+ harnessLogger.debug(
297
+ 'bridge server initialized on port %d',
298
+ config.webSocketPort
299
+ );
300
+ return bridge;
147
301
  }),
148
302
  ]);
303
+ const crashArtifactWriter = createCrashArtifactWriter({
304
+ runnerName: platform.name,
305
+ platformId: platform.platformId,
306
+ });
307
+ const appMonitor = platformInstance.createAppMonitor({
308
+ crashArtifactWriter,
309
+ });
310
+ const appLaunchOptions = (
311
+ platform.config as { appLaunchOptions?: AppLaunchOptions }
312
+ ).appLaunchOptions;
149
313
 
150
- // Forward client logs to console if enabled
151
314
  const clientLogListener = createClientLogListener();
315
+ const bridgeEventListener = (event: BridgeEvents) => {
316
+ const runId = getCurrentRunId();
317
+ if (!runId) {
318
+ return;
319
+ }
320
+
321
+ switch (event.type) {
322
+ case 'collection-started':
323
+ scheduleHook('collection:started', {
324
+ runId,
325
+ file: event.file,
326
+ });
327
+ break;
328
+ case 'collection-finished':
329
+ scheduleHook('collection:finished', {
330
+ runId,
331
+ file: event.file,
332
+ duration: event.duration,
333
+ totalTests: event.totalTests,
334
+ });
335
+ break;
336
+ case 'suite-started':
337
+ scheduleHook('suite:started', {
338
+ runId,
339
+ file: event.file,
340
+ name: event.name,
341
+ });
342
+ break;
343
+ case 'suite-finished':
344
+ scheduleHook('suite:finished', {
345
+ runId,
346
+ file: event.file,
347
+ name: event.name,
348
+ duration: event.duration,
349
+ status: event.status,
350
+ error: event.error,
351
+ });
352
+ break;
353
+ case 'test-started':
354
+ scheduleHook('test:started', {
355
+ runId,
356
+ file: event.file,
357
+ suite: event.suite,
358
+ name: event.name,
359
+ });
360
+ break;
361
+ case 'test-finished':
362
+ scheduleHook('test:finished', {
363
+ runId,
364
+ file: event.file,
365
+ suite: event.suite,
366
+ name: event.name,
367
+ duration: event.duration,
368
+ status: event.status,
369
+ error: event.error,
370
+ });
371
+ break;
372
+ case 'module-bundling-started':
373
+ scheduleHook('metro:bundle-started', {
374
+ runId,
375
+ target: 'module',
376
+ file: event.file,
377
+ });
378
+ break;
379
+ case 'module-bundling-finished':
380
+ scheduleHook('metro:bundle-finished', {
381
+ runId,
382
+ target: 'module',
383
+ file: event.file,
384
+ duration: event.duration,
385
+ });
386
+ break;
387
+ case 'module-bundling-failed':
388
+ scheduleHook('metro:bundle-failed', {
389
+ runId,
390
+ target: 'module',
391
+ file: event.file,
392
+ duration: event.duration,
393
+ error: event.error,
394
+ });
395
+ break;
396
+ case 'setup-file-bundling-started':
397
+ scheduleHook('metro:bundle-started', {
398
+ runId,
399
+ target: 'setupFile',
400
+ file: event.file,
401
+ setupType: event.setupType,
402
+ });
403
+ break;
404
+ case 'setup-file-bundling-finished':
405
+ scheduleHook('metro:bundle-finished', {
406
+ runId,
407
+ target: 'setupFile',
408
+ file: event.file,
409
+ setupType: event.setupType,
410
+ duration: event.duration,
411
+ });
412
+ break;
413
+ case 'setup-file-bundling-failed':
414
+ scheduleHook('metro:bundle-failed', {
415
+ runId,
416
+ target: 'setupFile',
417
+ file: event.file,
418
+ setupType: event.setupType,
419
+ duration: event.duration,
420
+ error: event.error,
421
+ });
422
+ break;
423
+ }
424
+ };
425
+ const onMetroEvent = (event: ReportableEvent) => {
426
+ const runId = getCurrentRunId();
427
+
428
+ if (runId && event.type === 'client_log') {
429
+ scheduleHook('metro:client-log', {
430
+ runId,
431
+ level: event.level,
432
+ data: event.data,
433
+ });
434
+ }
435
+ };
436
+ const crashSupervisor = createCrashSupervisor({
437
+ appMonitor,
438
+ platformRunner: platformInstance,
439
+ });
440
+
441
+ const onReady = (device: DeviceDescriptor) => {
442
+ crashSupervisor.markReady();
443
+
444
+ const runId = getCurrentRunId();
445
+ if (!runId) {
446
+ return;
447
+ }
448
+
449
+ scheduleHook('runtime:ready', {
450
+ runId,
451
+ device,
452
+ });
453
+ };
454
+ const onDisconnect = () => {
455
+ const runId = getCurrentRunId();
456
+ if (!runId) {
457
+ return;
458
+ }
459
+
460
+ scheduleHook('runtime:disconnected', {
461
+ runId,
462
+ reason: 'bridge-disconnected',
463
+ });
464
+ };
465
+ const onAppMonitorEvent = (event: AppMonitorEvent) => {
466
+ const runId = getCurrentRunId();
467
+ if (!runId) {
468
+ return;
469
+ }
470
+
471
+ if (event.type === 'app_started') {
472
+ scheduleHook('app:started', {
473
+ runId,
474
+ testFile: activeTestFilePath,
475
+ pid: event.pid,
476
+ source: event.source,
477
+ line: event.line,
478
+ });
479
+ return;
480
+ }
481
+
482
+ if (event.type === 'app_exited') {
483
+ scheduleHook('app:exited', {
484
+ runId,
485
+ testFile: activeTestFilePath,
486
+ pid: event.pid,
487
+ source: event.source,
488
+ line: event.line,
489
+ isConfirmed: event.isConfirmed,
490
+ crashDetails: event.crashDetails,
491
+ });
492
+ return;
493
+ }
494
+
495
+ if (event.type === 'possible_crash') {
496
+ scheduleHook('app:possible-crash', {
497
+ runId,
498
+ testFile: activeTestFilePath,
499
+ pid: event.pid,
500
+ source: event.source,
501
+ line: event.line,
502
+ isConfirmed: event.isConfirmed,
503
+ crashDetails: event.crashDetails,
504
+ });
505
+ }
506
+ };
507
+
508
+ serverBridge.on('ready', onReady);
509
+ serverBridge.on('disconnect', onDisconnect);
510
+ serverBridge.on('event', bridgeEventListener);
511
+ metroInstance.events.addListener(onMetroEvent);
512
+ appMonitor.addListener(onAppMonitorEvent);
513
+ harnessLogger.debug('registered runtime, bridge, and Metro listeners');
152
514
 
153
515
  if (config.forwardClientLogs) {
154
516
  metroInstance.events.addListener(clientLogListener);
517
+ harnessLogger.debug('client log forwarding enabled');
155
518
  }
156
519
 
157
- const dispose = async () => {
520
+ const dispose = async (reason: 'normal' | 'abort' | 'error' = 'normal') => {
521
+ harnessLogger.debug('disposing Harness (reason=%s)', reason);
522
+ let hookError: unknown;
523
+
524
+ try {
525
+ await flushPendingHooks();
526
+ await pluginManager.callHook('harness:before-dispose', {
527
+ runId: currentRun?.runId,
528
+ reason,
529
+ summary: currentRun?.summary,
530
+ status: currentRun?.status,
531
+ error: currentRun?.error,
532
+ });
533
+ await flushPendingHooks();
534
+ } catch (error) {
535
+ hookError = error;
536
+ }
537
+
158
538
  if (config.forwardClientLogs) {
159
539
  metroInstance.events.removeListener(clientLogListener);
160
540
  }
541
+ metroInstance.events.removeListener(onMetroEvent);
542
+ appMonitor.removeListener(onAppMonitorEvent);
543
+ serverBridge.off('ready', onReady);
544
+ serverBridge.off('disconnect', onDisconnect);
545
+ serverBridge.off('event', bridgeEventListener);
161
546
  await Promise.all([
547
+ crashSupervisor.dispose(),
162
548
  serverBridge.dispose(),
163
549
  platformInstance.dispose(),
164
550
  metroInstance.dispose(),
165
551
  ]);
166
- };
552
+ pluginAbortController.abort();
553
+ harnessLogger.debug('Harness resources disposed');
167
554
 
168
- const crashMonitor = createCrashMonitor({
169
- interval: config.crashDetectionInterval,
170
- platformRunner: platformInstance,
171
- bridgeServer: serverBridge,
172
- });
555
+ if (hookError) {
556
+ throw hookError;
557
+ }
558
+ };
173
559
 
174
560
  if (signal.aborted) {
175
- await dispose();
561
+ await dispose('abort');
176
562
 
177
563
  throw new DOMException('The operation was aborted', 'AbortError');
178
564
  }
179
565
 
180
566
  try {
181
- await prewarmMetroBundle({
182
- projectRoot,
183
- entryPoint: config.entryPoint,
184
- platform: platform.platformId,
185
- dev: true,
186
- minify: false,
187
- signal,
567
+ await pluginManager.callHook('harness:before-creation', {
568
+ appLaunchOptions,
188
569
  });
189
- logMetroPrewarmCompleted(platform);
570
+ await appMonitor.start();
571
+ harnessLogger.debug('app monitor started');
572
+ } catch (error) {
573
+ const runState = currentRun as HarnessRunState | null;
574
+
575
+ if (runState) {
576
+ runState.error = error;
577
+ currentRun = runState;
578
+ }
579
+ await dispose(error instanceof DOMException && error.name === 'AbortError' ? 'abort' : 'error');
580
+ throw error;
581
+ }
582
+
583
+ const ensureAppReady = async (testFilePath: string) => {
584
+ await flushPendingHooks();
585
+ setActiveTestFilePath(testFilePath);
586
+ crashSupervisor.setActiveTestFile(testFilePath);
587
+ harnessLogger.debug('ensuring app is ready for %s', testFilePath);
588
+
589
+ if (crashSupervisor.isReady() && (await platformInstance.isAppRunning())) {
590
+ harnessLogger.debug('reusing existing ready app for %s', testFilePath);
591
+ return;
592
+ }
190
593
 
594
+ crashSupervisor.reset();
595
+ harnessLogger.debug('app not ready, waiting for launch and runtime readiness');
191
596
  await waitForAppReady({
192
- metroEvents: metroInstance.events,
597
+ metroInstance,
193
598
  serverBridge,
194
599
  platformInstance: platformInstance as HarnessPlatformRunner,
195
- bundleStartTimeout: config.bundleStartTimeout,
196
- maxRestarts: config.maxAppRestarts,
197
- signal,
600
+ platformId: platform.platformId,
601
+ bundleStartTimeout: config.bundleStartTimeout ?? 60000,
602
+ readyTimeout: config.bridgeTimeout,
603
+ maxAppRestarts: config.maxAppRestarts ?? 2,
604
+ testFilePath,
605
+ crashSupervisor,
606
+ appLaunchOptions,
198
607
  });
199
- } catch (error) {
200
- await dispose();
201
- throw error;
202
- }
608
+ await flushPendingHooks();
609
+ harnessLogger.debug('app is ready for %s', testFilePath);
610
+ };
203
611
 
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);
212
- });
612
+ const restart = async (testFilePath?: string) => {
613
+ await flushPendingHooks();
614
+ await crashSupervisor.stop();
615
+ setActiveTestFilePath(testFilePath);
616
+ harnessLogger.debug(
617
+ 'restarting app (testFile=%s mode=%s)',
618
+ testFilePath ?? 'n/a',
619
+ testFilePath ? 'stop-and-ensure-ready' : 'direct-restart'
620
+ );
621
+
622
+ if (testFilePath) {
623
+ harnessLogger.debug('stopping app before restart');
624
+ await platformInstance.stopApp();
625
+ } else {
626
+ harnessLogger.debug('requesting direct app restart');
627
+ await platformInstance.restartApp(appLaunchOptions);
628
+ }
629
+
630
+ crashSupervisor.reset();
631
+ await crashSupervisor.start();
632
+
633
+ if (testFilePath) {
634
+ await ensureAppReady(testFilePath);
635
+ }
636
+
637
+ await flushPendingHooks();
638
+ harnessLogger.debug('restart completed');
639
+ };
213
640
 
214
641
  return {
215
642
  context,
216
643
  runTests: async (path, options) => {
644
+ await flushPendingHooks();
645
+ activeTestFilePath = path;
217
646
  const client = serverBridge.rpc.clients.at(-1);
218
647
 
219
648
  if (!client) {
220
649
  throw new Error('No client found');
221
650
  }
222
651
 
223
- return await client.runTests(path, {
652
+ harnessLogger.debug('running test file on client: %s', path);
653
+ const result = await client.runTests(path, {
224
654
  ...options,
225
655
  runner: platform.runner,
226
656
  });
657
+ await flushPendingHooks();
658
+ return result;
227
659
  },
660
+ ensureAppReady,
228
661
  restart,
229
- dispose,
230
- crashMonitor,
662
+ dispose: () => dispose('normal'),
663
+ crashSupervisor,
664
+ callHook: async (name, payload) => {
665
+ await flushPendingHooks();
666
+ await pluginManager.callHook(name, payload);
667
+ await flushPendingHooks();
668
+ },
669
+ setRunState: (runState) => {
670
+ currentRun = runState;
671
+ },
672
+ getRunState: () => currentRun,
231
673
  };
232
674
  };
233
675
 
@@ -237,6 +679,10 @@ export const getHarness = async (
237
679
  projectRoot: string
238
680
  ): Promise<Harness> => {
239
681
  const abortSignal = AbortSignal.timeout(config.bridgeTimeout);
682
+ harnessLogger.debug(
683
+ 'creating Harness with bridge timeout %dms',
684
+ config.bridgeTimeout
685
+ );
240
686
 
241
687
  try {
242
688
  const harness = await getHarnessInternal(