webperf-snippets 1.4.0 → 1.6.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.
Files changed (64) hide show
  1. package/README.md +17 -9
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/INP.js +6 -4
  4. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
  5. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
  6. package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
  7. package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
  8. package/snippets/Interaction/Input-Latency-Breakdown.js +5 -2
  9. package/snippets/Interaction/Interactions.js +10 -1
  10. package/snippets/Interaction/Scroll-Performance.js +2 -0
  11. package/snippets/Loading/Back-Forward-Cache.js +7 -0
  12. package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
  13. package/snippets/Loading/Cache-Strategy-Analysis.js +37 -23
  14. package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
  15. package/snippets/Loading/Compression-Audit.js +3 -0
  16. package/snippets/Loading/Content-Visibility.js +2 -2
  17. package/snippets/Loading/Critical-CSS-Detection.js +8 -1
  18. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
  19. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  20. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
  21. package/snippets/Loading/Find-render-blocking-resources.js +1 -1
  22. package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
  23. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
  24. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
  25. package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
  26. package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
  27. package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
  28. package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
  29. package/snippets/Loading/Priority-Hints-Audit.js +10 -0
  30. package/snippets/Loading/Resource-Hints-Validation.js +3 -3
  31. package/snippets/Loading/Resource-Hints.js +7 -1
  32. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
  33. package/snippets/Loading/Script-Loading.js +6 -2
  34. package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
  35. package/snippets/Loading/Service-Worker-Analysis.js +12 -2
  36. package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
  37. package/snippets/Loading/TTFB-Resources.js +1 -1
  38. package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
  39. package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
  40. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
  41. package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
  42. package/snippets/Media/Image-Element-Audit.js +16 -13
  43. package/snippets/Media/Oversized-Images.js +4 -0
  44. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
  45. package/snippets/Media/Video-Element-Audit.js +13 -2
  46. package/snippets/SCHEMA.md +431 -6
  47. package/snippets/_view/fields.js +37 -0
  48. package/snippets/_view/format.js +39 -0
  49. package/snippets/_view/index.js +304 -0
  50. package/snippets/_view/locales/en.js +39 -0
  51. package/snippets/_view/package.json +1 -0
  52. package/src/baseline.js +30 -6
  53. package/src/bin.js +40 -37
  54. package/src/blocking.js +1 -1
  55. package/src/budgets.js +73 -0
  56. package/src/mcp/documents.js +41 -21
  57. package/src/mcp/server.js +1 -1
  58. package/src/reporters/budgets-view.js +38 -0
  59. package/src/reporters/human.js +42 -20
  60. package/src/reporters/markdown.js +53 -1
  61. package/src/reporters/summary-view.js +12 -0
  62. package/src/runner.js +3 -3
  63. package/src/view.js +11 -0
  64. package/src/workflows/interaction.js +1 -0
@@ -0,0 +1,304 @@
1
+ // The view of a snippet result, shared by the panel of the extension, the reports of the CLI and the
2
+ // visualizer of the site: the title of its table, its details and its columns with labels, formats and
3
+ // order. Each one paints the view its own way, so this module writes no HTML. A field with no entry in
4
+ // fields.js or locales/ still reads well: its format comes from the suffix of its key, and its label
5
+ // from the key in words.
6
+
7
+ import FIELDS from './fields.js'
8
+ import en from './locales/en.js'
9
+ import { formatOfKey, formatValue, SUFFIXES } from './format.js'
10
+
11
+ export { formatValue }
12
+
13
+ const LOCALES = { en }
14
+
15
+ // Kept in capitals when a key is turned into words
16
+ const ACRONYMS = new Set(
17
+ 'api bfcache cdn cls cors cpu css csr dns dom ect fcp fid fps html http https id inp js json lcp loaf loafs rtt ssr svg sw tbt tcp tls ttfb ui url urls'.split(' ')
18
+ )
19
+ const CAPITALS = { bfcache: 'bfcache', loaf: 'LoAF', loafs: 'LoAFs', urls: 'URLs' }
20
+
21
+ // A key in words, without the unit its suffix names, since the value shows it: totalBlockingUntilMs is
22
+ // "Total blocking until", isLCP "Is LCP". A count is not a unit, so imageCount is "Image count"
23
+ export function words(key) {
24
+ const suffix = SUFFIXES.find(([s, format]) => format !== 'count' && key.endsWith(s) && key !== s)?.[0]
25
+ const stem = suffix ? key.slice(0, -suffix.length) : key
26
+ const parts = stem
27
+ // Words in mixed case that the split by capitals would break
28
+ .replace(/LoAF(s?)/g, ' loaf$1 ')
29
+ .replace(/[_-]+/g, ' ')
30
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
31
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
32
+ .trim()
33
+ .split(/\s+/)
34
+ .map((w) => {
35
+ const lower = w.toLowerCase()
36
+ if (ACRONYMS.has(lower)) return CAPITALS[lower] ?? lower.toUpperCase()
37
+ return w.length > 1 && w === w.toUpperCase() ? w : lower
38
+ })
39
+ const text = parts.join(' ')
40
+ return text.charAt(0).toUpperCase() + text.slice(1)
41
+ }
42
+
43
+ // A field with nothing to show: no value, an empty string, or an empty list or object
44
+ const isEmpty = (value) =>
45
+ value === undefined || value === null || value === '' || (typeof value === 'object' && Object.keys(value).length === 0)
46
+
47
+ // The locale, its language, and English, which has every word
48
+ const chainOf = (locale) => [...new Set([locale, locale.split('-')[0], 'en'])]
49
+
50
+ function dictionaries(script, { locale = 'en', fields = {}, locales = {} }) {
51
+ const allFields = { ...FIELDS, ...fields }
52
+ const allLocales = { ...LOCALES, ...locales }
53
+ const chain = chainOf(locale).map((l) => allLocales[l]).filter(Boolean)
54
+ // The entries of the snippet come before the general ones in any language: a key can mean something
55
+ // else in a snippet, and its own label in English says it better than a general one translated
56
+ const scopes = [...chain.map((d) => d[script]), ...chain.map((d) => d['*'])].filter(Boolean)
57
+ // The words of the view itself (Yes, No, none, rows), in the first language that has them
58
+ const ui = (name) => chain.map((l) => l['*']?.words?.[name]).find((w) => w !== undefined)
59
+ return {
60
+ title: scopes.find((s) => s.title)?.title,
61
+ // Label and phrase come from the same entry, so a sentence never mixes two languages
62
+ words: (key) => scopes.find((s) => s.fields?.[key])?.fields[key] ?? {},
63
+ shape: (key) => ({ ...allFields['*']?.[key], ...allFields[script]?.[key] }),
64
+ ui,
65
+ locale,
66
+ }
67
+ }
68
+
69
+ function describeField(key, d) {
70
+ const shape = d.shape(key)
71
+ const { label, phrase } = d.words(key)
72
+ return { key, label: label ?? words(key), format: shape.format ?? formatOfKey(key), order: shape.order, hidden: shape.hidden, phrase }
73
+ }
74
+
75
+ // A lower order first; a field without one keeps its place after them
76
+ const byOrder = (a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)
77
+
78
+ const splitPhrase = (template) => {
79
+ const [before, after = ''] = template.split('{value}')
80
+ return { before, after }
81
+ }
82
+
83
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
84
+
85
+ // The format a value has by its shape, when neither its entry nor the suffix of its key gives one
86
+ function shapeOf(value) {
87
+ if (typeof value === 'boolean') return 'boolean'
88
+ if (typeof value === 'string' && /^https?:\/\//.test(value)) return 'url'
89
+ if (Array.isArray(value)) return value.every(isObject) ? 'table' : 'list'
90
+ if (!isObject(value)) return undefined
91
+ const values = Object.values(value)
92
+ if (values.every((v) => typeof v === 'number')) return 'distribution'
93
+ if (values.every((v) => isObject(v) && typeof v.value === 'number')) return 'phases'
94
+ if (values.every(isObject)) return 'table'
95
+ return 'fields'
96
+ }
97
+
98
+ // The file of a URL, or its host when the path has none
99
+ function shortUrl(url) {
100
+ try {
101
+ const { hostname, pathname } = new URL(url)
102
+ return pathname.split('/').filter(Boolean).pop() ?? hostname
103
+ } catch {
104
+ return url
105
+ }
106
+ }
107
+
108
+ // A part of a distribution reads inside a line, so in lower case, unless it starts with an acronym
109
+ const inLine = (label) => (/^[A-Z][a-z]/.test(label) ? label.charAt(0).toLowerCase() + label.slice(1) : label)
110
+
111
+ const plural = (forms, n, locale) => (forms?.[new Intl.PluralRules(locale).select(n)] ?? forms?.other ?? '{n}').replace('{n}', n)
112
+
113
+ // The phases of a metric: a part each, with its share of the whole and the slowest marked, and the
114
+ // thresholds of the metric as the scale to draw them on
115
+ function phases(value, d, result) {
116
+ const entries = Object.entries(value).map(([key, v]) => [key, typeof v === 'number' ? { value: v } : v])
117
+ const sum = entries.reduce((total, [, v]) => total + v.value, 0)
118
+ // The snippet knows which one is the slowest, also on a tie; otherwise, the longest
119
+ const named = result?.details?.slowestPhase
120
+ const slowest = entries.some(([key]) => key === named)
121
+ ? named
122
+ : entries.reduce((max, [key, v]) => (v.value > (max?.value ?? 0) ? { key, value: v.value } : max), null)?.key
123
+ const parts = entries.map(([key, v]) => ({
124
+ key,
125
+ label: describeField(key, d).label,
126
+ value: v.value,
127
+ text: formatValue(v.value, 'ms', d.locale),
128
+ percent: v.percent ?? (sum > 0 ? Math.round((v.value / sum) * 100) : 0),
129
+ overTarget: v.overTarget === true,
130
+ slowest: key === slowest,
131
+ }))
132
+ const t = result?.thresholds
133
+ const scale = { total: typeof result?.value === 'number' ? result.value : sum, ...(t ? { good: t.good, poor: t.needsImprovement } : {}) }
134
+ const text = parts
135
+ .filter((p) => p.value > 0)
136
+ .map((p) => `${p.label} ${p.text} (${formatValue(p.percent, 'percent', d.locale)})`)
137
+ .join(' · ')
138
+ return { parts, scale, text }
139
+ }
140
+
141
+ // A width and a height of the same thing, as one value of its size: naturalWidth and naturalHeight are
142
+ // naturalSize, 1,200×900
143
+ function joinSizes(rows, locale) {
144
+ const keys = new Set(rows.flatMap((row) => Object.keys(row)))
145
+ const heightOf = (width) => width.replace(/^width/, 'height').replace(/Width(Px)?$/, 'Height$1')
146
+ const pairs = [...keys]
147
+ .filter((key) => /^(width|.+Width)(Px)?$/.test(key) && keys.has(heightOf(key)))
148
+ .map((width) => [width, heightOf(width), width.replace(/^width/, 'size').replace(/Width(Px)?$/, 'Size$1')])
149
+ .filter(([, , size]) => !keys.has(size))
150
+ if (pairs.length === 0) return rows
151
+ const number = (n) => formatValue(n, 'count', locale)
152
+ return rows.map((row) =>
153
+ Object.fromEntries(
154
+ Object.entries(row).flatMap(([key, value]) => {
155
+ const pair = pairs.find(([width, height]) => key === width || key === height)
156
+ if (!pair) return [[key, value]]
157
+ if (key === pair[1]) return []
158
+ const [width, height, size] = pair
159
+ const both = typeof row[width] === 'number' && typeof row[height] === 'number'
160
+ const unit = size.endsWith('Px') ? ' px' : ''
161
+ return [[size, both ? `${number(row[width])}×${number(row[height])}${unit}` : undefined]]
162
+ })
163
+ )
164
+ )
165
+ }
166
+
167
+ const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
168
+
169
+ // A list of objects as a table: its columns, each with its label, and a row of cells per object. A
170
+ // column with the same value in every row is a constant, a line above the table; a column with the
171
+ // file of a URL column gives that one its place and label; and two or more columns of booleans are
172
+ // one column of marks
173
+ function table(items, d) {
174
+ const rows = joinSizes(
175
+ items.map((item) => (isObject(item) ? item : { value: item })),
176
+ d.locale
177
+ )
178
+ // A column empty in every row says nothing
179
+ let columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]
180
+ .filter((key) => rows.some((row) => !isEmpty(row[key])))
181
+ .map((key) => describeField(key, d))
182
+ .filter((c) => !c.hidden)
183
+ .sort(byOrder)
184
+ const cells = rows.map((row) => Object.fromEntries(columns.map(({ key }) => [key, describeValue(key, row[key], d, { inTable: true })])))
185
+
186
+ const same = (key) => rows.length > 1 && rows.every((row) => sameValue(row[key], rows[0][key]))
187
+ // Rows that are all the same keep their columns: a table without any says nothing of how many there are
188
+ const allSame = columns.every(({ key }) => same(key))
189
+ const constant = (key) => !allSame && same(key)
190
+ const constants = columns
191
+ .filter(({ key }) => constant(key))
192
+ .map(({ key }) => {
193
+ const { label, text } = describeValue(key, rows[0][key], d)
194
+ return { key, label, text }
195
+ })
196
+ columns = columns.filter(({ key }) => !constant(key))
197
+
198
+ for (const url of columns.filter(({ key }) => cells.every((c) => c[key].format === 'url' || c[key].text === ''))) {
199
+ const short = columns.find(
200
+ (c) => c !== url && cells.every((row) => (row[url.key].text === '' ? row[c.key].text === '' : row[c.key].value === row[url.key].text))
201
+ )
202
+ if (!short) continue
203
+ columns = columns.filter((c) => c !== url).map((c) => (c === short ? { ...url, label: short.label } : c))
204
+ }
205
+
206
+ const booleans = columns.filter(({ key }) => rows.every((row) => row[key] === undefined || typeof row[key] === 'boolean'))
207
+ const flagged = booleans.length > 1
208
+ if (flagged) columns = [...columns.filter((c) => !booleans.includes(c)), { key: 'flags', label: d.ui('flags'), format: 'flags' }]
209
+ const flagLabel = (c) => inLine(c.label.replace(/^(Is|Has) /, ''))
210
+
211
+ return {
212
+ columns: columns.map(({ key, label, format }) => ({ key, label, format })),
213
+ constants,
214
+ rows: cells.map((row, i) => ({
215
+ cells: columns.map(({ key }) => {
216
+ if (key !== 'flags' || !flagged) return row[key]
217
+ const flags = booleans.filter((c) => rows[i][c.key] === true).map(flagLabel)
218
+ return { key, format: 'flags', value: booleans.filter((c) => rows[i][c.key] === true).map((c) => c.key), flags, text: flags.join(' · ') }
219
+ }),
220
+ })),
221
+ }
222
+ }
223
+
224
+ // A value with its label and its text, and the parts, fields or rows of a value made of others
225
+ function describeValue(key, value, d, { inTable = false, result } = {}) {
226
+ const field = describeField(key, d)
227
+ const format = field.format ?? shapeOf(value)
228
+ const base = { key, label: field.label, format, value }
229
+ if (value === undefined || value === null) return { ...base, text: '' }
230
+ switch (format) {
231
+ case 'distribution': {
232
+ const parts = Object.entries(value)
233
+ .filter(([, n]) => n !== 0)
234
+ .map(([k, n]) => ({ key: k, label: inLine(describeField(k, d).label), value: n, text: formatValue(n, 'count', d.locale) }))
235
+ return { ...base, parts, text: parts.length ? parts.map((p) => `${p.label} ${p.text}`).join(' · ') : d.ui('none') }
236
+ }
237
+ case 'phases':
238
+ return { ...base, ...phases(value, d, result) }
239
+ case 'key':
240
+ return { ...base, text: describeField(String(value), d).label }
241
+ case 'table': {
242
+ // An object of objects is a table too, with the key of each one in its row
243
+ const items = Array.isArray(value) ? value : Object.entries(value).map(([name, v]) => ({ name, ...v }))
244
+ return { ...base, ...table(items, d), text: plural(d.ui('rows'), items.length, d.locale) }
245
+ }
246
+ case 'fields': {
247
+ const fields = Object.entries(value)
248
+ .filter(([k, v]) => !isEmpty(v) && !describeField(k, d).hidden)
249
+ .map(([k, v]) => describeValue(k, v, d))
250
+ return { ...base, fields, text: fields.map((f) => `${f.label} ${f.text}`).join(' · ') }
251
+ }
252
+ case 'list':
253
+ return { ...base, text: value.map((v) => formatValue(v, undefined, d.locale)).join(', ') }
254
+ case 'boolean':
255
+ return { ...base, text: inTable ? (value ? '✓' : '') : d.ui(value ? 'yes' : 'no') }
256
+ case 'url':
257
+ return { ...base, text: shortUrl(value), href: value }
258
+ default:
259
+ return { ...base, text: formatValue(value, format, d.locale) }
260
+ }
261
+ }
262
+
263
+ export function describeResult(result, options = {}) {
264
+ const d = dictionaries(result?.script, options)
265
+
266
+ const details = Object.entries(isObject(result?.details) ? result.details : {})
267
+ .filter(([, value]) => !isEmpty(value))
268
+ .map(([key, value]) => ({ field: describeField(key, d), value, key }))
269
+ .filter(({ field }) => !field.hidden)
270
+ .sort((a, b) => byOrder(a.field, b.field))
271
+ .map(({ field, key, value }) => ({
272
+ ...describeValue(key, value, d, { result }),
273
+ ...(field.phrase ? { phrase: splitPhrase(field.phrase) } : {}),
274
+ }))
275
+
276
+ const items = Array.isArray(result?.items) ? result.items : []
277
+ const { columns, constants, rows } = table(items, d)
278
+
279
+ // An issue with a target belongs to the row that has that URL, selector or name in one of its fields
280
+ const rowOf = (target) => {
281
+ const index = target === undefined ? -1 : items.findIndex((item) => isObject(item) && Object.values(item).includes(target))
282
+ return index < 0 ? undefined : index
283
+ }
284
+ const issues = (Array.isArray(result?.issues) ? result.issues : []).map(({ severity, code, message, target }) => ({
285
+ severity,
286
+ code,
287
+ message,
288
+ target,
289
+ row: rowOf(target),
290
+ }))
291
+
292
+ return {
293
+ title: d.title,
294
+ details,
295
+ columns,
296
+ constants,
297
+ rows: rows.map((row, i) => ({ ...row, issues: issues.filter((issue) => issue.row === i) })),
298
+ issues,
299
+ recommendations: Array.isArray(result?.recommendations) ? result.recommendations.filter((r) => typeof r === 'string') : [],
300
+ // The message of a snippet that records is written for the console (call getINP() again); the view
301
+ // says what to do in its own words
302
+ message: result?.status === 'tracking' && result.getDataFn ? d.ui('tracking') : result?.message,
303
+ }
304
+ }
@@ -0,0 +1,39 @@
1
+ // The English words of the view: the title of the table of each snippet, and the label of a field
2
+ // when its key in words does not say it well. `phrase` reads a field as a sentence around its value,
3
+ // at {value}. The entries of a snippet win over the general ones under "*". Another language is a file
4
+ // like this one, which falls back to this one for what it does not translate.
5
+
6
+ export default {
7
+ '*': {
8
+ title: 'Items',
9
+ fields: {
10
+ subParts: { label: 'Subparts' },
11
+ sslTls: { label: 'SSL/TLS' },
12
+ },
13
+ // The words of the view itself
14
+ words: {
15
+ yes: 'Yes',
16
+ no: 'No',
17
+ none: 'none',
18
+ flags: 'Flags',
19
+ tracking: 'Nothing recorded yet. Use the page, then read the result.',
20
+ rows: { one: '{n} row', other: '{n} rows' },
21
+ },
22
+ },
23
+ 'TTFB-Sub-Parts': {
24
+ fields: {
25
+ redirectWait: { label: 'Redirect' },
26
+ serviceWorkerCache: { label: 'Service worker' },
27
+ dnsLookup: { label: 'DNS' },
28
+ tcpConnection: { label: 'TCP' },
29
+ serverResponse: { label: 'Request' },
30
+ },
31
+ },
32
+ 'Find-render-blocking-resources': {
33
+ title: 'Render-blocking resources',
34
+ fields: {
35
+ totalBlockingUntilMs: { label: 'Blocked until', phrase: 'Rendering blocked until {value}' },
36
+ shortName: { label: 'File' },
37
+ },
38
+ },
39
+ }
@@ -0,0 +1 @@
1
+ { "type": "module", "private": true }
package/src/baseline.js CHANGED
@@ -116,14 +116,38 @@ function compareMetrics(base, current, thresholds) {
116
116
  return metrics;
117
117
  }
118
118
 
119
- // Two errors are the same when they share a code or, without one, when their messages differ only in
120
- // their figures: "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
121
- const keyOf = (error) => `${error.id}\u0000${error.code ?? error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
119
+ // A resource of the page itself by its path and query, so the review app of a PR, on another host,
120
+ // matches main; any other target as it is
121
+ function targetOf(target, pageUrl) {
122
+ if (!target) return "";
123
+ try {
124
+ const url = new URL(target);
125
+ return url.origin === new URL(pageUrl).origin ? url.pathname + url.search : url.href;
126
+ } catch {
127
+ return target;
128
+ }
129
+ }
130
+
131
+ // Two errors are the same when they share a code and a target or, when a snippet has no codes on one
132
+ // side (a baseline of 1.4.0), when their messages differ only in their figures: "Render-blocking CSS
133
+ // (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
134
+ const byMessage = (error) => `${error.id}\u0000${error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
122
135
 
123
136
  function compareErrors(base, current) {
124
- const byKey = (payload) => new Map(blockingErrors(payload.results ?? []).map((error) => [keyOf(error), error]));
125
- const b = byKey(base);
126
- const c = byKey(current);
137
+ const baseErrors = blockingErrors(base.results ?? []);
138
+ const currentErrors = blockingErrors(current.results ?? []);
139
+ // The snippets whose errors on one side all lack a code pair by message on both
140
+ const uncoded = (errors) => {
141
+ const ids = new Set(errors.map((e) => e.id));
142
+ for (const e of errors) if (e.code) ids.delete(e.id);
143
+ return ids;
144
+ };
145
+ const byMessageOnly = new Set([...uncoded(baseErrors), ...uncoded(currentErrors)]);
146
+ const keyOf = (error, pageUrl) =>
147
+ error.code && !byMessageOnly.has(error.id) ? `${error.id}\u0000${error.code}\u0000${targetOf(error.target, pageUrl)}` : byMessage(error);
148
+ const byKey = (errors, pageUrl) => new Map(errors.map((error) => [keyOf(error, pageUrl), error]));
149
+ const b = byKey(baseErrors, base.url);
150
+ const c = byKey(currentErrors, current.url);
127
151
  return {
128
152
  new: [...c].filter(([key]) => !b.has(key)).map(([, error]) => error),
129
153
  fixed: [...b].filter(([key]) => !c.has(key)).map(([, error]) => error),
package/src/bin.js CHANGED
@@ -18,6 +18,7 @@ import { parseOwnDomains } from "./own-domains.js";
18
18
  import { MEDIAN_METRICS, summarizeRuns } from "./runs.js";
19
19
  import { loadMatrix } from "./matrix.js";
20
20
  import { blockingErrors } from "./blocking.js";
21
+ import { MEASURED_BY, checkBudgets, parseBudgets, unmeasuredMetrics, violationsOf } from "./budgets.js";
21
22
  import { measurementConditions } from "./conditions.js";
22
23
  import { loadBaseline, parseThreshold, thresholdsWith, compareRun, compareMatrix } from "./baseline.js";
23
24
  import { describeRegressions, describeMatrixRegressions, matrixComparisonView } from "./reporters/baseline-view.js";
@@ -49,8 +50,11 @@ Options:
49
50
  --wait <ms> Post-load wait before evaluating (default: 3000)
50
51
  --cpu <factor> Slow the CPU down by this factor, such as 4 (no throttling by default)
51
52
  --network <preset> Throttle the network as DevTools does: fast-4g, slow-4g, 3g
52
- --budget-lcp <ms> Exit 1 if LCP exceeds this value
53
- --budget-cls <score> Exit 1 if CLS exceeds this value
53
+ --budget-lcp <ms> Exit 1 if LCP exceeds this value, or was not measured
54
+ --budget-fcp <ms> Exit 1 if FCP exceeds this value, or was not measured
55
+ --budget-ttfb <ms> Exit 1 if TTFB exceeds this value, or was not measured
56
+ --budget-cls <score> Exit 1 if CLS exceeds this value, or was not measured
57
+ --budget-inp <ms> Exit 1 if INP exceeds this value, or was not measured (interaction workflow)
54
58
  --interact-script <path> JSON file with interactions to run after the load; the
55
59
  Interaction snippets are installed first and read last
56
60
  Actions: scroll, click, hover, type, wait
@@ -124,30 +128,13 @@ function parseRunCount(raw) {
124
128
  return /^[1-9]$/.test(raw.trim()) ? Number(raw) : null;
125
129
  }
126
130
 
127
- function checkBudgets({ results, runs }, values) {
128
- const violations = [];
129
- const lcpBudget = values["budget-lcp"] ? Number(values["budget-lcp"]) : null;
130
- const clsBudget = values["budget-cls"] ? Number(values["budget-cls"]) : null;
131
-
132
- // With several runs the budgets compare the median of each metric, not one sample
133
- if (runs) {
134
- const lcp = runs.summary.LCP?.median;
135
- const cls = runs.summary.CLS?.median;
136
- if (lcp != null && lcpBudget != null && lcp > lcpBudget) violations.push(`LCP ${lcp}ms exceeds budget ${lcpBudget}ms`);
137
- if (cls != null && clsBudget != null && cls > clsBudget) violations.push(`CLS ${cls} exceeds budget ${clsBudget}`);
138
- return violations;
139
- }
140
-
141
- for (const r of results) {
142
- if (r.status !== "ok") continue;
143
- if (r.metric === "LCP" && lcpBudget != null && r.value > lcpBudget) {
144
- violations.push(`LCP ${r.value}ms exceeds budget ${lcpBudget}ms`);
145
- }
146
- if (r.metric === "CLS" && clsBudget != null && r.value > clsBudget) {
147
- violations.push(`CLS ${r.value} exceeds budget ${clsBudget}`);
148
- }
149
- }
150
- return violations;
131
+ // What to run instead, when a budget names a metric the run does not measure
132
+ function budgetUsage(metric, run) {
133
+ const workflows = Object.values(WORKFLOWS)
134
+ .filter((w) => w.steps.some((step) => MEASURED_BY[metric].includes(step.path)))
135
+ .map((w) => w.id);
136
+ const instead = [...workflows.map((id) => `--workflow ${id}`), `--snippet ${MEASURED_BY[metric][0].split("/").pop()}`].join(" or ");
137
+ return `--budget-${metric.toLowerCase()} needs a run that measures ${metric}, and ${run} does not. Use ${instead}`;
151
138
  }
152
139
 
153
140
  async function main() {
@@ -168,7 +155,10 @@ async function main() {
168
155
  markdown: { type: "boolean" },
169
156
  wait: { type: "string" },
170
157
  "budget-lcp": { type: "string" },
158
+ "budget-fcp": { type: "string" },
159
+ "budget-ttfb": { type: "string" },
171
160
  "budget-cls": { type: "string" },
161
+ "budget-inp": { type: "string" },
172
162
  viewport: { type: "string" },
173
163
  cpu: { type: "string" },
174
164
  network: { type: "string" },
@@ -275,10 +265,20 @@ async function main() {
275
265
  if (values["fail-on-regression"]) fail("--fail-on-regression needs --baseline");
276
266
  }
277
267
 
268
+ let budgets;
269
+ try {
270
+ budgets = parseBudgets(values);
271
+ } catch (err) {
272
+ fail(err.message);
273
+ }
274
+
278
275
  // What each page load runs, a snippet or a workflow, under the conditions of the load
279
276
  const items = values.snippet ? buildSnippetItem(values) : null;
280
277
  const workflow = values.snippet ? null : WORKFLOWS[values.workflow ?? "core-web-vitals"];
281
278
  if (!values.snippet && !workflow) fail(`Unknown workflow: ${values.workflow}`);
279
+ // A budget of a metric that no snippet of the run measures could never pass: a usage error
280
+ const [unmeasured] = unmeasuredMetrics(budgets, items ? [items[0].path] : workflow.steps.map((step) => step.path));
281
+ if (unmeasured) fail(budgetUsage(unmeasured, items ? `--snippet ${values.snippet}` : `the ${workflow.id} workflow`));
282
282
  const measureWith = (conditions) => {
283
283
  const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
284
284
  return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
@@ -294,7 +294,7 @@ async function main() {
294
294
  } catch (err) {
295
295
  fail(err.message);
296
296
  }
297
- await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds });
297
+ await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets });
298
298
  return;
299
299
  }
300
300
 
@@ -318,6 +318,9 @@ async function main() {
318
318
  for (const warning of payload.baseline.warnings) process.stderr.write(`Warning: ${warning}\n`);
319
319
  }
320
320
 
321
+ // The verdict of each budget goes in the report, so a pull request comment says which one failed
322
+ if (Object.keys(budgets).length > 0) payload.budgets = checkBudgets(payload, budgets);
323
+
321
324
  let output;
322
325
  if (values.markdown) {
323
326
  output = reportMarkdown(payload);
@@ -339,6 +342,7 @@ async function main() {
339
342
  timestamp: new Date().toISOString(),
340
343
  navMs: payload.navMs,
341
344
  results: payload.results,
345
+ ...(payload.budgets ? { budgets: payload.budgets } : {}),
342
346
  meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), ...(payload.runs ? { runs: payload.runs } : {}), cli_version: cliVersion },
343
347
  },
344
348
  });
@@ -349,12 +353,10 @@ async function main() {
349
353
  }
350
354
  }
351
355
 
352
- // Exit codes.
353
- const violations = checkBudgets(payload, values);
356
+ // Exit codes. stderr names the budgets that failed, also with --json, whose report is on stdout
357
+ const violations = violationsOf(payload.budgets ?? []);
354
358
  if (violations.length > 0) {
355
- if (!values.json) {
356
- for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
357
- }
359
+ for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
358
360
  process.exit(1);
359
361
  }
360
362
  // Without --fail-on-regression the comparison only informs, and the exit code is the one of today
@@ -385,7 +387,7 @@ async function measureRuns(measure, count, medianBy, progress) {
385
387
 
386
388
  // Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
387
389
  // its median run, and the exit code follows the same rules as one page, for every combination.
388
- async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds }) {
390
+ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets }) {
389
391
  for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
390
392
  const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
391
393
 
@@ -426,6 +428,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
426
428
  for (const warning of matrixComparisonView(root, combinations).warnings) process.stderr.write(`Warning: ${warning}\n`);
427
429
  }
428
430
 
431
+ if (Object.keys(budgets).length > 0) for (const c of combinations.filter((c) => !c.error)) c.budgets = checkBudgets(c, budgets);
432
+
429
433
  const withBaseline = comparison ? { baseline: comparison } : {};
430
434
  const report = { matrix: matrixPath, runs: matrix.runs, combinations, verbose: values.verbose, ...withBaseline };
431
435
  const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, runs: matrix.runs, ...withBaseline, combinations }) : reportMatrixHuman(report);
@@ -445,6 +449,7 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
445
449
  timestamp: new Date().toISOString(),
446
450
  navMs: c.navMs,
447
451
  results: c.results,
452
+ ...(c.budgets ? { budgets: c.budgets } : {}),
448
453
  meta: {
449
454
  viewport: viewportOf.get(c.id),
450
455
  device: c.device,
@@ -464,10 +469,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
464
469
  }
465
470
  }
466
471
 
467
- const violations = combinations.filter((c) => !c.error).flatMap((c) => checkBudgets(c, values).map((v) => `${v} (${c.id})`));
468
- if (!values.json) {
469
- for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
470
- }
472
+ const violations = combinations.flatMap((c) => violationsOf(c.budgets ?? []).map((v) => `${v} (${c.id})`));
473
+ for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
471
474
  const failed = combinations.some((c) => c.error || blockingErrors(c.results).length > 0);
472
475
  // Without --fail-on-regression the comparison only informs
473
476
  const regressions = values["fail-on-regression"] ? describeMatrixRegressions(combinations) : [];
package/src/blocking.js CHANGED
@@ -7,7 +7,7 @@ export function blockingErrors(results) {
7
7
  if (r.status === "error") errors.push({ id: r.id, message: r.error });
8
8
  if (r.reason || !Array.isArray(r.issues)) continue;
9
9
  for (const issue of r.issues) {
10
- if (issue.severity === "error") errors.push({ id: r.id, message: issue.message, ...(issue.code ? { code: issue.code } : {}) });
10
+ if (issue.severity === "error") errors.push({ id: r.id, message: issue.message, ...(issue.code ? { code: issue.code } : {}), ...(issue.target ? { target: issue.target } : {}) });
11
11
  }
12
12
  }
13
13
  return errors;
package/src/budgets.js ADDED
@@ -0,0 +1,73 @@
1
+ // The performance budgets of the command line (--budget-lcp, --budget-fcp, --budget-ttfb,
2
+ // --budget-cls and --budget-inp): reading them, telling whether the run can measure their metric,
3
+ // and the verdict of each one against what a page measured.
4
+
5
+ // The metrics that take a budget, with their unit
6
+ export const BUDGET_METRICS = { LCP: "ms", FCP: "ms", TTFB: "ms", CLS: "score", INP: "ms" };
7
+
8
+ // The snippets whose result carries each metric, by path
9
+ export const MEASURED_BY = {
10
+ LCP: ["CoreWebVitals/LCP", "CoreWebVitals/LCP-Subparts"],
11
+ FCP: ["Loading/FCP"],
12
+ TTFB: ["Loading/TTFB", "Loading/TTFB-Sub-Parts"],
13
+ CLS: ["CoreWebVitals/CLS"],
14
+ INP: ["CoreWebVitals/INP"],
15
+ };
16
+
17
+ const optionOf = (metric) => `--budget-${metric.toLowerCase()}`;
18
+
19
+ // The budgets given on the command line, by metric. A value that is not a number of 0 or more throws,
20
+ // so the command can exit with 2 before the browser opens.
21
+ export function parseBudgets(values) {
22
+ const budgets = {};
23
+ for (const metric of Object.keys(BUDGET_METRICS)) {
24
+ const raw = values[optionOf(metric).slice(2)];
25
+ if (raw === undefined) continue;
26
+ if (!/^\d+(?:\.\d+)?$/.test(raw.trim())) {
27
+ throw new Error(`Invalid ${optionOf(metric)} "${raw}". Use a number of 0 or more, such as ${metric === "CLS" ? "0.1" : "2500"}`);
28
+ }
29
+ budgets[metric] = Number(raw);
30
+ }
31
+ return budgets;
32
+ }
33
+
34
+ // The metrics with a budget that no snippet of the run measures, such as LCP in the audit workflow
35
+ export const unmeasuredMetrics = (budgets, paths) =>
36
+ Object.keys(budgets).filter((metric) => !MEASURED_BY[metric].some((path) => paths.includes(path)));
37
+
38
+ const nameOf = (path) => path.split("/").pop().toLowerCase();
39
+
40
+ // The result of the snippet that measures a metric, a step of the run before a follow-up
41
+ function resultFor(results, metric) {
42
+ const names = MEASURED_BY[metric].map(nameOf);
43
+ const ofMetric = results.filter((r) => r.metric === metric || names.includes(String(r.script ?? r.id).toLowerCase()));
44
+ return ofMetric.find((r) => !r.reason || r.status === "skipped") ?? ofMetric[0];
45
+ }
46
+
47
+ // The verdict of each budget: passed when the value is at most the budget, exceeded above it, and not
48
+ // measured when the run has no value, with the reason its snippet gave. With several runs, the median.
49
+ export function checkBudgets({ results = [], runs }, budgets) {
50
+ return Object.entries(budgets).map(([metric, budget]) => {
51
+ const unit = BUDGET_METRICS[metric];
52
+ const result = resultFor(results, metric);
53
+ const measured = result?.status === "ok" && typeof result.value === "number" ? result.value : null;
54
+ const value = runs ? (runs.summary?.[metric]?.median ?? null) : measured;
55
+ if (value === null) {
56
+ const note = result?.error ?? result?.message ?? (result?.status === "skipped" ? result.reason : undefined);
57
+ return { metric, budget, value: null, unit, verdict: "not measured", ...(note ? { note } : {}) };
58
+ }
59
+ return { metric, budget, value, unit, verdict: value > budget ? "exceeded" : "passed" };
60
+ });
61
+ }
62
+
63
+ const amount = (value, unit) => (unit === "ms" ? `${value}ms` : String(value));
64
+
65
+ // The budgets that failed, in the words of stderr
66
+ export const violationsOf = (checked) =>
67
+ checked
68
+ .filter((b) => b.verdict !== "passed")
69
+ .map((b) =>
70
+ b.verdict === "exceeded"
71
+ ? `${b.metric} ${amount(b.value, b.unit)} exceeds budget ${amount(b.budget, b.unit)}`
72
+ : `${b.metric} was not measured (budget ${amount(b.budget, b.unit)})${b.note ? `: ${b.note}` : ""}`
73
+ );