@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,18 +1,21 @@
1
- import { createHarnessBridge, } from '@react-native-harness/bridge/server';
1
+ import { createHarnessBridge, AppBridgeDisconnectedError, } from '@react-native-harness/bridge/server';
2
2
  import { HARNESS_BRIDGE_PATH, } from '@react-native-harness/bridge';
3
- import { getMetroInstance, isMetroCacheReusable, waitForMetroBackedAppReady, } from '@react-native-harness/bundler-metro';
3
+ import { createHarnessCache } from '@react-native-harness/cache';
4
+ import { getMetroInstance, waitForMetroBackedAppReady, } from '@react-native-harness/bundler-metro';
4
5
  import { createHarnessPluginManager, } from '@react-native-harness/plugins';
5
- import { createCrashArtifactWriter, logger, getTimeoutSignal, raceAbortSignals, } from '@react-native-harness/tools';
6
- import { getConfig, ConfigSchema, } from '@react-native-harness/config';
6
+ import { createCrashArtifactWriter, createDiagnostics, logger, getTimeoutSignal, ensureSignalsDeliverable, waitForAbort, } from '@react-native-harness/tools';
7
+ import { getConfig, isDiagnosticsEnabled, resolveMetroCacheEnabled, ConfigSchema, } from '@react-native-harness/config';
7
8
  import { preRunMessage } from 'jest-util';
8
9
  import path from 'node:path';
9
10
  import { PlatformReadyTimeoutError } from './errors.js';
10
11
  import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
11
12
  import { createCrashMonitor } from './crash-monitor.js';
13
+ import { createProcessResetStrategy, createRuntimeResetStrategy, resolveResetStrategyKind, withEscalation, } from './environment-reset.js';
12
14
  import { createHookQueue } from './hook-queue.js';
13
15
  import { createClientLogCollector, } from './client-log-handler.js';
14
16
  import { createActionHooksPlugin } from './action-hooks.js';
15
17
  import { createResourceLockManager, } from './resource-lock.js';
18
+ import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
16
19
  import { resolveHarnessMetroPort } from './metro-port.js';
17
20
  import { getAdditionalCliArgs } from './cli-args.js';
18
21
  import { logMetroCacheReused, logMetroPortFallback, logNativeCoverageCollected, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, logTestEnvironmentReady, logTestRunHeader, } from './logs.js';
@@ -41,45 +44,69 @@ export const waitForBridgeDisconnectOrTimeout = async ({ bridge, connection, tim
41
44
  bridge.on('disconnected', onDisconnected);
42
45
  });
43
46
  };
44
- // ---------------------------------------------------------------------------
45
- // Internal helpers
46
- // ---------------------------------------------------------------------------
47
- const createAbortError = () => new DOMException('The operation was aborted', 'AbortError');
48
- const waitForAbort = (signal) => {
47
+ // Resolves the NEXT time the bridge reports a 'connected' event, rejecting
48
+ // if `signal` aborts first. Deliberately does not use bridge.nextConnection(),
49
+ // which would resolve immediately for a stale, already-set connection.
50
+ export const waitForNextConnected = ({ bridge, signal, }) => new Promise((resolve, reject) => {
51
+ const onConnected = () => { cleanup(); resolve(); };
52
+ const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
53
+ const cleanup = () => {
54
+ bridge.off('connected', onConnected);
55
+ signal.removeEventListener('abort', onAbort);
56
+ };
49
57
  if (signal.aborted) {
50
- return Promise.reject(signal.reason ?? createAbortError());
58
+ onAbort();
59
+ return;
51
60
  }
52
- return new Promise((_, reject) => {
53
- signal.addEventListener('abort', () => reject(signal.reason ?? createAbortError()), { once: true });
54
- });
61
+ bridge.on('connected', onConnected);
62
+ signal.addEventListener('abort', onAbort, { once: true });
63
+ });
64
+ export const getSignalExitCodeForRunState = (state) => {
65
+ if (state?.completed &&
66
+ state.status === 'passed' &&
67
+ !state.error &&
68
+ (state.summary?.failed ?? 0) === 0) {
69
+ return 0;
70
+ }
71
+ return 1;
55
72
  };
56
- const withPlatformReadyTimeout = async (options) => {
73
+ // ---------------------------------------------------------------------------
74
+ // Internal helpers
75
+ // ---------------------------------------------------------------------------
76
+ // Bounds only the init *call* with a readiness timeout; `options.signal` (the
77
+ // session-lifetime signal) is passed to `work` unmodified, so a slow init
78
+ // call keeps observing real session teardown, not a synthetic timeout abort.
79
+ // On timeout this throws PlatformReadyTimeoutError without cancelling `work`
80
+ // itself — the caller is expected to abort the session signal as part of
81
+ // tearing down, which then cancels the still-inflight call cooperatively.
82
+ export const withPlatformReadyTimeout = async (options) => {
57
83
  const timeoutSignal = getTimeoutSignal(options.timeout);
58
- const combinedSignal = raceAbortSignals([options.signal, timeoutSignal]);
84
+ const abortWait = waitForAbort(timeoutSignal);
59
85
  try {
60
- return await options.work(combinedSignal);
86
+ return await Promise.race([
87
+ options.work(options.signal),
88
+ abortWait.promise.catch(() => {
89
+ throw new PlatformReadyTimeoutError(options.timeout);
90
+ }),
91
+ ]);
61
92
  }
62
- catch (error) {
63
- if (error instanceof DOMException &&
64
- error.name === 'AbortError' &&
65
- timeoutSignal.aborted &&
66
- !options.signal.aborted) {
67
- throw new PlatformReadyTimeoutError(options.timeout);
68
- }
69
- throw error;
93
+ finally {
94
+ abortWait.cancel();
70
95
  }
71
96
  };
72
97
  export const waitForStartupCrash = async ({ crashMonitor, detectNativeCrashes, testFilePath, signal, }) => {
73
98
  if (!detectNativeCrashes) {
74
- return await waitForAbort(signal);
99
+ return await waitForAbort(signal).promise;
75
100
  }
76
101
  const watch = crashMonitor.watch(testFilePath, 'startup');
77
102
  watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
103
+ const abortWait = waitForAbort(signal);
78
104
  try {
79
- return await Promise.race([watch.promise, waitForAbort(signal)]);
105
+ return await Promise.race([watch.promise, abortWait.promise]);
80
106
  }
81
107
  finally {
82
108
  watch.cancel();
109
+ abortWait.cancel();
83
110
  }
84
111
  };
85
112
  const waitForAppReady = async (base, testFilePath) => {
@@ -104,20 +131,7 @@ const waitForAppReady = async (base, testFilePath) => {
104
131
  // is already set. waitForReady is called before startAttempt, so a stale
105
132
  // connection from a previous run would resolve the promise before startAttempt
106
133
  // even restarts the app — leaving bridge.connection null after the restart.
107
- await new Promise((resolve, reject) => {
108
- const onConnected = () => { cleanup(); resolve(); };
109
- const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
110
- const cleanup = () => {
111
- bridge.off('connected', onConnected);
112
- signal.removeEventListener('abort', onAbort);
113
- };
114
- if (signal.aborted) {
115
- onAbort();
116
- return;
117
- }
118
- bridge.on('connected', onConnected);
119
- signal.addEventListener('abort', onAbort, { once: true });
120
- });
134
+ await waitForNextConnected({ bridge, signal });
121
135
  logWait('runtime ready received');
122
136
  },
123
137
  waitForCrash: async (signal) => {
@@ -184,9 +198,23 @@ const applyEnvVars = (harnessConfig, globalConfig) => {
184
198
  process.env.RN_HARNESS_VIEW_FLATTENING = 'false';
185
199
  }
186
200
  };
201
+ /**
202
+ * Logs the "Reusing Metro cache" message when persistent Metro caching is
203
+ * enabled and the cache is warm. `projectRoot` must be the config-resolved
204
+ * project root so the warmth check looks at the same directory Metro caches
205
+ * into.
206
+ */
207
+ export const maybeLogMetroCacheReused = (options) => {
208
+ if (!resolveMetroCacheEnabled(options.config)) {
209
+ return;
210
+ }
211
+ if (createHarnessCache({ projectRoot: options.projectRoot }).isWarm('metro')) {
212
+ logMetroCacheReused(options.platform);
213
+ }
214
+ };
187
215
  const loadConfig = async (globalConfig) => {
188
216
  const projectRoot = globalConfig.rootDir;
189
- const { config: rawConfig } = await getConfig(projectRoot);
217
+ const { config: rawConfig, projectRoot: configProjectRoot } = await getConfig(projectRoot);
190
218
  const cliArgs = getAdditionalCliArgs();
191
219
  let harnessConfig = cliArgs.metroPort != null
192
220
  ? ConfigSchema.parse({ ...rawConfig, metroPort: cliArgs.metroPort })
@@ -207,29 +235,40 @@ const loadConfig = async (globalConfig) => {
207
235
  const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName);
208
236
  if (!platform)
209
237
  throw new RunnerNotFoundError(selectedRunnerName);
210
- return { harnessConfig, platform, projectRoot };
238
+ return { harnessConfig, platform, projectRoot, configProjectRoot };
211
239
  };
212
240
  // ---------------------------------------------------------------------------
213
241
  // Session creation
214
242
  // ---------------------------------------------------------------------------
215
243
  export const createHarnessSession = async (globalConfig, { lockManager = defaultResourceLockManager } = {}) => {
216
244
  preRunMessage.remove(process.stderr);
217
- const { harnessConfig, platform, projectRoot } = await loadConfig(globalConfig);
245
+ const setupStartedAt = Date.now();
246
+ const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
218
247
  applyEnvVars(harnessConfig, globalConfig);
248
+ const diagnostics = createDiagnostics({
249
+ enabled: isDiagnosticsEnabled(harnessConfig),
250
+ });
251
+ diagnostics.record({
252
+ label: 'session.config.load',
253
+ duration: Date.now() - setupStartedAt,
254
+ });
219
255
  sessionLogger.debug('creating session for runner=%s platform=%s', platform.name, platform.platformId);
220
- // Single AbortController for the entire setup phase. Registered signal
221
- // handlers abort this so that slow inflight operations (Metro init, platform
222
- // runner startup) are cancelled promptly on SIGTERM/SIGINT.
223
- const setupController = new AbortController();
224
- const onEarlySignal = () => setupController.abort();
256
+ // Single, session-lifetime AbortController: it aborts on early SIGTERM/SIGINT
257
+ // during setup (below) and, once the session is up, in dispose()'s finally
258
+ // (unchanged lifetime). It is the one signal the plugin manager and platform
259
+ // runner see for the entire session, so its abort reaches every long-lived
260
+ // child the runner threads it into.
261
+ ensureSignalsDeliverable();
262
+ const sessionController = new AbortController();
263
+ const onEarlySignal = () => sessionController.abort();
225
264
  process.once('SIGTERM', onEarlySignal);
226
265
  process.once('SIGINT', onEarlySignal);
227
266
  const resourceLockKey = await (platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform));
228
267
  let didWaitForResourceLock = false;
229
268
  let lastStillWaitingLogAt = 0;
230
269
  logTestRunHeader(platform);
231
- const resourceLease = await lockManager.acquire(resourceLockKey, {
232
- signal: setupController.signal,
270
+ const resourceLease = await diagnostics.measure('session.lock.wait', () => lockManager.acquire(resourceLockKey, {
271
+ signal: sessionController.signal,
233
272
  onWait: () => {
234
273
  didWaitForResourceLock = true;
235
274
  logRunnerWaitingInQueue(platform);
@@ -242,7 +281,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
242
281
  logRunnerStillWaitingInQueue(platform);
243
282
  sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
244
283
  },
245
- });
284
+ }), { lockKey: resourceLockKey });
246
285
  if (didWaitForResourceLock)
247
286
  logRunnerStarting(platform);
248
287
  sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
@@ -250,26 +289,39 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
250
289
  // port resolution but before the inner try/catch (e.g. bridge creation failure).
251
290
  let metroPortLease = null;
252
291
  try {
253
- const resolution = await resolveHarnessMetroPort({
254
- config: harnessConfig,
255
- platform,
256
- resourceLockManager: lockManager,
257
- signal: setupController.signal,
292
+ const portResolveSpan = diagnostics.start('session.port.resolve');
293
+ let resolution;
294
+ try {
295
+ resolution = await resolveHarnessMetroPort({
296
+ config: harnessConfig,
297
+ platform,
298
+ resourceLockManager: lockManager,
299
+ signal: sessionController.signal,
300
+ });
301
+ }
302
+ catch (error) {
303
+ portResolveSpan.fail(error);
304
+ throw error;
305
+ }
306
+ portResolveSpan.end({
307
+ port: resolution.config.metroPort,
308
+ didFallback: resolution.didFallback,
258
309
  });
259
310
  metroPortLease = resolution.metroPortLease;
260
311
  const { config: runtimeConfig, initialMetroPort, didFallback } = resolution;
261
312
  if (didFallback)
262
313
  logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
263
- if (runtimeConfig.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) {
264
- logMetroCacheReused(platform);
265
- }
266
- const pluginAbortController = new AbortController();
314
+ maybeLogMetroCacheReused({
315
+ config: runtimeConfig,
316
+ projectRoot: configProjectRoot,
317
+ platform,
318
+ });
267
319
  const pluginManager = createHarnessPluginManager({
268
320
  plugins: (runtimeConfig.plugins ?? []),
269
321
  projectRoot,
270
322
  config: runtimeConfig,
271
323
  runner: platform,
272
- abortSignal: pluginAbortController.signal,
324
+ abortSignal: sessionController.signal,
273
325
  });
274
326
  const hooks = createHookQueue();
275
327
  let currentRun = null;
@@ -281,43 +333,68 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
281
333
  platformId: platform.platformId,
282
334
  rootDir: path.join(projectRoot, '.harness', 'crash-reports'),
283
335
  });
284
- const bridge = await createHarnessBridge({
336
+ const bridge = await diagnostics.measure('session.bridge.init', () => createHarnessBridge({
285
337
  noServer: true,
286
338
  timeout: runtimeConfig.bridgeTimeout,
287
339
  context,
288
- });
340
+ }));
289
341
  sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
290
342
  let metroInstance;
291
343
  let platformInstance;
344
+ // Noop until the Metro instance resolves and the deriver is attached.
345
+ let disposeMetroDiagnostics = () => undefined;
292
346
  try {
293
347
  [metroInstance, platformInstance] = await Promise.all([
294
- getMetroInstance({
348
+ diagnostics.measure('metro.init', () => getMetroInstance({
295
349
  projectRoot,
296
350
  harnessConfig: runtimeConfig,
297
351
  websocketEndpoints: {
298
352
  [HARNESS_BRIDGE_PATH]: bridge.ws,
299
353
  },
300
- }, setupController.signal).then((instance) => {
354
+ watchMode: globalConfig.watch || globalConfig.watchAll,
355
+ }, sessionController.signal).then((instance) => {
301
356
  sessionLogger.debug('Metro initialized');
357
+ // Attach the Metro diagnostics deriver before the eager prewarm
358
+ // fires so its bundle build/request events land in the trace.
359
+ disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
360
+ if (runtimeConfig.eagerPrewarm !== false) {
361
+ // Kick off the first bundle build while the platform is still booting.
362
+ // prewarm() memoizes this first call, so its session-lifetime setup
363
+ // signal is the one that matters — later callers' signals cannot
364
+ // cancel it. Not awaited: metro.init must resolve immediately.
365
+ const prewarmSpan = diagnostics.start('metro.prewarm');
366
+ instance
367
+ .prewarm({
368
+ platform: platform.platformId,
369
+ signal: sessionController.signal,
370
+ })
371
+ .then((completed) => prewarmSpan.end({ completed }))
372
+ // Swallow AbortError on teardown so it never becomes an
373
+ // unhandled rejection; non-abort failures resolve false.
374
+ .catch((error) => prewarmSpan.fail(error));
375
+ }
302
376
  return instance;
303
- }),
304
- withPlatformReadyTimeout({
377
+ })),
378
+ diagnostics.measure('platform.init', () => withPlatformReadyTimeout({
305
379
  timeout: runtimeConfig.platformReadyTimeout,
306
- signal: setupController.signal,
380
+ signal: sessionController.signal,
307
381
  work: async (signal) => {
308
382
  return await import(platform.runner).then((module) => module.default(platform.config, runtimeConfig, {
309
383
  signal,
310
384
  crashArtifactWriter,
385
+ diagnostics,
311
386
  })).then((instance) => {
312
387
  sessionLogger.debug('platform runner initialized');
313
388
  return instance;
314
389
  });
315
390
  },
316
- }),
391
+ }), { runner: platform.runner, platformId: platform.platformId }),
317
392
  ]);
318
393
  }
319
394
  catch (error) {
320
- // Only bridge needs cleanup here; leases are released by the outer catch.
395
+ // Only bridge and the Metro diagnostics listener need cleanup here;
396
+ // leases are released by the outer catch.
397
+ disposeMetroDiagnostics();
321
398
  await bridge.dispose();
322
399
  throw error;
323
400
  }
@@ -335,7 +412,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
335
412
  const restartAppSession = async () => {
336
413
  await crashMonitor.stop();
337
414
  await disposeCurrentAppSession();
338
- const session = await platformInstance.createAppSession(appLaunchOptions);
415
+ const session = await diagnostics.measure('app.session.create', () => platformInstance.createAppSession(appLaunchOptions));
339
416
  currentAppSession = session;
340
417
  crashMonitor.setAppSession(session);
341
418
  await crashMonitor.start();
@@ -398,11 +475,13 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
398
475
  if (runtimeConfig.forwardClientLogs) {
399
476
  metroInstance.events.addListener(clientLogListener);
400
477
  }
478
+ const disposeBridgeDiagnostics = observeBridgeEvents(bridge, diagnostics);
401
479
  sessionLogger.debug('registered runtime, bridge, and Metro listeners');
402
480
  // --- Dispose ---
403
481
  let disposePromise = null;
404
482
  const disposeOnce = async (reason) => {
405
483
  sessionLogger.debug('disposing session (reason=%s)', reason);
484
+ const disposeSpan = diagnostics.start('dispose.total', { reason });
406
485
  let hookError;
407
486
  try {
408
487
  await hooks.drain();
@@ -434,6 +513,8 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
434
513
  bridge.off('disconnected', onDisconnected);
435
514
  bridge.off('event', bridgeEventListener);
436
515
  bridge.off('event', onTestRunnerEvent);
516
+ disposeMetroDiagnostics();
517
+ disposeBridgeDiagnostics();
437
518
  const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios;
438
519
  if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) {
439
520
  try {
@@ -453,9 +534,13 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
453
534
  }
454
535
  let cleanupError;
455
536
  try {
537
+ // The app session (and its logcat/device log stream) must be fully
538
+ // torn down before the platform is disposed: platform disposal can
539
+ // shut down the device/emulator, and doing that concurrently with
540
+ // the app session's own cleanup races the device's log stream
541
+ // against the device disappearing out from under it.
542
+ await Promise.all([crashMonitor.dispose(), disposeCurrentAppSession()]);
456
543
  await Promise.all([
457
- crashMonitor.dispose(),
458
- disposeCurrentAppSession(),
459
544
  bridge.dispose(),
460
545
  platformInstance.dispose(),
461
546
  metroInstance.dispose(),
@@ -467,13 +552,18 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
467
552
  }
468
553
  finally {
469
554
  await resourceLease.release();
470
- pluginAbortController.abort();
555
+ sessionController.abort();
471
556
  }
472
557
  sessionLogger.debug('session resources disposed');
473
- if (hookError)
558
+ if (hookError) {
559
+ disposeSpan.fail(hookError);
474
560
  throw hookError;
475
- if (cleanupError)
561
+ }
562
+ if (cleanupError) {
563
+ disposeSpan.fail(cleanupError);
476
564
  throw cleanupError;
565
+ }
566
+ disposeSpan.end();
477
567
  };
478
568
  const dispose = (reason = 'normal') => {
479
569
  disposePromise ??= disposeOnce(reason);
@@ -483,7 +573,10 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
483
573
  // that all infrastructure is up.
484
574
  process.off('SIGTERM', onEarlySignal);
485
575
  process.off('SIGINT', onEarlySignal);
486
- const onSignal = () => void dispose('abort').then(() => process.exit(0));
576
+ const onSignal = () => {
577
+ const exitCode = getSignalExitCodeForRunState(currentRun);
578
+ void dispose('abort').then(() => process.exit(exitCode), () => process.exit(1));
579
+ };
487
580
  process.once('SIGTERM', onSignal);
488
581
  process.once('SIGINT', onSignal);
489
582
  // --- Startup hooks ---
@@ -500,28 +593,42 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
500
593
  throw error;
501
594
  }
502
595
  logTestEnvironmentReady(platform);
596
+ diagnostics.record({
597
+ label: 'session.setup',
598
+ duration: Date.now() - setupStartedAt,
599
+ });
503
600
  sessionLogger.debug('session ready');
504
601
  // --- Public API ---
505
602
  const ensureAppReady = async (testFilePath) => {
506
603
  await hooks.drain();
507
604
  sessionLogger.debug('ensuring app is ready for %s', testFilePath);
605
+ const appReadySpan = diagnostics.start('app.ready.total');
508
606
  if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) {
509
607
  const state = await currentAppSession.getState();
510
608
  if (state.status === 'running') {
511
609
  sessionLogger.debug('reusing existing ready app for %s', testFilePath);
610
+ appReadySpan.end({ file: testFilePath, reused: true });
512
611
  return;
513
612
  }
514
613
  }
515
614
  crashMonitor.reset();
516
615
  sessionLogger.debug('app not ready, waiting for launch and runtime readiness');
517
- await waitForAppReady(appReadyBaseOptions, testFilePath);
616
+ try {
617
+ await waitForAppReady(appReadyBaseOptions, testFilePath);
618
+ }
619
+ catch (error) {
620
+ appReadySpan.fail(error, { file: testFilePath, reused: false });
621
+ throw error;
622
+ }
518
623
  await hooks.drain();
519
624
  sessionLogger.debug('app is ready for %s', testFilePath);
625
+ appReadySpan.end({ file: testFilePath, reused: false });
520
626
  };
521
- const restartApp = async (testFilePath) => {
522
- await hooks.drain();
627
+ // Shared by restartApp (crash/timeout recovery) and the 'process' reset
628
+ // strategy: kill the current app session and either wait for a fresh one
629
+ // to become ready for testFilePath, or (no testFilePath) just launch one.
630
+ const performRestart = async (testFilePath) => {
523
631
  await crashMonitor.stop();
524
- sessionLogger.debug('restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', testFilePath ? 'stop-and-ensure-ready' : 'direct-restart');
525
632
  await disposeCurrentAppSession();
526
633
  if (testFilePath) {
527
634
  await ensureAppReady(testFilePath);
@@ -533,8 +640,65 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
533
640
  crashMonitor.reset();
534
641
  await crashMonitor.start();
535
642
  }
643
+ };
644
+ const restartApp = async (testFilePath) => {
645
+ await hooks.drain();
646
+ const reason = testFilePath
647
+ ? 'stop-and-ensure-ready'
648
+ : 'direct-restart';
649
+ sessionLogger.debug('restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', reason);
650
+ const restartSpan = diagnostics.start('app.restart', { reason });
651
+ try {
652
+ await performRestart(testFilePath);
653
+ await hooks.drain();
654
+ sessionLogger.debug('restart completed');
655
+ restartSpan.end();
656
+ }
657
+ catch (error) {
658
+ restartSpan.fail(error);
659
+ throw error;
660
+ }
661
+ };
662
+ const processResetStrategy = createProcessResetStrategy({
663
+ restart: performRestart,
664
+ });
665
+ const runtimeResetStrategy = createRuntimeResetStrategy({
666
+ getConnection: () => bridge.connection,
667
+ expectDisconnect: crashMonitor.expectDisconnect,
668
+ waitForReconnect: (signal) => waitForNextConnected({ bridge, signal }),
669
+ // Pending rpc.invoke calls reject with AppBridgeDisconnectedError when
670
+ // the app connection drops (see bridge server's disconnect()).
671
+ isDisconnectError: (error) => error instanceof AppBridgeDisconnectedError,
672
+ timeoutMs: runtimeConfig.bundleStartTimeout ?? 60000,
673
+ });
674
+ const resetEnvironment = async (testFilePath) => {
675
+ const requestedKind = resolveResetStrategyKind(runtimeConfig.resetEnvironmentBetweenTestFiles);
676
+ if (requestedKind === null) {
677
+ return;
678
+ }
536
679
  await hooks.drain();
537
- sessionLogger.debug('restart completed');
680
+ sessionLogger.debug('resetting environment (testFile=%s kind=%s)', testFilePath, requestedKind);
681
+ let escalated = false;
682
+ const strategy = requestedKind === 'runtime'
683
+ ? withEscalation(runtimeResetStrategy, processResetStrategy, {
684
+ onEscalate: (error) => {
685
+ escalated = true;
686
+ sessionLogger.debug('runtime reset failed, escalating to process restart: %s', error);
687
+ },
688
+ })
689
+ : processResetStrategy;
690
+ const resetSpan = diagnostics.start('app.reset', { requested: requestedKind });
691
+ const performed = () => (escalated ? 'process' : requestedKind);
692
+ try {
693
+ await strategy.reset({ testFilePath });
694
+ await hooks.drain();
695
+ sessionLogger.debug('environment reset completed');
696
+ resetSpan.end({ performed: performed(), escalated });
697
+ }
698
+ catch (error) {
699
+ resetSpan.fail(error, { performed: performed(), escalated });
700
+ throw error;
701
+ }
538
702
  };
539
703
  const runTestFile = async (testPath, options) => {
540
704
  await hooks.drain();
@@ -583,6 +747,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
583
747
  return {
584
748
  config: runtimeConfig,
585
749
  context,
750
+ diagnostics,
586
751
  onTestRunnerEvent: (listener) => {
587
752
  testRunnerEventListeners.add(listener);
588
753
  return () => {
@@ -592,26 +757,34 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
592
757
  runTestFile,
593
758
  ensureAppReady,
594
759
  restartApp,
760
+ resetEnvironment,
595
761
  resetCrashState: () => crashMonitor.reset(),
596
762
  flushClientLogs,
597
763
  callHook: async (name, payload) => {
598
764
  await hooks.drain();
599
- await pluginManager.callHook(name, payload);
765
+ await diagnostics.measure('plugin.hook', () => pluginManager.callHook(name, payload), { hook: String(name) });
600
766
  await hooks.drain();
601
767
  },
602
768
  setRunState: (state) => {
603
769
  currentRun = state;
604
770
  },
605
- dispose: (reason = 'normal') => {
606
- process.off('SIGTERM', onSignal);
607
- process.off('SIGINT', onSignal);
608
- return dispose(reason);
771
+ dispose: async (reason = 'normal') => {
772
+ try {
773
+ await dispose(reason);
774
+ }
775
+ finally {
776
+ process.off('SIGTERM', onSignal);
777
+ process.off('SIGINT', onSignal);
778
+ }
609
779
  },
610
780
  };
611
781
  }
612
782
  catch (error) {
613
783
  process.off('SIGTERM', onEarlySignal);
614
784
  process.off('SIGINT', onEarlySignal);
785
+ // Cancels any inflight init work still holding sessionController.signal
786
+ // (e.g. a platform init call abandoned after a readiness timeout race).
787
+ sessionController.abort();
615
788
  await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
616
789
  throw error;
617
790
  }
package/dist/run.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,KAAK,EACV,eAAe,IAAI,sBAAsB,EAE1C,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAuD3D,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,aAAa,EAAE,UAAU,CAAC,aAAa,CAAC;IACxC,YAAY,EAAE,UAAU,CAAC,YAAY,CAAC;CACvC,KAAK,OAAO,CAAC;IACZ,UAAU,EAAE,cAAc,CAAC;IAC3B,aAAa,EAAE,sBAAsB,CAAC;IACtC,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH,eAAO,MAAM,kBAAkB,EAAE,kBAmFhC,CAAC"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,KAAK,EACV,eAAe,IAAI,sBAAsB,EAE1C,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAmE3D,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,aAAa,EAAE,UAAU,CAAC,aAAa,CAAC;IACxC,YAAY,EAAE,UAAU,CAAC,YAAY,CAAC;CACvC,KAAK,OAAO,CAAC;IACZ,UAAU,EAAE,cAAc,CAAC;IAC3B,aAAa,EAAE,sBAAsB,CAAC;IACtC,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH,eAAO,MAAM,kBAAkB,EAAE,kBA0FhC,CAAC"}
package/dist/run.js CHANGED
@@ -1,9 +1,20 @@
1
1
  import path from 'node:path';
2
2
  import { formatResultsErrors } from 'jest-message-util';
3
3
  import { toTestResult } from './toTestResult.js';
4
+ import { formatHarnessErrorMessage } from './format-harness-error.js';
4
5
  // Helper function to flatten nested test suites into a flat array of tests with hierarchy
5
6
  const flattenTests = (suiteResult, ancestorTitles = []) => {
6
7
  const tests = [];
8
+ if (suiteResult.error) {
9
+ tests.push({
10
+ name: suiteResult.name,
11
+ status: 'failed',
12
+ duration: suiteResult.duration,
13
+ error: suiteResult.error,
14
+ ancestorTitles: [...ancestorTitles],
15
+ fullName: [...ancestorTitles, suiteResult.name].join(' '),
16
+ });
17
+ }
7
18
  // Add tests from current suite with current hierarchy
8
19
  for (const test of suiteResult.tests) {
9
20
  tests.push({
@@ -48,10 +59,16 @@ export const runHarnessTestFile = async ({ testPath, globalConfig, projectConfig
48
59
  // Extract setup files from Jest config and convert to relative paths
49
60
  const setupFiles = projectConfig.setupFiles?.map((setupFile) => path.relative(globalConfig.rootDir, setupFile));
50
61
  const setupFilesAfterEnv = projectConfig.setupFilesAfterEnv?.map((setupFile) => path.relative(globalConfig.rootDir, setupFile));
62
+ const testTimeout = session.config.testTimeout ??
63
+ projectConfig
64
+ .testTimeout ??
65
+ globalConfig
66
+ .testTimeout;
51
67
  const harnessResult = await session.runTestFile(relativeTestPath, {
52
68
  testNamePattern: globalConfig.testNamePattern,
53
69
  setupFiles,
54
70
  setupFilesAfterEnv,
71
+ testTimeout,
55
72
  runner: session.context.platform.runner,
56
73
  });
57
74
  const end = Date.now();
@@ -59,13 +76,13 @@ export const runHarnessTestFile = async ({ testPath, globalConfig, projectConfig
59
76
  const stats = calculateStats(allTests);
60
77
  // Convert TestResult[] to the format expected by toTestResult
61
78
  const tests = allTests.map((test) => {
62
- const errorMessage = test.error?.message;
63
79
  const codeFrame = test.error?.codeFrame;
80
+ const errorMessage = formatHarnessErrorMessage(test.error, {
81
+ testStartedAt: test.startedAt,
82
+ });
64
83
  return {
65
84
  duration: test.duration,
66
- errorMessage: errorMessage
67
- ? `${errorMessage}${codeFrame ? `\n\n${codeFrame.content}` : ''}`
68
- : undefined,
85
+ errorMessage,
69
86
  title: test.name,
70
87
  fullName: test.fullName,
71
88
  status: test.status,