@react-native-harness/jest 1.3.0 → 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 (98) hide show
  1. package/dist/__tests__/bridge.test.js +29 -4
  2. package/dist/__tests__/crash-monitor.test.js +194 -176
  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__/errors.test.js +39 -0
  7. package/dist/__tests__/execute-run.test.js +439 -29
  8. package/dist/__tests__/format-harness-error.test.d.ts +2 -0
  9. package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
  10. package/dist/__tests__/format-harness-error.test.js +27 -0
  11. package/dist/__tests__/harness-cache.test.js +62 -41
  12. package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
  13. package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
  14. package/dist/__tests__/harness-session-signal.test.js +298 -0
  15. package/dist/__tests__/harness-session.test.js +217 -234
  16. package/dist/__tests__/index.test.js +62 -11
  17. package/dist/__tests__/metro-port.test.js +2 -0
  18. package/dist/__tests__/run.test.d.ts +2 -0
  19. package/dist/__tests__/run.test.d.ts.map +1 -0
  20. package/dist/__tests__/run.test.js +48 -0
  21. package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
  22. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
  23. package/dist/__tests__/test-file-platform-filter.test.js +47 -0
  24. package/dist/crash-monitor.d.ts +12 -4
  25. package/dist/crash-monitor.d.ts.map +1 -1
  26. package/dist/crash-monitor.js +135 -102
  27. package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
  28. package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
  29. package/dist/diagnostics/__tests__/derivers.test.js +248 -0
  30. package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
  31. package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
  32. package/dist/diagnostics/__tests__/report.test.js +97 -0
  33. package/dist/diagnostics/derivers.d.ts +24 -0
  34. package/dist/diagnostics/derivers.d.ts.map +1 -0
  35. package/dist/diagnostics/derivers.js +146 -0
  36. package/dist/diagnostics/index.d.ts +3 -0
  37. package/dist/diagnostics/index.d.ts.map +1 -0
  38. package/dist/diagnostics/index.js +2 -0
  39. package/dist/diagnostics/report.d.ts +7 -0
  40. package/dist/diagnostics/report.d.ts.map +1 -0
  41. package/dist/diagnostics/report.js +84 -0
  42. package/dist/environment-reset.d.ts +51 -0
  43. package/dist/environment-reset.d.ts.map +1 -0
  44. package/dist/environment-reset.js +83 -0
  45. package/dist/errors.d.ts +10 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +41 -2
  48. package/dist/execute-run.d.ts +4 -2
  49. package/dist/execute-run.d.ts.map +1 -1
  50. package/dist/execute-run.js +179 -49
  51. package/dist/format-harness-error.d.ts +5 -0
  52. package/dist/format-harness-error.d.ts.map +1 -0
  53. package/dist/format-harness-error.js +43 -0
  54. package/dist/harness-session.d.ts +46 -1
  55. package/dist/harness-session.d.ts.map +1 -1
  56. package/dist/harness-session.js +386 -118
  57. package/dist/index.d.ts +7 -3
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +24 -2
  60. package/dist/run.d.ts.map +1 -1
  61. package/dist/run.js +22 -4
  62. package/dist/test-file-platform-filter.d.ts +5 -0
  63. package/dist/test-file-platform-filter.d.ts.map +1 -0
  64. package/dist/test-file-platform-filter.js +40 -0
  65. package/dist/toTestResult.d.ts +1 -0
  66. package/dist/toTestResult.d.ts.map +1 -1
  67. package/dist/toTestResult.js +2 -2
  68. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  69. package/package.json +8 -7
  70. package/src/__tests__/bridge.test.ts +45 -5
  71. package/src/__tests__/crash-monitor.test.ts +234 -199
  72. package/src/__tests__/environment-reset.test.ts +308 -0
  73. package/src/__tests__/errors.test.ts +75 -0
  74. package/src/__tests__/execute-run.test.ts +561 -37
  75. package/src/__tests__/format-harness-error.test.ts +32 -0
  76. package/src/__tests__/harness-cache.test.ts +91 -0
  77. package/src/__tests__/harness-session.test.ts +303 -0
  78. package/src/__tests__/index.test.ts +92 -12
  79. package/src/__tests__/metro-port.test.ts +2 -0
  80. package/src/__tests__/run.test.ts +74 -0
  81. package/src/__tests__/test-file-platform-filter.test.ts +83 -0
  82. package/src/crash-monitor.ts +220 -131
  83. package/src/diagnostics/__tests__/derivers.test.ts +300 -0
  84. package/src/diagnostics/__tests__/report.test.ts +128 -0
  85. package/src/diagnostics/derivers.ts +170 -0
  86. package/src/diagnostics/index.ts +2 -0
  87. package/src/diagnostics/report.ts +124 -0
  88. package/src/environment-reset.ts +138 -0
  89. package/src/errors.ts +62 -1
  90. package/src/execute-run.ts +230 -57
  91. package/src/format-harness-error.ts +73 -0
  92. package/src/harness-session.ts +535 -162
  93. package/src/index.ts +40 -11
  94. package/src/run.ts +24 -4
  95. package/src/test-file-platform-filter.ts +57 -0
  96. package/src/toTestResult.ts +3 -2
  97. package/tsconfig.json +3 -0
  98. package/tsconfig.lib.json +3 -0
@@ -1,53 +1,116 @@
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 { logger, createCrashArtifactWriter, 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';
9
+ import path from 'node:path';
8
10
  import { PlatformReadyTimeoutError } from './errors.js';
9
11
  import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
10
12
  import { createCrashMonitor } from './crash-monitor.js';
13
+ import { createProcessResetStrategy, createRuntimeResetStrategy, resolveResetStrategyKind, withEscalation, } from './environment-reset.js';
11
14
  import { createHookQueue } from './hook-queue.js';
12
15
  import { createClientLogCollector, } from './client-log-handler.js';
13
16
  import { createActionHooksPlugin } from './action-hooks.js';
14
17
  import { createResourceLockManager, } from './resource-lock.js';
18
+ import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
15
19
  import { resolveHarnessMetroPort } from './metro-port.js';
16
20
  import { getAdditionalCliArgs } from './cli-args.js';
17
21
  import { logMetroCacheReused, logMetroPortFallback, logNativeCoverageCollected, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, logTestEnvironmentReady, logTestRunHeader, } from './logs.js';
18
22
  const sessionLogger = logger.child('runtime');
19
23
  const defaultResourceLockManager = createResourceLockManager();
20
24
  const ignorePromiseRejection = () => undefined;
25
+ const isBridgeDisconnectError = (error) => error instanceof Error && error.message === 'App bridge disconnected';
26
+ const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
27
+ export const waitForBridgeDisconnectOrTimeout = async ({ bridge, connection, timeoutMs, }) => {
28
+ if (bridge.connection !== connection) {
29
+ return true;
30
+ }
31
+ return await new Promise((resolve) => {
32
+ const onDisconnected = () => {
33
+ cleanup();
34
+ resolve(true);
35
+ };
36
+ const cleanup = () => {
37
+ clearTimeout(timer);
38
+ bridge.off('disconnected', onDisconnected);
39
+ };
40
+ const timer = setTimeout(() => {
41
+ cleanup();
42
+ resolve(bridge.connection !== connection);
43
+ }, timeoutMs);
44
+ bridge.on('disconnected', onDisconnected);
45
+ });
46
+ };
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
+ };
57
+ if (signal.aborted) {
58
+ onAbort();
59
+ return;
60
+ }
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;
72
+ };
21
73
  // ---------------------------------------------------------------------------
22
74
  // Internal helpers
23
75
  // ---------------------------------------------------------------------------
24
- const createAbortError = () => new DOMException('The operation was aborted', 'AbortError');
25
- const waitForAbort = (signal) => {
26
- if (signal.aborted) {
27
- return Promise.reject(signal.reason ?? createAbortError());
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) => {
83
+ const timeoutSignal = getTimeoutSignal(options.timeout);
84
+ const abortWait = waitForAbort(timeoutSignal);
85
+ try {
86
+ return await Promise.race([
87
+ options.work(options.signal),
88
+ abortWait.promise.catch(() => {
89
+ throw new PlatformReadyTimeoutError(options.timeout);
90
+ }),
91
+ ]);
92
+ }
93
+ finally {
94
+ abortWait.cancel();
28
95
  }
29
- return new Promise((_, reject) => {
30
- signal.addEventListener('abort', () => reject(signal.reason ?? createAbortError()), { once: true });
31
- });
32
96
  };
33
- const withPlatformReadyTimeout = async (options) => {
34
- const timeoutSignal = getTimeoutSignal(options.timeout);
35
- const combinedSignal = raceAbortSignals([options.signal, timeoutSignal]);
97
+ export const waitForStartupCrash = async ({ crashMonitor, detectNativeCrashes, testFilePath, signal, }) => {
98
+ if (!detectNativeCrashes) {
99
+ return await waitForAbort(signal).promise;
100
+ }
101
+ const watch = crashMonitor.watch(testFilePath, 'startup');
102
+ watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
103
+ const abortWait = waitForAbort(signal);
36
104
  try {
37
- return await options.work(combinedSignal);
105
+ return await Promise.race([watch.promise, abortWait.promise]);
38
106
  }
39
- catch (error) {
40
- if (error instanceof DOMException &&
41
- error.name === 'AbortError' &&
42
- timeoutSignal.aborted &&
43
- !options.signal.aborted) {
44
- throw new PlatformReadyTimeoutError(options.timeout);
45
- }
46
- throw error;
107
+ finally {
108
+ watch.cancel();
109
+ abortWait.cancel();
47
110
  }
48
111
  };
49
112
  const waitForAppReady = async (base, testFilePath) => {
50
- const { metroInstance, bridge, platformInstance, platformId, bundleStartTimeout, readyTimeout, maxAppRestarts, crashMonitor, appLaunchOptions, } = base;
113
+ const { metroInstance, bridge, platformId, bundleStartTimeout, readyTimeout, maxAppRestarts, crashMonitor, detectNativeCrashes, restartAppSession, } = base;
51
114
  const logWait = (message, ...args) => sessionLogger.debug(`waitForAppReady: ${message}`, ...args);
52
115
  return await waitForMetroBackedAppReady({
53
116
  metro: metroInstance,
@@ -58,7 +121,7 @@ const waitForAppReady = async (base, testFilePath) => {
58
121
  signal: new AbortController().signal,
59
122
  startAttempt: async () => {
60
123
  logWait('launching app for %s', testFilePath);
61
- await platformInstance.restartApp(appLaunchOptions);
124
+ await restartAppSession();
62
125
  logWait('launch request completed, waiting for bridge ready');
63
126
  },
64
127
  waitForReady: async (signal) => {
@@ -68,32 +131,17 @@ const waitForAppReady = async (base, testFilePath) => {
68
131
  // is already set. waitForReady is called before startAttempt, so a stale
69
132
  // connection from a previous run would resolve the promise before startAttempt
70
133
  // even restarts the app — leaving bridge.connection null after the restart.
71
- await new Promise((resolve, reject) => {
72
- const onConnected = (_conn) => { cleanup(); resolve(); };
73
- const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
74
- const cleanup = () => {
75
- bridge.off('connected', onConnected);
76
- signal.removeEventListener('abort', onAbort);
77
- };
78
- if (signal.aborted) {
79
- onAbort();
80
- return;
81
- }
82
- bridge.on('connected', onConnected);
83
- signal.addEventListener('abort', onAbort, { once: true });
84
- });
134
+ await waitForNextConnected({ bridge, signal });
85
135
  logWait('runtime ready received');
86
136
  },
87
137
  waitForCrash: async (signal) => {
88
- const watch = crashMonitor.watch(testFilePath, 'startup');
89
- watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
90
- try {
91
- logWait('waiting for crash or runtime ready');
92
- return await Promise.race([watch.promise, waitForAbort(signal)]);
93
- }
94
- finally {
95
- watch.cancel();
96
- }
138
+ logWait('waiting for crash or runtime ready');
139
+ return await waitForStartupCrash({
140
+ crashMonitor,
141
+ detectNativeCrashes,
142
+ testFilePath,
143
+ signal,
144
+ });
97
145
  },
98
146
  onAttemptStart: () => {
99
147
  logWait('beginning launch attempt for %s', testFilePath);
@@ -150,9 +198,23 @@ const applyEnvVars = (harnessConfig, globalConfig) => {
150
198
  process.env.RN_HARNESS_VIEW_FLATTENING = 'false';
151
199
  }
152
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
+ };
153
215
  const loadConfig = async (globalConfig) => {
154
216
  const projectRoot = globalConfig.rootDir;
155
- const { config: rawConfig } = await getConfig(projectRoot);
217
+ const { config: rawConfig, projectRoot: configProjectRoot } = await getConfig(projectRoot);
156
218
  const cliArgs = getAdditionalCliArgs();
157
219
  let harnessConfig = cliArgs.metroPort != null
158
220
  ? ConfigSchema.parse({ ...rawConfig, metroPort: cliArgs.metroPort })
@@ -173,29 +235,40 @@ const loadConfig = async (globalConfig) => {
173
235
  const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName);
174
236
  if (!platform)
175
237
  throw new RunnerNotFoundError(selectedRunnerName);
176
- return { harnessConfig, platform, projectRoot };
238
+ return { harnessConfig, platform, projectRoot, configProjectRoot };
177
239
  };
178
240
  // ---------------------------------------------------------------------------
179
241
  // Session creation
180
242
  // ---------------------------------------------------------------------------
181
243
  export const createHarnessSession = async (globalConfig, { lockManager = defaultResourceLockManager } = {}) => {
182
244
  preRunMessage.remove(process.stderr);
183
- const { harnessConfig, platform, projectRoot } = await loadConfig(globalConfig);
245
+ const setupStartedAt = Date.now();
246
+ const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
184
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
+ });
185
255
  sessionLogger.debug('creating session for runner=%s platform=%s', platform.name, platform.platformId);
186
- // Single AbortController for the entire setup phase. Registered signal
187
- // handlers abort this so that slow inflight operations (Metro init, platform
188
- // runner startup) are cancelled promptly on SIGTERM/SIGINT.
189
- const setupController = new AbortController();
190
- 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();
191
264
  process.once('SIGTERM', onEarlySignal);
192
265
  process.once('SIGINT', onEarlySignal);
193
266
  const resourceLockKey = await (platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform));
194
267
  let didWaitForResourceLock = false;
195
268
  let lastStillWaitingLogAt = 0;
196
269
  logTestRunHeader(platform);
197
- const resourceLease = await lockManager.acquire(resourceLockKey, {
198
- signal: setupController.signal,
270
+ const resourceLease = await diagnostics.measure('session.lock.wait', () => lockManager.acquire(resourceLockKey, {
271
+ signal: sessionController.signal,
199
272
  onWait: () => {
200
273
  didWaitForResourceLock = true;
201
274
  logRunnerWaitingInQueue(platform);
@@ -208,7 +281,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
208
281
  logRunnerStillWaitingInQueue(platform);
209
282
  sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
210
283
  },
211
- });
284
+ }), { lockKey: resourceLockKey });
212
285
  if (didWaitForResourceLock)
213
286
  logRunnerStarting(platform);
214
287
  sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
@@ -216,90 +289,147 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
216
289
  // port resolution but before the inner try/catch (e.g. bridge creation failure).
217
290
  let metroPortLease = null;
218
291
  try {
219
- const resolution = await resolveHarnessMetroPort({
220
- config: harnessConfig,
221
- platform,
222
- resourceLockManager: lockManager,
223
- 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,
224
309
  });
225
310
  metroPortLease = resolution.metroPortLease;
226
311
  const { config: runtimeConfig, initialMetroPort, didFallback } = resolution;
227
312
  if (didFallback)
228
313
  logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
229
- if (runtimeConfig.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) {
230
- logMetroCacheReused(platform);
231
- }
232
- const pluginAbortController = new AbortController();
314
+ maybeLogMetroCacheReused({
315
+ config: runtimeConfig,
316
+ projectRoot: configProjectRoot,
317
+ platform,
318
+ });
233
319
  const pluginManager = createHarnessPluginManager({
234
320
  plugins: (runtimeConfig.plugins ?? []),
235
321
  projectRoot,
236
322
  config: runtimeConfig,
237
323
  runner: platform,
238
- abortSignal: pluginAbortController.signal,
324
+ abortSignal: sessionController.signal,
239
325
  });
240
326
  const hooks = createHookQueue();
241
327
  let currentRun = null;
242
328
  const getCurrentRunId = () => currentRun?.runId;
243
329
  const clientLogCollector = createClientLogCollector();
244
330
  const context = { platform };
245
- const bridge = await createHarnessBridge({
331
+ const crashArtifactWriter = createCrashArtifactWriter({
332
+ runnerName: platform.name,
333
+ platformId: platform.platformId,
334
+ rootDir: path.join(projectRoot, '.harness', 'crash-reports'),
335
+ });
336
+ const bridge = await diagnostics.measure('session.bridge.init', () => createHarnessBridge({
246
337
  noServer: true,
247
338
  timeout: runtimeConfig.bridgeTimeout,
248
339
  context,
249
- });
340
+ }));
250
341
  sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
251
342
  let metroInstance;
252
343
  let platformInstance;
344
+ // Noop until the Metro instance resolves and the deriver is attached.
345
+ let disposeMetroDiagnostics = () => undefined;
253
346
  try {
254
347
  [metroInstance, platformInstance] = await Promise.all([
255
- getMetroInstance({
348
+ diagnostics.measure('metro.init', () => getMetroInstance({
256
349
  projectRoot,
257
350
  harnessConfig: runtimeConfig,
258
351
  websocketEndpoints: {
259
352
  [HARNESS_BRIDGE_PATH]: bridge.ws,
260
353
  },
261
- }, setupController.signal).then((instance) => {
354
+ watchMode: globalConfig.watch || globalConfig.watchAll,
355
+ }, sessionController.signal).then((instance) => {
262
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
+ }
263
376
  return instance;
264
- }),
265
- withPlatformReadyTimeout({
377
+ })),
378
+ diagnostics.measure('platform.init', () => withPlatformReadyTimeout({
266
379
  timeout: runtimeConfig.platformReadyTimeout,
267
- signal: setupController.signal,
380
+ signal: sessionController.signal,
268
381
  work: async (signal) => {
269
382
  return await import(platform.runner).then((module) => module.default(platform.config, runtimeConfig, {
270
383
  signal,
384
+ crashArtifactWriter,
385
+ diagnostics,
271
386
  })).then((instance) => {
272
387
  sessionLogger.debug('platform runner initialized');
273
388
  return instance;
274
389
  });
275
390
  },
276
- }),
391
+ }), { runner: platform.runner, platformId: platform.platformId }),
277
392
  ]);
278
393
  }
279
394
  catch (error) {
280
- // 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();
281
398
  await bridge.dispose();
282
399
  throw error;
283
400
  }
284
- const crashArtifactWriter = createCrashArtifactWriter({
285
- runnerName: platform.name,
286
- platformId: platform.platformId,
287
- });
288
- const appMonitor = platformInstance.createAppMonitor({ crashArtifactWriter });
289
401
  const appLaunchOptions = platform.config.appLaunchOptions;
290
- const crashMonitor = createCrashMonitor({ appMonitor, platformRunner: platformInstance });
402
+ let currentAppSession = null;
403
+ const crashMonitor = createCrashMonitor();
404
+ const disposeCurrentAppSession = async () => {
405
+ const session = currentAppSession;
406
+ currentAppSession = null;
407
+ crashMonitor.setAppSession(null);
408
+ if (session) {
409
+ await session.dispose();
410
+ }
411
+ };
412
+ const restartAppSession = async () => {
413
+ await crashMonitor.stop();
414
+ await disposeCurrentAppSession();
415
+ const session = await diagnostics.measure('app.session.create', () => platformInstance.createAppSession(appLaunchOptions));
416
+ currentAppSession = session;
417
+ crashMonitor.setAppSession(session);
418
+ await crashMonitor.start();
419
+ return session;
420
+ };
291
421
  // Pre-build the options that are constant across all app-ready calls;
292
422
  // only testFilePath varies per call.
293
423
  const appReadyBaseOptions = {
294
424
  metroInstance,
295
425
  bridge,
296
- platformInstance,
297
426
  platformId: platform.platformId,
298
427
  bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
299
428
  readyTimeout: runtimeConfig.bridgeTimeout,
300
429
  maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
301
430
  crashMonitor,
302
- appLaunchOptions,
431
+ detectNativeCrashes: runtimeConfig.detectNativeCrashes !== false,
432
+ restartAppSession,
303
433
  };
304
434
  // --- Event listeners ---
305
435
  const bridgeEventListener = buildBridgeHookScheduler(hooks, pluginManager, getCurrentRunId);
@@ -311,6 +441,17 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
311
441
  };
312
442
  const flushClientLogs = () => clientLogCollector.flush();
313
443
  const clientLogListener = clientLogCollector.handleEvent;
444
+ const testRunnerEventListeners = new Set();
445
+ const onTestRunnerEvent = (event) => {
446
+ if (event.type === 'test-started' ||
447
+ event.type === 'test-finished' ||
448
+ event.type === 'suite-started' ||
449
+ event.type === 'suite-finished' ||
450
+ event.type === 'file-started' ||
451
+ event.type === 'file-finished') {
452
+ testRunnerEventListeners.forEach((listener) => listener(event));
453
+ }
454
+ };
314
455
  const onConnected = (conn) => {
315
456
  const runId = getCurrentRunId();
316
457
  if (!runId)
@@ -322,19 +463,25 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
322
463
  if (!runId)
323
464
  return;
324
465
  hooks.schedule(() => pluginManager.callHook('runtime:disconnected', { runId, reason: 'bridge-disconnected' }));
466
+ if (runtimeConfig.detectNativeCrashes !== false) {
467
+ crashMonitor.handleBridgeDisconnect();
468
+ }
325
469
  };
326
470
  bridge.on('connected', onConnected);
327
471
  bridge.on('disconnected', onDisconnected);
328
472
  bridge.on('event', bridgeEventListener);
473
+ bridge.on('event', onTestRunnerEvent);
329
474
  metroInstance.events.addListener(onMetroEvent);
330
475
  if (runtimeConfig.forwardClientLogs) {
331
476
  metroInstance.events.addListener(clientLogListener);
332
477
  }
478
+ const disposeBridgeDiagnostics = observeBridgeEvents(bridge, diagnostics);
333
479
  sessionLogger.debug('registered runtime, bridge, and Metro listeners');
334
480
  // --- Dispose ---
335
481
  let disposePromise = null;
336
482
  const disposeOnce = async (reason) => {
337
483
  sessionLogger.debug('disposing session (reason=%s)', reason);
484
+ const disposeSpan = diagnostics.start('dispose.total', { reason });
338
485
  let hookError;
339
486
  try {
340
487
  await hooks.drain();
@@ -365,10 +512,14 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
365
512
  bridge.off('connected', onConnected);
366
513
  bridge.off('disconnected', onDisconnected);
367
514
  bridge.off('event', bridgeEventListener);
515
+ bridge.off('event', onTestRunnerEvent);
516
+ disposeMetroDiagnostics();
517
+ disposeBridgeDiagnostics();
368
518
  const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios;
369
519
  if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) {
370
520
  try {
371
- await platformInstance.stopApp();
521
+ await crashMonitor.stop();
522
+ await disposeCurrentAppSession();
372
523
  const lcovPath = await platformInstance.collectNativeCoverage({
373
524
  pods: nativeCoverageConfig.pods,
374
525
  outputDir: projectRoot,
@@ -383,8 +534,13 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
383
534
  }
384
535
  let cleanupError;
385
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()]);
386
543
  await Promise.all([
387
- crashMonitor.dispose(),
388
544
  bridge.dispose(),
389
545
  platformInstance.dispose(),
390
546
  metroInstance.dispose(),
@@ -396,13 +552,18 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
396
552
  }
397
553
  finally {
398
554
  await resourceLease.release();
399
- pluginAbortController.abort();
555
+ sessionController.abort();
400
556
  }
401
557
  sessionLogger.debug('session resources disposed');
402
- if (hookError)
558
+ if (hookError) {
559
+ disposeSpan.fail(hookError);
403
560
  throw hookError;
404
- if (cleanupError)
561
+ }
562
+ if (cleanupError) {
563
+ disposeSpan.fail(cleanupError);
405
564
  throw cleanupError;
565
+ }
566
+ disposeSpan.end();
406
567
  };
407
568
  const dispose = (reason = 'normal') => {
408
569
  disposePromise ??= disposeOnce(reason);
@@ -412,15 +573,16 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
412
573
  // that all infrastructure is up.
413
574
  process.off('SIGTERM', onEarlySignal);
414
575
  process.off('SIGINT', onEarlySignal);
415
- 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
+ };
416
580
  process.once('SIGTERM', onSignal);
417
581
  process.once('SIGINT', onSignal);
418
582
  // --- Startup hooks ---
419
583
  try {
420
584
  await pluginManager.callHook('harness:before-creation', { appLaunchOptions });
421
585
  await hooks.drain();
422
- await appMonitor.start();
423
- sessionLogger.debug('app monitor started');
424
586
  await pluginManager.callHook('harness:before-run', { appLaunchOptions });
425
587
  await hooks.drain();
426
588
  }
@@ -431,38 +593,112 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
431
593
  throw error;
432
594
  }
433
595
  logTestEnvironmentReady(platform);
596
+ diagnostics.record({
597
+ label: 'session.setup',
598
+ duration: Date.now() - setupStartedAt,
599
+ });
434
600
  sessionLogger.debug('session ready');
435
601
  // --- Public API ---
436
602
  const ensureAppReady = async (testFilePath) => {
437
603
  await hooks.drain();
438
604
  sessionLogger.debug('ensuring app is ready for %s', testFilePath);
439
- if (crashMonitor.isAlive() && bridge.connection !== null && await platformInstance.isAppRunning()) {
440
- sessionLogger.debug('reusing existing ready app for %s', testFilePath);
441
- return;
605
+ const appReadySpan = diagnostics.start('app.ready.total');
606
+ if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) {
607
+ const state = await currentAppSession.getState();
608
+ if (state.status === 'running') {
609
+ sessionLogger.debug('reusing existing ready app for %s', testFilePath);
610
+ appReadySpan.end({ file: testFilePath, reused: true });
611
+ return;
612
+ }
442
613
  }
443
614
  crashMonitor.reset();
444
615
  sessionLogger.debug('app not ready, waiting for launch and runtime readiness');
445
- 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
+ }
446
623
  await hooks.drain();
447
624
  sessionLogger.debug('app is ready for %s', testFilePath);
625
+ appReadySpan.end({ file: testFilePath, reused: false });
448
626
  };
449
- const restartApp = async (testFilePath) => {
450
- 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) => {
451
631
  await crashMonitor.stop();
452
- sessionLogger.debug('restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', testFilePath ? 'stop-and-ensure-ready' : 'direct-restart');
632
+ await disposeCurrentAppSession();
453
633
  if (testFilePath) {
454
- await platformInstance.stopApp();
634
+ await ensureAppReady(testFilePath);
455
635
  }
456
636
  else {
457
- await platformInstance.restartApp(appLaunchOptions);
637
+ const session = await platformInstance.createAppSession(appLaunchOptions);
638
+ currentAppSession = session;
639
+ crashMonitor.setAppSession(session);
640
+ crashMonitor.reset();
641
+ await crashMonitor.start();
458
642
  }
459
- crashMonitor.reset();
460
- await crashMonitor.start();
461
- if (testFilePath) {
462
- await ensureAppReady(testFilePath);
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;
463
678
  }
464
679
  await hooks.drain();
465
- 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
+ }
466
702
  };
467
703
  const runTestFile = async (testPath, options) => {
468
704
  await hooks.drain();
@@ -470,7 +706,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
470
706
  if (!conn)
471
707
  throw new Error('No active app connection');
472
708
  sessionLogger.debug('running test file on client: %s', testPath);
473
- if (!runtimeConfig.detectNativeCrashes) {
709
+ if (runtimeConfig.detectNativeCrashes === false) {
474
710
  const result = await conn.runTests(testPath, { ...options, runner: platform.runner });
475
711
  await hooks.drain();
476
712
  return result;
@@ -480,13 +716,30 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
480
716
  // crash wins the race or cancel() is called after the test run wins.
481
717
  crashWatch.promise.catch(ignorePromiseRejection);
482
718
  try {
483
- const result = await Promise.race([
484
- conn.runTests(testPath, { ...options, runner: platform.runner }),
485
- crashWatch.promise,
486
- ]);
719
+ const testRunPromise = conn.runTests(testPath, {
720
+ ...options,
721
+ runner: platform.runner,
722
+ });
723
+ const result = await Promise.race([testRunPromise, crashWatch.promise]);
724
+ const bridgeDisconnected = await waitForBridgeDisconnectOrTimeout({
725
+ bridge,
726
+ connection: conn,
727
+ timeoutMs: TEST_RUN_BRIDGE_STABILITY_WAIT_MS,
728
+ });
729
+ if (bridgeDisconnected) {
730
+ return await crashWatch.promise;
731
+ }
487
732
  await hooks.drain();
488
733
  return result;
489
734
  }
735
+ catch (error) {
736
+ if (isBridgeDisconnectError(error)) {
737
+ const result = await crashWatch.promise;
738
+ await hooks.drain();
739
+ return result;
740
+ }
741
+ throw error;
742
+ }
490
743
  finally {
491
744
  crashWatch.cancel();
492
745
  }
@@ -494,29 +747,44 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
494
747
  return {
495
748
  config: runtimeConfig,
496
749
  context,
750
+ diagnostics,
751
+ onTestRunnerEvent: (listener) => {
752
+ testRunnerEventListeners.add(listener);
753
+ return () => {
754
+ testRunnerEventListeners.delete(listener);
755
+ };
756
+ },
497
757
  runTestFile,
498
758
  ensureAppReady,
499
759
  restartApp,
760
+ resetEnvironment,
500
761
  resetCrashState: () => crashMonitor.reset(),
501
762
  flushClientLogs,
502
763
  callHook: async (name, payload) => {
503
764
  await hooks.drain();
504
- await pluginManager.callHook(name, payload);
765
+ await diagnostics.measure('plugin.hook', () => pluginManager.callHook(name, payload), { hook: String(name) });
505
766
  await hooks.drain();
506
767
  },
507
768
  setRunState: (state) => {
508
769
  currentRun = state;
509
770
  },
510
- dispose: (reason = 'normal') => {
511
- process.off('SIGTERM', onSignal);
512
- process.off('SIGINT', onSignal);
513
- 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
+ }
514
779
  },
515
780
  };
516
781
  }
517
782
  catch (error) {
518
783
  process.off('SIGTERM', onEarlySignal);
519
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();
520
788
  await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
521
789
  throw error;
522
790
  }