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

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