webperf-snippets 1.10.0 → 1.12.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/src/bin.js CHANGED
@@ -15,7 +15,7 @@ import { MissingBrowserError } from "./browser.js";
15
15
  import { parseHeader } from "./headers.js";
16
16
  import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
17
17
  import { parseOwnDomains } from "./own-domains.js";
18
- import { MEDIAN_METRICS, summarizeRuns } from "./runs.js";
18
+ import { MEDIAN_METRICS, measureRuns } from "./runs.js";
19
19
  import { loadMatrix } from "./matrix.js";
20
20
  import { blockingErrors } from "./blocking.js";
21
21
  import { MEASURED_BY, checkBudgets, parseBudgets, unmeasuredMetrics, violationsOf } from "./budgets.js";
@@ -89,6 +89,7 @@ Options:
89
89
  --verbose Show all items, even for passing checks
90
90
  --headed Show the browser window (debug)
91
91
  -h, --help Show this help
92
+ -v, --version Show the version
92
93
 
93
94
  Examples:
94
95
  npx webperf-snippets https://example.com
@@ -182,6 +183,7 @@ async function main() {
182
183
  verbose: { type: "boolean" },
183
184
  headed: { type: "boolean" },
184
185
  help: { type: "boolean", short: "h" },
186
+ version: { type: "boolean", short: "v" },
185
187
  },
186
188
  allowPositionals: true,
187
189
  });
@@ -196,6 +198,11 @@ async function main() {
196
198
  return;
197
199
  }
198
200
 
201
+ if (values.version) {
202
+ process.stdout.write(`${cliVersion}\n`);
203
+ return;
204
+ }
205
+
199
206
  const url = positionals[0];
200
207
  const matrixPath = values.matrix;
201
208
  if (url && matrixPath) fail("Use a URL or --matrix, not both");
@@ -325,7 +332,7 @@ async function main() {
325
332
  if (runCount == null) {
326
333
  payload = await measureWith(conditions);
327
334
  } else {
328
- const median = await measureRuns(() => measureWith(conditions), runCount, medianBy, (i) => `[${i}/${runCount}] ${url} · run ${i}/${runCount}`);
335
+ const median = await measureRunsWithProgress(() => measureWith(conditions), runCount, medianBy, (i) => `[${i}/${runCount}] ${url} · run ${i}/${runCount}`);
329
336
  if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
330
337
  payload = { ...median.payload, runs: median.runs };
331
338
  }
@@ -388,22 +395,16 @@ async function main() {
388
395
  process.exit(blockingErrors(payload.results).length > 0 ? 1 : 0);
389
396
  }
390
397
 
391
- // Measures `count` runs one after the other, never in parallel: they would compete for the CPU and
392
- // skew the timings. Each run is reported on stderr as it starts, so stdout keeps only the report. A
393
- // run that fails is kept as { error } and left out of the median.
394
- async function measureRuns(measure, count, medianBy, progress) {
395
- const runs = [];
396
- for (let i = 1; i <= count; i++) {
397
- process.stderr.write(`${progress(i)}\n`);
398
- try {
399
- runs.push(await measure());
400
- } catch (err) {
401
- if (err instanceof MissingBrowserError) throw err;
402
- runs.push({ error: err.message });
403
- }
404
- }
405
- return summarizeRuns(runs, { medianBy });
406
- }
398
+ // Each run is reported on stderr as it starts, so stdout keeps only the report
399
+ const measureRunsWithProgress = (measure, count, medianBy, progress) =>
400
+ measureRuns(
401
+ (i) => {
402
+ process.stderr.write(`${progress(i)}\n`);
403
+ return measure();
404
+ },
405
+ count,
406
+ { medianBy, isFatal: (err) => err instanceof MissingBrowserError }
407
+ );
407
408
 
408
409
  // Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
409
410
  // its median run, and the exit code follows the same rules as one page, for every combination.
@@ -417,7 +418,7 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
417
418
  const combinations = [];
418
419
  for (const c of matrix.combinations) {
419
420
  const conditions = { url: c.url, viewport: VIEWPORT_PRESETS[c.viewport], storageState: c.storageState, headers: c.headers, cpu: c.cpu, network: c.network, ownDomains: c.ownDomains };
420
- const median = await measureRuns(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
421
+ const median = await measureRunsWithProgress(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
421
422
  const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
422
423
  combinations.push(
423
424
  median.payload
package/src/crux.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_THRESHOLDS } from "./baseline.js";
1
2
  import { rate } from "./ratings.js";
2
3
 
3
4
  const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
@@ -122,12 +123,17 @@ export function trendOf(p75s) {
122
123
 
123
124
  const BARS = "▁▂▃▄▅▆▇█";
124
125
 
125
- // The series as bars between its lowest and highest values, a space for a period without data
126
- export function sparkline(values) {
126
+ // The series as bars, a space for a period without data. The bars span the range of the series, or
127
+ // `minRange` when the series is narrower, centred in it: a CLS of 0.20 to 0.21 drawn from its lowest
128
+ // to its highest value looked like a big swing. Eight equal steps, so a flat series sits in the middle.
129
+ export function sparkline(values, minRange = 0) {
127
130
  const known = values.filter((v) => v !== null);
128
131
  const min = Math.min(...known);
129
132
  const max = Math.max(...known);
130
- return values.map((v) => (v === null ? " " : max === min ? BARS[3] : BARS[Math.round(((v - min) / (max - min)) * 7)])).join("");
133
+ const range = Math.max(max - min, minRange);
134
+ if (range === 0) return values.map((v) => (v === null ? " " : BARS[4])).join("");
135
+ const low = max - min >= minRange ? min : (min + max) / 2 - range / 2;
136
+ return values.map((v) => (v === null ? " " : BARS[Math.min(7, Math.floor(((v - low) / range) * 8))])).join("");
131
137
  }
132
138
 
133
139
  const dateOf = ({ year, month, day }) => `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
@@ -202,7 +208,8 @@ export function historyView(history) {
202
208
  const rows = METRICS.filter((m) => history.metrics[m.name]).map((m) => {
203
209
  const { p75s, first, last, change, trend } = history.metrics[m.name];
204
210
  const changeText = change === null ? "·" : `${change > 0 ? "▲ +" : change < 0 ? "▼ −" : "= "}${Math.abs(change)}%`;
205
- return { metric: m.name, unit: m.unit, first, last, change: changeText, trend: trend ?? "·", sparkline: sparkline(p75s) };
211
+ // The smallest change --baseline counts for the metric is the least the bars span
212
+ return { metric: m.name, unit: m.unit, first, last, change: changeText, trend: trend ?? "·", sparkline: sparkline(p75s, DEFAULT_THRESHOLDS[m.name].absolute) };
206
213
  });
207
214
  return { title, rows };
208
215
  }
@@ -0,0 +1,26 @@
1
+ import { loadBaseline, compareRun, parseThreshold, thresholdsWith } from "../baseline.js";
2
+ import { describeRegressions } from "../reporters/baseline-view.js";
3
+
4
+ // The baseline of run_snippet and run_workflow, as --baseline of the CLI: the runId of an earlier run of
5
+ // the connection, or the path of a page the CLI wrote with --json. The comparison is the one of the CLI
6
+ // (baseline.js), so both give the same verdict for the same two pages.
7
+
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+
10
+ // Read and checked before the page loads, as the CLI does before the browser opens: a wrong baseline
11
+ // fails at once. Throws an error whose message says what to do.
12
+ export function prepareBaseline({ baseline, thresholds = [] }, runs) {
13
+ const parsed = thresholdsWith(thresholds.map(parseThreshold));
14
+ // An id of a run the connection no longer keeps is not a file to look for: runs.page says so
15
+ if (runs.has(baseline) || UUID.test(baseline)) return { data: runs.page(baseline), source: { runId: baseline }, thresholds: parsed };
16
+ const { kind, data } = loadBaseline(baseline);
17
+ if (kind === "matrix") throw new Error(`${baseline} is the baseline of a matrix: this server measures one page; compare it with webperf-snippets --matrix <file> --baseline ${baseline}`);
18
+ return { data, source: { file: baseline }, thresholds: parsed };
19
+ }
20
+
21
+ // The page of this run against its baseline: the `baseline` of the JSON of the CLI, named by its runId or
22
+ // its file, with the lines --fail-on-regression would print for it
23
+ export function compareWithBaseline({ data, source, thresholds }, current) {
24
+ const { file, ...comparison } = compareRun(data, current, { file: source.file, thresholds });
25
+ return { ...source, ...comparison, regressions: describeRegressions(comparison) };
26
+ }
@@ -0,0 +1,72 @@
1
+ // The reason of an abort is whatever the client sent, often a string, or nothing: a caller gets an error
2
+ const gaveUp = () => new Error("The caller gave up while it waited for its turn");
3
+
4
+ // One task at a time, in the order they arrive. A caller that gives up while it waits leaves the line
5
+ // at once, so it never runs and holds up no one. A task that fails passes the turn like any other, and
6
+ // its failure stays with its caller.
7
+ //
8
+ // A waiting caller hears how many tasks are ahead when it starts to wait, whenever the line moves, and
9
+ // every `reportEveryMs` while it does not: a client can restart its timeout on each report, and a task
10
+ // ahead can take minutes.
11
+ export function createQueue({ reportEveryMs = 5000 } = {}) {
12
+ const waiting = [];
13
+ let taken = false;
14
+
15
+ // A report is a courtesy: one that fails, as one to a client that left, changes nothing
16
+ const report = async ({ onWait }, ahead) => {
17
+ try {
18
+ await onWait?.(ahead);
19
+ } catch {
20
+ // nothing to tell, and no one to tell it to
21
+ }
22
+ };
23
+ const reportAll = () => waiting.forEach((waiter, i) => report(waiter, i + 1));
24
+
25
+ function take({ signal, onWait }) {
26
+ if (signal?.aborted) return Promise.reject(gaveUp());
27
+ if (!taken) {
28
+ taken = true;
29
+ return Promise.resolve();
30
+ }
31
+ return new Promise((resolve, reject) => {
32
+ const waiter = { onWait };
33
+ const leave = () => {
34
+ waiting.splice(waiting.indexOf(waiter), 1);
35
+ clearInterval(timer);
36
+ signal?.removeEventListener("abort", cancel);
37
+ };
38
+ const cancel = () => {
39
+ leave();
40
+ reject(gaveUp());
41
+ reportAll();
42
+ };
43
+ waiter.enter = () => {
44
+ leave();
45
+ resolve();
46
+ };
47
+ const timer = setInterval(() => report(waiter, waiting.indexOf(waiter) + 1), reportEveryMs);
48
+ // The timer alone must not keep the process alive
49
+ timer.unref?.();
50
+ signal?.addEventListener("abort", cancel, { once: true });
51
+ waiting.push(waiter);
52
+ report(waiter, waiting.length);
53
+ });
54
+ }
55
+
56
+ function pass() {
57
+ taken = waiting.length > 0;
58
+ waiting[0]?.enter();
59
+ reportAll();
60
+ }
61
+
62
+ async function run(task, options = {}) {
63
+ await take(options);
64
+ try {
65
+ return await task();
66
+ } finally {
67
+ pass();
68
+ }
69
+ }
70
+
71
+ return { run };
72
+ }