webperf-snippets 0.3.0 → 0.4.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/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 } 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,38 @@ 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. Results keep the order of the items. Without interactions every item
46
+ // runs once, as it always did.
47
+ async function evaluateAroundInteractions(page, items, interactions) {
48
+ if (!interactions) return evaluateItems(page, items);
49
+
50
+ const tracking = items.filter((item) => isTrackingSnippet(item.path ?? "", item.source));
51
+ const installed = await evaluateItems(page, tracking);
52
+ await runInteractions(page, interactions);
53
+ const others = await evaluateItems(
54
+ page,
55
+ items.filter((item) => !tracking.includes(item))
56
+ );
57
+ const collected = [];
58
+ for (const result of installed) collected.push(await collectTrackingData(page, result));
59
+
60
+ const byId = new Map([...collected, ...others].map((r) => [r.id, r]));
61
+ return items.map((item) => byId.get(item.id));
62
+ }
63
+
64
+ async function collectTrackingData(page, result) {
65
+ if (result.status !== "tracking" || !result.getDataFn) return result;
66
+ try {
67
+ const data = await page.evaluate(`(async () => await ${result.getDataFn}())()`);
68
+ if (data && typeof data === "object") return { id: result.id, ...data };
69
+ return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
70
+ } catch (err) {
71
+ return { id: result.id, status: "error", error: `${result.getDataFn}() failed: ${err.message}` };
72
+ }
73
+ }
74
+
41
75
  export async function runSnippets({
42
76
  url,
43
77
  items,
@@ -46,6 +80,7 @@ export async function runSnippets({
46
80
  viewport = VIEWPORT_PRESETS.mobile,
47
81
  navTimeout = DEFAULT_NAV_TIMEOUT,
48
82
  interactScript,
83
+ interactions = interactScript,
49
84
  storageState,
50
85
  }) {
51
86
  const browser = await chromium.launch({ headless });
@@ -58,8 +93,7 @@ export async function runSnippets({
58
93
  await page.goto(url, { waitUntil: "load", timeout: navTimeout });
59
94
  if (waitMs > 0) await page.waitForTimeout(waitMs);
60
95
  const navMs = Date.now() - navStart;
61
- if (interactScript) await runInteractions(page, interactScript);
62
- const results = await evaluateItems(page, items);
96
+ const results = await evaluateAroundInteractions(page, items, interactions);
63
97
  return { url, navMs, results, pageErrors };
64
98
  } finally {
65
99
  await browser.close();
@@ -75,6 +109,7 @@ export async function runMeasurement({
75
109
  viewport = VIEWPORT_PRESETS.mobile,
76
110
  navTimeout = DEFAULT_NAV_TIMEOUT,
77
111
  interactScript,
112
+ interactions = interactScript,
78
113
  storageState,
79
114
  }) {
80
115
  const browser = await chromium.launch({ headless });
@@ -88,21 +123,35 @@ export async function runMeasurement({
88
123
  await page.goto(url, { waitUntil: "load", timeout: navTimeout });
89
124
  if (waitMs > 0) await page.waitForTimeout(waitMs);
90
125
  const navMs = Date.now() - navStart;
91
- if (interactScript) await runInteractions(page, interactScript);
92
-
93
- const items = workflow.steps.map((step) => ({
94
- id: step.id,
95
- path: step.path,
96
- source: loadSnippet(step.path),
97
- }));
98
- const initialResults = await evaluateItems(page, items);
99
-
100
- const followUps = [];
101
- for (const result of initialResults) {
102
- for (const rule of rules) {
103
- if (rule.when(result)) followUps.push({ ...rule.append, reason: rule.reason });
104
- }
105
- }
126
+
127
+ // The steps of the user's script, or the workflow's own. A step that needs a click or a key press
128
+ // is skipped when the interactions have none, so it does not fail for lack of data.
129
+ const steps = interactions ?? workflow.defaultInteractions;
130
+ const hasInput = steps ? loadInteractionSteps(steps).some(isInputStep) : false;
131
+ const skip = (step) => step.needsInput && !hasInput;
132
+
133
+ const items = workflow.steps
134
+ .filter((step) => !skip(step))
135
+ .map((step) => ({
136
+ id: step.id,
137
+ path: step.path,
138
+ source: loadSnippet(step.path),
139
+ }));
140
+ const measured = await evaluateAroundInteractions(page, items, steps);
141
+ const initialResults = workflow.steps.map(
142
+ (step) =>
143
+ measured.find((r) => r.id === step.id) ?? {
144
+ id: step.id,
145
+ status: "skipped",
146
+ reason: "The interactions have no click or type step; add one with --interact-script",
147
+ }
148
+ );
149
+
150
+ const followUps = nextSteps(
151
+ initialResults,
152
+ rules,
153
+ workflow.steps.map((step) => step.path)
154
+ );
106
155
 
107
156
  let followUpResults = [];
108
157
  if (followUps.length > 0) {
@@ -112,7 +161,10 @@ export async function runMeasurement({
112
161
  source: loadSnippet(f.path),
113
162
  }));
114
163
  const raw = await evaluateItems(page, followItems);
115
- followUpResults = raw.map((r) => {
164
+ // A tracking snippet answers through its data function, which is read right away here
165
+ const collected = [];
166
+ for (const r of raw) collected.push(await collectTrackingData(page, r));
167
+ followUpResults = collected.map((r) => {
116
168
  const f = followUps.find((x) => x.id === r.id);
117
169
  return f ? { ...r, reason: f.reason } : r;
118
170
  });
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ // A tracking snippet installs observers and answers later through a window function named in
2
+ // its result (getDataFn). The Interaction snippets work this way. CLS and INP also name a
3
+ // getDataFn but answer with their value in one go, so only the Interaction category counts.
4
+ export function isTrackingSnippet(path, source) {
5
+ return path.startsWith("Interaction/") && source.includes("getDataFn");
6
+ }
@@ -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
+ };
@@ -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
  };