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.
@@ -45,19 +45,28 @@
45
45
 
46
46
  // @shared isFirstParty
47
47
  function isFirstParty(hostname) {
48
- return getRootDomain(hostname) === getRootDomain(location.hostname);
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
- const ownRoots = new Set(
53
- OWN_DOMAINS.map((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]))
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) || ownRoots.has(getRootDomain(u.hostname)) } : null;
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").forEach((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 (corsLimitedAnalysis) {
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
- '%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.',
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
- function findLcpCandidate(imgs) {
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: audited.flatMap((r) =>
363
- r.issues.map((i) => ({
364
- severity: i.s,
365
- message: `${shortSrc(r.src) || "(no src)"}: ${i.msg}`,
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
  })();
@@ -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://cdn.example", "requestCount": 6, "action": "add-preconnect", "recommendedHint": "preconnect" },
678
- { "domain": "https://fonts.example", "requestCount": 2, "action": "remove-dns-prefetch", "recommendedHint": "preconnect" }
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
- return getRootDomain(hostname) === getRootDomain(location.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);
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 before evaluation
46
+ --interact-script <path> JSON file with interactions to run after the load; the
47
+ Interaction snippets are installed first and read last
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
- const path = resolveSnippetPath(values.snippet);
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
  }
@@ -1,28 +1,76 @@
1
- // Declarative follow-ups: if a result matches the predicate, append the step.
1
+ // Declarative follow-ups: if a result matches the predicate, append the steps.
2
2
  // Mirrors the WORKFLOWS.md decision trees from the snippet catalogue.
3
+ //
4
+ // A rule looks at the first results only. The results of a follow-up do not fire rules of their own.
5
+ const ok = (r, id) => r.id === id && r.status === "ok";
6
+
3
7
  export const RULES = [
4
8
  {
5
- when: (r) => r.id === "LCP" && r.status === "ok" && r.value > 2500,
9
+ when: (r) => ok(r, "LCP") && r.value > 2500,
6
10
  append: { id: "LCP-Subparts", path: "CoreWebVitals/LCP-Subparts" },
7
11
  reason: "LCP > 2.5s — drilling into sub-parts",
8
12
  },
9
13
  {
10
- when: (r) => r.id === "TTFB" && r.status === "ok" && r.value > 600,
14
+ when: (r) => ok(r, "LCP") && r.value > 4000,
15
+ append: { id: "LCP-Trail", path: "CoreWebVitals/LCP-Trail" },
16
+ reason: "LCP > 4s — listing the LCP candidates",
17
+ },
18
+ {
19
+ when: (r) => ok(r, "LCP") && r.details?.elementType === "Image",
20
+ append: [
21
+ { id: "LCP-Image-Entropy", path: "CoreWebVitals/LCP-Image-Entropy" },
22
+ { id: "image-audit", path: "Media/Image-Element-Audit" },
23
+ ],
24
+ reason: "LCP is an image — checking its entropy and its attributes",
25
+ },
26
+ {
27
+ when: (r) => ok(r, "LCP") && r.details?.elementType === "Video poster",
28
+ append: [
29
+ { id: "LCP-Video-Candidate", path: "CoreWebVitals/LCP-Video-Candidate" },
30
+ { id: "video-audit", path: "Media/Video-Element-Audit" },
31
+ ],
32
+ reason: "LCP is a video — checking its candidate and its loading",
33
+ },
34
+ {
35
+ when: (r) => ok(r, "CLS") && r.value > 0.1,
36
+ append: { id: "layout-shifts", path: "Interaction/Layout-Shift-Loading-and-Interaction" },
37
+ reason: "CLS > 0.1 — separating the shifts of the load from those of the interactions",
38
+ },
39
+ {
40
+ when: (r) => ok(r, "CLS") && r.value > 0.25,
41
+ append: [
42
+ { id: "lazy-atf", path: "Loading/Find-Above-The-Fold-Lazy-Loaded-Images" },
43
+ { id: "fonts", path: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold" },
44
+ { id: "critical-css", path: "Loading/Critical-CSS-Detection" },
45
+ { id: "image-audit", path: "Media/Image-Element-Audit" },
46
+ ],
47
+ reason: "CLS > 0.25 — looking for the usual causes: lazy images, font swaps, late CSS and images without dimensions",
48
+ },
49
+ {
50
+ when: (r) => ok(r, "TTFB") && r.value > 600,
11
51
  append: { id: "ttfb-subparts", path: "Loading/TTFB-Sub-Parts" },
12
52
  reason: "TTFB > 600ms — drilling into sub-parts",
13
53
  },
14
54
  {
15
- when: (r) => r.id === "FCP" && r.status === "ok" && r.value > 1800,
55
+ when: (r) => ok(r, "FCP") && r.value > 1800,
16
56
  append: { id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
17
57
  reason: "FCP > 1.8s — checking render-blocking resources",
18
58
  },
19
59
  ];
20
60
 
21
- export function nextSteps(results) {
61
+ // The follow-up steps the results call for, each snippet once: a path two rules ask for is added by
62
+ // the first, and a path in `skipPaths` (a step the workflow already ran) is left out.
63
+ export function nextSteps(results, rules = RULES, skipPaths = []) {
64
+ const seen = new Set(skipPaths);
22
65
  const followUps = [];
23
66
  for (const result of results) {
24
- for (const rule of RULES) {
25
- if (rule.when(result)) followUps.push({ ...rule.append, reason: rule.reason });
67
+ for (const rule of rules) {
68
+ if (!rule.when(result)) continue;
69
+ for (const step of [rule.append].flat()) {
70
+ if (seen.has(step.path)) continue;
71
+ seen.add(step.path);
72
+ followUps.push({ ...step, reason: rule.reason });
73
+ }
26
74
  }
27
75
  }
28
76
  return followUps;
@@ -22,9 +22,19 @@ export async function executeStep(page, step) {
22
22
  }
23
23
  }
24
24
 
25
- export async function runInteractions(page, scriptPath) {
26
- const { interactions } = JSON.parse(readFileSync(scriptPath, "utf8"));
27
- for (const step of interactions) {
25
+ // `source` is the path of a JSON file ({ "interactions": [...] }) or the list of steps itself.
26
+ export function loadInteractionSteps(source) {
27
+ return Array.isArray(source) ? source : JSON.parse(readFileSync(source, "utf8")).interactions;
28
+ }
29
+
30
+ // A click or a key press creates the events that Interactions and Input-Latency-Breakdown measure;
31
+ // a scroll, a hover and a wait do not.
32
+ export function isInputStep(step) {
33
+ return step.action === "click" || step.action === "type";
34
+ }
35
+
36
+ export async function runInteractions(page, source) {
37
+ for (const step of loadInteractionSteps(source)) {
28
38
  await executeStep(page, step);
29
39
  }
30
40
  }
@@ -6,7 +6,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
6
6
  const PKG_ROOT = resolve(HERE, "..");
7
7
 
8
8
  // Resolution order: bundled (published package) → workspace root (dev).
9
- const SNIPPET_DIRS = [
9
+ export const SNIPPET_DIRS = [
10
10
  join(PKG_ROOT, "snippets"),
11
11
  resolve(PKG_ROOT, "..", "snippets"),
12
12
  ];
@@ -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);