webperf-snippets 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +120 -5
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +52 -30
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +15 -0
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +55 -25
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +59 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +22 -3
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1176 -0
  59. package/snippets/_shared/helpers.js +44 -0
  60. package/src/bin.js +55 -3
  61. package/src/crux.js +84 -0
  62. package/src/report-to.js +46 -0
  63. package/src/reporters/human.js +20 -1
  64. package/src/reporters/markdown.js +22 -1
  65. package/src/runner.js +4 -2
@@ -0,0 +1,1176 @@
1
+ # Script Return Value Schema
2
+
3
+ All scripts in the skills directory must return a structured JSON object as the IIFE return value. This allows agents using `mcp__chrome-devtools__evaluate_script` to read structured data directly from the return value, rather than parsing human-readable console output.
4
+
5
+ ## Why this matters
6
+
7
+ `evaluate_script` captures **both** the console output **and** the return value of the evaluated expression. Console output (with `%c` CSS styling, emojis, tables) is meant for humans reading DevTools. The return value is meant for agents.
8
+
9
+ ```
10
+ // Agent workflow
11
+ result = evaluate_script(scriptCode) // return value → structured JSON for agent
12
+ get_console_message() // console output → human debugging only
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Base Shape
18
+
19
+ Every script must return an object matching this shape:
20
+
21
+ ```typescript
22
+ {
23
+ // Required in all scripts
24
+ script: string; // Script name, e.g. "LCP", "TTFB", "Script-Loading"
25
+ status: "ok" // Script ran, has data
26
+ | "tracking" // Observer active, data accumulates over time
27
+ | "error" // Failed or no data available
28
+ | "unsupported"; // Browser API not supported
29
+
30
+ // Metric scripts (LCP, CLS, INP, TTFB, FCP)
31
+ metric?: string; // Short metric name: "LCP", "CLS", "INP", "TTFB", "FCP"
32
+ value?: number; // Always a number, never a formatted string
33
+ unit?: "ms" // Milliseconds
34
+ | "score" // Unitless score (CLS)
35
+ | "count" // Integer count
36
+ | "bytes" // Raw bytes
37
+ | "bpp" // Bits per pixel
38
+ | "fps"; // Frames per second
39
+ rating?: "good" | "needs-improvement" | "poor";
40
+ thresholds?: {
41
+ good: number; // Upper bound for "good"
42
+ needsImprovement: number; // Upper bound for "needs-improvement"
43
+ };
44
+
45
+ // Audit/inspection scripts (render-blocking, images, scripts)
46
+ count?: number; // Total number of items found
47
+ items?: object[]; // Array of individual findings
48
+
49
+ // Audit scripts that read resource sizes or timings hidden by Timing-Allow-Origin
50
+ corsLimitedAnalysis?: boolean; // true when at least one cross-origin resource could not be analyzed; the result is a lower bound
51
+
52
+ // Script-specific structured data
53
+ details?: object;
54
+
55
+ // Issues detected (for audit scripts)
56
+ issues?: Array<{
57
+ severity: "error" | "warning" | "info";
58
+ message: string;
59
+ }>;
60
+
61
+ // Tracking scripts (status: "tracking")
62
+ message?: string; // Human-readable status message
63
+ getDataFn?: string; // window function to call for data: evaluate_script(`${getDataFn}()`). May be a dotted path such as "loafHelpers.getData"
64
+
65
+ // Error info (status: "error" or "unsupported")
66
+ error?: string;
67
+ }
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Execution Patterns
73
+
74
+ ### Pattern 1: Fully synchronous
75
+
76
+ Scripts that read DOM or `performance.getEntriesByType()` directly. Return JSON at the end of the IIFE.
77
+
78
+ ```js
79
+ // Example: TTFB.js
80
+ (() => {
81
+ const [nav] = performance.getEntriesByType("navigation");
82
+ if (!nav) return { script: "TTFB", status: "error", error: "No navigation entry" };
83
+
84
+ const value = Math.round(nav.responseStart);
85
+ const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor";
86
+
87
+ // Human output
88
+ console.log(`TTFB: ${value}ms (${rating})`);
89
+
90
+ // Agent output
91
+ return {
92
+ script: "TTFB",
93
+ status: "ok",
94
+ metric: "TTFB",
95
+ value,
96
+ unit: "ms",
97
+ rating,
98
+ thresholds: { good: 800, needsImprovement: 1800 },
99
+ };
100
+ })();
101
+ ```
102
+
103
+ **Scripts using this pattern:** TTFB, TTFB-Sub-Parts, FCP, Find-render-blocking-resources, Script-Loading, LCP-Video-Candidate, Resource-Hints, Resource-Hints-Validation, Priority-Hints-Audit, Validate-Preload-Async-Defer-Scripts, Fonts-Preloaded, Service-Worker-Analysis, Back-Forward-Cache, Content-Visibility, Critical-CSS-Detection, Inline-CSS-Info-and-Size, Inline-Script-Info-and-Size, First-And-Third-Party-Script-Info, First-And-Third-Party-Script-Timings, Compression-Audit, Third-Party-Impact-by-Domain, JS-Execution-Time-Breakdown, CSS-Media-Queries-Analysis, Client-Side-Redirect-Detection, SSR-Hydration-Data-Analysis, Network-Bandwidth-Connection-Quality, Find-Above-The-Fold-Lazy-Loaded-Images, Find-Images-With-Lazy-and-Fetchpriority, Find-non-Lazy-Loaded-Images-outside-of-the-viewport, SVG-Embedded-Bitmap-Analysis, Prefetch-Resource-Validation, TTFB-Resources.
104
+
105
+ ### Pattern 2: Buffered observer
106
+
107
+ Chrome exposes some entry types only through a `PerformanceObserver`: `largest-contentful-paint`, `layout-shift`, `longtask`, `event` and `first-input`. `performance.getEntriesByType()` returns `[]` for them, so a synchronous read gives an empty result. Collect them with a buffered observer and wait briefly for it to deliver, which makes the script async (Pattern 4).
108
+
109
+ Entry types that `getEntriesByType()` does return can be read synchronously: `navigation`, `resource`, `paint`, `mark`, `measure` and `long-animation-frame`.
110
+
111
+ ```js
112
+ // Example: LCP.js
113
+ (async () => {
114
+ if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
115
+ return { script: "LCP", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
116
+ }
117
+
118
+ // Collect buffered entries; 100ms lets the observer process them on busy pages
119
+ const lastEntry = await new Promise((resolve) => {
120
+ const entries = [];
121
+ const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
122
+ obs.observe({ type: "largest-contentful-paint", buffered: true });
123
+ setTimeout(() => { obs.disconnect(); resolve(entries.at(-1) ?? null); }, 100);
124
+ });
125
+ if (!lastEntry) return { script: "LCP", status: "error", error: "No LCP entries yet" };
126
+
127
+ const activationStart = performance.getEntriesByType("navigation")[0]?.activationStart ?? 0;
128
+ const value = Math.round(Math.max(0, lastEntry.startTime - activationStart));
129
+ const rating = value <= 2500 ? "good" : value <= 4000 ? "needs-improvement" : "poor";
130
+
131
+ return {
132
+ script: "LCP", status: "ok", metric: "LCP", value, unit: "ms", rating,
133
+ thresholds: { good: 2500, needsImprovement: 4000 },
134
+ details: { element: selector, elementType: type, url: lastEntry.url, sizePixels: lastEntry.size }
135
+ };
136
+ })();
137
+ ```
138
+
139
+ Always check `PerformanceObserver.supportedEntryTypes` first. On browsers without the entry type a script must return `status: "unsupported"`, not a value that looks like a passing result.
140
+
141
+ The repository enforces this with an ESLint rule (`no-restricted-syntax` in `eslint.config.mjs`) that rejects `getEntriesByType()` for the observer-only types.
142
+
143
+ **Scripts using this pattern:** LCP, CLS, LCP-Subparts, LCP-Trail, LCP-Image-Entropy, LongTask, Layout-Shift-Loading-and-Interaction.
144
+
145
+ ### Pattern 3: Tracking observers
146
+
147
+ Scripts that observe ongoing user interactions cannot return meaningful data synchronously. They return `status: "tracking"` immediately, and expose a `window.getXxx()` function for agents to call later.
148
+
149
+ ```js
150
+ // Return at the end of the IIFE:
151
+ return {
152
+ script: "INP",
153
+ status: "tracking",
154
+ message: "INP tracking active. Interact with the page then call getINP() for results.",
155
+ getDataFn: "getINP",
156
+ };
157
+ ```
158
+
159
+ **Agent workflow for tracking scripts:**
160
+
161
+ ```
162
+ 1. evaluate_script(INP.js) → { status: "tracking", getDataFn: "getINP" }
163
+ 2. (user interacts with the page)
164
+ 3. evaluate_script("getINP()") → { script: "INP", status: "ok", value: 350, rating: "needs-improvement", ... }
165
+ ```
166
+
167
+ **The window function must also return a structured object** matching the same schema, including when nothing has been recorded yet:
168
+
169
+ - `status: "ok"` with `count: 0` when zero is a valid result (no long tasks, no long animation frames).
170
+ - `status: "error"` with an `error` message when there is nothing to report yet and the agent needs to act first (no interactions recorded).
171
+
172
+ **Scripts using this pattern:** INP, Interactions, Input-Latency-Breakdown, Layout-Shift-Loading-and-Interaction, Scroll-Performance, Long-Animation-Frames (ongoing tracking), LongTask (ongoing tracking), Long-Animation-Frames-Script-Attribution.
173
+
174
+ ### Pattern 4: Async scripts
175
+
176
+ Scripts that use `async/await` or `setTimeout`. The IIFE returns a Promise, which `evaluate_script` can await (Chrome DevTools `awaitPromise`).
177
+
178
+ Keep the existing `async () => {}` wrapper. Add a `return` statement with structured data at the end. The agent receives the resolved value.
179
+
180
+ A script wrapped in `void (async () => {...})()` discards its return value. Never use `void` on the IIFE.
181
+
182
+ **Scripts using this pattern:** Image-Element-Audit (fetches content-type headers), Video-Element-Audit, SVG-Embedded-Bitmap-Analysis, Service-Worker-Analysis, and every script that collects observer-only entry types (Pattern 2).
183
+
184
+ ---
185
+
186
+ ## Script-Specific Schemas
187
+
188
+ ### Core Web Vitals
189
+
190
+ #### LCP
191
+
192
+ ```json
193
+ {
194
+ "script": "LCP",
195
+ "status": "ok",
196
+ "metric": "LCP",
197
+ "value": 1240,
198
+ "unit": "ms",
199
+ "rating": "good",
200
+ "thresholds": { "good": 2500, "needsImprovement": 4000 },
201
+ "details": {
202
+ "element": "img.hero",
203
+ "elementType": "Image",
204
+ "url": "https://web.dev/hero.jpg",
205
+ "sizePixels": 756000
206
+ }
207
+ }
208
+ ```
209
+
210
+ #### CLS
211
+
212
+ Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
213
+
214
+ ```json
215
+ {
216
+ "script": "CLS",
217
+ "status": "ok",
218
+ "metric": "CLS",
219
+ "value": 0.05,
220
+ "unit": "score",
221
+ "rating": "good",
222
+ "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
223
+ "message": "CLS tracking active. Call getCLS() for updated value after page interactions.",
224
+ "getDataFn": "getCLS"
225
+ }
226
+ ```
227
+
228
+ `getCLS()` returns the same shape with the latest accumulated value.
229
+
230
+ #### INP (tracking)
231
+
232
+ ```json
233
+ {
234
+ "script": "INP",
235
+ "status": "tracking",
236
+ "message": "INP tracking active. Interact with the page then call getINP() for results.",
237
+ "getDataFn": "getINP"
238
+ }
239
+ ```
240
+
241
+ `getINP()` returns:
242
+
243
+ ```json
244
+ {
245
+ "script": "INP",
246
+ "status": "ok",
247
+ "metric": "INP",
248
+ "value": 350,
249
+ "unit": "ms",
250
+ "rating": "needs-improvement",
251
+ "thresholds": { "good": 200, "needsImprovement": 500 },
252
+ "details": {
253
+ "totalInteractions": 5,
254
+ "worstEvent": "click -> button.submit",
255
+ "phases": { "inputDelay": 120, "processingTime": 180, "presentationDelay": 50 }
256
+ }
257
+ }
258
+ ```
259
+
260
+ If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
261
+
262
+ `getINPDetails()` returns the full sorted interaction list (array of up to 15 entries). Use when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions:
263
+
264
+ ```json
265
+ [
266
+ {
267
+ "formattedName": "click → button.submit",
268
+ "duration": 450,
269
+ "startTime": 1200,
270
+ "phases": { "inputDelay": 120, "processingTime": 280, "presentationDelay": 50 }
271
+ }
272
+ ]
273
+ ```
274
+
275
+ #### LCP-Subparts
276
+
277
+ ```json
278
+ {
279
+ "script": "LCP-Subparts",
280
+ "status": "ok",
281
+ "metric": "LCP",
282
+ "value": 2100,
283
+ "unit": "ms",
284
+ "rating": "needs-improvement",
285
+ "thresholds": { "good": 2500, "needsImprovement": 4000 },
286
+ "details": {
287
+ "element": "img.hero",
288
+ "url": "hero.jpg",
289
+ "subParts": {
290
+ "ttfb": { "value": 450, "percent": 21, "overTarget": false },
291
+ "resourceLoadDelay": { "value": 120, "percent": 6, "overTarget": false },
292
+ "resourceLoadTime": { "value": 1200, "percent": 57, "overTarget": true },
293
+ "elementRenderDelay": { "value": 330, "percent": 16, "overTarget": true }
294
+ },
295
+ "slowestPhase": "resourceLoadTime"
296
+ }
297
+ }
298
+ ```
299
+
300
+ #### LCP-Trail
301
+
302
+ ```json
303
+ {
304
+ "script": "LCP-Trail",
305
+ "status": "ok",
306
+ "metric": "LCP",
307
+ "value": 1240,
308
+ "unit": "ms",
309
+ "rating": "good",
310
+ "thresholds": { "good": 2500, "needsImprovement": 4000 },
311
+ "details": {
312
+ "candidateCount": 2,
313
+ "finalElement": "img.hero",
314
+ "candidates": [
315
+ { "index": 1, "selector": "h1", "time": 800, "elementType": "Text block" },
316
+ {
317
+ "index": 2,
318
+ "selector": "img.hero",
319
+ "time": 1240,
320
+ "elementType": "Image",
321
+ "url": "hero.jpg"
322
+ }
323
+ ]
324
+ }
325
+ }
326
+ ```
327
+
328
+ #### LCP-Image-Entropy
329
+
330
+ `items` holds the 50 most relevant images: the LCP image first, then the low-entropy ones, then the lowest bits per pixel. `count` and `details` cover every image.
331
+
332
+ ```json
333
+ {
334
+ "script": "LCP-Image-Entropy",
335
+ "status": "ok",
336
+ "count": 5,
337
+ "details": {
338
+ "totalImages": 5,
339
+ "lowEntropyCount": 1,
340
+ "lcpImageEligible": true,
341
+ "lcpImage": {
342
+ "url": "hero.jpg",
343
+ "bpp": 1.65,
344
+ "isLowEntropy": false
345
+ }
346
+ },
347
+ "items": [
348
+ {
349
+ "url": "hero.jpg",
350
+ "width": 1200,
351
+ "height": 630,
352
+ "fileSizeBytes": 156000,
353
+ "bpp": 1.65,
354
+ "isLowEntropy": false,
355
+ "lcpEligible": true,
356
+ "isLCP": true
357
+ }
358
+ ],
359
+ "issues": []
360
+ }
361
+ ```
362
+
363
+ #### LCP-Video-Candidate
364
+
365
+ ```json
366
+ {
367
+ "script": "LCP-Video-Candidate",
368
+ "status": "ok",
369
+ "metric": "LCP",
370
+ "value": 1800,
371
+ "unit": "ms",
372
+ "rating": "good",
373
+ "thresholds": { "good": 2500, "needsImprovement": 4000 },
374
+ "details": {
375
+ "isVideo": true,
376
+ "posterUrl": "https://web.dev/hero.avif",
377
+ "posterFormat": "avif",
378
+ "posterPreloaded": true,
379
+ "fetchpriorityOnPreload": "high",
380
+ "isCrossOrigin": false,
381
+ "videoAttributes": { "autoplay": true, "muted": true, "playsinline": true, "preload": "auto" }
382
+ },
383
+ "issues": []
384
+ }
385
+ ```
386
+
387
+ ### Loading
388
+
389
+ #### TTFB
390
+
391
+ ```json
392
+ {
393
+ "script": "TTFB",
394
+ "status": "ok",
395
+ "metric": "TTFB",
396
+ "value": 245,
397
+ "unit": "ms",
398
+ "rating": "good",
399
+ "thresholds": { "good": 800, "needsImprovement": 1800 }
400
+ }
401
+ ```
402
+
403
+ #### TTFB-Sub-Parts
404
+
405
+ ```json
406
+ {
407
+ "script": "TTFB-Sub-Parts",
408
+ "status": "ok",
409
+ "metric": "TTFB",
410
+ "value": 245,
411
+ "unit": "ms",
412
+ "rating": "good",
413
+ "thresholds": { "good": 800, "needsImprovement": 1800 },
414
+ "details": {
415
+ "subParts": {
416
+ "redirectWait": { "value": 0, "unit": "ms" },
417
+ "serviceWorkerCache": { "value": 0, "unit": "ms" },
418
+ "dnsLookup": { "value": 5, "unit": "ms" },
419
+ "tcpConnection": { "value": 30, "unit": "ms" },
420
+ "sslTls": { "value": 45, "unit": "ms" },
421
+ "serverResponse": { "value": 165, "unit": "ms" }
422
+ },
423
+ "slowestPhase": "serverResponse"
424
+ }
425
+ }
426
+ ```
427
+
428
+ #### Find-render-blocking-resources
429
+
430
+ ```json
431
+ {
432
+ "script": "Find-render-blocking-resources",
433
+ "status": "ok",
434
+ "count": 3,
435
+ "details": {
436
+ "totalBlockingUntilMs": 450,
437
+ "totalSizeBytes": 135000,
438
+ "byType": { "link": 2, "script": 1 }
439
+ },
440
+ "items": [
441
+ {
442
+ "type": "link",
443
+ "url": "https://web.dev/style.css",
444
+ "shortName": "style.css",
445
+ "responseEndMs": 450,
446
+ "durationMs": 200,
447
+ "sizeBytes": 45000
448
+ }
449
+ ]
450
+ }
451
+ ```
452
+
453
+ #### Script-Loading
454
+
455
+ `items` holds the 50 scripts to look at first: render-blocking, then third-party, then the largest. `count` and `details` cover every script. `corsLimitedAnalysis` is `true` when `details.sizeUnknownCount` is above zero, so `details.totalSizeBytes` is a lower bound.
456
+
457
+ ```json
458
+ {
459
+ "script": "Script-Loading",
460
+ "status": "ok",
461
+ "count": 8,
462
+ "rating": "needs-improvement",
463
+ "details": {
464
+ "totalSizeBytes": 245000,
465
+ "sizeUnknownCount": 0,
466
+ "byStrategy": { "blocking": 2, "async": 4, "defer": 1, "module": 1 },
467
+ "byParty": { "firstParty": 5, "thirdParty": 3 },
468
+ "thirdPartyBlockingCount": 1
469
+ },
470
+ "items": [
471
+ {
472
+ "url": "https://web.dev/app.js",
473
+ "shortName": "app.js",
474
+ "strategy": "blocking",
475
+ "location": "head",
476
+ "party": "first",
477
+ "sizeBytes": 85000,
478
+ "sizeKnown": true,
479
+ "durationMs": 120
480
+ }
481
+ ],
482
+ "issues": [
483
+ { "severity": "error", "message": "2 blocking scripts in <head>" },
484
+ { "severity": "error", "message": "1 third-party blocking script" }
485
+ ]
486
+ }
487
+ ```
488
+
489
+ #### Compression-Audit
490
+
491
+ Synchronous. `count` is the number of uncompressed text resources; `items` holds the 50 with the largest estimated savings, while `details` totals the whole set. A cross-origin resource without `Timing-Allow-Origin` reports zero for every size, so it is counted in `sizeUnknownCount` (left out of `items`) and `corsLimitedAnalysis` is `true`. Resources under 1 KB are counted in `skippedSmallCount`, so the counts add up to `totalTextResources`. `estimatedSavingsBytes` applies a typical gzip/brotli reduction (70% for JS, CSS, HTML, JSON, SVG and XML; 50% for TTF/OTF), so it is an estimate.
492
+
493
+ ```json
494
+ {
495
+ "script": "Compression-Audit",
496
+ "status": "ok",
497
+ "count": 1,
498
+ "corsLimitedAnalysis": true,
499
+ "details": {
500
+ "totalTextResources": 6,
501
+ "compressedCount": 4,
502
+ "uncompressedCount": 1,
503
+ "sizeUnknownCount": 1,
504
+ "skippedSmallCount": 0,
505
+ "totalUncompressedBytes": 61440,
506
+ "estimatedSavingsBytes": 43008,
507
+ "byEncoding": { "br": 3, "gzip": 1, "none": 1 },
508
+ "contentEncodingSupported": true
509
+ },
510
+ "items": [
511
+ {
512
+ "url": "https://web.dev/app.css",
513
+ "shortName": "app.css",
514
+ "type": "css",
515
+ "encoding": "none",
516
+ "encodedBytes": 61440,
517
+ "decodedBytes": 61440,
518
+ "estimatedSavingsBytes": 43008
519
+ }
520
+ ],
521
+ "issues": [
522
+ { "severity": "warning", "message": "1 text resource(s) served without compression; enabling gzip or brotli could save about 42.0 KB" },
523
+ { "severity": "info", "message": "1 cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown" }
524
+ ]
525
+ }
526
+ ```
527
+
528
+ #### Third-Party-Impact-by-Domain
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`.
531
+
532
+ ```json
533
+ {
534
+ "script": "Third-Party-Impact-by-Domain",
535
+ "status": "ok",
536
+ "count": 2,
537
+ "corsLimitedAnalysis": true,
538
+ "details": {
539
+ "thirdPartyRequests": 3,
540
+ "thirdPartyBytes": 5120,
541
+ "thirdPartyLoafMs": 128,
542
+ "firstPartyRequests": 1,
543
+ "blockingDomains": 1,
544
+ "loafSupported": true,
545
+ "sizeUnknownCount": 1
546
+ },
547
+ "items": [
548
+ { "domain": "tagmanager.example", "requests": 2, "transferBytes": 5120, "sizeUnknownCount": 0, "renderBlocking": true, "loafMs": 128, "loafScripts": 1 },
549
+ { "domain": "metrics.example", "requests": 1, "transferBytes": 0, "sizeUnknownCount": 1, "renderBlocking": false, "loafMs": 0, "loafScripts": 0 }
550
+ ],
551
+ "issues": [
552
+ { "severity": "warning", "message": "1 third-party domain(s) serve render-blocking resources (tagmanager.example); load them async or defer, or self-host them" },
553
+ { "severity": "info", "message": "1 third-party request(s) without Timing-Allow-Origin report zero sizes, so the transfer size is a lower bound" }
554
+ ]
555
+ }
556
+ ```
557
+
558
+ #### Service-Worker-Analysis
559
+
560
+ Async. Returns one item per registration. `cacheHitRate` is computed over the resources whose source is known: a cross-origin resource without `Timing-Allow-Origin` reports zero for every size, so it is counted in `fromUnknown` and left out of the rate. `rating` is present only when a rate exists, and `cacheStorage` lists the 20 largest caches (`cacheStorageCount` is the total). With no registration the script returns `status: "ok"`, `count: 0` and an info issue.
561
+
562
+ ```json
563
+ {
564
+ "script": "Service-Worker-Analysis",
565
+ "status": "ok",
566
+ "count": 1,
567
+ "rating": "needs-improvement",
568
+ "details": {
569
+ "controlled": true,
570
+ "controllerState": "activated",
571
+ "swOverheadMs": 0.8,
572
+ "totalResources": 12,
573
+ "swIntercepted": 3,
574
+ "notIntercepted": 9,
575
+ "fromCache": 1,
576
+ "fromNetwork": 1,
577
+ "fromUnknown": 1,
578
+ "cacheHitRate": 50,
579
+ "savedBytes": 650,
580
+ "cacheStorageCount": 1,
581
+ "cacheStorage": [{ "name": "precache", "entries": 1 }]
582
+ },
583
+ "items": [
584
+ {
585
+ "scope": "https://web.dev/",
586
+ "scriptURL": "https://web.dev/sw.js",
587
+ "state": "active",
588
+ "hasWaiting": false,
589
+ "hasInstalling": false,
590
+ "navigationPreloadEnabled": false,
591
+ "navigationPreloadHeaderValue": "true"
592
+ }
593
+ ],
594
+ "issues": [
595
+ { "severity": "warning", "message": "Moderate SW cache hit rate (50%). Review the caching strategy for more resources." },
596
+ { "severity": "info", "message": "1 intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them." }
597
+ ]
598
+ }
599
+ ```
600
+
601
+ #### JS-Execution-Time-Breakdown
602
+
603
+ `criticalBundles` lists the scripts over 1 MB decoded (at most 20, largest first). `items` holds the first 50 scripts, and a script whose size is hidden by a missing `Timing-Allow-Origin` header has `corsRestricted: true`, which sets `corsLimitedAnalysis` to `true`.
604
+
605
+ ```json
606
+ {
607
+ "script": "JS-Execution-Time-Breakdown",
608
+ "status": "ok",
609
+ "count": 2,
610
+ "details": {
611
+ "blockingCount": 1,
612
+ "nonBlockingCount": 1,
613
+ "totalTransferBytes": 1300644,
614
+ "totalDecodedBytes": 1300044,
615
+ "totalDownloadMs": 6,
616
+ "totalEstParseMobileMs": 1270,
617
+ "splitCandidatesCount": 1,
618
+ "domInteractiveMs": 9,
619
+ "domContentLoadedMs": 9,
620
+ "loadEventMs": 9,
621
+ "criticalBundles": [
622
+ { "shortName": "big.js", "decodedBytes": 1300024, "transferBytes": 1300324, "estimatedParseMobileMs": 1270, "estimatedParseDesktopMs": 423 }
623
+ ]
624
+ },
625
+ "items": [
626
+ { "shortName": "big.js", "isBlocking": true, "downloadMs": 3, "estimatedParseMobileMs": 1270, "transferBytes": 1300324, "decodedBytes": 1300024, "corsRestricted": false }
627
+ ],
628
+ "issues": [{ "severity": "error", "message": "1 render-blocking script(s) delay HTML parsing" }]
629
+ }
630
+ ```
631
+
632
+ #### FCP
633
+
634
+ `value` is measured from `activationStart` on prerendered pages. `details` splits it into three phases that add up to `value`: TTFB, the wait for the last render-blocking resource, and the rest until the paint. `items` lists the render-blocking resources, the last to finish first (at most 50).
635
+
636
+ ```json
637
+ {
638
+ "script": "FCP",
639
+ "status": "ok",
640
+ "metric": "FCP",
641
+ "value": 1240,
642
+ "unit": "ms",
643
+ "rating": "good",
644
+ "thresholds": { "good": 1800, "needsImprovement": 3000 },
645
+ "details": { "ttfbMs": 180, "renderBlockingLoadMs": 620, "renderDelayMs": 440, "blockingResourceCount": 2 },
646
+ "items": [{ "url": "app.css", "type": "CSS", "durationMs": 310 }]
647
+ }
648
+ ```
649
+
650
+ #### Resource-Hints
651
+
652
+ `items` holds at most 50 hints, those with an error or a warning first, and `count` is the total. `details.sizeUnknownCount` counts the third-party resources whose size is hidden by a missing `Timing-Allow-Origin` header; `corsLimitedAnalysis` is `true` when it is above zero, and the `sizeBytes` of `missingPreconnects` is then a lower bound. `details.missingPreconnects` lists the third-party origins without a preconnect (at most 20). `details.missingPreconnectsCount` is the total.
653
+
654
+ ```json
655
+ {
656
+ "script": "Resource-Hints",
657
+ "status": "ok",
658
+ "details": {
659
+ "byType": { "preload": 2, "preconnect": 1 },
660
+ "missingPreconnectsCount": 1,
661
+ "missingPreconnects": [
662
+ { "origin": "https://cdn.example", "requestCount": 6, "sizeBytes": 84000, "resourceTypes": ["script", "img"], "recommendedHint": "preconnect" }
663
+ ]
664
+ }
665
+ }
666
+ ```
667
+
668
+ #### Resource-Hints-Validation
669
+
670
+ `items` lists the origins that need a hint and the origins that have both `preconnect` and `dns-prefetch` (at most 50).
671
+
672
+ ```json
673
+ {
674
+ "script": "Resource-Hints-Validation",
675
+ "status": "ok",
676
+ "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
+ ]
680
+ }
681
+ ```
682
+
683
+ #### Priority-Hints-Audit and Validate-Preload-Async-Defer-Scripts
684
+
685
+ Issues keep `{ severity, message }`. The fix is the last sentence of the message, after `Fix:`. `Validate-Preload-Async-Defer-Scripts` returns one item per script, an issue replacing the valid entry of the same URL (at most 50).
686
+
687
+ ```json
688
+ {
689
+ "script": "Validate-Preload-Async-Defer-Scripts",
690
+ "status": "ok",
691
+ "items": [{ "url": "app.js", "type": "blocking", "strategy": "defer", "location": "head", "reviewNote": "" }],
692
+ "issues": [{ "severity": "warning", "message": "app.js is preloaded and loaded with defer. Fix: remove the preload or the defer." }]
693
+ }
694
+ ```
695
+
696
+ #### Fonts-Preloaded-Loaded-and-used-above-the-fold
697
+
698
+ `items` lists the loaded fonts and `usedFonts` the fonts used above the fold, the two lists the Visualizer reads. `details.preloadedFonts` lists the preload links (at most 20).
699
+
700
+ ```json
701
+ {
702
+ "script": "Fonts-Preloaded-Loaded-and-used-above-the-fold",
703
+ "status": "ok",
704
+ "count": 1,
705
+ "details": {
706
+ "preloadedCount": 1,
707
+ "loadedCount": 1,
708
+ "usedAboveFoldCount": 1,
709
+ "preloadedNotUsedCount": 0,
710
+ "usedNotPreloadedCount": 0,
711
+ "preloadedFonts": [{ "family": "inter", "href": "https://cdn.example/inter.woff2", "fontType": "font/woff2", "crossorigin": "anonymous", "thirdParty": true }]
712
+ },
713
+ "items": [{ "family": "Inter", "weight": "400", "style": "normal", "display": "swap" }],
714
+ "usedFonts": [{ "family": "Inter", "weight": "400", "style": "normal", "elements": 12 }]
715
+ }
716
+ ```
717
+
718
+ #### SSR-Hydration-Data-Analysis
719
+
720
+ `details.frameworks` summarizes each detected framework. `items` lists the Next.js props and the Astro islands with one shape, the biggest first (at most 30). `flags` holds `large-array`, `deeply-nested` or `sensitive-key`, and `detail` is the client directive of an island. Only key names are checked for sensitive data, so a value that contains the word "token" is not flagged.
721
+
722
+ ```json
723
+ {
724
+ "script": "SSR-Hydration-Data-Analysis",
725
+ "status": "ok",
726
+ "count": 1,
727
+ "details": {
728
+ "frameworksFound": 1,
729
+ "totalHydrationBytes": 4200,
730
+ "otherInlineBytes": 900,
731
+ "hasExceedingThreshold": false,
732
+ "frameworks": [{ "name": "Next.js", "sizeBytes": 4200, "thresholdBytes": 51200, "exceedsThreshold": false }]
733
+ },
734
+ "items": [
735
+ { "framework": "Next.js", "kind": "prop", "name": "rows", "sizeBytes": 3100, "flags": ["large-array"], "detail": null },
736
+ { "framework": "Astro", "kind": "island", "name": "Counter.js", "sizeBytes": 20, "flags": [], "detail": "load" }
737
+ ],
738
+ "issues": [{ "severity": "warning", "message": "pageProps has keys that look sensitive (session.token). Everything in pageProps is sent to every visitor." }]
739
+ }
740
+ ```
741
+
742
+ #### CSS-Media-Queries-Analysis
743
+
744
+ `details.performanceImpact` estimates the cost of the desktop-only CSS for each device profile, in numbers rounded to one decimal. It is `null` when there is no desktop-only CSS. The detailed console report stays opt-in through `analyzeCSSPerformanceImpact()`.
745
+
746
+ ```json
747
+ {
748
+ "script": "CSS-Media-Queries-Analysis",
749
+ "status": "ok",
750
+ "count": 2,
751
+ "details": {
752
+ "corsBlockedCount": 0,
753
+ "performanceImpact": {
754
+ "unnecessaryBytes": 1200,
755
+ "totalClasses": 4,
756
+ "totalProperties": 9,
757
+ "deviceImpact": {
758
+ "Mid-range (Moto G Power, iPhone SE)": { "renderBlockingTimeMs": 3.1, "runtimeOverheadMs": 0.1, "fcpImpactMs": 1.9, "lcpImpactMs": 1.2, "inpOverheadMs": 0.1 }
759
+ }
760
+ }
761
+ }
762
+ }
763
+ ```
764
+
765
+ #### Content-Visibility
766
+
767
+ With no usage the script returns `count: 0`, no items and an `info` issue.
768
+
769
+ ```json
770
+ {
771
+ "script": "Content-Visibility",
772
+ "status": "ok",
773
+ "count": 0,
774
+ "details": { "autoCount": 0, "hiddenCount": 0 },
775
+ "items": [],
776
+ "issues": [{ "severity": "info", "message": "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
777
+ }
778
+ ```
779
+
780
+ ### Interaction
781
+
782
+ Tracking scripts return `status: "tracking"` first. The function named by `getDataFn` returns the full result. Items are capped at 50 (20 for frames) and sorted by relevance; `count` holds the total.
783
+
784
+ #### Interactions (tracking)
785
+
786
+ ```json
787
+ {
788
+ "script": "Interactions",
789
+ "status": "tracking",
790
+ "message": "Tracking interactions. Interact with the page then call getInteractionSummary() for results.",
791
+ "getDataFn": "getInteractionSummary"
792
+ }
793
+ ```
794
+
795
+ `getInteractionSummary()`:
796
+
797
+ ```json
798
+ {
799
+ "script": "Interactions",
800
+ "status": "ok",
801
+ "count": 5,
802
+ "details": {
803
+ "totalInteractions": 5,
804
+ "worstMs": 312,
805
+ "avgMs": 140,
806
+ "p75Ms": 210,
807
+ "byRating": { "good": 3, "needs-improvement": 1, "poor": 1 }
808
+ },
809
+ "items": [{ "type": "click", "durationMs": 312, "rating": "needs-improvement" }],
810
+ "issues": [{ "severity": "warning", "message": "click interaction took 312ms (needs-improvement), above the 200ms threshold" }]
811
+ }
812
+ ```
813
+
814
+ `items` is sorted slowest first, and `issues` lists at most the 10 slowest interactions that are not good.
815
+
816
+ #### Input-Latency-Breakdown (tracking)
817
+
818
+ ```json
819
+ {
820
+ "script": "Input-Latency-Breakdown",
821
+ "status": "tracking",
822
+ "message": "Tracking input latency by event type. Interact with the page then call getInputLatencyBreakdown().",
823
+ "getDataFn": "getInputLatencyBreakdown"
824
+ }
825
+ ```
826
+
827
+ `getInputLatencyBreakdown()` returns `status: "error"` until an interaction is recorded. Then:
828
+
829
+ ```json
830
+ {
831
+ "script": "Input-Latency-Breakdown",
832
+ "status": "ok",
833
+ "count": 2,
834
+ "details": { "eventTypes": { "click": { "count": 3, "p75Ms": 260, "inputDelayMs": 4, "processingMs": 240, "presentationMs": 16 } } },
835
+ "items": [{ "type": "click", "count": 3, "p75Ms": 260, "inputDelayMs": 4, "processingMs": 240, "presentationMs": 16, "rating": "needs-improvement" }],
836
+ "issues": [{ "severity": "warning", "message": "click P75 is 260ms (needs-improvement). Bottleneck: Processing. Fix: optimize the event handlers or debounce them." }]
837
+ }
838
+ ```
839
+
840
+ #### Forced-Synchronous-Layout (tracking)
841
+
842
+ ```json
843
+ {
844
+ "script": "Forced-Synchronous-Layout",
845
+ "status": "tracking",
846
+ "count": 0,
847
+ "message": "FSL Detector active. Reproduce the interaction then call getFSLSummary() to inspect results.",
848
+ "getDataFn": "getFSLSummary",
849
+ "stopFn": "stopFSLDetector"
850
+ }
851
+ ```
852
+
853
+ `getFSLSummary()` groups the events by property, access type and element:
854
+
855
+ ```json
856
+ {
857
+ "script": "Forced-Synchronous-Layout",
858
+ "status": "ok",
859
+ "count": 30,
860
+ "details": { "byProperty": { "offsetWidth": 15, "getBoundingClientRect()": 15 }, "byElement": { "div#box": 30 }, "fastestSinceLastMutationMs": 0.02 },
861
+ "items": [{ "property": "offsetWidth", "accessType": "read", "element": "div#box", "count": 15, "fastestSinceLastMutationMs": 0.02 }],
862
+ "issues": [{ "severity": "error", "message": "offsetWidth triggered 15 forced synchronous layouts. Read layout properties before writing to the DOM." }]
863
+ }
864
+ ```
865
+
866
+ The detector sees mutations made through `classList`, `setAttribute`, `style.setProperty` and `style.cssText`.
867
+
868
+ #### Layout-Shift-Loading-and-Interaction
869
+
870
+ Immediately returns the buffered CLS (the largest session window), plus a summary function for ongoing tracking. `details.topElements` lists the five elements that shifted the most, and `getLayoutShiftSummary()` returns the same value.
871
+
872
+ ```json
873
+ {
874
+ "script": "Layout-Shift-Loading-and-Interaction",
875
+ "status": "tracking",
876
+ "metric": "CLS",
877
+ "value": 0.08,
878
+ "unit": "score",
879
+ "rating": "good",
880
+ "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
881
+ "details": {
882
+ "currentCLS": 0.08,
883
+ "shiftCount": 3,
884
+ "countedShifts": 3,
885
+ "excludedShifts": 0,
886
+ "topElements": [{ "selector": "#banner", "shiftCount": 2, "totalImpact": 0.06 }]
887
+ },
888
+ "message": "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
889
+ "getDataFn": "getLayoutShiftSummary"
890
+ }
891
+ ```
892
+
893
+ #### Long-Animation-Frames
894
+
895
+ Returns buffered LoAF data immediately. Ongoing tracking continues. `items` holds the 20 frames with the most blocking time, in the same shape at start and in `getLoAFSummary()`, each with its 10 slowest scripts.
896
+
897
+ ```json
898
+ {
899
+ "script": "Long-Animation-Frames",
900
+ "status": "tracking",
901
+ "count": 3,
902
+ "details": {
903
+ "totalLoAFs": 3,
904
+ "withBlockingTime": 2,
905
+ "totalBlockingTimeMs": 280,
906
+ "worstBlockingMs": 180
907
+ },
908
+ "items": [
909
+ {
910
+ "startTimeMs": 512,
911
+ "durationMs": 230,
912
+ "blockingDurationMs": 180,
913
+ "scripts": [{ "invoker": "TimerHandler:setTimeout", "source": "app.js", "durationMs": 150, "forcedStyleAndLayoutMs": 0 }]
914
+ }
915
+ ],
916
+ "message": "Tracking long animation frames. Call getLoAFSummary() for full script attribution.",
917
+ "getDataFn": "getLoAFSummary"
918
+ }
919
+ ```
920
+
921
+ #### Long-Animation-Frames-Script-Attribution
922
+
923
+ Returns buffered LoAF data immediately (do not wait for a timer). `functions` lists the five slowest functions of each file.
924
+
925
+ ```json
926
+ {
927
+ "script": "Long-Animation-Frames-Script-Attribution",
928
+ "status": "ok",
929
+ "details": {
930
+ "frameCount": 5,
931
+ "totalBlockingMs": 420,
932
+ "byCategory": {
933
+ "first-party": { "durationMs": 180, "count": 3 },
934
+ "third-party": { "durationMs": 210, "count": 2 },
935
+ "framework": { "durationMs": 30, "count": 1 }
936
+ }
937
+ },
938
+ "items": [
939
+ {
940
+ "file": "app.js",
941
+ "category": "first-party",
942
+ "durationMs": 180,
943
+ "count": 3,
944
+ "functions": [{ "name": "startupWork", "invoker": "TimerHandler:setTimeout", "durationMs": 150 }]
945
+ }
946
+ ]
947
+ }
948
+ ```
949
+
950
+ #### Long-Animation-Frames-Helpers (tracking)
951
+
952
+ Installs `window.loafHelpers`. `getDataFn` is the dotted path `loafHelpers.getData`, which returns a summary with the 20 longest frames. `loafHelpers.getRawData()` returns the raw frame array for custom analysis.
953
+
954
+ ```json
955
+ {
956
+ "script": "Long-Animation-Frames-Helpers",
957
+ "status": "tracking",
958
+ "message": "LoAF Helpers loaded. Use loafHelpers.summary(), loafHelpers.topScripts(), etc.",
959
+ "getDataFn": "loafHelpers.getData"
960
+ }
961
+ ```
962
+
963
+ #### Scroll-Performance (tracking)
964
+
965
+ `details` keeps the counts as numbers. `items` lists the non-passive listeners and the CSS findings with the same fields, without duplicates.
966
+
967
+ ```json
968
+ {
969
+ "script": "Scroll-Performance",
970
+ "status": "tracking",
971
+ "details": {
972
+ "nonPassiveListeners": 2,
973
+ "cssAudit": {
974
+ "smoothScrollElements": 1,
975
+ "willChangeElements": 1,
976
+ "contentVisibilityElements": 3,
977
+ "overscrollElements": 1
978
+ }
979
+ },
980
+ "items": [
981
+ { "kind": "non-passive-listener", "target": "WINDOW", "detail": "wheel" },
982
+ { "kind": "will-change", "target": "div#card", "detail": "transform" },
983
+ { "kind": "overscroll", "target": "div#panel", "detail": "contain" }
984
+ ],
985
+ "issues": [
986
+ { "severity": "warning", "message": "2 non-passive scroll or touch listener(s). Add { passive: true } so scrolling does not wait for JavaScript." },
987
+ { "severity": "info", "message": "will-change is set on 1 element(s). Remove it from elements that do not animate, since each one keeps a compositor layer." }
988
+ ],
989
+ "message": "Scroll performance tracking active. Scroll the page then call getScrollSummary() for FPS data.",
990
+ "getDataFn": "getScrollSummary"
991
+ }
992
+ ```
993
+
994
+ #### LongTask
995
+
996
+ Returns buffered long tasks immediately. Ongoing tracking continues. `items` lists the 50 slowest tasks, in the same shape at start and in `getLongTaskSummary()`. With no long tasks the summary returns `status: "ok"` and `count: 0`.
997
+
998
+ ```json
999
+ {
1000
+ "script": "LongTask",
1001
+ "status": "tracking",
1002
+ "count": 4,
1003
+ "details": {
1004
+ "totalBlockingTimeMs": 380,
1005
+ "worstTaskMs": 220,
1006
+ "bySeverity": { "critical": 1, "high": 1, "medium": 2, "low": 0 }
1007
+ },
1008
+ "items": [{ "startTimeMs": 512, "durationMs": 220, "blockingTimeMs": 170, "severity": "high", "attribution": "window" }],
1009
+ "message": "Tracking long tasks. Call getLongTaskSummary() for statistics.",
1010
+ "getDataFn": "getLongTaskSummary"
1011
+ }
1012
+ ```
1013
+
1014
+ ### Media
1015
+
1016
+ #### Image-Element-Audit (async)
1017
+
1018
+ `items` holds the 50 images to look at first: the LCP image, then those with the most errors and warnings. `count` and `details` cover every image. The format comes from the `Content-Type` of the response; when a cross-origin image blocks `fetch()` it is guessed from the URL, `details.formatGuessedCount` counts those images and `corsLimitedAnalysis` is `true`.
1019
+
1020
+ ```json
1021
+ {
1022
+ "script": "Image-Element-Audit",
1023
+ "status": "ok",
1024
+ "count": 8,
1025
+ "details": {
1026
+ "totalImages": 8,
1027
+ "inViewport": 3,
1028
+ "offViewport": 5,
1029
+ "totalErrors": 2,
1030
+ "totalWarnings": 3,
1031
+ "totalInfos": 1,
1032
+ "lcpCandidate": {
1033
+ "selector": "img.hero",
1034
+ "format": "avif",
1035
+ "fetchpriority": "high",
1036
+ "loading": "(not set)",
1037
+ "preloaded": true
1038
+ }
1039
+ },
1040
+ "items": [
1041
+ {
1042
+ "selector": "img.hero",
1043
+ "url": "hero.avif",
1044
+ "format": "avif",
1045
+ "inViewport": true,
1046
+ "isLCP": true,
1047
+ "loading": "(not set)",
1048
+ "decoding": "sync",
1049
+ "fetchpriority": "high",
1050
+ "hasDimensions": true,
1051
+ "hasSrcset": false,
1052
+ "hasSizes": false,
1053
+ "inPicture": false,
1054
+ "issues": []
1055
+ }
1056
+ ],
1057
+ "issues": [
1058
+ {
1059
+ "severity": "warning",
1060
+ "message": "img.thumbnail: Missing width/height attributes (CLS risk)"
1061
+ }
1062
+ ]
1063
+ }
1064
+ ```
1065
+
1066
+ #### Oversized-Images (async)
1067
+
1068
+ Compares the pixels of each loaded image file with the pixels drawn (rendered size times `devicePixelRatio`, scaled by `object-fit`). `ratio` is file pixels per pixel drawn: `oversized` at 1.5 or more, `undersized` (blurry) under 0.9. `items` lists the flagged images, the most wasted pixels first (at most 50); `count` and `details` cover all of them. `wastedBytes` and `transferBytes` are `null` when the transfer size is hidden (cross-origin without `Timing-Allow-Origin`); `details.wastedBytes` counts each URL once. Images with a zero-size box, unloaded images and SVGs are counted in `details.skipped*`.
1069
+
1070
+ ```json
1071
+ {
1072
+ "script": "Oversized-Images",
1073
+ "status": "ok",
1074
+ "count": 1,
1075
+ "details": { "devicePixelRatio": 2, "totalImages": 9, "imagesChecked": 7, "oversized": 1, "undersized": 0, "wastedPixels": 1800000, "wastedBytes": 1614610, "skippedHidden": 1, "skippedNotLoaded": 1, "skippedVector": 0 },
1076
+ "items": [
1077
+ { "selector": "img#hero", "url": "https://example.com/hero.png", "verdict": "oversized", "naturalWidth": 1600, "naturalHeight": 1200, "renderedWidth": 200, "renderedHeight": 150, "neededWidth": 400, "neededHeight": 300, "ratio": 4, "wastedPixels": 1800000, "transferBytes": 1922153, "wastedBytes": 1614610, "hasSrcset": false, "hasSizes": false }
1078
+ ],
1079
+ "issues": [{ "severity": "warning", "message": "hero.png: 1600x1200 file for a 400x300 slot at 2x (4x too large, about 1577 KB wasted)" }]
1080
+ }
1081
+ ```
1082
+
1083
+ #### Video-Element-Audit
1084
+
1085
+ Same shape as Image-Element-Audit but for video elements.
1086
+
1087
+ #### SVG-Embedded-Bitmap-Analysis
1088
+
1089
+ `details` counts the SVGs, also on a page with none. `spriteOpportunity` is `true` when five or more standalone inline SVGs could share one `<symbol>` sprite.
1090
+
1091
+ ```json
1092
+ {
1093
+ "script": "SVG-Embedded-Bitmap-Analysis",
1094
+ "status": "ok",
1095
+ "count": 2,
1096
+ "details": { "externalSvgCount": 3, "inlineSvgTotal": 7, "svgsWithUse": 1, "standaloneInlineSvgs": 6, "spriteOpportunity": true },
1097
+ "items": [{ "url": "icon.svg", "hasBitmap": true, "bitmapType": "image/png", "sizeBytes": 4500 }],
1098
+ "issues": [{ "severity": "warning", "message": "2 SVG files contain embedded bitmaps" }]
1099
+ }
1100
+ ```
1101
+
1102
+ ### Resources
1103
+
1104
+ #### Network-Bandwidth-Connection-Quality
1105
+
1106
+ `rating` follows `effectiveType`: `4g` is `good`, `3g` is `needs-improvement`, `2g` and `slow-2g` are `poor`. It is absent when the type is unknown.
1107
+
1108
+ ```json
1109
+ {
1110
+ "script": "Network-Bandwidth-Connection-Quality",
1111
+ "status": "ok",
1112
+ "rating": "good",
1113
+ "details": {
1114
+ "effectiveType": "4g",
1115
+ "downlink": 10,
1116
+ "rtt": 50,
1117
+ "saveData": false
1118
+ }
1119
+ }
1120
+ ```
1121
+
1122
+ ---
1123
+
1124
+ ## Guidelines for Agents
1125
+
1126
+ ### Reading results
1127
+
1128
+ ```
1129
+ // Prefer return value over console output
1130
+ result = evaluate_script(scriptCode)
1131
+ if result.status == "ok" → use result.value, result.rating, result.details, result.items
1132
+ if result.status == "tracking" → call evaluate_script(`${result.getDataFn}()`) after user interaction
1133
+ if result.status == "error" → check result.error, the browser may not have loaded the page yet
1134
+ if result.status == "unsupported" → browser does not support the required API (check: Chrome 107+?)
1135
+ ```
1136
+
1137
+ ### Tracking scripts workflow
1138
+
1139
+ ```
1140
+ // 1. Start tracking
1141
+ result = evaluate_script(INP_js)
1142
+ // result = { status: "tracking", getDataFn: "getINP" }
1143
+
1144
+ // 2. Wait for/trigger user interactions
1145
+
1146
+ // 3. Collect data
1147
+ data = evaluate_script("getINP()")
1148
+ // data = { status: "ok", value: 350, rating: "needs-improvement", ... }
1149
+ ```
1150
+
1151
+ ### Making decisions from return values
1152
+
1153
+ - `rating === "good"` → no action needed for this metric
1154
+ - `rating === "needs-improvement"` → investigate, check `details` and `issues`
1155
+ - `rating === "poor"` → high priority fix, check `issues` for specific problems
1156
+ - `count > 0` and `issues.length > 0` → audit found actionable problems
1157
+ - `count === 0` → nothing to audit (no render-blocking resources, no images, etc.)
1158
+
1159
+ ---
1160
+
1161
+ ## Implementation Rules
1162
+
1163
+ 1. **Numbers are numbers** — never `"245ms"`, always `245`. The agent formats as needed.
1164
+ 2. **Consistent field names** — `value` for the metric, `unit` for its unit, `rating` for the threshold assessment.
1165
+ 3. **Issues are actionable** — each issue message describes what to fix, not what was found.
1166
+ 4. **Items are homogeneous** — all objects in `items[]` have the same fields.
1167
+ 5. **No DOM references in return value** — elements can't be serialized to JSON.
1168
+ 6. **Keep console output unchanged** — the return value is additive, not a replacement.
1169
+ 7. **Window functions match the schema** — `getINP()`, `getLoAFSummary()`, etc. return the same structured shape, and never `undefined`.
1170
+ 8. **Bounded output** — `items[]` holds at most 50 entries, sorted by relevance, and the whole return stays under 50 KB. Put the total in `count`.
1171
+ 9. **Nested detail stays nested** — phases and groups are objects such as `{ "dnsLookup": { "value": 12, "unit": "ms" } }`, not flattened into `dnsLookupMs`. Renderers adapt to the schema, not the other way around.
1172
+ 10. **Valid statuses only** — `ok`, `tracking`, `error` or `unsupported`, with an `error` message when the status is `error` or `unsupported`.
1173
+ 11. **Hidden cross-origin data is reported, not guessed** — a resource whose timing or size is hidden (no `Timing-Allow-Origin`) reports zeros for every size and timing. Count it (`sizeKnown: false`, `corsRestrictedCount`) instead of treating the zero as a measurement.
1174
+ 12. **Partial analysis is flagged** — when hidden cross-origin data leaves part of the set unanalyzed, set the top-level `corsLimitedAnalysis: true` and report how many resources were skipped in `details` (`sizeUnknownCount`, `corsRestrictedCount`). Set it to `false` when every resource was analyzed.
1175
+
1176
+ The rules are checked by `cli/tests/e2e/snippet-contract.test.js`, which runs every script, and every `getDataFn`, on an empty page, a seeded page and a heavy page.