webperf-snippets 1.5.0 → 1.6.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 CHANGED
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
73
73
  The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
74
74
 
75
75
  ```json
76
- "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.5.0" }
76
+ "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.6.0" }
77
77
  ```
78
78
 
79
79
  Single snippet:
@@ -158,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
158
158
  "timestamp": "2026-05-06T10:00:00.000Z",
159
159
  "navMs": 1240,
160
160
  "results": [],
161
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.5.0" }
161
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.6.0" }
162
162
  }
163
163
  ```
164
164
 
@@ -300,6 +300,8 @@ CI gating:
300
300
  npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
301
301
  ```
302
302
 
303
+ Each budget compares the value measured, or the median with `--runs`, and fails when the value is above it. `--budget-lcp` and `--budget-cls` need the `core-web-vitals` workflow, `--budget-fcp` the `loading` workflow, `--budget-ttfb` the `loading` or `audit` workflow, and `--budget-inp` the `interaction` workflow with a click or a key press; `--snippet` with the snippet of the metric works too. A budget of a metric that the run does not measure exits with code `2` before the page loads, and one whose metric was not measured, such as INP without interactions, exits with code `1`: a budget never passes unchecked. The report lists every budget with its value and verdict, `passed`, `exceeded` with by how much, or `not measured` with why: a Budgets section in the human and markdown reports, and `budgets` in the JSON output and in the body of `--report-to`. A matrix lists the failed budgets with their combination under its table. Each failed budget is also written to stderr, with `--json` too.
304
+
303
305
  ### Options
304
306
 
305
307
  | Option | Description |
@@ -325,8 +327,11 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
325
327
  | `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
326
328
  | `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
327
329
  | `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
328
- | `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
329
- | `--budget-cls <score>` | Exit `1` if CLS exceeds this value. |
330
+ | `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value or was not measured. |
331
+ | `--budget-fcp <ms>` | Exit `1` if FCP exceeds this value or was not measured. |
332
+ | `--budget-ttfb <ms>` | Exit `1` if TTFB exceeds this value or was not measured. |
333
+ | `--budget-cls <score>` | Exit `1` if CLS exceeds this value or was not measured. |
334
+ | `--budget-inp <ms>` | Exit `1` if INP exceeds this value or was not measured. |
330
335
  | `--verbose` | Show all items, including passing checks. |
331
336
  | `--headed` | Show the browser window (debug). |
332
337
  | `-h, --help` | Show help. |
@@ -453,8 +458,11 @@ jobs:
453
458
  | ---------------- | ------------------- | -------------------------------------------------------------------------------------------- |
454
459
  | `url` | required | URL to measure. |
455
460
  | `workflow` | `core-web-vitals` | Workflow to run: `core-web-vitals`, `loading`, `audit` or `interaction`. |
456
- | `budget-lcp` | `2500` | LCP budget in ms. An empty value disables the check. |
457
- | `budget-cls` | `0.1` | CLS budget. An empty value disables the check. |
461
+ | `budget-lcp` | `auto` | LCP budget in ms. `auto` is `2500` with `core-web-vitals` and no budget with the other workflows. An empty value disables the check. |
462
+ | `budget-fcp` | | FCP budget in ms, for `loading`. |
463
+ | `budget-ttfb` | | TTFB budget in ms, for `loading` or `audit`. |
464
+ | `budget-cls` | `auto` | CLS budget. `auto` is `0.1` with `core-web-vitals` and no budget with the other workflows. An empty value disables the check. |
465
+ | `budget-inp` | | INP budget in ms, for `interaction` with a click or a key press. |
458
466
  | `fail-on-budget` | `true` | Fail the step when a budget is exceeded or a snippet errors. Use `"false"` to read `exit-code` and decide yourself. |
459
467
  | `cli-version` | `latest` | Version of the `webperf-snippets` npm package to run. |
460
468
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "mcpName": "io.github.nucliweb/webperf-snippets",
5
5
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
6
6
  "type": "module",
@@ -349,10 +349,11 @@
349
349
  };
350
350
  }
351
351
  if (interactions.length === 0) {
352
+ // Nothing failed: someone has to use the page first, so it is still tracking
352
353
  return {
353
354
  script: "INP",
354
- status: "error",
355
- error: "No interactions recorded yet. Interact with the page and call getINP() again.",
355
+ status: "tracking",
356
+ message: "No interactions yet: click, tap or type on the page, then call getINP() again.",
356
357
  getDataFn: "getINP",
357
358
  details,
358
359
  };
@@ -378,8 +379,9 @@
378
379
  console.groupEnd();
379
380
  return {
380
381
  script: "INP",
381
- status: "error",
382
- error: "No interactions recorded yet. Interact with the page and call getINPDetails() again.",
382
+ status: "tracking",
383
+ message: "No interactions yet: click, tap or type on the page, then call getINPDetails() again.",
384
+ getDataFn: "getINPDetails",
383
385
  count: 0,
384
386
  items: [],
385
387
  };
@@ -132,10 +132,11 @@
132
132
  console.log(
133
133
  " Interact with the page (click, type, etc.) and call this again."
134
134
  );
135
+ // Nothing failed: someone has to use the page first, so it is still tracking
135
136
  return {
136
137
  script: "Input-Latency-Breakdown",
137
- status: "error",
138
- error: "No interactions recorded yet. Interact with the page (click, type, etc.) and call getInputLatencyBreakdown() again.",
138
+ status: "tracking",
139
+ message: "No interactions yet: click, tap or type on the page, then call getInputLatencyBreakdown() again.",
139
140
  getDataFn: "getInputLatencyBreakdown",
140
141
  };
141
142
  }
@@ -184,7 +184,14 @@
184
184
  if (allInteractions.length === 0) {
185
185
  console.log("%c📊 No interactions recorded yet.", "font-weight: bold;");
186
186
  console.log(" Interact with the page (click, type, etc.) and call this again.");
187
- return { script: "Interactions", status: "error", error: "No interactions recorded yet", count: 0 };
187
+ // Nothing failed: someone has to use the page first, so it is still tracking
188
+ return {
189
+ script: "Interactions",
190
+ status: "tracking",
191
+ message: "No interactions yet: click, tap or type on the page, then call getInteractionSummary() again.",
192
+ getDataFn: "getInteractionSummary",
193
+ count: 0,
194
+ };
188
195
  }
189
196
 
190
197
  console.group("%c📊 Interaction Summary", "font-weight: bold; font-size: 14px;");
@@ -800,56 +800,58 @@
800
800
 
801
801
  console.groupEnd(); // Additional Insights
802
802
 
803
- // Recommendations
803
+ // Recommendations: the console marks their priority; the result orders them by it, as plain text
804
+ const PRIORITY = ["🔴", "🟡", "â„šī¸"];
804
805
  const recommendations = [];
806
+ const recommend = (icon, text) => recommendations.push({ icon, text });
805
807
 
806
808
  if (noCacheEntries.filter((e) => isStaticAsset(e.type)).length > 0) {
807
- recommendations.push(
808
- "🔴 Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."
809
+ recommend("🔴",
810
+ "Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."
809
811
  );
810
812
  }
811
813
  if (shortCacheEntries.filter((e) => e.hasHash).length > 0) {
812
- recommendations.push(
813
- "🟡 Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."
814
+ recommend("🟡",
815
+ "Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."
814
816
  );
815
817
  }
816
818
  if (allAntiPatterns.some((p) => p.id === "expires-without-cc")) {
817
- recommendations.push(
818
- "🟡 Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."
819
+ recommend("🟡",
820
+ "Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."
819
821
  );
820
822
  }
821
823
  if (cacheEfficiencyPercent !== null && cacheEfficiencyPercent < 50) {
822
- recommendations.push(
823
- `🔴 Cache efficiency is low (${cacheEfficiencyPercent}%, measured on the ${analyzableEntries.length} resources whose cache headers could be read). Review caching strategy for those static assets.`
824
+ recommend("🔴",
825
+ `Cache efficiency is low (${cacheEfficiencyPercent}%, measured on the ${analyzableEntries.length} resources whose cache headers could be read). Review caching strategy for those static assets.`
824
826
  );
825
827
  }
826
828
  if (cdnHitRate !== null && cdnHitRate < 70) {
827
- recommendations.push(
828
- `🟡 CDN hit rate is ${cdnHitRate}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`
829
+ recommend("🟡",
830
+ `CDN hit rate is ${cdnHitRate}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`
829
831
  );
830
832
  }
831
833
  if (protocolDistribution["http/1.1"] > 0) {
832
- recommendations.push(
833
- `🟡 ${protocolDistribution["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`
834
+ recommend("🟡",
835
+ `${protocolDistribution["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`
834
836
  );
835
837
  }
836
838
  if (uncompressedResources.length > 0) {
837
- recommendations.push(
838
- `🟡 ${uncompressedResources.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`
839
+ recommend("🟡",
840
+ `${uncompressedResources.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`
839
841
  );
840
842
  }
841
843
  if (costlyDuplicates.length > 0) {
842
- recommendations.push(
843
- `🟡 ${costlyDuplicates.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`
844
+ recommend("🟡",
845
+ `${costlyDuplicates.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`
844
846
  );
845
847
  } else if (cachedDuplicates.length > 0) {
846
- recommendations.push(
847
- `â„šī¸ ${cachedDuplicates.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`
848
+ recommend("â„šī¸",
849
+ `${cachedDuplicates.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`
848
850
  );
849
851
  }
850
852
  if (corsRestricted > 0 && headersAnalyzed === 0) {
851
- recommendations.push(
852
- `â„šī¸ All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights.`
853
+ recommend("â„šī¸",
854
+ `All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights.`
853
855
  );
854
856
  }
855
857
 
@@ -857,7 +859,7 @@
857
859
  console.log("");
858
860
  console.group("%c📌 Recommendations", "font-weight: bold;");
859
861
  for (const rec of recommendations) {
860
- console.log(` ${rec}`);
862
+ console.log(` ${rec.icon} ${rec.text}`);
861
863
  }
862
864
  console.groupEnd();
863
865
  }
@@ -935,6 +937,6 @@
935
937
  })),
936
938
  ...(coverageIssue ? [coverageIssue] : []),
937
939
  ],
938
- recommendations,
940
+ recommendations: [...recommendations].sort((a, b) => PRIORITY.indexOf(a.icon) - PRIORITY.indexOf(b.icon)).map((r) => r.text),
939
941
  };
940
942
  })();
@@ -24,9 +24,10 @@
24
24
  const criticalBudget = 14 * 1024; // 14 KB (first TCP round-trip)
25
25
 
26
26
  // --- External stylesheets ---
27
+ // A link without href or disabled loads nothing and blocks nothing, so it is not a stylesheet of the page
27
28
  const linkStylesheets = Array.from(
28
29
  document.querySelectorAll('link[rel="stylesheet"]')
29
- );
30
+ ).filter((link) => link.href && !link.disabled);
30
31
 
31
32
  const perfEntries = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e));
32
33
 
@@ -59,6 +59,9 @@ Every script must return an object matching this shape:
59
59
  target?: string; // The URL, selector or name the issue is about, when there is one issue per thing
60
60
  }>;
61
61
 
62
+ // Next steps in plain text, most important first, without emoji (Cache-Strategy-Analysis)
63
+ recommendations?: string[];
64
+
62
65
  // Tracking scripts (status: "tracking")
63
66
  message?: string; // Human-readable status message
64
67
  getDataFn?: string; // window function to call for data: evaluate_script(`${getDataFn}()`). May be a dotted path such as "loafHelpers.getData"
@@ -170,7 +173,7 @@ return {
170
173
  **The window function must also return a structured object** matching the same schema, including when nothing has been recorded yet:
171
174
 
172
175
  - `status: "ok"` with `count: 0` when zero is a valid result (no long tasks, no long animation frames).
173
- - `status: "error"` with an `error` message when there is nothing to report yet and the agent needs to act first (no interactions recorded).
176
+ - `status: "tracking"` with a `message` and its `getDataFn` when there is nothing to report yet and someone has to act first (no interactions recorded): nothing failed, the snippet is still recording. `status: "error"` is for a snippet that failed.
174
177
 
175
178
  **Scripts using this pattern:** INP, Interactions, Input-Latency-Breakdown, Layout-Shift-Loading-and-Interaction, Scroll-Performance, Long-Animation-Frames (ongoing tracking), LongTask (ongoing tracking), Long-Animation-Frames-Script-Attribution.
176
179
 
@@ -262,11 +265,11 @@ Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` afte
262
265
  }
263
266
  ```
264
267
 
265
- If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
268
+ If no interactions yet, `getINP()` returns `status: "tracking"` with a `message` and `getDataFn: "getINP"`: interact with the page and call it again.
266
269
 
267
270
  The phases describe the whole interaction, not one of its events. A tap fires `pointerdown`, `pointerup` and `click`, and a key press `keydown` and `keyup`, with one `interactionId`. INP, Interactions and Input-Latency-Breakdown group the entries by `interactionId`, also when they arrive in separate observer callbacks, and measure the interaction over its entries painted in the frame of the longest one: `inputDelay` runs from the first `startTime` to the first `processingStart`, `processingTime` to the last `processingEnd`, and `presentationDelay` from there to the paint (`startTime + duration`, never below zero). The interaction is reported under the event whose handler ran longest, or under the `click` or `keydown` when no handler took a millisecond more than theirs; `worstEvent`, the Interactions `type` and the Input-Latency-Breakdown event type follow that rule.
268
271
 
269
- `getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "error"`, `count: 0` and no items.
272
+ `getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "tracking"`, a `message`, `count: 0` and no items.
270
273
 
271
274
  ```json
272
275
  {
@@ -1398,7 +1401,7 @@ Each issue names the preloaded script in `target`.
1398
1401
 
1399
1402
  ### Cache-Strategy-Analysis
1400
1403
 
1401
- Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes.
1404
+ Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. `recommendations` lists the next steps in plain text, the most important first; the console marks their priority with 🔴, 🟡 and â„šī¸.
1402
1405
 
1403
1406
  | Code | Severity | When |
1404
1407
  |------|----------|------|
@@ -0,0 +1,37 @@
1
+ // How each field of a result is shown, the same in every language: its format, its place among the
2
+ // others (a lower order comes first; a field without one keeps its place after them) and whether it is
3
+ // hidden. The entries of a snippet win over the general ones under "*". A field without an entry takes
4
+ // its format from the suffix of its key (format.js). The words are in locales/.
5
+
6
+ export default {
7
+ '*': {
8
+ // A CSS selector of an element of the page, which a consumer can make inspectable
9
+ selector: { format: 'selector' },
10
+ element: { format: 'selector' },
11
+ lcpElement: { format: 'selector' },
12
+ finalElement: { format: 'selector' },
13
+ // The name of a phase, read as its label
14
+ slowestPhase: { format: 'key' },
15
+ // The issues of a row: the issues section of the result lists them already
16
+ issues: { hidden: true },
17
+ },
18
+ 'Find-Above-The-Fold-Lazy-Loaded-Images': {
19
+ // Its top and left in px: the distance from the edge of the viewport says what matters
20
+ position: { hidden: true },
21
+ },
22
+ 'Input-Latency-Breakdown': {
23
+ // The same as its rows, one per event type
24
+ eventTypes: { hidden: true },
25
+ },
26
+ INP: {
27
+ phases: { format: 'phases' },
28
+ },
29
+ 'Find-render-blocking-resources': {
30
+ shortName: { order: 1 },
31
+ type: { order: 2 },
32
+ responseEndMs: { order: 3 },
33
+ durationMs: { order: 4 },
34
+ sizeBytes: { order: 5 },
35
+ url: { order: 6 },
36
+ },
37
+ }
@@ -0,0 +1,39 @@
1
+ // The formats of the values of a result, with the numbers written as the locale writes them. A field
2
+ // without a format in fields.js takes it from the suffix of its key.
3
+
4
+ const KB = 1024
5
+ const MB = 1024 * 1024
6
+
7
+ const number = (value, locale, digits = 0, minimum = 0) =>
8
+ new Intl.NumberFormat(locale, { maximumFractionDigits: digits, minimumFractionDigits: minimum }).format(value)
9
+
10
+ const FORMATS = {
11
+ ms: (value, locale) => `${number(Math.round(value), locale)} ms`,
12
+ bytes: (value, locale) =>
13
+ value >= MB ? `${number(value / MB, locale, 1, 1)} MB` : value >= KB ? `${number(value / KB, locale, 1, 1)} KB` : `${number(value, locale)} B`,
14
+ percent: (value, locale) => new Intl.NumberFormat(locale, { style: 'unit', unit: 'percent', maximumFractionDigits: 1 }).format(value),
15
+ count: (value, locale) => number(value, locale),
16
+ score: (value, locale) => number(value, locale, 3, 3),
17
+ bpp: (value, locale) => `${number(value, locale, 3, 3)} bpp`,
18
+ fps: (value, locale) => `${number(Math.round(value), locale)} fps`,
19
+ px: (value, locale) => `${number(Math.round(value), locale)} px`,
20
+ }
21
+
22
+ // The suffixes that name a unit, and the format each one gives
23
+ export const SUFFIXES = [
24
+ ['Ms', 'ms'],
25
+ ['Bytes', 'bytes'],
26
+ ['Percent', 'percent'],
27
+ ['Count', 'count'],
28
+ ['Px', 'px'],
29
+ ]
30
+
31
+ export const formatOfKey = (key) => SUFFIXES.find(([suffix]) => key.endsWith(suffix) && key !== suffix)?.[1]
32
+
33
+ // Text as it is, a number in its format, and an object or a list as compact JSON
34
+ export function formatValue(value, format, locale = 'en') {
35
+ if (value === undefined || value === null) return ''
36
+ if (typeof value === 'number') return (FORMATS[format] ?? ((v, l) => number(v, l, 3)))(value, locale)
37
+ if (typeof value === 'object') return JSON.stringify(value)
38
+ return String(value)
39
+ }