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 +66 -16
- package/package.json +1 -1
- package/snippets/Loading/Find-render-blocking-resources.js +11 -13
- package/src/bin.js +34 -57
- package/src/decision-tree.js +11 -1
- package/src/interactions.js +30 -0
- package/src/reporters/markdown.js +82 -0
- package/src/runner.js +78 -16
- package/src/workflows/loading.js +14 -0
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
|
-
|
|
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
|
|
59
|
-
|
|
|
60
|
-
| `--workflow <name>`
|
|
61
|
-
| `--snippet <name>`
|
|
62
|
-
| `--json`
|
|
63
|
-
| `--
|
|
64
|
-
| `--
|
|
65
|
-
| `--
|
|
66
|
-
| `--
|
|
67
|
-
|
|
|
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
|
|
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
|
-
- **
|
|
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:
|
|
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
|
@@ -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 (
|
|
67
|
-
const sizeKB = (
|
|
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 {
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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.
|
package/src/decision-tree.js
CHANGED
|
@@ -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
|
|
40
|
-
for (const
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
+
};
|