webperf-snippets 0.1.0 → 0.2.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 CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  Run curated [WebPerf Snippets](https://webperf-snippets.nucliweb.net) headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes and gate CI on real performance budgets.
4
4
 
5
- > **Status:** v0.1 (MVP). Core Web Vitals workflow only. See [Roadmap](#roadmap) for what's next.
5
+ <img width="1820" height="1442" alt="webperf-snippets-CLI" src="https://github.com/user-attachments/assets/af7e6b02-8877-407e-87a4-db063468b5fb" />
6
+
7
+
8
+ > **Status:** v0.2. Core Web Vitals, loading audit, and structural checks. See [Roadmap](#roadmap) for what's next.
6
9
 
7
10
  ## Why
8
11
 
@@ -35,6 +38,24 @@ Run the default Core Web Vitals workflow (LCP + CLS, plus LCP-Subparts if LCP >
35
38
  npx webperf-snippets https://web.dev
36
39
  ```
37
40
 
41
+ Loading audit (TTFB, FCP, render-blocking, scripts, fonts):
42
+
43
+ ```bash
44
+ npx webperf-snippets https://web.dev --workflow loading
45
+ ```
46
+
47
+ Structural checks for CI (render-blocking, fonts, priority hints, resource hints):
48
+
49
+ ```bash
50
+ npx webperf-snippets https://web.dev --workflow audit
51
+ ```
52
+
53
+ Markdown output for PR comments:
54
+
55
+ ```bash
56
+ npx webperf-snippets https://web.dev --markdown
57
+ ```
58
+
38
59
  JSON output (for piping into `jq` or CI):
39
60
 
40
61
  ```bash
@@ -47,6 +68,12 @@ Single snippet:
47
68
  npx webperf-snippets https://web.dev --snippet LCP-Subparts
48
69
  ```
49
70
 
71
+ Synthetic INP measurement with an interaction script:
72
+
73
+ ```bash
74
+ npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
75
+ ```
76
+
50
77
  CI gating:
51
78
 
52
79
  ```bash
@@ -55,16 +82,40 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
55
82
 
56
83
  ### Options
57
84
 
58
- | Option | Description |
59
- | --------------------- | ---------------------------------------------------------- |
60
- | `--workflow <name>` | Workflow to run. Default: `core-web-vitals`. |
61
- | `--snippet <name>` | Run a single snippet (`LCP`, `CLS`, or `Category/Name`). |
62
- | `--json` | Output JSON instead of formatted text. |
63
- | `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`.|
64
- | `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
65
- | `--budget-cls <score>`| Exit `1` if CLS exceeds this value. |
66
- | `--headed` | Show the browser window (debug). |
67
- | `-h, --help` | Show help. |
85
+ | Option | Description |
86
+ | ---------------------------- | ---------------------------------------------------------------------- |
87
+ | `--workflow <name>` | Workflow to run. Default: `core-web-vitals`. Options: `core-web-vitals`, `loading`, `audit`. |
88
+ | `--snippet <name>` | Run a single snippet by alias or `Category/Name` path. |
89
+ | `--json` | Output JSON instead of formatted text. |
90
+ | `--markdown` | Output GitHub-renderable markdown (for PR comments). |
91
+ | `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
92
+ | `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
93
+ | `--interact-script <path>` | JSON file with interactions to run before evaluation (for INP). |
94
+ | `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
95
+ | `--budget-cls <score>` | Exit `1` if CLS exceeds this value. |
96
+ | `--verbose` | Show all items, including passing checks. |
97
+ | `--headed` | Show the browser window (debug). |
98
+ | `-h, --help` | Show help. |
99
+
100
+ ### Snippet aliases
101
+
102
+ | Alias | Snippet |
103
+ | ------------------ | ---------------------------------------------- |
104
+ | `LCP` | CoreWebVitals/LCP |
105
+ | `CLS` | CoreWebVitals/CLS |
106
+ | `LCP-Subparts` | CoreWebVitals/LCP-Subparts |
107
+ | `fonts` | Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold |
108
+ | `render-blocking` | Loading/Find-render-blocking-resources |
109
+ | `resource-hints` | Loading/Resource-Hints-Validation |
110
+ | `preload-scripts` | Loading/Validate-Preload-Async-Defer-Scripts |
111
+ | `priority-hints` | Loading/Priority-Hints-Audit |
112
+ | `critical-css` | Loading/Critical-CSS-Detection |
113
+ | `ttfb` | Loading/TTFB-Sub-Parts |
114
+ | `script-parties` | Loading/First-And-Third-Party-Script-Info |
115
+ | `script-loading` | Loading/Script-Loading |
116
+ | `lazy-atf` | Loading/Find-Above-The-Fold-Lazy-Loaded-Images |
117
+ | `lazy-conflict` | Loading/Find-Images-With-Lazy-and-Fetchpriority |
118
+ | `eager-below-fold` | Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport |
68
119
 
69
120
  ### Exit codes
70
121
 
@@ -112,17 +163,16 @@ Tag protection rules restrict who can push `cli-v*` tags. Configure them under *
112
163
 
113
164
  The `NPM_TOKEN` secret must be set in the repository settings with publish access to the `webperf-snippets` npm package.
114
165
 
115
- ## Known limitations (v0.1)
166
+ ## Known limitations
116
167
 
117
- - **No INP**: INP requires real user interactions. v0.2 will support synthetic interaction scripts.
118
168
  - **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
119
169
  - **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
120
- - **Decision-tree follow-ups re-navigate**: when a follow-up snippet fires (e.g. LCP-Subparts), the page is loaded again. v0.2 will share a single page session.
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 — use RUM for field data.
121
171
 
122
172
  ## Roadmap
123
173
 
124
- - v0.2: Loading workflow (TTFB, FCP, render-blocking, scripts, fonts), shared page session, synthetic interactions for INP.
125
- - v0.3: Markdown reporter for PR comments, GitHub Action wrapper.
174
+ - ~~v0.2: Loading workflow (TTFB, FCP, render-blocking, scripts, fonts), shared page session, synthetic interactions for INP, markdown reporter for PR comments.~~ ✓ Released
175
+ - v0.3: GitHub Action wrapper.
126
176
  - v0.4: Auth flows (login + measure logged-in pages), CrUX field-data enrichment.
127
177
 
128
178
  ## How it works
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
5
5
  "type": "module",
6
6
  "bin": "./src/bin.js",
@@ -31,6 +31,15 @@
31
31
  })
32
32
  .sort((a, b) => b.responseEnd - a.responseEnd);
33
33
 
34
+ const lastBlockingEnd = blockingResources.length
35
+ ? Math.max(...blockingResources.map((r) => r.responseEnd))
36
+ : 0;
37
+ const totalSizeBytes = blockingResources.reduce((sum, r) => sum + r.size, 0);
38
+ const byType = blockingResources.reduce((acc, r) => {
39
+ acc[r.type] = (acc[r.type] || 0) + 1;
40
+ return acc;
41
+ }, {});
42
+
34
43
  console.group("%c🚧 Render-Blocking Resources", "font-weight: bold; font-size: 14px;");
35
44
 
36
45
  if (blockingResources.length === 0) {
@@ -45,14 +54,6 @@
45
54
  console.log(" • Scripts use async or defer attributes");
46
55
  console.log(" • Critical resources are optimized");
47
56
  } else {
48
- // Calculate metrics
49
- const lastBlockingEnd = Math.max(...blockingResources.map((r) => r.responseEnd));
50
- const totalSize = blockingResources.reduce((sum, r) => sum + r.size, 0);
51
- const byType = blockingResources.reduce((acc, r) => {
52
- acc[r.type] = (acc[r.type] || 0) + 1;
53
- return acc;
54
- }, {});
55
-
56
57
  // Summary
57
58
  console.log(
58
59
  `%c⚠️ Found ${blockingResources.length} render-blocking resource(s)`,
@@ -63,8 +64,8 @@
63
64
  console.log("%c📊 Impact Summary:", "font-weight: bold;");
64
65
  console.log(` Rendering blocked until: ${lastBlockingEnd.toFixed(0)}ms`);
65
66
  console.log(` Total blocking resources: ${blockingResources.length}`);
66
- if (totalSize > 0) {
67
- const sizeKB = (totalSize / 1024).toFixed(1);
67
+ if (totalSizeBytes > 0) {
68
+ const sizeKB = (totalSizeBytes / 1024).toFixed(1);
68
69
  console.log(` Total size: ${sizeKB} KB`);
69
70
  }
70
71
  console.log(` By type: ${Object.entries(byType).map(([k, v]) => `${k} (${v})`).join(", ")}`);
@@ -134,9 +135,6 @@
134
135
 
135
136
  console.groupEnd();
136
137
 
137
- const lastBlockingEnd = blockingResources.length ? Math.max(...blockingResources.map((r) => r.responseEnd)) : 0;
138
- const totalSizeBytes = blockingResources.reduce((sum, r) => sum + r.size, 0);
139
- const byType = blockingResources.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
140
138
  return {
141
139
  script: "Find-render-blocking-resources",
142
140
  status: "ok",
package/src/bin.js CHANGED
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { loadSnippet } from "./load-snippet.js";
4
- import { runSnippets, VIEWPORT_PRESETS } from "./runner.js";
4
+ import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
5
5
  import { cwvWorkflow } from "./workflows/cwv.js";
6
6
  import { auditWorkflow } from "./workflows/audit.js";
7
- import { nextSteps } from "./decision-tree.js";
7
+ import { loadingWorkflow } from "./workflows/loading.js";
8
+ import { RULES } from "./decision-tree.js";
8
9
  import { reportHuman } from "./reporters/human.js";
9
10
  import { reportJson } from "./reporters/json.js";
11
+ import { reportMarkdown } from "./reporters/markdown.js";
10
12
 
11
13
  const WORKFLOWS = {
12
14
  "core-web-vitals": cwvWorkflow,
13
15
  audit: auditWorkflow,
16
+ loading: loadingWorkflow,
14
17
  };
15
18
 
16
19
  const SNIPPET_ALIASES = {
@@ -53,7 +56,7 @@ Run curated WebPerf Snippets headlessly via Playwright.
53
56
 
54
57
  Options:
55
58
  --workflow <name> Workflow to run (default: core-web-vitals)
56
- Workflows: core-web-vitals, audit
59
+ Workflows: core-web-vitals, audit, loading
57
60
  --snippet <name> Run a single snippet by alias or Category/Name path
58
61
  Aliases: LCP, CLS, LCP-Subparts, fonts,
59
62
  render-blocking, resource-hints, preload-scripts,
@@ -61,10 +64,13 @@ Options:
61
64
  script-parties, script-loading,
62
65
  lazy-atf, lazy-conflict, eager-below-fold
63
66
  --json Output JSON instead of formatted text
67
+ --markdown Output GitHub-renderable markdown (for PR comments)
64
68
  --viewport <preset> Viewport preset: mobile (default), tablet, desktop
65
69
  --wait <ms> Post-load wait before evaluating (default: 3000)
66
70
  --budget-lcp <ms> Exit 1 if LCP exceeds this value
67
71
  --budget-cls <score> Exit 1 if CLS exceeds this value
72
+ --interact-script <path> JSON file with interactions to run before evaluation
73
+ Actions: scroll, click, hover, type, wait
68
74
  --verbose Show all items, even for passing checks
69
75
  --headed Show the browser window (debug)
70
76
  -h, --help Show this help
@@ -77,6 +83,7 @@ Examples:
77
83
  npx webperf-snippets https://web.dev --snippet render-blocking
78
84
  npx webperf-snippets https://web.dev --snippet fonts
79
85
  npx webperf-snippets https://web.dev --budget-lcp 2500
86
+ npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
80
87
  `;
81
88
 
82
89
  function fail(message, code = 2) {
@@ -88,19 +95,9 @@ function resolveSnippetPath(name) {
88
95
  return SNIPPET_ALIASES[name] ?? name;
89
96
  }
90
97
 
91
- function buildItems(values) {
92
- if (values.snippet) {
93
- const path = resolveSnippetPath(values.snippet);
94
- return [{ id: values.snippet, path, source: loadSnippet(path) }];
95
- }
96
- const workflowName = values.workflow ?? "core-web-vitals";
97
- const workflow = WORKFLOWS[workflowName];
98
- if (!workflow) fail(`Unknown workflow: ${workflowName}`);
99
- return workflow.steps.map((step) => ({
100
- id: step.id,
101
- path: step.path,
102
- source: loadSnippet(step.path),
103
- }));
98
+ function buildSnippetItem(values) {
99
+ const path = resolveSnippetPath(values.snippet);
100
+ return [{ id: values.snippet, path, source: loadSnippet(path) }];
104
101
  }
105
102
 
106
103
  function checkBudgets(results, values) {
@@ -128,10 +125,12 @@ async function main() {
128
125
  workflow: { type: "string" },
129
126
  snippet: { type: "string" },
130
127
  json: { type: "boolean" },
128
+ markdown: { type: "boolean" },
131
129
  wait: { type: "string" },
132
130
  "budget-lcp": { type: "string" },
133
131
  "budget-cls": { type: "string" },
134
132
  viewport: { type: "string" },
133
+ "interact-script": { type: "string" },
135
134
  verbose: { type: "boolean" },
136
135
  headed: { type: "boolean" },
137
136
  help: { type: "boolean", short: "h" },
@@ -155,7 +154,6 @@ async function main() {
155
154
  process.exit(2);
156
155
  }
157
156
 
158
- const items = buildItems(values);
159
157
  const waitMs = values.wait ? Number(values.wait) : 3000;
160
158
  const viewportName = values.viewport ?? "mobile";
161
159
  const viewport = VIEWPORT_PRESETS[viewportName];
@@ -163,48 +161,27 @@ async function main() {
163
161
  fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
164
162
  }
165
163
 
166
- const initial = await runSnippets({
167
- url,
168
- items,
169
- waitMs,
170
- headless: !values.headed,
171
- viewport,
172
- });
173
-
174
- // Apply decision tree to spawn follow-up steps.
175
- const followUps = nextSteps(initial.results);
176
- let followUpRun = { results: [], pageErrors: [] };
177
- if (followUps.length > 0) {
178
- const followItems = followUps.map((f) => ({
179
- id: f.id,
180
- path: f.path,
181
- source: loadSnippet(f.path),
182
- reason: f.reason,
183
- }));
184
- // v0.1 limitation: follow-ups re-navigate. Acceptable for now; consolidate
185
- // into a single page session in v0.2 if perf becomes a problem.
186
- followUpRun = await runSnippets({
187
- url,
188
- items: followItems,
189
- waitMs,
190
- headless: !values.headed,
191
- viewport,
192
- });
193
- // Carry forward the human-readable reason so reporters can show it.
194
- followUpRun.results = followUpRun.results.map((r) => {
195
- const f = followUps.find((x) => x.id === r.id);
196
- return f ? { ...r, reason: f.reason } : r;
197
- });
198
- }
164
+ const interactScript = values["interact-script"];
199
165
 
200
- const payload = {
201
- url,
202
- navMs: initial.navMs,
203
- results: [...initial.results, ...followUpRun.results],
204
- pageErrors: [...initial.pageErrors, ...(followUpRun.pageErrors ?? [])],
205
- };
166
+ let payload;
167
+ if (values.snippet) {
168
+ const items = buildSnippetItem(values);
169
+ payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript });
170
+ } else {
171
+ const workflowName = values.workflow ?? "core-web-vitals";
172
+ const workflow = WORKFLOWS[workflowName];
173
+ if (!workflow) fail(`Unknown workflow: ${workflowName}`);
174
+ payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript });
175
+ }
206
176
 
207
- const output = values.json ? reportJson(payload) : reportHuman({ ...payload, verbose: values.verbose });
177
+ let output;
178
+ if (values.markdown) {
179
+ output = reportMarkdown(payload);
180
+ } else if (values.json) {
181
+ output = reportJson(payload);
182
+ } else {
183
+ output = reportHuman({ ...payload, verbose: values.verbose });
184
+ }
208
185
  process.stdout.write(output + "\n");
209
186
 
210
187
  // Exit codes.
@@ -1,11 +1,21 @@
1
1
  // Declarative follow-ups: if a result matches the predicate, append the step.
2
2
  // Mirrors the WORKFLOWS.md decision trees from the snippet catalogue.
3
- const RULES = [
3
+ export const RULES = [
4
4
  {
5
5
  when: (r) => r.id === "LCP" && r.status === "ok" && r.value > 2500,
6
6
  append: { id: "LCP-Subparts", path: "CoreWebVitals/LCP-Subparts" },
7
7
  reason: "LCP > 2.5s — drilling into sub-parts",
8
8
  },
9
+ {
10
+ when: (r) => r.id === "TTFB" && r.status === "ok" && r.value > 600,
11
+ append: { id: "ttfb-subparts", path: "Loading/TTFB-Sub-Parts" },
12
+ reason: "TTFB > 600ms — drilling into sub-parts",
13
+ },
14
+ {
15
+ when: (r) => r.id === "FCP" && r.status === "ok" && r.value > 1800,
16
+ append: { id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
17
+ reason: "FCP > 1.8s — checking render-blocking resources",
18
+ },
9
19
  ];
10
20
 
11
21
  export function nextSteps(results) {
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export async function executeStep(page, step) {
4
+ switch (step.action) {
5
+ case "scroll":
6
+ await page.evaluate((y) => window.scrollBy(0, y), step.y ?? 300);
7
+ break;
8
+ case "click":
9
+ await page.click(step.selector);
10
+ break;
11
+ case "hover":
12
+ await page.hover(step.selector);
13
+ break;
14
+ case "type":
15
+ await page.type(step.selector, step.text);
16
+ break;
17
+ case "wait":
18
+ await page.waitForTimeout(step.ms);
19
+ break;
20
+ default:
21
+ throw new Error(`Unknown interaction action: "${step.action}"`);
22
+ }
23
+ }
24
+
25
+ export async function runInteractions(page, scriptPath) {
26
+ const { interactions } = JSON.parse(readFileSync(scriptPath, "utf8"));
27
+ for (const step of interactions) {
28
+ await executeStep(page, step);
29
+ }
30
+ }
@@ -0,0 +1,82 @@
1
+ const RATING_ICON = {
2
+ good: "✅",
3
+ "needs-improvement": "⚠️",
4
+ poor: "❌",
5
+ };
6
+
7
+ function formatValue(value, unit) {
8
+ if (unit === "ms") return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${value}ms`;
9
+ if (unit === "score") return value.toFixed(4);
10
+ return String(value);
11
+ }
12
+
13
+ function renderMetricRow(r) {
14
+ const value = formatValue(r.value, r.unit);
15
+ const icon = RATING_ICON[r.rating] ?? "·";
16
+ return `| ${r.id} | ${value} | ${icon} ${r.rating} |`;
17
+ }
18
+
19
+ function renderAuditRow(r) {
20
+ const errors = (r.issues ?? []).filter((i) => i.severity === "error");
21
+ const warnings = (r.issues ?? []).filter((i) => i.severity === "warning");
22
+ const icon = errors.length ? "🔴" : warnings.length ? "🟡" : "🟢";
23
+ const label = errors.length ? "fail" : warnings.length ? "warning" : "pass";
24
+ const detail = r.issues?.length ? r.issues.map((i) => i.message).join("; ") : "No issues";
25
+ return `| ${r.id} | ${icon} ${label} | ${detail} |`;
26
+ }
27
+
28
+ function renderErrorRow(r) {
29
+ return `| ${r.id} | ❌ error | ${r.error} |`;
30
+ }
31
+
32
+ function renderRow(r) {
33
+ if (r.status === "error") return renderErrorRow(r);
34
+ if (Array.isArray(r.issues)) return renderAuditRow(r);
35
+ return renderMetricRow(r);
36
+ }
37
+
38
+ function sectionHeader(results) {
39
+ if (results.some((r) => Array.isArray(r.issues))) {
40
+ return ["| Check | Status | Issues |", "|-------|--------|--------|"];
41
+ }
42
+ return ["| Metric | Value | Status |", "|--------|-------|--------|"];
43
+ }
44
+
45
+ function renderSection(title, results) {
46
+ return [
47
+ `### ${title}`,
48
+ ...sectionHeader(results),
49
+ ...results.map(renderRow),
50
+ ].join("\n");
51
+ }
52
+
53
+ export function reportMarkdown({ url, navMs, results, pageErrors }) {
54
+ const lines = [];
55
+ lines.push(`## WebPerf Results — ${url}`);
56
+ lines.push(`> Navigated in ${navMs}ms`);
57
+ lines.push("");
58
+
59
+ const initial = results.filter((r) => !r.reason);
60
+ if (initial.length > 0) {
61
+ lines.push(renderSection("Results", initial));
62
+ }
63
+
64
+ // Group follow-ups by their triggering reason.
65
+ const byReason = new Map();
66
+ for (const r of results.filter((r) => r.reason)) {
67
+ if (!byReason.has(r.reason)) byReason.set(r.reason, []);
68
+ byReason.get(r.reason).push(r);
69
+ }
70
+ for (const [reason, group] of byReason) {
71
+ lines.push("");
72
+ lines.push(renderSection(`Follow-up *(${reason})*`, group));
73
+ }
74
+
75
+ if (pageErrors?.length) {
76
+ lines.push("");
77
+ lines.push("### ⚠ Page Errors");
78
+ for (const err of pageErrors) lines.push(`- ${err}`);
79
+ }
80
+
81
+ return lines.join("\n");
82
+ }
package/src/runner.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { chromium } from "playwright";
2
+ import { loadSnippet } from "./load-snippet.js";
3
+ import { runInteractions } from "./interactions.js";
2
4
 
3
5
  export const VIEWPORT_PRESETS = {
4
6
  mobile: { width: 375, height: 812 },
@@ -15,6 +17,27 @@ function toExpression(source) {
15
17
  return source.trim().replace(/;\s*$/, "");
16
18
  }
17
19
 
20
+ async function evaluateItems(page, items) {
21
+ const results = [];
22
+ for (const item of items) {
23
+ try {
24
+ const result = await page.evaluate(toExpression(item.source));
25
+ if (result && typeof result === "object") {
26
+ results.push({ id: item.id, ...result });
27
+ } else {
28
+ results.push({
29
+ id: item.id,
30
+ status: "error",
31
+ error: "Snippet did not return an object — check the IIFE return statement",
32
+ });
33
+ }
34
+ } catch (err) {
35
+ results.push({ id: item.id, status: "error", error: err.message });
36
+ }
37
+ }
38
+ return results;
39
+ }
40
+
18
41
  export async function runSnippets({
19
42
  url,
20
43
  items,
@@ -22,12 +45,40 @@ export async function runSnippets({
22
45
  headless = true,
23
46
  viewport = VIEWPORT_PRESETS.mobile,
24
47
  navTimeout = DEFAULT_NAV_TIMEOUT,
48
+ interactScript,
25
49
  }) {
26
50
  const browser = await chromium.launch({ headless });
27
51
  try {
28
52
  const context = await browser.newContext({ viewport });
29
53
  const page = await context.newPage();
54
+ const pageErrors = [];
55
+ page.on("pageerror", (err) => pageErrors.push(err.message));
56
+ const navStart = Date.now();
57
+ await page.goto(url, { waitUntil: "load", timeout: navTimeout });
58
+ if (waitMs > 0) await page.waitForTimeout(waitMs);
59
+ const navMs = Date.now() - navStart;
60
+ if (interactScript) await runInteractions(page, interactScript);
61
+ const results = await evaluateItems(page, items);
62
+ return { url, navMs, results, pageErrors };
63
+ } finally {
64
+ await browser.close();
65
+ }
66
+ }
30
67
 
68
+ export async function runMeasurement({
69
+ url,
70
+ workflow,
71
+ rules = [],
72
+ waitMs = 3000,
73
+ headless = true,
74
+ viewport = VIEWPORT_PRESETS.mobile,
75
+ navTimeout = DEFAULT_NAV_TIMEOUT,
76
+ interactScript,
77
+ }) {
78
+ const browser = await chromium.launch({ headless });
79
+ try {
80
+ const context = await browser.newContext({ viewport });
81
+ const page = await context.newPage();
31
82
  const pageErrors = [];
32
83
  page.on("pageerror", (err) => pageErrors.push(err.message));
33
84
 
@@ -35,26 +86,37 @@ export async function runSnippets({
35
86
  await page.goto(url, { waitUntil: "load", timeout: navTimeout });
36
87
  if (waitMs > 0) await page.waitForTimeout(waitMs);
37
88
  const navMs = Date.now() - navStart;
89
+ if (interactScript) await runInteractions(page, interactScript);
90
+
91
+ const items = workflow.steps.map((step) => ({
92
+ id: step.id,
93
+ path: step.path,
94
+ source: loadSnippet(step.path),
95
+ }));
96
+ const initialResults = await evaluateItems(page, items);
38
97
 
39
- const results = [];
40
- for (const item of items) {
41
- try {
42
- const result = await page.evaluate(toExpression(item.source));
43
- if (result && typeof result === "object") {
44
- results.push({ id: item.id, ...result });
45
- } else {
46
- results.push({
47
- id: item.id,
48
- status: "error",
49
- error: "Snippet did not return an object — check the IIFE return statement",
50
- });
51
- }
52
- } catch (err) {
53
- results.push({ id: item.id, status: "error", error: err.message });
98
+ const followUps = [];
99
+ for (const result of initialResults) {
100
+ for (const rule of rules) {
101
+ if (rule.when(result)) followUps.push({ ...rule.append, reason: rule.reason });
54
102
  }
55
103
  }
56
104
 
57
- return { url, navMs, results, pageErrors };
105
+ let followUpResults = [];
106
+ if (followUps.length > 0) {
107
+ const followItems = followUps.map((f) => ({
108
+ id: f.id,
109
+ path: f.path,
110
+ source: loadSnippet(f.path),
111
+ }));
112
+ const raw = await evaluateItems(page, followItems);
113
+ followUpResults = raw.map((r) => {
114
+ const f = followUps.find((x) => x.id === r.id);
115
+ return f ? { ...r, reason: f.reason } : r;
116
+ });
117
+ }
118
+
119
+ return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors };
58
120
  } finally {
59
121
  await browser.close();
60
122
  }
@@ -0,0 +1,14 @@
1
+ // Loading performance workflow — timing-sensitive measurements requiring a real page load.
2
+ // Complements the audit workflow (deterministic structural checks) with live metrics.
3
+ export const loadingWorkflow = {
4
+ id: "loading",
5
+ title: "Loading Performance",
6
+ steps: [
7
+ { id: "TTFB", path: "Loading/TTFB" },
8
+ { id: "FCP", path: "Loading/FCP" },
9
+ { id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
10
+ { id: "resource-hints", path: "Loading/Resource-Hints-Validation" },
11
+ { id: "scripts", path: "Loading/Script-Loading" },
12
+ { id: "fonts", path: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold" },
13
+ ],
14
+ };