rolldown-perf-kit 0.0.0-stage → 0.1.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/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
// `record`, `inspect` and `replay` - field numbers for a build that is not deployed.
|
|
2
|
+
// Standalone entry: replay.mjs (perf-kit-replay). The archive folder is the whole
|
|
3
|
+
// interface: one agent records, another inspects and replays.
|
|
4
|
+
//
|
|
5
|
+
// record: loads ONE deployed variant in the user's logged-in Chrome (like
|
|
6
|
+
// `field`), N timing runs + one body run, and writes a replay archive OUTSIDE
|
|
7
|
+
// the repo: every response with safelisted headers, its median server time,
|
|
8
|
+
// a network model fitted from the asset downloads, and HANDOFF.txt.
|
|
9
|
+
// inspect: what an archive holds, whether it is complete, how to replay it.
|
|
10
|
+
// replay: serves that archive to a throwaway headless Chrome from one local
|
|
11
|
+
// HTTP/2 server for every host. The local dist replaces the deployed files;
|
|
12
|
+
// API responses keep their recorded server time; all bodies share the
|
|
13
|
+
// recorded bandwidth. Alternating pairs, the same reports as `field`.
|
|
14
|
+
// --calibrate first: the replay of the RECORDED build must reproduce the field
|
|
15
|
+
// medians, or the model is wrong and an A/B result means nothing.
|
|
16
|
+
// Each command returns a result object (replay.mjs --json prints it).
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { parseArgs } from 'node:util';
|
|
23
|
+
|
|
24
|
+
import { launchBrowser, sleep } from '../cdp.mjs';
|
|
25
|
+
import { analyzeChunkDiscovery, loadChunkGraph, requestSamples } from '../chunk-discovery.mjs';
|
|
26
|
+
import { criticalPath, makeRedactor } from '../field/analysis.mjs';
|
|
27
|
+
import {
|
|
28
|
+
attachBrowser,
|
|
29
|
+
contextForOrigin,
|
|
30
|
+
restoreCookies,
|
|
31
|
+
snapshotCookies,
|
|
32
|
+
} from '../field/browser.mjs';
|
|
33
|
+
import { bodyRun, diagnosticRun, timingRun } from '../field/capture.mjs';
|
|
34
|
+
import { loadFieldConfig, validateFieldConfig } from '../field/config.mjs';
|
|
35
|
+
import {
|
|
36
|
+
MAX_RETRIES,
|
|
37
|
+
buildDiagnosticReports,
|
|
38
|
+
buildFieldReport,
|
|
39
|
+
buildRuntimeReport,
|
|
40
|
+
msStr,
|
|
41
|
+
printFieldReport,
|
|
42
|
+
slug,
|
|
43
|
+
writeJson,
|
|
44
|
+
} from '../field/run.mjs';
|
|
45
|
+
import { onInterrupt } from '../interrupt.mjs';
|
|
46
|
+
import { replayCertificate } from './cert.mjs';
|
|
47
|
+
import {
|
|
48
|
+
ARCHIVE_SCHEMA,
|
|
49
|
+
archiveGaps,
|
|
50
|
+
bodyHashes,
|
|
51
|
+
buildArchive,
|
|
52
|
+
calibrate,
|
|
53
|
+
calibrationVerdict,
|
|
54
|
+
isStaticRequest,
|
|
55
|
+
mergeDocument,
|
|
56
|
+
safeHeaders,
|
|
57
|
+
sha256,
|
|
58
|
+
} from './model.mjs';
|
|
59
|
+
import { chooseCompression, distFile, startReplayServer } from './server.mjs';
|
|
60
|
+
|
|
61
|
+
const mbit = (bps) => (typeof bps === 'number' ? `${((bps * 8) / 1e6).toFixed(1)} Mbit/s` : 'n/a');
|
|
62
|
+
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'replay.mjs');
|
|
63
|
+
|
|
64
|
+
function insideGitWorkTree(dir) {
|
|
65
|
+
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
|
66
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
67
|
+
if (path.dirname(d) === d) return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The commands the replaying agent runs next, with absolute paths. */
|
|
72
|
+
function replayCommands(archiveDir, build) {
|
|
73
|
+
const dist = build ?? '<the build the site served>';
|
|
74
|
+
return {
|
|
75
|
+
inspect: `node ${CLI} inspect --archive ${archiveDir}`,
|
|
76
|
+
calibrate: `node ${CLI} replay --archive ${archiveDir} --dist recorded=${dist} --calibrate`,
|
|
77
|
+
compare: `node ${CLI} replay --archive ${archiveDir} --dist base=${dist} --dist candidate=<local dist>`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- record ------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const RECORD_OPTIONS = {
|
|
84
|
+
config: { type: 'string' },
|
|
85
|
+
variant: { type: 'string' },
|
|
86
|
+
url: { type: 'string' },
|
|
87
|
+
cookie: { type: 'string', multiple: true },
|
|
88
|
+
block: { type: 'string', multiple: true },
|
|
89
|
+
gate: { type: 'string' },
|
|
90
|
+
'ignore-lcp': { type: 'string' },
|
|
91
|
+
redact: { type: 'string', multiple: true },
|
|
92
|
+
build: { type: 'string' },
|
|
93
|
+
runs: { type: 'string', default: '6' },
|
|
94
|
+
pause: { type: 'string', default: '3000' },
|
|
95
|
+
settle: { type: 'string', default: '3000' },
|
|
96
|
+
timeout: { type: 'string', default: '30000' },
|
|
97
|
+
out: { type: 'string' },
|
|
98
|
+
'cdp-url': { type: 'string' },
|
|
99
|
+
'cdp-port': { type: 'string' },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What to record: a variant of a field config (--config [--variant]), or a page
|
|
104
|
+
* described by flags alone (--url, --cookie name=value, --block, --gate,
|
|
105
|
+
* --ignore-lcp, --redact), so a recording agent needs no file. --build names the
|
|
106
|
+
* local copy of the build the site serves (calibration and HTML check).
|
|
107
|
+
* Errors name a cookie, never its value.
|
|
108
|
+
*/
|
|
109
|
+
export function recordTarget(opts, baseDir = process.cwd()) {
|
|
110
|
+
let config;
|
|
111
|
+
let variant;
|
|
112
|
+
if (opts.config) {
|
|
113
|
+
config = loadFieldConfig(opts.config);
|
|
114
|
+
variant = opts.variant ? config.variants[opts.variant] : Object.values(config.variants)[0];
|
|
115
|
+
if (!variant) throw new Error(`no variant "${opts.variant}" in the config`);
|
|
116
|
+
} else {
|
|
117
|
+
if (!opts.url)
|
|
118
|
+
throw new Error(
|
|
119
|
+
'record needs --url <page> (with --cookie/--block/--gate as needed) or --config <file>',
|
|
120
|
+
);
|
|
121
|
+
const name = opts.variant ?? 'recorded';
|
|
122
|
+
const cookies = (opts.cookie ?? []).map((pair) => {
|
|
123
|
+
const eq = pair.indexOf('=');
|
|
124
|
+
if (eq < 1) throw new Error(`--cookie needs name=value (got "${pair.split('=')[0]}")`);
|
|
125
|
+
return { name: pair.slice(0, eq), value: pair.slice(eq + 1) };
|
|
126
|
+
});
|
|
127
|
+
config = validateFieldConfig(
|
|
128
|
+
{
|
|
129
|
+
url: opts.url,
|
|
130
|
+
variants: { [name]: { cookies } },
|
|
131
|
+
block: opts.block ?? [],
|
|
132
|
+
ignoreLcp: opts['ignore-lcp'] ?? null,
|
|
133
|
+
gate: opts.gate ?? null,
|
|
134
|
+
redact: opts.redact ?? [],
|
|
135
|
+
},
|
|
136
|
+
baseDir,
|
|
137
|
+
);
|
|
138
|
+
variant = config.variants[name];
|
|
139
|
+
}
|
|
140
|
+
if (opts.build) variant = { ...variant, build: path.resolve(baseDir, opts.build) };
|
|
141
|
+
return { config, variant };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function cmdRecord(argv) {
|
|
145
|
+
const opts = parseArgs({ args: argv, options: RECORD_OPTIONS, allowPositionals: true }).values;
|
|
146
|
+
const { config, variant } = recordTarget(opts);
|
|
147
|
+
const redact = makeRedactor(config.redact);
|
|
148
|
+
const outDir = path.resolve(
|
|
149
|
+
opts.out ??
|
|
150
|
+
path.join(os.tmpdir(), 'perf-kit-replay', `${slug(redact(config.url))}-${variant.name}`),
|
|
151
|
+
);
|
|
152
|
+
const repo = insideGitWorkTree(outDir);
|
|
153
|
+
if (repo)
|
|
154
|
+
throw new Error(
|
|
155
|
+
`refusing to write a replay archive inside a git work tree (${repo}): it holds real responses`,
|
|
156
|
+
);
|
|
157
|
+
// The archive replaces the folder: never a folder that is not an archive.
|
|
158
|
+
if (
|
|
159
|
+
fs.existsSync(outDir) &&
|
|
160
|
+
fs.readdirSync(outDir).length &&
|
|
161
|
+
!fs.existsSync(path.join(outDir, 'archive.json'))
|
|
162
|
+
)
|
|
163
|
+
throw new Error(`refusing to replace ${outDir}: it is not empty and holds no archive.json`);
|
|
164
|
+
const runs = Number(opts.runs);
|
|
165
|
+
const timing = { settleMs: Number(opts.settle), timeoutMs: Number(opts.timeout), throttle: null };
|
|
166
|
+
const pause = Number(opts.pause);
|
|
167
|
+
|
|
168
|
+
console.log(
|
|
169
|
+
`record: ${redact(variant.url)} variant: ${variant.name} runs: ${runs} + 1 body run`,
|
|
170
|
+
);
|
|
171
|
+
const browser = await attachBrowser({ cdpUrl: opts['cdp-url'], cdpPort: opts['cdp-port'] });
|
|
172
|
+
const { cdp } = browser;
|
|
173
|
+
const names = [...new Set(variant.cookies.map((c) => c.name))];
|
|
174
|
+
let restore = null;
|
|
175
|
+
let offInterrupt = () => {};
|
|
176
|
+
try {
|
|
177
|
+
const chrome = await cdp
|
|
178
|
+
.send('Browser.getVersion')
|
|
179
|
+
.then((v) => v.product)
|
|
180
|
+
.catch(() => null);
|
|
181
|
+
const ctx = await contextForOrigin(cdp, config.origin);
|
|
182
|
+
if (!ctx.found)
|
|
183
|
+
console.log(`note: no open tab on ${config.origin}; using the default profile.`);
|
|
184
|
+
const saved = await snapshotCookies(cdp, {
|
|
185
|
+
browserContextId: ctx.browserContextId,
|
|
186
|
+
origin: config.origin,
|
|
187
|
+
names,
|
|
188
|
+
});
|
|
189
|
+
// One restore, shared: a Ctrl-C during the final restore waits for it.
|
|
190
|
+
let restoring = null;
|
|
191
|
+
restore = () =>
|
|
192
|
+
(restoring ??= restoreCookies(cdp, {
|
|
193
|
+
browserContextId: ctx.browserContextId,
|
|
194
|
+
origin: config.origin,
|
|
195
|
+
saved,
|
|
196
|
+
configured: variant.cookies,
|
|
197
|
+
}));
|
|
198
|
+
offInterrupt = onInterrupt(restore);
|
|
199
|
+
// The critical path is found in redacted URLs with a redacted gate, as in
|
|
200
|
+
// `field` and the replay. The archive keeps the raw URLs: the replay matches on them.
|
|
201
|
+
const common = {
|
|
202
|
+
config,
|
|
203
|
+
variant,
|
|
204
|
+
context: ctx.browserContextId,
|
|
205
|
+
saved,
|
|
206
|
+
configured: variant.cookies,
|
|
207
|
+
redact,
|
|
208
|
+
keepRaw: true,
|
|
209
|
+
...timing,
|
|
210
|
+
};
|
|
211
|
+
const gate = redact(config.gate);
|
|
212
|
+
|
|
213
|
+
const timed = [];
|
|
214
|
+
for (let i = 0; i < runs; i++) {
|
|
215
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
216
|
+
const run = await timingRun(cdp, common);
|
|
217
|
+
if (run.invalid) {
|
|
218
|
+
console.log(
|
|
219
|
+
` run ${i + 1} INVALID: ${run.invalid}${attempt < MAX_RETRIES ? ' - retrying' : ''}`,
|
|
220
|
+
);
|
|
221
|
+
await sleep(pause);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const cp = criticalPath(run.requests, run.sample.lcp, { pattern: gate });
|
|
225
|
+
timed.push({
|
|
226
|
+
requests: run.rawRequests,
|
|
227
|
+
lcp: run.sample.lcp,
|
|
228
|
+
fcp: run.sample.fcp ?? null,
|
|
229
|
+
lcpElement: run.sample.lcpElement ?? null,
|
|
230
|
+
criticalPath: cp,
|
|
231
|
+
path: run.path,
|
|
232
|
+
connection: run.connection ?? null,
|
|
233
|
+
});
|
|
234
|
+
const b = cp.breakdown;
|
|
235
|
+
console.log(
|
|
236
|
+
` run ${i + 1} LCP ${msStr(run.sample.lcp).padStart(7)}` +
|
|
237
|
+
(b
|
|
238
|
+
? ` gate start ${msStr(b.beforeGateMs)} ttfb ${msStr(b.ttfbMs)} after ${msStr(b.afterGateMs)}`
|
|
239
|
+
: ` gate: ${cp.reason}`),
|
|
240
|
+
);
|
|
241
|
+
await sleep(pause);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!timed.length) throw new Error('no valid timing run');
|
|
246
|
+
|
|
247
|
+
let body = null;
|
|
248
|
+
for (let attempt = 0; attempt <= MAX_RETRIES && !body; attempt++) {
|
|
249
|
+
const run = await bodyRun(cdp, common);
|
|
250
|
+
if (run.invalid) {
|
|
251
|
+
console.log(
|
|
252
|
+
` body run INVALID: ${run.invalid}${attempt < MAX_RETRIES ? ' - retrying' : ''}`,
|
|
253
|
+
);
|
|
254
|
+
await sleep(pause);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
body = run;
|
|
258
|
+
}
|
|
259
|
+
if (!body) throw new Error('no valid body run');
|
|
260
|
+
|
|
261
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
262
|
+
fs.mkdirSync(path.join(outDir, 'bodies'), { recursive: true, mode: 0o700 });
|
|
263
|
+
fs.chmodSync(outDir, 0o700);
|
|
264
|
+
let bodyBytes = 0;
|
|
265
|
+
const rows = body.rows.map(({ body: data, postData, headers, ...r }) => {
|
|
266
|
+
let bodyRef = null;
|
|
267
|
+
if (data?.length) {
|
|
268
|
+
bodyRef = sha256(data);
|
|
269
|
+
const file = path.join(outDir, 'bodies', bodyRef);
|
|
270
|
+
if (!fs.existsSync(file)) {
|
|
271
|
+
fs.writeFileSync(file, data, { mode: 0o600 });
|
|
272
|
+
bodyBytes += data.length;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const { bodyHash, bodyNormHash } = bodyHashes(postData);
|
|
276
|
+
return {
|
|
277
|
+
...r,
|
|
278
|
+
headers: safeHeaders(headers),
|
|
279
|
+
bodyRef,
|
|
280
|
+
bodyBytes: data?.length ?? 0,
|
|
281
|
+
reqBodyHash: bodyHash,
|
|
282
|
+
reqBodyNormHash: bodyNormHash,
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
const archive = buildArchive({
|
|
286
|
+
config,
|
|
287
|
+
variant,
|
|
288
|
+
timing: timed,
|
|
289
|
+
bodyRun: { rows, page: body.page },
|
|
290
|
+
});
|
|
291
|
+
archive.recordedWith = {
|
|
292
|
+
tool: 'perf-kit replay',
|
|
293
|
+
archiveSchema: ARCHIVE_SCHEMA,
|
|
294
|
+
chrome,
|
|
295
|
+
node: process.version,
|
|
296
|
+
cookieNames: names,
|
|
297
|
+
timingRuns: timed.length,
|
|
298
|
+
};
|
|
299
|
+
fs.writeFileSync(path.join(outDir, 'archive.json'), `${JSON.stringify(archive, null, 2)}\n`, {
|
|
300
|
+
mode: 0o600,
|
|
301
|
+
});
|
|
302
|
+
// The field waterfalls, for a calibration that fails: what did the page wait for?
|
|
303
|
+
fs.writeFileSync(
|
|
304
|
+
path.join(outDir, 'timing.json'),
|
|
305
|
+
`${JSON.stringify({ runs: timed }, null, 2)}\n`,
|
|
306
|
+
{ mode: 0o600 },
|
|
307
|
+
);
|
|
308
|
+
const commands = replayCommands(outDir, variant.build);
|
|
309
|
+
fs.writeFileSync(path.join(outDir, 'HANDOFF.txt'), handoffText(archive, { redact, commands }), {
|
|
310
|
+
mode: 0o600,
|
|
311
|
+
});
|
|
312
|
+
printArchiveSummary(archive, outDir, redact);
|
|
313
|
+
const gaps = findGaps(archive, timed, archive.build, redact);
|
|
314
|
+
printGaps(gaps);
|
|
315
|
+
return {
|
|
316
|
+
archive: outDir,
|
|
317
|
+
handoff: path.join(outDir, 'HANDOFF.txt'),
|
|
318
|
+
complete: gaps.length === 0,
|
|
319
|
+
gaps,
|
|
320
|
+
...archiveFacts(archive, outDir, redact),
|
|
321
|
+
bodyBytesWritten: bodyBytes,
|
|
322
|
+
next: commands,
|
|
323
|
+
};
|
|
324
|
+
} finally {
|
|
325
|
+
if (restore) {
|
|
326
|
+
try {
|
|
327
|
+
await restore();
|
|
328
|
+
if (names.length) console.log(`cookies restored: ${names.join(', ')} (values not printed)`);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.error(`WARNING: cookie restore failed: ${err.message}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
offInterrupt();
|
|
334
|
+
await browser.close().catch(() => {});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function handoffText(archive, { redact, commands }) {
|
|
339
|
+
const f = archive.field;
|
|
340
|
+
const n = archive.network;
|
|
341
|
+
return [
|
|
342
|
+
'perf-kit replay archive',
|
|
343
|
+
'',
|
|
344
|
+
`recorded: ${redact(archive.url)} variant ${archive.variant} ${new Date(archive.recordedAtMs).toISOString()}`,
|
|
345
|
+
` ${f.runs} timing runs, ${archive.recordedWith?.chrome ?? 'Chrome'}, Chrome rated the network ${n.effectiveType ?? 'n/a'}`,
|
|
346
|
+
`field medians: LCP ${msStr(f.lcpMs)}, document end ${msStr(f.documentEndMs)}, gate start ${msStr(f.beforeGateMs)},` +
|
|
347
|
+
` gate ttfb ${msStr(f.ttfbMs)}, after gate ${msStr(f.afterGateMs)}`,
|
|
348
|
+
`local copy of the recorded build: ${archive.build ?? 'not given (record --build <dir>)'}`,
|
|
349
|
+
'',
|
|
350
|
+
'This folder holds REAL responses of the recording account. Keep it on this machine.',
|
|
351
|
+
'Never copy it into a repo, a ticket, a merge request or a chat.',
|
|
352
|
+
'',
|
|
353
|
+
'Next (in this order; --json prints one result object on stdout):',
|
|
354
|
+
` ${commands.inspect}`,
|
|
355
|
+
` ${commands.calibrate}`,
|
|
356
|
+
` ${commands.compare}`,
|
|
357
|
+
'Trust an A/B only after the calibration passes. The calibration "verdict" says what to do:',
|
|
358
|
+
' pass - an A/B is usable; record-again - record again (longer --settle);',
|
|
359
|
+
' model-error - the archive is complete, a new recording does not fix it: report the rows.',
|
|
360
|
+
'',
|
|
361
|
+
].join('\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const archiveBodyBytes = (archiveDir) => {
|
|
365
|
+
try {
|
|
366
|
+
const dir = path.join(archiveDir, 'bodies');
|
|
367
|
+
return fs.readdirSync(dir).reduce((s, f) => s + fs.statSync(path.join(dir, f)).size, 0);
|
|
368
|
+
} catch {
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/** The field timing runs of an archive (timing.json), or [] for an archive without them. */
|
|
374
|
+
function readTimingRuns(archiveDir) {
|
|
375
|
+
try {
|
|
376
|
+
return JSON.parse(fs.readFileSync(path.join(archiveDir, 'timing.json'), 'utf8')).runs ?? [];
|
|
377
|
+
} catch {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Requests most field runs made that the replay of `build` answers with a 404,
|
|
384
|
+
* as the server routes them: a same-origin GET for a file of the build comes
|
|
385
|
+
* from the build. Without a build, same-origin static files count as served.
|
|
386
|
+
*/
|
|
387
|
+
function findGaps(archive, runs, build, redact) {
|
|
388
|
+
const dist = build && fs.existsSync(build) ? build : null;
|
|
389
|
+
const inBuild = (r) => {
|
|
390
|
+
if (r.method !== 'GET' || !r.url.startsWith(`${archive.origin}/`)) return false;
|
|
391
|
+
return dist
|
|
392
|
+
? Boolean(distFile(dist, new URL(r.url).pathname))
|
|
393
|
+
: isStaticRequest(r, archive.origin);
|
|
394
|
+
};
|
|
395
|
+
return archiveGaps(archive, runs, { inBuild }).map((g) => ({ ...g, key: redact(g.key) }));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function printGaps(gaps) {
|
|
399
|
+
if (!gaps.length) return;
|
|
400
|
+
console.log(
|
|
401
|
+
` GAPS: ${gaps.length} request(s) that most field runs made have no recorded response (404 in the replay):`,
|
|
402
|
+
);
|
|
403
|
+
for (const g of gaps.slice(0, 10)) console.log(` ${g.runs}/${g.of} runs ${g.key}`);
|
|
404
|
+
console.log(' Record again with a longer --settle (for example 6000).');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Static chunks that started late before paint (lib/chunk-discovery.mjs), from
|
|
409
|
+
* the CDP waterfalls of field or replay runs and the build's Vite manifest. The
|
|
410
|
+
* threshold is the recorded round trip: a static import that starts more than one
|
|
411
|
+
* RTT after a static ancestor finished was discovered late.
|
|
412
|
+
*/
|
|
413
|
+
export function discoverChunks(runs, { origin, build, rttMs, redact }) {
|
|
414
|
+
const manifest = build ? path.join(build, '.vite', 'manifest.json') : null;
|
|
415
|
+
const graph = build && fs.existsSync(build) ? loadChunkGraph(build) : null;
|
|
416
|
+
if (!graph && manifest && fs.existsSync(manifest)) {
|
|
417
|
+
// loadChunkGraph refuses a manifest older than a file it names (a stale graph).
|
|
418
|
+
const reason = `${manifest} is older than files of the build; copy a build with mv or cp -p, not cp -R`;
|
|
419
|
+
return {
|
|
420
|
+
fcp: { state: 'unknown', reason, groups: [] },
|
|
421
|
+
lcp: { state: 'unknown', reason, groups: [] },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return analyzeChunkDiscovery(graph, requestSamples(runs, origin, redact), rttMs);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The LCP result of discoverChunks, short enough for a --json result. */
|
|
428
|
+
const lateChunks = (discovery) => {
|
|
429
|
+
const d = discovery?.lcp;
|
|
430
|
+
if (!d) return null;
|
|
431
|
+
return {
|
|
432
|
+
state: d.state,
|
|
433
|
+
reason: d.reason ?? null,
|
|
434
|
+
rttMs: d.latencyMs ?? null,
|
|
435
|
+
groups: d.groups.map((g) => ({
|
|
436
|
+
parent: g.parent,
|
|
437
|
+
chunks: g.count,
|
|
438
|
+
bytes: g.bytes,
|
|
439
|
+
maxDelayMs: g.maxDelayMs,
|
|
440
|
+
runs: `${g.runsAffected}/${g.measuredRuns}`,
|
|
441
|
+
importChain: g.example.chunks[0]?.importChain ?? [],
|
|
442
|
+
source: g.example.chunks[0]?.facadeModuleId ?? null,
|
|
443
|
+
})),
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
function printLateChunks(discovery, label) {
|
|
448
|
+
const d = lateChunks(discovery);
|
|
449
|
+
if (!d) return;
|
|
450
|
+
if (d.state !== 'open') {
|
|
451
|
+
console.log(
|
|
452
|
+
` late chunks before LCP (${label}): ${d.state}${d.reason ? ` - ${d.reason}` : ''}`,
|
|
453
|
+
);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
console.log(
|
|
457
|
+
` late chunks before LCP (${label}): ${d.groups.length} static import group(s) started more than ${msStr(d.rttMs)} (one RTT) after a static ancestor finished`,
|
|
458
|
+
);
|
|
459
|
+
for (const g of d.groups.slice(0, 4)) {
|
|
460
|
+
console.log(
|
|
461
|
+
` ${g.parent}: ${g.chunks} chunk(s), ${(g.bytes / 1024).toFixed(1)}KiB, up to ${msStr(g.maxDelayMs)} late (${g.runs} runs)`,
|
|
462
|
+
);
|
|
463
|
+
console.log(
|
|
464
|
+
` import chain: ${g.importChain.join(' -> ')}${g.source ? ` source: ${g.source}` : ''}`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
console.log(
|
|
468
|
+
' Leads, not predicted gains: check whether paint needs them, then test with a replay A/B. Groups overlap; do not add them.',
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The facts an agent needs about an archive, without any response content. */
|
|
473
|
+
function archiveFacts(archive, archiveDir, redact) {
|
|
474
|
+
const calls = Object.values(archive.endpoints).reduce((s, e) => s + e.calls.length, 0);
|
|
475
|
+
const n = archive.network;
|
|
476
|
+
return {
|
|
477
|
+
url: redact(archive.url),
|
|
478
|
+
variant: archive.variant,
|
|
479
|
+
recordedAt: new Date(archive.recordedAtMs).toISOString(),
|
|
480
|
+
ageHours: Math.round(((Date.now() - archive.recordedAtMs) / 3_600_000) * 10) / 10,
|
|
481
|
+
recordedWith: archive.recordedWith
|
|
482
|
+
? {
|
|
483
|
+
...archive.recordedWith,
|
|
484
|
+
cookieNames: (archive.recordedWith.cookieNames ?? []).map(redact),
|
|
485
|
+
}
|
|
486
|
+
: null,
|
|
487
|
+
build: archive.build,
|
|
488
|
+
gate: redact(archive.gate),
|
|
489
|
+
requestKeys: Object.keys(archive.endpoints).length,
|
|
490
|
+
calls,
|
|
491
|
+
bodyBytes: archiveBodyBytes(archiveDir),
|
|
492
|
+
hosts: archive.hosts.map((h) => ({ ...h, host: redact(h.host) })),
|
|
493
|
+
field: { ...archive.field, paths: (archive.field.paths ?? []).map(redact) },
|
|
494
|
+
network: {
|
|
495
|
+
effectiveType: n.effectiveType ?? null,
|
|
496
|
+
staticTtfbMs: n.staticTtfbMs,
|
|
497
|
+
wireBytes: n.wireBytes ?? 0,
|
|
498
|
+
bandwidthBps: n.bandwidthBps,
|
|
499
|
+
rttMs: n.rttMs,
|
|
500
|
+
},
|
|
501
|
+
page: archive.page
|
|
502
|
+
? {
|
|
503
|
+
cookieNames: (archive.page.cookieNames ?? []).map(redact),
|
|
504
|
+
localStorageKeys: archive.page.localStorageKeys,
|
|
505
|
+
}
|
|
506
|
+
: null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function printArchiveSummary(archive, archiveDir, redact) {
|
|
511
|
+
const facts = archiveFacts(archive, archiveDir, redact);
|
|
512
|
+
const n = archive.network;
|
|
513
|
+
const f = archive.field;
|
|
514
|
+
console.log(
|
|
515
|
+
`\narchive: ${facts.requestKeys} request keys, ${facts.calls} calls, ${(facts.bodyBytes / 1e6).toFixed(1)} MB of bodies; recorded ${facts.recordedAt} (${facts.ageHours} h ago)`,
|
|
516
|
+
);
|
|
517
|
+
console.log(
|
|
518
|
+
` volatile query parameters: ${
|
|
519
|
+
Object.keys(archive.volatile).length
|
|
520
|
+
? Object.entries(archive.volatile)
|
|
521
|
+
.map(([r, p]) => `${redact(r)} [${p.join(',')}]`)
|
|
522
|
+
.join('; ')
|
|
523
|
+
: 'none'
|
|
524
|
+
}`,
|
|
525
|
+
);
|
|
526
|
+
console.log(
|
|
527
|
+
` hosts: ${facts.hosts.map((h) => `${h.host} ${h.requests}${h.blocked ? ` (${h.blocked} blocked)` : ''}`).join(', ')}`,
|
|
528
|
+
);
|
|
529
|
+
console.log(
|
|
530
|
+
` document: ttfb ${msStr(archive.document?.ttfbMs)}, download ${msStr(archive.document?.downloadMs)},` +
|
|
531
|
+
` early share ${archive.document?.earlyFraction ?? 'n/a'}; headers kept: ${Object.keys(archive.document?.headers ?? {}).join(', ') || 'none'}`,
|
|
532
|
+
);
|
|
533
|
+
console.log(
|
|
534
|
+
` network model: static ttfb ${msStr(n.staticTtfbMs)} on an idle link (${n.estimates.idleLinkSamples} of ${n.estimates.staticSamples} files; all files ${msStr(n.estimates.staticTtfbAllMedianMs)}, p90 ${msStr(n.estimates.staticTtfbP90Ms)}),` +
|
|
535
|
+
` wire queue ${Math.round((n.wireBytes ?? 0) / 1000)} kB, rtt ${msStr(n.rttMs)}, bandwidth ${mbit(n.bandwidthBps)}` +
|
|
536
|
+
` (busiest window; fastest single ${mbit(n.estimates.fastestSingleBps)});` +
|
|
537
|
+
` Chrome rated it ${n.effectiveType ?? 'n/a'} (rtt ${msStr(n.connectionRttMs)})`,
|
|
538
|
+
);
|
|
539
|
+
console.log(
|
|
540
|
+
` field medians (${f.runs} runs): LCP ${msStr(f.lcpMs)}, document end ${msStr(f.documentEndMs)}, gate start ${msStr(f.beforeGateMs)},` +
|
|
541
|
+
` gate ttfb ${msStr(f.ttfbMs)}, after gate ${msStr(f.afterGateMs)} path ${f.paths.map(redact).join(', ')}`,
|
|
542
|
+
);
|
|
543
|
+
if (archive.page) {
|
|
544
|
+
console.log(
|
|
545
|
+
` page state the replay starts WITHOUT: script-visible cookies ${archive.page.cookieNames?.length ?? 0}` +
|
|
546
|
+
` (${(archive.page.cookieNames ?? []).map(redact).join(', ') || 'none'}), localStorage keys ${archive.page.localStorageKeys ?? 'n/a'}`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (archive.build && archive.document?.bodyRef) {
|
|
550
|
+
const local = path.join(archive.build, 'index.html');
|
|
551
|
+
if (fs.existsSync(local)) {
|
|
552
|
+
const a = fs.readFileSync(path.join(archiveDir, 'bodies', archive.document.bodyRef), 'utf8');
|
|
553
|
+
const b = fs.readFileSync(local, 'utf8');
|
|
554
|
+
const al = a.split('\n');
|
|
555
|
+
const bl = new Set(b.split('\n'));
|
|
556
|
+
const differing = al.filter((line) => !bl.has(line)).length;
|
|
557
|
+
console.log(
|
|
558
|
+
a === b
|
|
559
|
+
? ` document HTML: identical to ${local}`
|
|
560
|
+
: ` document HTML: DIFFERS from ${local} (${a.length} vs ${b.length} chars, ${differing} recorded lines not in the local file${/\snonce=/.test(a) ? ', has nonce attributes' : ''}). The replay adds the recorded-only tags to the local file (--document merged).`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
console.log(`\nfiles: ${archiveDir}`);
|
|
565
|
+
console.log(
|
|
566
|
+
'This archive holds REAL responses from the recording account. Keep it on this machine; delete it with rm -rf when done.',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// --- inspect -----------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Problems that make an archive unusable (fatal) or a replay less faithful.
|
|
574
|
+
* The replaying agent checks this before it trusts anything else in the folder.
|
|
575
|
+
*/
|
|
576
|
+
export function validateArchive(archive, archiveDir) {
|
|
577
|
+
const fatal = [];
|
|
578
|
+
const warnings = [];
|
|
579
|
+
if (archive?.kind !== 'perf-kit-replay') fatal.push('not a replay archive (kind)');
|
|
580
|
+
else if (archive.schemaVersion !== ARCHIVE_SCHEMA) {
|
|
581
|
+
fatal.push(
|
|
582
|
+
`archive schema ${archive.schemaVersion}; this tool reads schema ${ARCHIVE_SCHEMA} - record again`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
for (const key of ['url', 'origin', 'endpoints', 'network', 'field']) {
|
|
586
|
+
if (archive?.[key] == null) fatal.push(`missing "${key}"`);
|
|
587
|
+
}
|
|
588
|
+
if (fatal.length) return { fatal, warnings, missingBodies: 0 };
|
|
589
|
+
const refs = new Set(
|
|
590
|
+
[
|
|
591
|
+
archive.document?.bodyRef,
|
|
592
|
+
...Object.values(archive.endpoints).flatMap((e) => e.calls.map((c) => c.bodyRef)),
|
|
593
|
+
].filter(Boolean),
|
|
594
|
+
);
|
|
595
|
+
const missingBodies = [...refs].filter(
|
|
596
|
+
(ref) => !fs.existsSync(path.join(archiveDir, 'bodies', ref)),
|
|
597
|
+
).length;
|
|
598
|
+
if (missingBodies)
|
|
599
|
+
fatal.push(`${missingBodies} of ${refs.size} response bodies are missing from bodies/`);
|
|
600
|
+
if (!archive.document)
|
|
601
|
+
warnings.push('no document recorded: the replay cannot serve the page HTML');
|
|
602
|
+
if (!archive.network.bandwidthBps)
|
|
603
|
+
warnings.push('no bandwidth in the model: downloads are not shaped');
|
|
604
|
+
if (!archive.network.effectiveType)
|
|
605
|
+
warnings.push('no Chrome network rating recorded: Chrome rates loopback as fast (use --ect)');
|
|
606
|
+
if (!archive.build)
|
|
607
|
+
warnings.push(
|
|
608
|
+
'no local copy of the recorded build named: calibration needs --dist recorded=<dir>',
|
|
609
|
+
);
|
|
610
|
+
if (Date.now() - archive.recordedAtMs > 24 * 3_600_000)
|
|
611
|
+
warnings.push('recorded more than 24 h ago: the network and the data may have changed');
|
|
612
|
+
return { fatal, warnings, missingBodies };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function loadArchive(dir) {
|
|
616
|
+
const archiveDir = path.resolve(dir);
|
|
617
|
+
const file = path.join(archiveDir, 'archive.json');
|
|
618
|
+
if (!fs.existsSync(file)) throw new Error(`no replay archive at ${file} (run \`record\` first)`);
|
|
619
|
+
const archive = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
620
|
+
const check = validateArchive(archive, archiveDir);
|
|
621
|
+
if (check.fatal.length) throw new Error(`${file}: ${check.fatal.join('; ')}`);
|
|
622
|
+
return archive;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export async function cmdInspect(argv) {
|
|
626
|
+
const opts = parseArgs({
|
|
627
|
+
args: argv,
|
|
628
|
+
options: { archive: { type: 'string' } },
|
|
629
|
+
allowPositionals: true,
|
|
630
|
+
}).values;
|
|
631
|
+
if (!opts.archive) throw new Error('inspect needs --archive <dir>');
|
|
632
|
+
const archiveDir = path.resolve(opts.archive);
|
|
633
|
+
const file = path.join(archiveDir, 'archive.json');
|
|
634
|
+
if (!fs.existsSync(file)) throw new Error(`no replay archive at ${file}`);
|
|
635
|
+
const archive = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
636
|
+
const check = validateArchive(archive, archiveDir);
|
|
637
|
+
if (check.fatal.length) {
|
|
638
|
+
console.log(`archive: ${archiveDir}\n UNUSABLE: ${check.fatal.join('; ')}`);
|
|
639
|
+
return { archive: archiveDir, usable: false, complete: false, ...check, gaps: [] };
|
|
640
|
+
}
|
|
641
|
+
const redact = makeRedactor(archive.redact);
|
|
642
|
+
console.log(`archive: ${archiveDir} (${redact(archive.url)}, variant ${archive.variant})`);
|
|
643
|
+
printArchiveSummary(archive, archiveDir, redact);
|
|
644
|
+
for (const w of check.warnings) console.log(` warning: ${w}`);
|
|
645
|
+
const runs = readTimingRuns(archiveDir);
|
|
646
|
+
const gaps = findGaps(archive, runs, archive.build, redact);
|
|
647
|
+
printGaps(gaps);
|
|
648
|
+
const discovery = archive.build
|
|
649
|
+
? discoverChunks(runs, {
|
|
650
|
+
origin: archive.origin,
|
|
651
|
+
build: archive.build,
|
|
652
|
+
rttMs: archive.network.rttMs,
|
|
653
|
+
redact,
|
|
654
|
+
})
|
|
655
|
+
: {
|
|
656
|
+
lcp: {
|
|
657
|
+
state: 'unknown',
|
|
658
|
+
reason: 'no local copy of the recorded build named (record --build <dir>)',
|
|
659
|
+
groups: [],
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
printLateChunks(discovery, 'field runs');
|
|
663
|
+
const next = replayCommands(archiveDir, archive.build);
|
|
664
|
+
console.log(`\nnext:\n ${next.calibrate}\n ${next.compare}`);
|
|
665
|
+
return {
|
|
666
|
+
archive: archiveDir,
|
|
667
|
+
usable: true,
|
|
668
|
+
complete: gaps.length === 0,
|
|
669
|
+
...check,
|
|
670
|
+
gaps,
|
|
671
|
+
lateChunksBeforeLcp: lateChunks(discovery),
|
|
672
|
+
...archiveFacts(archive, archiveDir, redact),
|
|
673
|
+
next,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// --- replay ------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
const REPLAY_OPTIONS = {
|
|
680
|
+
archive: { type: 'string' },
|
|
681
|
+
dist: { type: 'string', multiple: true },
|
|
682
|
+
runs: { type: 'string', default: '6' },
|
|
683
|
+
diagnose: { type: 'string', default: '0' },
|
|
684
|
+
cut: { type: 'string' },
|
|
685
|
+
calibrate: { type: 'boolean', default: false },
|
|
686
|
+
document: { type: 'string', default: 'merged' },
|
|
687
|
+
bandwidth: { type: 'string' },
|
|
688
|
+
rtt: { type: 'string' },
|
|
689
|
+
'static-ttfb': { type: 'string' },
|
|
690
|
+
wire: { type: 'string' },
|
|
691
|
+
ect: { type: 'string' },
|
|
692
|
+
storage: { type: 'string' },
|
|
693
|
+
pause: { type: 'string', default: '300' },
|
|
694
|
+
settle: { type: 'string', default: '3000' },
|
|
695
|
+
timeout: { type: 'string', default: '30000' },
|
|
696
|
+
out: { type: 'string' },
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const ECT_NAMES = { 'slow-2g': 'Slow-2G', '2g': '2G', '3g': '3G', '4g': '4G' };
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Chrome switches that send every host to the replay server and accept its
|
|
703
|
+
* certificate. `--no-proxy-server`: through a system proxy, the proxy would
|
|
704
|
+
* resolve the hosts and the requests would leave the machine. Chrome's own
|
|
705
|
+
* service requests (model downloads, autofill) would reach the replay server
|
|
706
|
+
* too; they are turned off to keep the logs clean.
|
|
707
|
+
* `ect` forces Chrome's network rating: over loopback Chrome rates the network
|
|
708
|
+
* as fast and never holds back low-priority requests, which a slow real
|
|
709
|
+
* network makes it do.
|
|
710
|
+
*/
|
|
711
|
+
export const replayBrowserArgs = (port, { ect = null } = {}) => [
|
|
712
|
+
`--host-resolver-rules=MAP * 127.0.0.1:${port}, EXCLUDE localhost`,
|
|
713
|
+
'--no-proxy-server',
|
|
714
|
+
'--ignore-certificate-errors',
|
|
715
|
+
'--disable-features=OptimizationHints,OptimizationGuideModelDownloading,OptimizationHintsFetching,AutofillServerCommunication,MediaRouter',
|
|
716
|
+
'--disable-client-side-phishing-detection',
|
|
717
|
+
'--no-pings',
|
|
718
|
+
...(ect && ECT_NAMES[ect] ? [`--force-effective-connection-type=${ECT_NAMES[ect]}`] : []),
|
|
719
|
+
];
|
|
720
|
+
|
|
721
|
+
function parseDists(values, archive) {
|
|
722
|
+
const list = values?.length ? values : archive.build ? [`recorded=${archive.build}`] : [];
|
|
723
|
+
if (!list.length) throw new Error('replay needs --dist <dir> (or name=<dir>, repeat for an A/B)');
|
|
724
|
+
const used = new Set();
|
|
725
|
+
return list.map((value, i) => {
|
|
726
|
+
const eq = value.indexOf('=');
|
|
727
|
+
let name =
|
|
728
|
+
eq > 0 ? value.slice(0, eq) : i === 0 ? 'base' : `candidate${list.length > 2 ? i : ''}`;
|
|
729
|
+
const dir = path.resolve(eq > 0 ? value.slice(eq + 1) : value);
|
|
730
|
+
if (!fs.existsSync(path.join(dir, 'index.html')))
|
|
731
|
+
throw new Error(`--dist ${dir} has no index.html`);
|
|
732
|
+
while (used.has(name)) name = `${name}-${i}`;
|
|
733
|
+
used.add(name);
|
|
734
|
+
return { name, cookies: [], url: archive.url, build: dir };
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export async function cmdReplay(argv) {
|
|
739
|
+
const opts = parseArgs({ args: argv, options: REPLAY_OPTIONS, allowPositionals: true }).values;
|
|
740
|
+
if (!opts.archive) throw new Error('replay needs --archive <dir> (written by `record`)');
|
|
741
|
+
const archiveDir = path.resolve(opts.archive);
|
|
742
|
+
const archive = loadArchive(archiveDir);
|
|
743
|
+
const variants = parseDists(opts.dist, archive);
|
|
744
|
+
if (!['merged', 'local', 'recorded'].includes(opts.document))
|
|
745
|
+
throw new Error('--document must be merged, local or recorded');
|
|
746
|
+
const runs = Number(opts.runs);
|
|
747
|
+
const diagnose = Number(opts.diagnose);
|
|
748
|
+
const cut = opts.cut ?? (archive.gate ? 'gate' : 'lcp');
|
|
749
|
+
const pause = Number(opts.pause);
|
|
750
|
+
const timing = { settleMs: Number(opts.settle), timeoutMs: Number(opts.timeout), throttle: null };
|
|
751
|
+
const network = {
|
|
752
|
+
...archive.network,
|
|
753
|
+
...(opts.bandwidth ? { bandwidthBps: (Number(opts.bandwidth) * 1e6) / 8 } : {}),
|
|
754
|
+
...(opts.rtt ? { rttMs: Number(opts.rtt) } : {}),
|
|
755
|
+
...(opts['static-ttfb']
|
|
756
|
+
? { staticTtfbMs: Number(opts['static-ttfb']), staticTtfbPoints: null }
|
|
757
|
+
: {}),
|
|
758
|
+
...(opts.wire ? { wireBytes: Number(opts.wire) * 1000 } : {}),
|
|
759
|
+
};
|
|
760
|
+
const ect = opts.ect === 'off' ? null : (opts.ect ?? archive.network.effectiveType ?? null);
|
|
761
|
+
if (ect && !ECT_NAMES[ect]) throw new Error('--ect must be slow-2g, 2g, 3g, 4g or off');
|
|
762
|
+
// The field runs load the page in a profile that has visited it before. A page
|
|
763
|
+
// that caches data in storage (a boot payload, flags) starts without waiting
|
|
764
|
+
// for it there, so by default the replay is warm when the recorded page had storage.
|
|
765
|
+
const storedKeys = archive.page?.localStorageKeys ?? 0;
|
|
766
|
+
const storage = opts.storage ?? (storedKeys > 0 ? 'warm' : 'empty');
|
|
767
|
+
if (!['warm', 'empty'].includes(storage)) throw new Error('--storage must be warm or empty');
|
|
768
|
+
const compression = chooseCompression(archive.assets, variants[0].build);
|
|
769
|
+
if (!compression.encoding) {
|
|
770
|
+
// Only an encoding the server can produce; it sends any other body unencoded.
|
|
771
|
+
const enc =
|
|
772
|
+
archive.assets.find((a) => ['br', 'gzip', 'zstd'].includes(a.encoding))?.encoding ?? null;
|
|
773
|
+
Object.assign(compression, { encoding: enc, level: null });
|
|
774
|
+
}
|
|
775
|
+
const redact = makeRedactor(archive.redact);
|
|
776
|
+
const config = {
|
|
777
|
+
url: archive.url,
|
|
778
|
+
origin: archive.origin,
|
|
779
|
+
variants: Object.fromEntries(variants.map((v) => [v.name, v])),
|
|
780
|
+
block: archive.block,
|
|
781
|
+
ignoreLcp: archive.ignoreLcp,
|
|
782
|
+
gate: archive.gate,
|
|
783
|
+
redact: archive.redact,
|
|
784
|
+
};
|
|
785
|
+
const outDir = path.resolve(opts.out ?? path.join(archiveDir, 'replay-report'));
|
|
786
|
+
|
|
787
|
+
console.log(`replay: ${redact(archive.url)} from ${archiveDir}`);
|
|
788
|
+
console.log(` dists: ${variants.map((v) => `${v.name}=${v.build}`).join(' ')}`);
|
|
789
|
+
console.log(
|
|
790
|
+
` network: static ttfb ${network.staticTtfbPoints ? `sampled (idle-link median ${msStr(network.staticTtfbMs)}, p90 ${msStr(network.staticTtfbPoints[18])})` : msStr(network.staticTtfbMs)},` +
|
|
791
|
+
` wire queue ${Math.round((network.wireBytes ?? 0) / 1000)} kB, rtt ${msStr(network.rttMs)}, bandwidth ${mbit(network.bandwidthBps)} shared;` +
|
|
792
|
+
` API: recorded server time; document: ${opts.document} HTML after ${msStr(archive.document?.ttfbMs)}` +
|
|
793
|
+
(archive.document?.earlyFraction != null
|
|
794
|
+
? ` (${Math.round(archive.document.earlyFraction * 100)}% at once, the rest ${msStr(archive.document.downloadMs)} later)`
|
|
795
|
+
: ''),
|
|
796
|
+
);
|
|
797
|
+
console.log(
|
|
798
|
+
` Chrome network rating: ${ect ?? 'not forced (Chrome rates loopback as fast)'}${opts.ect ? ' (--ect)' : ect ? ' (recorded)' : ''}`,
|
|
799
|
+
);
|
|
800
|
+
console.log(
|
|
801
|
+
` storage: ${storage}` +
|
|
802
|
+
(opts.storage
|
|
803
|
+
? ' (--storage)'
|
|
804
|
+
: storage === 'warm'
|
|
805
|
+
? ` (the recorded page had ${storedKeys} localStorage keys; --storage empty for a first visit)`
|
|
806
|
+
: ' (the recorded page had no localStorage)'),
|
|
807
|
+
);
|
|
808
|
+
console.log(
|
|
809
|
+
` compression: ${compression.encoding ?? 'none'}${compression.level != null ? ` level ${compression.level}` : ''}` +
|
|
810
|
+
(compression.files
|
|
811
|
+
? ` (recorded/local size ${compression.recordedOverLocal} over ${compression.files} files)`
|
|
812
|
+
: ''),
|
|
813
|
+
);
|
|
814
|
+
if (opts.document === 'merged' && archive.document?.bodyRef) {
|
|
815
|
+
const recordedHtml = fs.readFileSync(
|
|
816
|
+
path.join(archiveDir, 'bodies', archive.document.bodyRef),
|
|
817
|
+
'utf8',
|
|
818
|
+
);
|
|
819
|
+
const { added } = mergeDocument(
|
|
820
|
+
recordedHtml,
|
|
821
|
+
fs.readFileSync(path.join(variants[0].build, 'index.html'), 'utf8'),
|
|
822
|
+
);
|
|
823
|
+
console.log(
|
|
824
|
+
` document tags added from the recorded HTML: ${added.length ? added.map((a) => (a.ref ? `<${a.tag} ${a.ref}>` : `<${a.tag}> inline ${a.inlineChars} chars`)).join(', ') : 'none'}`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const server = await startReplayServer({
|
|
829
|
+
archive,
|
|
830
|
+
archiveDir,
|
|
831
|
+
tls: replayCertificate(),
|
|
832
|
+
network,
|
|
833
|
+
compression,
|
|
834
|
+
documentSource: opts.document,
|
|
835
|
+
});
|
|
836
|
+
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-kit-replay-chrome-'));
|
|
837
|
+
let browser = null;
|
|
838
|
+
const cleanup = async () => {
|
|
839
|
+
await browser?.close().catch(() => {});
|
|
840
|
+
await server.close().catch(() => {});
|
|
841
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
842
|
+
};
|
|
843
|
+
const offInterrupt = onInterrupt(cleanup);
|
|
844
|
+
try {
|
|
845
|
+
browser = await launchBrowser({
|
|
846
|
+
profileDir,
|
|
847
|
+
extraArgs: replayBrowserArgs(server.port, { ect }),
|
|
848
|
+
});
|
|
849
|
+
} catch (err) {
|
|
850
|
+
offInterrupt();
|
|
851
|
+
await cleanup();
|
|
852
|
+
throw err;
|
|
853
|
+
}
|
|
854
|
+
const { cdp } = browser;
|
|
855
|
+
await cdp.send('Security.setIgnoreCertificateErrors', { ignore: true }).catch(() => {});
|
|
856
|
+
// Raw rows too: chunk discovery needs the real chunk names (a hash can look like an ID).
|
|
857
|
+
const common = { config, saved: new Map(), redact, keepRaw: true, ...timing };
|
|
858
|
+
const gate = redact(config.gate);
|
|
859
|
+
|
|
860
|
+
// A fresh browser context per load: no storage, cookies or connections carry
|
|
861
|
+
// over. Both builds of a pair get the same seed: the same sampled network.
|
|
862
|
+
// `warm` first loads the page once, unmeasured, in that context: the build
|
|
863
|
+
// writes its own storage, as on an earlier visit, and the measured load reads
|
|
864
|
+
// it. Every load keeps the HTTP cache off and bypasses service workers, as the
|
|
865
|
+
// field runs do, so only storage and cookies carry over.
|
|
866
|
+
const load = async (variant, kind, seed = 0) => {
|
|
867
|
+
const { browserContextId } = await cdp.send('Target.createBrowserContext');
|
|
868
|
+
try {
|
|
869
|
+
if (storage === 'warm') {
|
|
870
|
+
server.beginRun(variant.build, { seed });
|
|
871
|
+
await timingRun(cdp, { ...common, variant, context: browserContextId });
|
|
872
|
+
}
|
|
873
|
+
server.beginRun(variant.build, { seed });
|
|
874
|
+
const run =
|
|
875
|
+
kind === 'diagnostic'
|
|
876
|
+
? await diagnosticRun(cdp, { ...common, variant, context: browserContextId, cut })
|
|
877
|
+
: await timingRun(cdp, { ...common, variant, context: browserContextId });
|
|
878
|
+
return { run, log: server.takeLog() };
|
|
879
|
+
} finally {
|
|
880
|
+
await cdp.send('Target.disposeBrowserContext', { browserContextId }).catch(() => {});
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
console.log('\nwarm-up (one unmeasured load per dist; fills the compression cache)');
|
|
886
|
+
for (const v of variants) await load(v, 'timing');
|
|
887
|
+
|
|
888
|
+
const timed = [];
|
|
889
|
+
const invalid = [];
|
|
890
|
+
const logs = new Map(variants.map((v) => [v.name, []]));
|
|
891
|
+
for (let i = 0; i < runs; i++) {
|
|
892
|
+
const order = i % 2 ? [...variants].reverse() : variants;
|
|
893
|
+
for (const variant of order) {
|
|
894
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
895
|
+
const { run, log } = await load(variant, 'timing', i + 1);
|
|
896
|
+
if (run.invalid) {
|
|
897
|
+
invalid.push({ pair: i + 1, variant: variant.name, reason: run.invalid });
|
|
898
|
+
console.log(
|
|
899
|
+
` pair ${i + 1} ${variant.name.padEnd(10)} INVALID: ${run.invalid}${attempt < MAX_RETRIES ? ' - retrying' : ''}`,
|
|
900
|
+
);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const cp = criticalPath(run.requests, run.sample.lcp, { pattern: gate });
|
|
904
|
+
timed.push({ pair: i + 1, variant: variant.name, ...run, criticalPath: cp });
|
|
905
|
+
logs.get(variant.name).push(...log);
|
|
906
|
+
const b = cp.breakdown;
|
|
907
|
+
const misses = log.filter((e) => e.source === 'miss').length;
|
|
908
|
+
console.log(
|
|
909
|
+
` pair ${i + 1} ${variant.name.padEnd(10)} LCP ${msStr(run.sample.lcp).padStart(7)}` +
|
|
910
|
+
(b
|
|
911
|
+
? ` doc end ${msStr(b.documentEndMs)} gate start ${msStr(b.beforeGateMs)} ttfb ${msStr(b.ttfbMs)} after ${msStr(b.afterGateMs)}`
|
|
912
|
+
: ` gate: ${cp.reason}`) +
|
|
913
|
+
(misses ? ` ${misses} unrecorded` : ''),
|
|
914
|
+
);
|
|
915
|
+
await sleep(pause);
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const diagnostics = new Map();
|
|
922
|
+
for (const variant of diagnose > 0 ? variants : []) {
|
|
923
|
+
const { run } = await load(variant, 'diagnostic');
|
|
924
|
+
if (run.invalid) console.log(` ${variant.name} diagnostic run INVALID: ${run.invalid}`);
|
|
925
|
+
else diagnostics.set(variant.name, run);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const summaries = {};
|
|
929
|
+
const replayInfo = {};
|
|
930
|
+
for (const variant of variants) {
|
|
931
|
+
const mine = timed.filter((t) => t.variant === variant.name);
|
|
932
|
+
const runtime = buildRuntimeReport({
|
|
933
|
+
variant,
|
|
934
|
+
mine,
|
|
935
|
+
invalid,
|
|
936
|
+
redact,
|
|
937
|
+
throttle: null,
|
|
938
|
+
});
|
|
939
|
+
runtime.mode = 'replay';
|
|
940
|
+
if (runtime.criticalPath) delete runtime.criticalPath.medianRunRequests;
|
|
941
|
+
replayInfo[variant.name] = summarizeLog(
|
|
942
|
+
logs.get(variant.name),
|
|
943
|
+
mine,
|
|
944
|
+
redact,
|
|
945
|
+
archive.field.paths.map(redact),
|
|
946
|
+
);
|
|
947
|
+
runtime.replay = replayInfo[variant.name];
|
|
948
|
+
runtime.chunkDiscovery = discoverChunks(
|
|
949
|
+
mine.map((t) => ({
|
|
950
|
+
requests: t.rawRequests,
|
|
951
|
+
lcp: t.sample.lcp,
|
|
952
|
+
fcp: t.sample.fcp ?? null,
|
|
953
|
+
})),
|
|
954
|
+
{ origin: archive.origin, build: variant.build, rttMs: network.rttMs, redact },
|
|
955
|
+
);
|
|
956
|
+
writeJson(path.join(outDir, variant.name, 'runtime-metrics.json'), runtime);
|
|
957
|
+
let coverage = null;
|
|
958
|
+
let profile = null;
|
|
959
|
+
const diag = diagnostics.get(variant.name);
|
|
960
|
+
if (diag) {
|
|
961
|
+
({ coverage, profile } = buildDiagnosticReports({ variant, diag, config, redact }));
|
|
962
|
+
writeJson(path.join(outDir, variant.name, 'coverage.json'), coverage);
|
|
963
|
+
writeJson(path.join(outDir, variant.name, 'profile.json'), profile);
|
|
964
|
+
}
|
|
965
|
+
summaries[variant.name] = { runtime, coverage, profile };
|
|
966
|
+
}
|
|
967
|
+
const report = buildFieldReport({
|
|
968
|
+
variants,
|
|
969
|
+
timed,
|
|
970
|
+
invalid,
|
|
971
|
+
summaries,
|
|
972
|
+
config,
|
|
973
|
+
redact,
|
|
974
|
+
cut,
|
|
975
|
+
throttle: null,
|
|
976
|
+
});
|
|
977
|
+
report.mode = 'replay';
|
|
978
|
+
report.replay = {
|
|
979
|
+
archive: archiveDir,
|
|
980
|
+
recordedVariant: archive.variant,
|
|
981
|
+
network,
|
|
982
|
+
effectiveType: ect,
|
|
983
|
+
storage,
|
|
984
|
+
compression,
|
|
985
|
+
document: opts.document,
|
|
986
|
+
variants: replayInfo,
|
|
987
|
+
};
|
|
988
|
+
report.limits = [
|
|
989
|
+
'a model of one recorded network and one recorded server, not the real ones: run --calibrate before trusting an A/B',
|
|
990
|
+
'API responses are the recorded ones; a request the recording never saw gets a 404 (see replay.variants.*.unrecorded)',
|
|
991
|
+
storage === 'warm'
|
|
992
|
+
? "each measured load follows one unmeasured load of the same build in the same browser context: storage the build wrote carries over, the recorded profile's own storage does not"
|
|
993
|
+
: 'each load starts with empty storage and no cookies; the recorded page had both (see the archive "page" field)',
|
|
994
|
+
...report.limits.slice(1),
|
|
995
|
+
];
|
|
996
|
+
if (opts.calibrate) {
|
|
997
|
+
const first = summaries[variants[0].name].runtime;
|
|
998
|
+
const calibration = calibrate(archive.field, {
|
|
999
|
+
lcpMs: first.metrics['runtime.lcp_ms'],
|
|
1000
|
+
...first.criticalPath?.medians,
|
|
1001
|
+
});
|
|
1002
|
+
const gaps = findGaps(archive, readTimingRuns(archiveDir), variants[0].build, redact);
|
|
1003
|
+
report.calibration = { ...calibration, verdict: calibrationVerdict(calibration, gaps), gaps };
|
|
1004
|
+
}
|
|
1005
|
+
writeJson(path.join(outDir, 'replay-report.json'), report);
|
|
1006
|
+
printFieldReport(report, summaries, config);
|
|
1007
|
+
printReplaySection(report, variants, summaries);
|
|
1008
|
+
console.log(`\nfiles: ${outDir}`);
|
|
1009
|
+
return {
|
|
1010
|
+
report: path.join(outDir, 'replay-report.json'),
|
|
1011
|
+
archive: archiveDir,
|
|
1012
|
+
effectiveType: ect,
|
|
1013
|
+
storage,
|
|
1014
|
+
network: {
|
|
1015
|
+
staticTtfbMs: network.staticTtfbMs,
|
|
1016
|
+
wireBytes: network.wireBytes ?? 0,
|
|
1017
|
+
bandwidthBps: network.bandwidthBps,
|
|
1018
|
+
},
|
|
1019
|
+
variants: Object.fromEntries(
|
|
1020
|
+
variants.map((v) => {
|
|
1021
|
+
const s = report.variants[v.name];
|
|
1022
|
+
const info = replayInfo[v.name];
|
|
1023
|
+
return [
|
|
1024
|
+
v.name,
|
|
1025
|
+
{
|
|
1026
|
+
dist: v.build,
|
|
1027
|
+
validRuns: s.validRuns,
|
|
1028
|
+
invalidRuns: s.invalidRuns,
|
|
1029
|
+
lcpMs: s.lcpMs,
|
|
1030
|
+
medians: s.criticalPath?.medians ?? null,
|
|
1031
|
+
unrecordedPageRequests: info.unrecorded.filter((u) => u.byPage),
|
|
1032
|
+
pathMatchesField: info.pathMatchesField,
|
|
1033
|
+
lateChunksBeforeLcp: lateChunks(summaries[v.name].runtime.chunkDiscovery),
|
|
1034
|
+
},
|
|
1035
|
+
];
|
|
1036
|
+
}),
|
|
1037
|
+
),
|
|
1038
|
+
comparisons: report.comparisons,
|
|
1039
|
+
calibration: report.calibration ?? null,
|
|
1040
|
+
};
|
|
1041
|
+
} finally {
|
|
1042
|
+
offInterrupt();
|
|
1043
|
+
await cleanup();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** What the server did for one variant: match levels, unrecorded requests, the landing path. */
|
|
1048
|
+
export function summarizeLog(log, mine, redact, fieldPaths = []) {
|
|
1049
|
+
const levels = {};
|
|
1050
|
+
for (const e of log) {
|
|
1051
|
+
const k = `${e.source}:${e.level}`;
|
|
1052
|
+
levels[k] = (levels[k] ?? 0) + 1;
|
|
1053
|
+
}
|
|
1054
|
+
// A request of the page: started by its HTML or scripts. The browser's own
|
|
1055
|
+
// requests (favicon, navigation) have the initiator type "other".
|
|
1056
|
+
const pageUrls = new Set(
|
|
1057
|
+
mine.flatMap((t) =>
|
|
1058
|
+
t.requests.filter((r) => r.initiator?.type && r.initiator.type !== 'other').map((r) => r.url),
|
|
1059
|
+
),
|
|
1060
|
+
);
|
|
1061
|
+
const unrecorded = new Map();
|
|
1062
|
+
for (const e of log.filter((x) => x.source === 'miss')) {
|
|
1063
|
+
const url = redact(e.url);
|
|
1064
|
+
const key = `${e.method} ${url}`;
|
|
1065
|
+
const row = unrecorded.get(key) ?? {
|
|
1066
|
+
method: e.method,
|
|
1067
|
+
url,
|
|
1068
|
+
count: 0,
|
|
1069
|
+
byPage: pageUrls.has(url),
|
|
1070
|
+
};
|
|
1071
|
+
row.count++;
|
|
1072
|
+
unrecorded.set(key, row);
|
|
1073
|
+
}
|
|
1074
|
+
const paths = [...new Set(mine.map((t) => t.path))];
|
|
1075
|
+
return {
|
|
1076
|
+
loads: mine.length,
|
|
1077
|
+
levels,
|
|
1078
|
+
unrecorded: [...unrecorded.values()].sort(
|
|
1079
|
+
(a, b) => Number(b.byPage) - Number(a.byPage) || b.count - a.count,
|
|
1080
|
+
),
|
|
1081
|
+
paths,
|
|
1082
|
+
pathMatchesField: fieldPaths.length ? paths.every((p) => fieldPaths.includes(p)) : null,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function printReplaySection(report, variants, summaries) {
|
|
1087
|
+
const r = report.replay;
|
|
1088
|
+
for (const v of variants) {
|
|
1089
|
+
const info = r.variants[v.name];
|
|
1090
|
+
const lv = Object.entries(info.levels)
|
|
1091
|
+
.sort((a, b) => b[1] - a[1])
|
|
1092
|
+
.map(([k, n]) => `${k} ${n}`)
|
|
1093
|
+
.join(', ');
|
|
1094
|
+
console.log(`\n${v.name}: served ${lv}`);
|
|
1095
|
+
if (info.pathMatchesField === false)
|
|
1096
|
+
console.log(
|
|
1097
|
+
` WARNING: the replay landed on ${info.paths.join(', ')}, not on the recorded path`,
|
|
1098
|
+
);
|
|
1099
|
+
const byPage = info.unrecorded.filter((u) => u.byPage);
|
|
1100
|
+
if (byPage.length) {
|
|
1101
|
+
console.log(
|
|
1102
|
+
` UNRECORDED requests of the page (404 in the replay - the result may not hold):`,
|
|
1103
|
+
);
|
|
1104
|
+
for (const u of byPage.slice(0, 10)) console.log(` ${u.count}x ${u.method} ${u.url}`);
|
|
1105
|
+
}
|
|
1106
|
+
const other = info.unrecorded.length - byPage.length;
|
|
1107
|
+
if (other)
|
|
1108
|
+
console.log(
|
|
1109
|
+
` ${other} unrecorded request kinds that the page did not start (favicon, browser-internal)`,
|
|
1110
|
+
);
|
|
1111
|
+
printLateChunks(summaries[v.name].runtime.chunkDiscovery, `${v.name} replay runs`);
|
|
1112
|
+
}
|
|
1113
|
+
if (report.calibration) {
|
|
1114
|
+
const c = report.calibration;
|
|
1115
|
+
console.log(
|
|
1116
|
+
`\ncalibration: replay of ${variants[0].name} vs the recorded field medians (${c.pass ? 'PASS' : 'FAIL'})`,
|
|
1117
|
+
);
|
|
1118
|
+
for (const row of c.rows) {
|
|
1119
|
+
if (row.pass === null || row.replay === null) {
|
|
1120
|
+
console.log(
|
|
1121
|
+
` ${row.label.padEnd(13)} field ${msStr(row.field).padStart(7)} replay ${msStr(row.replay).padStart(7)} ${row.pass === null ? '(not compared)' : 'OFF (no value in the replay)'}`,
|
|
1122
|
+
);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
console.log(
|
|
1126
|
+
` ${row.label.padEnd(13)} field ${msStr(row.field).padStart(7)} replay ${msStr(row.replay).padStart(7)}` +
|
|
1127
|
+
` ${row.deltaMs > 0 ? '+' : ''}${row.deltaMs}ms (tolerance ${row.toleranceMs}ms) ${row.pass ? 'ok' : 'OFF'}`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
const verdicts = {
|
|
1131
|
+
pass: 'PASS - every row is within tolerance and the archive is complete; an A/B on this archive is usable',
|
|
1132
|
+
'record-again':
|
|
1133
|
+
'RECORD AGAIN - the archive misses responses that most field runs got (see the gaps)',
|
|
1134
|
+
'model-error':
|
|
1135
|
+
'MODEL ERROR - the archive is complete, so a new recording does not fix the rows that are OFF',
|
|
1136
|
+
};
|
|
1137
|
+
console.log(`verdict: ${verdicts[c.verdict]}`);
|
|
1138
|
+
printGaps(c.gaps);
|
|
1139
|
+
}
|
|
1140
|
+
}
|