webperf-snippets 1.16.0 → 1.17.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.
@@ -1,6 +1,8 @@
1
+ import { inconclusiveForTheWorse } from "../baseline.js";
2
+
1
3
  // 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
+ // its two values, the change and the verdict, why an unchanged one did not count and what to do with an
5
+ // inconclusive one. The values stay numbers with their unit, for each report to format.
4
6
 
5
7
  const MINUS = "−";
6
8
  const signed = (n, text) => (n > 0 ? `+${text}` : n < 0 ? `${MINUS}${text}` : text);
@@ -16,14 +18,20 @@ function describeChange(metric, { delta, deltaPercent }) {
16
18
  }
17
19
 
18
20
  // Three decimals where the verdict is decided, two above it: "0.028", "0.35"
19
- const pValue = (p) => (p < 0.001 ? "< 0.001" : p < 0.05 ? p.toFixed(3) : p.toFixed(2));
21
+ // Three decimals up to 0.1, so a p-value next to the 0.05 it is compared with is not rounded away from it
22
+ const pValue = (p) => (p < 0.001 ? "< 0.001" : p < 0.1 ? p.toFixed(3) : p.toFixed(2));
20
23
  const amountOf = (metric, n) => (metric === "CLS" ? signed(n, Math.abs(n).toFixed(4)) : signed(n, `${Math.abs(Math.round(n))}ms`));
21
24
 
25
+ // What a person does with an inconclusive metric: the runs can tell neither a change nor its absence
26
+ const MEASURE_AGAIN = "measure again, or with more runs";
27
+
22
28
  // The evidence of a change measured with five runs a side: its p-value, its shift and the 95 % interval
23
- // of the shift; for a change over the threshold that stayed unchanged, that the runs gave no evidence
29
+ // of the shift; for a change over the threshold that is inconclusive, that the runs gave no evidence and
30
+ // what to do about it
24
31
  function evidenceOf(metric, m) {
25
32
  const { p, shift, ci } = m.stats;
26
- if (m.verdict === "unchanged") return Math.abs(m.delta) > m.threshold ? `no evidence: p = ${pValue(p)}` : null;
33
+ if (m.verdict === "inconclusive") return `no evidence: p = ${pValue(p)}; ${MEASURE_AGAIN}`;
34
+ if (m.verdict === "unchanged") return null;
27
35
  return `p = ${pValue(p)}, ${amountOf(metric, shift)}, 95% CI ${amountOf(metric, ci[0])} to ${amountOf(metric, ci[1])}`;
28
36
  }
29
37
 
@@ -36,8 +44,15 @@ function noteOf(metric, m) {
36
44
  return `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}`;
37
45
  }
38
46
 
39
- export function baselineView({ file, url, runs, metrics, errors, warnings }) {
40
- const summary = `${url}, ${runs.count === 1 ? "1 run" : `${runs.count} runs, median by ${runs.medianBy}`}`;
47
+ // "LCP, FCP inconclusive for the worse after 5 runs a side: both pages measured 2 more, up to 7"
48
+ const describeExtended = ({ after, metrics }, runs) =>
49
+ `${metrics.join(", ")} inconclusive for the worse after ${after} runs a side: both pages measured ${runs.count - after} more, up to ${runs.count}`;
50
+
51
+ // A baseline from a file is named by its file, with the URL it measured; a live one, measured in this
52
+ // run, by its URL, with the runs it took and, when it measured more, why
53
+ export function baselineView({ file, live, url, runs, metrics, errors, warnings, extended }) {
54
+ const runsText = runs.count === 1 ? "1 run" : `${runs.count} runs, median by ${runs.medianBy}`;
55
+ const [source, summary] = live ? [url, `measured in this run, ${runsText}`] : [file, `${url}, ${runsText}`];
41
56
  const rows = Object.entries(metrics).map(([metric, m]) => ({
42
57
  metric,
43
58
  unit: unitOf(metric),
@@ -47,7 +62,7 @@ export function baselineView({ file, url, runs, metrics, errors, warnings }) {
47
62
  verdict: m.verdict,
48
63
  note: m.verdict === "not measured" ? null : noteOf(metric, m),
49
64
  }));
50
- return { file, summary, rows, newErrors: errors.new, fixedErrors: errors.fixed, warnings };
65
+ return { source, summary, ...(extended ? { extended: describeExtended(extended, runs) } : {}), rows, newErrors: errors.new, fixedErrors: errors.fixed, warnings };
51
66
  }
52
67
 
53
68
  // What got worse, one line each, for --fail-on-regression: the metrics that regressed and the errors
@@ -61,13 +76,23 @@ export function describeRegressions({ metrics, errors }) {
61
76
  ];
62
77
  }
63
78
 
79
+ // The metrics inconclusive for the worse, one line each, for --fail-on-inconclusive: their change, their
80
+ // p-value and what to do. An inconclusive improvement never fails.
81
+ export function describeInconclusive(comparison) {
82
+ return inconclusiveForTheWorse(comparison).map((metric) => {
83
+ const m = comparison.metrics[metric];
84
+ return `${metric} ${describeChange(metric, m)}, p = ${pValue(m.stats.p)}; ${MEASURE_AGAIN}`;
85
+ });
86
+ }
87
+
64
88
  // The change of a metric in a cell of the matrix table: the figure only when it counts, so the table
65
89
  // reads at a glance. "▲ +380ms", "▼ −400ms", "=" for unchanged, "·" when one side did not measure it.
66
90
  export function changeCell(metric, m) {
67
91
  if (m.verdict === "not measured") return { text: "·", verdict: m.verdict };
68
92
  if (m.verdict === "unchanged") return { text: "=", verdict: m.verdict };
69
93
  const amount = metric === "CLS" ? Math.abs(m.delta).toFixed(4) : `${Math.abs(Math.round(m.delta))}ms`;
70
- return { text: `${arrow(m.delta)} ${signed(m.delta, amount)}`, verdict: m.verdict };
94
+ // An inconclusive change counts for nothing yet: a question mark keeps it apart from a regression
95
+ return { text: `${arrow(m.delta)} ${signed(m.delta, amount)}${m.verdict === "inconclusive" ? "?" : ""}`, verdict: m.verdict };
71
96
  }
72
97
 
73
98
  // The comparison of a matrix below its table. A warning that every compared combination has, such as
@@ -94,3 +119,8 @@ export function matrixComparisonView(root, combinations) {
94
119
  export function describeMatrixRegressions(combinations) {
95
120
  return combinations.filter((c) => c.baseline).flatMap((c) => describeRegressions(c.baseline).map((r) => `${c.id}: ${r}`));
96
121
  }
122
+
123
+ // The inconclusive metrics for the worse of every combination, each named by its combination
124
+ export function describeMatrixInconclusive(combinations) {
125
+ return combinations.filter((c) => c.baseline).flatMap((c) => describeInconclusive(c.baseline).map((line) => `${c.id}: ${line}`));
126
+ }
@@ -148,6 +148,11 @@ function renderResult(r, verbose) {
148
148
  return ` ${styleText("dim", "·")} ${pad(r.id, 16)} ${styleText("dim", `not measured: ${r.message ?? "nothing recorded"}`)}`;
149
149
  }
150
150
 
151
+ // The browser lacks the API the snippet reads, so there is no value to show
152
+ if (r.status === "unsupported") {
153
+ return ` ${styleText("dim", "·")} ${pad(r.id, 16)} ${styleText("dim", `not supported: ${r.error ?? "the browser lacks the API"}`)}`;
154
+ }
155
+
151
156
  if (r.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold") {
152
157
  return renderFontsResult(r);
153
158
  }
@@ -257,7 +262,7 @@ function renderRuns(runs) {
257
262
  return lines;
258
263
  }
259
264
 
260
- const VERDICT_STYLE = { regressed: "red", improved: "green", unchanged: "dim", "not measured": "dim", new: "cyan" };
265
+ const VERDICT_STYLE = { regressed: "red", improved: "green", unchanged: "dim", inconclusive: "yellow", "not measured": "dim", new: "cyan" };
261
266
 
262
267
  // Every budget the run was given, passed or not, so the report says which one decided the exit code
263
268
  function renderBudgets(budgets) {
@@ -273,7 +278,8 @@ function renderBudgets(budgets) {
273
278
  // trust the table, then a row per metric and the errors that appeared or went away
274
279
  function renderBaseline(comparison) {
275
280
  const view = baselineView(comparison);
276
- const lines = ["", styleText("bold", `Compared with ${view.file} (${view.summary})`)];
281
+ const lines = ["", styleText("bold", `Compared with ${view.source} (${view.summary})`)];
282
+ if (view.extended) lines.push(styleText("dim", ` ${view.extended}`));
277
283
  for (const warning of view.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
278
284
  lines.push(styleText("dim", ` ${pad("Metric", 9)}${pad("Baseline", 11)}${pad("Now", 10)}Change`));
279
285
  for (const row of view.rows) {
@@ -48,6 +48,11 @@ function renderNotMeasuredRow(r) {
48
48
  return `| ${r.id} | · not measured | ${cell(r.message ?? "nothing recorded")} |`;
49
49
  }
50
50
 
51
+ // The browser lacks the API the snippet reads, so there is no value to show
52
+ function renderUnsupportedRow(r) {
53
+ return `| ${r.id} | · not supported | ${cell(r.error ?? "the browser lacks the API")} |`;
54
+ }
55
+
51
56
  // A result with a count and no value: its count, and its first numbers as the human report gives them
52
57
  function renderSummaryRow(r) {
53
58
  return `| ${r.id} | · ${r.count ?? ""} | ${summaryNumbers(r).join(" · ")} |`;
@@ -57,6 +62,7 @@ function renderRow(r) {
57
62
  if (r.status === "skipped") return renderSkippedRow(r);
58
63
  if (r.status === "error") return renderErrorRow(r);
59
64
  if (r.status === "tracking") return renderNotMeasuredRow(r);
65
+ if (r.status === "unsupported") return renderUnsupportedRow(r);
60
66
  if (Array.isArray(r.issues)) return renderAuditRow(r);
61
67
  if (isSummaryResult(r)) return renderSummaryRow(r);
62
68
  return renderMetricRow(r);
@@ -162,13 +168,14 @@ function renderRuns(runs) {
162
168
  return lines;
163
169
  }
164
170
 
165
- const VERDICT_ICON = { regressed: "❌", improved: "✅", unchanged: "➖", "not measured": "·" };
171
+ const VERDICT_ICON = { regressed: "❌", improved: "✅", unchanged: "➖", inconclusive: "❔", "not measured": "·" };
166
172
 
167
173
  // The comparison with --baseline. The warnings go in the comment itself: whoever reads the pull
168
174
  // request does not see the stderr of the CI.
169
175
  function renderBaseline(comparison) {
170
176
  const view = baselineView(comparison);
171
- const lines = ["", `### Compared with \`${view.file}\``, `> ${view.summary}`];
177
+ const lines = ["", `### Compared with \`${view.source}\``, `> ${view.summary}`];
178
+ if (view.extended) lines.push(">", `> ${view.extended}`);
172
179
  for (const warning of view.warnings) lines.push(">", `> ⚠️ ${warning}`);
173
180
  lines.push("", "| Metric | Baseline | Now | Change | Verdict |", "|--------|----------|-----|--------|---------|");
174
181
  for (const row of view.rows) {
@@ -176,8 +183,8 @@ function renderBaseline(comparison) {
176
183
  const verdict = `${VERDICT_ICON[row.verdict]} ${row.verdict}${row.note ? ` (${row.note})` : ""}`;
177
184
  lines.push(`| ${row.metric} | ${value(row.baseline)} | ${value(row.current)} | ${row.change ?? "·"} | ${verdict} |`);
178
185
  }
179
- if (view.newErrors.length) lines.push("", "#### New errors", ...view.newErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
180
- if (view.fixedErrors.length) lines.push("", "#### Fixed errors", ...view.fixedErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
186
+ if (view.newErrors.length) lines.push("", "#### New errors", ...view.newErrors.map((e) => `- \`${e.id}\`: ${md(oneLine(e.message))}`));
187
+ if (view.fixedErrors.length) lines.push("", "#### Fixed errors", ...view.fixedErrors.map((e) => `- \`${e.id}\`: ${md(oneLine(e.message))}`));
181
188
  return lines;
182
189
  }
183
190