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.
- package/README.md +185 -20
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +52 -30
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/Find-render-blocking-resources.js +11 -13
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +15 -0
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +55 -25
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +59 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +22 -3
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1176 -0
- package/snippets/_shared/helpers.js +44 -0
- package/src/bin.js +86 -57
- package/src/crux.js +84 -0
- package/src/decision-tree.js +11 -1
- package/src/interactions.js +30 -0
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +20 -1
- package/src/reporters/markdown.js +103 -0
- package/src/runner.js +81 -17
- 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 {
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
+
}
|
package/src/decision-tree.js
CHANGED
|
@@ -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
|
+
}
|
package/src/report-to.js
ADDED
|
@@ -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
|
+
}
|
package/src/reporters/human.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|