@react-native-harness/jest 1.1.0-rc.2 → 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.js +1 -1
  3. package/dist/__tests__/harness.test.js +147 -3
  4. package/dist/__tests__/resource-lock.test.d.ts +2 -0
  5. package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
  6. package/dist/__tests__/resource-lock.test.js +103 -0
  7. package/dist/action-hooks.d.ts +9 -0
  8. package/dist/action-hooks.d.ts.map +1 -0
  9. package/dist/action-hooks.js +36 -0
  10. package/dist/cli-args.test.d.ts +2 -0
  11. package/dist/cli-args.test.d.ts.map +1 -0
  12. package/dist/cli-args.test.js +1 -0
  13. package/dist/crash-monitor.d.ts +16 -0
  14. package/dist/crash-monitor.d.ts.map +1 -0
  15. package/dist/crash-monitor.js +75 -0
  16. package/dist/errors.d.ts +4 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +9 -2
  19. package/dist/event-mapper.d.ts +16 -0
  20. package/dist/event-mapper.d.ts.map +1 -0
  21. package/dist/event-mapper.js +120 -0
  22. package/dist/global-setup.d.ts +3 -0
  23. package/dist/global-setup.d.ts.map +1 -0
  24. package/dist/global-setup.js +60 -0
  25. package/dist/global-teardown.d.ts +3 -0
  26. package/dist/global-teardown.d.ts.map +1 -0
  27. package/dist/global-teardown.js +11 -0
  28. package/dist/harness-reporter.d.ts +8 -0
  29. package/dist/harness-reporter.d.ts.map +1 -0
  30. package/dist/harness-reporter.js +12 -0
  31. package/dist/harness.d.ts.map +1 -1
  32. package/dist/harness.js +478 -386
  33. package/dist/jest-options.d.ts +2 -0
  34. package/dist/jest-options.d.ts.map +1 -0
  35. package/dist/jest-options.js +1 -0
  36. package/dist/logs.d.ts +3 -0
  37. package/dist/logs.d.ts.map +1 -1
  38. package/dist/logs.js +9 -0
  39. package/dist/resource-lock.d.ts +25 -0
  40. package/dist/resource-lock.d.ts.map +1 -0
  41. package/dist/resource-lock.js +278 -0
  42. package/dist/setup.d.ts.map +1 -1
  43. package/dist/setup.js +11 -0
  44. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  45. package/dist/types.d.ts +17 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +1 -0
  48. package/package.json +7 -7
  49. package/src/__tests__/errors.test.ts +18 -6
  50. package/src/__tests__/harness-cache.test.ts +2 -2
  51. package/src/__tests__/harness.test.ts +254 -19
  52. package/src/__tests__/resource-lock.test.ts +138 -0
  53. package/src/action-hooks.ts +66 -0
  54. package/src/errors.ts +10 -4
  55. package/src/harness.ts +585 -445
  56. package/src/logs.ts +13 -3
  57. package/src/resource-lock.ts +450 -0
  58. package/src/setup.ts +21 -1
package/src/harness.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  BridgeServer,
4
4
  } from '@react-native-harness/bridge/server';
5
5
  import {
6
+ HARNESS_BRIDGE_PATH,
6
7
  HarnessContext,
7
8
  type BridgeEvents,
8
9
  type DeviceDescriptor,
@@ -13,6 +14,7 @@ import {
13
14
  type AppMonitorEvent,
14
15
  type AppLaunchOptions,
15
16
  HarnessPlatform,
17
+ type HarnessPlatformInitOptions,
16
18
  HarnessPlatformRunner,
17
19
  } from '@react-native-harness/platforms';
18
20
  import {
@@ -20,6 +22,7 @@ import {
20
22
  isMetroCacheReusable,
21
23
  waitForMetroBackedAppReady,
22
24
  type MetroInstance,
25
+ type MetroWebSocketEndpoint,
23
26
  type ReportableEvent,
24
27
  } from '@react-native-harness/bundler-metro';
25
28
  import {
@@ -30,8 +33,13 @@ import {
30
33
  type HarnessRunStatus,
31
34
  type HarnessRunSummary,
32
35
  } from '@react-native-harness/plugins';
33
- import { logger, createCrashArtifactWriter } from '@react-native-harness/tools';
34
- import { InitializationTimeoutError } from './errors.js';
36
+ import {
37
+ logger,
38
+ createCrashArtifactWriter,
39
+ getTimeoutSignal,
40
+ raceAbortSignals,
41
+ } from '@react-native-harness/tools';
42
+ import { PlatformReadyTimeoutError } from './errors.js';
35
43
  import { Config as HarnessConfig } from '@react-native-harness/config';
36
44
  import {
37
45
  createCrashSupervisor,
@@ -39,9 +47,16 @@ import {
39
47
  } from './crash-supervisor.js';
40
48
  import { createClientLogListener } from './client-log-handler.js';
41
49
  import path from 'node:path';
42
- import { logMetroCacheReused } from './logs.js';
50
+ import {
51
+ logMetroCacheReused,
52
+ logRunnerStarting,
53
+ logRunnerStillWaitingInQueue,
54
+ logRunnerWaitingInQueue,
55
+ } from './logs.js';
56
+ import { createResourceLockManager } from './resource-lock.js';
43
57
 
44
58
  const harnessLogger = logger.child('runtime');
59
+ const resourceLockManager = createResourceLockManager();
45
60
 
46
61
  export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
47
62
 
@@ -76,10 +91,7 @@ export const maybeLogMetroCacheReuse = (
76
91
  platform: HarnessPlatform,
77
92
  projectRoot: string
78
93
  ): void => {
79
- if (
80
- config.unstable__enableMetroCache &&
81
- isMetroCacheReusable(projectRoot)
82
- ) {
94
+ if (config.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) {
83
95
  logMetroCacheReused(platform);
84
96
  }
85
97
  };
@@ -87,6 +99,9 @@ export const maybeLogMetroCacheReuse = (
87
99
  const createAbortError = () =>
88
100
  new DOMException('The operation was aborted', 'AbortError');
89
101
 
102
+ const getDefaultResourceLockKey = (platform: HarnessPlatform): string =>
103
+ `${platform.platformId}:${platform.name}`;
104
+
90
105
  const waitForAbort = (signal: AbortSignal): Promise<never> => {
91
106
  if (signal.aborted) {
92
107
  return Promise.reject(signal.reason ?? createAbortError());
@@ -103,6 +118,30 @@ const waitForAbort = (signal: AbortSignal): Promise<never> => {
103
118
  });
104
119
  };
105
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
+ }
143
+ };
144
+
106
145
  export const waitForAppReady = async (options: {
107
146
  metroInstance: MetroInstance;
108
147
  serverBridge: BridgeServer;
@@ -207,470 +246,581 @@ const getHarnessInternal = async (
207
246
  platform.name,
208
247
  platform.platformId
209
248
  );
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
- };
274
-
275
- harnessLogger.debug(
276
- 'starting Metro, platform runner, and bridge initialization'
277
- );
278
- const [metroInstance, platformInstance, serverBridge] = await Promise.all([
279
- getMetroInstance({ projectRoot, harnessConfig: config }, signal).then(
280
- (instance) => {
281
- harnessLogger.debug('Metro initialized');
282
- return instance;
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;
283
267
  }
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
- }),
291
- getBridgeServer({
292
- port: config.webSocketPort,
293
- timeout: config.bridgeTimeout,
294
- context,
295
- }).then((bridge) => {
268
+
269
+ lastStillWaitingLogAt = elapsedMs;
270
+ logRunnerStillWaitingInQueue(platform);
296
271
  harnessLogger.debug(
297
- 'bridge server initialized on port %d',
298
- config.webSocketPort
272
+ 'still waiting in queue for runner=%s key=%s elapsedMs=%d',
273
+ platform.name,
274
+ resourceLockKey,
275
+ elapsedMs
299
276
  );
300
- return bridge;
301
- }),
302
- ]);
303
- const crashArtifactWriter = createCrashArtifactWriter({
304
- runnerName: platform.name,
305
- platformId: platform.platformId,
306
- });
307
- const appMonitor = platformInstance.createAppMonitor({
308
- crashArtifactWriter,
277
+ },
309
278
  });
310
- const appLaunchOptions = (
311
- platform.config as { appLaunchOptions?: AppLaunchOptions }
312
- ).appLaunchOptions;
313
-
314
- const clientLogListener = createClientLogListener();
315
- const bridgeEventListener = (event: BridgeEvents) => {
316
- const runId = getCurrentRunId();
317
- if (!runId) {
318
- return;
319
- }
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
+ }
320
319
 
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,
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);
421
333
  });
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
334
 
441
- const onReady = (device: DeviceDescriptor) => {
442
- crashSupervisor.markReady();
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,
364
+ timeout: config.bridgeTimeout,
365
+ context,
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
+ }
443
433
 
444
- const runId = getCurrentRunId();
445
- if (!runId) {
446
- return;
447
- }
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();
448
540
 
449
- scheduleHook('runtime:ready', {
450
- runId,
451
- device,
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,
452
552
  });
453
- };
454
- const onDisconnect = () => {
455
- const runId = getCurrentRunId();
456
- if (!runId) {
457
- return;
458
- }
459
553
 
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
- }
554
+ const onReady = (device: DeviceDescriptor) => {
555
+ crashSupervisor.markReady();
470
556
 
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
- }
557
+ const runId = getCurrentRunId();
558
+ if (!runId) {
559
+ return;
560
+ }
481
561
 
482
- if (event.type === 'app_exited') {
483
- scheduleHook('app:exited', {
562
+ scheduleHook('runtime:ready', {
484
563
  runId,
485
- testFile: activeTestFilePath,
486
- pid: event.pid,
487
- source: event.source,
488
- line: event.line,
489
- isConfirmed: event.isConfirmed,
490
- crashDetails: event.crashDetails,
564
+ device,
491
565
  });
492
- return;
493
- }
566
+ };
567
+ const onDisconnect = () => {
568
+ const runId = getCurrentRunId();
569
+ if (!runId) {
570
+ return;
571
+ }
494
572
 
495
- if (event.type === 'possible_crash') {
496
- scheduleHook('app:possible-crash', {
573
+ scheduleHook('runtime:disconnected', {
497
574
  runId,
498
- testFile: activeTestFilePath,
499
- pid: event.pid,
500
- source: event.source,
501
- line: event.line,
502
- isConfirmed: event.isConfirmed,
503
- crashDetails: event.crashDetails,
575
+ reason: 'bridge-disconnected',
504
576
  });
505
- }
506
- };
577
+ };
578
+ const onAppMonitorEvent = (event: AppMonitorEvent) => {
579
+ const runId = getCurrentRunId();
580
+ if (!runId) {
581
+ return;
582
+ }
507
583
 
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');
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
+ }
514
594
 
515
- if (config.forwardClientLogs) {
516
- metroInstance.events.addListener(clientLogListener);
517
- harnessLogger.debug('client log forwarding enabled');
518
- }
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
+ }
519
607
 
520
- const dispose = async (reason: 'normal' | 'abort' | 'error' = 'normal') => {
521
- harnessLogger.debug('disposing Harness (reason=%s)', reason);
522
- let hookError: unknown;
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
+ };
523
620
 
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
- }
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');
537
627
 
538
628
  if (config.forwardClientLogs) {
539
- metroInstance.events.removeListener(clientLogListener);
629
+ metroInstance.events.addListener(clientLogListener);
630
+ harnessLogger.debug('client log forwarding enabled');
540
631
  }
541
- metroInstance.events.removeListener(onMetroEvent);
542
- appMonitor.removeListener(onAppMonitorEvent);
543
- serverBridge.off('ready', onReady);
544
- serverBridge.off('disconnect', onDisconnect);
545
- serverBridge.off('event', bridgeEventListener);
546
- await Promise.all([
547
- crashSupervisor.dispose(),
548
- serverBridge.dispose(),
549
- platformInstance.dispose(),
550
- metroInstance.dispose(),
551
- ]);
552
- pluginAbortController.abort();
553
- harnessLogger.debug('Harness resources disposed');
554
-
555
- if (hookError) {
556
- throw hookError;
557
- }
558
- };
559
632
 
560
- if (signal.aborted) {
561
- await dispose('abort');
633
+ const dispose = async (reason: 'normal' | 'abort' | 'error' = 'normal') => {
634
+ harnessLogger.debug('disposing Harness (reason=%s)', reason);
635
+ let hookError: unknown;
562
636
 
563
- throw new DOMException('The operation was aborted', 'AbortError');
564
- }
565
-
566
- try {
567
- await pluginManager.callHook('harness:before-creation', {
568
- appLaunchOptions,
569
- });
570
- await appMonitor.start();
571
- harnessLogger.debug('app monitor started');
572
- } catch (error) {
573
- const runState = currentRun as HarnessRunState | null;
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
+ }
574
658
 
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
- }
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');
582
682
 
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);
683
+ if (hookError) {
684
+ throw hookError;
685
+ }
588
686
 
589
- if (crashSupervisor.isReady() && (await platformInstance.isAppRunning())) {
590
- harnessLogger.debug('reusing existing ready app for %s', testFilePath);
591
- return;
592
- }
687
+ if (cleanupError) {
688
+ throw cleanupError;
689
+ }
690
+ };
593
691
 
594
- crashSupervisor.reset();
595
- harnessLogger.debug('app not ready, waiting for launch and runtime readiness');
596
- await waitForAppReady({
597
- metroInstance,
598
- serverBridge,
599
- platformInstance: platformInstance as HarnessPlatformRunner,
600
- platformId: platform.platformId,
601
- bundleStartTimeout: config.bundleStartTimeout ?? 60000,
602
- readyTimeout: config.bridgeTimeout,
603
- maxAppRestarts: config.maxAppRestarts ?? 2,
604
- testFilePath,
605
- crashSupervisor,
606
- appLaunchOptions,
607
- });
608
- await flushPendingHooks();
609
- harnessLogger.debug('app is ready for %s', testFilePath);
610
- };
692
+ if (signal.aborted) {
693
+ await dispose('abort');
611
694
 
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);
695
+ throw new DOMException('The operation was aborted', 'AbortError');
628
696
  }
629
697
 
630
- crashSupervisor.reset();
631
- await crashSupervisor.start();
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;
632
711
 
633
- if (testFilePath) {
634
- await ensureAppReady(testFilePath);
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;
635
722
  }
636
723
 
637
- await flushPendingHooks();
638
- harnessLogger.debug('restart completed');
639
- };
640
-
641
- return {
642
- context,
643
- runTests: async (path, options) => {
724
+ const ensureAppReady = async (testFilePath: string) => {
644
725
  await flushPendingHooks();
645
- activeTestFilePath = path;
646
- const client = serverBridge.rpc.clients.at(-1);
647
-
648
- if (!client) {
649
- throw new Error('No client found');
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;
650
736
  }
651
737
 
652
- harnessLogger.debug('running test file on client: %s', path);
653
- const result = await client.runTests(path, {
654
- ...options,
655
- 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,
656
753
  });
657
754
  await flushPendingHooks();
658
- return result;
659
- },
660
- ensureAppReady,
661
- restart,
662
- dispose: () => dispose('normal'),
663
- crashSupervisor,
664
- callHook: async (name, payload) => {
755
+ harnessLogger.debug('app is ready for %s', testFilePath);
756
+ };
757
+
758
+ const restart = async (testFilePath?: string) => {
665
759
  await flushPendingHooks();
666
- await pluginManager.callHook(name, payload);
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
+
667
783
  await flushPendingHooks();
668
- },
669
- setRunState: (runState) => {
670
- currentRun = runState;
671
- },
672
- getRunState: () => currentRun,
673
- };
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
+ }
674
824
  };
675
825
 
676
826
  export const getHarness = async (
@@ -678,25 +828,15 @@ export const getHarness = async (
678
828
  platform: HarnessPlatform,
679
829
  projectRoot: string
680
830
  ): Promise<Harness> => {
681
- const abortSignal = AbortSignal.timeout(config.bridgeTimeout);
682
831
  harnessLogger.debug(
683
- 'creating Harness with bridge timeout %dms',
684
- config.bridgeTimeout
832
+ 'creating Harness with platform ready timeout %dms',
833
+ config.platformReadyTimeout
685
834
  );
686
835
 
687
- try {
688
- const harness = await getHarnessInternal(
689
- config,
690
- platform,
691
- projectRoot,
692
- abortSignal
693
- );
694
- return harness;
695
- } catch (error) {
696
- if (error instanceof DOMException && error.name === 'AbortError') {
697
- throw new InitializationTimeoutError();
698
- }
699
-
700
- throw error;
701
- }
836
+ return await getHarnessInternal(
837
+ config,
838
+ platform,
839
+ projectRoot,
840
+ new AbortController().signal
841
+ );
702
842
  };