webperf-snippets 0.3.0 → 0.4.1
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 +26 -11
- package/package.json +1 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +93 -17
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +19 -1
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +27 -2
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +19 -1
- package/snippets/Loading/Resource-Hints-Validation.js +30 -10
- package/snippets/Loading/Script-Loading.js +19 -1
- package/snippets/Loading/TTFB-Resources.js +29 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +39 -11
- package/snippets/Media/Image-Element-Audit.js +52 -8
- package/snippets/SCHEMA.md +7 -4
- package/snippets/_shared/helpers.js +18 -1
- package/src/bin.js +18 -43
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +40 -3
- package/src/load-snippet.js +1 -1
- package/src/reporters/human.js +23 -4
- package/src/reporters/markdown.js +5 -0
- package/src/runner.js +73 -19
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- package/src/workflows/loading.js +9 -0
|
@@ -31,7 +31,12 @@ function renderErrorRow(r) {
|
|
|
31
31
|
return `| ${r.id} | ❌ error | ${r.error} |`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function renderSkippedRow(r) {
|
|
35
|
+
return `| ${r.id} | ⏭ skipped | ${r.reason} |`;
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
function renderRow(r) {
|
|
39
|
+
if (r.status === "skipped") return renderSkippedRow(r);
|
|
35
40
|
if (r.status === "error") return renderErrorRow(r);
|
|
36
41
|
if (Array.isArray(r.issues)) return renderAuditRow(r);
|
|
37
42
|
return renderMetricRow(r);
|
package/src/runner.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { chromium } from "playwright";
|
|
2
2
|
import { loadSnippet } from "./load-snippet.js";
|
|
3
|
-
import { runInteractions } from "./interactions.js";
|
|
3
|
+
import { runInteractions, loadInteractionSteps, isInputStep, settle } from "./interactions.js";
|
|
4
|
+
import { isTrackingSnippet } from "./tracking.js";
|
|
5
|
+
import { nextSteps } from "./decision-tree.js";
|
|
4
6
|
|
|
5
7
|
export const VIEWPORT_PRESETS = {
|
|
6
8
|
mobile: { width: 375, height: 812 },
|
|
@@ -38,6 +40,40 @@ async function evaluateItems(page, items) {
|
|
|
38
40
|
return results;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
// Runs the items around a set of interactions. A tracking snippet is installed first, so it sees
|
|
44
|
+
// the interactions, and answers through its getDataFn afterwards. The other items run after the
|
|
45
|
+
// interactions, as before. The browser reports the timing of an interaction after the frame that
|
|
46
|
+
// follows it, so the runner lets it settle before it reads anything. Results keep the order of the
|
|
47
|
+
// items. Without interactions every item runs once, as it always did.
|
|
48
|
+
async function evaluateAroundInteractions(page, items, interactions) {
|
|
49
|
+
if (!interactions) return evaluateItems(page, items);
|
|
50
|
+
|
|
51
|
+
const tracking = items.filter((item) => isTrackingSnippet(item.path ?? "", item.source));
|
|
52
|
+
const installed = await evaluateItems(page, tracking);
|
|
53
|
+
await runInteractions(page, interactions);
|
|
54
|
+
await settle(page);
|
|
55
|
+
const others = await evaluateItems(
|
|
56
|
+
page,
|
|
57
|
+
items.filter((item) => !tracking.includes(item))
|
|
58
|
+
);
|
|
59
|
+
const collected = [];
|
|
60
|
+
for (const result of installed) collected.push(await collectTrackingData(page, result));
|
|
61
|
+
|
|
62
|
+
const byId = new Map([...collected, ...others].map((r) => [r.id, r]));
|
|
63
|
+
return items.map((item) => byId.get(item.id));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function collectTrackingData(page, result) {
|
|
67
|
+
if (result.status !== "tracking" || !result.getDataFn) return result;
|
|
68
|
+
try {
|
|
69
|
+
const data = await page.evaluate(`(async () => await ${result.getDataFn}())()`);
|
|
70
|
+
if (data && typeof data === "object") return { id: result.id, ...data };
|
|
71
|
+
return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { id: result.id, status: "error", error: `${result.getDataFn}() failed: ${err.message}` };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
41
77
|
export async function runSnippets({
|
|
42
78
|
url,
|
|
43
79
|
items,
|
|
@@ -46,6 +82,7 @@ export async function runSnippets({
|
|
|
46
82
|
viewport = VIEWPORT_PRESETS.mobile,
|
|
47
83
|
navTimeout = DEFAULT_NAV_TIMEOUT,
|
|
48
84
|
interactScript,
|
|
85
|
+
interactions = interactScript,
|
|
49
86
|
storageState,
|
|
50
87
|
}) {
|
|
51
88
|
const browser = await chromium.launch({ headless });
|
|
@@ -58,8 +95,7 @@ export async function runSnippets({
|
|
|
58
95
|
await page.goto(url, { waitUntil: "load", timeout: navTimeout });
|
|
59
96
|
if (waitMs > 0) await page.waitForTimeout(waitMs);
|
|
60
97
|
const navMs = Date.now() - navStart;
|
|
61
|
-
|
|
62
|
-
const results = await evaluateItems(page, items);
|
|
98
|
+
const results = await evaluateAroundInteractions(page, items, interactions);
|
|
63
99
|
return { url, navMs, results, pageErrors };
|
|
64
100
|
} finally {
|
|
65
101
|
await browser.close();
|
|
@@ -75,6 +111,7 @@ export async function runMeasurement({
|
|
|
75
111
|
viewport = VIEWPORT_PRESETS.mobile,
|
|
76
112
|
navTimeout = DEFAULT_NAV_TIMEOUT,
|
|
77
113
|
interactScript,
|
|
114
|
+
interactions = interactScript,
|
|
78
115
|
storageState,
|
|
79
116
|
}) {
|
|
80
117
|
const browser = await chromium.launch({ headless });
|
|
@@ -88,21 +125,35 @@ export async function runMeasurement({
|
|
|
88
125
|
await page.goto(url, { waitUntil: "load", timeout: navTimeout });
|
|
89
126
|
if (waitMs > 0) await page.waitForTimeout(waitMs);
|
|
90
127
|
const navMs = Date.now() - navStart;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
128
|
+
|
|
129
|
+
// The steps of the user's script, or the workflow's own. A step that needs a click or a key press
|
|
130
|
+
// is skipped when the interactions have none, so it does not fail for lack of data.
|
|
131
|
+
const steps = interactions ?? workflow.defaultInteractions;
|
|
132
|
+
const hasInput = steps ? loadInteractionSteps(steps).some(isInputStep) : false;
|
|
133
|
+
const skip = (step) => step.needsInput && !hasInput;
|
|
134
|
+
|
|
135
|
+
const items = workflow.steps
|
|
136
|
+
.filter((step) => !skip(step))
|
|
137
|
+
.map((step) => ({
|
|
138
|
+
id: step.id,
|
|
139
|
+
path: step.path,
|
|
140
|
+
source: loadSnippet(step.path),
|
|
141
|
+
}));
|
|
142
|
+
const measured = await evaluateAroundInteractions(page, items, steps);
|
|
143
|
+
const initialResults = workflow.steps.map(
|
|
144
|
+
(step) =>
|
|
145
|
+
measured.find((r) => r.id === step.id) ?? {
|
|
146
|
+
id: step.id,
|
|
147
|
+
status: "skipped",
|
|
148
|
+
reason: "The interactions have no click or type step; add one with --interact-script",
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const followUps = nextSteps(
|
|
153
|
+
initialResults,
|
|
154
|
+
rules,
|
|
155
|
+
workflow.steps.map((step) => step.path)
|
|
156
|
+
);
|
|
106
157
|
|
|
107
158
|
let followUpResults = [];
|
|
108
159
|
if (followUps.length > 0) {
|
|
@@ -112,7 +163,10 @@ export async function runMeasurement({
|
|
|
112
163
|
source: loadSnippet(f.path),
|
|
113
164
|
}));
|
|
114
165
|
const raw = await evaluateItems(page, followItems);
|
|
115
|
-
|
|
166
|
+
// A tracking snippet answers through its data function, which is read right away here
|
|
167
|
+
const collected = [];
|
|
168
|
+
for (const r of raw) collected.push(await collectTrackingData(page, r));
|
|
169
|
+
followUpResults = collected.map((r) => {
|
|
116
170
|
const f = followUps.find((x) => x.id === r.id);
|
|
117
171
|
return f ? { ...r, reason: f.reason } : r;
|
|
118
172
|
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SNIPPET_DIRS } from "./load-snippet.js";
|
|
4
|
+
|
|
5
|
+
// The categories whose snippets run headlessly. DevTools-Overrides pairs are pasted by hand.
|
|
6
|
+
const CATEGORIES = ["CoreWebVitals", "Loading", "Interaction", "Media", "Resources"];
|
|
7
|
+
|
|
8
|
+
// Short names for the snippets people run most. Any snippet also answers to its own name.
|
|
9
|
+
const SNIPPET_ALIASES = {
|
|
10
|
+
LCP: "CoreWebVitals/LCP",
|
|
11
|
+
CLS: "CoreWebVitals/CLS",
|
|
12
|
+
INP: "CoreWebVitals/INP",
|
|
13
|
+
"LCP-Subparts": "CoreWebVitals/LCP-Subparts",
|
|
14
|
+
fonts: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
|
|
15
|
+
"Fonts-Preloaded-Loaded-and-used-above-the-fold":
|
|
16
|
+
"Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
|
|
17
|
+
// Tier 1 — Loading
|
|
18
|
+
"render-blocking": "Loading/Find-render-blocking-resources",
|
|
19
|
+
"Find-render-blocking-resources": "Loading/Find-render-blocking-resources",
|
|
20
|
+
"resource-hints": "Loading/Resource-Hints-Validation",
|
|
21
|
+
"Resource-Hints-Validation": "Loading/Resource-Hints-Validation",
|
|
22
|
+
"preload-scripts": "Loading/Validate-Preload-Async-Defer-Scripts",
|
|
23
|
+
"Validate-Preload-Async-Defer-Scripts": "Loading/Validate-Preload-Async-Defer-Scripts",
|
|
24
|
+
"priority-hints": "Loading/Priority-Hints-Audit",
|
|
25
|
+
"Priority-Hints-Audit": "Loading/Priority-Hints-Audit",
|
|
26
|
+
"critical-css": "Loading/Critical-CSS-Detection",
|
|
27
|
+
"Critical-CSS-Detection": "Loading/Critical-CSS-Detection",
|
|
28
|
+
ttfb: "Loading/TTFB-Sub-Parts",
|
|
29
|
+
"TTFB-Sub-Parts": "Loading/TTFB-Sub-Parts",
|
|
30
|
+
"script-parties": "Loading/First-And-Third-Party-Script-Info",
|
|
31
|
+
"First-And-Third-Party-Script-Info": "Loading/First-And-Third-Party-Script-Info",
|
|
32
|
+
"script-loading": "Loading/Script-Loading",
|
|
33
|
+
"Script-Loading": "Loading/Script-Loading",
|
|
34
|
+
// Tier 2 — Media
|
|
35
|
+
"lazy-atf": "Loading/Find-Above-The-Fold-Lazy-Loaded-Images",
|
|
36
|
+
"Find-Above-The-Fold-Lazy-Loaded-Images": "Loading/Find-Above-The-Fold-Lazy-Loaded-Images",
|
|
37
|
+
"lazy-conflict": "Loading/Find-Images-With-Lazy-and-Fetchpriority",
|
|
38
|
+
"Find-Images-With-Lazy-and-Fetchpriority": "Loading/Find-Images-With-Lazy-and-Fetchpriority",
|
|
39
|
+
"eager-below-fold": "Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport",
|
|
40
|
+
"Find-non-Lazy-Loaded-Images-outside-of-the-viewport":
|
|
41
|
+
"Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport",
|
|
42
|
+
};
|
|
43
|
+
let cache = null;
|
|
44
|
+
|
|
45
|
+
// Every runnable snippet as "Category/Name", read from the bundled copy or the workspace.
|
|
46
|
+
export function listSnippets() {
|
|
47
|
+
if (cache) return cache;
|
|
48
|
+
const dir = SNIPPET_DIRS.find((d) => existsSync(join(d, CATEGORIES[0])));
|
|
49
|
+
cache = dir
|
|
50
|
+
? CATEGORIES.flatMap((category) =>
|
|
51
|
+
readdirSync(join(dir, category))
|
|
52
|
+
.filter((file) => file.endsWith(".js"))
|
|
53
|
+
.map((file) => `${category}/${file.slice(0, -3)}`)
|
|
54
|
+
).sort()
|
|
55
|
+
: [];
|
|
56
|
+
return cache;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function distance(a, b) {
|
|
60
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
61
|
+
for (let i = 1; i <= a.length; i++) {
|
|
62
|
+
let previous = row[0];
|
|
63
|
+
row[0] = i;
|
|
64
|
+
for (let j = 1; j <= b.length; j++) {
|
|
65
|
+
const current = row[j];
|
|
66
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
67
|
+
previous = current;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return row[b.length];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Turns an alias, a bare snippet name or a Category/Name path into a Category/Name path.
|
|
74
|
+
// Only a listed snippet resolves, so a name can never point at a file outside the snippets.
|
|
75
|
+
export function resolveSnippetName(name) {
|
|
76
|
+
if (Object.hasOwn(SNIPPET_ALIASES, name)) return SNIPPET_ALIASES[name];
|
|
77
|
+
const all = listSnippets();
|
|
78
|
+
const wanted = name.toLowerCase();
|
|
79
|
+
const found = all.find((path) => path.toLowerCase() === wanted || path.split("/")[1].toLowerCase() === wanted);
|
|
80
|
+
if (found) return found;
|
|
81
|
+
|
|
82
|
+
const bare = wanted.split("/").pop();
|
|
83
|
+
const closest = all
|
|
84
|
+
.map((path) => ({ path, score: distance(bare, path.split("/")[1].toLowerCase()) }))
|
|
85
|
+
.sort((a, b) => a.score - b.score)
|
|
86
|
+
.slice(0, 3)
|
|
87
|
+
.map((c) => c.path);
|
|
88
|
+
throw new Error(`Unknown snippet "${name}". Closest: ${closest.join(", ")}. Run with a snippet name such as Compression-Audit, or a Category/Name path.`);
|
|
89
|
+
}
|
package/src/tracking.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// A tracking snippet installs observers and answers later through a window function named in its
|
|
2
|
+
// result (getDataFn). The Interaction snippets work this way, and so does INP, whose value only
|
|
3
|
+
// comes out of getINP(). CLS also names a getDataFn but answers with its value in one go.
|
|
4
|
+
export function isTrackingSnippet(path, source) {
|
|
5
|
+
return (path.startsWith("Interaction/") || path === "CoreWebVitals/INP") && source.includes("getDataFn");
|
|
6
|
+
}
|
package/src/workflows/audit.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Structural audit workflow — deterministic checks suitable for CI.
|
|
2
2
|
// These snippets return binary pass/fail results with no timing noise.
|
|
3
|
+
//
|
|
4
|
+
// Every step runs on the same page, one after the other. The steps that make requests of their
|
|
5
|
+
// own (an image probe, a fetch to read a Content-Type) go last, because those requests add Resource
|
|
6
|
+
// Timing entries that the steps before them would count.
|
|
3
7
|
export const auditWorkflow = {
|
|
4
8
|
id: "audit",
|
|
5
9
|
title: "Structural Audit",
|
|
@@ -13,9 +17,22 @@ export const auditWorkflow = {
|
|
|
13
17
|
{ id: "ttfb", path: "Loading/TTFB-Sub-Parts" },
|
|
14
18
|
{ id: "script-parties", path: "Loading/First-And-Third-Party-Script-Info" },
|
|
15
19
|
{ id: "script-loading", path: "Loading/Script-Loading" },
|
|
20
|
+
{ id: "compression", path: "Loading/Compression-Audit" },
|
|
21
|
+
{ id: "inline-scripts", path: "Loading/Inline-Script-Info-and-Size" },
|
|
22
|
+
{ id: "inline-css", path: "Loading/Inline-CSS-Info-and-Size" },
|
|
23
|
+
{ id: "webfonts", path: "Loading/Webfont-Usage-Analyzer" },
|
|
24
|
+
{ id: "content-visibility", path: "Loading/Content-Visibility" },
|
|
25
|
+
{ id: "prefetch", path: "Loading/Prefetch-Resource-Validation" },
|
|
16
26
|
// Tier 2 — Media determinista
|
|
17
27
|
{ id: "lazy-atf", path: "Loading/Find-Above-The-Fold-Lazy-Loaded-Images" },
|
|
18
28
|
{ id: "lazy-conflict", path: "Loading/Find-Images-With-Lazy-and-Fetchpriority" },
|
|
19
29
|
{ id: "eager-below-fold", path: "Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport" },
|
|
30
|
+
{ id: "video", path: "Media/Video-Element-Audit" },
|
|
31
|
+
// Tier 3 — DOM
|
|
32
|
+
{ id: "dom-size", path: "Interaction/DOM-Size-and-Depth" },
|
|
33
|
+
// Steps that make their own requests, last
|
|
34
|
+
{ id: "oversized-images", path: "Media/Oversized-Images" },
|
|
35
|
+
{ id: "image-audit", path: "Media/Image-Element-Audit" },
|
|
36
|
+
{ id: "svg-bitmaps", path: "Media/SVG-Embedded-Bitmap-Analysis" },
|
|
20
37
|
],
|
|
21
38
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Interaction performance workflow. The Interaction snippets record what happens while the page is
|
|
2
|
+
// used, so this workflow runs a set of interactions (the steps of --interact-script, or a scroll of
|
|
3
|
+
// its own) and reads the snippets afterwards.
|
|
4
|
+
//
|
|
5
|
+
// A step flagged needsInput measures clicks and key presses; when the interactions have neither, the
|
|
6
|
+
// step is skipped with a reason instead of failing for lack of data.
|
|
7
|
+
export const interactionWorkflow = {
|
|
8
|
+
id: "interaction",
|
|
9
|
+
title: "Interaction Performance",
|
|
10
|
+
// Used without --interact-script. Clicks belong to each site, so the default only scrolls.
|
|
11
|
+
defaultInteractions: [
|
|
12
|
+
{ action: "scroll", y: 600 },
|
|
13
|
+
{ action: "wait", ms: 300 },
|
|
14
|
+
{ action: "scroll", y: 600 },
|
|
15
|
+
{ action: "wait", ms: 300 },
|
|
16
|
+
{ action: "scroll", y: 600 },
|
|
17
|
+
{ action: "wait", ms: 300 },
|
|
18
|
+
{ action: "scroll", y: -1800 },
|
|
19
|
+
{ action: "wait", ms: 500 },
|
|
20
|
+
],
|
|
21
|
+
steps: [
|
|
22
|
+
{ id: "long-tasks", path: "Interaction/LongTask" },
|
|
23
|
+
{ id: "long-frames", path: "Interaction/Long-Animation-Frames" },
|
|
24
|
+
{ id: "scroll", path: "Interaction/Scroll-Performance" },
|
|
25
|
+
{ id: "layout-shifts", path: "Interaction/Layout-Shift-Loading-and-Interaction" },
|
|
26
|
+
{ id: "forced-layout", path: "Interaction/Forced-Synchronous-Layout" },
|
|
27
|
+
{ id: "interactions", path: "Interaction/Interactions", needsInput: true },
|
|
28
|
+
{ id: "input-latency", path: "Interaction/Input-Latency-Breakdown", needsInput: true },
|
|
29
|
+
],
|
|
30
|
+
};
|
package/src/workflows/loading.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Loading performance workflow — timing-sensitive measurements requiring a real page load.
|
|
2
2
|
// Complements the audit workflow (deterministic structural checks) with live metrics.
|
|
3
|
+
//
|
|
4
|
+
// Every step runs on the same page, one after the other. The step that makes requests of its own
|
|
5
|
+
// (the HEAD requests that read the cache headers) goes last, because those requests add Resource
|
|
6
|
+
// Timing entries that the steps before it would read.
|
|
3
7
|
export const loadingWorkflow = {
|
|
4
8
|
id: "loading",
|
|
5
9
|
title: "Loading Performance",
|
|
@@ -9,6 +13,11 @@ export const loadingWorkflow = {
|
|
|
9
13
|
{ id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
|
|
10
14
|
{ id: "resource-hints", path: "Loading/Resource-Hints-Validation" },
|
|
11
15
|
{ id: "scripts", path: "Loading/Script-Loading" },
|
|
16
|
+
{ id: "script-timings", path: "Loading/First-And-Third-Party-Script-Timings" },
|
|
17
|
+
{ id: "ttfb-resources", path: "Loading/TTFB-Resources" },
|
|
18
|
+
{ id: "js-execution", path: "Loading/JS-Execution-Time-Breakdown" },
|
|
19
|
+
{ id: "third-party-impact", path: "Loading/Third-Party-Impact-by-Domain" },
|
|
12
20
|
{ id: "fonts", path: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold" },
|
|
21
|
+
{ id: "cache-strategy", path: "Loading/Cache-Strategy-Analysis" },
|
|
13
22
|
],
|
|
14
23
|
};
|