@react-native-harness/jest 1.4.0-rc.1 → 1.4.0

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 (75) hide show
  1. package/dist/__tests__/bridge.test.js +6 -2
  2. package/dist/__tests__/crash-monitor.test.js +64 -0
  3. package/dist/__tests__/environment-reset.test.d.ts +2 -0
  4. package/dist/__tests__/environment-reset.test.d.ts.map +1 -0
  5. package/dist/__tests__/environment-reset.test.js +238 -0
  6. package/dist/__tests__/execute-run.test.js +250 -4
  7. package/dist/__tests__/format-harness-error.test.d.ts +2 -0
  8. package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
  9. package/dist/__tests__/format-harness-error.test.js +27 -0
  10. package/dist/__tests__/harness-cache.test.js +62 -41
  11. package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
  12. package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
  13. package/dist/__tests__/harness-session-signal.test.js +298 -0
  14. package/dist/__tests__/harness-session.test.js +161 -2
  15. package/dist/__tests__/index.test.js +3 -1
  16. package/dist/__tests__/metro-port.test.js +2 -0
  17. package/dist/__tests__/run.test.d.ts +2 -0
  18. package/dist/__tests__/run.test.d.ts.map +1 -0
  19. package/dist/__tests__/run.test.js +48 -0
  20. package/dist/crash-monitor.d.ts +7 -0
  21. package/dist/crash-monitor.d.ts.map +1 -1
  22. package/dist/crash-monitor.js +19 -0
  23. package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
  24. package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
  25. package/dist/diagnostics/__tests__/derivers.test.js +248 -0
  26. package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
  27. package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
  28. package/dist/diagnostics/__tests__/report.test.js +97 -0
  29. package/dist/diagnostics/derivers.d.ts +24 -0
  30. package/dist/diagnostics/derivers.d.ts.map +1 -0
  31. package/dist/diagnostics/derivers.js +146 -0
  32. package/dist/diagnostics/index.d.ts +3 -0
  33. package/dist/diagnostics/index.d.ts.map +1 -0
  34. package/dist/diagnostics/index.js +2 -0
  35. package/dist/diagnostics/report.d.ts +7 -0
  36. package/dist/diagnostics/report.d.ts.map +1 -0
  37. package/dist/diagnostics/report.js +84 -0
  38. package/dist/environment-reset.d.ts +51 -0
  39. package/dist/environment-reset.d.ts.map +1 -0
  40. package/dist/environment-reset.js +83 -0
  41. package/dist/execute-run.d.ts.map +1 -1
  42. package/dist/execute-run.js +121 -69
  43. package/dist/format-harness-error.d.ts +5 -0
  44. package/dist/format-harness-error.d.ts.map +1 -0
  45. package/dist/format-harness-error.js +43 -0
  46. package/dist/harness-session.d.ts +26 -1
  47. package/dist/harness-session.d.ts.map +1 -1
  48. package/dist/harness-session.js +260 -87
  49. package/dist/run.d.ts.map +1 -1
  50. package/dist/run.js +21 -4
  51. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  52. package/package.json +8 -7
  53. package/src/__tests__/bridge.test.ts +8 -3
  54. package/src/__tests__/crash-monitor.test.ts +84 -0
  55. package/src/__tests__/environment-reset.test.ts +308 -0
  56. package/src/__tests__/execute-run.test.ts +327 -4
  57. package/src/__tests__/format-harness-error.test.ts +32 -0
  58. package/src/__tests__/harness-cache.test.ts +91 -0
  59. package/src/__tests__/harness-session.test.ts +211 -1
  60. package/src/__tests__/index.test.ts +3 -1
  61. package/src/__tests__/metro-port.test.ts +2 -0
  62. package/src/__tests__/run.test.ts +74 -0
  63. package/src/crash-monitor.ts +28 -0
  64. package/src/diagnostics/__tests__/derivers.test.ts +300 -0
  65. package/src/diagnostics/__tests__/report.test.ts +128 -0
  66. package/src/diagnostics/derivers.ts +170 -0
  67. package/src/diagnostics/index.ts +2 -0
  68. package/src/diagnostics/report.ts +124 -0
  69. package/src/environment-reset.ts +138 -0
  70. package/src/execute-run.ts +136 -77
  71. package/src/format-harness-error.ts +73 -0
  72. package/src/harness-session.ts +369 -131
  73. package/src/run.ts +23 -4
  74. package/tsconfig.json +3 -0
  75. package/tsconfig.lib.json +3 -0
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createHarnessBridge,
3
+ AppBridgeDisconnectedError,
3
4
  type HarnessBridge,
4
5
  type AppConnection,
5
6
  } from '@react-native-harness/bridge/server';
@@ -18,9 +19,9 @@ import {
18
19
  type HarnessPlatformInitOptions,
19
20
  type HarnessPlatformRunner,
20
21
  } from '@react-native-harness/platforms';
22
+ import { createHarnessCache } from '@react-native-harness/cache';
21
23
  import {
22
24
  getMetroInstance,
23
- isMetroCacheReusable,
24
25
  waitForMetroBackedAppReady,
25
26
  type MetroInstance,
26
27
  type MetroWebSocketEndpoint,
@@ -36,12 +37,17 @@ import {
36
37
  } from '@react-native-harness/plugins';
37
38
  import {
38
39
  createCrashArtifactWriter,
40
+ createDiagnostics,
39
41
  logger,
40
42
  getTimeoutSignal,
41
- raceAbortSignals,
43
+ ensureSignalsDeliverable,
44
+ waitForAbort,
45
+ type Diagnostics,
42
46
  } from '@react-native-harness/tools';
43
47
  import {
44
48
  getConfig,
49
+ isDiagnosticsEnabled,
50
+ resolveMetroCacheEnabled,
45
51
  type Config as HarnessConfig,
46
52
  ConfigSchema,
47
53
  } from '@react-native-harness/config';
@@ -51,6 +57,12 @@ import path from 'node:path';
51
57
  import { PlatformReadyTimeoutError } from './errors.js';
52
58
  import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
53
59
  import { createCrashMonitor, type CrashMonitor } from './crash-monitor.js';
60
+ import {
61
+ createProcessResetStrategy,
62
+ createRuntimeResetStrategy,
63
+ resolveResetStrategyKind,
64
+ withEscalation,
65
+ } from './environment-reset.js';
54
66
  import { createHookQueue, type HookQueue } from './hook-queue.js';
55
67
  import {
56
68
  createClientLogCollector,
@@ -62,6 +74,7 @@ import {
62
74
  type ResourceLockManager,
63
75
  type ResourceLease,
64
76
  } from './resource-lock.js';
77
+ import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
65
78
  import { resolveHarnessMetroPort } from './metro-port.js';
66
79
  import { getAdditionalCliArgs } from './cli-args.js';
67
80
  import {
@@ -121,12 +134,35 @@ export const waitForBridgeDisconnectOrTimeout = async ({
121
134
  });
122
135
  };
123
136
 
137
+ // Resolves the NEXT time the bridge reports a 'connected' event, rejecting
138
+ // if `signal` aborts first. Deliberately does not use bridge.nextConnection(),
139
+ // which would resolve immediately for a stale, already-set connection.
140
+ export const waitForNextConnected = ({
141
+ bridge,
142
+ signal,
143
+ }: {
144
+ bridge: Pick<HarnessBridge, 'on' | 'off'>;
145
+ signal: AbortSignal;
146
+ }): Promise<void> =>
147
+ new Promise<void>((resolve, reject) => {
148
+ const onConnected = () => { cleanup(); resolve(); };
149
+ const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
150
+ const cleanup = () => {
151
+ bridge.off('connected', onConnected);
152
+ signal.removeEventListener('abort', onAbort);
153
+ };
154
+ if (signal.aborted) { onAbort(); return; }
155
+ bridge.on('connected', onConnected);
156
+ signal.addEventListener('abort', onAbort, { once: true });
157
+ });
158
+
124
159
  export type HarnessRunState = {
125
160
  readonly runId: string;
126
161
  readonly startTime: number;
127
162
  readonly testFiles: string[];
128
163
  readonly watchMode: boolean;
129
164
  readonly coverageEnabled: boolean;
165
+ readonly completed?: boolean;
130
166
  readonly summary?: HarnessRunSummary;
131
167
  readonly status?: HarnessRunStatus;
132
168
  readonly error?: unknown;
@@ -137,10 +173,12 @@ export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
137
173
  export type HarnessSession = {
138
174
  readonly config: HarnessConfig;
139
175
  readonly context: HarnessContext;
176
+ readonly diagnostics: Diagnostics;
140
177
  onTestRunnerEvent: (listener: (event: TestRunnerEvents) => void) => () => void;
141
178
  runTestFile: (path: string, options: HarnessRunTestsOptions) => Promise<TestSuiteResult>;
142
179
  ensureAppReady: (testFilePath: string) => Promise<void>;
143
180
  restartApp: (testFilePath?: string) => Promise<void>;
181
+ resetEnvironment: (testFilePath: string) => Promise<void>;
144
182
  resetCrashState: () => void;
145
183
  flushClientLogs: () => ClientLogBuffer;
146
184
  callHook: HarnessPluginManager<HarnessConfig, HarnessPlatform>['callHook'];
@@ -148,45 +186,47 @@ export type HarnessSession = {
148
186
  dispose: (reason?: 'normal' | 'abort' | 'error') => Promise<void>;
149
187
  };
150
188
 
189
+ export const getSignalExitCodeForRunState = (
190
+ state: HarnessRunState | null
191
+ ): number => {
192
+ if (
193
+ state?.completed &&
194
+ state.status === 'passed' &&
195
+ !state.error &&
196
+ (state.summary?.failed ?? 0) === 0
197
+ ) {
198
+ return 0;
199
+ }
200
+
201
+ return 1;
202
+ };
203
+
151
204
  // ---------------------------------------------------------------------------
152
205
  // Internal helpers
153
206
  // ---------------------------------------------------------------------------
154
207
 
155
- const createAbortError = () =>
156
- new DOMException('The operation was aborted', 'AbortError');
157
-
158
- const waitForAbort = (signal: AbortSignal): Promise<never> => {
159
- if (signal.aborted) {
160
- return Promise.reject(signal.reason ?? createAbortError());
161
- }
162
- return new Promise((_, reject) => {
163
- signal.addEventListener(
164
- 'abort',
165
- () => reject(signal.reason ?? createAbortError()),
166
- { once: true },
167
- );
168
- });
169
- };
170
-
171
- const withPlatformReadyTimeout = async <T>(options: {
208
+ // Bounds only the init *call* with a readiness timeout; `options.signal` (the
209
+ // session-lifetime signal) is passed to `work` unmodified, so a slow init
210
+ // call keeps observing real session teardown, not a synthetic timeout abort.
211
+ // On timeout this throws PlatformReadyTimeoutError without cancelling `work`
212
+ // itself — the caller is expected to abort the session signal as part of
213
+ // tearing down, which then cancels the still-inflight call cooperatively.
214
+ export const withPlatformReadyTimeout = async <T>(options: {
172
215
  timeout: number;
173
216
  signal: AbortSignal;
174
217
  work: (signal: AbortSignal) => Promise<T>;
175
218
  }): Promise<T> => {
176
219
  const timeoutSignal = getTimeoutSignal(options.timeout);
177
- const combinedSignal = raceAbortSignals([options.signal, timeoutSignal]);
220
+ const abortWait = waitForAbort(timeoutSignal);
178
221
  try {
179
- return await options.work(combinedSignal);
180
- } catch (error) {
181
- if (
182
- error instanceof DOMException &&
183
- error.name === 'AbortError' &&
184
- timeoutSignal.aborted &&
185
- !options.signal.aborted
186
- ) {
187
- throw new PlatformReadyTimeoutError(options.timeout);
188
- }
189
- throw error;
222
+ return await Promise.race([
223
+ options.work(options.signal),
224
+ abortWait.promise.catch(() => {
225
+ throw new PlatformReadyTimeoutError(options.timeout);
226
+ }),
227
+ ]);
228
+ } finally {
229
+ abortWait.cancel();
190
230
  }
191
231
  };
192
232
 
@@ -214,15 +254,17 @@ export const waitForStartupCrash = async ({
214
254
  signal: AbortSignal;
215
255
  }) => {
216
256
  if (!detectNativeCrashes) {
217
- return await waitForAbort(signal);
257
+ return await waitForAbort(signal).promise;
218
258
  }
219
259
 
220
260
  const watch = crashMonitor.watch(testFilePath, 'startup');
221
261
  watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
262
+ const abortWait = waitForAbort(signal);
222
263
  try {
223
- return await Promise.race([watch.promise, waitForAbort(signal)]);
264
+ return await Promise.race([watch.promise, abortWait.promise]);
224
265
  } finally {
225
266
  watch.cancel();
267
+ abortWait.cancel();
226
268
  }
227
269
  };
228
270
 
@@ -264,17 +306,7 @@ const waitForAppReady = async (
264
306
  // is already set. waitForReady is called before startAttempt, so a stale
265
307
  // connection from a previous run would resolve the promise before startAttempt
266
308
  // even restarts the app — leaving bridge.connection null after the restart.
267
- await new Promise<void>((resolve, reject) => {
268
- const onConnected = () => { cleanup(); resolve(); };
269
- const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
270
- const cleanup = () => {
271
- bridge.off('connected', onConnected);
272
- signal.removeEventListener('abort', onAbort);
273
- };
274
- if (signal.aborted) { onAbort(); return; }
275
- bridge.on('connected', onConnected);
276
- signal.addEventListener('abort', onAbort, { once: true });
277
- });
309
+ await waitForNextConnected({ bridge, signal });
278
310
  logWait('runtime ready received');
279
311
  },
280
312
  waitForCrash: async (signal) => {
@@ -358,13 +390,37 @@ const applyEnvVars = (
358
390
  }
359
391
  };
360
392
 
393
+ /**
394
+ * Logs the "Reusing Metro cache" message when persistent Metro caching is
395
+ * enabled and the cache is warm. `projectRoot` must be the config-resolved
396
+ * project root so the warmth check looks at the same directory Metro caches
397
+ * into.
398
+ */
399
+ export const maybeLogMetroCacheReused = (options: {
400
+ config: Pick<HarnessConfig, 'cache' | 'unstable__enableMetroCache'>;
401
+ projectRoot: string;
402
+ platform: HarnessPlatform;
403
+ }): void => {
404
+ if (!resolveMetroCacheEnabled(options.config)) {
405
+ return;
406
+ }
407
+
408
+ if (
409
+ createHarnessCache({ projectRoot: options.projectRoot }).isWarm('metro')
410
+ ) {
411
+ logMetroCacheReused(options.platform);
412
+ }
413
+ };
414
+
361
415
  const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
362
416
  harnessConfig: HarnessConfig;
363
417
  platform: HarnessPlatform;
364
418
  projectRoot: string;
419
+ configProjectRoot: string;
365
420
  }> => {
366
421
  const projectRoot = globalConfig.rootDir;
367
- const { config: rawConfig } = await getConfig(projectRoot);
422
+ const { config: rawConfig, projectRoot: configProjectRoot } =
423
+ await getConfig(projectRoot);
368
424
 
369
425
  const cliArgs = getAdditionalCliArgs();
370
426
  let harnessConfig = cliArgs.metroPort != null
@@ -393,7 +449,7 @@ const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
393
449
  const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName);
394
450
  if (!platform) throw new RunnerNotFoundError(selectedRunnerName);
395
451
 
396
- return { harnessConfig, platform, projectRoot };
452
+ return { harnessConfig, platform, projectRoot, configProjectRoot };
397
453
  };
398
454
 
399
455
  // ---------------------------------------------------------------------------
@@ -406,20 +462,32 @@ export const createHarnessSession = async (
406
462
  ): Promise<HarnessSession> => {
407
463
  preRunMessage.remove(process.stderr);
408
464
 
409
- const { harnessConfig, platform, projectRoot } = await loadConfig(globalConfig);
465
+ const setupStartedAt = Date.now();
466
+ const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
410
467
  applyEnvVars(harnessConfig, globalConfig);
411
468
 
469
+ const diagnostics = createDiagnostics({
470
+ enabled: isDiagnosticsEnabled(harnessConfig),
471
+ });
472
+ diagnostics.record({
473
+ label: 'session.config.load',
474
+ duration: Date.now() - setupStartedAt,
475
+ });
476
+
412
477
  sessionLogger.debug(
413
478
  'creating session for runner=%s platform=%s',
414
479
  platform.name,
415
480
  platform.platformId,
416
481
  );
417
482
 
418
- // Single AbortController for the entire setup phase. Registered signal
419
- // handlers abort this so that slow inflight operations (Metro init, platform
420
- // runner startup) are cancelled promptly on SIGTERM/SIGINT.
421
- const setupController = new AbortController();
422
- const onEarlySignal = () => setupController.abort();
483
+ // Single, session-lifetime AbortController: it aborts on early SIGTERM/SIGINT
484
+ // during setup (below) and, once the session is up, in dispose()'s finally
485
+ // (unchanged lifetime). It is the one signal the plugin manager and platform
486
+ // runner see for the entire session, so its abort reaches every long-lived
487
+ // child the runner threads it into.
488
+ ensureSignalsDeliverable();
489
+ const sessionController = new AbortController();
490
+ const onEarlySignal = () => sessionController.abort();
423
491
  process.once('SIGTERM', onEarlySignal);
424
492
  process.once('SIGINT', onEarlySignal);
425
493
 
@@ -429,20 +497,25 @@ export const createHarnessSession = async (
429
497
 
430
498
  logTestRunHeader(platform);
431
499
 
432
- const resourceLease = await lockManager.acquire(resourceLockKey, {
433
- signal: setupController.signal,
434
- onWait: () => {
435
- didWaitForResourceLock = true;
436
- logRunnerWaitingInQueue(platform);
437
- sessionLogger.debug('waiting in queue for runner=%s key=%s', platform.name, resourceLockKey);
438
- },
439
- onStillWaiting: (elapsedMs) => {
440
- if (elapsedMs - lastStillWaitingLogAt < 5000) return;
441
- lastStillWaitingLogAt = elapsedMs;
442
- logRunnerStillWaitingInQueue(platform);
443
- sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
444
- },
445
- });
500
+ const resourceLease = await diagnostics.measure(
501
+ 'session.lock.wait',
502
+ () =>
503
+ lockManager.acquire(resourceLockKey, {
504
+ signal: sessionController.signal,
505
+ onWait: () => {
506
+ didWaitForResourceLock = true;
507
+ logRunnerWaitingInQueue(platform);
508
+ sessionLogger.debug('waiting in queue for runner=%s key=%s', platform.name, resourceLockKey);
509
+ },
510
+ onStillWaiting: (elapsedMs) => {
511
+ if (elapsedMs - lastStillWaitingLogAt < 5000) return;
512
+ lastStillWaitingLogAt = elapsedMs;
513
+ logRunnerStillWaitingInQueue(platform);
514
+ sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
515
+ },
516
+ }),
517
+ { lockKey: resourceLockKey }
518
+ );
446
519
 
447
520
  if (didWaitForResourceLock) logRunnerStarting(platform);
448
521
  sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
@@ -452,28 +525,40 @@ export const createHarnessSession = async (
452
525
  let metroPortLease: ResourceLease | null = null;
453
526
 
454
527
  try {
455
- const resolution = await resolveHarnessMetroPort({
456
- config: harnessConfig,
457
- platform,
458
- resourceLockManager: lockManager,
459
- signal: setupController.signal,
528
+ const portResolveSpan = diagnostics.start('session.port.resolve');
529
+ let resolution: Awaited<ReturnType<typeof resolveHarnessMetroPort>>;
530
+ try {
531
+ resolution = await resolveHarnessMetroPort({
532
+ config: harnessConfig,
533
+ platform,
534
+ resourceLockManager: lockManager,
535
+ signal: sessionController.signal,
536
+ });
537
+ } catch (error) {
538
+ portResolveSpan.fail(error);
539
+ throw error;
540
+ }
541
+ portResolveSpan.end({
542
+ port: resolution.config.metroPort,
543
+ didFallback: resolution.didFallback,
460
544
  });
461
545
  metroPortLease = resolution.metroPortLease;
462
546
  const { config: runtimeConfig, initialMetroPort, didFallback } = resolution;
463
547
 
464
548
  if (didFallback) logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
465
549
 
466
- if (runtimeConfig.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) {
467
- logMetroCacheReused(platform);
468
- }
550
+ maybeLogMetroCacheReused({
551
+ config: runtimeConfig,
552
+ projectRoot: configProjectRoot,
553
+ platform,
554
+ });
469
555
 
470
- const pluginAbortController = new AbortController();
471
556
  const pluginManager = createHarnessPluginManager<HarnessConfig, HarnessPlatform>({
472
557
  plugins: (runtimeConfig.plugins ?? []) as Array<HarnessPlugin<object, HarnessConfig, HarnessPlatform>>,
473
558
  projectRoot,
474
559
  config: runtimeConfig,
475
560
  runner: platform,
476
- abortSignal: pluginAbortController.signal,
561
+ abortSignal: sessionController.signal,
477
562
  });
478
563
 
479
564
  const hooks = createHookQueue();
@@ -488,49 +573,83 @@ export const createHarnessSession = async (
488
573
  rootDir: path.join(projectRoot, '.harness', 'crash-reports'),
489
574
  });
490
575
 
491
- const bridge = await createHarnessBridge({
492
- noServer: true,
493
- timeout: runtimeConfig.bridgeTimeout,
494
- context,
495
- });
576
+ const bridge = await diagnostics.measure('session.bridge.init', () =>
577
+ createHarnessBridge({
578
+ noServer: true,
579
+ timeout: runtimeConfig.bridgeTimeout,
580
+ context,
581
+ })
582
+ );
496
583
  sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
497
584
 
498
585
  let metroInstance: MetroInstance;
499
586
  let platformInstance: HarnessPlatformRunner;
587
+ // Noop until the Metro instance resolves and the deriver is attached.
588
+ let disposeMetroDiagnostics: () => void = () => undefined;
500
589
 
501
590
  try {
502
591
  [metroInstance, platformInstance] = await Promise.all([
503
- getMetroInstance(
504
- {
505
- projectRoot,
506
- harnessConfig: runtimeConfig,
507
- websocketEndpoints: {
508
- [HARNESS_BRIDGE_PATH]: bridge.ws as unknown as MetroWebSocketEndpoint,
592
+ diagnostics.measure('metro.init', () =>
593
+ getMetroInstance(
594
+ {
595
+ projectRoot,
596
+ harnessConfig: runtimeConfig,
597
+ websocketEndpoints: {
598
+ [HARNESS_BRIDGE_PATH]: bridge.ws as unknown as MetroWebSocketEndpoint,
599
+ },
600
+ watchMode: globalConfig.watch || globalConfig.watchAll,
509
601
  },
510
- },
511
- setupController.signal,
512
- ).then((instance) => {
513
- sessionLogger.debug('Metro initialized');
514
- return instance;
515
- }),
516
- withPlatformReadyTimeout({
517
- timeout: runtimeConfig.platformReadyTimeout,
518
- signal: setupController.signal,
519
- work: async (signal) => {
520
- return await import(platform.runner).then((module) =>
521
- module.default(platform.config, runtimeConfig, {
522
- signal,
523
- crashArtifactWriter,
524
- } satisfies HarnessPlatformInitOptions),
525
- ).then((instance) => {
526
- sessionLogger.debug('platform runner initialized');
527
- return instance;
528
- });
529
- },
530
- }),
602
+ sessionController.signal,
603
+ ).then((instance) => {
604
+ sessionLogger.debug('Metro initialized');
605
+ // Attach the Metro diagnostics deriver before the eager prewarm
606
+ // fires so its bundle build/request events land in the trace.
607
+ disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
608
+ if (runtimeConfig.eagerPrewarm !== false) {
609
+ // Kick off the first bundle build while the platform is still booting.
610
+ // prewarm() memoizes this first call, so its session-lifetime setup
611
+ // signal is the one that matters — later callers' signals cannot
612
+ // cancel it. Not awaited: metro.init must resolve immediately.
613
+ const prewarmSpan = diagnostics.start('metro.prewarm');
614
+ instance
615
+ .prewarm({
616
+ platform: platform.platformId,
617
+ signal: sessionController.signal,
618
+ })
619
+ .then((completed) => prewarmSpan.end({ completed }))
620
+ // Swallow AbortError on teardown so it never becomes an
621
+ // unhandled rejection; non-abort failures resolve false.
622
+ .catch((error) => prewarmSpan.fail(error));
623
+ }
624
+ return instance;
625
+ })
626
+ ),
627
+ diagnostics.measure(
628
+ 'platform.init',
629
+ () =>
630
+ withPlatformReadyTimeout({
631
+ timeout: runtimeConfig.platformReadyTimeout,
632
+ signal: sessionController.signal,
633
+ work: async (signal) => {
634
+ return await import(platform.runner).then((module) =>
635
+ module.default(platform.config, runtimeConfig, {
636
+ signal,
637
+ crashArtifactWriter,
638
+ diagnostics,
639
+ } satisfies HarnessPlatformInitOptions),
640
+ ).then((instance) => {
641
+ sessionLogger.debug('platform runner initialized');
642
+ return instance;
643
+ });
644
+ },
645
+ }),
646
+ { runner: platform.runner, platformId: platform.platformId }
647
+ ),
531
648
  ]);
532
649
  } catch (error) {
533
- // Only bridge needs cleanup here; leases are released by the outer catch.
650
+ // Only bridge and the Metro diagnostics listener need cleanup here;
651
+ // leases are released by the outer catch.
652
+ disposeMetroDiagnostics();
534
653
  await bridge.dispose();
535
654
  throw error;
536
655
  }
@@ -552,7 +671,9 @@ export const createHarnessSession = async (
552
671
  const restartAppSession = async (): Promise<AppSession> => {
553
672
  await crashMonitor.stop();
554
673
  await disposeCurrentAppSession();
555
- const session = await platformInstance.createAppSession(appLaunchOptions);
674
+ const session = await diagnostics.measure('app.session.create', () =>
675
+ platformInstance.createAppSession(appLaunchOptions)
676
+ );
556
677
  currentAppSession = session;
557
678
  crashMonitor.setAppSession(session);
558
679
  await crashMonitor.start();
@@ -625,6 +746,8 @@ export const createHarnessSession = async (
625
746
  metroInstance.events.addListener(clientLogListener);
626
747
  }
627
748
 
749
+ const disposeBridgeDiagnostics = observeBridgeEvents(bridge, diagnostics);
750
+
628
751
  sessionLogger.debug('registered runtime, bridge, and Metro listeners');
629
752
 
630
753
  // --- Dispose ---
@@ -633,6 +756,7 @@ export const createHarnessSession = async (
633
756
 
634
757
  const disposeOnce = async (reason: 'normal' | 'abort' | 'error') => {
635
758
  sessionLogger.debug('disposing session (reason=%s)', reason);
759
+ const disposeSpan = diagnostics.start('dispose.total', { reason });
636
760
  let hookError: unknown;
637
761
 
638
762
  try {
@@ -665,6 +789,8 @@ export const createHarnessSession = async (
665
789
  bridge.off('disconnected', onDisconnected);
666
790
  bridge.off('event', bridgeEventListener);
667
791
  bridge.off('event', onTestRunnerEvent);
792
+ disposeMetroDiagnostics();
793
+ disposeBridgeDiagnostics();
668
794
 
669
795
  const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios;
670
796
  if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) {
@@ -685,9 +811,13 @@ export const createHarnessSession = async (
685
811
 
686
812
  let cleanupError: unknown;
687
813
  try {
814
+ // The app session (and its logcat/device log stream) must be fully
815
+ // torn down before the platform is disposed: platform disposal can
816
+ // shut down the device/emulator, and doing that concurrently with
817
+ // the app session's own cleanup races the device's log stream
818
+ // against the device disappearing out from under it.
819
+ await Promise.all([crashMonitor.dispose(), disposeCurrentAppSession()]);
688
820
  await Promise.all([
689
- crashMonitor.dispose(),
690
- disposeCurrentAppSession(),
691
821
  bridge.dispose(),
692
822
  platformInstance.dispose(),
693
823
  metroInstance.dispose(),
@@ -697,13 +827,20 @@ export const createHarnessSession = async (
697
827
  cleanupError = error;
698
828
  } finally {
699
829
  await resourceLease.release();
700
- pluginAbortController.abort();
830
+ sessionController.abort();
701
831
  }
702
832
 
703
833
  sessionLogger.debug('session resources disposed');
704
834
 
705
- if (hookError) throw hookError;
706
- if (cleanupError) throw cleanupError;
835
+ if (hookError) {
836
+ disposeSpan.fail(hookError);
837
+ throw hookError;
838
+ }
839
+ if (cleanupError) {
840
+ disposeSpan.fail(cleanupError);
841
+ throw cleanupError;
842
+ }
843
+ disposeSpan.end();
707
844
  };
708
845
 
709
846
  const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => {
@@ -715,7 +852,13 @@ export const createHarnessSession = async (
715
852
  // that all infrastructure is up.
716
853
  process.off('SIGTERM', onEarlySignal);
717
854
  process.off('SIGINT', onEarlySignal);
718
- const onSignal = () => void dispose('abort').then(() => process.exit(0));
855
+ const onSignal = () => {
856
+ const exitCode = getSignalExitCodeForRunState(currentRun);
857
+ void dispose('abort').then(
858
+ () => process.exit(exitCode),
859
+ () => process.exit(1),
860
+ );
861
+ };
719
862
  process.once('SIGTERM', onSignal);
720
863
  process.once('SIGINT', onSignal);
721
864
 
@@ -734,6 +877,10 @@ export const createHarnessSession = async (
734
877
  }
735
878
 
736
879
  logTestEnvironmentReady(platform);
880
+ diagnostics.record({
881
+ label: 'session.setup',
882
+ duration: Date.now() - setupStartedAt,
883
+ });
737
884
  sessionLogger.debug('session ready');
738
885
 
739
886
  // --- Public API ---
@@ -741,31 +888,35 @@ export const createHarnessSession = async (
741
888
  const ensureAppReady = async (testFilePath: string): Promise<void> => {
742
889
  await hooks.drain();
743
890
  sessionLogger.debug('ensuring app is ready for %s', testFilePath);
891
+ const appReadySpan = diagnostics.start('app.ready.total');
744
892
 
745
893
  if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) {
746
894
  const state = await currentAppSession.getState();
747
895
  if (state.status === 'running') {
748
896
  sessionLogger.debug('reusing existing ready app for %s', testFilePath);
897
+ appReadySpan.end({ file: testFilePath, reused: true });
749
898
  return;
750
899
  }
751
900
  }
752
901
 
753
902
  crashMonitor.reset();
754
903
  sessionLogger.debug('app not ready, waiting for launch and runtime readiness');
755
- await waitForAppReady(appReadyBaseOptions, testFilePath);
904
+ try {
905
+ await waitForAppReady(appReadyBaseOptions, testFilePath);
906
+ } catch (error) {
907
+ appReadySpan.fail(error, { file: testFilePath, reused: false });
908
+ throw error;
909
+ }
756
910
  await hooks.drain();
757
911
  sessionLogger.debug('app is ready for %s', testFilePath);
912
+ appReadySpan.end({ file: testFilePath, reused: false });
758
913
  };
759
914
 
760
- const restartApp = async (testFilePath?: string): Promise<void> => {
761
- await hooks.drain();
915
+ // Shared by restartApp (crash/timeout recovery) and the 'process' reset
916
+ // strategy: kill the current app session and either wait for a fresh one
917
+ // to become ready for testFilePath, or (no testFilePath) just launch one.
918
+ const performRestart = async (testFilePath?: string): Promise<void> => {
762
919
  await crashMonitor.stop();
763
- sessionLogger.debug(
764
- 'restarting app (testFile=%s mode=%s)',
765
- testFilePath ?? 'n/a',
766
- testFilePath ? 'stop-and-ensure-ready' : 'direct-restart',
767
- );
768
-
769
920
  await disposeCurrentAppSession();
770
921
 
771
922
  if (testFilePath) {
@@ -777,9 +928,84 @@ export const createHarnessSession = async (
777
928
  crashMonitor.reset();
778
929
  await crashMonitor.start();
779
930
  }
931
+ };
780
932
 
933
+ const restartApp = async (testFilePath?: string): Promise<void> => {
781
934
  await hooks.drain();
782
- sessionLogger.debug('restart completed');
935
+ const reason: 'stop-and-ensure-ready' | 'direct-restart' = testFilePath
936
+ ? 'stop-and-ensure-ready'
937
+ : 'direct-restart';
938
+ sessionLogger.debug(
939
+ 'restarting app (testFile=%s mode=%s)',
940
+ testFilePath ?? 'n/a',
941
+ reason,
942
+ );
943
+ const restartSpan = diagnostics.start('app.restart', { reason });
944
+
945
+ try {
946
+ await performRestart(testFilePath);
947
+ await hooks.drain();
948
+ sessionLogger.debug('restart completed');
949
+ restartSpan.end();
950
+ } catch (error) {
951
+ restartSpan.fail(error);
952
+ throw error;
953
+ }
954
+ };
955
+
956
+ const processResetStrategy = createProcessResetStrategy({
957
+ restart: performRestart,
958
+ });
959
+ const runtimeResetStrategy = createRuntimeResetStrategy({
960
+ getConnection: () => bridge.connection,
961
+ expectDisconnect: crashMonitor.expectDisconnect,
962
+ waitForReconnect: (signal) => waitForNextConnected({ bridge, signal }),
963
+ // Pending rpc.invoke calls reject with AppBridgeDisconnectedError when
964
+ // the app connection drops (see bridge server's disconnect()).
965
+ isDisconnectError: (error) => error instanceof AppBridgeDisconnectedError,
966
+ timeoutMs: runtimeConfig.bundleStartTimeout ?? 60000,
967
+ });
968
+
969
+ const resetEnvironment = async (testFilePath: string): Promise<void> => {
970
+ const requestedKind = resolveResetStrategyKind(
971
+ runtimeConfig.resetEnvironmentBetweenTestFiles,
972
+ );
973
+ if (requestedKind === null) {
974
+ return;
975
+ }
976
+
977
+ await hooks.drain();
978
+ sessionLogger.debug(
979
+ 'resetting environment (testFile=%s kind=%s)',
980
+ testFilePath,
981
+ requestedKind,
982
+ );
983
+
984
+ let escalated = false;
985
+ const strategy = requestedKind === 'runtime'
986
+ ? withEscalation(runtimeResetStrategy, processResetStrategy, {
987
+ onEscalate: (error) => {
988
+ escalated = true;
989
+ sessionLogger.debug(
990
+ 'runtime reset failed, escalating to process restart: %s',
991
+ error,
992
+ );
993
+ },
994
+ })
995
+ : processResetStrategy;
996
+
997
+ const resetSpan = diagnostics.start('app.reset', { requested: requestedKind });
998
+ const performed = () => (escalated ? 'process' : requestedKind);
999
+
1000
+ try {
1001
+ await strategy.reset({ testFilePath });
1002
+ await hooks.drain();
1003
+ sessionLogger.debug('environment reset completed');
1004
+ resetSpan.end({ performed: performed(), escalated });
1005
+ } catch (error) {
1006
+ resetSpan.fail(error, { performed: performed(), escalated });
1007
+ throw error;
1008
+ }
783
1009
  };
784
1010
 
785
1011
  const runTestFile = async (
@@ -835,6 +1061,7 @@ export const createHarnessSession = async (
835
1061
  return {
836
1062
  config: runtimeConfig,
837
1063
  context,
1064
+ diagnostics,
838
1065
  onTestRunnerEvent: (listener) => {
839
1066
  testRunnerEventListeners.add(listener);
840
1067
  return () => {
@@ -844,25 +1071,36 @@ export const createHarnessSession = async (
844
1071
  runTestFile,
845
1072
  ensureAppReady,
846
1073
  restartApp,
1074
+ resetEnvironment,
847
1075
  resetCrashState: () => crashMonitor.reset(),
848
1076
  flushClientLogs,
849
1077
  callHook: async (name, payload) => {
850
1078
  await hooks.drain();
851
- await pluginManager.callHook(name, payload);
1079
+ await diagnostics.measure(
1080
+ 'plugin.hook',
1081
+ () => pluginManager.callHook(name, payload),
1082
+ { hook: String(name) }
1083
+ );
852
1084
  await hooks.drain();
853
1085
  },
854
1086
  setRunState: (state) => {
855
1087
  currentRun = state;
856
1088
  },
857
- dispose: (reason = 'normal') => {
858
- process.off('SIGTERM', onSignal);
859
- process.off('SIGINT', onSignal);
860
- return dispose(reason);
1089
+ dispose: async (reason = 'normal') => {
1090
+ try {
1091
+ await dispose(reason);
1092
+ } finally {
1093
+ process.off('SIGTERM', onSignal);
1094
+ process.off('SIGINT', onSignal);
1095
+ }
861
1096
  },
862
1097
  };
863
1098
  } catch (error) {
864
1099
  process.off('SIGTERM', onEarlySignal);
865
1100
  process.off('SIGINT', onEarlySignal);
1101
+ // Cancels any inflight init work still holding sessionController.signal
1102
+ // (e.g. a platform init call abandoned after a readiness timeout race).
1103
+ sessionController.abort();
866
1104
  await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
867
1105
  throw error;
868
1106
  }