@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.
- package/dist/cli-args.d.ts +3 -4
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +3 -8
- package/dist/diagnostics/index.d.ts +4 -1
- package/dist/diagnostics/index.d.ts.map +1 -1
- package/dist/diagnostics/index.js +2 -1
- package/dist/diagnostics/report.d.ts +81 -1
- package/dist/diagnostics/report.d.ts.map +1 -1
- package/dist/diagnostics/report.js +225 -3
- package/dist/diagnostics/telemetry.d.ts +42 -0
- package/dist/diagnostics/telemetry.d.ts.map +1 -0
- package/dist/diagnostics/telemetry.js +120 -0
- package/dist/errors.d.ts +8 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +29 -3
- package/dist/execute-run.d.ts.map +1 -1
- package/dist/execute-run.js +73 -21
- package/dist/format-harness-error.d.ts.map +1 -1
- package/dist/format-harness-error.js +3 -0
- package/dist/harness-session.d.ts +8 -1
- package/dist/harness-session.d.ts.map +1 -1
- package/dist/harness-session.js +106 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/jest-preset.d.ts +3 -0
- package/dist/jest-preset.d.ts.map +1 -1
- package/dist/jest-preset.js +18 -0
- package/dist/relative-path.d.ts +8 -0
- package/dist/relative-path.d.ts.map +1 -0
- package/dist/relative-path.js +8 -0
- package/dist/resource-lock.d.ts.map +1 -1
- package/dist/resource-lock.js +47 -27
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +4 -4
- package/dist/watch-session.d.ts +6 -0
- package/dist/watch-session.d.ts.map +1 -0
- package/dist/watch-session.js +37 -0
- package/package.json +8 -8
- package/src/cli-args.ts +5 -13
- package/src/diagnostics/index.ts +12 -1
- package/src/diagnostics/report.ts +326 -5
- package/src/diagnostics/telemetry.ts +186 -0
- package/src/errors.ts +42 -5
- package/src/execute-run.ts +93 -23
- package/src/format-harness-error.ts +5 -0
- package/src/harness-session.ts +139 -21
- package/src/index.ts +8 -7
- package/src/jest-preset.ts +18 -0
- package/src/relative-path.ts +10 -0
- package/src/resource-lock.ts +64 -33
- package/src/run.ts +4 -4
- package/src/watch-session.ts +65 -0
- package/dist/crash-supervisor.d.ts +0 -21
- package/dist/crash-supervisor.d.ts.map +0 -1
- package/dist/crash-supervisor.js +0 -210
- package/dist/event-mapper.d.ts +0 -16
- package/dist/event-mapper.d.ts.map +0 -1
- package/dist/event-mapper.js +0 -120
- package/dist/global-setup.d.ts +0 -3
- package/dist/global-setup.d.ts.map +0 -1
- package/dist/global-setup.js +0 -60
- package/dist/global-teardown.d.ts +0 -3
- package/dist/global-teardown.d.ts.map +0 -1
- package/dist/global-teardown.js +0 -11
- package/dist/harness-reporter.d.ts +0 -8
- package/dist/harness-reporter.d.ts.map +0 -1
- package/dist/harness-reporter.js +0 -12
- package/dist/harness.d.ts +0 -47
- package/dist/harness.d.ts.map +0 -1
- package/dist/harness.js +0 -619
- package/dist/jest-options.d.ts +0 -2
- package/dist/jest-options.d.ts.map +0 -1
- package/dist/jest-options.js +0 -1
- package/dist/setup.d.ts +0 -3
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js +0 -80
- package/dist/teardown.d.ts +0 -3
- package/dist/teardown.d.ts.map +0 -1
- package/dist/teardown.js +0 -10
- package/dist/types.d.ts +0 -17
- package/dist/types.d.ts.map +0 -1
- 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 =
|
|
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 {
|
|
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(
|
|
32
|
-
|
|
33
|
-
|
|
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;
|