webperf-snippets 0.2.0 → 0.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.
Files changed (73) hide show
  1. package/README.md +142 -14
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +44 -9
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +72 -24
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +86 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +74 -11
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1179 -0
  59. package/snippets/_shared/helpers.js +61 -0
  60. package/src/bin.js +72 -45
  61. package/src/crux.js +84 -0
  62. package/src/decision-tree.js +55 -7
  63. package/src/interactions.js +13 -3
  64. package/src/load-snippet.js +1 -1
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +43 -5
  67. package/src/reporters/markdown.js +27 -1
  68. package/src/runner.js +75 -21
  69. package/src/snippet-names.js +89 -0
  70. package/src/tracking.js +6 -0
  71. package/src/workflows/audit.js +17 -0
  72. package/src/workflows/interaction.js +30 -0
  73. package/src/workflows/loading.js +9 -0
@@ -0,0 +1,61 @@
1
+ /* eslint-disable no-unused-vars */
2
+ // Canonical source of the helpers that several snippets copy.
3
+ //
4
+ // Snippets run pasted into the DevTools console, so they cannot import anything. Each snippet
5
+ // that needs a helper carries a copy of it between the same two markers:
6
+ //
7
+ // // @shared <name>
8
+ // ...
9
+ // // @end-shared <name>
10
+ //
11
+ // `npm run check:consistency` fails when a copy differs from the block below (indentation is
12
+ // ignored). Edit the block here first, then paste it into every snippet that carries the marker.
13
+
14
+ // @shared getRootDomain
15
+ function getRootDomain(hostname) {
16
+ const host = hostname.replace(/\.$/, "");
17
+ // An IP address has no registrable domain, so each address is its own root
18
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
19
+ const parts = host.split(".");
20
+ if (parts.length <= 2) return host;
21
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
22
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
23
+ const tld = parts[parts.length - 1];
24
+ const sld = parts[parts.length - 2];
25
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
26
+ return parts.slice(-2).join(".");
27
+ }
28
+ // @end-shared getRootDomain
29
+
30
+ // Declared at the top of each snippet that carries isFirstParty: the domains of the site that
31
+ // differ from the page's root domain, such as its own CDN. They count as first party.
32
+ const OWN_DOMAINS = [];
33
+
34
+ // @shared isFirstParty
35
+ function isFirstParty(hostname) {
36
+ const root = getRootDomain(hostname);
37
+ if (root === getRootDomain(location.hostname)) return true;
38
+ return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
39
+ }
40
+ // @end-shared isFirstParty
41
+
42
+ // @shared logOwnDomainsHint
43
+ function logOwnDomainsHint(thirdPartyCount) {
44
+ if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
45
+ console.log(
46
+ '%cℹ️ OWN_DOMAINS is empty. If this site serves its own assets from other domains (for example a CDN), add them at the top of the snippet, such as const OWN_DOMAINS = ["cdn.example.net"];, and run it again so they count as first party.',
47
+ "color: #3b82f6;"
48
+ );
49
+ }
50
+ }
51
+ // @end-shared logOwnDomainsHint
52
+
53
+ // @shared formatBytes
54
+ function formatBytes(bytes) {
55
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
56
+ if (bytes === 0) return "0 B";
57
+ const units = ["B", "KB", "MB", "GB"];
58
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
59
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
60
+ }
61
+ // @end-shared formatBytes
package/src/bin.js CHANGED
@@ -1,54 +1,27 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
2
3
  import { parseArgs } from "node:util";
3
4
  import { loadSnippet } from "./load-snippet.js";
5
+ import { resolveSnippetName } from "./snippet-names.js";
4
6
  import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
5
7
  import { cwvWorkflow } from "./workflows/cwv.js";
6
8
  import { auditWorkflow } from "./workflows/audit.js";
7
9
  import { loadingWorkflow } from "./workflows/loading.js";
10
+ import { interactionWorkflow } from "./workflows/interaction.js";
8
11
  import { RULES } from "./decision-tree.js";
9
12
  import { reportHuman } from "./reporters/human.js";
10
13
  import { reportJson } from "./reporters/json.js";
11
14
  import { reportMarkdown } from "./reporters/markdown.js";
15
+ import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
16
+ import { fetchCrux, formFactorFor } from "./crux.js";
12
17
 
13
18
  const WORKFLOWS = {
14
19
  "core-web-vitals": cwvWorkflow,
15
20
  audit: auditWorkflow,
16
21
  loading: loadingWorkflow,
22
+ interaction: interactionWorkflow,
17
23
  };
18
24
 
19
- const SNIPPET_ALIASES = {
20
- LCP: "CoreWebVitals/LCP",
21
- CLS: "CoreWebVitals/CLS",
22
- "LCP-Subparts": "CoreWebVitals/LCP-Subparts",
23
- fonts: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
24
- "Fonts-Preloaded-Loaded-and-used-above-the-fold":
25
- "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
26
- // Tier 1 — Loading
27
- "render-blocking": "Loading/Find-render-blocking-resources",
28
- "Find-render-blocking-resources": "Loading/Find-render-blocking-resources",
29
- "resource-hints": "Loading/Resource-Hints-Validation",
30
- "Resource-Hints-Validation": "Loading/Resource-Hints-Validation",
31
- "preload-scripts": "Loading/Validate-Preload-Async-Defer-Scripts",
32
- "Validate-Preload-Async-Defer-Scripts": "Loading/Validate-Preload-Async-Defer-Scripts",
33
- "priority-hints": "Loading/Priority-Hints-Audit",
34
- "Priority-Hints-Audit": "Loading/Priority-Hints-Audit",
35
- "critical-css": "Loading/Critical-CSS-Detection",
36
- "Critical-CSS-Detection": "Loading/Critical-CSS-Detection",
37
- ttfb: "Loading/TTFB-Sub-Parts",
38
- "TTFB-Sub-Parts": "Loading/TTFB-Sub-Parts",
39
- "script-parties": "Loading/First-And-Third-Party-Script-Info",
40
- "First-And-Third-Party-Script-Info": "Loading/First-And-Third-Party-Script-Info",
41
- "script-loading": "Loading/Script-Loading",
42
- "Script-Loading": "Loading/Script-Loading",
43
- // Tier 2 — Media
44
- "lazy-atf": "Loading/Find-Above-The-Fold-Lazy-Loaded-Images",
45
- "Find-Above-The-Fold-Lazy-Loaded-Images": "Loading/Find-Above-The-Fold-Lazy-Loaded-Images",
46
- "lazy-conflict": "Loading/Find-Images-With-Lazy-and-Fetchpriority",
47
- "Find-Images-With-Lazy-and-Fetchpriority": "Loading/Find-Images-With-Lazy-and-Fetchpriority",
48
- "eager-below-fold": "Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport",
49
- "Find-non-Lazy-Loaded-Images-outside-of-the-viewport":
50
- "Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport",
51
- };
52
25
 
53
26
  const USAGE = `webperf-snippets <url> [options]
54
27
 
@@ -56,9 +29,10 @@ Run curated WebPerf Snippets headlessly via Playwright.
56
29
 
57
30
  Options:
58
31
  --workflow <name> Workflow to run (default: core-web-vitals)
59
- Workflows: core-web-vitals, audit, loading
60
- --snippet <name> Run a single snippet by alias or Category/Name path
61
- Aliases: LCP, CLS, LCP-Subparts, fonts,
32
+ Workflows: core-web-vitals, audit, loading, interaction
33
+ --snippet <name> Run a single snippet by name, alias or Category/Name path
34
+ Any of the 56 snippets, such as Compression-Audit
35
+ Aliases: LCP, CLS, INP, LCP-Subparts, fonts,
62
36
  render-blocking, resource-hints, preload-scripts,
63
37
  priority-hints, critical-css, ttfb,
64
38
  script-parties, script-loading,
@@ -69,8 +43,17 @@ Options:
69
43
  --wait <ms> Post-load wait before evaluating (default: 3000)
70
44
  --budget-lcp <ms> Exit 1 if LCP exceeds this value
71
45
  --budget-cls <score> Exit 1 if CLS exceeds this value
72
- --interact-script <path> JSON file with interactions to run before evaluation
46
+ --interact-script <path> JSON file with interactions to run after the load; the
47
+ Interaction snippets are installed first and read last
73
48
  Actions: scroll, click, hover, type, wait
49
+ --storage-state <path> Playwright storage state (cookies + localStorage) to
50
+ measure pages that require authentication
51
+ --crux-key <key> Add CrUX field data (p75) next to the measured values.
52
+ Also read from the CRUX_API_KEY environment variable
53
+ --report-to <url> POST the results to this https URL after the run
54
+ (without it the CLI makes no external calls)
55
+ --api-key <key> Sent as "Authorization: Bearer <key>" with --report-to.
56
+ Prefer the PERF_REVIEWS_API_KEY environment variable
74
57
  --verbose Show all items, even for passing checks
75
58
  --headed Show the browser window (debug)
76
59
  -h, --help Show this help
@@ -82,8 +65,10 @@ Examples:
82
65
  npx webperf-snippets https://web.dev --snippet LCP-Subparts
83
66
  npx webperf-snippets https://web.dev --snippet render-blocking
84
67
  npx webperf-snippets https://web.dev --snippet fonts
68
+ npx webperf-snippets https://web.dev --snippet Compression-Audit
85
69
  npx webperf-snippets https://web.dev --budget-lcp 2500
86
70
  npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
71
+ npx webperf-snippets https://example.com/dashboard --storage-state auth.json
87
72
  `;
88
73
 
89
74
  function fail(message, code = 2) {
@@ -91,12 +76,13 @@ function fail(message, code = 2) {
91
76
  process.exit(code);
92
77
  }
93
78
 
94
- function resolveSnippetPath(name) {
95
- return SNIPPET_ALIASES[name] ?? name;
96
- }
97
-
98
79
  function buildSnippetItem(values) {
99
- const path = resolveSnippetPath(values.snippet);
80
+ let path;
81
+ try {
82
+ path = resolveSnippetName(values.snippet);
83
+ } catch (err) {
84
+ fail(err.message);
85
+ }
100
86
  return [{ id: values.snippet, path, source: loadSnippet(path) }];
101
87
  }
102
88
 
@@ -131,6 +117,10 @@ async function main() {
131
117
  "budget-cls": { type: "string" },
132
118
  viewport: { type: "string" },
133
119
  "interact-script": { type: "string" },
120
+ "storage-state": { type: "string" },
121
+ "crux-key": { type: "string" },
122
+ "report-to": { type: "string" },
123
+ "api-key": { type: "string" },
134
124
  verbose: { type: "boolean" },
135
125
  headed: { type: "boolean" },
136
126
  help: { type: "boolean", short: "h" },
@@ -161,19 +151,33 @@ async function main() {
161
151
  fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
162
152
  }
163
153
 
154
+ const reportUrl = values["report-to"];
155
+ if (reportUrl) {
156
+ const problem = validateReportUrl(reportUrl);
157
+ if (problem) fail(problem);
158
+ }
159
+
164
160
  const interactScript = values["interact-script"];
161
+ const storageState = values["storage-state"];
162
+ if (storageState && !existsSync(storageState)) {
163
+ fail(`Storage state file not found: ${storageState}`);
164
+ }
165
165
 
166
166
  let payload;
167
167
  if (values.snippet) {
168
168
  const items = buildSnippetItem(values);
169
- payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript });
169
+ payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState });
170
170
  } else {
171
171
  const workflowName = values.workflow ?? "core-web-vitals";
172
172
  const workflow = WORKFLOWS[workflowName];
173
173
  if (!workflow) fail(`Unknown workflow: ${workflowName}`);
174
- payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript });
174
+ payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState });
175
175
  }
176
176
 
177
+ const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
178
+ const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
179
+ if (crux) payload.crux = crux;
180
+
177
181
  let output;
178
182
  if (values.markdown) {
179
183
  output = reportMarkdown(payload);
@@ -184,6 +188,27 @@ async function main() {
184
188
  }
185
189
  process.stdout.write(output + "\n");
186
190
 
191
+ if (reportUrl) {
192
+ const outcome = await reportTo({
193
+ url: reportUrl,
194
+ apiKey: values["api-key"] || process.env.PERF_REVIEWS_API_KEY,
195
+ body: {
196
+ url,
197
+ workflow: values.snippet ? null : (values.workflow ?? "core-web-vitals"),
198
+ ...(values.snippet ? { snippet: values.snippet } : {}),
199
+ timestamp: new Date().toISOString(),
200
+ navMs: payload.navMs,
201
+ results: payload.results,
202
+ meta: { viewport: viewportName, waitMs, cli_version: cliVersion },
203
+ },
204
+ });
205
+ if (!outcome.ok) {
206
+ process.stderr.write(`Warning: could not report results to ${new URL(reportUrl).origin} (${outcome.warning})\n`);
207
+ } else if (outcome.regressions.length > 0) {
208
+ process.stderr.write(`Regressions detected:\n${outcome.regressions.map((r) => ` - ${typeof r === "string" ? r : JSON.stringify(r)}`).join("\n")}\n`);
209
+ }
210
+ }
211
+
187
212
  // Exit codes.
188
213
  const violations = checkBudgets(payload.results, values);
189
214
  if (violations.length > 0) {
@@ -194,8 +219,10 @@ async function main() {
194
219
  }
195
220
 
196
221
  const anyError = payload.results.some((r) => r.status === "error");
222
+ // A follow-up (a result with a reason) only informs: its error issues show in the report and
223
+ // leave the exit code to the budgets and to the steps the workflow runs itself.
197
224
  const anyAuditViolation = payload.results.some(
198
- (r) => Array.isArray(r.issues) && r.issues.some((i) => i.severity === "error"),
225
+ (r) => !r.reason && Array.isArray(r.issues) && r.issues.some((i) => i.severity === "error"),
199
226
  );
200
227
  process.exit(anyError || anyAuditViolation ? 1 : 0);
201
228
  }
package/src/crux.js ADDED
@@ -0,0 +1,84 @@
1
+ const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
2
+ const CRUX_TIMEOUT_MS = 10000;
3
+
4
+ // Good / needs-improvement upper bounds from web.dev/articles/vitals.
5
+ const METRICS = [
6
+ { name: "LCP", key: "largest_contentful_paint", unit: "ms", good: 2500, poor: 4000 },
7
+ { name: "CLS", key: "cumulative_layout_shift", unit: "score", good: 0.1, poor: 0.25 },
8
+ { name: "INP", key: "interaction_to_next_paint", unit: "ms", good: 200, poor: 500 },
9
+ ];
10
+
11
+ const FORM_FACTOR_LABEL = { PHONE: "mobile", DESKTOP: "desktop", TABLET: "tablet" };
12
+
13
+ const rate = (value, { good, poor }) => (value <= good ? "good" : value <= poor ? "needs-improvement" : "poor");
14
+
15
+ // The viewport preset decides which population the field data describes.
16
+ export function formFactorFor(viewportName) {
17
+ return viewportName === "desktop" ? "DESKTOP" : "PHONE";
18
+ }
19
+
20
+ function parseMetrics(record) {
21
+ const metrics = {};
22
+ for (const m of METRICS) {
23
+ // CLS p75 comes as a string ("0.08"); LCP and INP as numbers.
24
+ const p75 = Number(record?.metrics?.[m.key]?.percentiles?.p75);
25
+ if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(p75, m), unit: m.unit };
26
+ }
27
+ return metrics;
28
+ }
29
+
30
+ async function query({ endpoint, apiKey, body, timeoutMs }) {
31
+ const res = await fetch(`${endpoint}?key=${encodeURIComponent(apiKey)}`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify(body),
35
+ signal: AbortSignal.timeout(timeoutMs),
36
+ });
37
+ if (res.status === 404) return { notFound: true };
38
+ if (!res.ok) return { status: res.status };
39
+ return { record: (await res.json()).record };
40
+ }
41
+
42
+ // Never throws, and no message it returns contains the key (it travels in the
43
+ // query string, so the request URL is never logged or put in an error).
44
+ export async function fetchCrux({
45
+ url,
46
+ apiKey,
47
+ formFactor = "PHONE",
48
+ endpoint = process.env.WEBPERF_CRUX_ENDPOINT ?? DEFAULT_ENDPOINT,
49
+ timeoutMs = CRUX_TIMEOUT_MS,
50
+ }) {
51
+ try {
52
+ let scope = "url";
53
+ let answer = await query({ endpoint, apiKey, body: { url, formFactor }, timeoutMs });
54
+ if (answer.notFound) {
55
+ scope = "origin";
56
+ answer = await query({ endpoint, apiKey, body: { origin: new URL(url).origin, formFactor }, timeoutMs });
57
+ }
58
+ if (answer.notFound) return { unavailable: "not in the CrUX dataset" };
59
+ if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}` };
60
+ const metrics = parseMetrics(answer.record);
61
+ if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX record" };
62
+ return { scope, formFactor, metrics };
63
+ } catch (err) {
64
+ const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
65
+ return { unavailable: reason };
66
+ }
67
+ }
68
+
69
+ // Shared by both reporters: what to show and next to which synthetic value.
70
+ export function fieldDataView(crux, results) {
71
+ const label = FORM_FACTOR_LABEL[crux.formFactor] ?? crux.formFactor.toLowerCase();
72
+ const title = `Field data (CrUX p75, ${label}${crux.scope === "origin" ? ", origin-level" : ""})`;
73
+ const rows = METRICS.filter((m) => crux.metrics[m.name]).map((m) => {
74
+ const synthetic = results.find((r) => r.status === "ok" && r.metric === m.name);
75
+ return {
76
+ metric: m.name,
77
+ unit: m.unit,
78
+ p75: crux.metrics[m.name].p75,
79
+ rating: crux.metrics[m.name].rating,
80
+ synthetic: synthetic ? synthetic.value : null,
81
+ };
82
+ });
83
+ return { title, rows };
84
+ }
@@ -1,28 +1,76 @@
1
- // Declarative follow-ups: if a result matches the predicate, append the step.
1
+ // Declarative follow-ups: if a result matches the predicate, append the steps.
2
2
  // Mirrors the WORKFLOWS.md decision trees from the snippet catalogue.
3
+ //
4
+ // A rule looks at the first results only. The results of a follow-up do not fire rules of their own.
5
+ const ok = (r, id) => r.id === id && r.status === "ok";
6
+
3
7
  export const RULES = [
4
8
  {
5
- when: (r) => r.id === "LCP" && r.status === "ok" && r.value > 2500,
9
+ when: (r) => ok(r, "LCP") && r.value > 2500,
6
10
  append: { id: "LCP-Subparts", path: "CoreWebVitals/LCP-Subparts" },
7
11
  reason: "LCP > 2.5s — drilling into sub-parts",
8
12
  },
9
13
  {
10
- when: (r) => r.id === "TTFB" && r.status === "ok" && r.value > 600,
14
+ when: (r) => ok(r, "LCP") && r.value > 4000,
15
+ append: { id: "LCP-Trail", path: "CoreWebVitals/LCP-Trail" },
16
+ reason: "LCP > 4s — listing the LCP candidates",
17
+ },
18
+ {
19
+ when: (r) => ok(r, "LCP") && r.details?.elementType === "Image",
20
+ append: [
21
+ { id: "LCP-Image-Entropy", path: "CoreWebVitals/LCP-Image-Entropy" },
22
+ { id: "image-audit", path: "Media/Image-Element-Audit" },
23
+ ],
24
+ reason: "LCP is an image — checking its entropy and its attributes",
25
+ },
26
+ {
27
+ when: (r) => ok(r, "LCP") && r.details?.elementType === "Video poster",
28
+ append: [
29
+ { id: "LCP-Video-Candidate", path: "CoreWebVitals/LCP-Video-Candidate" },
30
+ { id: "video-audit", path: "Media/Video-Element-Audit" },
31
+ ],
32
+ reason: "LCP is a video — checking its candidate and its loading",
33
+ },
34
+ {
35
+ when: (r) => ok(r, "CLS") && r.value > 0.1,
36
+ append: { id: "layout-shifts", path: "Interaction/Layout-Shift-Loading-and-Interaction" },
37
+ reason: "CLS > 0.1 — separating the shifts of the load from those of the interactions",
38
+ },
39
+ {
40
+ when: (r) => ok(r, "CLS") && r.value > 0.25,
41
+ append: [
42
+ { id: "lazy-atf", path: "Loading/Find-Above-The-Fold-Lazy-Loaded-Images" },
43
+ { id: "fonts", path: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold" },
44
+ { id: "critical-css", path: "Loading/Critical-CSS-Detection" },
45
+ { id: "image-audit", path: "Media/Image-Element-Audit" },
46
+ ],
47
+ reason: "CLS > 0.25 — looking for the usual causes: lazy images, font swaps, late CSS and images without dimensions",
48
+ },
49
+ {
50
+ when: (r) => ok(r, "TTFB") && r.value > 600,
11
51
  append: { id: "ttfb-subparts", path: "Loading/TTFB-Sub-Parts" },
12
52
  reason: "TTFB > 600ms — drilling into sub-parts",
13
53
  },
14
54
  {
15
- when: (r) => r.id === "FCP" && r.status === "ok" && r.value > 1800,
55
+ when: (r) => ok(r, "FCP") && r.value > 1800,
16
56
  append: { id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
17
57
  reason: "FCP > 1.8s — checking render-blocking resources",
18
58
  },
19
59
  ];
20
60
 
21
- export function nextSteps(results) {
61
+ // The follow-up steps the results call for, each snippet once: a path two rules ask for is added by
62
+ // the first, and a path in `skipPaths` (a step the workflow already ran) is left out.
63
+ export function nextSteps(results, rules = RULES, skipPaths = []) {
64
+ const seen = new Set(skipPaths);
22
65
  const followUps = [];
23
66
  for (const result of results) {
24
- for (const rule of RULES) {
25
- if (rule.when(result)) followUps.push({ ...rule.append, reason: rule.reason });
67
+ for (const rule of rules) {
68
+ if (!rule.when(result)) continue;
69
+ for (const step of [rule.append].flat()) {
70
+ if (seen.has(step.path)) continue;
71
+ seen.add(step.path);
72
+ followUps.push({ ...step, reason: rule.reason });
73
+ }
26
74
  }
27
75
  }
28
76
  return followUps;
@@ -22,9 +22,19 @@ export async function executeStep(page, step) {
22
22
  }
23
23
  }
24
24
 
25
- export async function runInteractions(page, scriptPath) {
26
- const { interactions } = JSON.parse(readFileSync(scriptPath, "utf8"));
27
- for (const step of interactions) {
25
+ // `source` is the path of a JSON file ({ "interactions": [...] }) or the list of steps itself.
26
+ export function loadInteractionSteps(source) {
27
+ return Array.isArray(source) ? source : JSON.parse(readFileSync(source, "utf8")).interactions;
28
+ }
29
+
30
+ // A click or a key press creates the events that Interactions and Input-Latency-Breakdown measure;
31
+ // a scroll, a hover and a wait do not.
32
+ export function isInputStep(step) {
33
+ return step.action === "click" || step.action === "type";
34
+ }
35
+
36
+ export async function runInteractions(page, source) {
37
+ for (const step of loadInteractionSteps(source)) {
28
38
  await executeStep(page, step);
29
39
  }
30
40
  }
@@ -6,7 +6,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
6
6
  const PKG_ROOT = resolve(HERE, "..");
7
7
 
8
8
  // Resolution order: bundled (published package) → workspace root (dev).
9
- const SNIPPET_DIRS = [
9
+ export const SNIPPET_DIRS = [
10
10
  join(PKG_ROOT, "snippets"),
11
11
  resolve(PKG_ROOT, "..", "snippets"),
12
12
  ];
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const HERE = dirname(fileURLToPath(import.meta.url));
6
+ const REPORT_TIMEOUT_MS = 5000;
7
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
8
+
9
+ export const cliVersion = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version;
10
+
11
+ // Returns an error message when the URL cannot receive a report, or null.
12
+ // https is required so the API key is never sent in clear text; local hosts
13
+ // are allowed over http for development and tests.
14
+ export function validateReportUrl(value) {
15
+ let parsed;
16
+ try {
17
+ parsed = new URL(value);
18
+ } catch {
19
+ return `--report-to must be an https URL, got "${value}"`;
20
+ }
21
+ if (parsed.protocol === "https:") return null;
22
+ if (parsed.protocol === "http:" && LOCAL_HOSTS.has(parsed.hostname)) return null;
23
+ return `--report-to must be an https URL (http is allowed only for localhost), got "${value}"`;
24
+ }
25
+
26
+ // POSTs the results to `url`. Never throws and never touches the exit code:
27
+ // the caller decides what to do with the outcome. The key is only sent in the
28
+ // Authorization header and never appears in a returned message.
29
+ export async function reportTo({ url, apiKey, body, timeoutMs = REPORT_TIMEOUT_MS }) {
30
+ const headers = { "Content-Type": "application/json" };
31
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
32
+ try {
33
+ const res = await fetch(url, {
34
+ method: "POST",
35
+ headers,
36
+ body: JSON.stringify(body),
37
+ signal: AbortSignal.timeout(timeoutMs),
38
+ });
39
+ if (!res.ok) return { ok: false, warning: `the endpoint answered ${res.status}` };
40
+ const data = await res.json().catch(() => ({}));
41
+ return { ok: true, regressions: data.ok === false ? (data.regressions ?? []) : [] };
42
+ } catch (err) {
43
+ const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? err.message);
44
+ return { ok: false, warning: reason };
45
+ }
46
+ }
@@ -1,4 +1,5 @@
1
1
  import { styleText } from "node:util";
2
+ import { fieldDataView } from "../crux.js";
2
3
 
3
4
  const RATING_ICON = {
4
5
  good: "🟢",
@@ -99,11 +100,11 @@ function renderAuditResult(r, verbose) {
99
100
  lines.push("");
100
101
  const shown = items.slice(0, MAX_ITEMS);
101
102
  for (const item of shown) {
102
- const name = item.shortName ?? item.resource ?? item.url ?? item.src ?? item.selector ?? item.filename ?? "";
103
- const tag = item.type ?? item.tag ?? item.strategy ?? item.media ?? "";
103
+ const name = item.shortName ?? item.resource ?? item.url ?? item.src ?? item.selector ?? item.filename ?? item.target ?? "";
104
+ const tag = item.type ?? item.tag ?? item.strategy ?? item.media ?? item.kind ?? "";
104
105
  const timing = item.responseEndMs != null ? `${item.responseEndMs}ms` : item.durationMs != null ? `${item.durationMs}ms` : "";
105
- const cols = [tag, name, timing].filter(Boolean).join(" ");
106
- lines.push(` ${styleText("dim", `· ${cols}`)}`);
106
+ const cols = [tag, name, item.detail, timing].filter(Boolean).join(" ");
107
+ if (cols) lines.push(` ${styleText("dim", `· ${cols}`)}`);
107
108
  }
108
109
  if (items.length > MAX_ITEMS) {
109
110
  lines.push(` ${styleText("dim", `… and ${items.length - MAX_ITEMS} more`)}`);
@@ -117,7 +118,22 @@ function renderAuditResult(r, verbose) {
117
118
  return lines.join("\n");
118
119
  }
119
120
 
121
+ // A result with a count and details but no value and no issues, such as the long tasks: the count,
122
+ // then the first numbers of its details.
123
+ function renderSummaryResult(r) {
124
+ const numbers = Object.entries(r.details ?? {})
125
+ .filter(([, value]) => typeof value === "number")
126
+ .slice(0, 3)
127
+ .map(([key, value]) => `${key} ${value}`);
128
+ const summary = [r.count !== undefined ? String(r.count) : null, ...numbers].filter(Boolean).join(" · ");
129
+ return ` ${RATING_ICON[r.rating] ?? "·"} ${pad(r.id, 16)} ${styleText("dim", summary)}`;
130
+ }
131
+
120
132
  function renderResult(r, verbose) {
133
+ if (r.status === "skipped") {
134
+ return ` ${styleText("dim", "-")} ${pad(r.id, 16)} ${styleText("dim", `skipped: ${r.reason}`)}`;
135
+ }
136
+
121
137
  if (r.status === "error") {
122
138
  return ` ${styleText("red", "✗")} ${pad(r.id, 16)} ${styleText("dim", r.error)}`;
123
139
  }
@@ -130,6 +146,10 @@ function renderResult(r, verbose) {
130
146
  return renderAuditResult(r, verbose);
131
147
  }
132
148
 
149
+ if (r.value === undefined && (r.count !== undefined || r.details)) {
150
+ return renderSummaryResult(r);
151
+ }
152
+
133
153
  const icon = RATING_ICON[r.rating] ?? "·";
134
154
  const valueText = paint(r.rating, formatValue(r.value, r.unit));
135
155
  const ratingText = styleText("dim", r.rating ?? "");
@@ -164,12 +184,30 @@ function renderResult(r, verbose) {
164
184
  return out;
165
185
  }
166
186
 
167
- export function reportHuman({ url, navMs, results, pageErrors, verbose }) {
187
+ function renderFieldData(crux, results) {
188
+ if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`)];
189
+ const { title, rows } = fieldDataView(crux, results);
190
+ const lines = [styleText("bold", title)];
191
+ for (const row of rows) {
192
+ const icon = RATING_ICON[row.rating] ?? "·";
193
+ const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
194
+ lines.push(
195
+ ` ${icon} ${pad(row.metric, 6)} ${pad(paint(row.rating, formatValue(row.p75, row.unit)), 10)} ${pad(styleText("dim", row.rating), 20)} ${styleText("dim", `(synthetic: ${synthetic})`)}`
196
+ );
197
+ }
198
+ return lines;
199
+ }
200
+
201
+ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux }) {
168
202
  const lines = [];
169
203
  lines.push(styleText(["bold"], `WebPerf Snippets — ${url}`));
170
204
  lines.push(styleText("dim", `Navigated in ${navMs}ms`));
171
205
  lines.push("");
172
206
  for (const r of results) lines.push(renderResult(r, verbose));
207
+ if (crux) {
208
+ lines.push("");
209
+ lines.push(...renderFieldData(crux, results));
210
+ }
173
211
  if (pageErrors?.length) {
174
212
  lines.push("");
175
213
  lines.push(styleText("yellow", "Page errors during run:"));
@@ -1,3 +1,5 @@
1
+ import { fieldDataView } from "../crux.js";
2
+
1
3
  const RATING_ICON = {
2
4
  good: "✅",
3
5
  "needs-improvement": "⚠️",
@@ -29,7 +31,12 @@ function renderErrorRow(r) {
29
31
  return `| ${r.id} | ❌ error | ${r.error} |`;
30
32
  }
31
33
 
34
+ function renderSkippedRow(r) {
35
+ return `| ${r.id} | ⏭ skipped | ${r.reason} |`;
36
+ }
37
+
32
38
  function renderRow(r) {
39
+ if (r.status === "skipped") return renderSkippedRow(r);
33
40
  if (r.status === "error") return renderErrorRow(r);
34
41
  if (Array.isArray(r.issues)) return renderAuditRow(r);
35
42
  return renderMetricRow(r);
@@ -50,7 +57,21 @@ function renderSection(title, results) {
50
57
  ].join("\n");
51
58
  }
52
59
 
53
- export function reportMarkdown({ url, navMs, results, pageErrors }) {
60
+ function renderFieldData(crux, results) {
61
+ if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`];
62
+ const { title, rows } = fieldDataView(crux, results);
63
+ return [
64
+ `### ${title}`,
65
+ "| Metric | Field p75 | Status | Synthetic |",
66
+ "|--------|-----------|--------|-----------|",
67
+ ...rows.map((row) => {
68
+ const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
69
+ return `| ${row.metric} | ${formatValue(row.p75, row.unit)} | ${RATING_ICON[row.rating] ?? "·"} ${row.rating} | ${synthetic} |`;
70
+ }),
71
+ ];
72
+ }
73
+
74
+ export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
54
75
  const lines = [];
55
76
  lines.push(`## WebPerf Results — ${url}`);
56
77
  lines.push(`> Navigated in ${navMs}ms`);
@@ -72,6 +93,11 @@ export function reportMarkdown({ url, navMs, results, pageErrors }) {
72
93
  lines.push(renderSection(`Follow-up *(${reason})*`, group));
73
94
  }
74
95
 
96
+ if (crux) {
97
+ lines.push("");
98
+ lines.push(...renderFieldData(crux, results));
99
+ }
100
+
75
101
  if (pageErrors?.length) {
76
102
  lines.push("");
77
103
  lines.push("### ⚠ Page Errors");