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

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