@react-native-harness/jest 1.6.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 (82) 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 +106 -22
  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 +139 -21
  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
  53. package/dist/crash-supervisor.d.ts +0 -21
  54. package/dist/crash-supervisor.d.ts.map +0 -1
  55. package/dist/crash-supervisor.js +0 -210
  56. package/dist/event-mapper.d.ts +0 -16
  57. package/dist/event-mapper.d.ts.map +0 -1
  58. package/dist/event-mapper.js +0 -120
  59. package/dist/global-setup.d.ts +0 -3
  60. package/dist/global-setup.d.ts.map +0 -1
  61. package/dist/global-setup.js +0 -60
  62. package/dist/global-teardown.d.ts +0 -3
  63. package/dist/global-teardown.d.ts.map +0 -1
  64. package/dist/global-teardown.js +0 -11
  65. package/dist/harness-reporter.d.ts +0 -8
  66. package/dist/harness-reporter.d.ts.map +0 -1
  67. package/dist/harness-reporter.js +0 -12
  68. package/dist/harness.d.ts +0 -47
  69. package/dist/harness.d.ts.map +0 -1
  70. package/dist/harness.js +0 -619
  71. package/dist/jest-options.d.ts +0 -2
  72. package/dist/jest-options.d.ts.map +0 -1
  73. package/dist/jest-options.js +0 -1
  74. package/dist/setup.d.ts +0 -3
  75. package/dist/setup.d.ts.map +0 -1
  76. package/dist/setup.js +0 -80
  77. package/dist/teardown.d.ts +0 -3
  78. package/dist/teardown.d.ts.map +0 -1
  79. package/dist/teardown.js +0 -10
  80. package/dist/types.d.ts +0 -17
  81. package/dist/types.d.ts.map +0 -1
  82. package/dist/types.js +0 -1
@@ -1,11 +1,14 @@
1
+ import fsSync from 'node:fs';
1
2
  import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
- import type { DiagnosticEntry } from '@react-native-harness/tools';
4
- import { getHarnessRootPath } from '@react-native-harness/tools';
4
+ import type { DiagnosticEntry, Diagnostics } from '@react-native-harness/tools';
5
+ import { getHarnessRootPath, logger, unrefTimer } from '@react-native-harness/tools';
6
+
7
+ const reportLogger = logger.child('diagnostics');
5
8
 
6
9
  type TraceEvent = {
7
10
  name: string;
8
- ph: 'X' | 'i';
11
+ ph: 'X' | 'i' | 'C';
9
12
  ts: number;
10
13
  dur?: number;
11
14
  s?: 'g';
@@ -17,7 +20,20 @@ type TraceEvent = {
17
20
  const TRACE_PID = 1;
18
21
  const TRACE_TID = 1;
19
22
 
20
- const toTraceEvent = (entry: DiagnosticEntry): TraceEvent => {
23
+ export const toTraceEvent = (entry: DiagnosticEntry): TraceEvent => {
24
+ if (entry.kind === 'gauge') {
25
+ // Counter events become graphed tracks in Perfetto / chrome://tracing,
26
+ // one per distinct name, so each gauge label is its own series.
27
+ return {
28
+ name: entry.label,
29
+ ph: 'C',
30
+ ts: entry.startTime * 1000,
31
+ pid: TRACE_PID,
32
+ tid: TRACE_TID,
33
+ args: { [entry.label]: entry.value ?? 0 },
34
+ };
35
+ }
36
+
21
37
  if (entry.kind === 'mark') {
22
38
  return {
23
39
  name: entry.label,
@@ -45,7 +61,7 @@ export const writeTraceFile = async (
45
61
  entries: DiagnosticEntry[],
46
62
  options: { projectRoot: string; runId: string }
47
63
  ): Promise<string> => {
48
- const dir = path.join(getHarnessRootPath(options.projectRoot), 'diagnostics');
64
+ const dir = getDiagnosticsDir(options.projectRoot);
49
65
  await fs.mkdir(dir, { recursive: true });
50
66
 
51
67
  const filePath = path.join(dir, `${options.runId}.json`);
@@ -56,6 +72,303 @@ export const writeTraceFile = async (
56
72
  return filePath;
57
73
  };
58
74
 
75
+ const getDiagnosticsDir = (projectRoot: string): string =>
76
+ path.join(getHarnessRootPath(projectRoot), 'diagnostics');
77
+
78
+ export type DiagnosticsAppender = {
79
+ readonly filePath: string;
80
+ /** Synchronously writes everything not yet on disk. For `exit` handlers. */
81
+ flush(): void;
82
+ /** Waits for in-flight writes, then writes the rest and detaches. */
83
+ dispose(): Promise<void>;
84
+ };
85
+
86
+ /**
87
+ * Persists diagnostics entries as they are recorded, one JSON object per line
88
+ * in `.harness/diagnostics/<id>.jsonl`. The final trace is only written when a
89
+ * run finishes, so a job that is cancelled or killed mid-run (the case where
90
+ * the data matters most) would otherwise leave nothing behind.
91
+ *
92
+ * Entries are batched (every `flushIntervalMs` or `maxBufferedEntries`) and
93
+ * appended asynchronously, one write at a time, so recording an entry never
94
+ * blocks on disk, which matters most when the machine is overloaded. Only the
95
+ * `exit` handler writes synchronously: a process that is going away cannot
96
+ * wait for the event loop. The session already owns SIGINT/SIGTERM and leaves
97
+ * through `process.exit`, which emits `exit`; installing signal handlers here
98
+ * would depend on listener order and fight other handlers about who re-raises.
99
+ *
100
+ * Entries recorded before the appender was created are replayed, so the log
101
+ * starts at the beginning of the session.
102
+ */
103
+ export const createDiagnosticsAppender = (
104
+ diagnostics: Diagnostics,
105
+ options: {
106
+ projectRoot: string;
107
+ id: string;
108
+ flushIntervalMs?: number;
109
+ maxBufferedEntries?: number;
110
+ }
111
+ ): DiagnosticsAppender => {
112
+ const filePath = path.join(
113
+ getDiagnosticsDir(options.projectRoot),
114
+ `${options.id}.jsonl`
115
+ );
116
+
117
+ if (!diagnostics.enabled) {
118
+ return {
119
+ filePath,
120
+ flush: () => undefined,
121
+ dispose: async () => undefined,
122
+ };
123
+ }
124
+
125
+ const maxBufferedEntries = options.maxBufferedEntries ?? 100;
126
+ let buffer: string[] = [];
127
+ // Chunks handed to the async writer and not yet confirmed on disk, kept so
128
+ // the `exit` handler can write them if the process leaves first. In the
129
+ // rare race where one was also completed by the writer, a chunk ends up in
130
+ // the file twice; losing it would be worse for a log whose purpose is
131
+ // surviving abrupt termination.
132
+ let pending: string[][] = [];
133
+ let writeChain: Promise<void> = Promise.resolve();
134
+ let directoryReady = false;
135
+
136
+ const render = (lines: string[]) => `${lines.join('\n')}\n`;
137
+
138
+ const flushAsync = () => {
139
+ if (buffer.length === 0) {
140
+ return;
141
+ }
142
+
143
+ const lines = buffer;
144
+ buffer = [];
145
+ pending.push(lines);
146
+ writeChain = writeChain.then(async () => {
147
+ try {
148
+ if (!directoryReady) {
149
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
150
+ directoryReady = true;
151
+ }
152
+ await fs.appendFile(filePath, render(lines), 'utf-8');
153
+ } catch (error) {
154
+ reportLogger.debug('failed to append diagnostics: %s', error);
155
+ } finally {
156
+ pending = pending.filter((chunk) => chunk !== lines);
157
+ }
158
+ });
159
+ };
160
+
161
+ const flushSync = () => {
162
+ const lines = [...pending.flat(), ...buffer];
163
+ pending = [];
164
+ buffer = [];
165
+ if (lines.length === 0) {
166
+ return;
167
+ }
168
+
169
+ try {
170
+ fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
171
+ fsSync.appendFileSync(filePath, render(lines), 'utf-8');
172
+ } catch (error) {
173
+ reportLogger.debug('failed to append diagnostics: %s', error);
174
+ }
175
+ };
176
+
177
+ const unsubscribe = diagnostics.subscribe(
178
+ (entry) => {
179
+ buffer.push(JSON.stringify(entry));
180
+ if (buffer.length >= maxBufferedEntries) {
181
+ flushAsync();
182
+ }
183
+ },
184
+ { replay: true }
185
+ );
186
+
187
+ const timer = setInterval(flushAsync, options.flushIntervalMs ?? 2000);
188
+ unrefTimer(timer);
189
+ process.on('exit', flushSync);
190
+
191
+ const dispose = async () => {
192
+ unsubscribe();
193
+ clearInterval(timer);
194
+ process.off('exit', flushSync);
195
+ flushAsync();
196
+ await writeChain;
197
+ };
198
+
199
+ return { filePath, flush: flushSync, dispose };
200
+ };
201
+
202
+ export type RunKpis = {
203
+ bootWaitMs: number | null;
204
+ timeToReadyMs: number | null;
205
+ peakHostCompressedMb: number | null;
206
+ peakLoad1: number | null;
207
+ minHostFreeMb: number | null;
208
+ /** Memory swapped out during the run, in MB. */
209
+ swapoutsDelta: number | null;
210
+ maxEventLoopLagMs: number | null;
211
+ testFileCount: number;
212
+ result: 'passed' | 'failed' | null;
213
+ };
214
+
215
+ const maxOf = (values: number[]): number | null =>
216
+ values.length > 0 ? Math.max(...values) : null;
217
+
218
+ const minOf = (values: number[]): number | null =>
219
+ values.length > 0 ? Math.min(...values) : null;
220
+
221
+ /**
222
+ * Reduces a run's entries to the handful of numbers worth comparing between
223
+ * runs. Anything that cannot be derived from the entries is null, never a
224
+ * guess, so a missing probe shows up as "n/a" rather than as a misleading 0.
225
+ */
226
+ export const summarizeRunKpis = (entries: DiagnosticEntry[]): RunKpis => {
227
+ const gaugeValues = (label: string): number[] =>
228
+ entries
229
+ .filter((entry) => entry.kind === 'gauge' && entry.label === label)
230
+ .map((entry) => entry.value)
231
+ .filter((value): value is number => typeof value === 'number');
232
+
233
+ const spans = (label: string): DiagnosticEntry[] =>
234
+ entries.filter((entry) => entry.kind === 'span' && entry.label === label);
235
+
236
+ const bootSpans = entries.filter(
237
+ (entry) => entry.kind === 'span' && entry.label.endsWith('.waitForBoot')
238
+ );
239
+
240
+ const sessionStart = spans('session.config.load')[0]?.startTime;
241
+ const firstReady = spans('app.ready.total').find(
242
+ (entry) => entry.status === 'ok'
243
+ );
244
+
245
+ const swapouts = gaugeValues('host.mem.swapouts');
246
+ const runStatus = spans('run.total')[0]?.attrs?.status;
247
+
248
+ return {
249
+ bootWaitMs:
250
+ bootSpans.length > 0
251
+ ? bootSpans.reduce((total, entry) => total + entry.duration, 0)
252
+ : null,
253
+ timeToReadyMs:
254
+ sessionStart !== undefined && firstReady
255
+ ? firstReady.startTime + firstReady.duration - sessionStart
256
+ : null,
257
+ peakHostCompressedMb: maxOf(gaugeValues('host.mem.compressed')),
258
+ peakLoad1: maxOf(gaugeValues('host.load1')),
259
+ minHostFreeMb: minOf(gaugeValues('host.mem.free')),
260
+ swapoutsDelta:
261
+ swapouts.length >= 2
262
+ ? (swapouts[swapouts.length - 1] as number) - (swapouts[0] as number)
263
+ : null,
264
+ maxEventLoopLagMs: maxOf(gaugeValues('node.event_loop.max_ms')),
265
+ testFileCount: spans('run.file').length,
266
+ result:
267
+ runStatus === 'passed' || runStatus === 'failed' ? runStatus : null,
268
+ };
269
+ };
270
+
271
+ export const writeSummaryFile = async (
272
+ kpis: RunKpis,
273
+ options: { projectRoot: string; runId: string; sessionId?: string }
274
+ ): Promise<string> => {
275
+ const dir = getDiagnosticsDir(options.projectRoot);
276
+ await fs.mkdir(dir, { recursive: true });
277
+
278
+ const filePath = path.join(dir, `${options.runId}.summary.json`);
279
+ const summary = {
280
+ runId: options.runId,
281
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
282
+ ...kpis,
283
+ };
284
+ await fs.writeFile(filePath, JSON.stringify(summary, null, 2), 'utf-8');
285
+
286
+ return filePath;
287
+ };
288
+
289
+ const formatNumber = (value: number | null, unit: string, digits = 0) =>
290
+ value === null ? 'n/a' : `${value.toFixed(digits)}${unit}`;
291
+
292
+ const formatKpiDuration = (ms: number | null) =>
293
+ ms === null ? 'n/a' : formatDuration(ms);
294
+
295
+ /** The KPIs as label/value pairs, in display order. */
296
+ export const formatRunKpis = (kpis: RunKpis): Array<[string, string]> => [
297
+ ['Result', kpis.result ?? 'n/a'],
298
+ ['Test files', String(kpis.testFileCount)],
299
+ ['Boot wait', formatKpiDuration(kpis.bootWaitMs)],
300
+ ['Time to ready', formatKpiDuration(kpis.timeToReadyMs)],
301
+ ['Peak host load (1m)', formatNumber(kpis.peakLoad1, '', 1)],
302
+ ['Peak compressed memory', formatNumber(kpis.peakHostCompressedMb, ' MB')],
303
+ ['Min free memory', formatNumber(kpis.minHostFreeMb, ' MB')],
304
+ ['Swapped out during run', formatNumber(kpis.swapoutsDelta, ' MB')],
305
+ ['Max event loop lag', formatNumber(kpis.maxEventLoopLagMs, ' ms')],
306
+ ];
307
+
308
+ /**
309
+ * Appends the KPIs as a markdown table to the GitHub Actions job summary when
310
+ * running there. Best effort: a failure here must never fail the run.
311
+ */
312
+ export const appendStepSummary = async (
313
+ kpis: RunKpis,
314
+ env: NodeJS.ProcessEnv = process.env
315
+ ): Promise<void> => {
316
+ const summaryPath = env.GITHUB_STEP_SUMMARY;
317
+ if (!summaryPath) {
318
+ return;
319
+ }
320
+
321
+ const rows = formatRunKpis(kpis).map(([label, value]) => `| ${label} | ${value} |`);
322
+ const markdown = [
323
+ '### Harness run KPIs',
324
+ '',
325
+ '| KPI | Value |',
326
+ '| --- | --- |',
327
+ ...rows,
328
+ '',
329
+ '',
330
+ ].join('\n');
331
+
332
+ try {
333
+ await fs.appendFile(summaryPath, markdown, 'utf-8');
334
+ } catch (error) {
335
+ reportLogger.debug('failed to write GitHub step summary: %s', error);
336
+ }
337
+ };
338
+
339
+ /**
340
+ * Adds entries recorded after a run's trace was written (session teardown) to
341
+ * that trace, or writes a new trace if there is none yet.
342
+ */
343
+ export const appendToTraceFile = async (
344
+ entries: DiagnosticEntry[],
345
+ options: { projectRoot: string; runId: string }
346
+ ): Promise<string> => {
347
+ const filePath = path.join(
348
+ getDiagnosticsDir(options.projectRoot),
349
+ `${options.runId}.json`
350
+ );
351
+
352
+ let existing: TraceEvent[] = [];
353
+ try {
354
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8')) as {
355
+ traceEvents?: TraceEvent[];
356
+ };
357
+ existing = parsed.traceEvents ?? [];
358
+ } catch {
359
+ // No trace yet (or an unreadable one): start a fresh file.
360
+ }
361
+
362
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
363
+ await fs.writeFile(
364
+ filePath,
365
+ JSON.stringify({ traceEvents: [...existing, ...entries.map(toTraceEvent)] }),
366
+ 'utf-8'
367
+ );
368
+
369
+ return filePath;
370
+ };
371
+
59
372
  const formatDuration = (ms: number): string => {
60
373
  if (ms >= 1000) {
61
374
  return `${(ms / 1000).toFixed(1)}s`;
@@ -82,6 +395,10 @@ export const printSummary = (
82
395
 
83
396
  const statsByLabel = new Map<string, LabelStats>();
84
397
  for (const entry of entries) {
398
+ // Gauges are samples, not timings; they are summarised as KPIs below.
399
+ if (entry.kind === 'gauge') {
400
+ continue;
401
+ }
85
402
  const existing = statsByLabel.get(entry.label);
86
403
  if (existing) {
87
404
  existing.count += 1;
@@ -121,4 +438,8 @@ export const printSummary = (
121
438
  );
122
439
  }
123
440
  }
441
+ log(' KPIs');
442
+ for (const [label, value] of formatRunKpis(summarizeRunKpis(entries))) {
443
+ log(` ${label}: ${value}`);
444
+ }
124
445
  };
@@ -0,0 +1,186 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import os from 'node:os';
3
+ import {
4
+ logger,
5
+ createDefaultProbes,
6
+ createLifecycleEmitter,
7
+ DEFAULT_SAMPLE_INTERVAL_MS,
8
+ startTelemetryProbes,
9
+ type Diagnostics,
10
+ type DiagnosticAttrs,
11
+ type LifecyclePhase,
12
+ type HostPressure,
13
+ } from '@react-native-harness/tools';
14
+ import {
15
+ resolveDiagnosticsOptions,
16
+ type Config as HarnessConfig,
17
+ } from '@react-native-harness/config';
18
+ import type { HarnessPlatformRunner } from '@react-native-harness/platforms';
19
+ import { appendToTraceFile, createDiagnosticsAppender } from './report.js';
20
+
21
+ const telemetryLogger = logger.child('diagnostics');
22
+
23
+ export type SessionTelemetry = {
24
+ /** Identifies the session; names the incremental `.jsonl` log. */
25
+ readonly sessionId: string;
26
+ /** Session code emits phases here; probes listen. */
27
+ readonly lifecycle: ReturnType<typeof createLifecycleEmitter>;
28
+ /** Emits a lifecycle phase. */
29
+ phase(phase: LifecyclePhase, attrs?: DiagnosticAttrs): void;
30
+ /** Makes the platform runner available to the platform collector probe. */
31
+ attachPlatform(platform: HarnessPlatformRunner): void;
32
+ /** Records the run that is current, so teardown data can join its trace. */
33
+ trackRun(runId: string): void;
34
+ /**
35
+ * Host pressure observed by the host-resources probe so far: peak load,
36
+ * mean idle CPU, lowest free memory and memory swapped out since the first
37
+ * sample. Null
38
+ * when no host sample was taken (diagnostics or the host probe are off).
39
+ */
40
+ hostPressure(): HostPressure | null;
41
+ /** Resolves once lifecycle driven collection started so far has finished. */
42
+ settled(): Promise<void>;
43
+ /**
44
+ * Stops probes and the incremental log. Entries recorded since the last
45
+ * run's report was written (the end-of-run snapshot, teardown spans) are
46
+ * added to that run's trace. Safe to call more than once.
47
+ */
48
+ dispose(): Promise<void>;
49
+ };
50
+
51
+ /**
52
+ * The single composition point for run telemetry: it wires the incremental
53
+ * log and the probes to the one diagnostics sink. Session code only emits
54
+ * lifecycle phases; it never calls a probe. Everything is a no-op, with no
55
+ * timers, listeners or spawned processes, when diagnostics are disabled.
56
+ */
57
+ export const startSessionTelemetry = ({
58
+ diagnostics,
59
+ config,
60
+ projectRoot,
61
+ }: {
62
+ diagnostics: Diagnostics;
63
+ config: Pick<HarnessConfig, 'diagnostics'>;
64
+ projectRoot: string;
65
+ }): SessionTelemetry => {
66
+ const sessionId = randomUUID();
67
+ const lifecycle = createLifecycleEmitter();
68
+ const abortController = new AbortController();
69
+ const options = resolveDiagnosticsOptions(config);
70
+
71
+ let platform: HarnessPlatformRunner | undefined;
72
+ let lastRunId: string | undefined;
73
+
74
+ const appender = createDiagnosticsAppender(diagnostics, {
75
+ projectRoot,
76
+ id: sessionId,
77
+ });
78
+
79
+ const run = startTelemetryProbes(
80
+ createDefaultProbes({
81
+ host: options.probes.host,
82
+ node: options.probes.node,
83
+ snapshots: options.probes.snapshots,
84
+ platform: options.probes.platform
85
+ ? { getCollector: () => platform?.createTelemetryCollector?.() }
86
+ : undefined,
87
+ }),
88
+ {
89
+ diagnostics,
90
+ signal: abortController.signal,
91
+ lifecycle,
92
+ sampleIntervalMs: options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS,
93
+ }
94
+ );
95
+
96
+ // Running aggregates rather than stored samples: a watch-mode session can
97
+ // live for hours, and only the extremes are needed.
98
+ let peakLoad1: number | null = null;
99
+ let idlePctSum = 0;
100
+ let idlePctSamples = 0;
101
+ let minHostFreeMb: number | null = null;
102
+ let firstSwapoutsMb: number | null = null;
103
+ let lastSwapoutsMb: number | null = null;
104
+ const unsubscribePressure = diagnostics.subscribe(
105
+ ({ kind, label, value }) => {
106
+ if (kind !== 'gauge' || typeof value !== 'number') {
107
+ return;
108
+ }
109
+ if (label === 'host.load1') {
110
+ peakLoad1 = Math.max(peakLoad1 ?? value, value);
111
+ } else if (label === 'host.cpu.idle_pct') {
112
+ idlePctSum += value;
113
+ idlePctSamples += 1;
114
+ } else if (label === 'host.mem.free') {
115
+ minHostFreeMb = Math.min(minHostFreeMb ?? value, value);
116
+ } else if (label === 'host.mem.swapouts') {
117
+ firstSwapoutsMb ??= value;
118
+ lastSwapoutsMb = value;
119
+ }
120
+ },
121
+ { replay: true }
122
+ );
123
+
124
+ const hostPressure = (): HostPressure | null =>
125
+ peakLoad1 === null && minHostFreeMb === null && lastSwapoutsMb === null
126
+ ? null
127
+ : {
128
+ peakLoad1,
129
+ meanIdlePct: idlePctSamples > 0 ? idlePctSum / idlePctSamples : null,
130
+ minHostFreeMb,
131
+ swapoutsDeltaMb:
132
+ firstSwapoutsMb !== null && lastSwapoutsMb !== null
133
+ ? lastSwapoutsMb - firstSwapoutsMb
134
+ : null,
135
+ cpuCount: os.availableParallelism(),
136
+ };
137
+
138
+ const phase = (name: LifecyclePhase, attrs?: DiagnosticAttrs) => {
139
+ if (!diagnostics.enabled) {
140
+ return;
141
+ }
142
+ lifecycle.emit({ type: 'phase', phase: name, attrs });
143
+ };
144
+
145
+ let disposed: Promise<void> | null = null;
146
+
147
+ return {
148
+ sessionId,
149
+ lifecycle,
150
+ phase,
151
+ attachPlatform: (runner) => {
152
+ platform = runner;
153
+ },
154
+ trackRun: (runId) => {
155
+ lastRunId = runId;
156
+ },
157
+ hostPressure,
158
+ settled: run.settled,
159
+ dispose: () => {
160
+ disposed ??= (async () => {
161
+ unsubscribePressure();
162
+ try {
163
+ abortController.abort();
164
+ await run.dispose();
165
+ if (diagnostics.enabled && lastRunId) {
166
+ const leftover = diagnostics.flush();
167
+ if (leftover.length > 0) {
168
+ await appendToTraceFile(leftover, {
169
+ projectRoot,
170
+ runId: lastRunId,
171
+ });
172
+ }
173
+ }
174
+ } catch (error) {
175
+ telemetryLogger.debug(
176
+ 'failed to finalize diagnostics trace: %s',
177
+ error
178
+ );
179
+ } finally {
180
+ await appender.dispose();
181
+ }
182
+ })();
183
+ return disposed;
184
+ },
185
+ };
186
+ };
package/src/errors.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { formatPath, HarnessError } from '@react-native-harness/tools';
1
+ import {
2
+ formatHostPressure,
3
+ formatPath,
4
+ HarnessError,
5
+ isHostStarved,
6
+ type HostPressure,
7
+ } from '@react-native-harness/tools';
2
8
  import type { AppCrashDetails } from '@react-native-harness/platforms';
3
9
  export {
4
10
  StartupStallError,
@@ -27,13 +33,34 @@ export class InitializationTimeoutError extends HarnessError {
27
33
  }
28
34
  }
29
35
 
36
+ const buildPlatformReadyTimeoutMessage = (
37
+ timeout: number,
38
+ hostPressure: HostPressure | null
39
+ ): string => {
40
+ const prefix = `The platform did not become ready within ${timeout}ms.`;
41
+
42
+ if (hostPressure && isHostStarved(hostPressure)) {
43
+ 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.`;
44
+ }
45
+
46
+ const suggestion = `Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.`;
47
+ return hostPressure
48
+ ? `${prefix} ${suggestion} Host during startup: ${formatHostPressure(hostPressure)}.`
49
+ : `${prefix} ${suggestion}`;
50
+ };
51
+
30
52
  export class PlatformReadyTimeoutError extends HarnessError {
31
- constructor(public readonly timeout: number) {
32
- super(
33
- `The platform did not become ready within ${timeout}ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.`
34
- );
53
+ constructor(
54
+ public readonly timeout: number,
55
+ public readonly hostPressure: HostPressure | null = null
56
+ ) {
57
+ super(buildPlatformReadyTimeoutMessage(timeout, hostPressure));
35
58
  this.name = 'PlatformReadyTimeoutError';
36
59
  }
60
+
61
+ get hostStarved(): boolean {
62
+ return this.hostPressure !== null && isHostStarved(this.hostPressure);
63
+ }
37
64
  }
38
65
 
39
66
  export class MetroPortRangeExhaustedError extends HarnessError {
@@ -179,4 +206,14 @@ export class RuntimeDisconnectError extends HarnessError {
179
206
  }
180
207
  }
181
208
 
209
+ // The runtime sends this name through the runTests RPC when its native host
210
+ // surface is replaced while a test file is still running.
211
+ export class HostSurfaceReplacedError extends HarnessError {
212
+ constructor(public readonly testFilePath: string, cause: Error) {
213
+ super(cause.message, { cause });
214
+ this.name = 'HostSurfaceReplacedError';
215
+ this.stack = cause.stack;
216
+ }
217
+ }
218
+
182
219
  export type HarnessRuntimeFailure = NativeCrashError | RuntimeDisconnectError;