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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
// Canonical source of the helpers that several snippets copy.
|
|
3
|
+
//
|
|
4
|
+
// Snippets run pasted into the DevTools console, so they cannot import anything. Each snippet
|
|
5
|
+
// that needs a helper carries a copy of it between the same two markers:
|
|
6
|
+
//
|
|
7
|
+
// // @shared <name>
|
|
8
|
+
// ...
|
|
9
|
+
// // @end-shared <name>
|
|
10
|
+
//
|
|
11
|
+
// `npm run check:consistency` fails when a copy differs from the block below (indentation is
|
|
12
|
+
// ignored). Edit the block here first, then paste it into every snippet that carries the marker.
|
|
13
|
+
|
|
14
|
+
// @shared getRootDomain
|
|
15
|
+
function getRootDomain(hostname) {
|
|
16
|
+
const host = hostname.replace(/\.$/, "");
|
|
17
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
18
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
19
|
+
const parts = host.split(".");
|
|
20
|
+
if (parts.length <= 2) return host;
|
|
21
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
22
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
23
|
+
const tld = parts[parts.length - 1];
|
|
24
|
+
const sld = parts[parts.length - 2];
|
|
25
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
26
|
+
return parts.slice(-2).join(".");
|
|
27
|
+
}
|
|
28
|
+
// @end-shared getRootDomain
|
|
29
|
+
|
|
30
|
+
// @shared isFirstParty
|
|
31
|
+
function isFirstParty(hostname) {
|
|
32
|
+
return getRootDomain(hostname) === getRootDomain(location.hostname);
|
|
33
|
+
}
|
|
34
|
+
// @end-shared isFirstParty
|
|
35
|
+
|
|
36
|
+
// @shared formatBytes
|
|
37
|
+
function formatBytes(bytes) {
|
|
38
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
39
|
+
if (bytes === 0) return "0 B";
|
|
40
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
41
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
42
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
43
|
+
}
|
|
44
|
+
// @end-shared formatBytes
|
package/src/bin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { parseArgs } from "node:util";
|
|
3
4
|
import { loadSnippet } from "./load-snippet.js";
|
|
4
5
|
import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
|
|
@@ -9,6 +10,8 @@ import { RULES } from "./decision-tree.js";
|
|
|
9
10
|
import { reportHuman } from "./reporters/human.js";
|
|
10
11
|
import { reportJson } from "./reporters/json.js";
|
|
11
12
|
import { reportMarkdown } from "./reporters/markdown.js";
|
|
13
|
+
import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
|
|
14
|
+
import { fetchCrux, formFactorFor } from "./crux.js";
|
|
12
15
|
|
|
13
16
|
const WORKFLOWS = {
|
|
14
17
|
"core-web-vitals": cwvWorkflow,
|
|
@@ -19,6 +22,7 @@ const WORKFLOWS = {
|
|
|
19
22
|
const SNIPPET_ALIASES = {
|
|
20
23
|
LCP: "CoreWebVitals/LCP",
|
|
21
24
|
CLS: "CoreWebVitals/CLS",
|
|
25
|
+
INP: "CoreWebVitals/INP",
|
|
22
26
|
"LCP-Subparts": "CoreWebVitals/LCP-Subparts",
|
|
23
27
|
fonts: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
|
|
24
28
|
"Fonts-Preloaded-Loaded-and-used-above-the-fold":
|
|
@@ -58,7 +62,7 @@ Options:
|
|
|
58
62
|
--workflow <name> Workflow to run (default: core-web-vitals)
|
|
59
63
|
Workflows: core-web-vitals, audit, loading
|
|
60
64
|
--snippet <name> Run a single snippet by alias or Category/Name path
|
|
61
|
-
Aliases: LCP, CLS, LCP-Subparts, fonts,
|
|
65
|
+
Aliases: LCP, CLS, INP, LCP-Subparts, fonts,
|
|
62
66
|
render-blocking, resource-hints, preload-scripts,
|
|
63
67
|
priority-hints, critical-css, ttfb,
|
|
64
68
|
script-parties, script-loading,
|
|
@@ -71,6 +75,14 @@ Options:
|
|
|
71
75
|
--budget-cls <score> Exit 1 if CLS exceeds this value
|
|
72
76
|
--interact-script <path> JSON file with interactions to run before evaluation
|
|
73
77
|
Actions: scroll, click, hover, type, wait
|
|
78
|
+
--storage-state <path> Playwright storage state (cookies + localStorage) to
|
|
79
|
+
measure pages that require authentication
|
|
80
|
+
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
81
|
+
Also read from the CRUX_API_KEY environment variable
|
|
82
|
+
--report-to <url> POST the results to this https URL after the run
|
|
83
|
+
(without it the CLI makes no external calls)
|
|
84
|
+
--api-key <key> Sent as "Authorization: Bearer <key>" with --report-to.
|
|
85
|
+
Prefer the PERF_REVIEWS_API_KEY environment variable
|
|
74
86
|
--verbose Show all items, even for passing checks
|
|
75
87
|
--headed Show the browser window (debug)
|
|
76
88
|
-h, --help Show this help
|
|
@@ -84,6 +96,7 @@ Examples:
|
|
|
84
96
|
npx webperf-snippets https://web.dev --snippet fonts
|
|
85
97
|
npx webperf-snippets https://web.dev --budget-lcp 2500
|
|
86
98
|
npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
|
|
99
|
+
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
87
100
|
`;
|
|
88
101
|
|
|
89
102
|
function fail(message, code = 2) {
|
|
@@ -131,6 +144,10 @@ async function main() {
|
|
|
131
144
|
"budget-cls": { type: "string" },
|
|
132
145
|
viewport: { type: "string" },
|
|
133
146
|
"interact-script": { type: "string" },
|
|
147
|
+
"storage-state": { type: "string" },
|
|
148
|
+
"crux-key": { type: "string" },
|
|
149
|
+
"report-to": { type: "string" },
|
|
150
|
+
"api-key": { type: "string" },
|
|
134
151
|
verbose: { type: "boolean" },
|
|
135
152
|
headed: { type: "boolean" },
|
|
136
153
|
help: { type: "boolean", short: "h" },
|
|
@@ -161,19 +178,33 @@ async function main() {
|
|
|
161
178
|
fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
const reportUrl = values["report-to"];
|
|
182
|
+
if (reportUrl) {
|
|
183
|
+
const problem = validateReportUrl(reportUrl);
|
|
184
|
+
if (problem) fail(problem);
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
const interactScript = values["interact-script"];
|
|
188
|
+
const storageState = values["storage-state"];
|
|
189
|
+
if (storageState && !existsSync(storageState)) {
|
|
190
|
+
fail(`Storage state file not found: ${storageState}`);
|
|
191
|
+
}
|
|
165
192
|
|
|
166
193
|
let payload;
|
|
167
194
|
if (values.snippet) {
|
|
168
195
|
const items = buildSnippetItem(values);
|
|
169
|
-
payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript });
|
|
196
|
+
payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState });
|
|
170
197
|
} else {
|
|
171
198
|
const workflowName = values.workflow ?? "core-web-vitals";
|
|
172
199
|
const workflow = WORKFLOWS[workflowName];
|
|
173
200
|
if (!workflow) fail(`Unknown workflow: ${workflowName}`);
|
|
174
|
-
payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript });
|
|
201
|
+
payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState });
|
|
175
202
|
}
|
|
176
203
|
|
|
204
|
+
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
205
|
+
const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
|
|
206
|
+
if (crux) payload.crux = crux;
|
|
207
|
+
|
|
177
208
|
let output;
|
|
178
209
|
if (values.markdown) {
|
|
179
210
|
output = reportMarkdown(payload);
|
|
@@ -184,6 +215,27 @@ async function main() {
|
|
|
184
215
|
}
|
|
185
216
|
process.stdout.write(output + "\n");
|
|
186
217
|
|
|
218
|
+
if (reportUrl) {
|
|
219
|
+
const outcome = await reportTo({
|
|
220
|
+
url: reportUrl,
|
|
221
|
+
apiKey: values["api-key"] || process.env.PERF_REVIEWS_API_KEY,
|
|
222
|
+
body: {
|
|
223
|
+
url,
|
|
224
|
+
workflow: values.snippet ? null : (values.workflow ?? "core-web-vitals"),
|
|
225
|
+
...(values.snippet ? { snippet: values.snippet } : {}),
|
|
226
|
+
timestamp: new Date().toISOString(),
|
|
227
|
+
navMs: payload.navMs,
|
|
228
|
+
results: payload.results,
|
|
229
|
+
meta: { viewport: viewportName, waitMs, cli_version: cliVersion },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
if (!outcome.ok) {
|
|
233
|
+
process.stderr.write(`Warning: could not report results to ${new URL(reportUrl).origin} (${outcome.warning})\n`);
|
|
234
|
+
} else if (outcome.regressions.length > 0) {
|
|
235
|
+
process.stderr.write(`Regressions detected:\n${outcome.regressions.map((r) => ` - ${typeof r === "string" ? r : JSON.stringify(r)}`).join("\n")}\n`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
187
239
|
// Exit codes.
|
|
188
240
|
const violations = checkBudgets(payload.results, values);
|
|
189
241
|
if (violations.length > 0) {
|
package/src/crux.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
|
|
2
|
+
const CRUX_TIMEOUT_MS = 10000;
|
|
3
|
+
|
|
4
|
+
// Good / needs-improvement upper bounds from web.dev/articles/vitals.
|
|
5
|
+
const METRICS = [
|
|
6
|
+
{ name: "LCP", key: "largest_contentful_paint", unit: "ms", good: 2500, poor: 4000 },
|
|
7
|
+
{ name: "CLS", key: "cumulative_layout_shift", unit: "score", good: 0.1, poor: 0.25 },
|
|
8
|
+
{ name: "INP", key: "interaction_to_next_paint", unit: "ms", good: 200, poor: 500 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const FORM_FACTOR_LABEL = { PHONE: "mobile", DESKTOP: "desktop", TABLET: "tablet" };
|
|
12
|
+
|
|
13
|
+
const rate = (value, { good, poor }) => (value <= good ? "good" : value <= poor ? "needs-improvement" : "poor");
|
|
14
|
+
|
|
15
|
+
// The viewport preset decides which population the field data describes.
|
|
16
|
+
export function formFactorFor(viewportName) {
|
|
17
|
+
return viewportName === "desktop" ? "DESKTOP" : "PHONE";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseMetrics(record) {
|
|
21
|
+
const metrics = {};
|
|
22
|
+
for (const m of METRICS) {
|
|
23
|
+
// CLS p75 comes as a string ("0.08"); LCP and INP as numbers.
|
|
24
|
+
const p75 = Number(record?.metrics?.[m.key]?.percentiles?.p75);
|
|
25
|
+
if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(p75, m), unit: m.unit };
|
|
26
|
+
}
|
|
27
|
+
return metrics;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function query({ endpoint, apiKey, body, timeoutMs }) {
|
|
31
|
+
const res = await fetch(`${endpoint}?key=${encodeURIComponent(apiKey)}`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
36
|
+
});
|
|
37
|
+
if (res.status === 404) return { notFound: true };
|
|
38
|
+
if (!res.ok) return { status: res.status };
|
|
39
|
+
return { record: (await res.json()).record };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Never throws, and no message it returns contains the key (it travels in the
|
|
43
|
+
// query string, so the request URL is never logged or put in an error).
|
|
44
|
+
export async function fetchCrux({
|
|
45
|
+
url,
|
|
46
|
+
apiKey,
|
|
47
|
+
formFactor = "PHONE",
|
|
48
|
+
endpoint = process.env.WEBPERF_CRUX_ENDPOINT ?? DEFAULT_ENDPOINT,
|
|
49
|
+
timeoutMs = CRUX_TIMEOUT_MS,
|
|
50
|
+
}) {
|
|
51
|
+
try {
|
|
52
|
+
let scope = "url";
|
|
53
|
+
let answer = await query({ endpoint, apiKey, body: { url, formFactor }, timeoutMs });
|
|
54
|
+
if (answer.notFound) {
|
|
55
|
+
scope = "origin";
|
|
56
|
+
answer = await query({ endpoint, apiKey, body: { origin: new URL(url).origin, formFactor }, timeoutMs });
|
|
57
|
+
}
|
|
58
|
+
if (answer.notFound) return { unavailable: "not in the CrUX dataset" };
|
|
59
|
+
if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}` };
|
|
60
|
+
const metrics = parseMetrics(answer.record);
|
|
61
|
+
if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX record" };
|
|
62
|
+
return { scope, formFactor, metrics };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
|
|
65
|
+
return { unavailable: reason };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Shared by both reporters: what to show and next to which synthetic value.
|
|
70
|
+
export function fieldDataView(crux, results) {
|
|
71
|
+
const label = FORM_FACTOR_LABEL[crux.formFactor] ?? crux.formFactor.toLowerCase();
|
|
72
|
+
const title = `Field data (CrUX p75, ${label}${crux.scope === "origin" ? ", origin-level" : ""})`;
|
|
73
|
+
const rows = METRICS.filter((m) => crux.metrics[m.name]).map((m) => {
|
|
74
|
+
const synthetic = results.find((r) => r.status === "ok" && r.metric === m.name);
|
|
75
|
+
return {
|
|
76
|
+
metric: m.name,
|
|
77
|
+
unit: m.unit,
|
|
78
|
+
p75: crux.metrics[m.name].p75,
|
|
79
|
+
rating: crux.metrics[m.name].rating,
|
|
80
|
+
synthetic: synthetic ? synthetic.value : null,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
return { title, rows };
|
|
84
|
+
}
|
package/src/report-to.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const REPORT_TIMEOUT_MS = 5000;
|
|
7
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
8
|
+
|
|
9
|
+
export const cliVersion = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version;
|
|
10
|
+
|
|
11
|
+
// Returns an error message when the URL cannot receive a report, or null.
|
|
12
|
+
// https is required so the API key is never sent in clear text; local hosts
|
|
13
|
+
// are allowed over http for development and tests.
|
|
14
|
+
export function validateReportUrl(value) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(value);
|
|
18
|
+
} catch {
|
|
19
|
+
return `--report-to must be an https URL, got "${value}"`;
|
|
20
|
+
}
|
|
21
|
+
if (parsed.protocol === "https:") return null;
|
|
22
|
+
if (parsed.protocol === "http:" && LOCAL_HOSTS.has(parsed.hostname)) return null;
|
|
23
|
+
return `--report-to must be an https URL (http is allowed only for localhost), got "${value}"`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// POSTs the results to `url`. Never throws and never touches the exit code:
|
|
27
|
+
// the caller decides what to do with the outcome. The key is only sent in the
|
|
28
|
+
// Authorization header and never appears in a returned message.
|
|
29
|
+
export async function reportTo({ url, apiKey, body, timeoutMs = REPORT_TIMEOUT_MS }) {
|
|
30
|
+
const headers = { "Content-Type": "application/json" };
|
|
31
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers,
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) return { ok: false, warning: `the endpoint answered ${res.status}` };
|
|
40
|
+
const data = await res.json().catch(() => ({}));
|
|
41
|
+
return { ok: true, regressions: data.ok === false ? (data.regressions ?? []) : [] };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? err.message);
|
|
44
|
+
return { ok: false, warning: reason };
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/reporters/human.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { styleText } from "node:util";
|
|
2
|
+
import { fieldDataView } from "../crux.js";
|
|
2
3
|
|
|
3
4
|
const RATING_ICON = {
|
|
4
5
|
good: "🟢",
|
|
@@ -164,12 +165,30 @@ function renderResult(r, verbose) {
|
|
|
164
165
|
return out;
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
function renderFieldData(crux, results) {
|
|
169
|
+
if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`)];
|
|
170
|
+
const { title, rows } = fieldDataView(crux, results);
|
|
171
|
+
const lines = [styleText("bold", title)];
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
const icon = RATING_ICON[row.rating] ?? "·";
|
|
174
|
+
const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
|
|
175
|
+
lines.push(
|
|
176
|
+
` ${icon} ${pad(row.metric, 6)} ${pad(paint(row.rating, formatValue(row.p75, row.unit)), 10)} ${pad(styleText("dim", row.rating), 20)} ${styleText("dim", `(synthetic: ${synthetic})`)}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return lines;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function reportHuman({ url, navMs, results, pageErrors, verbose, crux }) {
|
|
168
183
|
const lines = [];
|
|
169
184
|
lines.push(styleText(["bold"], `WebPerf Snippets — ${url}`));
|
|
170
185
|
lines.push(styleText("dim", `Navigated in ${navMs}ms`));
|
|
171
186
|
lines.push("");
|
|
172
187
|
for (const r of results) lines.push(renderResult(r, verbose));
|
|
188
|
+
if (crux) {
|
|
189
|
+
lines.push("");
|
|
190
|
+
lines.push(...renderFieldData(crux, results));
|
|
191
|
+
}
|
|
173
192
|
if (pageErrors?.length) {
|
|
174
193
|
lines.push("");
|
|
175
194
|
lines.push(styleText("yellow", "Page errors during run:"));
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fieldDataView } from "../crux.js";
|
|
2
|
+
|
|
1
3
|
const RATING_ICON = {
|
|
2
4
|
good: "✅",
|
|
3
5
|
"needs-improvement": "⚠️",
|
|
@@ -50,7 +52,21 @@ function renderSection(title, results) {
|
|
|
50
52
|
].join("\n");
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
function renderFieldData(crux, results) {
|
|
56
|
+
if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`];
|
|
57
|
+
const { title, rows } = fieldDataView(crux, results);
|
|
58
|
+
return [
|
|
59
|
+
`### ${title}`,
|
|
60
|
+
"| Metric | Field p75 | Status | Synthetic |",
|
|
61
|
+
"|--------|-----------|--------|-----------|",
|
|
62
|
+
...rows.map((row) => {
|
|
63
|
+
const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
|
|
64
|
+
return `| ${row.metric} | ${formatValue(row.p75, row.unit)} | ${RATING_ICON[row.rating] ?? "·"} ${row.rating} | ${synthetic} |`;
|
|
65
|
+
}),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
|
|
54
70
|
const lines = [];
|
|
55
71
|
lines.push(`## WebPerf Results — ${url}`);
|
|
56
72
|
lines.push(`> Navigated in ${navMs}ms`);
|
|
@@ -72,6 +88,11 @@ export function reportMarkdown({ url, navMs, results, pageErrors }) {
|
|
|
72
88
|
lines.push(renderSection(`Follow-up *(${reason})*`, group));
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
if (crux) {
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push(...renderFieldData(crux, results));
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
if (pageErrors?.length) {
|
|
76
97
|
lines.push("");
|
|
77
98
|
lines.push("### ⚠ Page Errors");
|
package/src/runner.js
CHANGED
|
@@ -46,10 +46,11 @@ export async function runSnippets({
|
|
|
46
46
|
viewport = VIEWPORT_PRESETS.mobile,
|
|
47
47
|
navTimeout = DEFAULT_NAV_TIMEOUT,
|
|
48
48
|
interactScript,
|
|
49
|
+
storageState,
|
|
49
50
|
}) {
|
|
50
51
|
const browser = await chromium.launch({ headless });
|
|
51
52
|
try {
|
|
52
|
-
const context = await browser.newContext({ viewport });
|
|
53
|
+
const context = await browser.newContext({ viewport, storageState });
|
|
53
54
|
const page = await context.newPage();
|
|
54
55
|
const pageErrors = [];
|
|
55
56
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
@@ -74,10 +75,11 @@ export async function runMeasurement({
|
|
|
74
75
|
viewport = VIEWPORT_PRESETS.mobile,
|
|
75
76
|
navTimeout = DEFAULT_NAV_TIMEOUT,
|
|
76
77
|
interactScript,
|
|
78
|
+
storageState,
|
|
77
79
|
}) {
|
|
78
80
|
const browser = await chromium.launch({ headless });
|
|
79
81
|
try {
|
|
80
|
-
const context = await browser.newContext({ viewport });
|
|
82
|
+
const context = await browser.newContext({ viewport, storageState });
|
|
81
83
|
const page = await context.newPage();
|
|
82
84
|
const pageErrors = [];
|
|
83
85
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|