webperf-snippets 0.1.0 โ†’ 0.3.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 (69) hide show
  1. package/README.md +185 -20
  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 +52 -30
  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/Find-render-blocking-resources.js +11 -13
  33. package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
  34. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  35. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
  36. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  37. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  38. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  39. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  40. package/snippets/Loading/Resource-Hints-Validation.js +15 -0
  41. package/snippets/Loading/Resource-Hints.js +29 -8
  42. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  43. package/snippets/Loading/Script-Loading.js +55 -25
  44. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  45. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  46. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  47. package/snippets/Loading/TTFB-Resources.js +59 -18
  48. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  49. package/snippets/Loading/TTFB.js +3 -2
  50. package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  52. package/snippets/Loading/WORKFLOWS.md +47 -1
  53. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  54. package/snippets/Media/Image-Element-Audit.js +22 -3
  55. package/snippets/Media/Oversized-Images.js +217 -0
  56. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  57. package/snippets/Media/WORKFLOWS.md +22 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  59. package/snippets/SCHEMA.md +1176 -0
  60. package/snippets/_shared/helpers.js +44 -0
  61. package/src/bin.js +86 -57
  62. package/src/crux.js +84 -0
  63. package/src/decision-tree.js +11 -1
  64. package/src/interactions.js +30 -0
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +20 -1
  67. package/src/reporters/markdown.js +103 -0
  68. package/src/runner.js +81 -17
  69. package/src/workflows/loading.js +14 -0
@@ -0,0 +1,44 @@
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
+ // @shared isFirstParty
31
+ function isFirstParty(hostname) {
32
+ return getRootDomain(hostname) === getRootDomain(location.hostname);
33
+ }
34
+ // @end-shared isFirstParty
35
+
36
+ // @shared formatBytes
37
+ function formatBytes(bytes) {
38
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
39
+ if (bytes === 0) return "0 B";
40
+ const units = ["B", "KB", "MB", "GB"];
41
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
42
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
43
+ }
44
+ // @end-shared formatBytes
package/src/bin.js CHANGED
@@ -1,21 +1,28 @@
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";
4
- import { runSnippets, VIEWPORT_PRESETS } from "./runner.js";
5
+ import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
5
6
  import { cwvWorkflow } from "./workflows/cwv.js";
6
7
  import { auditWorkflow } from "./workflows/audit.js";
7
- import { nextSteps } from "./decision-tree.js";
8
+ import { loadingWorkflow } from "./workflows/loading.js";
9
+ import { RULES } from "./decision-tree.js";
8
10
  import { reportHuman } from "./reporters/human.js";
9
11
  import { reportJson } from "./reporters/json.js";
12
+ import { reportMarkdown } from "./reporters/markdown.js";
13
+ import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
14
+ import { fetchCrux, formFactorFor } from "./crux.js";
10
15
 
11
16
  const WORKFLOWS = {
12
17
  "core-web-vitals": cwvWorkflow,
13
18
  audit: auditWorkflow,
19
+ loading: loadingWorkflow,
14
20
  };
15
21
 
16
22
  const SNIPPET_ALIASES = {
17
23
  LCP: "CoreWebVitals/LCP",
18
24
  CLS: "CoreWebVitals/CLS",
25
+ INP: "CoreWebVitals/INP",
19
26
  "LCP-Subparts": "CoreWebVitals/LCP-Subparts",
20
27
  fonts: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
21
28
  "Fonts-Preloaded-Loaded-and-used-above-the-fold":
@@ -53,18 +60,29 @@ Run curated WebPerf Snippets headlessly via Playwright.
53
60
 
54
61
  Options:
55
62
  --workflow <name> Workflow to run (default: core-web-vitals)
56
- Workflows: core-web-vitals, audit
63
+ Workflows: core-web-vitals, audit, loading
57
64
  --snippet <name> Run a single snippet by alias or Category/Name path
58
- Aliases: LCP, CLS, LCP-Subparts, fonts,
65
+ Aliases: LCP, CLS, INP, LCP-Subparts, fonts,
59
66
  render-blocking, resource-hints, preload-scripts,
60
67
  priority-hints, critical-css, ttfb,
61
68
  script-parties, script-loading,
62
69
  lazy-atf, lazy-conflict, eager-below-fold
63
70
  --json Output JSON instead of formatted text
71
+ --markdown Output GitHub-renderable markdown (for PR comments)
64
72
  --viewport <preset> Viewport preset: mobile (default), tablet, desktop
65
73
  --wait <ms> Post-load wait before evaluating (default: 3000)
66
74
  --budget-lcp <ms> Exit 1 if LCP exceeds this value
67
75
  --budget-cls <score> Exit 1 if CLS exceeds this value
76
+ --interact-script <path> JSON file with interactions to run before evaluation
77
+ Actions: scroll, click, hover, type, wait
78
+ --storage-state <path> Playwright storage state (cookies + localStorage) to
79
+ measure pages that require authentication
80
+ --crux-key <key> Add CrUX field data (p75) next to the measured values.
81
+ Also read from the CRUX_API_KEY environment variable
82
+ --report-to <url> POST the results to this https URL after the run
83
+ (without it the CLI makes no external calls)
84
+ --api-key <key> Sent as "Authorization: Bearer <key>" with --report-to.
85
+ Prefer the PERF_REVIEWS_API_KEY environment variable
68
86
  --verbose Show all items, even for passing checks
69
87
  --headed Show the browser window (debug)
70
88
  -h, --help Show this help
@@ -77,6 +95,8 @@ Examples:
77
95
  npx webperf-snippets https://web.dev --snippet render-blocking
78
96
  npx webperf-snippets https://web.dev --snippet fonts
79
97
  npx webperf-snippets https://web.dev --budget-lcp 2500
98
+ npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
99
+ npx webperf-snippets https://example.com/dashboard --storage-state auth.json
80
100
  `;
81
101
 
82
102
  function fail(message, code = 2) {
@@ -88,19 +108,9 @@ function resolveSnippetPath(name) {
88
108
  return SNIPPET_ALIASES[name] ?? name;
89
109
  }
90
110
 
91
- function buildItems(values) {
92
- if (values.snippet) {
93
- const path = resolveSnippetPath(values.snippet);
94
- return [{ id: values.snippet, path, source: loadSnippet(path) }];
95
- }
96
- const workflowName = values.workflow ?? "core-web-vitals";
97
- const workflow = WORKFLOWS[workflowName];
98
- if (!workflow) fail(`Unknown workflow: ${workflowName}`);
99
- return workflow.steps.map((step) => ({
100
- id: step.id,
101
- path: step.path,
102
- source: loadSnippet(step.path),
103
- }));
111
+ function buildSnippetItem(values) {
112
+ const path = resolveSnippetPath(values.snippet);
113
+ return [{ id: values.snippet, path, source: loadSnippet(path) }];
104
114
  }
105
115
 
106
116
  function checkBudgets(results, values) {
@@ -128,10 +138,16 @@ async function main() {
128
138
  workflow: { type: "string" },
129
139
  snippet: { type: "string" },
130
140
  json: { type: "boolean" },
141
+ markdown: { type: "boolean" },
131
142
  wait: { type: "string" },
132
143
  "budget-lcp": { type: "string" },
133
144
  "budget-cls": { type: "string" },
134
145
  viewport: { type: "string" },
146
+ "interact-script": { type: "string" },
147
+ "storage-state": { type: "string" },
148
+ "crux-key": { type: "string" },
149
+ "report-to": { type: "string" },
150
+ "api-key": { type: "string" },
135
151
  verbose: { type: "boolean" },
136
152
  headed: { type: "boolean" },
137
153
  help: { type: "boolean", short: "h" },
@@ -155,7 +171,6 @@ async function main() {
155
171
  process.exit(2);
156
172
  }
157
173
 
158
- const items = buildItems(values);
159
174
  const waitMs = values.wait ? Number(values.wait) : 3000;
160
175
  const viewportName = values.viewport ?? "mobile";
161
176
  const viewport = VIEWPORT_PRESETS[viewportName];
@@ -163,50 +178,64 @@ async function main() {
163
178
  fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
164
179
  }
165
180
 
166
- const initial = await runSnippets({
167
- url,
168
- items,
169
- waitMs,
170
- headless: !values.headed,
171
- viewport,
172
- });
173
-
174
- // Apply decision tree to spawn follow-up steps.
175
- const followUps = nextSteps(initial.results);
176
- let followUpRun = { results: [], pageErrors: [] };
177
- if (followUps.length > 0) {
178
- const followItems = followUps.map((f) => ({
179
- id: f.id,
180
- path: f.path,
181
- source: loadSnippet(f.path),
182
- reason: f.reason,
183
- }));
184
- // v0.1 limitation: follow-ups re-navigate. Acceptable for now; consolidate
185
- // into a single page session in v0.2 if perf becomes a problem.
186
- followUpRun = await runSnippets({
187
- url,
188
- items: followItems,
189
- waitMs,
190
- headless: !values.headed,
191
- viewport,
192
- });
193
- // Carry forward the human-readable reason so reporters can show it.
194
- followUpRun.results = followUpRun.results.map((r) => {
195
- const f = followUps.find((x) => x.id === r.id);
196
- return f ? { ...r, reason: f.reason } : r;
197
- });
181
+ const reportUrl = values["report-to"];
182
+ if (reportUrl) {
183
+ const problem = validateReportUrl(reportUrl);
184
+ if (problem) fail(problem);
198
185
  }
199
186
 
200
- const payload = {
201
- url,
202
- navMs: initial.navMs,
203
- results: [...initial.results, ...followUpRun.results],
204
- pageErrors: [...initial.pageErrors, ...(followUpRun.pageErrors ?? [])],
205
- };
187
+ const interactScript = values["interact-script"];
188
+ const storageState = values["storage-state"];
189
+ if (storageState && !existsSync(storageState)) {
190
+ fail(`Storage state file not found: ${storageState}`);
191
+ }
192
+
193
+ let payload;
194
+ if (values.snippet) {
195
+ const items = buildSnippetItem(values);
196
+ payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState });
197
+ } else {
198
+ const workflowName = values.workflow ?? "core-web-vitals";
199
+ const workflow = WORKFLOWS[workflowName];
200
+ if (!workflow) fail(`Unknown workflow: ${workflowName}`);
201
+ payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState });
202
+ }
206
203
 
207
- const output = values.json ? reportJson(payload) : reportHuman({ ...payload, verbose: values.verbose });
204
+ const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
205
+ const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
206
+ if (crux) payload.crux = crux;
207
+
208
+ let output;
209
+ if (values.markdown) {
210
+ output = reportMarkdown(payload);
211
+ } else if (values.json) {
212
+ output = reportJson(payload);
213
+ } else {
214
+ output = reportHuman({ ...payload, verbose: values.verbose });
215
+ }
208
216
  process.stdout.write(output + "\n");
209
217
 
218
+ if (reportUrl) {
219
+ const outcome = await reportTo({
220
+ url: reportUrl,
221
+ apiKey: values["api-key"] || process.env.PERF_REVIEWS_API_KEY,
222
+ body: {
223
+ url,
224
+ workflow: values.snippet ? null : (values.workflow ?? "core-web-vitals"),
225
+ ...(values.snippet ? { snippet: values.snippet } : {}),
226
+ timestamp: new Date().toISOString(),
227
+ navMs: payload.navMs,
228
+ results: payload.results,
229
+ meta: { viewport: viewportName, waitMs, cli_version: cliVersion },
230
+ },
231
+ });
232
+ if (!outcome.ok) {
233
+ process.stderr.write(`Warning: could not report results to ${new URL(reportUrl).origin} (${outcome.warning})\n`);
234
+ } else if (outcome.regressions.length > 0) {
235
+ process.stderr.write(`Regressions detected:\n${outcome.regressions.map((r) => ` - ${typeof r === "string" ? r : JSON.stringify(r)}`).join("\n")}\n`);
236
+ }
237
+ }
238
+
210
239
  // Exit codes.
211
240
  const violations = checkBudgets(payload.results, values);
212
241
  if (violations.length > 0) {
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,11 +1,21 @@
1
1
  // Declarative follow-ups: if a result matches the predicate, append the step.
2
2
  // Mirrors the WORKFLOWS.md decision trees from the snippet catalogue.
3
- const RULES = [
3
+ export const RULES = [
4
4
  {
5
5
  when: (r) => r.id === "LCP" && r.status === "ok" && r.value > 2500,
6
6
  append: { id: "LCP-Subparts", path: "CoreWebVitals/LCP-Subparts" },
7
7
  reason: "LCP > 2.5s โ€” drilling into sub-parts",
8
8
  },
9
+ {
10
+ when: (r) => r.id === "TTFB" && r.status === "ok" && r.value > 600,
11
+ append: { id: "ttfb-subparts", path: "Loading/TTFB-Sub-Parts" },
12
+ reason: "TTFB > 600ms โ€” drilling into sub-parts",
13
+ },
14
+ {
15
+ when: (r) => r.id === "FCP" && r.status === "ok" && r.value > 1800,
16
+ append: { id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
17
+ reason: "FCP > 1.8s โ€” checking render-blocking resources",
18
+ },
9
19
  ];
10
20
 
11
21
  export function nextSteps(results) {
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export async function executeStep(page, step) {
4
+ switch (step.action) {
5
+ case "scroll":
6
+ await page.evaluate((y) => window.scrollBy(0, y), step.y ?? 300);
7
+ break;
8
+ case "click":
9
+ await page.click(step.selector);
10
+ break;
11
+ case "hover":
12
+ await page.hover(step.selector);
13
+ break;
14
+ case "type":
15
+ await page.type(step.selector, step.text);
16
+ break;
17
+ case "wait":
18
+ await page.waitForTimeout(step.ms);
19
+ break;
20
+ default:
21
+ throw new Error(`Unknown interaction action: "${step.action}"`);
22
+ }
23
+ }
24
+
25
+ export async function runInteractions(page, scriptPath) {
26
+ const { interactions } = JSON.parse(readFileSync(scriptPath, "utf8"));
27
+ for (const step of interactions) {
28
+ await executeStep(page, step);
29
+ }
30
+ }
@@ -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: "๐ŸŸข",
@@ -164,12 +165,30 @@ function renderResult(r, verbose) {
164
165
  return out;
165
166
  }
166
167
 
167
- export function reportHuman({ url, navMs, results, pageErrors, verbose }) {
168
+ function renderFieldData(crux, results) {
169
+ if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`)];
170
+ const { title, rows } = fieldDataView(crux, results);
171
+ const lines = [styleText("bold", title)];
172
+ for (const row of rows) {
173
+ const icon = RATING_ICON[row.rating] ?? "ยท";
174
+ const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
175
+ lines.push(
176
+ ` ${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})`)}`
177
+ );
178
+ }
179
+ return lines;
180
+ }
181
+
182
+ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux }) {
168
183
  const lines = [];
169
184
  lines.push(styleText(["bold"], `WebPerf Snippets โ€” ${url}`));
170
185
  lines.push(styleText("dim", `Navigated in ${navMs}ms`));
171
186
  lines.push("");
172
187
  for (const r of results) lines.push(renderResult(r, verbose));
188
+ if (crux) {
189
+ lines.push("");
190
+ lines.push(...renderFieldData(crux, results));
191
+ }
173
192
  if (pageErrors?.length) {
174
193
  lines.push("");
175
194
  lines.push(styleText("yellow", "Page errors during run:"));
@@ -0,0 +1,103 @@
1
+ import { fieldDataView } from "../crux.js";
2
+
3
+ const RATING_ICON = {
4
+ good: "โœ…",
5
+ "needs-improvement": "โš ๏ธ",
6
+ poor: "โŒ",
7
+ };
8
+
9
+ function formatValue(value, unit) {
10
+ if (unit === "ms") return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${value}ms`;
11
+ if (unit === "score") return value.toFixed(4);
12
+ return String(value);
13
+ }
14
+
15
+ function renderMetricRow(r) {
16
+ const value = formatValue(r.value, r.unit);
17
+ const icon = RATING_ICON[r.rating] ?? "ยท";
18
+ return `| ${r.id} | ${value} | ${icon} ${r.rating} |`;
19
+ }
20
+
21
+ function renderAuditRow(r) {
22
+ const errors = (r.issues ?? []).filter((i) => i.severity === "error");
23
+ const warnings = (r.issues ?? []).filter((i) => i.severity === "warning");
24
+ const icon = errors.length ? "๐Ÿ”ด" : warnings.length ? "๐ŸŸก" : "๐ŸŸข";
25
+ const label = errors.length ? "fail" : warnings.length ? "warning" : "pass";
26
+ const detail = r.issues?.length ? r.issues.map((i) => i.message).join("; ") : "No issues";
27
+ return `| ${r.id} | ${icon} ${label} | ${detail} |`;
28
+ }
29
+
30
+ function renderErrorRow(r) {
31
+ return `| ${r.id} | โŒ error | ${r.error} |`;
32
+ }
33
+
34
+ function renderRow(r) {
35
+ if (r.status === "error") return renderErrorRow(r);
36
+ if (Array.isArray(r.issues)) return renderAuditRow(r);
37
+ return renderMetricRow(r);
38
+ }
39
+
40
+ function sectionHeader(results) {
41
+ if (results.some((r) => Array.isArray(r.issues))) {
42
+ return ["| Check | Status | Issues |", "|-------|--------|--------|"];
43
+ }
44
+ return ["| Metric | Value | Status |", "|--------|-------|--------|"];
45
+ }
46
+
47
+ function renderSection(title, results) {
48
+ return [
49
+ `### ${title}`,
50
+ ...sectionHeader(results),
51
+ ...results.map(renderRow),
52
+ ].join("\n");
53
+ }
54
+
55
+ function renderFieldData(crux, results) {
56
+ if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`];
57
+ const { title, rows } = fieldDataView(crux, results);
58
+ return [
59
+ `### ${title}`,
60
+ "| Metric | Field p75 | Status | Synthetic |",
61
+ "|--------|-----------|--------|-----------|",
62
+ ...rows.map((row) => {
63
+ const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
64
+ return `| ${row.metric} | ${formatValue(row.p75, row.unit)} | ${RATING_ICON[row.rating] ?? "ยท"} ${row.rating} | ${synthetic} |`;
65
+ }),
66
+ ];
67
+ }
68
+
69
+ export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
70
+ const lines = [];
71
+ lines.push(`## WebPerf Results โ€” ${url}`);
72
+ lines.push(`> Navigated in ${navMs}ms`);
73
+ lines.push("");
74
+
75
+ const initial = results.filter((r) => !r.reason);
76
+ if (initial.length > 0) {
77
+ lines.push(renderSection("Results", initial));
78
+ }
79
+
80
+ // Group follow-ups by their triggering reason.
81
+ const byReason = new Map();
82
+ for (const r of results.filter((r) => r.reason)) {
83
+ if (!byReason.has(r.reason)) byReason.set(r.reason, []);
84
+ byReason.get(r.reason).push(r);
85
+ }
86
+ for (const [reason, group] of byReason) {
87
+ lines.push("");
88
+ lines.push(renderSection(`Follow-up *(${reason})*`, group));
89
+ }
90
+
91
+ if (crux) {
92
+ lines.push("");
93
+ lines.push(...renderFieldData(crux, results));
94
+ }
95
+
96
+ if (pageErrors?.length) {
97
+ lines.push("");
98
+ lines.push("### โš  Page Errors");
99
+ for (const err of pageErrors) lines.push(`- ${err}`);
100
+ }
101
+
102
+ return lines.join("\n");
103
+ }