webperf-snippets 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +120 -5
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +52 -30
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +15 -0
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +55 -25
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +59 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +22 -3
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1176 -0
  59. package/snippets/_shared/helpers.js +44 -0
  60. package/src/bin.js +55 -3
  61. package/src/crux.js +84 -0
  62. package/src/report-to.js +46 -0
  63. package/src/reporters/human.js +20 -1
  64. package/src/reporters/markdown.js +22 -1
  65. package/src/runner.js +4 -2
@@ -0,0 +1,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
+ }
@@ -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
+ }
@@ -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
- export function reportHuman({ url, navMs, results, pageErrors, verbose }) {
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
- export function reportMarkdown({ url, navMs, results, pageErrors }) {
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));