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.
- package/README.md +142 -14
- 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 +141 -43
- 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/First-And-Third-Party-Script-Info.js +58 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -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 +44 -9
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +72 -24
- 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 +86 -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 +244 -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 +74 -11
- 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 +1179 -0
- package/snippets/_shared/helpers.js +61 -0
- package/src/bin.js +72 -45
- package/src/crux.js +84 -0
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +27 -1
- package/src/runner.js +75 -21
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/decision-tree.js
CHANGED
|
@@ -1,28 +1,76 @@
|
|
|
1
|
-
// Declarative follow-ups: if a result matches the predicate, append the
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
25
|
-
if (rule.when(result))
|
|
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;
|
package/src/interactions.js
CHANGED
|
@@ -22,9 +22,19 @@ export async function executeStep(page, step) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
}
|
package/src/load-snippet.js
CHANGED
|
@@ -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
|
];
|
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: "🟢",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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");
|