webperf-snippets 0.3.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 +24 -11
- package/package.json +1 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +93 -17
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +19 -1
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +27 -2
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +19 -1
- package/snippets/Loading/Resource-Hints-Validation.js +30 -10
- package/snippets/Loading/Script-Loading.js +19 -1
- package/snippets/Loading/TTFB-Resources.js +29 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +39 -11
- package/snippets/Media/Image-Element-Audit.js +52 -8
- package/snippets/SCHEMA.md +7 -4
- package/snippets/_shared/helpers.js +18 -1
- package/src/bin.js +18 -43
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/reporters/human.js +23 -4
- package/src/reporters/markdown.js +5 -0
- package/src/runner.js +71 -19
- 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
|
@@ -45,19 +45,28 @@
|
|
|
45
45
|
|
|
46
46
|
// @shared isFirstParty
|
|
47
47
|
function isFirstParty(hostname) {
|
|
48
|
-
|
|
48
|
+
const root = getRootDomain(hostname);
|
|
49
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
50
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
49
51
|
}
|
|
50
52
|
// @end-shared isFirstParty
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
// @shared logOwnDomainsHint
|
|
55
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
56
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
57
|
+
console.log(
|
|
58
|
+
'%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.',
|
|
59
|
+
"color: #3b82f6;"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// @end-shared logOwnDomainsHint
|
|
55
64
|
|
|
56
65
|
// Root domain of an http(s) URL, or null for anything else (data:, blob:, empty)
|
|
57
66
|
const rootDomainOf = (url) => {
|
|
58
67
|
try {
|
|
59
68
|
const u = new URL(url);
|
|
60
|
-
return /^https?:$/.test(u.protocol) ? { root: getRootDomain(u.hostname), first: isFirstParty(u.hostname)
|
|
69
|
+
return /^https?:$/.test(u.protocol) ? { root: getRootDomain(u.hostname), first: isFirstParty(u.hostname) } : null;
|
|
61
70
|
} catch {
|
|
62
71
|
return null;
|
|
63
72
|
}
|
|
@@ -106,12 +115,17 @@
|
|
|
106
115
|
// Main-thread time: script time inside long animation frames, attributed by the script's source URL
|
|
107
116
|
const loafSupported = !!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame");
|
|
108
117
|
let thirdPartyLoafMs = null;
|
|
118
|
+
let longFrames = null;
|
|
119
|
+
let longFramesMs = null;
|
|
109
120
|
if (loafSupported) {
|
|
110
121
|
thirdPartyLoafMs = 0;
|
|
111
122
|
domains.forEach((d) => {
|
|
112
123
|
d.loafMs = 0;
|
|
113
124
|
});
|
|
114
|
-
performance.getEntriesByType("long-animation-frame")
|
|
125
|
+
const frames = performance.getEntriesByType("long-animation-frame");
|
|
126
|
+
longFrames = frames.length;
|
|
127
|
+
longFramesMs = frames.reduce((total, frame) => total + frame.duration, 0);
|
|
128
|
+
frames.forEach((frame) => {
|
|
115
129
|
(frame.scripts || []).forEach((script) => {
|
|
116
130
|
const parsed = rootDomainOf(script.sourceURL);
|
|
117
131
|
if (!parsed || parsed.first) return;
|
|
@@ -125,7 +139,12 @@
|
|
|
125
139
|
d.loafMs = Math.round(d.loafMs || 0);
|
|
126
140
|
});
|
|
127
141
|
thirdPartyLoafMs = Math.round(thirdPartyLoafMs);
|
|
142
|
+
longFramesMs = Math.round(longFramesMs);
|
|
128
143
|
}
|
|
144
|
+
// Time in long frames that no third-party script accounts for: first-party scripts, scripts under
|
|
145
|
+
// the 5 ms that Long Animation Frames list, and style, layout and rendering work
|
|
146
|
+
const unattributedLoafMs = loafSupported ? Math.max(0, longFramesMs - thirdPartyLoafMs) : null;
|
|
147
|
+
const loafUnattributed = loafSupported && domains.size > 0 && thirdPartyLoafMs === 0 && longFrames > 0;
|
|
129
148
|
|
|
130
149
|
// Render-blocking first, then main-thread time, then transfer size
|
|
131
150
|
const all = [...domains.values()].sort(
|
|
@@ -158,6 +177,12 @@
|
|
|
158
177
|
message: `${d.domain} scripts take ${d.loafMs} ms of main-thread time in long animation frames (over ${LOAF_WARNING_MS} ms); defer, delay or remove them`,
|
|
159
178
|
});
|
|
160
179
|
});
|
|
180
|
+
if (loafUnattributed) {
|
|
181
|
+
issues.push({
|
|
182
|
+
severity: "info",
|
|
183
|
+
message: `${longFrames} long animation frame(s) took ${longFramesMs} ms, but no third-party script was attributed in them (Long Animation Frames only list scripts over 5 ms), so 0 ms per domain does not rule out third-party cost`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
161
186
|
if (corsLimitedAnalysis) {
|
|
162
187
|
issues.push({
|
|
163
188
|
severity: "info",
|
|
@@ -185,15 +210,16 @@
|
|
|
185
210
|
if (!loafSupported) {
|
|
186
211
|
console.log("%cℹ️ Long Animation Frames are not supported; main-thread time is unavailable (Chrome 123+)", "color: #3b82f6;");
|
|
187
212
|
}
|
|
188
|
-
if (
|
|
189
|
-
console.log("%cℹ️ Some third-party sizes are hidden (no Timing-Allow-Origin); sizes are a lower bound", "color: #3b82f6;");
|
|
190
|
-
}
|
|
191
|
-
if (OWN_DOMAINS.length === 0 && all.length > 0) {
|
|
213
|
+
if (loafUnattributed) {
|
|
192
214
|
console.log(
|
|
193
|
-
|
|
215
|
+
`%cℹ️ ${longFrames} long animation frame(s) took ${longFramesMs} ms, but no third-party script was attributed in them (Long Animation Frames only list scripts over 5 ms), so 0 ms per domain does not rule out third-party cost`,
|
|
194
216
|
"color: #3b82f6;"
|
|
195
217
|
);
|
|
196
218
|
}
|
|
219
|
+
if (corsLimitedAnalysis) {
|
|
220
|
+
console.log("%cℹ️ Some third-party sizes are hidden (no Timing-Allow-Origin); sizes are a lower bound", "color: #3b82f6;");
|
|
221
|
+
}
|
|
222
|
+
logOwnDomainsHint(all.length);
|
|
197
223
|
console.groupEnd();
|
|
198
224
|
|
|
199
225
|
return {
|
|
@@ -205,6 +231,8 @@
|
|
|
205
231
|
thirdPartyRequests,
|
|
206
232
|
thirdPartyBytes,
|
|
207
233
|
thirdPartyLoafMs,
|
|
234
|
+
longFrames,
|
|
235
|
+
unattributedLoafMs,
|
|
208
236
|
firstPartyRequests,
|
|
209
237
|
blockingDomains: blocking.length,
|
|
210
238
|
loafSupported,
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Fallback when the browser cannot report the LCP element: the largest image in the viewport
|
|
18
|
+
function estimateLcpCandidate(imgs) {
|
|
18
19
|
let candidate = null;
|
|
19
20
|
let maxArea = 0;
|
|
20
21
|
imgs.filter(isInViewport).forEach((img) => {
|
|
@@ -28,6 +29,40 @@
|
|
|
28
29
|
return candidate;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// Chrome only exposes largest-contentful-paint entries through a PerformanceObserver
|
|
33
|
+
function getLastLcpEntry() {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const entries = [];
|
|
36
|
+
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
|
37
|
+
observer.observe({ type: "largest-contentful-paint", buffered: true });
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
observer.disconnect();
|
|
40
|
+
resolve(entries.at(-1) ?? null);
|
|
41
|
+
}, 100);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The <img> the browser reports as the Largest Contentful Paint, or null when the LCP element is
|
|
46
|
+
// not one of the page's images (text, a CSS background, a video). Estimates by area when the
|
|
47
|
+
// browser reports no LCP.
|
|
48
|
+
async function findLcpCandidate(imgs) {
|
|
49
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
50
|
+
return { candidate: estimateLcpCandidate(imgs), nonImageTag: null };
|
|
51
|
+
}
|
|
52
|
+
const entry = await getLastLcpEntry();
|
|
53
|
+
if (!entry) return { candidate: estimateLcpCandidate(imgs), nonImageTag: null };
|
|
54
|
+
const el = entry.element;
|
|
55
|
+
if (el?.tagName === "IMG") {
|
|
56
|
+
return imgs.includes(el) ? { candidate: el, nonImageTag: null } : { candidate: null, nonImageTag: "IMG" };
|
|
57
|
+
}
|
|
58
|
+
// The element left the DOM after painting: find its image by URL
|
|
59
|
+
if (!el && entry.url) {
|
|
60
|
+
const byUrl = imgs.find((img) => (img.currentSrc || img.src) === entry.url) ?? null;
|
|
61
|
+
return { candidate: byUrl, nonImageTag: byUrl ? null : "unknown" };
|
|
62
|
+
}
|
|
63
|
+
return { candidate: null, nonImageTag: el ? el.tagName : "unknown" };
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
function detectFormat(url) {
|
|
32
67
|
if (!url) return "unknown";
|
|
33
68
|
const lower = url.toLowerCase();
|
|
@@ -120,7 +155,7 @@
|
|
|
120
155
|
return { script: "Image-Element-Audit", status: "ok", count: 0, corsLimitedAnalysis: false, items: [], issues: [] };
|
|
121
156
|
}
|
|
122
157
|
|
|
123
|
-
const lcpCandidate = findLcpCandidate(images);
|
|
158
|
+
const { candidate: lcpCandidate, nonImageTag } = await findLcpCandidate(images);
|
|
124
159
|
const imagePreloads = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
|
|
125
160
|
|
|
126
161
|
// Fetch actual formats from Content-Type headers in parallel.
|
|
@@ -213,6 +248,10 @@
|
|
|
213
248
|
);
|
|
214
249
|
|
|
215
250
|
// LCP candidate
|
|
251
|
+
if (nonImageTag) {
|
|
252
|
+
console.log("");
|
|
253
|
+
console.log(`%cℹ️ The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP.`, "color: #3b82f6;");
|
|
254
|
+
}
|
|
216
255
|
if (lcpCandidate) {
|
|
217
256
|
const lcp = audited.find((r) => r.isLcp);
|
|
218
257
|
const fpOk = lcp.fetchpriority === "high";
|
|
@@ -359,11 +398,16 @@
|
|
|
359
398
|
inPicture: r.inPicture,
|
|
360
399
|
issues: r.issues.map((i) => ({ severity: i.s, message: i.msg })),
|
|
361
400
|
})),
|
|
362
|
-
issues:
|
|
363
|
-
|
|
364
|
-
severity:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
401
|
+
issues: [
|
|
402
|
+
...(nonImageTag
|
|
403
|
+
? [{ severity: "info", message: `The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP` }]
|
|
404
|
+
: []),
|
|
405
|
+
...audited.flatMap((r) =>
|
|
406
|
+
r.issues.map((i) => ({
|
|
407
|
+
severity: i.s,
|
|
408
|
+
message: `${shortSrc(r.src) || "(no src)"}: ${i.msg}`,
|
|
409
|
+
}))
|
|
410
|
+
),
|
|
411
|
+
],
|
|
368
412
|
};
|
|
369
413
|
})();
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -527,7 +527,7 @@ Synchronous. `count` is the number of uncompressed text resources; `items` holds
|
|
|
527
527
|
|
|
528
528
|
#### Third-Party-Impact-by-Domain
|
|
529
529
|
|
|
530
|
-
Synchronous. `count` is the number of third-party root domains; `items` holds the 50 to look at first (render-blocking, then the most main-thread time, then the largest transfer), while `details` totals every domain. `loafMs` is the script time attributed to the domain in `long-animation-frame` entries (sum of `scripts[].duration` by `sourceURL`), or `null` for every item when `details.loafSupported` is `false`. A cross-origin request without `Timing-Allow-Origin` reports zero for every size, so it is counted in `sizeUnknownCount` (per domain and in `details`), left out of the byte totals, and `corsLimitedAnalysis` is `true`.
|
|
530
|
+
Synchronous. `count` is the number of third-party root domains; `items` holds the 50 to look at first (render-blocking, then the most main-thread time, then the largest transfer), while `details` totals every domain. `loafMs` is the script time attributed to the domain in `long-animation-frame` entries (sum of `scripts[].duration` by `sourceURL`), or `null` for every item when `details.loafSupported` is `false`. `details.longFrames` counts the long animation frames on the page and `details.unattributedLoafMs` is the time in them that no third-party script accounts for (first-party scripts, scripts under the 5 ms that the API lists, and style, layout and rendering work); both are `null` when `loafSupported` is `false`. A cross-origin request without `Timing-Allow-Origin` reports zero for every size, so it is counted in `sizeUnknownCount` (per domain and in `details`), left out of the byte totals, and `corsLimitedAnalysis` is `true`.
|
|
531
531
|
|
|
532
532
|
```json
|
|
533
533
|
{
|
|
@@ -539,6 +539,8 @@ Synchronous. `count` is the number of third-party root domains; `items` holds th
|
|
|
539
539
|
"thirdPartyRequests": 3,
|
|
540
540
|
"thirdPartyBytes": 5120,
|
|
541
541
|
"thirdPartyLoafMs": 128,
|
|
542
|
+
"longFrames": 2,
|
|
543
|
+
"unattributedLoafMs": 74,
|
|
542
544
|
"firstPartyRequests": 1,
|
|
543
545
|
"blockingDomains": 1,
|
|
544
546
|
"loafSupported": true,
|
|
@@ -667,15 +669,16 @@ Async. Returns one item per registration. `cacheHitRate` is computed over the re
|
|
|
667
669
|
|
|
668
670
|
#### Resource-Hints-Validation
|
|
669
671
|
|
|
670
|
-
`items` lists the origins that need a hint and the origins that have both `preconnect` and `dns-prefetch` (at most 50).
|
|
672
|
+
`items` lists the hints to remove because nothing uses them (`remove-unused-preload`, `remove-unused-preconnect`, `remove-unused-dns-prefetch`, with the `href` of the hint), the origins that need a hint and the origins that have both `preconnect` and `dns-prefetch` (at most 50). `href` is `null` for the last two kinds.
|
|
671
673
|
|
|
672
674
|
```json
|
|
673
675
|
{
|
|
674
676
|
"script": "Resource-Hints-Validation",
|
|
675
677
|
"status": "ok",
|
|
676
678
|
"items": [
|
|
677
|
-
{ "domain": "https://
|
|
678
|
-
{ "domain": "https://
|
|
679
|
+
{ "domain": "https://old.example", "href": "https://old.example/", "requestCount": 0, "action": "remove-unused-preconnect", "recommendedHint": null },
|
|
680
|
+
{ "domain": "https://cdn.example", "href": null, "requestCount": 6, "action": "add-preconnect", "recommendedHint": "preconnect" },
|
|
681
|
+
{ "domain": "https://fonts.example", "href": null, "requestCount": 2, "action": "remove-dns-prefetch", "recommendedHint": "preconnect" }
|
|
679
682
|
]
|
|
680
683
|
}
|
|
681
684
|
```
|
|
@@ -27,12 +27,29 @@ function getRootDomain(hostname) {
|
|
|
27
27
|
}
|
|
28
28
|
// @end-shared getRootDomain
|
|
29
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
|
+
|
|
30
34
|
// @shared isFirstParty
|
|
31
35
|
function isFirstParty(hostname) {
|
|
32
|
-
|
|
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);
|
|
33
39
|
}
|
|
34
40
|
// @end-shared isFirstParty
|
|
35
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
|
+
|
|
36
53
|
// @shared formatBytes
|
|
37
54
|
function formatBytes(bytes) {
|
|
38
55
|
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
package/src/bin.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { loadSnippet } from "./load-snippet.js";
|
|
5
|
+
import { resolveSnippetName } from "./snippet-names.js";
|
|
5
6
|
import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
|
|
6
7
|
import { cwvWorkflow } from "./workflows/cwv.js";
|
|
7
8
|
import { auditWorkflow } from "./workflows/audit.js";
|
|
8
9
|
import { loadingWorkflow } from "./workflows/loading.js";
|
|
10
|
+
import { interactionWorkflow } from "./workflows/interaction.js";
|
|
9
11
|
import { RULES } from "./decision-tree.js";
|
|
10
12
|
import { reportHuman } from "./reporters/human.js";
|
|
11
13
|
import { reportJson } from "./reporters/json.js";
|
|
@@ -17,42 +19,9 @@ const WORKFLOWS = {
|
|
|
17
19
|
"core-web-vitals": cwvWorkflow,
|
|
18
20
|
audit: auditWorkflow,
|
|
19
21
|
loading: loadingWorkflow,
|
|
22
|
+
interaction: interactionWorkflow,
|
|
20
23
|
};
|
|
21
24
|
|
|
22
|
-
const SNIPPET_ALIASES = {
|
|
23
|
-
LCP: "CoreWebVitals/LCP",
|
|
24
|
-
CLS: "CoreWebVitals/CLS",
|
|
25
|
-
INP: "CoreWebVitals/INP",
|
|
26
|
-
"LCP-Subparts": "CoreWebVitals/LCP-Subparts",
|
|
27
|
-
fonts: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
|
|
28
|
-
"Fonts-Preloaded-Loaded-and-used-above-the-fold":
|
|
29
|
-
"Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
|
|
30
|
-
// Tier 1 — Loading
|
|
31
|
-
"render-blocking": "Loading/Find-render-blocking-resources",
|
|
32
|
-
"Find-render-blocking-resources": "Loading/Find-render-blocking-resources",
|
|
33
|
-
"resource-hints": "Loading/Resource-Hints-Validation",
|
|
34
|
-
"Resource-Hints-Validation": "Loading/Resource-Hints-Validation",
|
|
35
|
-
"preload-scripts": "Loading/Validate-Preload-Async-Defer-Scripts",
|
|
36
|
-
"Validate-Preload-Async-Defer-Scripts": "Loading/Validate-Preload-Async-Defer-Scripts",
|
|
37
|
-
"priority-hints": "Loading/Priority-Hints-Audit",
|
|
38
|
-
"Priority-Hints-Audit": "Loading/Priority-Hints-Audit",
|
|
39
|
-
"critical-css": "Loading/Critical-CSS-Detection",
|
|
40
|
-
"Critical-CSS-Detection": "Loading/Critical-CSS-Detection",
|
|
41
|
-
ttfb: "Loading/TTFB-Sub-Parts",
|
|
42
|
-
"TTFB-Sub-Parts": "Loading/TTFB-Sub-Parts",
|
|
43
|
-
"script-parties": "Loading/First-And-Third-Party-Script-Info",
|
|
44
|
-
"First-And-Third-Party-Script-Info": "Loading/First-And-Third-Party-Script-Info",
|
|
45
|
-
"script-loading": "Loading/Script-Loading",
|
|
46
|
-
"Script-Loading": "Loading/Script-Loading",
|
|
47
|
-
// Tier 2 — Media
|
|
48
|
-
"lazy-atf": "Loading/Find-Above-The-Fold-Lazy-Loaded-Images",
|
|
49
|
-
"Find-Above-The-Fold-Lazy-Loaded-Images": "Loading/Find-Above-The-Fold-Lazy-Loaded-Images",
|
|
50
|
-
"lazy-conflict": "Loading/Find-Images-With-Lazy-and-Fetchpriority",
|
|
51
|
-
"Find-Images-With-Lazy-and-Fetchpriority": "Loading/Find-Images-With-Lazy-and-Fetchpriority",
|
|
52
|
-
"eager-below-fold": "Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport",
|
|
53
|
-
"Find-non-Lazy-Loaded-Images-outside-of-the-viewport":
|
|
54
|
-
"Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport",
|
|
55
|
-
};
|
|
56
25
|
|
|
57
26
|
const USAGE = `webperf-snippets <url> [options]
|
|
58
27
|
|
|
@@ -60,8 +29,9 @@ Run curated WebPerf Snippets headlessly via Playwright.
|
|
|
60
29
|
|
|
61
30
|
Options:
|
|
62
31
|
--workflow <name> Workflow to run (default: core-web-vitals)
|
|
63
|
-
Workflows: core-web-vitals, audit, loading
|
|
64
|
-
--snippet <name> Run a single snippet by alias or Category/Name path
|
|
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
|
|
65
35
|
Aliases: LCP, CLS, INP, LCP-Subparts, fonts,
|
|
66
36
|
render-blocking, resource-hints, preload-scripts,
|
|
67
37
|
priority-hints, critical-css, ttfb,
|
|
@@ -73,7 +43,8 @@ Options:
|
|
|
73
43
|
--wait <ms> Post-load wait before evaluating (default: 3000)
|
|
74
44
|
--budget-lcp <ms> Exit 1 if LCP exceeds this value
|
|
75
45
|
--budget-cls <score> Exit 1 if CLS exceeds this value
|
|
76
|
-
--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
|
|
77
48
|
Actions: scroll, click, hover, type, wait
|
|
78
49
|
--storage-state <path> Playwright storage state (cookies + localStorage) to
|
|
79
50
|
measure pages that require authentication
|
|
@@ -94,6 +65,7 @@ Examples:
|
|
|
94
65
|
npx webperf-snippets https://web.dev --snippet LCP-Subparts
|
|
95
66
|
npx webperf-snippets https://web.dev --snippet render-blocking
|
|
96
67
|
npx webperf-snippets https://web.dev --snippet fonts
|
|
68
|
+
npx webperf-snippets https://web.dev --snippet Compression-Audit
|
|
97
69
|
npx webperf-snippets https://web.dev --budget-lcp 2500
|
|
98
70
|
npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
|
|
99
71
|
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
@@ -104,12 +76,13 @@ function fail(message, code = 2) {
|
|
|
104
76
|
process.exit(code);
|
|
105
77
|
}
|
|
106
78
|
|
|
107
|
-
function resolveSnippetPath(name) {
|
|
108
|
-
return SNIPPET_ALIASES[name] ?? name;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
79
|
function buildSnippetItem(values) {
|
|
112
|
-
|
|
80
|
+
let path;
|
|
81
|
+
try {
|
|
82
|
+
path = resolveSnippetName(values.snippet);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
fail(err.message);
|
|
85
|
+
}
|
|
113
86
|
return [{ id: values.snippet, path, source: loadSnippet(path) }];
|
|
114
87
|
}
|
|
115
88
|
|
|
@@ -246,8 +219,10 @@ async function main() {
|
|
|
246
219
|
}
|
|
247
220
|
|
|
248
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.
|
|
249
224
|
const anyAuditViolation = payload.results.some(
|
|
250
|
-
(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"),
|
|
251
226
|
);
|
|
252
227
|
process.exit(anyError || anyAuditViolation ? 1 : 0);
|
|
253
228
|
}
|
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/reporters/human.js
CHANGED
|
@@ -100,11 +100,11 @@ function renderAuditResult(r, verbose) {
|
|
|
100
100
|
lines.push("");
|
|
101
101
|
const shown = items.slice(0, MAX_ITEMS);
|
|
102
102
|
for (const item of shown) {
|
|
103
|
-
const name = item.shortName ?? item.resource ?? item.url ?? item.src ?? item.selector ?? item.filename ?? "";
|
|
104
|
-
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 ?? "";
|
|
105
105
|
const timing = item.responseEndMs != null ? `${item.responseEndMs}ms` : item.durationMs != null ? `${item.durationMs}ms` : "";
|
|
106
|
-
const cols = [tag, name, timing].filter(Boolean).join(" ");
|
|
107
|
-
lines.push(` ${styleText("dim", `· ${cols}`)}`);
|
|
106
|
+
const cols = [tag, name, item.detail, timing].filter(Boolean).join(" ");
|
|
107
|
+
if (cols) lines.push(` ${styleText("dim", `· ${cols}`)}`);
|
|
108
108
|
}
|
|
109
109
|
if (items.length > MAX_ITEMS) {
|
|
110
110
|
lines.push(` ${styleText("dim", `… and ${items.length - MAX_ITEMS} more`)}`);
|
|
@@ -118,7 +118,22 @@ function renderAuditResult(r, verbose) {
|
|
|
118
118
|
return lines.join("\n");
|
|
119
119
|
}
|
|
120
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
|
+
|
|
121
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
|
+
|
|
122
137
|
if (r.status === "error") {
|
|
123
138
|
return ` ${styleText("red", "✗")} ${pad(r.id, 16)} ${styleText("dim", r.error)}`;
|
|
124
139
|
}
|
|
@@ -131,6 +146,10 @@ function renderResult(r, verbose) {
|
|
|
131
146
|
return renderAuditResult(r, verbose);
|
|
132
147
|
}
|
|
133
148
|
|
|
149
|
+
if (r.value === undefined && (r.count !== undefined || r.details)) {
|
|
150
|
+
return renderSummaryResult(r);
|
|
151
|
+
}
|
|
152
|
+
|
|
134
153
|
const icon = RATING_ICON[r.rating] ?? "·";
|
|
135
154
|
const valueText = paint(r.rating, formatValue(r.value, r.unit));
|
|
136
155
|
const ratingText = styleText("dim", r.rating ?? "");
|
|
@@ -31,7 +31,12 @@ function renderErrorRow(r) {
|
|
|
31
31
|
return `| ${r.id} | ❌ error | ${r.error} |`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function renderSkippedRow(r) {
|
|
35
|
+
return `| ${r.id} | ⏭ skipped | ${r.reason} |`;
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
function renderRow(r) {
|
|
39
|
+
if (r.status === "skipped") return renderSkippedRow(r);
|
|
35
40
|
if (r.status === "error") return renderErrorRow(r);
|
|
36
41
|
if (Array.isArray(r.issues)) return renderAuditRow(r);
|
|
37
42
|
return renderMetricRow(r);
|