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.
- package/README.md +120 -5
- 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 +52 -30
- 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 +40 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -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 +15 -0
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +55 -25
- 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 +59 -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 +216 -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 +22 -3
- 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 +1176 -0
- package/snippets/_shared/helpers.js +44 -0
- package/src/bin.js +55 -3
- package/src/crux.js +84 -0
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +20 -1
- package/src/reporters/markdown.js +22 -1
- package/src/runner.js +4 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Run curated [WebPerf Snippets](https://webperf-snippets.nucliweb.net) headlessly
|
|
|
9
9
|
|
|
10
10
|
## Why
|
|
11
11
|
|
|
12
|
-
Lighthouse gives you a score. The DevTools snippets give you the *diagnosis
|
|
12
|
+
Lighthouse gives you a score. The DevTools snippets give you the *diagnosis*, such as TTFB / Resource Load Delay / Element Render Delay sub-parts, LoAF script attribution, and render-blocking resources. This CLI runs the same curated snippets in a headless browser so you can:
|
|
13
13
|
|
|
14
14
|
- Diagnose LCP regressions in CI without copy-pasting into DevTools.
|
|
15
15
|
- Gate pull requests on real performance budgets.
|
|
@@ -74,6 +74,65 @@ Synthetic INP measurement with an interaction script:
|
|
|
74
74
|
npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
Pages behind a login, with a Playwright storage state (cookies and localStorage):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Step 1: sign in once and save the session
|
|
81
|
+
npx playwright codegen --save-storage=auth.json https://example.com/login
|
|
82
|
+
|
|
83
|
+
# Step 2: measure an authenticated page
|
|
84
|
+
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The file holds live session cookies. Keep it out of version control (add it to `.gitignore`) and use a throwaway account when you run it in CI. The CLI exits with code `2` if the path does not exist.
|
|
88
|
+
|
|
89
|
+
Compare the measured values with CrUX field data (what real users experience at the 75th percentile):
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
CRUX_API_KEY=your-key npx webperf-snippets https://web.dev
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
Field data (CrUX p75, mobile)
|
|
97
|
+
🟡 LCP 2.80s needs-improvement (synthetic: 2.10s)
|
|
98
|
+
🟢 CLS 0.0800 good (synthetic: 0.0500)
|
|
99
|
+
🟡 INP 220ms needs-improvement (synthetic: n/a)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Get a key for the [Chrome UX Report API](https://developer.chrome.com/docs/crux/api) and pass it with `--crux-key` or the `CRUX_API_KEY` environment variable; prefer the variable, because a flag ends up in shell history and CI logs. The section appears in the human and markdown output and under `crux` in the `--json` output.
|
|
103
|
+
|
|
104
|
+
- The population follows `--viewport`: `desktop` uses desktop data, `mobile` and `tablet` use phone data. The heading says which one.
|
|
105
|
+
- When the page itself has no data, the CLI looks the origin up instead and labels the section `origin-level`, so it is not mistaken for page-level data. If the origin has none either, the output says `Field data: not available`.
|
|
106
|
+
- A CrUX error (quota, network, no answer within 10 seconds) only skips the section. It never changes the exit code, and the key is never printed.
|
|
107
|
+
- INP shows `n/a` as the synthetic value unless you run an interaction script.
|
|
108
|
+
|
|
109
|
+
Send the results to an endpoint after the run with `--report-to`:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npx webperf-snippets https://example.com --workflow audit \
|
|
113
|
+
--report-to https://reports.example.com/api/report \
|
|
114
|
+
--api-key $REPORT_API_KEY
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Without `--report-to` the CLI makes no external calls. With it, the results are POSTed as JSON once the snippets have finished:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"url": "https://example.com",
|
|
122
|
+
"workflow": "audit",
|
|
123
|
+
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
124
|
+
"navMs": 1240,
|
|
125
|
+
"results": [],
|
|
126
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.3.0" }
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the name. `results` is the same list the `--json` output contains, so it can include URLs and details of the measured page; send it only to an endpoint you trust.
|
|
131
|
+
|
|
132
|
+
- The URL must be `https`. `http` is accepted only for `localhost`, so a key is never sent in clear text.
|
|
133
|
+
- `--api-key` is sent as `Authorization: Bearer <key>`. Prefer the `PERF_REVIEWS_API_KEY` environment variable, because a flag ends up in shell history and CI logs. The key is never printed.
|
|
134
|
+
- The exit code never depends on the report. A failed POST (error status, unreachable endpoint, or no answer within 5 seconds) prints a warning and the exit code stays the one the budgets and snippets decide. If the endpoint answers `{ "ok": false, "regressions": [...] }`, the regressions are printed as a warning, again without changing the exit code.
|
|
135
|
+
|
|
77
136
|
CI gating:
|
|
78
137
|
|
|
79
138
|
```bash
|
|
@@ -91,6 +150,10 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
|
91
150
|
| `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
|
|
92
151
|
| `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
|
|
93
152
|
| `--interact-script <path>` | JSON file with interactions to run before evaluation (for INP). |
|
|
153
|
+
| `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
|
|
154
|
+
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
155
|
+
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
156
|
+
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
94
157
|
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
|
|
95
158
|
| `--budget-cls <score>` | Exit `1` if CLS exceeds this value. |
|
|
96
159
|
| `--verbose` | Show all items, including passing checks. |
|
|
@@ -103,6 +166,7 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
|
103
166
|
| ------------------ | ---------------------------------------------- |
|
|
104
167
|
| `LCP` | CoreWebVitals/LCP |
|
|
105
168
|
| `CLS` | CoreWebVitals/CLS |
|
|
169
|
+
| `INP` | CoreWebVitals/INP |
|
|
106
170
|
| `LCP-Subparts` | CoreWebVitals/LCP-Subparts |
|
|
107
171
|
| `fonts` | Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold |
|
|
108
172
|
| `render-blocking` | Loading/Find-render-blocking-resources |
|
|
@@ -136,9 +200,60 @@ GitHub Actions, fail the PR if LCP exceeds 2.5s:
|
|
|
136
200
|
npx webperf-snippets https://staging.web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
137
201
|
```
|
|
138
202
|
|
|
203
|
+
### GitHub Action
|
|
204
|
+
|
|
205
|
+
The repository ships a composite action that installs the CLI and Chromium, runs a workflow and exposes the result as markdown, so any repository can gate on performance budgets without extra setup:
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
jobs:
|
|
209
|
+
webperf:
|
|
210
|
+
runs-on: ubuntu-latest
|
|
211
|
+
permissions:
|
|
212
|
+
pull-requests: write
|
|
213
|
+
steps:
|
|
214
|
+
- uses: nucliweb/webperf-snippets/.github/actions/webperf-snippets@main
|
|
215
|
+
id: webperf
|
|
216
|
+
with:
|
|
217
|
+
url: https://staging.example.com
|
|
218
|
+
budget-lcp: 2500
|
|
219
|
+
fail-on-budget: "false"
|
|
220
|
+
|
|
221
|
+
- uses: actions/github-script@v7
|
|
222
|
+
env:
|
|
223
|
+
BODY: ${{ steps.webperf.outputs.markdown }}
|
|
224
|
+
with:
|
|
225
|
+
script: |
|
|
226
|
+
await github.rest.issues.createComment({
|
|
227
|
+
...context.repo,
|
|
228
|
+
issue_number: context.issue.number,
|
|
229
|
+
body: process.env.BODY,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
- if: steps.webperf.outputs.exit-code != '0'
|
|
233
|
+
run: exit 1
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
| Input | Default | Description |
|
|
237
|
+
| ---------------- | ------------------- | -------------------------------------------------------------------------------------------- |
|
|
238
|
+
| `url` | required | URL to measure. |
|
|
239
|
+
| `workflow` | `core-web-vitals` | Workflow to run: `core-web-vitals`, `loading` or `audit`. |
|
|
240
|
+
| `budget-lcp` | `2500` | LCP budget in ms. An empty value disables the check. |
|
|
241
|
+
| `budget-cls` | `0.1` | CLS budget. An empty value disables the check. |
|
|
242
|
+
| `fail-on-budget` | `true` | Fail the step when a budget is exceeded or a snippet errors. Use `"false"` to read `exit-code` and decide yourself. |
|
|
243
|
+
| `cli-version` | `latest` | Version of the `webperf-snippets` npm package to run. |
|
|
244
|
+
|
|
245
|
+
| Output | Description |
|
|
246
|
+
| ----------- | ------------------------------------------------------------------------------------ |
|
|
247
|
+
| `markdown` | The results as markdown. Also written to the job summary. |
|
|
248
|
+
| `exit-code` | Exit code of the CLI: `0` passed, `1` budget exceeded or a snippet errored, `2` usage error. |
|
|
249
|
+
|
|
250
|
+
The markdown includes text taken from the measured page, so pass it to other steps through an environment variable (`env:` and `process.env`) as above, never by interpolating `${{ steps.webperf.outputs.markdown }}` inside a script. With `fail-on-budget: "false"` the action step always succeeds, which lets later steps post the comment before the job fails. Without `permissions: pull-requests: write` the comment step cannot post on a pull request.
|
|
251
|
+
|
|
252
|
+
LCP, CLS and INP vary by 15 to 30% on shared runners; prefer the deterministic `audit` workflow for a gate, or generous budgets.
|
|
253
|
+
|
|
139
254
|
## Publishing
|
|
140
255
|
|
|
141
|
-
The CLI package is published to npm via a tag-based workflow. Publishing is explicit and intentional
|
|
256
|
+
The CLI package is published to npm via a tag-based workflow. Publishing is explicit and intentional; it only happens when a `cli-v*` tag is pushed.
|
|
142
257
|
|
|
143
258
|
### Release steps
|
|
144
259
|
|
|
@@ -153,7 +268,7 @@ The CLI package is published to npm via a tag-based workflow. Publishing is expl
|
|
|
153
268
|
|
|
154
269
|
### Why tag-based and not path-based
|
|
155
270
|
|
|
156
|
-
An alternative is to publish automatically on every push to `main` that touches `cli/`, using a version check to skip republishes. Tag-based publishing was chosen instead because it keeps releases deliberate
|
|
271
|
+
An alternative is to publish automatically on every push to `main` that touches `cli/`, using a version check to skip republishes. Tag-based publishing was chosen instead because it keeps releases deliberate; a passing CI on `main` does not mean the package is ready to ship, and a tag communicates that intent explicitly.
|
|
157
272
|
|
|
158
273
|
### Access control
|
|
159
274
|
|
|
@@ -167,7 +282,7 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
|
|
|
167
282
|
|
|
168
283
|
- **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
|
|
169
284
|
- **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
|
|
170
|
-
- **Synthetic INP ≠ field INP**: `--interact-script` measures handler latency for a single scripted event. Real INP reflects the worst interaction across all user sessions
|
|
285
|
+
- **Synthetic INP ≠ field INP**: `--interact-script` measures handler latency for a single scripted event. Real INP reflects the worst interaction across all user sessions; use RUM for field data.
|
|
171
286
|
|
|
172
287
|
## Roadmap
|
|
173
288
|
|
|
@@ -186,4 +301,4 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
|
|
|
186
301
|
|
|
187
302
|
## License
|
|
188
303
|
|
|
189
|
-
MIT
|
|
304
|
+
MIT, see [LICENSE](../LICENSE).
|
package/package.json
CHANGED
|
@@ -2,7 +2,31 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
|
|
6
|
+
console.warn("⚠️ layout-shift entries are not supported in this browser.");
|
|
7
|
+
return { script: "CLS", status: "unsupported", error: "layout-shift entries not supported in this browser" };
|
|
8
|
+
}
|
|
9
|
+
// CLS is the largest session window: shifts less than 1s apart, in a window of at most 5s.
|
|
5
10
|
let cls = 0;
|
|
11
|
+
let sessionValue = 0;
|
|
12
|
+
let sessionFirst = null;
|
|
13
|
+
let sessionLast = null;
|
|
14
|
+
|
|
15
|
+
const addShift = (entry) => {
|
|
16
|
+
if (entry.hadRecentInput) return;
|
|
17
|
+
if (
|
|
18
|
+
sessionLast &&
|
|
19
|
+
entry.startTime - sessionLast.startTime < 1000 &&
|
|
20
|
+
entry.startTime - sessionFirst.startTime < 5000
|
|
21
|
+
) {
|
|
22
|
+
sessionValue += entry.value;
|
|
23
|
+
} else {
|
|
24
|
+
sessionValue = entry.value;
|
|
25
|
+
sessionFirst = entry;
|
|
26
|
+
}
|
|
27
|
+
sessionLast = entry;
|
|
28
|
+
cls = Math.max(cls, sessionValue);
|
|
29
|
+
};
|
|
6
30
|
|
|
7
31
|
const valueToRating = (score) =>
|
|
8
32
|
score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
|
|
@@ -23,11 +47,7 @@
|
|
|
23
47
|
};
|
|
24
48
|
|
|
25
49
|
const observer = new PerformanceObserver((list) => {
|
|
26
|
-
for (const entry of list.getEntries())
|
|
27
|
-
if (!entry.hadRecentInput) {
|
|
28
|
-
cls += entry.value;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
50
|
+
for (const entry of list.getEntries()) addShift(entry);
|
|
31
51
|
logCLS();
|
|
32
52
|
});
|
|
33
53
|
|
|
@@ -63,16 +83,10 @@
|
|
|
63
83
|
""
|
|
64
84
|
);
|
|
65
85
|
|
|
66
|
-
// Return for agent
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const obs = new PerformanceObserver((list) => {
|
|
71
|
-
for (const e of list.getEntries()) if (!e.hadRecentInput) sum += e.value;
|
|
72
|
-
});
|
|
73
|
-
obs.observe({ type: "layout-shift", buffered: true });
|
|
74
|
-
setTimeout(() => { obs.disconnect(); resolve(sum); }, 100);
|
|
75
|
-
});
|
|
86
|
+
// Return for agent. Chrome only exposes layout-shift entries through a PerformanceObserver,
|
|
87
|
+
// so wait for the buffered observer above to deliver them.
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
89
|
+
const clsSync = cls;
|
|
76
90
|
const clsRating = valueToRating(clsSync);
|
|
77
91
|
return {
|
|
78
92
|
script: "CLS",
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("event")) {
|
|
6
|
+
console.warn("⚠️ event entries are not supported in this browser.");
|
|
7
|
+
return { script: "INP", status: "unsupported", error: "event entries not supported in this browser" };
|
|
8
|
+
}
|
|
5
9
|
const interactions = [];
|
|
6
10
|
let inpValue = 0;
|
|
7
11
|
let inpEntry = null;
|
|
@@ -24,10 +28,10 @@
|
|
|
24
28
|
// Sort by duration
|
|
25
29
|
const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
|
|
26
30
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
// Skip one interaction for every 50. Use the browser's total interaction count when
|
|
32
|
+
// available: `interactions` only holds those above the duration threshold.
|
|
33
|
+
const totalInteractions = performance.interactionCount || interactions.length;
|
|
34
|
+
const index = Math.min(sorted.length - 1, Math.floor(totalInteractions / 50));
|
|
31
35
|
|
|
32
36
|
return {
|
|
33
37
|
value: sorted[index].duration,
|
|
@@ -50,7 +54,7 @@
|
|
|
50
54
|
return `${entry.name} → ${selector}`;
|
|
51
55
|
};
|
|
52
56
|
|
|
53
|
-
// Get phase breakdown
|
|
57
|
+
// Get phase breakdown from the Event Timing entry
|
|
54
58
|
const getPhaseBreakdown = (entry) => {
|
|
55
59
|
const phases = {
|
|
56
60
|
inputDelay: 0,
|
|
@@ -61,7 +65,10 @@
|
|
|
61
65
|
if (entry.processingStart && entry.processingEnd) {
|
|
62
66
|
phases.inputDelay = entry.processingStart - entry.startTime;
|
|
63
67
|
phases.processingTime = entry.processingEnd - entry.processingStart;
|
|
64
|
-
phases.presentationDelay =
|
|
68
|
+
phases.presentationDelay = Math.max(
|
|
69
|
+
0,
|
|
70
|
+
entry.duration - phases.inputDelay - phases.processingTime
|
|
71
|
+
);
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
return phases;
|
|
@@ -130,7 +137,7 @@
|
|
|
130
137
|
|
|
131
138
|
if (inpEntry) {
|
|
132
139
|
console.log("");
|
|
133
|
-
console.log(`%c🎯 Worst Interaction (INP):`,
|
|
140
|
+
console.log(`%c🎯 Worst Interaction (INP):`, `font-weight: bold; color: ${color};`);
|
|
134
141
|
console.log(` Event: ${inpEntry.formattedName}`);
|
|
135
142
|
console.log(` Duration: ${formatMs(inpEntry.duration)}`);
|
|
136
143
|
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// LCP Image Entropy Check
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
|
-
(() => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
(async () => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
6
|
+
console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
|
|
7
|
+
return { script: "LCP-Image-Entropy", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
|
|
8
|
+
}
|
|
9
|
+
// @shared formatBytes
|
|
10
|
+
function formatBytes(bytes) {
|
|
11
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
12
|
+
if (bytes === 0) return "0 B";
|
|
13
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
14
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
15
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
16
|
+
}
|
|
17
|
+
// @end-shared formatBytes
|
|
12
18
|
|
|
13
19
|
const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
|
|
14
20
|
|
|
@@ -142,8 +148,15 @@
|
|
|
142
148
|
console.groupEnd();
|
|
143
149
|
}, 100);
|
|
144
150
|
|
|
145
|
-
//
|
|
146
|
-
|
|
151
|
+
// Return for agent (buffered entries + DOM)
|
|
152
|
+
// Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
|
|
153
|
+
// so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
|
|
154
|
+
const lcpEntriesSync = await new Promise((resolve) => {
|
|
155
|
+
const entries = [];
|
|
156
|
+
const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
|
157
|
+
obs.observe({ type: "largest-contentful-paint", buffered: true });
|
|
158
|
+
setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
|
|
159
|
+
});
|
|
147
160
|
const lcpEntrySync = lcpEntriesSync.at(-1);
|
|
148
161
|
const lcpElementSync = lcpEntrySync?.element ?? null;
|
|
149
162
|
const lcpUrlSync = lcpEntrySync?.url ?? null;
|
|
@@ -182,6 +195,11 @@
|
|
|
182
195
|
if (lcpImageSync?.isLowEntropy) {
|
|
183
196
|
issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
|
|
184
197
|
}
|
|
198
|
+
// The list keeps the 50 most relevant images (the LCP image, then low entropy, then lowest bits per pixel); count and details cover all of them
|
|
199
|
+
const MAX_ITEMS = 50;
|
|
200
|
+
const rankedImages = [...imagesSync].sort(
|
|
201
|
+
(a, b) => Number(b.isLCP) - Number(a.isLCP) || Number(b.isLowEntropy) - Number(a.isLowEntropy) || a.bpp - b.bpp
|
|
202
|
+
);
|
|
185
203
|
return {
|
|
186
204
|
script: "LCP-Image-Entropy",
|
|
187
205
|
status: "ok",
|
|
@@ -192,7 +210,7 @@
|
|
|
192
210
|
lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
|
|
193
211
|
lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
|
|
194
212
|
},
|
|
195
|
-
items:
|
|
213
|
+
items: rankedImages.slice(0, MAX_ITEMS),
|
|
196
214
|
issues: issuesSync,
|
|
197
215
|
};
|
|
198
216
|
})();
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
6
|
+
console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
|
|
7
|
+
return { script: "LCP-Subparts", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
|
|
8
|
+
}
|
|
5
9
|
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
10
|
const formatPercent = (value, total) => `${Math.round((value / total) * 100)}%`;
|
|
7
11
|
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// Tracks all LCP candidate elements during page load
|
|
3
3
|
// https://webperf-snippets.nucliweb.net
|
|
4
4
|
|
|
5
|
-
(() => {
|
|
5
|
+
(async () => {
|
|
6
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
7
|
+
console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
|
|
8
|
+
return { script: "LCP-Trail", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
|
|
9
|
+
}
|
|
6
10
|
const PALETTE = [
|
|
7
11
|
{ color: "#EF4444", name: "Red" },
|
|
8
12
|
{ color: "#F97316", name: "Orange" },
|
|
@@ -129,8 +133,15 @@
|
|
|
129
133
|
console.log("%c⏱️ LCP Trail Active", "font-weight: bold; font-size: 14px;");
|
|
130
134
|
console.log(" Highlights all LCP candidate elements with distinct colors.");
|
|
131
135
|
|
|
132
|
-
//
|
|
133
|
-
|
|
136
|
+
// Return for agent (buffered entries)
|
|
137
|
+
// Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
|
|
138
|
+
// so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
|
|
139
|
+
const trailEntries = await new Promise((resolve) => {
|
|
140
|
+
const entries = [];
|
|
141
|
+
const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
|
142
|
+
obs.observe({ type: "largest-contentful-paint", buffered: true });
|
|
143
|
+
setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
|
|
144
|
+
});
|
|
134
145
|
if (trailEntries.length === 0) {
|
|
135
146
|
return { script: "LCP-Trail", status: "error", error: "No LCP entries yet" };
|
|
136
147
|
}
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
// LCP Video Candidate
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
|
-
(() => {
|
|
5
|
-
|
|
4
|
+
(async () => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
6
|
+
console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
|
|
7
|
+
return { script: "LCP-Video-Candidate", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
|
|
8
|
+
}
|
|
9
|
+
// Chrome only exposes largest-contentful-paint entries through a PerformanceObserver, so
|
|
10
|
+
// performance.getEntriesByType() returns [] here. Collect via a buffered observer.
|
|
11
|
+
const collectBuffered = (type, timeoutMs = 100) =>
|
|
12
|
+
new Promise((resolve) => {
|
|
13
|
+
const entries = [];
|
|
14
|
+
try {
|
|
15
|
+
const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
|
16
|
+
obs.observe({ type, buffered: true });
|
|
17
|
+
setTimeout(() => { obs.disconnect(); resolve(entries); }, timeoutMs);
|
|
18
|
+
} catch {
|
|
19
|
+
resolve(entries);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const lcpEntries = await collectBuffered("largest-contentful-paint");
|
|
6
24
|
|
|
7
25
|
if (lcpEntries.length === 0) {
|
|
8
26
|
console.warn(
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
6
|
+
console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
|
|
7
|
+
return { script: "LCP", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
|
|
8
|
+
}
|
|
5
9
|
const valueToRating = (ms) =>
|
|
6
10
|
ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
|
|
7
11
|
|
|
@@ -97,8 +101,6 @@
|
|
|
97
101
|
return { script: "LCP", status: "error", error: "No LCP entries buffered" };
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
printLCP(lastLcpEntry);
|
|
101
|
-
|
|
102
104
|
const lcpActivationStart = getActivationStart();
|
|
103
105
|
const lcpValue = Math.round(Math.max(0, lastLcpEntry.startTime - lcpActivationStart));
|
|
104
106
|
const lcpRating = valueToRating(lcpValue);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Fetch & XHR Timeline — Run in console after page load
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
|
-
(() => {
|
|
4
|
+
(async () => {
|
|
5
5
|
const calls = window.__perfCalls;
|
|
6
6
|
|
|
7
7
|
if (!calls) {
|
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
|
|
15
|
+
// so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
|
|
16
|
+
const lcpEntries = await new Promise((resolve) => {
|
|
17
|
+
const entries = [];
|
|
18
|
+
const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
|
19
|
+
obs.observe({ type: "largest-contentful-paint", buffered: true });
|
|
20
|
+
setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
|
|
21
|
+
});
|
|
15
22
|
const lcpTime = lcpEntries.length
|
|
16
23
|
? Math.round(lcpEntries[lcpEntries.length - 1].startTime)
|
|
17
24
|
: window.__lcpTime ?? null;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// DOM Size and Depth
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
// Lighthouse "Avoid an excessive DOM size" thresholds
|
|
6
|
+
const THRESHOLDS = { warnNodes: 800, errorNodes: 1400, maxDepth: 32, maxChildren: 60 };
|
|
7
|
+
const MAX_ITEMS = 50;
|
|
8
|
+
const SELECTOR_LEVELS = 4;
|
|
9
|
+
|
|
10
|
+
// Short selector for reporting: stops at the nearest id, at most SELECTOR_LEVELS levels
|
|
11
|
+
// up, and marks open shadow root boundaries with ">>>". Never returns DOM nodes.
|
|
12
|
+
const selectorFor = (el) => {
|
|
13
|
+
const parts = [];
|
|
14
|
+
let node = el;
|
|
15
|
+
let truncated = false;
|
|
16
|
+
while (node && node.nodeType === 1) {
|
|
17
|
+
if (parts.length === SELECTOR_LEVELS) {
|
|
18
|
+
truncated = true;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
let part = node.localName;
|
|
22
|
+
if (node.id) {
|
|
23
|
+
parts.unshift(`${part}#${window.CSS.escape(node.id)}`);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
const cls = typeof node.className === "string" ? node.className.trim().split(/\s+/)[0] : "";
|
|
27
|
+
if (cls) part += `.${window.CSS.escape(cls)}`;
|
|
28
|
+
parts.unshift(part);
|
|
29
|
+
const parent = node.parentNode;
|
|
30
|
+
if (parent && parent.nodeType === 11 && parent.host) {
|
|
31
|
+
parts.unshift(">>>");
|
|
32
|
+
node = parent.host;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
node = node.parentElement;
|
|
36
|
+
}
|
|
37
|
+
return (truncated ? "... > " : "") + parts.join(" > ").replace(/> >>> >/g, ">>>");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Iterative walk (explicit stack) so very deep trees cannot overflow the call stack.
|
|
41
|
+
// Depth counts <html> as level 1 and continues through open shadow roots.
|
|
42
|
+
let totalElements = 0;
|
|
43
|
+
let maxDepth = 0;
|
|
44
|
+
let deepest = null;
|
|
45
|
+
let shadowRoots = 0;
|
|
46
|
+
const parents = [];
|
|
47
|
+
const stack = [document.documentElement, 1];
|
|
48
|
+
|
|
49
|
+
while (stack.length) {
|
|
50
|
+
const depth = stack.pop();
|
|
51
|
+
const el = stack.pop();
|
|
52
|
+
totalElements++;
|
|
53
|
+
if (depth > maxDepth) {
|
|
54
|
+
maxDepth = depth;
|
|
55
|
+
deepest = el;
|
|
56
|
+
}
|
|
57
|
+
const roots = [el];
|
|
58
|
+
if (el.shadowRoot) {
|
|
59
|
+
shadowRoots++;
|
|
60
|
+
roots.push(el.shadowRoot);
|
|
61
|
+
}
|
|
62
|
+
for (const root of roots) {
|
|
63
|
+
const kids = root.children;
|
|
64
|
+
if (kids.length) parents.push({ el, root, children: kids.length, depth });
|
|
65
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i], depth + 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ranked = parents
|
|
70
|
+
.sort((a, b) => b.children - a.children || a.depth - b.depth);
|
|
71
|
+
const widest = ranked[0] ?? null;
|
|
72
|
+
const widestSelector = widest ? selectorFor(widest.el) + (widest.root !== widest.el ? " (shadow root)" : "") : null;
|
|
73
|
+
|
|
74
|
+
const items = ranked.slice(0, 10).map((p) => ({
|
|
75
|
+
selector: selectorFor(p.el) + (p.root !== p.el ? " (shadow root)" : ""),
|
|
76
|
+
children: p.children,
|
|
77
|
+
depth: p.depth,
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const issues = [];
|
|
81
|
+
if (totalElements > THRESHOLDS.errorNodes) {
|
|
82
|
+
issues.push({
|
|
83
|
+
severity: "error",
|
|
84
|
+
message: `${totalElements} nodes exceeds ${THRESHOLDS.errorNodes}; a large DOM slows style recalculation and layout on every interaction`,
|
|
85
|
+
});
|
|
86
|
+
} else if (totalElements > THRESHOLDS.warnNodes) {
|
|
87
|
+
issues.push({
|
|
88
|
+
severity: "warning",
|
|
89
|
+
message: `${totalElements} nodes exceeds ${THRESHOLDS.warnNodes}; a large DOM increases style recalculation and layout cost`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (maxDepth > THRESHOLDS.maxDepth) {
|
|
93
|
+
issues.push({
|
|
94
|
+
severity: "warning",
|
|
95
|
+
message: `DOM depth of ${maxDepth} exceeds ${THRESHOLDS.maxDepth}; deep trees make style and layout work costlier (deepest: ${selectorFor(deepest)})`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (widest && widest.children > THRESHOLDS.maxChildren) {
|
|
99
|
+
issues.push({
|
|
100
|
+
severity: "warning",
|
|
101
|
+
message: `${widestSelector} has ${widest.children} children, above ${THRESHOLDS.maxChildren}; consider virtualizing or paginating the list`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const deepestElement = deepest ? selectorFor(deepest) : null;
|
|
106
|
+
|
|
107
|
+
// Human output
|
|
108
|
+
console.group("%cDOM size and depth", "font-weight: bold; font-size: 14px;");
|
|
109
|
+
console.log(`Elements: ${totalElements} (warn > ${THRESHOLDS.warnNodes}, error > ${THRESHOLDS.errorNodes})`);
|
|
110
|
+
console.log(`Max depth: ${maxDepth} (limit ${THRESHOLDS.maxDepth}) at ${deepestElement}`);
|
|
111
|
+
if (widest) console.log(`Widest parent: ${widestSelector} with ${widest.children} children (limit ${THRESHOLDS.maxChildren})`);
|
|
112
|
+
console.log(`Open shadow roots traversed: ${shadowRoots}`);
|
|
113
|
+
issues.forEach((i) => console.warn(`${i.severity}: ${i.message}`));
|
|
114
|
+
if (items.length) console.table(items);
|
|
115
|
+
console.groupEnd();
|
|
116
|
+
|
|
117
|
+
// Agent output
|
|
118
|
+
return {
|
|
119
|
+
script: "DOM-Size-and-Depth",
|
|
120
|
+
status: "ok",
|
|
121
|
+
count: items.length,
|
|
122
|
+
issues,
|
|
123
|
+
items: items.slice(0, MAX_ITEMS),
|
|
124
|
+
details: {
|
|
125
|
+
totalElements,
|
|
126
|
+
maxDepth,
|
|
127
|
+
maxChildren: widest ? widest.children : 0,
|
|
128
|
+
widestParent: widestSelector,
|
|
129
|
+
deepestElement,
|
|
130
|
+
shadowRoots,
|
|
131
|
+
thresholds: THRESHOLDS,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
})();
|