webperf-snippets 1.10.0 → 1.12.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
@@ -21,6 +21,12 @@ const DEFAULT_NAV_TIMEOUT = 30000;
21
21
  const LATE_ENTRIES_MS = 2000;
22
22
  const POLL_MS = 100;
23
23
 
24
+ // A snippet still tracking after that wait has seen no interaction at all, and the browser agrees: a
25
+ // click that paints nothing is recorded too. The message of the snippet (click on the page, then call
26
+ // the function again) would ask for what the run has just done.
27
+ const NOTHING_RECORDED =
28
+ "A click or key press was sent, and this document recorded no interaction. Check that it reaches an element of this document: an interaction inside an iframe is recorded by the iframe.";
29
+
24
30
  // Snippets are IIFEs. Playwright evaluates a string as an expression, so we
25
31
  // trim trailing semicolons to keep the IIFE call as a single expression and
26
32
  // recover its return value in Node.
@@ -83,13 +89,26 @@ export async function collectTrackingData(page, result, hasInput) {
83
89
  await page.waitForTimeout(POLL_MS);
84
90
  data = await read();
85
91
  }
86
- if (data && typeof data === "object") return { id: result.id, ...data };
92
+ if (data && typeof data === "object") {
93
+ const sentAndMissed = hasInput && data.status === "tracking";
94
+ return { id: result.id, ...data, ...(sentAndMissed && { message: NOTHING_RECORDED }) };
95
+ }
87
96
  return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
88
97
  } catch (err) {
89
98
  return { id: result.id, status: "error", error: `${result.getDataFn}() failed: ${err.message}` };
90
99
  }
91
100
  }
92
101
 
102
+ // An aborted signal closes Chromium, which fails whatever the run is waiting for, a page that stopped
103
+ // answering included: Playwright has no timeout for an evaluation. Returns what stops listening.
104
+ function closeOnAbort(browser, signal) {
105
+ if (!signal) return () => {};
106
+ const close = () => browser.close().catch(() => {});
107
+ if (signal.aborted) close();
108
+ signal.addEventListener("abort", close, { once: true });
109
+ return () => signal.removeEventListener("abort", close);
110
+ }
111
+
93
112
  export async function runSnippets({
94
113
  url,
95
114
  items,
@@ -106,9 +125,11 @@ export async function runSnippets({
106
125
  ownDomains,
107
126
  // Called at each of the three stages of a run, for callers that report progress (the MCP server)
108
127
  onProgress = () => {},
128
+ signal,
109
129
  }) {
110
130
  await onProgress(1, "Opening Chromium");
111
131
  const browser = await launchChromium({ headless });
132
+ const stopListening = closeOnAbort(browser, signal);
112
133
  try {
113
134
  const context = await browser.newContext({ viewport, storageState });
114
135
  const page = await context.newPage();
@@ -126,6 +147,7 @@ export async function runSnippets({
126
147
  await onProgress(3, "Snippets evaluated");
127
148
  return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
128
149
  } finally {
150
+ stopListening();
129
151
  await browser.close();
130
152
  }
131
153
  }
@@ -146,9 +168,11 @@ export async function runMeasurement({
146
168
  network,
147
169
  ownDomains,
148
170
  onProgress = () => {},
171
+ signal,
149
172
  }) {
150
173
  await onProgress(1, "Opening Chromium");
151
174
  const browser = await launchChromium({ headless });
175
+ const stopListening = closeOnAbort(browser, signal);
152
176
  try {
153
177
  const context = await browser.newContext({ viewport, storageState });
154
178
  const page = await context.newPage();
@@ -212,6 +236,7 @@ export async function runMeasurement({
212
236
  await onProgress(3, "Workflow and follow-ups evaluated");
213
237
  return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
214
238
  } finally {
239
+ stopListening();
215
240
  await browser.close();
216
241
  }
217
242
  }
package/src/runs.js CHANGED
@@ -4,6 +4,11 @@ import { DEFAULT_THRESHOLDS } from "./baseline.js";
4
4
  // Every result of the report comes from that run, so they agree with each other (the sub-parts of
5
5
  // the LCP add up to its LCP), and a summary gives the median, min and max of each metric.
6
6
 
7
+ // The most runs of a measurement, the same in the tools of the MCP server as in --runs. Mann-Whitney U
8
+ // needs five a side (baseline.js), so nine leave room for failed runs and for a noisy page, and a page
9
+ // that takes a few seconds to load stays under two minutes.
10
+ export const MAX_RUNS = 9;
11
+
7
12
  // The metrics the snippets report, and navMs, which every run has
8
13
  export const MEDIAN_METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP", "navMs"];
9
14
 
@@ -50,6 +55,23 @@ export function describeRuns({ count, failed, medianRun, medianBy, medianByReque
50
55
  return `${count} run${count === 1 ? "" : "s"}, median by ${by}: run ${medianRun}${failed ? ` (${failed} failed)` : ""}`;
51
56
  }
52
57
 
58
+ // Measures `count` runs one after the other, never in parallel: they would compete for the CPU and
59
+ // skew the timings. A run that fails is kept as { error } and left out of the median, unless isFatal
60
+ // says the next run would fail the same way. An aborted signal, a caller that left, starts no more runs.
61
+ export async function measureRuns(measure, count, { medianBy, isFatal = () => false, signal } = {}) {
62
+ const runs = [];
63
+ for (let i = 1; i <= count; i++) {
64
+ signal?.throwIfAborted();
65
+ try {
66
+ runs.push(await measure(i));
67
+ } catch (err) {
68
+ if (isFatal(err)) throw err;
69
+ runs.push({ error: err.message });
70
+ }
71
+ }
72
+ return summarizeRuns(runs, { medianBy });
73
+ }
74
+
53
75
  // The runs as the CLI made them, in order, a run that failed being { error }. Returns the payload of
54
76
  // the median run and the `runs` field of the report, or nulls and the errors when every run failed.
55
77
  export function summarizeRuns(runs, { medianBy: requested } = {}) {
@@ -11,6 +11,12 @@ const SNIPPET_ALIASES = {
11
11
  CLS: "CoreWebVitals/CLS",
12
12
  INP: "CoreWebVitals/INP",
13
13
  "LCP-Subparts": "CoreWebVitals/LCP-Subparts",
14
+ // The full names of the metrics, in any case: the words someone types who does not know the acronym
15
+ "Largest-Contentful-Paint": "CoreWebVitals/LCP",
16
+ "Cumulative-Layout-Shift": "CoreWebVitals/CLS",
17
+ "Interaction-to-Next-Paint": "CoreWebVitals/INP",
18
+ "First-Contentful-Paint": "Loading/FCP",
19
+ "Time-to-First-Byte": "Loading/TTFB",
14
20
  fonts: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
15
21
  "Fonts-Preloaded-Loaded-and-used-above-the-fold":
16
22
  "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
@@ -65,34 +71,99 @@ export function listSnippets() {
65
71
  return cache;
66
72
  }
67
73
 
74
+ // The edits between two strings. Swapping two neighbours counts as one: LPC is one edit from LCP.
68
75
  function distance(a, b) {
69
- const row = Array.from({ length: b.length + 1 }, (_, i) => i);
76
+ let twoBack = [];
77
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
70
78
  for (let i = 1; i <= a.length; i++) {
71
- let previous = row[0];
72
- row[0] = i;
79
+ const row = [i];
73
80
  for (let j = 1; j <= b.length; j++) {
74
- const current = row[j];
75
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
76
- previous = current;
81
+ row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
82
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) row[j] = Math.min(row[j], twoBack[j - 2] + 1);
77
83
  }
84
+ twoBack = previous;
85
+ previous = row;
78
86
  }
79
- return row[b.length];
87
+ return previous[b.length];
80
88
  }
81
89
 
82
- // Turns an alias, a bare snippet name or a Category/Name path into a Category/Name path.
83
- // Only a listed snippet resolves, so a name can never point at a file outside the snippets.
84
- export function resolveSnippetName(name) {
90
+ // The words of a name, without their endings: LongTask and long-tasks give the same two.
91
+ function wordsOf(name) {
92
+ return name
93
+ .replace(/([a-z])([A-Z])|([A-Z])([A-Z][a-z])/g, "$1$3-$2$4")
94
+ .toLowerCase()
95
+ .split(/[^a-z0-9]+/)
96
+ .filter(Boolean)
97
+ .map((word) => (word.length > 4 ? word.replace(/(ing|ed)?s?$/, "") : word));
98
+ }
99
+
100
+ // The words of a guess that say nothing of what a snippet looks at. They rank, but do not make a match.
101
+ const GENERIC_WORDS = new Set(wordsOf("and the of with by find audit analysis analyzer info detection validation performance"));
102
+
103
+ // Equal, inside a longer word of the name (font in webfont), or a typo apart. Words of three letters only
104
+ // count when equal, and a typo needs five letters: size and side are one edit apart.
105
+ function isSameWord(guessed, word) {
106
+ if (guessed === word) return true;
107
+ if (Math.min(guessed.length, word.length) < 4) return false;
108
+ return word.includes(guessed) || (Math.max(guessed.length, word.length) > 4 && distance(guessed, word) === 1);
109
+ }
110
+
111
+ // How close a guess is to a name or an alias of a snippet, from 0 to 1: the share of its words the name
112
+ // has, or how alike they are spelled, which catches what splits into other words, such as LCP-Sub-Parts.
113
+ // Under MIN_SPELLING the likeness is chance: any two short names share letters.
114
+ const MIN_SPELLING = 0.65;
115
+ const MIN_SCORE = 0.5;
116
+ const NEAR_THE_FIRST = 0.75;
117
+ const MAX_SUGGESTIONS = 5;
118
+
119
+ function closeness(guess, name) {
120
+ const [a, b] = [guess.toLowerCase(), name.toLowerCase()];
121
+ const spelling = 1 - distance(a, b) / Math.max(a.length, b.length);
122
+ const words = wordsOf(name);
123
+ const guessed = wordsOf(guess);
124
+ const found = guessed.filter((word) => words.some((other) => isSameWord(word, other)));
125
+ const shared = found.some((word) => !GENERIC_WORDS.has(word)) ? found.length / guessed.length : 0;
126
+ return Math.max(spelling >= MIN_SPELLING ? spelling : 0, shared);
127
+ }
128
+
129
+ // The snippets a guess may have meant, the closest first, and on a tie the shortest name, which says the
130
+ // least else: Script-Loading before Inline-Script-Info-and-Size.
131
+ function suggestionsFor(guess) {
132
+ const ranked = listSnippets()
133
+ .map((path) => {
134
+ const name = path.split("/")[1];
135
+ const score = Math.max(...[name, ...aliasesOf(path)].map((n) => closeness(guess, n)));
136
+ return { path, score, length: wordsOf(name).length };
137
+ })
138
+ .filter((snippet) => snippet.score >= MIN_SCORE)
139
+ .sort((a, b) => b.score - a.score || a.length - b.length);
140
+ return ranked
141
+ .filter((snippet) => snippet.score >= ranked[0].score * NEAR_THE_FIRST)
142
+ .slice(0, MAX_SUGGESTIONS)
143
+ .map((snippet) => snippet.path);
144
+ }
145
+
146
+ // An alias as written, then a snippet name in any case, then an alias in any case: TTFB is the snippet,
147
+ // and ttfb the alias of its sub-parts.
148
+ function findSnippet(name) {
85
149
  if (Object.hasOwn(SNIPPET_ALIASES, name)) return SNIPPET_ALIASES[name];
86
- const all = listSnippets();
87
150
  const wanted = name.toLowerCase();
88
- const found = all.find((path) => path.toLowerCase() === wanted || path.split("/")[1].toLowerCase() === wanted);
151
+ const snippet = listSnippets().find((path) => path.toLowerCase() === wanted || path.split("/")[1].toLowerCase() === wanted);
152
+ if (snippet) return snippet;
153
+ const alias = Object.keys(SNIPPET_ALIASES).find((key) => key.toLowerCase() === wanted);
154
+ return alias && SNIPPET_ALIASES[alias];
155
+ }
156
+
157
+ // Turns an alias, a bare snippet name or a Category/Name path into a Category/Name path. The name decides
158
+ // and not the category: no two snippets share a name, so a wrong category can only mean that snippet.
159
+ // Only a listed snippet resolves, so a name can never point at a file outside the snippets.
160
+ export function resolveSnippetName(name) {
161
+ const [category, bare, ...rest] = name.split("/");
162
+ const inCategory = bare && rest.length === 0 && CATEGORIES.some((c) => c.toLowerCase() === category.toLowerCase());
163
+ const found = findSnippet(name) ?? (inCategory ? findSnippet(bare) : undefined);
89
164
  if (found) return found;
90
165
 
91
- const bare = wanted.split("/").pop();
92
- const closest = all
93
- .map((path) => ({ path, score: distance(bare, path.split("/")[1].toLowerCase()) }))
94
- .sort((a, b) => a.score - b.score)
95
- .slice(0, 3)
96
- .map((c) => c.path);
97
- throw new Error(`Unknown snippet "${name}". Closest: ${closest.join(", ")}. Run with a snippet name such as Compression-Audit, or a Category/Name path.`);
166
+ const suggestions = suggestionsFor(name.split("/").pop());
167
+ const answer = suggestions.length > 0 ? `Closest: ${suggestions.join(", ")}.` : "No snippet has a close name.";
168
+ throw new Error(`Unknown snippet "${name}". ${answer} Every name is in the list_snippets tool of the MCP server and in https://webperf-snippets.net/llms.txt.`);
98
169
  }