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.
@@ -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
+ }
@@ -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
- export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling }) {
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"], `WebPerf Snippets — ${url}`));
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
- export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling }) {
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(`## WebPerf Results — ${url}`);
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 ? (NETWORK_PRESETS[network]?.title ?? 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 (network != null) {
34
- const { download, upload, latency } = NETWORK_PRESETS[network];
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,