webperf-snippets 1.3.0 → 1.5.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 +67 -6
- package/package.json +3 -2
- package/snippets/CoreWebVitals/CLS.js +3 -2
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
- package/snippets/CoreWebVitals/LCP-Subparts.js +32 -35
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
- package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
- package/snippets/Interaction/Input-Latency-Breakdown.js +2 -0
- package/snippets/Interaction/Interactions.js +2 -0
- package/snippets/Interaction/Scroll-Performance.js +2 -0
- package/snippets/Loading/Back-Forward-Cache.js +7 -0
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
- package/snippets/Loading/Cache-Strategy-Analysis.js +12 -0
- package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
- package/snippets/Loading/Compression-Audit.js +3 -0
- package/snippets/Loading/Content-Visibility.js +2 -2
- package/snippets/Loading/Critical-CSS-Detection.js +6 -0
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
- package/snippets/Loading/Find-render-blocking-resources.js +1 -1
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
- package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
- package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
- package/snippets/Loading/Priority-Hints-Audit.js +10 -0
- package/snippets/Loading/Resource-Hints-Validation.js +3 -3
- package/snippets/Loading/Resource-Hints.js +7 -1
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
- package/snippets/Loading/Script-Loading.js +6 -2
- package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
- package/snippets/Loading/Service-Worker-Analysis.js +12 -2
- package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
- package/snippets/Loading/TTFB-Resources.js +1 -1
- package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
- package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
- package/snippets/Media/Image-Element-Audit.js +16 -13
- package/snippets/Media/Oversized-Images.js +4 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
- package/snippets/Media/Video-Element-Audit.js +13 -2
- package/snippets/SCHEMA.md +425 -3
- package/src/baseline.js +225 -0
- package/src/bin.js +68 -7
- package/src/blocking.js +3 -1
- package/src/conditions.js +7 -0
- package/src/mcp/documents.js +41 -21
- package/src/reporters/baseline-view.js +83 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +46 -6
- package/src/reporters/matrix-view.js +8 -2
package/README.md
CHANGED
|
@@ -70,6 +70,12 @@ JSON output (for piping into `jq` or CI):
|
|
|
70
70
|
npx webperf-snippets https://example.com --json
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.5.0" }
|
|
77
|
+
```
|
|
78
|
+
|
|
73
79
|
Single snippet:
|
|
74
80
|
|
|
75
81
|
```bash
|
|
@@ -152,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
152
158
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
153
159
|
"navMs": 1240,
|
|
154
160
|
"results": [],
|
|
155
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
161
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.5.0" }
|
|
156
162
|
}
|
|
157
163
|
```
|
|
158
164
|
|
|
@@ -232,10 +238,62 @@ npx webperf-snippets --matrix matrix.json --budget-lcp 2500
|
|
|
232
238
|
|
|
233
239
|
The options of the command line apply to every combination: `--workflow`, `--snippet`, `--wait`, `--interact-script`, `--header`, `--own-domains`, `--storage-state`, `--cpu`, `--network` and the budgets. The file replaces `cpu`, `network` and `storageState` where it sets them, and its `headers` and `ownDomains` add to the ones of the command line, the variant winning on the same header. A matrix file is meant to be versioned, so pass secrets, such as the token of an access proxy, with `--header` and an environment variable; a header of the file whose name looks like a secret raises a warning. A key the CLI does not know is an error, so a typo does not measure without its condition.
|
|
234
240
|
|
|
235
|
-
The report is a table with one row per combination and the rated median of each metric, followed by the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants) and `
|
|
241
|
+
The report is a table with one row per combination and the rated median of each metric, followed by the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants), `runs` and `conditions`, with the viewport of its device. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
|
|
236
242
|
|
|
237
243
|
With CrUX, the CLI asks once for each URL and form factor, so the variants of a device share their field data. With `--report-to`, it posts one report for each combination, the body of one page with `meta.device`, `meta.variant` and `meta.runs`; the headers of the command line and of the file are conditions of the load, and no report carries their values.
|
|
238
244
|
|
|
245
|
+
Compare a run with an earlier one with `--baseline <file>`, the `--json` output of that run. The comparison says which metrics got worse or better beyond the noise of the lab, which errors are new or fixed, and what differs in how both runs were measured:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
# On main, once: the baseline
|
|
249
|
+
npx webperf-snippets https://example.com --runs 5 --json > baseline.json
|
|
250
|
+
|
|
251
|
+
# On the pull request: compare and report
|
|
252
|
+
npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --markdown
|
|
253
|
+
|
|
254
|
+
# Fail when the page gets worse
|
|
255
|
+
npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --fail-on-regression
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of each with `--runs`; a metric that only one side measured is `not measured`. A metric regresses when it gets worse by more than its threshold, the larger of a share of the baseline and a minimum, and, when the baseline has two runs or more, ends above the slowest of them: a median inside the range of the baseline is noise the baseline already had. It improves under the same rules the other way, and otherwise it is unchanged.
|
|
259
|
+
|
|
260
|
+
| Metric | Share of the baseline | Minimum |
|
|
261
|
+
| -------------- | --------------------- | ------- |
|
|
262
|
+
| LCP, FCP, TTFB | 10% | 100 ms |
|
|
263
|
+
| INP | 10% | 50 ms |
|
|
264
|
+
| CLS | none | 0.05 |
|
|
265
|
+
|
|
266
|
+
`--threshold` changes one part of a threshold and keeps the other: `--threshold LCP=15%` the share, `--threshold LCP=200ms` the minimum, and `--threshold CLS=0.1` the only part of CLS. Repeat it for more metrics.
|
|
267
|
+
|
|
268
|
+
The errors that compare are the ones that decide the exit code: the issues of severity `error` of the steps of the workflow and the snippets that failed. Two errors are the same when they share their [code](https://github.com/nucliweb/webperf-snippets/blob/main/snippets/SCHEMA.md#issue-codes) and the resource they name, whatever their messages say; a resource of the page itself compares by its path and query, so the review app of a pull request, on another host, matches production. A snippet without codes on one side, as in a baseline of an earlier version, compares by message with the figures left out, so "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…" are one error. An error that only this run has is new, and one that only the baseline had is fixed. Warnings and information do not compare, since they change too easily.
|
|
269
|
+
|
|
270
|
+
The report adds the comparison after the usual one, and `--markdown` a table for the pull request comment. The JSON output carries it in `baseline`:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
"baseline": {
|
|
274
|
+
"file": "baseline.json",
|
|
275
|
+
"url": "https://example.com/",
|
|
276
|
+
"runs": { "count": 5, "medianBy": "LCP" },
|
|
277
|
+
"metrics": {
|
|
278
|
+
"LCP": { "baseline": 2100, "current": 2480, "delta": 380, "deltaPercent": 18.1, "threshold": 210, "verdict": "regressed" }
|
|
279
|
+
},
|
|
280
|
+
"errors": { "new": [{ "id": "render-blocking", "message": "1 render-blocking resource(s) delay rendering until 900ms" }], "fixed": [] },
|
|
281
|
+
"warnings": []
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply.
|
|
286
|
+
|
|
287
|
+
The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
|
|
288
|
+
|
|
289
|
+
A matrix compares with the `--json` output of an earlier matrix, each combination with the one of the same `id`:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
npx webperf-snippets --matrix matrix.json --runs 3 --baseline last-week.json
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The table adds a change column after each metric: the change when it counts (`▲ +380ms`, `▼ −90ms`), `=` when it is unchanged, and `new` for a combination the baseline did not have. Below the table come the warnings, once when every combination has them, the new and fixed errors of each combination, and the combinations that only one side has. `--markdown` gives the same, and the `<details>` block of each combination carries its own comparison. In the JSON output each combination carries its `baseline`, and the root `baseline` names the file and the `added` and `missing` combinations. `--fail-on-regression` exits with `1` when any combination regressed or has a new error.
|
|
296
|
+
|
|
239
297
|
CI gating:
|
|
240
298
|
|
|
241
299
|
```bash
|
|
@@ -261,6 +319,9 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
261
319
|
| `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
|
|
262
320
|
| `--median-by <metric>` | Metric that picks the median run: `LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`. Default: the first of LCP, FCP, TTFB and `navMs` measured. |
|
|
263
321
|
| `--matrix <file>` | Measure every URL of a JSON file on every device and in every variant it lists, and report a table of the medians. |
|
|
322
|
+
| `--baseline <file>` | Compare the page, or each combination of a matrix, with the `--json` output of an earlier run: which metrics got worse or better beyond the noise, and which errors are new or fixed. |
|
|
323
|
+
| `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
|
|
324
|
+
| `--fail-on-regression` | Exit `1` when a metric regressed or an error is new against `--baseline`. |
|
|
264
325
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
265
326
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
266
327
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
@@ -300,8 +361,8 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
|
|
|
300
361
|
| Code | Meaning |
|
|
301
362
|
| ---- | --------------------------------------------- |
|
|
302
363
|
| `0` | All checks passed. |
|
|
303
|
-
| `1` | Budget violation, a snippet errored,
|
|
304
|
-
| `2` | Usage error (missing URL, unknown workflow).
|
|
364
|
+
| `1` | Budget violation, a snippet errored, a step of the workflow reported an `error` issue, or, with `--fail-on-regression`, a regression against the baseline. |
|
|
365
|
+
| `2` | Usage error (missing URL, unknown workflow, a baseline or threshold that is not valid). |
|
|
305
366
|
|
|
306
367
|
## MCP server
|
|
307
368
|
|
|
@@ -328,8 +389,8 @@ The server also offers resources, documents a client can attach to the context:
|
|
|
328
389
|
|
|
329
390
|
| Resource | What it has |
|
|
330
391
|
| -------------------------------- | ------------------------------------------------------------------------ |
|
|
331
|
-
| `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result. |
|
|
332
|
-
| `webperf://schema/{snippet}` | The fields one snippet returns, with an example.
|
|
392
|
+
| `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result, the rules of the issue codes. |
|
|
393
|
+
| `webperf://schema/{snippet}` | The fields one snippet returns, with an example, and the codes of its issues. |
|
|
333
394
|
| `webperf://workflows/{category}` | The workflows and the decision tree of a category. |
|
|
334
395
|
|
|
335
396
|
And prompts, templates that chain the tools (in Claude Code, `/mcp__webperf__audit_lcp`): `audit_lcp`, `debug_inp` and `audit_page`, each with a `url` argument.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@modelcontextprotocol/client": "^2.3.1",
|
|
24
|
-
"vitest": "^5.0.5"
|
|
24
|
+
"vitest": "^5.0.5",
|
|
25
|
+
"web-vitals": "^6.2.3"
|
|
25
26
|
},
|
|
26
27
|
"engines": {
|
|
27
28
|
"node": ">=20.12.0"
|
|
@@ -53,10 +53,11 @@
|
|
|
53
53
|
|
|
54
54
|
observer.observe({ type: "layout-shift", buffered: true });
|
|
55
55
|
|
|
56
|
-
// Update on visibility change (final CLS)
|
|
56
|
+
// Update on visibility change (final CLS). The shifts that the observer has not delivered yet are
|
|
57
|
+
// in takeRecords(): without them, the last shift before the page hides is lost.
|
|
57
58
|
document.addEventListener("visibilitychange", () => {
|
|
58
59
|
if (document.visibilityState === "hidden") {
|
|
59
|
-
observer.takeRecords();
|
|
60
|
+
for (const entry of observer.takeRecords()) addShift(entry);
|
|
60
61
|
console.log("%c📊 Final CLS (on page hide):", "font-weight: bold;");
|
|
61
62
|
logCLS();
|
|
62
63
|
}
|
|
@@ -213,23 +213,22 @@
|
|
|
213
213
|
}
|
|
214
214
|
const issuesSync = [];
|
|
215
215
|
if (lowEntropyCount > 0) {
|
|
216
|
-
issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
|
|
216
|
+
issuesSync.push({ code: "low-entropy-images", severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
|
|
217
217
|
}
|
|
218
218
|
if (lcpImageSync?.isLowEntropy) {
|
|
219
|
-
issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
|
|
219
|
+
issuesSync.push({ code: "lcp-low-entropy", severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
|
|
220
220
|
}
|
|
221
221
|
// Entropy applies to the LCP only when it is an <img> whose file size can be read; for text, or an
|
|
222
222
|
// image this snippet cannot read, it says so. The images of the page are still listed.
|
|
223
223
|
const lcpTag = lcpElementSync ? lcpElementSync.tagName.toLowerCase() : null;
|
|
224
224
|
if (lcpTag && !lcpImageSync) {
|
|
225
|
-
issuesSync.push(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
? "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed."
|
|
225
|
+
issuesSync.push(
|
|
226
|
+
lcpTag === "img"
|
|
227
|
+
? { code: "lcp-size-unknown", severity: "info", message: "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed." }
|
|
229
228
|
: lcpUrlSync
|
|
230
|
-
? `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.`
|
|
231
|
-
: `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it
|
|
232
|
-
|
|
229
|
+
? { code: "lcp-not-img", severity: "info", message: `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.` }
|
|
230
|
+
: { code: "lcp-is-text", severity: "info", message: `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it.` }
|
|
231
|
+
);
|
|
233
232
|
}
|
|
234
233
|
// 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
|
|
235
234
|
const MAX_ITEMS = 50;
|
|
@@ -76,6 +76,23 @@
|
|
|
76
76
|
return { waiting: firstByte - requestStart, download: responseEnd - firstByte };
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
// The times that split the LCP into its subparts, as web-vitals computes them. The resource is the
|
|
80
|
+
// last request for the LCP URL that started before the render: an earlier download of the same URL
|
|
81
|
+
// did not paint the element. Its end is capped at the LCP, since a video keeps downloading after
|
|
82
|
+
// its first frame paints, so the four subparts always add up to the LCP.
|
|
83
|
+
const lcpSubpartTimes = (lcpEntry, navEntry) => {
|
|
84
|
+
const activationStart = navEntry.activationStart || 0;
|
|
85
|
+
const renderTime = Math.max(0, lcpEntry.startTime - activationStart);
|
|
86
|
+
const resEntry = performance
|
|
87
|
+
.getEntriesByType("resource")
|
|
88
|
+
.filter((e) => !isOwnRequest(e))
|
|
89
|
+
.findLast((e) => e.name === lcpEntry.url && (e.requestStart || e.startTime) <= lcpEntry.startTime);
|
|
90
|
+
const ttfb = Math.max(0, navEntry.responseStart - activationStart);
|
|
91
|
+
const requestStart = Math.max(ttfb, resEntry ? (resEntry.requestStart || resEntry.startTime) - activationStart : 0);
|
|
92
|
+
const responseEnd = Math.min(renderTime, Math.max(requestStart, resEntry ? resEntry.responseEnd - activationStart : 0));
|
|
93
|
+
return { resEntry, activationStart, ttfb, requestStart, responseEnd, renderTime };
|
|
94
|
+
};
|
|
95
|
+
|
|
79
96
|
const getNavigationEntry = () => {
|
|
80
97
|
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
81
98
|
if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) {
|
|
@@ -91,31 +108,14 @@
|
|
|
91
108
|
const navEntry = getNavigationEntry();
|
|
92
109
|
if (!navEntry) return;
|
|
93
110
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const activationStart = navEntry.activationStart || 0;
|
|
99
|
-
|
|
100
|
-
// Calculate sub-parts
|
|
101
|
-
const ttfb = Math.max(0, navEntry.responseStart - activationStart);
|
|
102
|
-
|
|
103
|
-
const lcpRequestStart = Math.max(
|
|
111
|
+
const {
|
|
112
|
+
resEntry: lcpResEntry,
|
|
113
|
+
activationStart,
|
|
104
114
|
ttfb,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const lcpResponseEnd = Math.max(
|
|
111
|
-
lcpRequestStart,
|
|
112
|
-
lcpResEntry ? lcpResEntry.responseEnd - activationStart : 0
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
const lcpRenderTime = Math.max(
|
|
116
|
-
lcpResponseEnd,
|
|
117
|
-
lcpEntry.startTime - activationStart
|
|
118
|
-
);
|
|
115
|
+
requestStart: lcpRequestStart,
|
|
116
|
+
responseEnd: lcpResponseEnd,
|
|
117
|
+
renderTime: lcpRenderTime,
|
|
118
|
+
} = lcpSubpartTimes(lcpEntry, navEntry);
|
|
119
119
|
|
|
120
120
|
const loadSplit = splitLoadTime(lcpResEntry, lcpRequestStart, lcpResponseEnd, activationStart);
|
|
121
121
|
|
|
@@ -278,17 +278,14 @@
|
|
|
278
278
|
if (!navEntrySync) {
|
|
279
279
|
return { script: "LCP-Subparts", status: "error", error: "No navigation entry" };
|
|
280
280
|
}
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
lcpResEntrySync ? lcpResEntrySync.responseEnd - activationStartSync : 0
|
|
290
|
-
);
|
|
291
|
-
const lcpRenderTimeSync = Math.max(lcpResponseEndSync, lcpEntry.startTime - activationStartSync);
|
|
281
|
+
const {
|
|
282
|
+
resEntry: lcpResEntrySync,
|
|
283
|
+
activationStart: activationStartSync,
|
|
284
|
+
ttfb: ttfbSync,
|
|
285
|
+
requestStart: lcpRequestStartSync,
|
|
286
|
+
responseEnd: lcpResponseEndSync,
|
|
287
|
+
renderTime: lcpRenderTimeSync,
|
|
288
|
+
} = lcpSubpartTimes(lcpEntry, navEntrySync);
|
|
292
289
|
const totalSync = Math.round(lcpRenderTimeSync);
|
|
293
290
|
const ratingSync = valueToRating(totalSync);
|
|
294
291
|
const ttfbVal = Math.round(ttfbSync);
|
|
@@ -71,22 +71,22 @@
|
|
|
71
71
|
// The value of a non-video LCP belongs to the LCP snippet; this one only says what the element is
|
|
72
72
|
if (!element || element.tagName !== "VIDEO") {
|
|
73
73
|
const tag = element ? element.tagName.toLowerCase() : null;
|
|
74
|
-
const
|
|
75
|
-
? `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.`
|
|
76
|
-
: "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it.";
|
|
74
|
+
const issue = tag
|
|
75
|
+
? { code: "lcp-not-video", severity: "info", message: `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.` }
|
|
76
|
+
: { code: "lcp-element-removed", severity: "info", message: "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it." };
|
|
77
77
|
console.log("%cLCP element is not a <video>", "font-weight: bold;");
|
|
78
78
|
console.log("");
|
|
79
79
|
console.log(` Tag : ${tag ? `<${tag}>` : "(element no longer in DOM)"}`);
|
|
80
80
|
if (lcp.url) console.log(` URL : ${lcp.url}`);
|
|
81
81
|
if (element) console.log(" Element :", element);
|
|
82
82
|
console.log("");
|
|
83
|
-
console.log(` ${message}`);
|
|
83
|
+
console.log(` ${issue.message}`);
|
|
84
84
|
console.groupEnd();
|
|
85
85
|
return {
|
|
86
86
|
script: "LCP-Video-Candidate",
|
|
87
87
|
status: "ok",
|
|
88
88
|
details: { isVideo: false, lcpElement: tag, lcpUrl: lcp.url || null },
|
|
89
|
-
issues: [
|
|
89
|
+
issues: [issue],
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -140,34 +140,35 @@
|
|
|
140
140
|
|
|
141
141
|
if (secondsAfterLoad > 10) {
|
|
142
142
|
issues.push({
|
|
143
|
+
code: "late-candidate",
|
|
143
144
|
s: "warning",
|
|
144
145
|
msg: `The LCP candidate rendered ${secondsAfterLoad} s after the page loaded, because the page stayed open without interaction. Reload and run the snippet again to measure the LCP of the load`,
|
|
145
146
|
});
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
if (lcpSource === "first-frame") {
|
|
149
|
-
issues.push({ s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
|
|
150
|
+
issues.push({ code: "first-frame-lcp", s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
|
|
150
151
|
}
|
|
151
152
|
if (lcpSource === "first-frame" && (!autoplay || !muted)) {
|
|
152
|
-
issues.push({ s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
|
|
153
|
+
issues.push({ code: "first-frame-not-autoplay-muted", s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
if (posterAttr && !posterPreload) {
|
|
156
|
-
issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
|
|
157
|
+
issues.push({ code: "poster-not-preloaded", s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
|
|
157
158
|
} else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") {
|
|
158
|
-
issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
|
|
159
|
+
issues.push({ code: "poster-preload-no-fetchpriority", s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
if (posterAttr && !isModernFormat && posterFormat !== "unknown") {
|
|
162
|
-
issues.push({ s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
|
|
163
|
+
issues.push({ code: "poster-legacy-format", s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
if (isCrossOrigin) {
|
|
166
|
-
issues.push({ s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
|
|
167
|
+
issues.push({ code: "render-time-hidden", s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
if (!autoplay && preload === "none") {
|
|
170
|
-
issues.push({ s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
|
|
171
|
+
issues.push({ code: "preload-none-delays-poster", s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
// Summary
|
|
@@ -265,6 +266,6 @@
|
|
|
265
266
|
isCrossOrigin,
|
|
266
267
|
videoAttributes: { autoplay, muted, playsinline, preload },
|
|
267
268
|
},
|
|
268
|
-
issues: issues.map((i) => ({ severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
|
|
269
|
+
issues: issues.map((i) => ({ code: i.code, severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
|
|
269
270
|
};
|
|
270
271
|
})();
|
|
@@ -80,23 +80,27 @@
|
|
|
80
80
|
const issues = [];
|
|
81
81
|
if (totalElements > THRESHOLDS.errorNodes) {
|
|
82
82
|
issues.push({
|
|
83
|
+
code: "too-many-nodes",
|
|
83
84
|
severity: "error",
|
|
84
85
|
message: `${totalElements} nodes exceeds ${THRESHOLDS.errorNodes}; a large DOM slows style recalculation and layout on every interaction`,
|
|
85
86
|
});
|
|
86
87
|
} else if (totalElements > THRESHOLDS.warnNodes) {
|
|
87
88
|
issues.push({
|
|
89
|
+
code: "too-many-nodes",
|
|
88
90
|
severity: "warning",
|
|
89
91
|
message: `${totalElements} nodes exceeds ${THRESHOLDS.warnNodes}; a large DOM increases style recalculation and layout cost`,
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
94
|
if (maxDepth > THRESHOLDS.maxDepth) {
|
|
93
95
|
issues.push({
|
|
96
|
+
code: "too-deep",
|
|
94
97
|
severity: "warning",
|
|
95
98
|
message: `DOM depth of ${maxDepth} exceeds ${THRESHOLDS.maxDepth}; deep trees make style and layout work costlier (deepest: ${selectorFor(deepest)})`,
|
|
96
99
|
});
|
|
97
100
|
}
|
|
98
101
|
if (widest && widest.children > THRESHOLDS.maxChildren) {
|
|
99
102
|
issues.push({
|
|
103
|
+
code: "too-many-children",
|
|
100
104
|
severity: "warning",
|
|
101
105
|
message: `${widestSelector} has ${widest.children} children, above ${THRESHOLDS.maxChildren}; consider virtualizing or paginating the list`,
|
|
102
106
|
});
|
|
@@ -265,8 +265,10 @@
|
|
|
265
265
|
.sort((a, b) => b[1] - a[1])
|
|
266
266
|
.slice(0, 10)
|
|
267
267
|
.map(([property, count]) => ({
|
|
268
|
+
code: "forced-layout",
|
|
268
269
|
severity: count > 5 ? "error" : "warning",
|
|
269
270
|
message: `${property} triggered ${count} forced synchronous layout${count > 1 ? "s" : ""}. Read layout properties before writing to the DOM.`,
|
|
271
|
+
target: property,
|
|
270
272
|
})),
|
|
271
273
|
};
|
|
272
274
|
};
|
|
@@ -259,8 +259,10 @@
|
|
|
259
259
|
const bottleneck = phases.reduce((a, c) => (a.value > c.value ? a : c));
|
|
260
260
|
const rating = valueToRating(p75(b.durations));
|
|
261
261
|
return {
|
|
262
|
+
code: "slow-event-type",
|
|
262
263
|
severity: rating === "poor" ? "error" : "warning",
|
|
263
264
|
message: `${type} P75 is ${Math.round(p75(b.durations))}ms (${rating}). Bottleneck: ${bottleneck.name}. Fix: ${BOTTLENECK_FIXES[bottleneck.name]}.`,
|
|
265
|
+
target: type,
|
|
264
266
|
};
|
|
265
267
|
});
|
|
266
268
|
|
|
@@ -264,8 +264,10 @@
|
|
|
264
264
|
.sort((a, b) => b.duration - a.duration)
|
|
265
265
|
.slice(0, 10)
|
|
266
266
|
.map((i) => ({
|
|
267
|
+
code: "slow-interaction",
|
|
267
268
|
severity: i.rating === "poor" ? "error" : "warning",
|
|
268
269
|
message: `${i.type} interaction took ${Math.round(i.duration)}ms (${i.rating}), above the ${i.rating === "poor" ? "500ms" : "200ms"} threshold`,
|
|
270
|
+
target: i.type,
|
|
269
271
|
})),
|
|
270
272
|
};
|
|
271
273
|
};
|
|
@@ -207,12 +207,14 @@
|
|
|
207
207
|
const issues = [];
|
|
208
208
|
if (nonPassiveListeners.length > 0) {
|
|
209
209
|
issues.push({
|
|
210
|
+
code: "non-passive-listeners",
|
|
210
211
|
severity: "warning",
|
|
211
212
|
message: `${nonPassiveListeners.length} non-passive scroll or touch listener(s). Add { passive: true } so scrolling does not wait for JavaScript.`,
|
|
212
213
|
});
|
|
213
214
|
}
|
|
214
215
|
if (css.willChangeElements.length > 0) {
|
|
215
216
|
issues.push({
|
|
217
|
+
code: "will-change",
|
|
216
218
|
severity: "info",
|
|
217
219
|
message: `will-change is set on ${css.willChangeElements.length} element(s). Remove it from elements that do not animate, since each one keeps a compositor layer.`,
|
|
218
220
|
});
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
: [];
|
|
83
83
|
confirmed.forEach(({ reason, frame }) => {
|
|
84
84
|
issues.push({
|
|
85
|
+
code: 'not-restored',
|
|
85
86
|
reason: `${reason} (${frame})`,
|
|
86
87
|
severity: 'high',
|
|
87
88
|
source: 'browser',
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
// cannot be detected from JavaScript.
|
|
94
95
|
if (window.onunload !== null && !confirmed.some(({ reason }) => reason === 'unload-listener')) {
|
|
95
96
|
issues.push({
|
|
97
|
+
code: 'unload-handler',
|
|
96
98
|
reason: 'window.onunload handler set',
|
|
97
99
|
severity: 'high',
|
|
98
100
|
source: 'detected',
|
|
@@ -108,6 +110,7 @@
|
|
|
108
110
|
const cacheControl = res.headers.get('cache-control') || '';
|
|
109
111
|
if (/no-store/i.test(cacheControl) && !confirmed.some(({ reason }) => reason === 'response-cache-control-no-store')) {
|
|
110
112
|
issues.push({
|
|
113
|
+
code: 'cache-control-no-store',
|
|
111
114
|
reason: 'Cache-Control: no-store on the page response',
|
|
112
115
|
severity: 'medium',
|
|
113
116
|
source: 'detected',
|
|
@@ -123,6 +126,7 @@
|
|
|
123
126
|
const iframeCount = document.querySelectorAll('iframe').length;
|
|
124
127
|
if (iframeCount > 0) {
|
|
125
128
|
issues.push({
|
|
129
|
+
code: 'iframes',
|
|
126
130
|
reason: `${iframeCount} iframe(s) on the page`,
|
|
127
131
|
severity: 'info',
|
|
128
132
|
source: 'note',
|
|
@@ -132,6 +136,7 @@
|
|
|
132
136
|
|
|
133
137
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
|
134
138
|
issues.push({
|
|
139
|
+
code: 'service-worker',
|
|
135
140
|
reason: 'Service Worker controls the page',
|
|
136
141
|
severity: 'info',
|
|
137
142
|
source: 'note',
|
|
@@ -255,8 +260,10 @@
|
|
|
255
260
|
notRestoredReasons: results.notRestoredReasons,
|
|
256
261
|
},
|
|
257
262
|
issues: results.blockingReasons.map((i) => ({
|
|
263
|
+
code: i.code,
|
|
258
264
|
severity: i.severity === 'high' ? 'error' : i.severity === 'medium' ? 'warning' : 'info',
|
|
259
265
|
message: i.reason,
|
|
266
|
+
...(i.code === 'not-restored' ? { target: i.reason } : {}),
|
|
260
267
|
})),
|
|
261
268
|
});
|
|
262
269
|
|
|
@@ -151,21 +151,23 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
|
|
|
151
151
|
|
|
152
152
|
const issues = [];
|
|
153
153
|
if (rules.length === 0) {
|
|
154
|
-
issues.push({ severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
|
|
154
|
+
issues.push({ code: "no-wide-rules", severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
|
|
155
155
|
} else {
|
|
156
156
|
issues.push({
|
|
157
|
+
code: "wide-rules",
|
|
157
158
|
severity: totalBytes > WARNING_BYTES ? "warning" : "info",
|
|
158
159
|
message: `${rules.length} @media rules for viewports wider than ${minWidth}px add ${formatBytes(totalBytes)} of CSS that narrower screens download and parse without using. Move them to a stylesheet with a media attribute, such as <link rel="stylesheet" href="wide.css" media="(min-width: ${minWidth + 1}px)">, which narrower screens fetch at low priority without blocking rendering`,
|
|
159
160
|
});
|
|
160
161
|
if (rules.length >= 20 && uniquenessRatio < 0.1) {
|
|
161
162
|
issues.push({
|
|
163
|
+
code: "repeated-queries",
|
|
162
164
|
severity: "info",
|
|
163
165
|
message: `${queries.length} distinct queries repeat across ${rules.length} @media blocks, the pattern of component-scoped or compiled CSS. Grouping the rules of each query under one block shortens the stylesheets`,
|
|
164
166
|
});
|
|
165
167
|
}
|
|
166
168
|
}
|
|
167
169
|
if (corsBlockedCount > 0) {
|
|
168
|
-
issues.push({ severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
|
|
170
|
+
issues.push({ code: "unreadable-stylesheets", severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
// --- Console output --------------------------------------------------------------------
|
|
@@ -337,6 +337,7 @@
|
|
|
337
337
|
|
|
338
338
|
if (isStaticAsset(type) && cc["no-store"]) {
|
|
339
339
|
entry.antiPatterns.push({
|
|
340
|
+
code: "static-no-store",
|
|
340
341
|
id: "static-no-store",
|
|
341
342
|
severity: "error",
|
|
342
343
|
message: `${type} asset with no-store; static assets should be cacheable`,
|
|
@@ -345,6 +346,7 @@
|
|
|
345
346
|
|
|
346
347
|
if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) {
|
|
347
348
|
entry.antiPatterns.push({
|
|
349
|
+
code: "versioned-short-cache",
|
|
348
350
|
id: "versioned-short-cache",
|
|
349
351
|
severity: "warning",
|
|
350
352
|
message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable`,
|
|
@@ -353,6 +355,7 @@
|
|
|
353
355
|
|
|
354
356
|
if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) {
|
|
355
357
|
entry.antiPatterns.push({
|
|
358
|
+
code: "versioned-not-immutable",
|
|
356
359
|
id: "versioned-not-immutable",
|
|
357
360
|
severity: "info",
|
|
358
361
|
message: `Versioned URL (hash detected) without Cache-Control: immutable`,
|
|
@@ -364,6 +367,7 @@
|
|
|
364
367
|
["none", "no-store", "no-cache"].includes(strategy)
|
|
365
368
|
) {
|
|
366
369
|
entry.antiPatterns.push({
|
|
370
|
+
code: "large-no-cache",
|
|
367
371
|
id: "large-no-cache",
|
|
368
372
|
severity: "error",
|
|
369
373
|
message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache`,
|
|
@@ -372,6 +376,7 @@
|
|
|
372
376
|
|
|
373
377
|
if (entry.headers["expires"] && !entry.headers["cache-control"]) {
|
|
374
378
|
entry.antiPatterns.push({
|
|
379
|
+
code: "expires-without-cc",
|
|
375
380
|
id: "expires-without-cc",
|
|
376
381
|
severity: "warning",
|
|
377
382
|
message: `Uses Expires header without Cache-Control (outdated pattern)`,
|
|
@@ -380,6 +385,7 @@
|
|
|
380
385
|
|
|
381
386
|
if (isStaticAsset(type) && maxAge === 0) {
|
|
382
387
|
entry.antiPatterns.push({
|
|
388
|
+
code: "maxage-zero-static",
|
|
383
389
|
id: "maxage-zero-static",
|
|
384
390
|
severity: "warning",
|
|
385
391
|
message: `Static asset with max-age=0, which forces revalidation on every request`,
|
|
@@ -394,6 +400,7 @@
|
|
|
394
400
|
!cc["no-store"]
|
|
395
401
|
) {
|
|
396
402
|
entry.antiPatterns.push({
|
|
403
|
+
code: "short-cache-static",
|
|
397
404
|
id: "short-cache-static",
|
|
398
405
|
severity: "warning",
|
|
399
406
|
message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration`,
|
|
@@ -472,6 +479,7 @@
|
|
|
472
479
|
// are anti-patterns like the header-based ones: they reach `items` and `issues`.
|
|
473
480
|
for (const entry of uncompressedResources) {
|
|
474
481
|
entry.antiPatterns.push({
|
|
482
|
+
code: "uncompressed",
|
|
475
483
|
id: "uncompressed",
|
|
476
484
|
severity: "warning",
|
|
477
485
|
message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip`,
|
|
@@ -480,6 +488,7 @@
|
|
|
480
488
|
for (const duplicate of costlyDuplicates) {
|
|
481
489
|
// One finding per URL, on the first request, not one per request
|
|
482
490
|
entries.find((e) => e.url === duplicate.url).antiPatterns.push({
|
|
491
|
+
code: "duplicate-uncached",
|
|
483
492
|
id: "duplicate-uncached",
|
|
484
493
|
severity: "warning",
|
|
485
494
|
message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable`,
|
|
@@ -551,6 +560,7 @@
|
|
|
551
560
|
.join(", ");
|
|
552
561
|
coverageIssue = {
|
|
553
562
|
severity: "info",
|
|
563
|
+
code: "header-coverage",
|
|
554
564
|
id: "header-coverage",
|
|
555
565
|
message: `Cache headers were read for ${headersAnalyzed} of ${entries.length} resources (${headerCoveragePercent}%): the rest are on other origins or are not static assets, so their Cache-Control cannot be read from the page. Most of them are on ${topHosts}; check those hosts in the Network panel or with curl -I`,
|
|
556
566
|
};
|
|
@@ -916,10 +926,12 @@
|
|
|
916
926
|
})),
|
|
917
927
|
issues: [
|
|
918
928
|
...allAntiPatterns.map((p) => ({
|
|
929
|
+
code: p.code,
|
|
919
930
|
severity: p.severity,
|
|
920
931
|
id: p.id,
|
|
921
932
|
resource: p.resource,
|
|
922
933
|
message: p.message,
|
|
934
|
+
target: p.url,
|
|
923
935
|
})),
|
|
924
936
|
...(coverageIssue ? [coverageIssue] : []),
|
|
925
937
|
],
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
if (sameOrigin && referrerPath !== currentPath && referrerPath !== '') {
|
|
100
100
|
hasClientRedirect = true;
|
|
101
101
|
indicators.push({
|
|
102
|
+
code: 'same-origin-navigation',
|
|
102
103
|
type: 'Same-origin navigation',
|
|
103
104
|
from: referrerPath,
|
|
104
105
|
to: currentPath,
|
|
@@ -153,6 +154,7 @@
|
|
|
153
154
|
// Only flag if it's BOTH fast AND small (likely a redirect page)
|
|
154
155
|
if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) {
|
|
155
156
|
indicators.push({
|
|
157
|
+
code: 'fast-minimal-navigation',
|
|
156
158
|
type: 'Fast minimal-content navigation',
|
|
157
159
|
duration: domContentLoaded.toFixed(1) + 'ms',
|
|
158
160
|
size: (responseSize / 1024).toFixed(1) + ' KB',
|
|
@@ -337,9 +339,9 @@
|
|
|
337
339
|
referrerPath,
|
|
338
340
|
},
|
|
339
341
|
issues: [
|
|
340
|
-
...(serverRedirects > 0 ? [{ severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []),
|
|
341
|
-
...(documentNavigations.length > 0 ? [{ severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []),
|
|
342
|
-
...indicators.filter(i => i.severity === "warning").map(i => ({ severity: "warning", message: i.type })),
|
|
342
|
+
...(serverRedirects > 0 ? [{ code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []),
|
|
343
|
+
...(documentNavigations.length > 0 ? [{ code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []),
|
|
344
|
+
...indicators.filter(i => i.severity === "warning").map(i => ({ code: i.code, severity: "warning", message: i.type })),
|
|
343
345
|
],
|
|
344
346
|
};
|
|
345
347
|
})();
|