@react-native-harness/jest 1.1.0-rc.1 → 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 (68) hide show
  1. package/dist/__tests__/harness-cache.test.d.ts +2 -0
  2. package/dist/__tests__/harness-cache.test.d.ts.map +1 -0
  3. package/dist/__tests__/harness-cache.test.js +51 -0
  4. package/dist/__tests__/harness.test.js +340 -47
  5. package/dist/__tests__/index.test.d.ts +2 -0
  6. package/dist/__tests__/index.test.d.ts.map +1 -0
  7. package/dist/__tests__/index.test.js +42 -0
  8. package/dist/cli-args.d.ts +1 -0
  9. package/dist/cli-args.d.ts.map +1 -1
  10. package/dist/cli-args.js +5 -0
  11. package/dist/crash-supervisor.d.ts.map +1 -1
  12. package/dist/crash-supervisor.js +7 -6
  13. package/dist/errors.d.ts +1 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +1 -0
  16. package/dist/harness.d.ts +23 -1
  17. package/dist/harness.d.ts.map +1 -1
  18. package/dist/harness.js +396 -50
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +205 -58
  21. package/dist/logs.d.ts +1 -0
  22. package/dist/logs.d.ts.map +1 -1
  23. package/dist/logs.js +3 -0
  24. package/dist/run.d.ts +7 -1
  25. package/dist/run.d.ts.map +1 -1
  26. package/dist/run.js +13 -8
  27. package/dist/setup.d.ts.map +1 -1
  28. package/dist/setup.js +18 -2
  29. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  30. package/package.json +7 -6
  31. package/src/__tests__/harness-cache.test.ts +74 -0
  32. package/src/__tests__/harness.test.ts +469 -51
  33. package/src/__tests__/index.test.ts +61 -0
  34. package/src/cli-args.ts +6 -0
  35. package/src/crash-supervisor.ts +13 -6
  36. package/src/env.d.ts +1 -1
  37. package/src/errors.ts +5 -0
  38. package/src/harness.ts +504 -58
  39. package/src/index.ts +276 -64
  40. package/src/logs.ts +4 -0
  41. package/src/run.ts +20 -10
  42. package/src/setup.ts +22 -1
  43. package/tsconfig.json +3 -3
  44. package/tsconfig.lib.json +6 -6
  45. package/dist/cli-args.test.d.ts +0 -2
  46. package/dist/cli-args.test.d.ts.map +0 -1
  47. package/dist/cli-args.test.js +0 -1
  48. package/dist/crash-monitor.d.ts +0 -16
  49. package/dist/crash-monitor.d.ts.map +0 -1
  50. package/dist/crash-monitor.js +0 -75
  51. package/dist/event-mapper.d.ts +0 -16
  52. package/dist/event-mapper.d.ts.map +0 -1
  53. package/dist/event-mapper.js +0 -120
  54. package/dist/global-setup.d.ts +0 -3
  55. package/dist/global-setup.d.ts.map +0 -1
  56. package/dist/global-setup.js +0 -60
  57. package/dist/global-teardown.d.ts +0 -3
  58. package/dist/global-teardown.d.ts.map +0 -1
  59. package/dist/global-teardown.js +0 -11
  60. package/dist/harness-reporter.d.ts +0 -8
  61. package/dist/harness-reporter.d.ts.map +0 -1
  62. package/dist/harness-reporter.js +0 -12
  63. package/dist/jest-options.d.ts +0 -2
  64. package/dist/jest-options.d.ts.map +0 -1
  65. package/dist/jest-options.js +0 -1
  66. package/dist/types.d.ts +0 -17
  67. package/dist/types.d.ts.map +0 -1
  68. package/dist/types.js +0 -1
package/src/harness.ts CHANGED
@@ -4,19 +4,33 @@ 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,
11
14
  type AppLaunchOptions,
12
15
  HarnessPlatform,
13
16
  HarnessPlatformRunner,
14
17
  } from '@react-native-harness/platforms';
15
18
  import {
16
19
  getMetroInstance,
17
- prewarmMetroBundle,
20
+ isMetroCacheReusable,
21
+ waitForMetroBackedAppReady,
22
+ type MetroInstance,
23
+ type ReportableEvent,
18
24
  } from '@react-native-harness/bundler-metro';
19
- import { createCrashArtifactWriter } from '@react-native-harness/tools';
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';
20
34
  import { InitializationTimeoutError } from './errors.js';
21
35
  import { Config as HarnessConfig } from '@react-native-harness/config';
22
36
  import {
@@ -24,10 +38,24 @@ import {
24
38
  type CrashSupervisor,
25
39
  } from './crash-supervisor.js';
26
40
  import { createClientLogListener } from './client-log-handler.js';
27
- import { logMetroPrewarmCompleted } from './logs.js';
41
+ import path from 'node:path';
42
+ import { logMetroCacheReused } from './logs.js';
43
+
44
+ const harnessLogger = logger.child('runtime');
28
45
 
29
46
  export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
30
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
+
31
59
  export type Harness = {
32
60
  context: HarnessContext;
33
61
  runTests: (
@@ -38,61 +66,130 @@ export type Harness = {
38
66
  restart: (testFilePath?: string) => Promise<void>;
39
67
  dispose: () => Promise<void>;
40
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
+ });
41
104
  };
42
105
 
43
106
  export const waitForAppReady = async (options: {
107
+ metroInstance: MetroInstance;
44
108
  serverBridge: BridgeServer;
45
109
  platformInstance: HarnessPlatformRunner;
46
- bridgeTimeout: number;
110
+ platformId: string;
111
+ bundleStartTimeout: number;
112
+ readyTimeout: number;
113
+ maxAppRestarts: number;
47
114
  testFilePath: string;
48
115
  crashSupervisor: CrashSupervisor;
116
+ signal?: AbortSignal;
49
117
  appLaunchOptions?: AppLaunchOptions;
118
+ launchApp?: () => Promise<void>;
50
119
  }): Promise<void> => {
51
120
  const {
121
+ metroInstance,
52
122
  serverBridge,
53
123
  platformInstance,
54
- bridgeTimeout,
124
+ platformId,
125
+ bundleStartTimeout,
126
+ readyTimeout,
127
+ maxAppRestarts,
55
128
  testFilePath,
56
129
  crashSupervisor,
57
130
  appLaunchOptions,
131
+ launchApp = () => platformInstance.restartApp(appLaunchOptions),
58
132
  } = options;
133
+ const signal = options.signal ?? new AbortController().signal;
59
134
 
60
- const signal = AbortSignal.timeout(bridgeTimeout);
61
-
62
- return new Promise<void>((resolve, reject) => {
63
- const launchApp = async () => {
135
+ const logWait = (message: string, ...args: Array<unknown>) => {
136
+ harnessLogger.debug(`waitForAppReady: ${message}`, ...args);
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
+ };
167
+
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();
183
+ }
184
+ },
185
+ onAttemptStart: () => {
186
+ logWait('beginning launch attempt for %s', testFilePath);
64
187
  crashSupervisor.beginLaunch(testFilePath);
65
- await platformInstance.restartApp(appLaunchOptions);
66
- };
67
-
68
- const onReady = () => {
69
- crashSupervisor.markReady();
70
- cleanup();
71
- resolve();
72
- };
73
-
74
- const onAbort = () => {
75
- cleanup();
76
- reject(new DOMException('The operation was aborted', 'AbortError'));
77
- };
78
-
79
- const cleanup = () => {
80
- serverBridge.off('ready', onReady);
81
- signal.removeEventListener('abort', onAbort);
188
+ },
189
+ onAttemptReset: () => {
190
+ logWait('resetting launch attempt state');
82
191
  crashSupervisor.cancelCrashWaiters();
83
- };
84
-
85
- signal.addEventListener('abort', onAbort);
86
- serverBridge.once('ready', onReady);
87
- void crashSupervisor.waitForCrash(testFilePath).catch((error) => {
88
- cleanup();
89
- reject(error);
90
- });
91
-
92
- void launchApp().catch((error) => {
93
- cleanup();
94
- reject(error);
95
- });
192
+ },
96
193
  });
97
194
  };
98
195
 
@@ -105,16 +202,102 @@ const getHarnessInternal = async (
105
202
  const context: HarnessContext = {
106
203
  platform,
107
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
+ });
108
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
+ };
274
+
275
+ harnessLogger.debug(
276
+ 'starting Metro, platform runner, and bridge initialization'
277
+ );
109
278
  const [metroInstance, platformInstance, serverBridge] = await Promise.all([
110
- getMetroInstance({ projectRoot, harnessConfig: config }, signal),
111
- import(platform.runner).then((module) =>
112
- module.default(platform.config, config)
279
+ getMetroInstance({ projectRoot, harnessConfig: config }, signal).then(
280
+ (instance) => {
281
+ harnessLogger.debug('Metro initialized');
282
+ return instance;
283
+ }
113
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
+ }),
114
291
  getBridgeServer({
115
292
  port: config.webSocketPort,
116
293
  timeout: config.bridgeTimeout,
117
294
  context,
295
+ }).then((bridge) => {
296
+ harnessLogger.debug(
297
+ 'bridge server initialized on port %d',
298
+ config.webSocketPort
299
+ );
300
+ return bridge;
118
301
  }),
119
302
  ]);
120
303
  const crashArtifactWriter = createCrashArtifactWriter({
@@ -129,76 +312,318 @@ const getHarnessInternal = async (
129
312
  ).appLaunchOptions;
130
313
 
131
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
+ };
132
436
  const crashSupervisor = createCrashSupervisor({
133
437
  appMonitor,
134
438
  platformRunner: platformInstance,
135
439
  });
136
440
 
137
- serverBridge.on('ready', crashSupervisor.markReady);
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');
138
514
 
139
515
  if (config.forwardClientLogs) {
140
516
  metroInstance.events.addListener(clientLogListener);
517
+ harnessLogger.debug('client log forwarding enabled');
141
518
  }
142
519
 
143
- 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
+
144
538
  if (config.forwardClientLogs) {
145
539
  metroInstance.events.removeListener(clientLogListener);
146
540
  }
147
- serverBridge.off('ready', crashSupervisor.markReady);
541
+ metroInstance.events.removeListener(onMetroEvent);
542
+ appMonitor.removeListener(onAppMonitorEvent);
543
+ serverBridge.off('ready', onReady);
544
+ serverBridge.off('disconnect', onDisconnect);
545
+ serverBridge.off('event', bridgeEventListener);
148
546
  await Promise.all([
149
547
  crashSupervisor.dispose(),
150
548
  serverBridge.dispose(),
151
549
  platformInstance.dispose(),
152
550
  metroInstance.dispose(),
153
551
  ]);
552
+ pluginAbortController.abort();
553
+ harnessLogger.debug('Harness resources disposed');
554
+
555
+ if (hookError) {
556
+ throw hookError;
557
+ }
154
558
  };
155
559
 
156
560
  if (signal.aborted) {
157
- await dispose();
561
+ await dispose('abort');
158
562
 
159
563
  throw new DOMException('The operation was aborted', 'AbortError');
160
564
  }
161
565
 
162
566
  try {
163
- await prewarmMetroBundle({
164
- projectRoot,
165
- entryPoint: config.entryPoint,
166
- platform: platform.platformId,
167
- dev: true,
168
- minify: false,
169
- signal,
567
+ await pluginManager.callHook('harness:before-creation', {
568
+ appLaunchOptions,
170
569
  });
171
- logMetroPrewarmCompleted(platform);
172
570
  await appMonitor.start();
571
+ harnessLogger.debug('app monitor started');
173
572
  } catch (error) {
174
- await dispose();
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');
175
580
  throw error;
176
581
  }
177
582
 
178
583
  const ensureAppReady = async (testFilePath: string) => {
584
+ await flushPendingHooks();
585
+ setActiveTestFilePath(testFilePath);
179
586
  crashSupervisor.setActiveTestFile(testFilePath);
587
+ harnessLogger.debug('ensuring app is ready for %s', testFilePath);
180
588
 
181
589
  if (crashSupervisor.isReady() && (await platformInstance.isAppRunning())) {
590
+ harnessLogger.debug('reusing existing ready app for %s', testFilePath);
182
591
  return;
183
592
  }
184
593
 
185
594
  crashSupervisor.reset();
595
+ harnessLogger.debug('app not ready, waiting for launch and runtime readiness');
186
596
  await waitForAppReady({
597
+ metroInstance,
187
598
  serverBridge,
188
599
  platformInstance: platformInstance as HarnessPlatformRunner,
189
- bridgeTimeout: config.bridgeTimeout,
600
+ platformId: platform.platformId,
601
+ bundleStartTimeout: config.bundleStartTimeout ?? 60000,
602
+ readyTimeout: config.bridgeTimeout,
603
+ maxAppRestarts: config.maxAppRestarts ?? 2,
190
604
  testFilePath,
191
605
  crashSupervisor,
192
606
  appLaunchOptions,
193
607
  });
608
+ await flushPendingHooks();
609
+ harnessLogger.debug('app is ready for %s', testFilePath);
194
610
  };
195
611
 
196
612
  const restart = async (testFilePath?: string) => {
613
+ await flushPendingHooks();
197
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
+ );
198
621
 
199
622
  if (testFilePath) {
623
+ harnessLogger.debug('stopping app before restart');
200
624
  await platformInstance.stopApp();
201
625
  } else {
626
+ harnessLogger.debug('requesting direct app restart');
202
627
  await platformInstance.restartApp(appLaunchOptions);
203
628
  }
204
629
 
@@ -208,26 +633,43 @@ const getHarnessInternal = async (
208
633
  if (testFilePath) {
209
634
  await ensureAppReady(testFilePath);
210
635
  }
636
+
637
+ await flushPendingHooks();
638
+ harnessLogger.debug('restart completed');
211
639
  };
212
640
 
213
641
  return {
214
642
  context,
215
643
  runTests: async (path, options) => {
644
+ await flushPendingHooks();
645
+ activeTestFilePath = path;
216
646
  const client = serverBridge.rpc.clients.at(-1);
217
647
 
218
648
  if (!client) {
219
649
  throw new Error('No client found');
220
650
  }
221
651
 
222
- return await client.runTests(path, {
652
+ harnessLogger.debug('running test file on client: %s', path);
653
+ const result = await client.runTests(path, {
223
654
  ...options,
224
655
  runner: platform.runner,
225
656
  });
657
+ await flushPendingHooks();
658
+ return result;
226
659
  },
227
660
  ensureAppReady,
228
661
  restart,
229
- dispose,
662
+ dispose: () => dispose('normal'),
230
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(