@react-native-harness/jest 1.5.0 → 1.7.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 (52) hide show
  1. package/dist/cli-args.d.ts +3 -4
  2. package/dist/cli-args.d.ts.map +1 -1
  3. package/dist/cli-args.js +3 -8
  4. package/dist/diagnostics/index.d.ts +4 -1
  5. package/dist/diagnostics/index.d.ts.map +1 -1
  6. package/dist/diagnostics/index.js +2 -1
  7. package/dist/diagnostics/report.d.ts +81 -1
  8. package/dist/diagnostics/report.d.ts.map +1 -1
  9. package/dist/diagnostics/report.js +225 -3
  10. package/dist/diagnostics/telemetry.d.ts +42 -0
  11. package/dist/diagnostics/telemetry.d.ts.map +1 -0
  12. package/dist/diagnostics/telemetry.js +120 -0
  13. package/dist/errors.d.ts +8 -2
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +29 -3
  16. package/dist/execute-run.d.ts.map +1 -1
  17. package/dist/execute-run.js +73 -21
  18. package/dist/format-harness-error.d.ts.map +1 -1
  19. package/dist/format-harness-error.js +3 -0
  20. package/dist/harness-session.d.ts +8 -1
  21. package/dist/harness-session.d.ts.map +1 -1
  22. package/dist/harness-session.js +110 -23
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -7
  25. package/dist/jest-preset.d.ts +3 -0
  26. package/dist/jest-preset.d.ts.map +1 -1
  27. package/dist/jest-preset.js +18 -0
  28. package/dist/relative-path.d.ts +8 -0
  29. package/dist/relative-path.d.ts.map +1 -0
  30. package/dist/relative-path.js +8 -0
  31. package/dist/resource-lock.d.ts.map +1 -1
  32. package/dist/resource-lock.js +47 -27
  33. package/dist/run.d.ts.map +1 -1
  34. package/dist/run.js +4 -4
  35. package/dist/watch-session.d.ts +6 -0
  36. package/dist/watch-session.d.ts.map +1 -0
  37. package/dist/watch-session.js +37 -0
  38. package/package.json +8 -8
  39. package/src/cli-args.ts +5 -13
  40. package/src/diagnostics/index.ts +12 -1
  41. package/src/diagnostics/report.ts +326 -5
  42. package/src/diagnostics/telemetry.ts +186 -0
  43. package/src/errors.ts +42 -5
  44. package/src/execute-run.ts +93 -23
  45. package/src/format-harness-error.ts +5 -0
  46. package/src/harness-session.ts +143 -22
  47. package/src/index.ts +8 -7
  48. package/src/jest-preset.ts +18 -0
  49. package/src/relative-path.ts +10 -0
  50. package/src/resource-lock.ts +64 -33
  51. package/src/run.ts +4 -4
  52. package/src/watch-session.ts +65 -0
@@ -5,11 +5,12 @@ import type {
5
5
  TestEvents,
6
6
  } from 'jest-runner';
7
7
  import { randomUUID } from 'node:crypto';
8
- import path from 'node:path';
9
8
  import { type HarnessSession, type HarnessRunState } from './harness-session.js';
10
9
  import { runHarnessTestFile } from './run.js';
10
+ import { toRelativePosixPath } from './relative-path.js';
11
11
  import { resolveResetStrategyKind } from './environment-reset.js';
12
12
  import {
13
+ HostSurfaceReplacedError,
13
14
  NativeCrashError,
14
15
  RuntimeDisconnectError,
15
16
  StartupStallError,
@@ -31,7 +32,13 @@ import {
31
32
  } from './test-file-platform-filter.js';
32
33
  import { formatHarnessErrorMessage } from './format-harness-error.js';
33
34
  import { logger } from '@react-native-harness/tools';
34
- import { printSummary, writeTraceFile } from './diagnostics/index.js';
35
+ import {
36
+ appendStepSummary,
37
+ printSummary,
38
+ summarizeRunKpis,
39
+ writeSummaryFile,
40
+ writeTraceFile,
41
+ } from './diagnostics/index.js';
35
42
  import { toJestStatus } from './toJestStatus.js';
36
43
 
37
44
  const diagnosticsLogger = logger.child('diagnostics');
@@ -60,15 +67,30 @@ class CancelRun extends Error {
60
67
  }
61
68
  }
62
69
 
63
- const buildTestFailure = (err: unknown): { message: string; stack: string } => {
64
- if (
65
- err instanceof NativeCrashError ||
66
- err instanceof RuntimeDisconnectError ||
67
- err instanceof StartupStallError ||
68
- err instanceof AppBridgeDisconnectedError ||
69
- err instanceof DeviceNotRespondingError
70
- ) {
71
- return { message: (err as Error).message, stack: '' };
70
+ const isRuntimeFailure = (err: unknown): err is Error =>
71
+ err instanceof NativeCrashError ||
72
+ err instanceof RuntimeDisconnectError ||
73
+ err instanceof StartupStallError ||
74
+ err instanceof AppBridgeDisconnectedError ||
75
+ err instanceof DeviceNotRespondingError;
76
+
77
+ const formatRunningTest = (test: TestRunnerTestStartedEvent): string =>
78
+ `Running test: "${[...test.ancestorTitles, test.name].join(' › ')}"`;
79
+
80
+ const buildTestFailure = (
81
+ err: unknown,
82
+ runningTest: TestRunnerTestStartedEvent | null = null,
83
+ ): { message: string; stack: string } => {
84
+ if (isRuntimeFailure(err)) {
85
+ if (!runningTest) {
86
+ return { message: err.message, stack: '' };
87
+ }
88
+
89
+ const [headline, ...details] = err.message.split('\n');
90
+ return {
91
+ message: [headline, formatRunningTest(runningTest), ...details].join('\n'),
92
+ stack: '',
93
+ };
72
94
  }
73
95
  return err as { message: string; stack: string };
74
96
  };
@@ -147,10 +169,19 @@ export const executeRun = async (
147
169
  const startTime = Date.now();
148
170
  const watchMode = globalConfig.watch || globalConfig.watchAll;
149
171
  const rootDir = globalConfig.rootDir ?? process.cwd();
150
- const testFiles = tests.map((t) => path.relative(rootDir, t.path));
172
+ const testFiles = tests.map((t) => toRelativePosixPath(rootDir, t.path));
151
173
  const summary = createRunSummary();
152
174
  let caseEventChain = Promise.resolve();
175
+ // The device runs one test at a time, so a test that started and has not
176
+ // finished is the one the app was running if it crashes or disconnects.
177
+ let runningTest: TestRunnerTestStartedEvent | null = null;
153
178
  const unsubscribe = session.onTestRunnerEvent((event) => {
179
+ if (event.type === 'test-started') {
180
+ runningTest = event;
181
+ } else if (event.type === 'test-finished') {
182
+ runningTest = null;
183
+ }
184
+
154
185
  if (isHarnessCaseEvent(event)) {
155
186
  caseEventChain = caseEventChain.then(() =>
156
187
  event.type === 'test-started'
@@ -177,6 +208,11 @@ export const executeRun = async (
177
208
  };
178
209
 
179
210
  updateRunState();
211
+ session.lifecycle.emit({
212
+ type: 'phase',
213
+ phase: 'run.start',
214
+ attrs: { runId },
215
+ });
180
216
 
181
217
  await session.callHook('run:started', {
182
218
  runId,
@@ -206,8 +242,9 @@ export const executeRun = async (
206
242
  for (const test of tests) {
207
243
  if (watcher.isInterrupted()) throw new CancelRun();
208
244
 
209
- const relativeTestPath = path.relative(rootDir, test.path);
245
+ const relativeTestPath = toRelativePosixPath(rootDir, test.path);
210
246
  const fileStartedAt = Date.now();
247
+ runningTest = null;
211
248
  let emittedTestFileFinished = false;
212
249
 
213
250
  const emitTestFileFinished = async (options: {
@@ -227,6 +264,11 @@ export const executeRun = async (
227
264
 
228
265
  await session.callHook('test-file:started', { runId, file: relativeTestPath });
229
266
  const fileSpan = diagnostics.start('run.file', { file: relativeTestPath });
267
+ session.lifecycle.emit({
268
+ type: 'phase',
269
+ phase: 'test-file.start',
270
+ attrs: { file: relativeTestPath },
271
+ });
230
272
  let fileStatus: 'passed' | 'failed' | 'skipped' | 'todo' = 'failed';
231
273
 
232
274
  try {
@@ -304,7 +346,11 @@ export const executeRun = async (
304
346
  await session.restartApp(test.path);
305
347
  shouldRestartAfterTimeout = false;
306
348
  }
307
- } catch (err) {
349
+ } catch (error) {
350
+ const err =
351
+ error instanceof Error && error.name === 'HostSurfaceReplacedError'
352
+ ? new HostSurfaceReplacedError(test.path, error)
353
+ : error;
308
354
  fileStatus = 'failed';
309
355
  if (!emittedTestFileFinished) {
310
356
  await emitTestFileFinished({
@@ -314,14 +360,10 @@ export const executeRun = async (
314
360
  });
315
361
  }
316
362
 
317
- const isRuntimeFailure =
318
- err instanceof NativeCrashError ||
319
- err instanceof RuntimeDisconnectError ||
320
- err instanceof StartupStallError ||
321
- err instanceof AppBridgeDisconnectedError ||
322
- err instanceof DeviceNotRespondingError;
363
+ const isFileRuntimeFailure =
364
+ err instanceof HostSurfaceReplacedError || isRuntimeFailure(err);
323
365
 
324
- if (isRuntimeFailure) {
366
+ if (isFileRuntimeFailure) {
325
367
  summary.failed += 1;
326
368
  updateRunState();
327
369
  }
@@ -330,12 +372,26 @@ export const executeRun = async (
330
372
  session.resetCrashState();
331
373
  }
332
374
 
333
- updateRunState({ error: isRuntimeFailure ? undefined : err });
375
+ updateRunState({ error: isFileRuntimeFailure ? undefined : err });
334
376
  await caseEventChain;
335
- await emitEvent('test-file-failure', test, buildTestFailure(err));
377
+ await emitEvent(
378
+ 'test-file-failure',
379
+ test,
380
+ buildTestFailure(err, runningTest),
381
+ );
382
+ if (err instanceof HostSurfaceReplacedError) {
383
+ // Abandoned async test work may still be running in this process.
384
+ await session.restartApp(test.path);
385
+ isFirstTest = true;
386
+ }
336
387
  }
337
388
  } finally {
338
389
  fileSpan.end({ status: fileStatus });
390
+ session.lifecycle.emit({
391
+ type: 'phase',
392
+ phase: 'test-file.end',
393
+ attrs: { file: relativeTestPath, status: fileStatus },
394
+ });
339
395
  }
340
396
  }
341
397
  } catch (err) {
@@ -358,6 +414,12 @@ export const executeRun = async (
358
414
  await caseEventChain;
359
415
  unsubscribe();
360
416
  runSpan.end({ runId, status: finalStatus });
417
+ // Idle time between runs (watch mode) is not part of any run's metrics.
418
+ session.lifecycle.emit({
419
+ type: 'phase',
420
+ phase: 'run.end',
421
+ attrs: { runId, status: finalStatus },
422
+ });
361
423
 
362
424
  if (session.diagnostics.enabled) {
363
425
  const entries = session.diagnostics.flush();
@@ -365,6 +427,14 @@ export const executeRun = async (
365
427
  printSummary(entries, (line) => diagnosticsLogger.info(line));
366
428
  const tracePath = await writeTraceFile(entries, { projectRoot: rootDir, runId });
367
429
  diagnosticsLogger.info(`wrote diagnostics trace to ${tracePath}`);
430
+ const kpis = summarizeRunKpis(entries);
431
+ const summaryPath = await writeSummaryFile(kpis, {
432
+ projectRoot: rootDir,
433
+ runId,
434
+ sessionId: session.sessionId,
435
+ });
436
+ diagnosticsLogger.info(`wrote diagnostics summary to ${summaryPath}`);
437
+ await appendStepSummary(kpis);
368
438
  } catch (reportError) {
369
439
  diagnosticsLogger.warn('failed to write diagnostics report: %s', reportError);
370
440
  }
@@ -54,6 +54,11 @@ export const formatHarnessErrorMessage = (
54
54
  }
55
55
 
56
56
  const parts = [error.message];
57
+
58
+ if (error.diff) {
59
+ parts.push(error.diff);
60
+ }
61
+
57
62
  const pendingPromiseDetails = error.diagnostics?.pendingPromises
58
63
  ? formatPendingPromises(
59
64
  error.diagnostics.pendingPromises,
@@ -43,7 +43,13 @@ import {
43
43
  getTimeoutSignal,
44
44
  ensureSignalsDeliverable,
45
45
  waitForAbort,
46
+ checkHostHealth,
47
+ formatGitHubWarning,
48
+ formatSaturatedHostMessage,
49
+ isCI,
50
+ readProcessUsage,
46
51
  type Diagnostics,
52
+ type HostPressure,
47
53
  } from '@react-native-harness/tools';
48
54
  import {
49
55
  getConfig,
@@ -75,7 +81,12 @@ import {
75
81
  type ResourceLockManager,
76
82
  type ResourceLease,
77
83
  } from './resource-lock.js';
78
- import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
84
+ import {
85
+ observeBridgeEvents,
86
+ observeMetroEvents,
87
+ startSessionTelemetry,
88
+ type SessionTelemetry,
89
+ } from './diagnostics/index.js';
79
90
  import { resolveHarnessMetroPort } from './metro-port.js';
80
91
  import { getAdditionalCliArgs } from './cli-args.js';
81
92
  import {
@@ -96,6 +107,8 @@ const isBridgeDisconnectError = (error: unknown) =>
96
107
  error instanceof Error && error.message === 'App bridge disconnected';
97
108
  const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
98
109
  const DISPOSE_HOOK_TIMEOUT_MS = 5_000;
110
+ // Upper bound on waiting for end-of-run telemetry before tearing resources down.
111
+ const DISPOSE_TELEMETRY_TIMEOUT_MS = 10_000;
99
112
 
100
113
  const waitForBoundedSettlement = async <T>(
101
114
  promise: Promise<T>,
@@ -194,6 +207,10 @@ export type HarnessSession = {
194
207
  readonly config: HarnessConfig;
195
208
  readonly context: HarnessContext;
196
209
  readonly diagnostics: Diagnostics;
210
+ /** Identifies this session; names its incremental diagnostics log. */
211
+ readonly sessionId: string;
212
+ /** Run lifecycle phases, for telemetry probes (see `SessionTelemetry`). */
213
+ readonly lifecycle: SessionTelemetry['lifecycle'];
197
214
  onTestRunnerEvent: (listener: (event: TestRunnerEvents) => void) => () => void;
198
215
  runTestFile: (path: string, options: HarnessRunTestsOptions) => Promise<TestSuiteResult>;
199
216
  ensureAppReady: (testFilePath: string) => Promise<void>;
@@ -235,6 +252,8 @@ export const withPlatformReadyTimeout = async <T>(options: {
235
252
  timeout: number;
236
253
  signal: AbortSignal;
237
254
  work: (signal: AbortSignal) => Promise<T>;
255
+ /** Host pressure at the moment of the timeout, attached to the error. */
256
+ getHostPressure?: () => HostPressure | null;
238
257
  }): Promise<T> => {
239
258
  const timeoutSignal = getTimeoutSignal(options.timeout);
240
259
  const abortWait = waitForAbort(timeoutSignal);
@@ -242,7 +261,10 @@ export const withPlatformReadyTimeout = async <T>(options: {
242
261
  return await Promise.race([
243
262
  options.work(options.signal),
244
263
  abortWait.promise.catch(() => {
245
- throw new PlatformReadyTimeoutError(options.timeout);
264
+ throw new PlatformReadyTimeoutError(
265
+ options.timeout,
266
+ options.getHostPressure?.() ?? null
267
+ );
246
268
  }),
247
269
  ]);
248
270
  } finally {
@@ -250,6 +272,39 @@ export const withPlatformReadyTimeout = async <T>(options: {
250
272
  }
251
273
  };
252
274
 
275
+ // A saturated host is exactly where `ps` is slow; it took over 10s on CI.
276
+ const HOST_HEALTH_PROCESS_LISTING_TIMEOUT_MS = 30_000;
277
+
278
+ // Tells, before anything is booted, whether the host is already saturated by
279
+ // something other than this run. Only a warning: a saturated runner can still
280
+ // pass, but a later startup timeout then has an explanation.
281
+ const reportSaturatedHost = async (diagnostics: Diagnostics): Promise<void> => {
282
+ try {
283
+ const verdict = await checkHostHealth();
284
+ diagnostics.mark('telemetry.host_health', {
285
+ load1: verdict.load1,
286
+ cpuCount: verdict.cpuCount,
287
+ idlePct: verdict.idlePct ?? undefined,
288
+ saturated: verdict.saturated,
289
+ });
290
+ if (!verdict.saturated) {
291
+ return;
292
+ }
293
+
294
+ const processes = await readProcessUsage(
295
+ AbortSignal.timeout(HOST_HEALTH_PROCESS_LISTING_TIMEOUT_MS)
296
+ ).catch(() => null);
297
+ const message = formatSaturatedHostMessage(verdict, processes?.topByCpu);
298
+ sessionLogger.warn(message);
299
+ const annotation = formatGitHubWarning('Saturated CI runner', message);
300
+ if (annotation) {
301
+ process.stdout.write(`${annotation}\n`);
302
+ }
303
+ } catch (error) {
304
+ sessionLogger.debug('host health check failed: %s', error);
305
+ }
306
+ };
307
+
253
308
  type AppReadyOptions = {
254
309
  metroInstance: MetroInstance;
255
310
  bridge: HarnessBridge;
@@ -484,6 +539,9 @@ export const createHarnessSession = async (
484
539
 
485
540
  const setupStartedAt = Date.now();
486
541
  const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
542
+ // Metro platform for prewarm and startup detection. Vega apps request
543
+ // "kepler" bundles while the harness platform id stays "vega".
544
+ const bundlePlatform = platform.bundlePlatform ?? platform.platformId;
487
545
  applyEnvVars(harnessConfig, globalConfig);
488
546
 
489
547
  const diagnostics = createDiagnostics({
@@ -494,6 +552,25 @@ export const createHarnessSession = async (
494
552
  duration: Date.now() - setupStartedAt,
495
553
  });
496
554
 
555
+ // The one composition point for run telemetry. Everything below only emits
556
+ // lifecycle phases; probes translate them and polled data into diagnostics.
557
+ const telemetry = startSessionTelemetry({
558
+ diagnostics,
559
+ config: harnessConfig,
560
+ projectRoot,
561
+ });
562
+ telemetry.phase('session.created');
563
+ if (isCI()) {
564
+ void reportSaturatedHost(diagnostics);
565
+ }
566
+ // Telemetry runs timers and listens on `exit`, so a setup failure before the
567
+ // main try/catch below must still stop it.
568
+ const disposeTelemetryOnFailure = <T>(work: Promise<T>): Promise<T> =>
569
+ work.catch(async (error) => {
570
+ await telemetry.dispose();
571
+ throw error;
572
+ });
573
+
497
574
  sessionLogger.debug(
498
575
  'creating session for runner=%s platform=%s',
499
576
  platform.name,
@@ -515,13 +592,15 @@ export const createHarnessSession = async (
515
592
  process.once('SIGTERM', onEarlySignal);
516
593
  process.once('SIGINT', onEarlySignal);
517
594
 
518
- const resourceLockKey = await (platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform));
595
+ const resourceLockKey = await disposeTelemetryOnFailure(
596
+ Promise.resolve(platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform))
597
+ );
519
598
  let didWaitForResourceLock = false;
520
599
  let lastStillWaitingLogAt = 0;
521
600
 
522
601
  logTestRunHeader(platform);
523
602
 
524
- const resourceLease = await diagnostics.measure(
603
+ const resourceLease = await disposeTelemetryOnFailure(diagnostics.measure(
525
604
  'session.lock.wait',
526
605
  () =>
527
606
  lockManager.acquire(resourceLockKey, {
@@ -539,7 +618,7 @@ export const createHarnessSession = async (
539
618
  },
540
619
  }),
541
620
  { lockKey: resourceLockKey }
542
- );
621
+ ));
543
622
 
544
623
  if (didWaitForResourceLock) logRunnerStarting(platform);
545
624
  sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
@@ -590,7 +669,10 @@ export const createHarnessSession = async (
590
669
  const getCurrentRunId = () => currentRun?.runId;
591
670
  const clientLogCollector = createClientLogCollector();
592
671
 
593
- const context: HarnessContext = { platform };
672
+ const context = {
673
+ platform,
674
+ updateSnapshot: globalConfig.updateSnapshot,
675
+ } satisfies Required<HarnessContext>;
594
676
  const crashArtifactWriter = createCrashArtifactWriter({
595
677
  runnerName: platform.name,
596
678
  platformId: platform.platformId,
@@ -602,6 +684,11 @@ export const createHarnessSession = async (
602
684
  noServer: true,
603
685
  timeout: runtimeConfig.bridgeTimeout,
604
686
  context,
687
+ liveness: {
688
+ // Loading a test file runs user code that has no timeout of its
689
+ // own, so it gets the same allowance as starting the bundle.
690
+ jsBlockBudget: { fileLoadMs: runtimeConfig.bundleStartTimeout },
691
+ },
605
692
  })
606
693
  );
607
694
  sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
@@ -616,6 +703,29 @@ export const createHarnessSession = async (
616
703
  // Noop until the Metro instance resolves and the deriver is attached.
617
704
  let disposeMetroDiagnostics: () => void = () => undefined;
618
705
 
706
+ const eagerPrewarm = runtimeConfig.eagerPrewarm !== false;
707
+ // On small hosts the bundle build and the platform boot compete for the
708
+ // same few cores, so the platform may ask to boot first.
709
+ const deferPrewarm = eagerPrewarm && (platform.deferMetroPrewarm?.() ?? false);
710
+ if (deferPrewarm) {
711
+ sessionLogger.debug('deferring Metro prewarm until the platform is ready');
712
+ }
713
+ const startPrewarm = (instance: MetroInstance) => {
714
+ // prewarm() memoizes this first call, so its session-lifetime setup
715
+ // signal is the one that matters — later callers' signals cannot
716
+ // cancel it. Not awaited: callers must not wait for the bundle.
717
+ const prewarmSpan = diagnostics.start('metro.prewarm', { deferred: deferPrewarm });
718
+ instance
719
+ .prewarm({
720
+ platform: bundlePlatform,
721
+ signal: sessionController.signal,
722
+ })
723
+ .then((completed) => prewarmSpan.end({ completed }))
724
+ // Swallow AbortError on teardown so it never becomes an
725
+ // unhandled rejection; non-abort failures resolve false.
726
+ .catch((error) => prewarmSpan.fail(error));
727
+ };
728
+
619
729
  try {
620
730
  [metroInstance, platformInstance] = await Promise.all([
621
731
  diagnostics.measure('metro.init', () =>
@@ -638,24 +748,13 @@ export const createHarnessSession = async (
638
748
  }
639
749
  initialized.metro = instance;
640
750
  sessionLogger.debug('Metro initialized');
751
+ telemetry.phase('metro.ready');
641
752
  // Attach the Metro diagnostics deriver before the eager prewarm
642
753
  // fires so its bundle build/request events land in the trace.
643
754
  disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
644
- if (runtimeConfig.eagerPrewarm !== false) {
755
+ if (eagerPrewarm && !deferPrewarm) {
645
756
  // Kick off the first bundle build while the platform is still booting.
646
- // prewarm() memoizes this first call, so its session-lifetime setup
647
- // signal is the one that matters — later callers' signals cannot
648
- // cancel it. Not awaited: metro.init must resolve immediately.
649
- const prewarmSpan = diagnostics.start('metro.prewarm');
650
- instance
651
- .prewarm({
652
- platform: platform.platformId,
653
- signal: sessionController.signal,
654
- })
655
- .then((completed) => prewarmSpan.end({ completed }))
656
- // Swallow AbortError on teardown so it never becomes an
657
- // unhandled rejection; non-abort failures resolve false.
658
- .catch((error) => prewarmSpan.fail(error));
757
+ startPrewarm(instance);
659
758
  }
660
759
  return instance;
661
760
  })
@@ -666,6 +765,7 @@ export const createHarnessSession = async (
666
765
  withPlatformReadyTimeout({
667
766
  timeout: runtimeConfig.platformReadyTimeout,
668
767
  signal: sessionController.signal,
768
+ getHostPressure: telemetry.hostPressure,
669
769
  work: async (signal) => {
670
770
  return await import(platform.runner).then((module) =>
671
771
  module.default(platform.config, runtimeConfig, {
@@ -679,6 +779,8 @@ export const createHarnessSession = async (
679
779
  }
680
780
  initialized.platform = instance;
681
781
  sessionLogger.debug('platform runner initialized');
782
+ telemetry.attachPlatform(instance);
783
+ telemetry.phase('platform.ready');
682
784
  return instance;
683
785
  });
684
786
  },
@@ -700,6 +802,10 @@ export const createHarnessSession = async (
700
802
  throw error;
701
803
  }
702
804
 
805
+ if (deferPrewarm) {
806
+ startPrewarm(metroInstance);
807
+ }
808
+
703
809
  const appLaunchOptions = (platform.config as { appLaunchOptions?: AppLaunchOptions }).appLaunchOptions;
704
810
 
705
811
  let currentAppSession: AppSession | null = null;
@@ -731,7 +837,7 @@ export const createHarnessSession = async (
731
837
  const appReadyBaseOptions: AppReadyOptions = {
732
838
  metroInstance,
733
839
  bridge,
734
- platformId: platform.platformId,
840
+ platformId: bundlePlatform,
735
841
  bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
736
842
  readyTimeout: runtimeConfig.bridgeTimeout,
737
843
  maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
@@ -808,6 +914,7 @@ export const createHarnessSession = async (
808
914
  // Abort only cancels finite/in-flight work. Ownership remains explicit
809
915
  // below, so this cannot race a platform or app-session disposal path.
810
916
  sessionController.abort();
917
+ telemetry.phase('dispose');
811
918
 
812
919
  const hookWork = (async () => {
813
920
  await hooks.drain();
@@ -840,6 +947,13 @@ export const createHarnessSession = async (
840
947
  hookError = hookResult.reason;
841
948
  }
842
949
 
950
+ // End-of-run telemetry has to observe the platform before it is torn
951
+ // down, but must never be able to hold teardown up for long.
952
+ await waitForBoundedSettlement(
953
+ telemetry.settled(),
954
+ DISPOSE_TELEMETRY_TIMEOUT_MS
955
+ );
956
+
843
957
  if (runtimeConfig.forwardClientLogs) {
844
958
  metroInstance.events.removeListener(clientLogListener);
845
959
  }
@@ -900,7 +1014,7 @@ export const createHarnessSession = async (
900
1014
  };
901
1015
 
902
1016
  const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => {
903
- disposePromise ??= disposeOnce(reason);
1017
+ disposePromise ??= disposeOnce(reason).finally(() => telemetry.dispose());
904
1018
  return disposePromise;
905
1019
  };
906
1020
 
@@ -966,6 +1080,7 @@ export const createHarnessSession = async (
966
1080
  await hooks.drain();
967
1081
  sessionLogger.debug('app is ready for %s', testFilePath);
968
1082
  appReadySpan.end({ file: testFilePath, reused: false });
1083
+ telemetry.phase('app.ready', { file: testFilePath });
969
1084
  };
970
1085
 
971
1086
  // Shared by restartApp (crash/timeout recovery) and the 'process' reset
@@ -1118,6 +1233,8 @@ export const createHarnessSession = async (
1118
1233
  config: runtimeConfig,
1119
1234
  context,
1120
1235
  diagnostics,
1236
+ sessionId: telemetry.sessionId,
1237
+ lifecycle: telemetry.lifecycle,
1121
1238
  onTestRunnerEvent: (listener) => {
1122
1239
  testRunnerEventListeners.add(listener);
1123
1240
  return () => {
@@ -1141,6 +1258,9 @@ export const createHarnessSession = async (
1141
1258
  },
1142
1259
  setRunState: (state) => {
1143
1260
  currentRun = state;
1261
+ if (state) {
1262
+ telemetry.trackRun(state.runId);
1263
+ }
1144
1264
  },
1145
1265
  dispose: async (reason = 'normal') => {
1146
1266
  try {
@@ -1157,6 +1277,7 @@ export const createHarnessSession = async (
1157
1277
  // Cancels any inflight init work still holding sessionController.signal
1158
1278
  // (e.g. a platform init call abandoned after a readiness timeout race).
1159
1279
  sessionController.abort();
1280
+ await telemetry.dispose();
1160
1281
  await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
1161
1282
  if (receivedEarlySignal) {
1162
1283
  process.exit(1);
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  TestWatcher,
8
8
  } from 'jest-runner';
9
9
  import { createHarnessSession, type HarnessSession } from './harness-session.js';
10
+ import { getWatchSession } from './watch-session.js';
10
11
  import { executeRun } from './execute-run.js';
11
12
  import { HarnessError } from '@react-native-harness/tools';
12
13
  import { getErrorMessage } from './logs.js';
@@ -20,7 +21,6 @@ export default class JestHarness implements EmittingTestRunnerInterface {
20
21
  readonly supportsEventEmitters = true as const;
21
22
 
22
23
  #globalConfig: Config.GlobalConfig;
23
- #session: HarnessSession | null = null;
24
24
  #listeners = new Map<keyof TestEvents, Set<TestEventListener<keyof TestEvents>>>();
25
25
 
26
26
  constructor(globalConfig: Config.GlobalConfig) {
@@ -54,13 +54,15 @@ export default class JestHarness implements EmittingTestRunnerInterface {
54
54
 
55
55
  const isWatchMode = this.#globalConfig.watch || this.#globalConfig.watchAll;
56
56
 
57
+ let session: HarnessSession | null = null;
58
+
57
59
  try {
58
- if (!this.#session) {
59
- this.#session = await createHarnessSession(this.#globalConfig);
60
- }
60
+ session = isWatchMode
61
+ ? await getWatchSession(this.#globalConfig)
62
+ : await createHarnessSession(this.#globalConfig);
61
63
 
62
64
  await executeRun(
63
- this.#session,
65
+ session,
64
66
  tests,
65
67
  watcher,
66
68
  this.#emit,
@@ -73,8 +75,7 @@ export default class JestHarness implements EmittingTestRunnerInterface {
73
75
  throw error;
74
76
  } finally {
75
77
  if (!isWatchMode) {
76
- await this.#session?.dispose();
77
- this.#session = null;
78
+ await session?.dispose();
78
79
  }
79
80
  }
80
81
  }
@@ -1,3 +1,21 @@
1
1
  export default {
2
2
  runner: '@react-native-harness/jest',
3
+ // Tests run serially on the device, so Jest only uses its workers to build
4
+ // the haste map. On a cold cache or after any file change that starts a pool
5
+ // of `availableParallelism() - 1` processes, which roughly doubles Jest's
6
+ // CPU time without making the run faster.
7
+ maxWorkers: 1,
8
+ // Build and cache output never contains tests, but it can hold tens of
9
+ // thousands of files. Jest checks these patterns before descending into a
10
+ // directory, so its crawl skips them entirely. Jest appends a project's own
11
+ // patterns to the preset's.
12
+ modulePathIgnorePatterns: [
13
+ '/\\.harness(/|$)',
14
+ '/ios/(Pods|build)(/|$)',
15
+ '/android/(build|\\.gradle|app/build|app/\\.cxx)(/|$)',
16
+ ],
17
+ // With the build output skipped, Jest's own crawl is cheaper than querying
18
+ // watchman, which costs two CLI process spawns per run and still lists every
19
+ // file. Metro keeps using watchman.
20
+ watchman: false,
3
21
  };
@@ -0,0 +1,10 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Returns `filePath` relative to `rootDir`, always using `/` as the separator.
5
+ *
6
+ * These paths leave the host (they are sent to the device and used in Metro
7
+ * bundle URLs and plugin hooks), so they must not depend on the host OS.
8
+ */
9
+ export const toRelativePosixPath = (rootDir: string, filePath: string): string =>
10
+ path.relative(rootDir, filePath).split(path.sep).join('/');