webperf-snippets 1.2.0 → 1.4.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/README.md +126 -4
- package/package.json +3 -2
- package/snippets/CoreWebVitals/CLS.js +3 -2
- package/snippets/CoreWebVitals/LCP-Subparts.js +32 -35
- package/src/baseline.js +201 -0
- package/src/bin.js +229 -20
- package/src/blocking.js +14 -0
- package/src/conditions.js +7 -0
- package/src/crux.js +6 -6
- package/src/matrix.js +227 -0
- package/src/ratings.js +16 -0
- package/src/reporters/baseline-view.js +83 -0
- package/src/reporters/human.js +89 -2
- package/src/reporters/markdown.js +84 -2
- package/src/reporters/matrix-view.js +42 -0
- package/src/runs.js +65 -0
- package/src/throttling.js +41 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The comparison with a baseline as the human and markdown reports draw it: one row per metric with
|
|
2
|
+
// its two values, the change and the verdict, and why an unchanged one did not count. The values stay
|
|
3
|
+
// numbers with their unit, for each report to format.
|
|
4
|
+
|
|
5
|
+
const MINUS = "−";
|
|
6
|
+
const signed = (n, text) => (n > 0 ? `+${text}` : n < 0 ? `${MINUS}${text}` : text);
|
|
7
|
+
const arrow = (delta) => (delta > 0 ? "▲" : delta < 0 ? "▼" : "=");
|
|
8
|
+
const unitOf = (metric) => (metric === "CLS" ? "score" : "ms");
|
|
9
|
+
|
|
10
|
+
// "▲ +380ms (+18%)", "▼ −90ms (−15%)", "▼ −0.0020"
|
|
11
|
+
function describeChange(metric, { delta, deltaPercent }) {
|
|
12
|
+
if (metric === "CLS") return `${arrow(delta)} ${signed(delta, Math.abs(delta).toFixed(4))}`;
|
|
13
|
+
const ms = signed(delta, `${Math.abs(Math.round(delta))}ms`);
|
|
14
|
+
const percent = deltaPercent == null ? "" : ` (${signed(deltaPercent, `${Math.abs(Math.round(deltaPercent))}%`)})`;
|
|
15
|
+
return `${arrow(delta)} ${ms}${percent}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// An unchanged metric that moved stayed under its threshold, or beat it inside the range of the runs
|
|
19
|
+
// of the baseline: noise the baseline already had
|
|
20
|
+
function noteOf(metric, m) {
|
|
21
|
+
if (m.verdict !== "unchanged" || m.delta === 0) return null;
|
|
22
|
+
if (Math.abs(m.delta) > m.threshold) return "inside the range of the baseline";
|
|
23
|
+
return `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function baselineView({ file, url, runs, metrics, errors, warnings }) {
|
|
27
|
+
const summary = `${url}, ${runs.count === 1 ? "1 run" : `${runs.count} runs, median by ${runs.medianBy}`}`;
|
|
28
|
+
const rows = Object.entries(metrics).map(([metric, m]) => ({
|
|
29
|
+
metric,
|
|
30
|
+
unit: unitOf(metric),
|
|
31
|
+
baseline: m.baseline,
|
|
32
|
+
current: m.current,
|
|
33
|
+
change: m.verdict === "not measured" ? null : describeChange(metric, m),
|
|
34
|
+
verdict: m.verdict,
|
|
35
|
+
note: m.verdict === "not measured" ? null : noteOf(metric, m),
|
|
36
|
+
}));
|
|
37
|
+
return { file, summary, rows, newErrors: errors.new, fixedErrors: errors.fixed, warnings };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What got worse, one line each, for --fail-on-regression: the metrics that regressed and the errors
|
|
41
|
+
// that only this run has
|
|
42
|
+
export function describeRegressions({ metrics, errors }) {
|
|
43
|
+
return [
|
|
44
|
+
...Object.entries(metrics)
|
|
45
|
+
.filter(([, m]) => m.verdict === "regressed")
|
|
46
|
+
.map(([metric, m]) => `${metric} ${describeChange(metric, m)}`),
|
|
47
|
+
...errors.new.map((e) => `new error ${e.id}: ${e.message}`),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The change of a metric in a cell of the matrix table: the figure only when it counts, so the table
|
|
52
|
+
// reads at a glance. "▲ +380ms", "▼ −400ms", "=" for unchanged, "·" when one side did not measure it.
|
|
53
|
+
export function changeCell(metric, m) {
|
|
54
|
+
if (m.verdict === "not measured") return { text: "·", verdict: m.verdict };
|
|
55
|
+
if (m.verdict === "unchanged") return { text: "=", verdict: m.verdict };
|
|
56
|
+
const amount = metric === "CLS" ? Math.abs(m.delta).toFixed(4) : `${Math.abs(Math.round(m.delta))}ms`;
|
|
57
|
+
return { text: `${arrow(m.delta)} ${signed(m.delta, amount)}`, verdict: m.verdict };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The comparison of a matrix below its table. A warning that every compared combination has, such as
|
|
61
|
+
// a baseline of one run, shows once; the others name their combinations.
|
|
62
|
+
export function matrixComparisonView(root, combinations) {
|
|
63
|
+
const compared = combinations.filter((c) => c.baseline);
|
|
64
|
+
const idsByWarning = new Map();
|
|
65
|
+
for (const c of compared) {
|
|
66
|
+
for (const warning of c.baseline.warnings) idsByWarning.set(warning, [...(idsByWarning.get(warning) ?? []), c.id]);
|
|
67
|
+
}
|
|
68
|
+
const warnings = [...idsByWarning].map(([warning, ids]) => (ids.length === compared.length ? warning : `${ids.join(", ")}: ${warning}`));
|
|
69
|
+
const errorsOf = (kind) => compared.flatMap((c) => c.baseline.errors[kind].map((e) => `${c.id}: ${e.id}: ${e.message}`));
|
|
70
|
+
return {
|
|
71
|
+
file: root.file,
|
|
72
|
+
warnings: [...root.warnings, ...warnings],
|
|
73
|
+
newErrors: errorsOf("new"),
|
|
74
|
+
fixedErrors: errorsOf("fixed"),
|
|
75
|
+
added: root.added,
|
|
76
|
+
missing: root.missing,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The regressions of every combination, each named by its combination
|
|
81
|
+
export function describeMatrixRegressions(combinations) {
|
|
82
|
+
return combinations.filter((c) => c.baseline).flatMap((c) => describeRegressions(c.baseline).map((r) => `${c.id}: ${r}`));
|
|
83
|
+
}
|
package/src/reporters/human.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { styleText } from "node:util";
|
|
2
2
|
import { fieldDataView } from "../crux.js";
|
|
3
3
|
import { describeThrottling } from "../throttling.js";
|
|
4
|
+
import { describeRuns } from "../runs.js";
|
|
5
|
+
import { matrixView } from "./matrix-view.js";
|
|
6
|
+
import { baselineView } from "./baseline-view.js";
|
|
4
7
|
|
|
5
8
|
const RATING_ICON = {
|
|
6
9
|
good: "🟢",
|
|
@@ -203,11 +206,41 @@ function renderFieldData(crux, results) {
|
|
|
203
206
|
return lines;
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
|
|
209
|
+
// The runs line, and the median, min and max of each metric across the runs
|
|
210
|
+
function renderRuns(runs) {
|
|
211
|
+
const lines = [styleText("dim", describeRuns(runs))];
|
|
212
|
+
for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
|
|
213
|
+
const cell = (label, value) => pad(`${label} ${formatValue(value, unit)}`, 16);
|
|
214
|
+
lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max).trimEnd()}`));
|
|
215
|
+
}
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const VERDICT_STYLE = { regressed: "red", improved: "green", unchanged: "dim", "not measured": "dim", new: "cyan" };
|
|
220
|
+
|
|
221
|
+
// The comparison with --baseline, after the report: its warnings first, since they say how far to
|
|
222
|
+
// trust the table, then a row per metric and the errors that appeared or went away
|
|
223
|
+
function renderBaseline(comparison) {
|
|
224
|
+
const view = baselineView(comparison);
|
|
225
|
+
const lines = ["", styleText("bold", `Compared with ${view.file} (${view.summary})`)];
|
|
226
|
+
for (const warning of view.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
|
|
227
|
+
lines.push(styleText("dim", ` ${pad("Metric", 9)}${pad("Baseline", 11)}${pad("Now", 10)}Change`));
|
|
228
|
+
for (const row of view.rows) {
|
|
229
|
+
const value = (v) => (v == null ? "·" : formatValue(v, row.unit));
|
|
230
|
+
const verdict = row.note ? `${row.verdict} (${row.note})` : row.verdict;
|
|
231
|
+
lines.push(` ${pad(row.metric, 9)}${pad(value(row.baseline), 11)}${pad(value(row.current), 10)}${pad(row.change ?? "", 20)}${styleText(VERDICT_STYLE[row.verdict], verdict)}`);
|
|
232
|
+
}
|
|
233
|
+
for (const e of view.newErrors) lines.push(` ${styleText("red", "+ new error")} ${e.id}: ${e.message}`);
|
|
234
|
+
for (const e of view.fixedErrors) lines.push(` ${styleText("green", "− fixed")} ${e.id}: ${e.message}`);
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, baseline, title = `WebPerf Snippets — ${url}` }) {
|
|
207
239
|
const lines = [];
|
|
208
|
-
lines.push(styleText(["bold"],
|
|
240
|
+
lines.push(styleText(["bold"], title));
|
|
209
241
|
lines.push(styleText("dim", `Navigated in ${navMs}ms`));
|
|
210
242
|
if (throttling) lines.push(styleText("dim", `Throttling: ${describeThrottling(throttling)}`));
|
|
243
|
+
if (runs) lines.push(...renderRuns(runs));
|
|
211
244
|
lines.push("");
|
|
212
245
|
for (const r of results) lines.push(renderResult(r, verbose));
|
|
213
246
|
if (crux) {
|
|
@@ -219,5 +252,59 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
|
|
|
219
252
|
lines.push(styleText("yellow", "Page errors during run:"));
|
|
220
253
|
for (const err of pageErrors) lines.push(` ${styleText("dim", err)}`);
|
|
221
254
|
}
|
|
255
|
+
if (baseline) lines.push(...renderBaseline(baseline));
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// One row per combination with the rated median of each metric, and below it the errors that decide
|
|
260
|
+
// the exit code. With verbose, the report of the median run of each combination follows.
|
|
261
|
+
export function reportMatrixHuman(report) {
|
|
262
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
263
|
+
const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
|
|
264
|
+
const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
|
|
265
|
+
const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
|
|
266
|
+
|
|
267
|
+
const lines = [];
|
|
268
|
+
lines.push(styleText(["bold"], `WebPerf Snippets — ${file} (${summary})`));
|
|
269
|
+
lines.push("");
|
|
270
|
+
// With --baseline, a change column follows each metric
|
|
271
|
+
const headings = metrics.map((m) => pad(m, 14) + (comparison ? pad("Δ", 12) : "")).join("");
|
|
272
|
+
lines.push(styleText("dim", ` ${identity(columns.map(([, label]) => label))}${headings.trimEnd()}`));
|
|
273
|
+
for (const { combination: c, cells, changes } of rows) {
|
|
274
|
+
const row = ` ${identity(columns.map(([key]) => c[key]))}`;
|
|
275
|
+
if (!cells) {
|
|
276
|
+
lines.push(row + styleText("red", "✗ every run failed"));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const drawn = cells.map((cell, i) => {
|
|
280
|
+
const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14);
|
|
281
|
+
return value + (changes ? pad(styleText(VERDICT_STYLE[changes[i].verdict], changes[i].text), 12) : "");
|
|
282
|
+
});
|
|
283
|
+
lines.push(row + drawn.join("").trimEnd());
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (errors.length) {
|
|
287
|
+
lines.push("");
|
|
288
|
+
lines.push(styleText("red", "Errors:"));
|
|
289
|
+
for (const e of errors) lines.push(` ${styleText("red", "✗")} ${e}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (comparison) {
|
|
293
|
+
lines.push("", styleText("bold", `Compared with ${comparison.file}`));
|
|
294
|
+
for (const warning of comparison.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
|
|
295
|
+
for (const e of comparison.newErrors) lines.push(` ${styleText("red", "+ new error")} ${e}`);
|
|
296
|
+
for (const e of comparison.fixedErrors) lines.push(` ${styleText("green", "− fixed")} ${e}`);
|
|
297
|
+
if (comparison.added.length) lines.push(` Added: ${comparison.added.join(", ")}`);
|
|
298
|
+
if (comparison.missing.length) lines.push(` Missing: ${comparison.missing.join(", ")}`);
|
|
299
|
+
if (!comparison.warnings.length && !comparison.newErrors.length && !comparison.fixedErrors.length && !comparison.added.length && !comparison.missing.length) {
|
|
300
|
+
lines.push(styleText("dim", " The same combinations, no new or fixed errors, and no warnings"));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (report.verbose) {
|
|
305
|
+
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
306
|
+
lines.push("", reportHuman({ ...c, verbose: true, title: `${c.id} — ${c.url}` }));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
222
309
|
return lines.join("\n");
|
|
223
310
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { fieldDataView } from "../crux.js";
|
|
2
2
|
import { describeThrottling } from "../throttling.js";
|
|
3
|
+
import { describeRuns } from "../runs.js";
|
|
4
|
+
import { matrixView } from "./matrix-view.js";
|
|
5
|
+
import { baselineView } from "./baseline-view.js";
|
|
3
6
|
|
|
4
7
|
const RATING_ICON = {
|
|
5
8
|
good: "✅",
|
|
@@ -72,12 +75,43 @@ function renderFieldData(crux, results) {
|
|
|
72
75
|
];
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
// The median, min and max of each metric across the runs
|
|
79
|
+
function renderRuns(runs) {
|
|
80
|
+
const lines = ["| Metric | Median | Min | Max |", "|--------|--------|-----|-----|"];
|
|
81
|
+
for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
|
|
82
|
+
lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} |`);
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const VERDICT_ICON = { regressed: "❌", improved: "✅", unchanged: "➖", "not measured": "·" };
|
|
88
|
+
|
|
89
|
+
// The comparison with --baseline. The warnings go in the comment itself: whoever reads the pull
|
|
90
|
+
// request does not see the stderr of the CI.
|
|
91
|
+
function renderBaseline(comparison) {
|
|
92
|
+
const view = baselineView(comparison);
|
|
93
|
+
const lines = ["", `### Compared with \`${view.file}\``, `> ${view.summary}`];
|
|
94
|
+
for (const warning of view.warnings) lines.push(">", `> ⚠️ ${warning}`);
|
|
95
|
+
lines.push("", "| Metric | Baseline | Now | Change | Verdict |", "|--------|----------|-----|--------|---------|");
|
|
96
|
+
for (const row of view.rows) {
|
|
97
|
+
const value = (v) => (v == null ? "·" : formatValue(v, row.unit));
|
|
98
|
+
const verdict = `${VERDICT_ICON[row.verdict]} ${row.verdict}${row.note ? ` (${row.note})` : ""}`;
|
|
99
|
+
lines.push(`| ${row.metric} | ${value(row.baseline)} | ${value(row.current)} | ${row.change ?? "·"} | ${verdict} |`);
|
|
100
|
+
}
|
|
101
|
+
if (view.newErrors.length) lines.push("", "#### New errors", ...view.newErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
|
|
102
|
+
if (view.fixedErrors.length) lines.push("", "#### Fixed errors", ...view.fixedErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// With a null title, the report has no heading, as inside the details block of a matrix
|
|
107
|
+
export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, baseline, title = `WebPerf Results — ${url}` }) {
|
|
76
108
|
const lines = [];
|
|
77
|
-
lines.push(`##
|
|
109
|
+
if (title) lines.push(`## ${title}`);
|
|
78
110
|
lines.push(`> Navigated in ${navMs}ms`);
|
|
79
111
|
if (throttling) lines.push(">", `> Throttling: ${describeThrottling(throttling)}`);
|
|
112
|
+
if (runs) lines.push(">", `> ${describeRuns(runs)}`);
|
|
80
113
|
lines.push("");
|
|
114
|
+
if (runs) lines.push(...renderRuns(runs), "");
|
|
81
115
|
|
|
82
116
|
const initial = results.filter((r) => !r.reason);
|
|
83
117
|
if (initial.length > 0) {
|
|
@@ -106,5 +140,53 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
106
140
|
for (const err of pageErrors) lines.push(`- ${err}`);
|
|
107
141
|
}
|
|
108
142
|
|
|
143
|
+
if (baseline) lines.push(...renderBaseline(baseline));
|
|
144
|
+
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A table with the rated median of each combination, the errors that decide the exit code, and the
|
|
149
|
+
// report of the median run of each combination folded into a details block, to keep a pull request
|
|
150
|
+
// comment readable.
|
|
151
|
+
export function reportMatrixMarkdown(report) {
|
|
152
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
153
|
+
// With --baseline, a change column follows each metric
|
|
154
|
+
const metricHeadings = metrics.flatMap((m) => (comparison ? [m, "Δ"] : [m]));
|
|
155
|
+
const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metricHeadings];
|
|
156
|
+
const row = (parts) => `|${parts.map((part) => (part ? ` ${part} ` : " ")).join("|")}|`;
|
|
157
|
+
|
|
158
|
+
const lines = [`## WebPerf Results — ${file}`, `> ${summary}`, ""];
|
|
159
|
+
lines.push(row(headings));
|
|
160
|
+
lines.push(`|${headings.map((h) => "-".repeat(h.length + 2)).join("|")}|`);
|
|
161
|
+
for (const { combination: c, cells, changes } of rows) {
|
|
162
|
+
const drawn = cells
|
|
163
|
+
? cells.flatMap((cell, i) => {
|
|
164
|
+
const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·";
|
|
165
|
+
return changes ? [value, changes[i].text] : [value];
|
|
166
|
+
})
|
|
167
|
+
: ["❌ every run failed", ...metricHeadings.slice(1).map(() => "")];
|
|
168
|
+
lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (errors.length) {
|
|
172
|
+
lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (comparison) {
|
|
176
|
+
lines.push("", `### Compared with \`${comparison.file}\``);
|
|
177
|
+
if (comparison.warnings.length) lines.push(...comparison.warnings.flatMap((w, i) => [...(i ? [">"] : []), `> ⚠️ ${w}`]), "");
|
|
178
|
+
const ids = (list) => list.map((id) => `\`${id}\``).join(", ");
|
|
179
|
+
const items = [
|
|
180
|
+
...comparison.newErrors.map((e) => `- New error: ${e}`),
|
|
181
|
+
...comparison.fixedErrors.map((e) => `- Fixed: ${e}`),
|
|
182
|
+
...(comparison.added.length ? [`- Added: ${ids(comparison.added)}`] : []),
|
|
183
|
+
...(comparison.missing.length ? [`- Missing: ${ids(comparison.missing)}`] : []),
|
|
184
|
+
];
|
|
185
|
+
lines.push(...(items.length ? items : ["The same combinations, no new or fixed errors."]));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
189
|
+
lines.push("", `<details><summary>${c.id}</summary>`, "", reportMarkdown({ ...c, title: null }), "", "</details>");
|
|
190
|
+
}
|
|
109
191
|
return lines.join("\n");
|
|
110
192
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { rate } from "../ratings.js";
|
|
3
|
+
import { blockingErrors } from "../blocking.js";
|
|
4
|
+
import { changeCell, matrixComparisonView } from "./baseline-view.js";
|
|
5
|
+
|
|
6
|
+
// The order of the metric columns of the matrix table
|
|
7
|
+
const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
|
|
8
|
+
|
|
9
|
+
// What the human and markdown reports of a matrix show, so they only differ in how they draw it:
|
|
10
|
+
// the title, the metric columns, one row per combination with the rated median of each metric, and
|
|
11
|
+
// the errors that decide the exit code. With --baseline, each row also has the change of each metric
|
|
12
|
+
// ("new" for a combination the baseline did not have), and the comparison goes below the table.
|
|
13
|
+
export function matrixView({ matrix, runs, combinations, baseline }) {
|
|
14
|
+
const measured = combinations.filter((c) => !c.error);
|
|
15
|
+
const medianBys = [...new Set(measured.map((c) => c.runs.medianBy))];
|
|
16
|
+
const metrics = METRICS.filter((metric) => measured.some((c) => c.runs.summary[metric]));
|
|
17
|
+
const median = medianBys.length ? `, median by ${medianBys.join(", ")}` : "";
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
file: basename(matrix),
|
|
21
|
+
// A matrix without variants has no Variant column
|
|
22
|
+
hasVariants: combinations.some((c) => c.variant),
|
|
23
|
+
summary: `${combinations.length} combinations, ${runs} run${runs === 1 ? "" : "s"} each${median}`,
|
|
24
|
+
metrics,
|
|
25
|
+
rows: combinations.map((c) => ({
|
|
26
|
+
combination: c,
|
|
27
|
+
...(baseline
|
|
28
|
+
? { changes: metrics.map((metric) => (!c.baseline ? { text: "new", verdict: "new" } : c.baseline.metrics[metric] ? changeCell(metric, c.baseline.metrics[metric]) : { text: "·", verdict: "not measured" })) }
|
|
29
|
+
: {}),
|
|
30
|
+
cells: c.error
|
|
31
|
+
? null
|
|
32
|
+
: metrics.map((metric) => {
|
|
33
|
+
const value = c.runs.summary[metric];
|
|
34
|
+
return value ? { median: value.median, unit: value.unit, rating: rate(metric, value.median) } : null;
|
|
35
|
+
}),
|
|
36
|
+
})),
|
|
37
|
+
...(baseline ? { comparison: matrixComparisonView(baseline, combinations) } : {}),
|
|
38
|
+
errors: combinations.flatMap((c) =>
|
|
39
|
+
c.error ? [`${c.id}: ${c.error}`] : blockingErrors(c.results).map((e) => `${c.id}: ${e.id}: ${e.message}`)
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/runs.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Several runs of the same measurement, reduced to one: the median run, as WebPageTest reports it.
|
|
2
|
+
// Every result of the report comes from that run, so they agree with each other (the sub-parts of
|
|
3
|
+
// the LCP add up to its LCP), and a summary gives the median, min and max of each metric.
|
|
4
|
+
|
|
5
|
+
// The metrics the snippets report, and navMs, which every run has
|
|
6
|
+
export const MEDIAN_METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP", "navMs"];
|
|
7
|
+
|
|
8
|
+
// Without --median-by, the median follows the first of these that the runs measured
|
|
9
|
+
const DEFAULT_ORDER = ["LCP", "FCP", "TTFB", "navMs"];
|
|
10
|
+
|
|
11
|
+
const SUMMARY_METRICS = MEDIAN_METRICS.filter((metric) => metric !== "navMs");
|
|
12
|
+
|
|
13
|
+
// The value of a metric in a run: its first result with that metric, as LCP comes before its
|
|
14
|
+
// follow-up LCP-Subparts, which reports the same metric
|
|
15
|
+
function valueOf(run, metric) {
|
|
16
|
+
if (metric === "navMs") return run.navMs;
|
|
17
|
+
const result = run.results.find((r) => r.metric === metric && r.status === "ok" && typeof r.value === "number");
|
|
18
|
+
return result?.value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const measures = (runs, metric) => runs.some((run) => typeof valueOf(run, metric) === "number");
|
|
22
|
+
|
|
23
|
+
// The middle element of numbers sorted from low to high. With an even count it takes the lower of
|
|
24
|
+
// the two middle ones, so the median is always a run that happened, never an average of two.
|
|
25
|
+
const middle = (sorted) => sorted[Math.floor((sorted.length - 1) / 2)];
|
|
26
|
+
|
|
27
|
+
// "3 runs, median by LCP: run 2 (1 failed)", for the header of the reports
|
|
28
|
+
export function describeRuns({ count, failed, medianRun, medianBy, medianByRequested }) {
|
|
29
|
+
const by = medianByRequested ? `${medianBy} (no run measured ${medianByRequested})` : medianBy;
|
|
30
|
+
return `${count} run${count === 1 ? "" : "s"}, median by ${by}: run ${medianRun}${failed ? ` (${failed} failed)` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The runs as the CLI made them, in order, a run that failed being { error }. Returns the payload of
|
|
34
|
+
// the median run and the `runs` field of the report, or nulls and the errors when every run failed.
|
|
35
|
+
export function summarizeRuns(runs, { medianBy: requested } = {}) {
|
|
36
|
+
const completed = runs.filter((run) => !run.error);
|
|
37
|
+
if (completed.length === 0) return { payload: null, runs: null, errors: runs.map((run) => run.error) };
|
|
38
|
+
|
|
39
|
+
const medianBy = requested && measures(completed, requested) ? requested : DEFAULT_ORDER.find((metric) => measures(completed, metric));
|
|
40
|
+
const ranked = completed
|
|
41
|
+
.filter((run) => typeof valueOf(run, medianBy) === "number")
|
|
42
|
+
.sort((a, b) => valueOf(a, medianBy) - valueOf(b, medianBy));
|
|
43
|
+
const payload = middle(ranked);
|
|
44
|
+
|
|
45
|
+
const summary = {};
|
|
46
|
+
for (const metric of SUMMARY_METRICS) {
|
|
47
|
+
if (!measures(completed, metric)) continue;
|
|
48
|
+
const values = runs.map((run) => (run.error ? null : (valueOf(run, metric) ?? null)));
|
|
49
|
+
const sorted = values.filter((value) => value !== null).sort((a, b) => a - b);
|
|
50
|
+
const unit = completed.flatMap((run) => run.results).find((r) => r.metric === metric && r.unit)?.unit;
|
|
51
|
+
summary[metric] = { median: middle(sorted), min: sorted[0], max: sorted[sorted.length - 1], values, unit };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
payload,
|
|
56
|
+
runs: {
|
|
57
|
+
count: runs.length,
|
|
58
|
+
failed: runs.length - completed.length,
|
|
59
|
+
medianRun: runs.indexOf(payload) + 1,
|
|
60
|
+
medianBy,
|
|
61
|
+
...(requested && requested !== medianBy ? { medianByRequested: requested } : {}),
|
|
62
|
+
summary,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/throttling.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// CPU and network throttling through CDP, as the Performance panel of DevTools applies it. The
|
|
2
2
|
// network presets copy the values of DevTools, so a measurement of the CLI compares with one made
|
|
3
3
|
// there. Throughputs are in bytes per second and latencies in milliseconds.
|
|
4
|
+
//
|
|
5
|
+
// A network can also be given by its own values, for the ones DevTools has no preset for, such as
|
|
6
|
+
// 5G or fibre: { downloadKbps, uploadKbps, latencyMs }, in kilobits per second and milliseconds.
|
|
4
7
|
export const NETWORK_PRESETS = {
|
|
5
8
|
"fast-4g": { title: "Fast 4G", download: (9 * 1000 * 1000) / 8 * 0.9, upload: (1.5 * 1000 * 1000) / 8 * 0.9, latency: 60 * 2.75 },
|
|
6
9
|
"slow-4g": { title: "Slow 4G", download: (1.6 * 1000 * 1000) / 8 * 0.9, upload: (750 * 1000) / 8 * 0.9, latency: 150 * 3.75 },
|
|
@@ -13,25 +16,59 @@ export function parseCpuFactor(raw) {
|
|
|
13
16
|
return raw.trim() !== "" && Number.isFinite(factor) && factor >= 1 ? factor : null;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
const NETWORK_VALUES = ["downloadKbps", "uploadKbps", "latencyMs"];
|
|
20
|
+
|
|
21
|
+
// The throughputs and latency of a preset name or of a network given by its values
|
|
22
|
+
export function resolveNetwork(network) {
|
|
23
|
+
if (typeof network === "string") {
|
|
24
|
+
const preset = NETWORK_PRESETS[network];
|
|
25
|
+
if (!preset) throw new Error(`Unknown network preset: "${network}". Choose from: ${Object.keys(NETWORK_PRESETS).join(", ")}`);
|
|
26
|
+
return preset;
|
|
27
|
+
}
|
|
28
|
+
for (const key of NETWORK_VALUES) {
|
|
29
|
+
const value = network?.[key];
|
|
30
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
31
|
+
throw new Error(`Invalid network: ${key} must be a number of at least 0, such as { "downloadKbps": 50000, "uploadKbps": 10000, "latencyMs": 20 }`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
download: (network.downloadKbps * 1000) / 8,
|
|
36
|
+
upload: (network.uploadKbps * 1000) / 8,
|
|
37
|
+
latency: network.latencyMs,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 400 kbps, 1.44 Mbps, 50 Mbps
|
|
42
|
+
function formatKbps(kbps) {
|
|
43
|
+
return kbps < 1000 ? `${kbps} kbps` : `${Number((kbps / 1000).toFixed(2))} Mbps`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describeNetwork(network) {
|
|
47
|
+
if (typeof network === "string") return NETWORK_PRESETS[network]?.title ?? network;
|
|
48
|
+
return `${formatKbps(network.downloadKbps)}, ${network.latencyMs} ms`;
|
|
49
|
+
}
|
|
50
|
+
|
|
16
51
|
// The conditions of a run, as the payload reports them, or undefined without throttling
|
|
17
52
|
export function throttlingOf({ cpu, network }) {
|
|
18
53
|
if (cpu == null && network == null) return undefined;
|
|
19
54
|
return { ...(cpu != null ? { cpu } : {}), ...(network != null ? { network } : {}) };
|
|
20
55
|
}
|
|
21
56
|
|
|
22
|
-
// "CPU 4x, Slow 4G", for the header of the reports
|
|
57
|
+
// "CPU 4x, Slow 4G" or "CPU 1x, 50 Mbps, 20 ms", for the header of the reports
|
|
23
58
|
export function describeThrottling({ cpu, network }) {
|
|
24
|
-
return [cpu != null ? `CPU ${cpu}x` : null, network != null ? (
|
|
59
|
+
return [cpu != null ? `CPU ${cpu}x` : null, network != null ? describeNetwork(network) : null]
|
|
25
60
|
.filter(Boolean)
|
|
26
61
|
.join(", ");
|
|
27
62
|
}
|
|
28
63
|
|
|
29
64
|
export async function applyThrottling(context, page, { cpu, network }) {
|
|
30
65
|
if (cpu == null && network == null) return;
|
|
66
|
+
// An invalid network fails before anything is applied to the page
|
|
67
|
+
const conditions = network != null ? resolveNetwork(network) : null;
|
|
31
68
|
const cdp = await context.newCDPSession(page);
|
|
32
69
|
if (cpu != null) await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpu });
|
|
33
|
-
if (
|
|
34
|
-
const { download, upload, latency } =
|
|
70
|
+
if (conditions) {
|
|
71
|
+
const { download, upload, latency } = conditions;
|
|
35
72
|
await cdp.send("Network.enable");
|
|
36
73
|
await cdp.send("Network.emulateNetworkConditions", {
|
|
37
74
|
offline: false,
|