@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
package/dist/errors.js CHANGED
@@ -1,4 +1,4 @@
1
- import { formatPath, HarnessError } from '@react-native-harness/tools';
1
+ import { formatHostPressure, formatPath, HarnessError, isHostStarved, } from '@react-native-harness/tools';
2
2
  export { StartupStallError, } from '@react-native-harness/bundler-metro';
3
3
  export class NoRunnerSpecifiedError extends HarnessError {
4
4
  constructor() {
@@ -20,13 +20,28 @@ export class InitializationTimeoutError extends HarnessError {
20
20
  this.name = 'InitializationTimeoutError';
21
21
  }
22
22
  }
23
+ const buildPlatformReadyTimeoutMessage = (timeout, hostPressure) => {
24
+ const prefix = `The platform did not become ready within ${timeout}ms.`;
25
+ if (hostPressure && isHostStarved(hostPressure)) {
26
+ return `${prefix} The host was starved for resources during startup (${formatHostPressure(hostPressure)}). A larger "platformReadyTimeout" is unlikely to help; reduce other load on the host or use a larger machine.`;
27
+ }
28
+ const suggestion = `Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.`;
29
+ return hostPressure
30
+ ? `${prefix} ${suggestion} Host during startup: ${formatHostPressure(hostPressure)}.`
31
+ : `${prefix} ${suggestion}`;
32
+ };
23
33
  export class PlatformReadyTimeoutError extends HarnessError {
24
34
  timeout;
25
- constructor(timeout) {
26
- super(`The platform did not become ready within ${timeout}ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.`);
35
+ hostPressure;
36
+ constructor(timeout, hostPressure = null) {
37
+ super(buildPlatformReadyTimeoutMessage(timeout, hostPressure));
27
38
  this.timeout = timeout;
39
+ this.hostPressure = hostPressure;
28
40
  this.name = 'PlatformReadyTimeoutError';
29
41
  }
42
+ get hostStarved() {
43
+ return this.hostPressure !== null && isHostStarved(this.hostPressure);
44
+ }
30
45
  }
31
46
  export class MetroPortRangeExhaustedError extends HarnessError {
32
47
  initialPort;
@@ -128,3 +143,14 @@ export class RuntimeDisconnectError extends HarnessError {
128
143
  return this.details.phase;
129
144
  }
130
145
  }
146
+ // The runtime sends this name through the runTests RPC when its native host
147
+ // surface is replaced while a test file is still running.
148
+ export class HostSurfaceReplacedError extends HarnessError {
149
+ testFilePath;
150
+ constructor(testFilePath, cause) {
151
+ super(cause.message, { cause });
152
+ this.testFilePath = testFilePath;
153
+ this.name = 'HostSurfaceReplacedError';
154
+ this.stack = cause.stack;
155
+ }
156
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"execute-run.d.ts","sourceRoot":"","sources":["../src/execute-run.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EACJ,WAAW,EACX,MAAM,EACN,UAAU,EACX,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,KAAK,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AA8BjF,KAAK,aAAa,GAAG,CAAC,IAAI,SAAS,MAAM,UAAU,EACjD,SAAS,EAAE,IAAI,EACf,GAAG,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,KAC3B,OAAO,CAAC,IAAI,CAAC,CAAC;AA+FnB,eAAO,MAAM,UAAU,GACrB,SAAS,cAAc,EACvB,OAAO,KAAK,CAAC,IAAI,CAAC,EAClB,SAAS,WAAW,EACpB,WAAW,aAAa,EACxB,cAAc,MAAM,CAAC,YAAY,KAChC,OAAO,CAAC,IAAI,CAsOd,CAAC"}
1
+ {"version":3,"file":"execute-run.d.ts","sourceRoot":"","sources":["../src/execute-run.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EACJ,WAAW,EACX,MAAM,EACN,UAAU,EACX,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,KAAK,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AAsCjF,KAAK,aAAa,GAAG,CAAC,IAAI,SAAS,MAAM,UAAU,EACjD,SAAS,EAAE,IAAI,EACf,GAAG,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,KAC3B,OAAO,CAAC,IAAI,CAAC,CAAC;AA8GnB,eAAO,MAAM,UAAU,GACrB,SAAS,cAAc,EACvB,OAAO,KAAK,CAAC,IAAI,CAAC,EAClB,SAAS,WAAW,EACpB,WAAW,aAAa,EACxB,cAAc,MAAM,CAAC,YAAY,KAChC,OAAO,CAAC,IAAI,CAsRd,CAAC"}
@@ -1,13 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import path from 'node:path';
3
2
  import { runHarnessTestFile } from './run.js';
3
+ import { toRelativePosixPath } from './relative-path.js';
4
4
  import { resolveResetStrategyKind } from './environment-reset.js';
5
- import { NativeCrashError, RuntimeDisconnectError, StartupStallError, } from './errors.js';
5
+ import { HostSurfaceReplacedError, NativeCrashError, RuntimeDisconnectError, StartupStallError, } from './errors.js';
6
6
  import { AppBridgeDisconnectedError, DeviceNotRespondingError, } from '@react-native-harness/bridge/server';
7
7
  import { createPlatformSkippedTestResult, shouldRunHarnessTestFile, } from './test-file-platform-filter.js';
8
8
  import { formatHarnessErrorMessage } from './format-harness-error.js';
9
9
  import { logger } from '@react-native-harness/tools';
10
- import { printSummary, writeTraceFile } from './diagnostics/index.js';
10
+ import { appendStepSummary, printSummary, summarizeRunKpis, writeSummaryFile, writeTraceFile, } from './diagnostics/index.js';
11
11
  import { toJestStatus } from './toJestStatus.js';
12
12
  const diagnosticsLogger = logger.child('diagnostics');
13
13
  const createRunSummary = () => ({ passed: 0, failed: 0, skipped: 0, todo: 0 });
@@ -23,13 +23,22 @@ class CancelRun extends Error {
23
23
  this.name = 'CancelRun';
24
24
  }
25
25
  }
26
- const buildTestFailure = (err) => {
27
- if (err instanceof NativeCrashError ||
28
- err instanceof RuntimeDisconnectError ||
29
- err instanceof StartupStallError ||
30
- err instanceof AppBridgeDisconnectedError ||
31
- err instanceof DeviceNotRespondingError) {
32
- return { message: err.message, stack: '' };
26
+ const isRuntimeFailure = (err) => err instanceof NativeCrashError ||
27
+ err instanceof RuntimeDisconnectError ||
28
+ err instanceof StartupStallError ||
29
+ err instanceof AppBridgeDisconnectedError ||
30
+ err instanceof DeviceNotRespondingError;
31
+ const formatRunningTest = (test) => `Running test: "${[...test.ancestorTitles, test.name].join(' › ')}"`;
32
+ const buildTestFailure = (err, runningTest = null) => {
33
+ if (isRuntimeFailure(err)) {
34
+ if (!runningTest) {
35
+ return { message: err.message, stack: '' };
36
+ }
37
+ const [headline, ...details] = err.message.split('\n');
38
+ return {
39
+ message: [headline, formatRunningTest(runningTest), ...details].join('\n'),
40
+ stack: '',
41
+ };
33
42
  }
34
43
  return err;
35
44
  };
@@ -80,10 +89,19 @@ export const executeRun = async (session, tests, watcher, emitEvent, globalConfi
80
89
  const startTime = Date.now();
81
90
  const watchMode = globalConfig.watch || globalConfig.watchAll;
82
91
  const rootDir = globalConfig.rootDir ?? process.cwd();
83
- const testFiles = tests.map((t) => path.relative(rootDir, t.path));
92
+ const testFiles = tests.map((t) => toRelativePosixPath(rootDir, t.path));
84
93
  const summary = createRunSummary();
85
94
  let caseEventChain = Promise.resolve();
95
+ // The device runs one test at a time, so a test that started and has not
96
+ // finished is the one the app was running if it crashes or disconnects.
97
+ let runningTest = null;
86
98
  const unsubscribe = session.onTestRunnerEvent((event) => {
99
+ if (event.type === 'test-started') {
100
+ runningTest = event;
101
+ }
102
+ else if (event.type === 'test-finished') {
103
+ runningTest = null;
104
+ }
87
105
  if (isHarnessCaseEvent(event)) {
88
106
  caseEventChain = caseEventChain.then(() => event.type === 'test-started'
89
107
  ? emitHarnessTestStarted(emitEvent, event)
@@ -106,6 +124,11 @@ export const executeRun = async (session, tests, watcher, emitEvent, globalConfi
106
124
  return state;
107
125
  };
108
126
  updateRunState();
127
+ session.lifecycle.emit({
128
+ type: 'phase',
129
+ phase: 'run.start',
130
+ attrs: { runId },
131
+ });
109
132
  await session.callHook('run:started', {
110
133
  runId,
111
134
  startTime,
@@ -128,8 +151,9 @@ export const executeRun = async (session, tests, watcher, emitEvent, globalConfi
128
151
  for (const test of tests) {
129
152
  if (watcher.isInterrupted())
130
153
  throw new CancelRun();
131
- const relativeTestPath = path.relative(rootDir, test.path);
154
+ const relativeTestPath = toRelativePosixPath(rootDir, test.path);
132
155
  const fileStartedAt = Date.now();
156
+ runningTest = null;
133
157
  let emittedTestFileFinished = false;
134
158
  const emitTestFileFinished = async (options) => {
135
159
  emittedTestFileFinished = true;
@@ -143,6 +167,11 @@ export const executeRun = async (session, tests, watcher, emitEvent, globalConfi
143
167
  };
144
168
  await session.callHook('test-file:started', { runId, file: relativeTestPath });
145
169
  const fileSpan = diagnostics.start('run.file', { file: relativeTestPath });
170
+ session.lifecycle.emit({
171
+ type: 'phase',
172
+ phase: 'test-file.start',
173
+ attrs: { file: relativeTestPath },
174
+ });
146
175
  let fileStatus = 'failed';
147
176
  try {
148
177
  if (!shouldRunHarnessTestFile(test.path, platformId, knownPlatformIds)) {
@@ -216,7 +245,10 @@ export const executeRun = async (session, tests, watcher, emitEvent, globalConfi
216
245
  shouldRestartAfterTimeout = false;
217
246
  }
218
247
  }
219
- catch (err) {
248
+ catch (error) {
249
+ const err = error instanceof Error && error.name === 'HostSurfaceReplacedError'
250
+ ? new HostSurfaceReplacedError(test.path, error)
251
+ : error;
220
252
  fileStatus = 'failed';
221
253
  if (!emittedTestFileFinished) {
222
254
  await emitTestFileFinished({
@@ -225,25 +257,31 @@ export const executeRun = async (session, tests, watcher, emitEvent, globalConfi
225
257
  result: null,
226
258
  });
227
259
  }
228
- const isRuntimeFailure = err instanceof NativeCrashError ||
229
- err instanceof RuntimeDisconnectError ||
230
- err instanceof StartupStallError ||
231
- err instanceof AppBridgeDisconnectedError ||
232
- err instanceof DeviceNotRespondingError;
233
- if (isRuntimeFailure) {
260
+ const isFileRuntimeFailure = err instanceof HostSurfaceReplacedError || isRuntimeFailure(err);
261
+ if (isFileRuntimeFailure) {
234
262
  summary.failed += 1;
235
263
  updateRunState();
236
264
  }
237
265
  if (err instanceof NativeCrashError || err instanceof RuntimeDisconnectError) {
238
266
  session.resetCrashState();
239
267
  }
240
- updateRunState({ error: isRuntimeFailure ? undefined : err });
268
+ updateRunState({ error: isFileRuntimeFailure ? undefined : err });
241
269
  await caseEventChain;
242
- await emitEvent('test-file-failure', test, buildTestFailure(err));
270
+ await emitEvent('test-file-failure', test, buildTestFailure(err, runningTest));
271
+ if (err instanceof HostSurfaceReplacedError) {
272
+ // Abandoned async test work may still be running in this process.
273
+ await session.restartApp(test.path);
274
+ isFirstTest = true;
275
+ }
243
276
  }
244
277
  }
245
278
  finally {
246
279
  fileSpan.end({ status: fileStatus });
280
+ session.lifecycle.emit({
281
+ type: 'phase',
282
+ phase: 'test-file.end',
283
+ attrs: { file: relativeTestPath, status: fileStatus },
284
+ });
247
285
  }
248
286
  }
249
287
  }
@@ -267,12 +305,26 @@ export const executeRun = async (session, tests, watcher, emitEvent, globalConfi
267
305
  await caseEventChain;
268
306
  unsubscribe();
269
307
  runSpan.end({ runId, status: finalStatus });
308
+ // Idle time between runs (watch mode) is not part of any run's metrics.
309
+ session.lifecycle.emit({
310
+ type: 'phase',
311
+ phase: 'run.end',
312
+ attrs: { runId, status: finalStatus },
313
+ });
270
314
  if (session.diagnostics.enabled) {
271
315
  const entries = session.diagnostics.flush();
272
316
  try {
273
317
  printSummary(entries, (line) => diagnosticsLogger.info(line));
274
318
  const tracePath = await writeTraceFile(entries, { projectRoot: rootDir, runId });
275
319
  diagnosticsLogger.info(`wrote diagnostics trace to ${tracePath}`);
320
+ const kpis = summarizeRunKpis(entries);
321
+ const summaryPath = await writeSummaryFile(kpis, {
322
+ projectRoot: rootDir,
323
+ runId,
324
+ sessionId: session.sessionId,
325
+ });
326
+ diagnosticsLogger.info(`wrote diagnostics summary to ${summaryPath}`);
327
+ await appendStepSummary(kpis);
276
328
  }
277
329
  catch (reportError) {
278
330
  diagnosticsLogger.warn('failed to write diagnostics report: %s', reportError);
@@ -1 +1 @@
1
- {"version":3,"file":"format-harness-error.d.ts","sourceRoot":"","sources":["../src/format-harness-error.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AA6CpE,eAAO,MAAM,yBAAyB,GACpC,OAAO,eAAe,GAAG,SAAS,EAClC,UAAS;IACP,aAAa,CAAC,EAAE,MAAM,CAAC;CACnB,KACL,MAAM,GAAG,SAsBX,CAAC"}
1
+ {"version":3,"file":"format-harness-error.d.ts","sourceRoot":"","sources":["../src/format-harness-error.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AA6CpE,eAAO,MAAM,yBAAyB,GACpC,OAAO,eAAe,GAAG,SAAS,EAClC,UAAS;IACP,aAAa,CAAC,EAAE,MAAM,CAAC;CACnB,KACL,MAAM,GAAG,SA2BX,CAAC"}
@@ -30,6 +30,9 @@ export const formatHarnessErrorMessage = (error, options = {}) => {
30
30
  return undefined;
31
31
  }
32
32
  const parts = [error.message];
33
+ if (error.diff) {
34
+ parts.push(error.diff);
35
+ }
33
36
  const pendingPromiseDetails = error.diagnostics?.pendingPromises
34
37
  ? formatPendingPromises(error.diagnostics.pendingPromises, options.testStartedAt)
35
38
  : null;
@@ -2,12 +2,13 @@ import { type HarnessBridge, type AppConnection } from '@react-native-harness/br
2
2
  import { type HarnessContext, type TestRunnerEvents, type TestExecutionOptions, type TestSuiteResult } from '@react-native-harness/bridge';
3
3
  import { type HarnessPlatform } from '@react-native-harness/platforms';
4
4
  import { type HarnessPluginManager, type HarnessRunStatus, type HarnessRunSummary } from '@react-native-harness/plugins';
5
- import { type Diagnostics } from '@react-native-harness/tools';
5
+ import { type Diagnostics, type HostPressure } from '@react-native-harness/tools';
6
6
  import { type Config as HarnessConfig } from '@react-native-harness/config';
7
7
  import type { Config as JestConfig } from 'jest-runner';
8
8
  import { type CrashMonitor } from './crash-monitor.js';
9
9
  import { type ClientLogBuffer } from './client-log-handler.js';
10
10
  import { type ResourceLockManager } from './resource-lock.js';
11
+ import { type SessionTelemetry } from './diagnostics/index.js';
11
12
  type DisconnectObservableBridge = {
12
13
  readonly connection: AppConnection | null;
13
14
  on: (event: 'disconnected', listener: () => void) => void;
@@ -38,6 +39,10 @@ export type HarnessSession = {
38
39
  readonly config: HarnessConfig;
39
40
  readonly context: HarnessContext;
40
41
  readonly diagnostics: Diagnostics;
42
+ /** Identifies this session; names its incremental diagnostics log. */
43
+ readonly sessionId: string;
44
+ /** Run lifecycle phases, for telemetry probes (see `SessionTelemetry`). */
45
+ readonly lifecycle: SessionTelemetry['lifecycle'];
41
46
  onTestRunnerEvent: (listener: (event: TestRunnerEvents) => void) => () => void;
42
47
  runTestFile: (path: string, options: HarnessRunTestsOptions) => Promise<TestSuiteResult>;
43
48
  ensureAppReady: (testFilePath: string) => Promise<void>;
@@ -54,6 +59,8 @@ export declare const withPlatformReadyTimeout: <T>(options: {
54
59
  timeout: number;
55
60
  signal: AbortSignal;
56
61
  work: (signal: AbortSignal) => Promise<T>;
62
+ /** Host pressure at the moment of the timeout, attached to the error. */
63
+ getHostPressure?: () => HostPressure | null;
57
64
  }) => Promise<T>;
58
65
  export declare const waitForStartupCrash: ({ crashMonitor, detectNativeCrashes, testFilePath, signal, }: {
59
66
  crashMonitor: CrashMonitor;
@@ -1 +1 @@
1
- {"version":3,"file":"harness-session.d.ts","sourceRoot":"","sources":["../src/harness-session.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"harness-session.d.ts","sourceRoot":"","sources":["../src/harness-session.ts"],"names":[],"mappings":"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"}
@@ -3,7 +3,7 @@ import { HARNESS_BRIDGE_PATH, } from '@react-native-harness/bridge';
3
3
  import { createHarnessCache } from '@react-native-harness/cache';
4
4
  import { getMetroInstance, waitForMetroBackedAppReady, } from '@react-native-harness/bundler-metro';
5
5
  import { createHarnessPluginManager, } from '@react-native-harness/plugins';
6
- import { createCrashArtifactWriter, createDiagnostics, delay, logger, getTimeoutSignal, ensureSignalsDeliverable, waitForAbort, } from '@react-native-harness/tools';
6
+ import { createCrashArtifactWriter, createDiagnostics, delay, logger, getTimeoutSignal, ensureSignalsDeliverable, waitForAbort, checkHostHealth, formatGitHubWarning, formatSaturatedHostMessage, isCI, readProcessUsage, } from '@react-native-harness/tools';
7
7
  import { getConfig, isDiagnosticsEnabled, resolveMetroCacheEnabled, ConfigSchema, } from '@react-native-harness/config';
8
8
  import { preRunMessage } from 'jest-util';
9
9
  import path from 'node:path';
@@ -15,7 +15,7 @@ import { createHookQueue } from './hook-queue.js';
15
15
  import { createClientLogCollector, } from './client-log-handler.js';
16
16
  import { createActionHooksPlugin } from './action-hooks.js';
17
17
  import { createResourceLockManager, } from './resource-lock.js';
18
- import { observeBridgeEvents, observeMetroEvents } from './diagnostics/index.js';
18
+ import { observeBridgeEvents, observeMetroEvents, startSessionTelemetry, } from './diagnostics/index.js';
19
19
  import { resolveHarnessMetroPort } from './metro-port.js';
20
20
  import { getAdditionalCliArgs } from './cli-args.js';
21
21
  import { logMetroCacheReused, logMetroPortFallback, logNativeCoverageCollected, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, logTestEnvironmentReady, logTestRunHeader, } from './logs.js';
@@ -25,6 +25,8 @@ const ignorePromiseRejection = () => undefined;
25
25
  const isBridgeDisconnectError = (error) => error instanceof Error && error.message === 'App bridge disconnected';
26
26
  const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
27
27
  const DISPOSE_HOOK_TIMEOUT_MS = 5_000;
28
+ // Upper bound on waiting for end-of-run telemetry before tearing resources down.
29
+ const DISPOSE_TELEMETRY_TIMEOUT_MS = 10_000;
28
30
  const waitForBoundedSettlement = async (promise, timeoutMs) => {
29
31
  const timeout = delay(timeoutMs);
30
32
  try {
@@ -99,7 +101,7 @@ export const withPlatformReadyTimeout = async (options) => {
99
101
  return await Promise.race([
100
102
  options.work(options.signal),
101
103
  abortWait.promise.catch(() => {
102
- throw new PlatformReadyTimeoutError(options.timeout);
104
+ throw new PlatformReadyTimeoutError(options.timeout, options.getHostPressure?.() ?? null);
103
105
  }),
104
106
  ]);
105
107
  }
@@ -107,6 +109,35 @@ export const withPlatformReadyTimeout = async (options) => {
107
109
  abortWait.cancel();
108
110
  }
109
111
  };
112
+ // A saturated host is exactly where `ps` is slow; it took over 10s on CI.
113
+ const HOST_HEALTH_PROCESS_LISTING_TIMEOUT_MS = 30_000;
114
+ // Tells, before anything is booted, whether the host is already saturated by
115
+ // something other than this run. Only a warning: a saturated runner can still
116
+ // pass, but a later startup timeout then has an explanation.
117
+ const reportSaturatedHost = async (diagnostics) => {
118
+ try {
119
+ const verdict = await checkHostHealth();
120
+ diagnostics.mark('telemetry.host_health', {
121
+ load1: verdict.load1,
122
+ cpuCount: verdict.cpuCount,
123
+ idlePct: verdict.idlePct ?? undefined,
124
+ saturated: verdict.saturated,
125
+ });
126
+ if (!verdict.saturated) {
127
+ return;
128
+ }
129
+ const processes = await readProcessUsage(AbortSignal.timeout(HOST_HEALTH_PROCESS_LISTING_TIMEOUT_MS)).catch(() => null);
130
+ const message = formatSaturatedHostMessage(verdict, processes?.topByCpu);
131
+ sessionLogger.warn(message);
132
+ const annotation = formatGitHubWarning('Saturated CI runner', message);
133
+ if (annotation) {
134
+ process.stdout.write(`${annotation}\n`);
135
+ }
136
+ }
137
+ catch (error) {
138
+ sessionLogger.debug('host health check failed: %s', error);
139
+ }
140
+ };
110
141
  export const waitForStartupCrash = async ({ crashMonitor, detectNativeCrashes, testFilePath, signal, }) => {
111
142
  if (!detectNativeCrashes) {
112
143
  return await waitForAbort(signal).promise;
@@ -257,6 +288,9 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
257
288
  preRunMessage.remove(process.stderr);
258
289
  const setupStartedAt = Date.now();
259
290
  const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig);
291
+ // Metro platform for prewarm and startup detection. Vega apps request
292
+ // "kepler" bundles while the harness platform id stays "vega".
293
+ const bundlePlatform = platform.bundlePlatform ?? platform.platformId;
260
294
  applyEnvVars(harnessConfig, globalConfig);
261
295
  const diagnostics = createDiagnostics({
262
296
  enabled: isDiagnosticsEnabled(harnessConfig),
@@ -265,6 +299,23 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
265
299
  label: 'session.config.load',
266
300
  duration: Date.now() - setupStartedAt,
267
301
  });
302
+ // The one composition point for run telemetry. Everything below only emits
303
+ // lifecycle phases; probes translate them and polled data into diagnostics.
304
+ const telemetry = startSessionTelemetry({
305
+ diagnostics,
306
+ config: harnessConfig,
307
+ projectRoot,
308
+ });
309
+ telemetry.phase('session.created');
310
+ if (isCI()) {
311
+ void reportSaturatedHost(diagnostics);
312
+ }
313
+ // Telemetry runs timers and listens on `exit`, so a setup failure before the
314
+ // main try/catch below must still stop it.
315
+ const disposeTelemetryOnFailure = (work) => work.catch(async (error) => {
316
+ await telemetry.dispose();
317
+ throw error;
318
+ });
268
319
  sessionLogger.debug('creating session for runner=%s platform=%s', platform.name, platform.platformId);
269
320
  // Single, session-lifetime AbortController: it aborts on early SIGTERM/SIGINT
270
321
  // during setup (below) and, once the session is up, in dispose()'s finally
@@ -280,11 +331,11 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
280
331
  };
281
332
  process.once('SIGTERM', onEarlySignal);
282
333
  process.once('SIGINT', onEarlySignal);
283
- const resourceLockKey = await (platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform));
334
+ const resourceLockKey = await disposeTelemetryOnFailure(Promise.resolve(platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform)));
284
335
  let didWaitForResourceLock = false;
285
336
  let lastStillWaitingLogAt = 0;
286
337
  logTestRunHeader(platform);
287
- const resourceLease = await diagnostics.measure('session.lock.wait', () => lockManager.acquire(resourceLockKey, {
338
+ const resourceLease = await disposeTelemetryOnFailure(diagnostics.measure('session.lock.wait', () => lockManager.acquire(resourceLockKey, {
288
339
  signal: sessionController.signal,
289
340
  onWait: () => {
290
341
  didWaitForResourceLock = true;
@@ -298,7 +349,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
298
349
  logRunnerStillWaitingInQueue(platform);
299
350
  sessionLogger.debug('still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, elapsedMs);
300
351
  },
301
- }), { lockKey: resourceLockKey });
352
+ }), { lockKey: resourceLockKey }));
302
353
  if (didWaitForResourceLock)
303
354
  logRunnerStarting(platform);
304
355
  sessionLogger.debug('resource lock acquired for runner=%s key=%s', platform.name, resourceLockKey);
@@ -344,7 +395,10 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
344
395
  let currentRun = null;
345
396
  const getCurrentRunId = () => currentRun?.runId;
346
397
  const clientLogCollector = createClientLogCollector();
347
- const context = { platform };
398
+ const context = {
399
+ platform,
400
+ updateSnapshot: globalConfig.updateSnapshot,
401
+ };
348
402
  const crashArtifactWriter = createCrashArtifactWriter({
349
403
  runnerName: platform.name,
350
404
  platformId: platform.platformId,
@@ -354,6 +408,11 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
354
408
  noServer: true,
355
409
  timeout: runtimeConfig.bridgeTimeout,
356
410
  context,
411
+ liveness: {
412
+ // Loading a test file runs user code that has no timeout of its
413
+ // own, so it gets the same allowance as starting the bundle.
414
+ jsBlockBudget: { fileLoadMs: runtimeConfig.bundleStartTimeout },
415
+ },
357
416
  }));
358
417
  sessionLogger.debug('bridge initialized on Metro websocket path %s', HARNESS_BRIDGE_PATH);
359
418
  let metroInstance;
@@ -365,6 +424,28 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
365
424
  let initializationCancelled = false;
366
425
  // Noop until the Metro instance resolves and the deriver is attached.
367
426
  let disposeMetroDiagnostics = () => undefined;
427
+ const eagerPrewarm = runtimeConfig.eagerPrewarm !== false;
428
+ // On small hosts the bundle build and the platform boot compete for the
429
+ // same few cores, so the platform may ask to boot first.
430
+ const deferPrewarm = eagerPrewarm && (platform.deferMetroPrewarm?.() ?? false);
431
+ if (deferPrewarm) {
432
+ sessionLogger.debug('deferring Metro prewarm until the platform is ready');
433
+ }
434
+ const startPrewarm = (instance) => {
435
+ // prewarm() memoizes this first call, so its session-lifetime setup
436
+ // signal is the one that matters — later callers' signals cannot
437
+ // cancel it. Not awaited: callers must not wait for the bundle.
438
+ const prewarmSpan = diagnostics.start('metro.prewarm', { deferred: deferPrewarm });
439
+ instance
440
+ .prewarm({
441
+ platform: bundlePlatform,
442
+ signal: sessionController.signal,
443
+ })
444
+ .then((completed) => prewarmSpan.end({ completed }))
445
+ // Swallow AbortError on teardown so it never becomes an
446
+ // unhandled rejection; non-abort failures resolve false.
447
+ .catch((error) => prewarmSpan.fail(error));
448
+ };
368
449
  try {
369
450
  [metroInstance, platformInstance] = await Promise.all([
370
451
  diagnostics.measure('metro.init', () => getMetroInstance({
@@ -383,30 +464,20 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
383
464
  }
384
465
  initialized.metro = instance;
385
466
  sessionLogger.debug('Metro initialized');
467
+ telemetry.phase('metro.ready');
386
468
  // Attach the Metro diagnostics deriver before the eager prewarm
387
469
  // fires so its bundle build/request events land in the trace.
388
470
  disposeMetroDiagnostics = observeMetroEvents(instance.events, diagnostics);
389
- if (runtimeConfig.eagerPrewarm !== false) {
471
+ if (eagerPrewarm && !deferPrewarm) {
390
472
  // Kick off the first bundle build while the platform is still booting.
391
- // prewarm() memoizes this first call, so its session-lifetime setup
392
- // signal is the one that matters — later callers' signals cannot
393
- // cancel it. Not awaited: metro.init must resolve immediately.
394
- const prewarmSpan = diagnostics.start('metro.prewarm');
395
- instance
396
- .prewarm({
397
- platform: platform.platformId,
398
- signal: sessionController.signal,
399
- })
400
- .then((completed) => prewarmSpan.end({ completed }))
401
- // Swallow AbortError on teardown so it never becomes an
402
- // unhandled rejection; non-abort failures resolve false.
403
- .catch((error) => prewarmSpan.fail(error));
473
+ startPrewarm(instance);
404
474
  }
405
475
  return instance;
406
476
  })),
407
477
  diagnostics.measure('platform.init', () => withPlatformReadyTimeout({
408
478
  timeout: runtimeConfig.platformReadyTimeout,
409
479
  signal: sessionController.signal,
480
+ getHostPressure: telemetry.hostPressure,
410
481
  work: async (signal) => {
411
482
  return await import(platform.runner).then((module) => module.default(platform.config, runtimeConfig, {
412
483
  signal,
@@ -418,6 +489,8 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
418
489
  }
419
490
  initialized.platform = instance;
420
491
  sessionLogger.debug('platform runner initialized');
492
+ telemetry.attachPlatform(instance);
493
+ telemetry.phase('platform.ready');
421
494
  return instance;
422
495
  });
423
496
  },
@@ -437,6 +510,9 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
437
510
  ]);
438
511
  throw error;
439
512
  }
513
+ if (deferPrewarm) {
514
+ startPrewarm(metroInstance);
515
+ }
440
516
  const appLaunchOptions = platform.config.appLaunchOptions;
441
517
  let currentAppSession = null;
442
518
  const crashMonitor = createCrashMonitor();
@@ -462,7 +538,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
462
538
  const appReadyBaseOptions = {
463
539
  metroInstance,
464
540
  bridge,
465
- platformId: platform.platformId,
541
+ platformId: bundlePlatform,
466
542
  bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000,
467
543
  readyTimeout: runtimeConfig.bridgeTimeout,
468
544
  maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2,
@@ -525,6 +601,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
525
601
  // Abort only cancels finite/in-flight work. Ownership remains explicit
526
602
  // below, so this cannot race a platform or app-session disposal path.
527
603
  sessionController.abort();
604
+ telemetry.phase('dispose');
528
605
  const hookWork = (async () => {
529
606
  await hooks.drain();
530
607
  await pluginManager.callHook('harness:after-run', {
@@ -551,6 +628,9 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
551
628
  else if (hookResult.status === 'rejected') {
552
629
  hookError = hookResult.reason;
553
630
  }
631
+ // End-of-run telemetry has to observe the platform before it is torn
632
+ // down, but must never be able to hold teardown up for long.
633
+ await waitForBoundedSettlement(telemetry.settled(), DISPOSE_TELEMETRY_TIMEOUT_MS);
554
634
  if (runtimeConfig.forwardClientLogs) {
555
635
  metroInstance.events.removeListener(clientLogListener);
556
636
  }
@@ -607,7 +687,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
607
687
  disposeSpan.end();
608
688
  };
609
689
  const dispose = (reason = 'normal') => {
610
- disposePromise ??= disposeOnce(reason);
690
+ disposePromise ??= disposeOnce(reason).finally(() => telemetry.dispose());
611
691
  return disposePromise;
612
692
  };
613
693
  // Switch from setup-phase signal handling to dispose-based handling now
@@ -664,6 +744,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
664
744
  await hooks.drain();
665
745
  sessionLogger.debug('app is ready for %s', testFilePath);
666
746
  appReadySpan.end({ file: testFilePath, reused: false });
747
+ telemetry.phase('app.ready', { file: testFilePath });
667
748
  };
668
749
  // Shared by restartApp (crash/timeout recovery) and the 'process' reset
669
750
  // strategy: kill the current app session and either wait for a fresh one
@@ -789,6 +870,8 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
789
870
  config: runtimeConfig,
790
871
  context,
791
872
  diagnostics,
873
+ sessionId: telemetry.sessionId,
874
+ lifecycle: telemetry.lifecycle,
792
875
  onTestRunnerEvent: (listener) => {
793
876
  testRunnerEventListeners.add(listener);
794
877
  return () => {
@@ -808,6 +891,9 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
808
891
  },
809
892
  setRunState: (state) => {
810
893
  currentRun = state;
894
+ if (state) {
895
+ telemetry.trackRun(state.runId);
896
+ }
811
897
  },
812
898
  dispose: async (reason = 'normal') => {
813
899
  try {
@@ -826,6 +912,7 @@ export const createHarnessSession = async (globalConfig, { lockManager = default
826
912
  // Cancels any inflight init work still holding sessionController.signal
827
913
  // (e.g. a platform init call abandoned after a readiness timeout race).
828
914
  sessionController.abort();
915
+ await telemetry.dispose();
829
916
  await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
830
917
  if (receivedEarlySignal) {
831
918
  process.exit(1);
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,MAAM,EACN,2BAA2B,EAC3B,IAAI,EACJ,UAAU,EACV,iBAAiB,EACjB,WAAW,EACZ,MAAM,aAAa,CAAC;AAMrB,KAAK,iBAAiB,CAAC,IAAI,SAAS,MAAM,UAAU,IAAI,CACtD,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,KACxB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE1B,MAAM,CAAC,OAAO,OAAO,WAAY,YAAW,2BAA2B;;IACrE,QAAQ,CAAC,QAAQ,QAAQ;IACzB,QAAQ,CAAC,qBAAqB,EAAG,IAAI,CAAU;gBAMnC,YAAY,EAAE,MAAM,CAAC,YAAY;IAI7C,EAAE,CAAC,IAAI,SAAS,MAAM,UAAU,EAC9B,SAAS,EAAE,IAAI,EACf,QAAQ,EAAE,iBAAiB,CAAC,IAAI,CAAC,GAChC,MAAM,IAAI;IAaP,QAAQ,CACZ,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,EAClB,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC;CA6CjB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,MAAM,EACN,2BAA2B,EAC3B,IAAI,EACJ,UAAU,EACV,iBAAiB,EACjB,WAAW,EACZ,MAAM,aAAa,CAAC;AAOrB,KAAK,iBAAiB,CAAC,IAAI,SAAS,MAAM,UAAU,IAAI,CACtD,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,KACxB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE1B,MAAM,CAAC,OAAO,OAAO,WAAY,YAAW,2BAA2B;;IACrE,QAAQ,CAAC,QAAQ,QAAQ;IACzB,QAAQ,CAAC,qBAAqB,EAAG,IAAI,CAAU;gBAKnC,YAAY,EAAE,MAAM,CAAC,YAAY;IAI7C,EAAE,CAAC,IAAI,SAAS,MAAM,UAAU,EAC9B,SAAS,EAAE,IAAI,EACf,QAAQ,EAAE,iBAAiB,CAAC,IAAI,CAAC,GAChC,MAAM,IAAI;IAaP,QAAQ,CACZ,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,EAClB,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC;CA8CjB"}