webperf-snippets 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -14
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +44 -9
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +72 -24
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +86 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +74 -11
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1179 -0
- package/snippets/_shared/helpers.js +61 -0
- package/src/bin.js +72 -45
- package/src/crux.js +84 -0
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +27 -1
- package/src/runner.js +75 -21
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- package/src/workflows/loading.js +9 -0
|
@@ -0,0 +1,1179 @@
|
|
|
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`. `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
|
+
|
|
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
|
+
"longFrames": 2,
|
|
543
|
+
"unattributedLoafMs": 74,
|
|
544
|
+
"firstPartyRequests": 1,
|
|
545
|
+
"blockingDomains": 1,
|
|
546
|
+
"loafSupported": true,
|
|
547
|
+
"sizeUnknownCount": 1
|
|
548
|
+
},
|
|
549
|
+
"items": [
|
|
550
|
+
{ "domain": "tagmanager.example", "requests": 2, "transferBytes": 5120, "sizeUnknownCount": 0, "renderBlocking": true, "loafMs": 128, "loafScripts": 1 },
|
|
551
|
+
{ "domain": "metrics.example", "requests": 1, "transferBytes": 0, "sizeUnknownCount": 1, "renderBlocking": false, "loafMs": 0, "loafScripts": 0 }
|
|
552
|
+
],
|
|
553
|
+
"issues": [
|
|
554
|
+
{ "severity": "warning", "message": "1 third-party domain(s) serve render-blocking resources (tagmanager.example); load them async or defer, or self-host them" },
|
|
555
|
+
{ "severity": "info", "message": "1 third-party request(s) without Timing-Allow-Origin report zero sizes, so the transfer size is a lower bound" }
|
|
556
|
+
]
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
#### Service-Worker-Analysis
|
|
561
|
+
|
|
562
|
+
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.
|
|
563
|
+
|
|
564
|
+
```json
|
|
565
|
+
{
|
|
566
|
+
"script": "Service-Worker-Analysis",
|
|
567
|
+
"status": "ok",
|
|
568
|
+
"count": 1,
|
|
569
|
+
"rating": "needs-improvement",
|
|
570
|
+
"details": {
|
|
571
|
+
"controlled": true,
|
|
572
|
+
"controllerState": "activated",
|
|
573
|
+
"swOverheadMs": 0.8,
|
|
574
|
+
"totalResources": 12,
|
|
575
|
+
"swIntercepted": 3,
|
|
576
|
+
"notIntercepted": 9,
|
|
577
|
+
"fromCache": 1,
|
|
578
|
+
"fromNetwork": 1,
|
|
579
|
+
"fromUnknown": 1,
|
|
580
|
+
"cacheHitRate": 50,
|
|
581
|
+
"savedBytes": 650,
|
|
582
|
+
"cacheStorageCount": 1,
|
|
583
|
+
"cacheStorage": [{ "name": "precache", "entries": 1 }]
|
|
584
|
+
},
|
|
585
|
+
"items": [
|
|
586
|
+
{
|
|
587
|
+
"scope": "https://web.dev/",
|
|
588
|
+
"scriptURL": "https://web.dev/sw.js",
|
|
589
|
+
"state": "active",
|
|
590
|
+
"hasWaiting": false,
|
|
591
|
+
"hasInstalling": false,
|
|
592
|
+
"navigationPreloadEnabled": false,
|
|
593
|
+
"navigationPreloadHeaderValue": "true"
|
|
594
|
+
}
|
|
595
|
+
],
|
|
596
|
+
"issues": [
|
|
597
|
+
{ "severity": "warning", "message": "Moderate SW cache hit rate (50%). Review the caching strategy for more resources." },
|
|
598
|
+
{ "severity": "info", "message": "1 intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them." }
|
|
599
|
+
]
|
|
600
|
+
}
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
#### JS-Execution-Time-Breakdown
|
|
604
|
+
|
|
605
|
+
`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`.
|
|
606
|
+
|
|
607
|
+
```json
|
|
608
|
+
{
|
|
609
|
+
"script": "JS-Execution-Time-Breakdown",
|
|
610
|
+
"status": "ok",
|
|
611
|
+
"count": 2,
|
|
612
|
+
"details": {
|
|
613
|
+
"blockingCount": 1,
|
|
614
|
+
"nonBlockingCount": 1,
|
|
615
|
+
"totalTransferBytes": 1300644,
|
|
616
|
+
"totalDecodedBytes": 1300044,
|
|
617
|
+
"totalDownloadMs": 6,
|
|
618
|
+
"totalEstParseMobileMs": 1270,
|
|
619
|
+
"splitCandidatesCount": 1,
|
|
620
|
+
"domInteractiveMs": 9,
|
|
621
|
+
"domContentLoadedMs": 9,
|
|
622
|
+
"loadEventMs": 9,
|
|
623
|
+
"criticalBundles": [
|
|
624
|
+
{ "shortName": "big.js", "decodedBytes": 1300024, "transferBytes": 1300324, "estimatedParseMobileMs": 1270, "estimatedParseDesktopMs": 423 }
|
|
625
|
+
]
|
|
626
|
+
},
|
|
627
|
+
"items": [
|
|
628
|
+
{ "shortName": "big.js", "isBlocking": true, "downloadMs": 3, "estimatedParseMobileMs": 1270, "transferBytes": 1300324, "decodedBytes": 1300024, "corsRestricted": false }
|
|
629
|
+
],
|
|
630
|
+
"issues": [{ "severity": "error", "message": "1 render-blocking script(s) delay HTML parsing" }]
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
#### FCP
|
|
635
|
+
|
|
636
|
+
`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).
|
|
637
|
+
|
|
638
|
+
```json
|
|
639
|
+
{
|
|
640
|
+
"script": "FCP",
|
|
641
|
+
"status": "ok",
|
|
642
|
+
"metric": "FCP",
|
|
643
|
+
"value": 1240,
|
|
644
|
+
"unit": "ms",
|
|
645
|
+
"rating": "good",
|
|
646
|
+
"thresholds": { "good": 1800, "needsImprovement": 3000 },
|
|
647
|
+
"details": { "ttfbMs": 180, "renderBlockingLoadMs": 620, "renderDelayMs": 440, "blockingResourceCount": 2 },
|
|
648
|
+
"items": [{ "url": "app.css", "type": "CSS", "durationMs": 310 }]
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
#### Resource-Hints
|
|
653
|
+
|
|
654
|
+
`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.
|
|
655
|
+
|
|
656
|
+
```json
|
|
657
|
+
{
|
|
658
|
+
"script": "Resource-Hints",
|
|
659
|
+
"status": "ok",
|
|
660
|
+
"details": {
|
|
661
|
+
"byType": { "preload": 2, "preconnect": 1 },
|
|
662
|
+
"missingPreconnectsCount": 1,
|
|
663
|
+
"missingPreconnects": [
|
|
664
|
+
{ "origin": "https://cdn.example", "requestCount": 6, "sizeBytes": 84000, "resourceTypes": ["script", "img"], "recommendedHint": "preconnect" }
|
|
665
|
+
]
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
#### Resource-Hints-Validation
|
|
671
|
+
|
|
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.
|
|
673
|
+
|
|
674
|
+
```json
|
|
675
|
+
{
|
|
676
|
+
"script": "Resource-Hints-Validation",
|
|
677
|
+
"status": "ok",
|
|
678
|
+
"items": [
|
|
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" }
|
|
682
|
+
]
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
#### Priority-Hints-Audit and Validate-Preload-Async-Defer-Scripts
|
|
687
|
+
|
|
688
|
+
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).
|
|
689
|
+
|
|
690
|
+
```json
|
|
691
|
+
{
|
|
692
|
+
"script": "Validate-Preload-Async-Defer-Scripts",
|
|
693
|
+
"status": "ok",
|
|
694
|
+
"items": [{ "url": "app.js", "type": "blocking", "strategy": "defer", "location": "head", "reviewNote": "" }],
|
|
695
|
+
"issues": [{ "severity": "warning", "message": "app.js is preloaded and loaded with defer. Fix: remove the preload or the defer." }]
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
#### Fonts-Preloaded-Loaded-and-used-above-the-fold
|
|
700
|
+
|
|
701
|
+
`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).
|
|
702
|
+
|
|
703
|
+
```json
|
|
704
|
+
{
|
|
705
|
+
"script": "Fonts-Preloaded-Loaded-and-used-above-the-fold",
|
|
706
|
+
"status": "ok",
|
|
707
|
+
"count": 1,
|
|
708
|
+
"details": {
|
|
709
|
+
"preloadedCount": 1,
|
|
710
|
+
"loadedCount": 1,
|
|
711
|
+
"usedAboveFoldCount": 1,
|
|
712
|
+
"preloadedNotUsedCount": 0,
|
|
713
|
+
"usedNotPreloadedCount": 0,
|
|
714
|
+
"preloadedFonts": [{ "family": "inter", "href": "https://cdn.example/inter.woff2", "fontType": "font/woff2", "crossorigin": "anonymous", "thirdParty": true }]
|
|
715
|
+
},
|
|
716
|
+
"items": [{ "family": "Inter", "weight": "400", "style": "normal", "display": "swap" }],
|
|
717
|
+
"usedFonts": [{ "family": "Inter", "weight": "400", "style": "normal", "elements": 12 }]
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
#### SSR-Hydration-Data-Analysis
|
|
722
|
+
|
|
723
|
+
`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.
|
|
724
|
+
|
|
725
|
+
```json
|
|
726
|
+
{
|
|
727
|
+
"script": "SSR-Hydration-Data-Analysis",
|
|
728
|
+
"status": "ok",
|
|
729
|
+
"count": 1,
|
|
730
|
+
"details": {
|
|
731
|
+
"frameworksFound": 1,
|
|
732
|
+
"totalHydrationBytes": 4200,
|
|
733
|
+
"otherInlineBytes": 900,
|
|
734
|
+
"hasExceedingThreshold": false,
|
|
735
|
+
"frameworks": [{ "name": "Next.js", "sizeBytes": 4200, "thresholdBytes": 51200, "exceedsThreshold": false }]
|
|
736
|
+
},
|
|
737
|
+
"items": [
|
|
738
|
+
{ "framework": "Next.js", "kind": "prop", "name": "rows", "sizeBytes": 3100, "flags": ["large-array"], "detail": null },
|
|
739
|
+
{ "framework": "Astro", "kind": "island", "name": "Counter.js", "sizeBytes": 20, "flags": [], "detail": "load" }
|
|
740
|
+
],
|
|
741
|
+
"issues": [{ "severity": "warning", "message": "pageProps has keys that look sensitive (session.token). Everything in pageProps is sent to every visitor." }]
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
#### CSS-Media-Queries-Analysis
|
|
746
|
+
|
|
747
|
+
`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()`.
|
|
748
|
+
|
|
749
|
+
```json
|
|
750
|
+
{
|
|
751
|
+
"script": "CSS-Media-Queries-Analysis",
|
|
752
|
+
"status": "ok",
|
|
753
|
+
"count": 2,
|
|
754
|
+
"details": {
|
|
755
|
+
"corsBlockedCount": 0,
|
|
756
|
+
"performanceImpact": {
|
|
757
|
+
"unnecessaryBytes": 1200,
|
|
758
|
+
"totalClasses": 4,
|
|
759
|
+
"totalProperties": 9,
|
|
760
|
+
"deviceImpact": {
|
|
761
|
+
"Mid-range (Moto G Power, iPhone SE)": { "renderBlockingTimeMs": 3.1, "runtimeOverheadMs": 0.1, "fcpImpactMs": 1.9, "lcpImpactMs": 1.2, "inpOverheadMs": 0.1 }
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
#### Content-Visibility
|
|
769
|
+
|
|
770
|
+
With no usage the script returns `count: 0`, no items and an `info` issue.
|
|
771
|
+
|
|
772
|
+
```json
|
|
773
|
+
{
|
|
774
|
+
"script": "Content-Visibility",
|
|
775
|
+
"status": "ok",
|
|
776
|
+
"count": 0,
|
|
777
|
+
"details": { "autoCount": 0, "hiddenCount": 0 },
|
|
778
|
+
"items": [],
|
|
779
|
+
"issues": [{ "severity": "info", "message": "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
|
|
780
|
+
}
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
### Interaction
|
|
784
|
+
|
|
785
|
+
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.
|
|
786
|
+
|
|
787
|
+
#### Interactions (tracking)
|
|
788
|
+
|
|
789
|
+
```json
|
|
790
|
+
{
|
|
791
|
+
"script": "Interactions",
|
|
792
|
+
"status": "tracking",
|
|
793
|
+
"message": "Tracking interactions. Interact with the page then call getInteractionSummary() for results.",
|
|
794
|
+
"getDataFn": "getInteractionSummary"
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
`getInteractionSummary()`:
|
|
799
|
+
|
|
800
|
+
```json
|
|
801
|
+
{
|
|
802
|
+
"script": "Interactions",
|
|
803
|
+
"status": "ok",
|
|
804
|
+
"count": 5,
|
|
805
|
+
"details": {
|
|
806
|
+
"totalInteractions": 5,
|
|
807
|
+
"worstMs": 312,
|
|
808
|
+
"avgMs": 140,
|
|
809
|
+
"p75Ms": 210,
|
|
810
|
+
"byRating": { "good": 3, "needs-improvement": 1, "poor": 1 }
|
|
811
|
+
},
|
|
812
|
+
"items": [{ "type": "click", "durationMs": 312, "rating": "needs-improvement" }],
|
|
813
|
+
"issues": [{ "severity": "warning", "message": "click interaction took 312ms (needs-improvement), above the 200ms threshold" }]
|
|
814
|
+
}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
`items` is sorted slowest first, and `issues` lists at most the 10 slowest interactions that are not good.
|
|
818
|
+
|
|
819
|
+
#### Input-Latency-Breakdown (tracking)
|
|
820
|
+
|
|
821
|
+
```json
|
|
822
|
+
{
|
|
823
|
+
"script": "Input-Latency-Breakdown",
|
|
824
|
+
"status": "tracking",
|
|
825
|
+
"message": "Tracking input latency by event type. Interact with the page then call getInputLatencyBreakdown().",
|
|
826
|
+
"getDataFn": "getInputLatencyBreakdown"
|
|
827
|
+
}
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
`getInputLatencyBreakdown()` returns `status: "error"` until an interaction is recorded. Then:
|
|
831
|
+
|
|
832
|
+
```json
|
|
833
|
+
{
|
|
834
|
+
"script": "Input-Latency-Breakdown",
|
|
835
|
+
"status": "ok",
|
|
836
|
+
"count": 2,
|
|
837
|
+
"details": { "eventTypes": { "click": { "count": 3, "p75Ms": 260, "inputDelayMs": 4, "processingMs": 240, "presentationMs": 16 } } },
|
|
838
|
+
"items": [{ "type": "click", "count": 3, "p75Ms": 260, "inputDelayMs": 4, "processingMs": 240, "presentationMs": 16, "rating": "needs-improvement" }],
|
|
839
|
+
"issues": [{ "severity": "warning", "message": "click P75 is 260ms (needs-improvement). Bottleneck: Processing. Fix: optimize the event handlers or debounce them." }]
|
|
840
|
+
}
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
#### Forced-Synchronous-Layout (tracking)
|
|
844
|
+
|
|
845
|
+
```json
|
|
846
|
+
{
|
|
847
|
+
"script": "Forced-Synchronous-Layout",
|
|
848
|
+
"status": "tracking",
|
|
849
|
+
"count": 0,
|
|
850
|
+
"message": "FSL Detector active. Reproduce the interaction then call getFSLSummary() to inspect results.",
|
|
851
|
+
"getDataFn": "getFSLSummary",
|
|
852
|
+
"stopFn": "stopFSLDetector"
|
|
853
|
+
}
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
`getFSLSummary()` groups the events by property, access type and element:
|
|
857
|
+
|
|
858
|
+
```json
|
|
859
|
+
{
|
|
860
|
+
"script": "Forced-Synchronous-Layout",
|
|
861
|
+
"status": "ok",
|
|
862
|
+
"count": 30,
|
|
863
|
+
"details": { "byProperty": { "offsetWidth": 15, "getBoundingClientRect()": 15 }, "byElement": { "div#box": 30 }, "fastestSinceLastMutationMs": 0.02 },
|
|
864
|
+
"items": [{ "property": "offsetWidth", "accessType": "read", "element": "div#box", "count": 15, "fastestSinceLastMutationMs": 0.02 }],
|
|
865
|
+
"issues": [{ "severity": "error", "message": "offsetWidth triggered 15 forced synchronous layouts. Read layout properties before writing to the DOM." }]
|
|
866
|
+
}
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
The detector sees mutations made through `classList`, `setAttribute`, `style.setProperty` and `style.cssText`.
|
|
870
|
+
|
|
871
|
+
#### Layout-Shift-Loading-and-Interaction
|
|
872
|
+
|
|
873
|
+
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.
|
|
874
|
+
|
|
875
|
+
```json
|
|
876
|
+
{
|
|
877
|
+
"script": "Layout-Shift-Loading-and-Interaction",
|
|
878
|
+
"status": "tracking",
|
|
879
|
+
"metric": "CLS",
|
|
880
|
+
"value": 0.08,
|
|
881
|
+
"unit": "score",
|
|
882
|
+
"rating": "good",
|
|
883
|
+
"thresholds": { "good": 0.1, "needsImprovement": 0.25 },
|
|
884
|
+
"details": {
|
|
885
|
+
"currentCLS": 0.08,
|
|
886
|
+
"shiftCount": 3,
|
|
887
|
+
"countedShifts": 3,
|
|
888
|
+
"excludedShifts": 0,
|
|
889
|
+
"topElements": [{ "selector": "#banner", "shiftCount": 2, "totalImpact": 0.06 }]
|
|
890
|
+
},
|
|
891
|
+
"message": "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
|
|
892
|
+
"getDataFn": "getLayoutShiftSummary"
|
|
893
|
+
}
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
#### Long-Animation-Frames
|
|
897
|
+
|
|
898
|
+
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.
|
|
899
|
+
|
|
900
|
+
```json
|
|
901
|
+
{
|
|
902
|
+
"script": "Long-Animation-Frames",
|
|
903
|
+
"status": "tracking",
|
|
904
|
+
"count": 3,
|
|
905
|
+
"details": {
|
|
906
|
+
"totalLoAFs": 3,
|
|
907
|
+
"withBlockingTime": 2,
|
|
908
|
+
"totalBlockingTimeMs": 280,
|
|
909
|
+
"worstBlockingMs": 180
|
|
910
|
+
},
|
|
911
|
+
"items": [
|
|
912
|
+
{
|
|
913
|
+
"startTimeMs": 512,
|
|
914
|
+
"durationMs": 230,
|
|
915
|
+
"blockingDurationMs": 180,
|
|
916
|
+
"scripts": [{ "invoker": "TimerHandler:setTimeout", "source": "app.js", "durationMs": 150, "forcedStyleAndLayoutMs": 0 }]
|
|
917
|
+
}
|
|
918
|
+
],
|
|
919
|
+
"message": "Tracking long animation frames. Call getLoAFSummary() for full script attribution.",
|
|
920
|
+
"getDataFn": "getLoAFSummary"
|
|
921
|
+
}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
#### Long-Animation-Frames-Script-Attribution
|
|
925
|
+
|
|
926
|
+
Returns buffered LoAF data immediately (do not wait for a timer). `functions` lists the five slowest functions of each file.
|
|
927
|
+
|
|
928
|
+
```json
|
|
929
|
+
{
|
|
930
|
+
"script": "Long-Animation-Frames-Script-Attribution",
|
|
931
|
+
"status": "ok",
|
|
932
|
+
"details": {
|
|
933
|
+
"frameCount": 5,
|
|
934
|
+
"totalBlockingMs": 420,
|
|
935
|
+
"byCategory": {
|
|
936
|
+
"first-party": { "durationMs": 180, "count": 3 },
|
|
937
|
+
"third-party": { "durationMs": 210, "count": 2 },
|
|
938
|
+
"framework": { "durationMs": 30, "count": 1 }
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
"items": [
|
|
942
|
+
{
|
|
943
|
+
"file": "app.js",
|
|
944
|
+
"category": "first-party",
|
|
945
|
+
"durationMs": 180,
|
|
946
|
+
"count": 3,
|
|
947
|
+
"functions": [{ "name": "startupWork", "invoker": "TimerHandler:setTimeout", "durationMs": 150 }]
|
|
948
|
+
}
|
|
949
|
+
]
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
#### Long-Animation-Frames-Helpers (tracking)
|
|
954
|
+
|
|
955
|
+
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.
|
|
956
|
+
|
|
957
|
+
```json
|
|
958
|
+
{
|
|
959
|
+
"script": "Long-Animation-Frames-Helpers",
|
|
960
|
+
"status": "tracking",
|
|
961
|
+
"message": "LoAF Helpers loaded. Use loafHelpers.summary(), loafHelpers.topScripts(), etc.",
|
|
962
|
+
"getDataFn": "loafHelpers.getData"
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
#### Scroll-Performance (tracking)
|
|
967
|
+
|
|
968
|
+
`details` keeps the counts as numbers. `items` lists the non-passive listeners and the CSS findings with the same fields, without duplicates.
|
|
969
|
+
|
|
970
|
+
```json
|
|
971
|
+
{
|
|
972
|
+
"script": "Scroll-Performance",
|
|
973
|
+
"status": "tracking",
|
|
974
|
+
"details": {
|
|
975
|
+
"nonPassiveListeners": 2,
|
|
976
|
+
"cssAudit": {
|
|
977
|
+
"smoothScrollElements": 1,
|
|
978
|
+
"willChangeElements": 1,
|
|
979
|
+
"contentVisibilityElements": 3,
|
|
980
|
+
"overscrollElements": 1
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
"items": [
|
|
984
|
+
{ "kind": "non-passive-listener", "target": "WINDOW", "detail": "wheel" },
|
|
985
|
+
{ "kind": "will-change", "target": "div#card", "detail": "transform" },
|
|
986
|
+
{ "kind": "overscroll", "target": "div#panel", "detail": "contain" }
|
|
987
|
+
],
|
|
988
|
+
"issues": [
|
|
989
|
+
{ "severity": "warning", "message": "2 non-passive scroll or touch listener(s). Add { passive: true } so scrolling does not wait for JavaScript." },
|
|
990
|
+
{ "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." }
|
|
991
|
+
],
|
|
992
|
+
"message": "Scroll performance tracking active. Scroll the page then call getScrollSummary() for FPS data.",
|
|
993
|
+
"getDataFn": "getScrollSummary"
|
|
994
|
+
}
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
#### LongTask
|
|
998
|
+
|
|
999
|
+
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`.
|
|
1000
|
+
|
|
1001
|
+
```json
|
|
1002
|
+
{
|
|
1003
|
+
"script": "LongTask",
|
|
1004
|
+
"status": "tracking",
|
|
1005
|
+
"count": 4,
|
|
1006
|
+
"details": {
|
|
1007
|
+
"totalBlockingTimeMs": 380,
|
|
1008
|
+
"worstTaskMs": 220,
|
|
1009
|
+
"bySeverity": { "critical": 1, "high": 1, "medium": 2, "low": 0 }
|
|
1010
|
+
},
|
|
1011
|
+
"items": [{ "startTimeMs": 512, "durationMs": 220, "blockingTimeMs": 170, "severity": "high", "attribution": "window" }],
|
|
1012
|
+
"message": "Tracking long tasks. Call getLongTaskSummary() for statistics.",
|
|
1013
|
+
"getDataFn": "getLongTaskSummary"
|
|
1014
|
+
}
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
### Media
|
|
1018
|
+
|
|
1019
|
+
#### Image-Element-Audit (async)
|
|
1020
|
+
|
|
1021
|
+
`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`.
|
|
1022
|
+
|
|
1023
|
+
```json
|
|
1024
|
+
{
|
|
1025
|
+
"script": "Image-Element-Audit",
|
|
1026
|
+
"status": "ok",
|
|
1027
|
+
"count": 8,
|
|
1028
|
+
"details": {
|
|
1029
|
+
"totalImages": 8,
|
|
1030
|
+
"inViewport": 3,
|
|
1031
|
+
"offViewport": 5,
|
|
1032
|
+
"totalErrors": 2,
|
|
1033
|
+
"totalWarnings": 3,
|
|
1034
|
+
"totalInfos": 1,
|
|
1035
|
+
"lcpCandidate": {
|
|
1036
|
+
"selector": "img.hero",
|
|
1037
|
+
"format": "avif",
|
|
1038
|
+
"fetchpriority": "high",
|
|
1039
|
+
"loading": "(not set)",
|
|
1040
|
+
"preloaded": true
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
"items": [
|
|
1044
|
+
{
|
|
1045
|
+
"selector": "img.hero",
|
|
1046
|
+
"url": "hero.avif",
|
|
1047
|
+
"format": "avif",
|
|
1048
|
+
"inViewport": true,
|
|
1049
|
+
"isLCP": true,
|
|
1050
|
+
"loading": "(not set)",
|
|
1051
|
+
"decoding": "sync",
|
|
1052
|
+
"fetchpriority": "high",
|
|
1053
|
+
"hasDimensions": true,
|
|
1054
|
+
"hasSrcset": false,
|
|
1055
|
+
"hasSizes": false,
|
|
1056
|
+
"inPicture": false,
|
|
1057
|
+
"issues": []
|
|
1058
|
+
}
|
|
1059
|
+
],
|
|
1060
|
+
"issues": [
|
|
1061
|
+
{
|
|
1062
|
+
"severity": "warning",
|
|
1063
|
+
"message": "img.thumbnail: Missing width/height attributes (CLS risk)"
|
|
1064
|
+
}
|
|
1065
|
+
]
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
#### Oversized-Images (async)
|
|
1070
|
+
|
|
1071
|
+
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*`.
|
|
1072
|
+
|
|
1073
|
+
```json
|
|
1074
|
+
{
|
|
1075
|
+
"script": "Oversized-Images",
|
|
1076
|
+
"status": "ok",
|
|
1077
|
+
"count": 1,
|
|
1078
|
+
"details": { "devicePixelRatio": 2, "totalImages": 9, "imagesChecked": 7, "oversized": 1, "undersized": 0, "wastedPixels": 1800000, "wastedBytes": 1614610, "skippedHidden": 1, "skippedNotLoaded": 1, "skippedVector": 0 },
|
|
1079
|
+
"items": [
|
|
1080
|
+
{ "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 }
|
|
1081
|
+
],
|
|
1082
|
+
"issues": [{ "severity": "warning", "message": "hero.png: 1600x1200 file for a 400x300 slot at 2x (4x too large, about 1577 KB wasted)" }]
|
|
1083
|
+
}
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
#### Video-Element-Audit
|
|
1087
|
+
|
|
1088
|
+
Same shape as Image-Element-Audit but for video elements.
|
|
1089
|
+
|
|
1090
|
+
#### SVG-Embedded-Bitmap-Analysis
|
|
1091
|
+
|
|
1092
|
+
`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.
|
|
1093
|
+
|
|
1094
|
+
```json
|
|
1095
|
+
{
|
|
1096
|
+
"script": "SVG-Embedded-Bitmap-Analysis",
|
|
1097
|
+
"status": "ok",
|
|
1098
|
+
"count": 2,
|
|
1099
|
+
"details": { "externalSvgCount": 3, "inlineSvgTotal": 7, "svgsWithUse": 1, "standaloneInlineSvgs": 6, "spriteOpportunity": true },
|
|
1100
|
+
"items": [{ "url": "icon.svg", "hasBitmap": true, "bitmapType": "image/png", "sizeBytes": 4500 }],
|
|
1101
|
+
"issues": [{ "severity": "warning", "message": "2 SVG files contain embedded bitmaps" }]
|
|
1102
|
+
}
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
### Resources
|
|
1106
|
+
|
|
1107
|
+
#### Network-Bandwidth-Connection-Quality
|
|
1108
|
+
|
|
1109
|
+
`rating` follows `effectiveType`: `4g` is `good`, `3g` is `needs-improvement`, `2g` and `slow-2g` are `poor`. It is absent when the type is unknown.
|
|
1110
|
+
|
|
1111
|
+
```json
|
|
1112
|
+
{
|
|
1113
|
+
"script": "Network-Bandwidth-Connection-Quality",
|
|
1114
|
+
"status": "ok",
|
|
1115
|
+
"rating": "good",
|
|
1116
|
+
"details": {
|
|
1117
|
+
"effectiveType": "4g",
|
|
1118
|
+
"downlink": 10,
|
|
1119
|
+
"rtt": 50,
|
|
1120
|
+
"saveData": false
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
---
|
|
1126
|
+
|
|
1127
|
+
## Guidelines for Agents
|
|
1128
|
+
|
|
1129
|
+
### Reading results
|
|
1130
|
+
|
|
1131
|
+
```
|
|
1132
|
+
// Prefer return value over console output
|
|
1133
|
+
result = evaluate_script(scriptCode)
|
|
1134
|
+
if result.status == "ok" → use result.value, result.rating, result.details, result.items
|
|
1135
|
+
if result.status == "tracking" → call evaluate_script(`${result.getDataFn}()`) after user interaction
|
|
1136
|
+
if result.status == "error" → check result.error, the browser may not have loaded the page yet
|
|
1137
|
+
if result.status == "unsupported" → browser does not support the required API (check: Chrome 107+?)
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
### Tracking scripts workflow
|
|
1141
|
+
|
|
1142
|
+
```
|
|
1143
|
+
// 1. Start tracking
|
|
1144
|
+
result = evaluate_script(INP_js)
|
|
1145
|
+
// result = { status: "tracking", getDataFn: "getINP" }
|
|
1146
|
+
|
|
1147
|
+
// 2. Wait for/trigger user interactions
|
|
1148
|
+
|
|
1149
|
+
// 3. Collect data
|
|
1150
|
+
data = evaluate_script("getINP()")
|
|
1151
|
+
// data = { status: "ok", value: 350, rating: "needs-improvement", ... }
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
### Making decisions from return values
|
|
1155
|
+
|
|
1156
|
+
- `rating === "good"` → no action needed for this metric
|
|
1157
|
+
- `rating === "needs-improvement"` → investigate, check `details` and `issues`
|
|
1158
|
+
- `rating === "poor"` → high priority fix, check `issues` for specific problems
|
|
1159
|
+
- `count > 0` and `issues.length > 0` → audit found actionable problems
|
|
1160
|
+
- `count === 0` → nothing to audit (no render-blocking resources, no images, etc.)
|
|
1161
|
+
|
|
1162
|
+
---
|
|
1163
|
+
|
|
1164
|
+
## Implementation Rules
|
|
1165
|
+
|
|
1166
|
+
1. **Numbers are numbers** — never `"245ms"`, always `245`. The agent formats as needed.
|
|
1167
|
+
2. **Consistent field names** — `value` for the metric, `unit` for its unit, `rating` for the threshold assessment.
|
|
1168
|
+
3. **Issues are actionable** — each issue message describes what to fix, not what was found.
|
|
1169
|
+
4. **Items are homogeneous** — all objects in `items[]` have the same fields.
|
|
1170
|
+
5. **No DOM references in return value** — elements can't be serialized to JSON.
|
|
1171
|
+
6. **Keep console output unchanged** — the return value is additive, not a replacement.
|
|
1172
|
+
7. **Window functions match the schema** — `getINP()`, `getLoAFSummary()`, etc. return the same structured shape, and never `undefined`.
|
|
1173
|
+
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`.
|
|
1174
|
+
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.
|
|
1175
|
+
10. **Valid statuses only** — `ok`, `tracking`, `error` or `unsupported`, with an `error` message when the status is `error` or `unsupported`.
|
|
1176
|
+
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.
|
|
1177
|
+
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.
|
|
1178
|
+
|
|
1179
|
+
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.
|