@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,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';
@@ -7,18 +8,20 @@ import {
7
8
  HARNESS_BRIDGE_PATH,
8
9
  type HarnessContext,
9
10
  type BridgeEvents,
11
+ type TestRunnerEvents,
10
12
  type TestExecutionOptions,
11
13
  type TestSuiteResult,
12
14
  } from '@react-native-harness/bridge';
13
15
  import {
14
16
  type AppLaunchOptions,
17
+ type AppSession,
15
18
  type HarnessPlatform,
16
19
  type HarnessPlatformInitOptions,
17
20
  type HarnessPlatformRunner,
18
21
  } from '@react-native-harness/platforms';
22
+ import { createHarnessCache } from '@react-native-harness/cache';
19
23
  import {
20
24
  getMetroInstance,
21
- isMetroCacheReusable,
22
25
  waitForMetroBackedAppReady,
23
26
  type MetroInstance,
24
27
  type MetroWebSocketEndpoint,
@@ -33,21 +36,33 @@ import {
33
36
  type HarnessRunSummary,
34
37
  } from '@react-native-harness/plugins';
35
38
  import {
36
- logger,
37
39
  createCrashArtifactWriter,
40
+ createDiagnostics,
41
+ logger,
38
42
  getTimeoutSignal,
39
- raceAbortSignals,
43
+ ensureSignalsDeliverable,
44
+ waitForAbort,
45
+ type Diagnostics,
40
46
  } from '@react-native-harness/tools';
41
47
  import {
42
48
  getConfig,
49
+ isDiagnosticsEnabled,
50
+ resolveMetroCacheEnabled,
43
51
  type Config as HarnessConfig,
44
52
  ConfigSchema,
45
53
  } from '@react-native-harness/config';
46
54
  import type { Config as JestConfig } from 'jest-runner';
47
55
  import { preRunMessage } from 'jest-util';
56
+ import path from 'node:path';
48
57
  import { PlatformReadyTimeoutError } from './errors.js';
49
58
  import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
50
59
  import { createCrashMonitor, type CrashMonitor } from './crash-monitor.js';
60
+ import {
61
+ createProcessResetStrategy,
62
+ createRuntimeResetStrategy,
63
+ resolveResetStrategyKind,
64
+ withEscalation,
65
+ } from './environment-reset.js';
51
66
  import { createHookQueue, type HookQueue } from './hook-queue.js';
52
67
  import {
53
68
  createClientLogCollector,
@@ -59,6 +74,7 @@ import {
59
74
  type ResourceLockManager,
60
75
  type ResourceLease,
61
76
  } from './resource-lock.js';
77
+ import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
62
78
  import { resolveHarnessMetroPort } from './metro-port.js';
63
79
  import { getAdditionalCliArgs } from './cli-args.js';
64
80
  import {
@@ -75,6 +91,70 @@ import {
75
91
  const sessionLogger = logger.child('runtime');
76
92
  const defaultResourceLockManager = createResourceLockManager();
77
93
  const ignorePromiseRejection = () => undefined;
94
+ const isBridgeDisconnectError = (error: unknown) =>
95
+ error instanceof Error && error.message === 'App bridge disconnected';
96
+ const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
97
+
98
+ type DisconnectObservableBridge = {
99
+ readonly connection: AppConnection | null;
100
+ on: (event: 'disconnected', listener: () => void) => void;
101
+ off: (event: 'disconnected', listener: () => void) => void;
102
+ };
103
+
104
+ export const waitForBridgeDisconnectOrTimeout = async ({
105
+ bridge,
106
+ connection,
107
+ timeoutMs,
108
+ }: {
109
+ bridge: DisconnectObservableBridge;
110
+ connection: AppConnection;
111
+ timeoutMs: number;
112
+ }): Promise<boolean> => {
113
+ if (bridge.connection !== connection) {
114
+ return true;
115
+ }
116
+
117
+ return await new Promise<boolean>((resolve) => {
118
+ const onDisconnected = () => {
119
+ cleanup();
120
+ resolve(true);
121
+ };
122
+
123
+ const cleanup = () => {
124
+ clearTimeout(timer);
125
+ bridge.off('disconnected', onDisconnected);
126
+ };
127
+
128
+ const timer = setTimeout(() => {
129
+ cleanup();
130
+ resolve(bridge.connection !== connection);
131
+ }, timeoutMs);
132
+
133
+ bridge.on('disconnected', onDisconnected);
134
+ });
135
+ };
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
+ });
78
158
 
79
159
  export type HarnessRunState = {
80
160
  readonly runId: string;
@@ -82,6 +162,7 @@ export type HarnessRunState = {
82
162
  readonly testFiles: string[];
83
163
  readonly watchMode: boolean;
84
164
  readonly coverageEnabled: boolean;
165
+ readonly completed?: boolean;
85
166
  readonly summary?: HarnessRunSummary;
86
167
  readonly status?: HarnessRunStatus;
87
168
  readonly error?: unknown;
@@ -92,9 +173,12 @@ export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
92
173
  export type HarnessSession = {
93
174
  readonly config: HarnessConfig;
94
175
  readonly context: HarnessContext;
176
+ readonly diagnostics: Diagnostics;
177
+ onTestRunnerEvent: (listener: (event: TestRunnerEvents) => void) => () => void;
95
178
  runTestFile: (path: string, options: HarnessRunTestsOptions) => Promise<TestSuiteResult>;
96
179
  ensureAppReady: (testFilePath: string) => Promise<void>;
97
180
  restartApp: (testFilePath?: string) => Promise<void>;
181
+ resetEnvironment: (testFilePath: string) => Promise<void>;
98
182
  resetCrashState: () => void;
99
183
  flushClientLogs: () => ClientLogBuffer;
100
184
  callHook: HarnessPluginManager<HarnessConfig, HarnessPlatform>['callHook'];
@@ -102,58 +186,86 @@ export type HarnessSession = {
102
186
  dispose: (reason?: 'normal' | 'abort' | 'error') => Promise<void>;
103
187
  };
104
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
+
105
204
  // ---------------------------------------------------------------------------
106
205
  // Internal helpers
107
206
  // ---------------------------------------------------------------------------
108
207
 
109
- const createAbortError = () =>
110
- new DOMException('The operation was aborted', 'AbortError');
111
-
112
- const waitForAbort = (signal: AbortSignal): Promise<never> => {
113
- if (signal.aborted) {
114
- return Promise.reject(signal.reason ?? createAbortError());
115
- }
116
- return new Promise((_, reject) => {
117
- signal.addEventListener(
118
- 'abort',
119
- () => reject(signal.reason ?? createAbortError()),
120
- { once: true },
121
- );
122
- });
123
- };
124
-
125
- 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: {
126
215
  timeout: number;
127
216
  signal: AbortSignal;
128
217
  work: (signal: AbortSignal) => Promise<T>;
129
218
  }): Promise<T> => {
130
219
  const timeoutSignal = getTimeoutSignal(options.timeout);
131
- const combinedSignal = raceAbortSignals([options.signal, timeoutSignal]);
220
+ const abortWait = waitForAbort(timeoutSignal);
132
221
  try {
133
- return await options.work(combinedSignal);
134
- } catch (error) {
135
- if (
136
- error instanceof DOMException &&
137
- error.name === 'AbortError' &&
138
- timeoutSignal.aborted &&
139
- !options.signal.aborted
140
- ) {
141
- throw new PlatformReadyTimeoutError(options.timeout);
142
- }
143
- 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();
144
230
  }
145
231
  };
146
232
 
147
233
  type AppReadyOptions = {
148
234
  metroInstance: MetroInstance;
149
235
  bridge: HarnessBridge;
150
- platformInstance: HarnessPlatformRunner;
151
236
  platformId: string;
152
237
  bundleStartTimeout: number;
153
238
  readyTimeout: number;
154
239
  maxAppRestarts: number;
155
240
  crashMonitor: CrashMonitor;
156
- appLaunchOptions?: AppLaunchOptions;
241
+ detectNativeCrashes: boolean;
242
+ restartAppSession: () => Promise<AppSession>;
243
+ };
244
+
245
+ export const waitForStartupCrash = async ({
246
+ crashMonitor,
247
+ detectNativeCrashes,
248
+ testFilePath,
249
+ signal,
250
+ }: {
251
+ crashMonitor: CrashMonitor;
252
+ detectNativeCrashes: boolean;
253
+ testFilePath: string;
254
+ signal: AbortSignal;
255
+ }) => {
256
+ if (!detectNativeCrashes) {
257
+ return await waitForAbort(signal).promise;
258
+ }
259
+
260
+ const watch = crashMonitor.watch(testFilePath, 'startup');
261
+ watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
262
+ const abortWait = waitForAbort(signal);
263
+ try {
264
+ return await Promise.race([watch.promise, abortWait.promise]);
265
+ } finally {
266
+ watch.cancel();
267
+ abortWait.cancel();
268
+ }
157
269
  };
158
270
 
159
271
  const waitForAppReady = async (
@@ -163,13 +275,13 @@ const waitForAppReady = async (
163
275
  const {
164
276
  metroInstance,
165
277
  bridge,
166
- platformInstance,
167
278
  platformId,
168
279
  bundleStartTimeout,
169
280
  readyTimeout,
170
281
  maxAppRestarts,
171
282
  crashMonitor,
172
- appLaunchOptions,
283
+ detectNativeCrashes,
284
+ restartAppSession,
173
285
  } = base;
174
286
 
175
287
  const logWait = (message: string, ...args: unknown[]) =>
@@ -184,7 +296,7 @@ const waitForAppReady = async (
184
296
  signal: new AbortController().signal,
185
297
  startAttempt: async () => {
186
298
  logWait('launching app for %s', testFilePath);
187
- await platformInstance.restartApp(appLaunchOptions);
299
+ await restartAppSession();
188
300
  logWait('launch request completed, waiting for bridge ready');
189
301
  },
190
302
  waitForReady: async (signal) => {
@@ -194,28 +306,17 @@ const waitForAppReady = async (
194
306
  // is already set. waitForReady is called before startAttempt, so a stale
195
307
  // connection from a previous run would resolve the promise before startAttempt
196
308
  // even restarts the app — leaving bridge.connection null after the restart.
197
- await new Promise<void>((resolve, reject) => {
198
- const onConnected = (_conn: AppConnection) => { cleanup(); resolve(); };
199
- const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
200
- const cleanup = () => {
201
- bridge.off('connected', onConnected);
202
- signal.removeEventListener('abort', onAbort);
203
- };
204
- if (signal.aborted) { onAbort(); return; }
205
- bridge.on('connected', onConnected);
206
- signal.addEventListener('abort', onAbort, { once: true });
207
- });
309
+ await waitForNextConnected({ bridge, signal });
208
310
  logWait('runtime ready received');
209
311
  },
210
312
  waitForCrash: async (signal) => {
211
- const watch = crashMonitor.watch(testFilePath, 'startup');
212
- watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race
213
- try {
214
- logWait('waiting for crash or runtime ready');
215
- return await Promise.race([watch.promise, waitForAbort(signal)]);
216
- } finally {
217
- watch.cancel();
218
- }
313
+ logWait('waiting for crash or runtime ready');
314
+ return await waitForStartupCrash({
315
+ crashMonitor,
316
+ detectNativeCrashes,
317
+ testFilePath,
318
+ signal,
319
+ });
219
320
  },
220
321
  onAttemptStart: () => {
221
322
  logWait('beginning launch attempt for %s', testFilePath);
@@ -289,13 +390,37 @@ const applyEnvVars = (
289
390
  }
290
391
  };
291
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
+
292
415
  const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
293
416
  harnessConfig: HarnessConfig;
294
417
  platform: HarnessPlatform;
295
418
  projectRoot: string;
419
+ configProjectRoot: string;
296
420
  }> => {
297
421
  const projectRoot = globalConfig.rootDir;
298
- const { config: rawConfig } = await getConfig(projectRoot);
422
+ const { config: rawConfig, projectRoot: configProjectRoot } =
423
+ await getConfig(projectRoot);
299
424
 
300
425
  const cliArgs = getAdditionalCliArgs();
301
426
  let harnessConfig = cliArgs.metroPort != null
@@ -324,7 +449,7 @@ const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{
324
449
  const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName);
325
450
  if (!platform) throw new RunnerNotFoundError(selectedRunnerName);
326
451
 
327
- return { harnessConfig, platform, projectRoot };
452
+ return { harnessConfig, platform, projectRoot, configProjectRoot };
328
453
  };
329
454
 
330
455
  // ---------------------------------------------------------------------------
@@ -337,20 +462,32 @@ export const createHarnessSession = async (
337
462
  ): Promise<HarnessSession> => {
338
463
  preRunMessage.remove(process.stderr);
339
464
 
340
- const { harnessConfig, platform, projectRoot } = await loadConfig(globalConfig);
465
+ const setupStartedAt = Date.now();
466
+ const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
341
467
  applyEnvVars(harnessConfig, globalConfig);
342
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
+
343
477
  sessionLogger.debug(
344
478
  'creating session for runner=%s platform=%s',
345
479
  platform.name,
346
480
  platform.platformId,
347
481
  );
348
482
 
349
- // Single AbortController for the entire setup phase. Registered signal
350
- // handlers abort this so that slow inflight operations (Metro init, platform
351
- // runner startup) are cancelled promptly on SIGTERM/SIGINT.
352
- const setupController = new AbortController();
353
- 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();
354
491
  process.once('SIGTERM', onEarlySignal);
355
492
  process.once('SIGINT', onEarlySignal);
356
493
 
@@ -360,20 +497,25 @@ export const createHarnessSession = async (
360
497
 
361
498
  logTestRunHeader(platform);
362
499
 
363
- const resourceLease = await lockManager.acquire(resourceLockKey, {
364
- signal: setupController.signal,
365
- onWait: () => {
366
- didWaitForResourceLock = true;
367
- logRunnerWaitingInQueue(platform);
368
- sessionLogger.debug('waiting in queue for runner=%s key=%s', platform.name, resourceLockKey);
369
- },
370
- onStillWaiting: (elapsedMs) => {
371
- if (elapsedMs - lastStillWaitingLogAt < 5000) return;
372
- lastStillWaitingLogAt = elapsedMs;
373
- logRunnerStillWaitingInQueue(platform);
374
- sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
375
- },
376
- });
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
+ );
377
519
 
378
520
  if (didWaitForResourceLock) logRunnerStarting(platform);
379
521
  sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
@@ -383,28 +525,40 @@ export const createHarnessSession = async (
383
525
  let metroPortLease: ResourceLease | null = null;
384
526
 
385
527
  try {
386
- const resolution = await resolveHarnessMetroPort({
387
- config: harnessConfig,
388
- platform,
389
- resourceLockManager: lockManager,
390
- 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,
391
544
  });
392
545
  metroPortLease = resolution.metroPortLease;
393
546
  const { config: runtimeConfig, initialMetroPort, didFallback } = resolution;
394
547
 
395
548
  if (didFallback) logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort);
396
549
 
397
- if (runtimeConfig.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) {
398
- logMetroCacheReused(platform);
399
- }
550
+ maybeLogMetroCacheReused({
551
+ config: runtimeConfig,
552
+ projectRoot: configProjectRoot,
553
+ platform,
554
+ });
400
555
 
401
- const pluginAbortController = new AbortController();
402
556
  const pluginManager = createHarnessPluginManager<HarnessConfig, HarnessPlatform>({
403
557
  plugins: (runtimeConfig.plugins ?? []) as Array<HarnessPlugin<object, HarnessConfig, HarnessPlatform>>,
404
558
  projectRoot,
405
559
  config: runtimeConfig,
406
560
  runner: platform,
407
- abortSignal: pluginAbortController.signal,
561
+ abortSignal: sessionController.signal,
408
562
  });
409
563
 
410
564
  const hooks = createHookQueue();
@@ -413,74 +567,131 @@ export const createHarnessSession = async (
413
567
  const clientLogCollector = createClientLogCollector();
414
568
 
415
569
  const context: HarnessContext = { platform };
416
-
417
- const bridge = await createHarnessBridge({
418
- noServer: true,
419
- timeout: runtimeConfig.bridgeTimeout,
420
- context,
570
+ const crashArtifactWriter = createCrashArtifactWriter({
571
+ runnerName: platform.name,
572
+ platformId: platform.platformId,
573
+ rootDir: path.join(projectRoot, '.harness', 'crash-reports'),
421
574
  });
575
+
576
+ const bridge = await diagnostics.measure('session.bridge.init', () =>
577
+ createHarnessBridge({
578
+ noServer: true,
579
+ timeout: runtimeConfig.bridgeTimeout,
580
+ context,
581
+ })
582
+ );
422
583
  sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
423
584
 
424
585
  let metroInstance: MetroInstance;
425
586
  let platformInstance: HarnessPlatformRunner;
587
+ // Noop until the Metro instance resolves and the deriver is attached.
588
+ let disposeMetroDiagnostics: () => void = () => undefined;
426
589
 
427
590
  try {
428
591
  [metroInstance, platformInstance] = await Promise.all([
429
- getMetroInstance(
430
- {
431
- projectRoot,
432
- harnessConfig: runtimeConfig,
433
- websocketEndpoints: {
434
- [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,
435
601
  },
436
- },
437
- setupController.signal,
438
- ).then((instance) => {
439
- sessionLogger.debug('Metro initialized');
440
- return instance;
441
- }),
442
- withPlatformReadyTimeout({
443
- timeout: runtimeConfig.platformReadyTimeout,
444
- signal: setupController.signal,
445
- work: async (signal) => {
446
- return await import(platform.runner).then((module) =>
447
- module.default(platform.config, runtimeConfig, {
448
- signal,
449
- } satisfies HarnessPlatformInitOptions),
450
- ).then((instance) => {
451
- sessionLogger.debug('platform runner initialized');
452
- return instance;
453
- });
454
- },
455
- }),
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
+ ),
456
648
  ]);
457
649
  } catch (error) {
458
- // 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();
459
653
  await bridge.dispose();
460
654
  throw error;
461
655
  }
462
656
 
463
- const crashArtifactWriter = createCrashArtifactWriter({
464
- runnerName: platform.name,
465
- platformId: platform.platformId,
466
- });
467
- const appMonitor = platformInstance.createAppMonitor({ crashArtifactWriter });
468
657
  const appLaunchOptions = (platform.config as { appLaunchOptions?: AppLaunchOptions }).appLaunchOptions;
469
658
 
470
- const crashMonitor = createCrashMonitor({ appMonitor, platformRunner: platformInstance });
659
+ let currentAppSession: AppSession | null = null;
660
+ const crashMonitor = createCrashMonitor();
661
+
662
+ const disposeCurrentAppSession = async () => {
663
+ const session = currentAppSession;
664
+ currentAppSession = null;
665
+ crashMonitor.setAppSession(null);
666
+ if (session) {
667
+ await session.dispose();
668
+ }
669
+ };
670
+
671
+ const restartAppSession = async (): Promise<AppSession> => {
672
+ await crashMonitor.stop();
673
+ await disposeCurrentAppSession();
674
+ const session = await diagnostics.measure('app.session.create', () =>
675
+ platformInstance.createAppSession(appLaunchOptions)
676
+ );
677
+ currentAppSession = session;
678
+ crashMonitor.setAppSession(session);
679
+ await crashMonitor.start();
680
+ return session;
681
+ };
471
682
 
472
683
  // Pre-build the options that are constant across all app-ready calls;
473
684
  // only testFilePath varies per call.
474
685
  const appReadyBaseOptions: AppReadyOptions = {
475
686
  metroInstance,
476
687
  bridge,
477
- platformInstance,
478
688
  platformId: platform.platformId,
479
689
  bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
480
690
  readyTimeout: runtimeConfig.bridgeTimeout,
481
691
  maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
482
692
  crashMonitor,
483
- appLaunchOptions,
693
+ detectNativeCrashes: runtimeConfig.detectNativeCrashes !== false,
694
+ restartAppSession,
484
695
  };
485
696
 
486
697
  // --- Event listeners ---
@@ -497,6 +708,19 @@ export const createHarnessSession = async (
497
708
  const flushClientLogs = (): ClientLogBuffer => clientLogCollector.flush();
498
709
 
499
710
  const clientLogListener = clientLogCollector.handleEvent;
711
+ const testRunnerEventListeners = new Set<(event: TestRunnerEvents) => void>();
712
+ const onTestRunnerEvent = (event: BridgeEvents) => {
713
+ if (
714
+ event.type === 'test-started' ||
715
+ event.type === 'test-finished' ||
716
+ event.type === 'suite-started' ||
717
+ event.type === 'suite-finished' ||
718
+ event.type === 'file-started' ||
719
+ event.type === 'file-finished'
720
+ ) {
721
+ testRunnerEventListeners.forEach((listener) => listener(event));
722
+ }
723
+ };
500
724
 
501
725
  const onConnected = (conn: AppConnection) => {
502
726
  const runId = getCurrentRunId();
@@ -508,16 +732,22 @@ export const createHarnessSession = async (
508
732
  const runId = getCurrentRunId();
509
733
  if (!runId) return;
510
734
  hooks.schedule(() => pluginManager.callHook('runtime:disconnected', { runId, reason: 'bridge-disconnected' }));
735
+ if (runtimeConfig.detectNativeCrashes !== false) {
736
+ crashMonitor.handleBridgeDisconnect();
737
+ }
511
738
  };
512
739
 
513
740
  bridge.on('connected', onConnected);
514
741
  bridge.on('disconnected', onDisconnected);
515
742
  bridge.on('event', bridgeEventListener);
743
+ bridge.on('event', onTestRunnerEvent);
516
744
  metroInstance.events.addListener(onMetroEvent);
517
745
  if (runtimeConfig.forwardClientLogs) {
518
746
  metroInstance.events.addListener(clientLogListener);
519
747
  }
520
748
 
749
+ const disposeBridgeDiagnostics = observeBridgeEvents(bridge, diagnostics);
750
+
521
751
  sessionLogger.debug('registered runtime, bridge, and Metro listeners');
522
752
 
523
753
  // --- Dispose ---
@@ -526,6 +756,7 @@ export const createHarnessSession = async (
526
756
 
527
757
  const disposeOnce = async (reason: 'normal' | 'abort' | 'error') => {
528
758
  sessionLogger.debug('disposing session (reason=%s)', reason);
759
+ const disposeSpan = diagnostics.start('dispose.total', { reason });
529
760
  let hookError: unknown;
530
761
 
531
762
  try {
@@ -557,11 +788,15 @@ export const createHarnessSession = async (
557
788
  bridge.off('connected', onConnected);
558
789
  bridge.off('disconnected', onDisconnected);
559
790
  bridge.off('event', bridgeEventListener);
791
+ bridge.off('event', onTestRunnerEvent);
792
+ disposeMetroDiagnostics();
793
+ disposeBridgeDiagnostics();
560
794
 
561
795
  const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios;
562
796
  if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) {
563
797
  try {
564
- await platformInstance.stopApp();
798
+ await crashMonitor.stop();
799
+ await disposeCurrentAppSession();
565
800
  const lcovPath = await platformInstance.collectNativeCoverage({
566
801
  pods: nativeCoverageConfig.pods,
567
802
  outputDir: projectRoot,
@@ -576,8 +811,13 @@ export const createHarnessSession = async (
576
811
 
577
812
  let cleanupError: unknown;
578
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()]);
579
820
  await Promise.all([
580
- crashMonitor.dispose(),
581
821
  bridge.dispose(),
582
822
  platformInstance.dispose(),
583
823
  metroInstance.dispose(),
@@ -587,13 +827,20 @@ export const createHarnessSession = async (
587
827
  cleanupError = error;
588
828
  } finally {
589
829
  await resourceLease.release();
590
- pluginAbortController.abort();
830
+ sessionController.abort();
591
831
  }
592
832
 
593
833
  sessionLogger.debug('session resources disposed');
594
834
 
595
- if (hookError) throw hookError;
596
- 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();
597
844
  };
598
845
 
599
846
  const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => {
@@ -605,7 +852,13 @@ export const createHarnessSession = async (
605
852
  // that all infrastructure is up.
606
853
  process.off('SIGTERM', onEarlySignal);
607
854
  process.off('SIGINT', onEarlySignal);
608
- 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
+ };
609
862
  process.once('SIGTERM', onSignal);
610
863
  process.once('SIGINT', onSignal);
611
864
 
@@ -614,8 +867,6 @@ export const createHarnessSession = async (
614
867
  try {
615
868
  await pluginManager.callHook('harness:before-creation', { appLaunchOptions });
616
869
  await hooks.drain();
617
- await appMonitor.start();
618
- sessionLogger.debug('app monitor started');
619
870
  await pluginManager.callHook('harness:before-run', { appLaunchOptions });
620
871
  await hooks.drain();
621
872
  } catch (error) {
@@ -626,6 +877,10 @@ export const createHarnessSession = async (
626
877
  }
627
878
 
628
879
  logTestEnvironmentReady(platform);
880
+ diagnostics.record({
881
+ label: 'session.setup',
882
+ duration: Date.now() - setupStartedAt,
883
+ });
629
884
  sessionLogger.debug('session ready');
630
885
 
631
886
  // --- Public API ---
@@ -633,43 +888,124 @@ export const createHarnessSession = async (
633
888
  const ensureAppReady = async (testFilePath: string): Promise<void> => {
634
889
  await hooks.drain();
635
890
  sessionLogger.debug('ensuring app is ready for %s', testFilePath);
636
-
637
- if (crashMonitor.isAlive() && bridge.connection !== null && await platformInstance.isAppRunning()) {
638
- sessionLogger.debug('reusing existing ready app for %s', testFilePath);
639
- return;
891
+ const appReadySpan = diagnostics.start('app.ready.total');
892
+
893
+ if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) {
894
+ const state = await currentAppSession.getState();
895
+ if (state.status === 'running') {
896
+ sessionLogger.debug('reusing existing ready app for %s', testFilePath);
897
+ appReadySpan.end({ file: testFilePath, reused: true });
898
+ return;
899
+ }
640
900
  }
641
901
 
642
902
  crashMonitor.reset();
643
903
  sessionLogger.debug('app not ready, waiting for launch and runtime readiness');
644
- 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
+ }
645
910
  await hooks.drain();
646
911
  sessionLogger.debug('app is ready for %s', testFilePath);
912
+ appReadySpan.end({ file: testFilePath, reused: false });
913
+ };
914
+
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> => {
919
+ await crashMonitor.stop();
920
+ await disposeCurrentAppSession();
921
+
922
+ if (testFilePath) {
923
+ await ensureAppReady(testFilePath);
924
+ } else {
925
+ const session = await platformInstance.createAppSession(appLaunchOptions);
926
+ currentAppSession = session;
927
+ crashMonitor.setAppSession(session);
928
+ crashMonitor.reset();
929
+ await crashMonitor.start();
930
+ }
647
931
  };
648
932
 
649
933
  const restartApp = async (testFilePath?: string): Promise<void> => {
650
934
  await hooks.drain();
651
- await crashMonitor.stop();
935
+ const reason: 'stop-and-ensure-ready' | 'direct-restart' = testFilePath
936
+ ? 'stop-and-ensure-ready'
937
+ : 'direct-restart';
652
938
  sessionLogger.debug(
653
939
  'restarting app (testFile=%s mode=%s)',
654
940
  testFilePath ?? 'n/a',
655
- testFilePath ? 'stop-and-ensure-ready' : 'direct-restart',
941
+ reason,
656
942
  );
943
+ const restartSpan = diagnostics.start('app.restart', { reason });
657
944
 
658
- if (testFilePath) {
659
- await platformInstance.stopApp();
660
- } else {
661
- await platformInstance.restartApp(appLaunchOptions);
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;
662
953
  }
954
+ };
663
955
 
664
- crashMonitor.reset();
665
- await crashMonitor.start();
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
+ });
666
968
 
667
- if (testFilePath) {
668
- await ensureAppReady(testFilePath);
969
+ const resetEnvironment = async (testFilePath: string): Promise<void> => {
970
+ const requestedKind = resolveResetStrategyKind(
971
+ runtimeConfig.resetEnvironmentBetweenTestFiles,
972
+ );
973
+ if (requestedKind === null) {
974
+ return;
669
975
  }
670
976
 
671
977
  await hooks.drain();
672
- sessionLogger.debug('restart completed');
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
+ }
673
1009
  };
674
1010
 
675
1011
  const runTestFile = async (
@@ -681,7 +1017,7 @@ export const createHarnessSession = async (
681
1017
  if (!conn) throw new Error('No active app connection');
682
1018
  sessionLogger.debug('running test file on client: %s', testPath);
683
1019
 
684
- if (!runtimeConfig.detectNativeCrashes) {
1020
+ if (runtimeConfig.detectNativeCrashes === false) {
685
1021
  const result = await conn.runTests(testPath, { ...options, runner: platform.runner });
686
1022
  await hooks.drain();
687
1023
  return result;
@@ -692,12 +1028,31 @@ export const createHarnessSession = async (
692
1028
  // crash wins the race or cancel() is called after the test run wins.
693
1029
  crashWatch.promise.catch(ignorePromiseRejection);
694
1030
  try {
695
- const result = await Promise.race([
696
- conn.runTests(testPath, { ...options, runner: platform.runner }),
697
- crashWatch.promise,
698
- ]);
1031
+ const testRunPromise = conn.runTests(testPath, {
1032
+ ...options,
1033
+ runner: platform.runner,
1034
+ });
1035
+ const result = await Promise.race([testRunPromise, crashWatch.promise]);
1036
+ const bridgeDisconnected = await waitForBridgeDisconnectOrTimeout({
1037
+ bridge,
1038
+ connection: conn,
1039
+ timeoutMs: TEST_RUN_BRIDGE_STABILITY_WAIT_MS,
1040
+ });
1041
+
1042
+ if (bridgeDisconnected) {
1043
+ return await crashWatch.promise;
1044
+ }
1045
+
699
1046
  await hooks.drain();
700
1047
  return result;
1048
+ } catch (error) {
1049
+ if (isBridgeDisconnectError(error)) {
1050
+ const result = await crashWatch.promise;
1051
+ await hooks.drain();
1052
+ return result;
1053
+ }
1054
+
1055
+ throw error;
701
1056
  } finally {
702
1057
  crashWatch.cancel();
703
1058
  }
@@ -706,28 +1061,46 @@ export const createHarnessSession = async (
706
1061
  return {
707
1062
  config: runtimeConfig,
708
1063
  context,
1064
+ diagnostics,
1065
+ onTestRunnerEvent: (listener) => {
1066
+ testRunnerEventListeners.add(listener);
1067
+ return () => {
1068
+ testRunnerEventListeners.delete(listener);
1069
+ };
1070
+ },
709
1071
  runTestFile,
710
1072
  ensureAppReady,
711
1073
  restartApp,
1074
+ resetEnvironment,
712
1075
  resetCrashState: () => crashMonitor.reset(),
713
1076
  flushClientLogs,
714
1077
  callHook: async (name, payload) => {
715
1078
  await hooks.drain();
716
- await pluginManager.callHook(name, payload);
1079
+ await diagnostics.measure(
1080
+ 'plugin.hook',
1081
+ () => pluginManager.callHook(name, payload),
1082
+ { hook: String(name) }
1083
+ );
717
1084
  await hooks.drain();
718
1085
  },
719
1086
  setRunState: (state) => {
720
1087
  currentRun = state;
721
1088
  },
722
- dispose: (reason = 'normal') => {
723
- process.off('SIGTERM', onSignal);
724
- process.off('SIGINT', onSignal);
725
- 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
+ }
726
1096
  },
727
1097
  };
728
1098
  } catch (error) {
729
1099
  process.off('SIGTERM', onEarlySignal);
730
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();
731
1104
  await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
732
1105
  throw error;
733
1106
  }