webperf-snippets 1.5.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.
- package/README.md +14 -6
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +6 -4
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
- package/snippets/Interaction/Interactions.js +8 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
- package/snippets/Loading/Critical-CSS-Detection.js +2 -1
- package/snippets/SCHEMA.md +7 -4
- package/snippets/_view/fields.js +37 -0
- package/snippets/_view/format.js +39 -0
- package/snippets/_view/index.js +304 -0
- package/snippets/_view/locales/en.js +39 -0
- package/snippets/_view/package.json +1 -0
- package/src/bin.js +40 -37
- package/src/budgets.js +73 -0
- package/src/mcp/server.js +1 -1
- package/src/reporters/budgets-view.js +38 -0
- package/src/reporters/human.js +42 -20
- package/src/reporters/markdown.js +53 -1
- package/src/reporters/summary-view.js +12 -0
- package/src/runner.js +3 -3
- package/src/view.js +11 -0
- 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/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-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
468
|
-
|
|
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/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
|
+
);
|
package/src/mcp/server.js
CHANGED
|
@@ -350,7 +350,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
350
350
|
{
|
|
351
351
|
title: "Read the tracking snippets",
|
|
352
352
|
description:
|
|
353
|
-
"Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet answers with status
|
|
353
|
+
"Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again.",
|
|
354
354
|
inputSchema: z.object({ sessionId: SessionId }),
|
|
355
355
|
outputSchema: z.object({ sessionId: z.string(), pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
|
|
356
356
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The budgets as the reports show them, each report with its own format of a value: a row per budget,
|
|
2
|
+
// passed or not, with by how much it was exceeded or why its metric was not measured.
|
|
3
|
+
|
|
4
|
+
const difference = (budget) => {
|
|
5
|
+
const over = budget.value - budget.budget;
|
|
6
|
+
return budget.unit === "ms" ? Math.round(over) : Math.round(over * 10000) / 10000;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function budgetRows(budgets, format) {
|
|
10
|
+
return budgets.map((b) => ({
|
|
11
|
+
metric: b.metric,
|
|
12
|
+
value: b.value == null ? "·" : format(b.value, b.unit),
|
|
13
|
+
budget: format(b.budget, b.unit),
|
|
14
|
+
passed: b.verdict === "passed",
|
|
15
|
+
verdict:
|
|
16
|
+
b.verdict === "exceeded"
|
|
17
|
+
? `exceeded by ${format(difference(b), b.unit)}`
|
|
18
|
+
: b.verdict === "not measured"
|
|
19
|
+
? `not measured${b.note ? `: ${b.note}` : ""}`
|
|
20
|
+
: "passed",
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The failed budgets of a matrix, each named with its combination, or null when no combination had
|
|
25
|
+
// budgets to check
|
|
26
|
+
export function matrixBudgetFailures(combinations, format) {
|
|
27
|
+
const checked = combinations.filter((c) => Array.isArray(c.budgets) && c.budgets.length > 0);
|
|
28
|
+
if (checked.length === 0) return null;
|
|
29
|
+
return checked.flatMap((c) =>
|
|
30
|
+
c.budgets
|
|
31
|
+
.filter((b) => b.verdict !== "passed")
|
|
32
|
+
.map((b) =>
|
|
33
|
+
b.verdict === "exceeded"
|
|
34
|
+
? `${c.id}: ${b.metric} ${format(b.value, b.unit)} exceeds budget ${format(b.budget, b.unit)}`
|
|
35
|
+
: `${c.id}: ${b.metric} was not measured${b.note ? ` (${b.note})` : ""}`
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|