@bejamas/bench 0.0.0-canary.1d776cdf
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 +127 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +125 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +289 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/summary-063R0d9g.js +1510 -0
- package/dist/summary-063R0d9g.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { Launcher } from "chrome-launcher";
|
|
5
|
+
import { gzipSync } from "node:zlib";
|
|
6
|
+
import pixelmatch from "pixelmatch";
|
|
7
|
+
import { PNG } from "pngjs";
|
|
8
|
+
import kleur from "kleur";
|
|
9
|
+
|
|
10
|
+
//#region package.json
|
|
11
|
+
var name = "@bejamas/bench";
|
|
12
|
+
var version = "0.0.0-canary.1d776cdf";
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/artifacts.ts
|
|
16
|
+
/**
|
|
17
|
+
* Report files are opt-in. Without a directory there is no writer, so callers
|
|
18
|
+
* use `artifacts?.write(path, encode())`, which also skips the encoding.
|
|
19
|
+
*/
|
|
20
|
+
function createArtifacts(outDir) {
|
|
21
|
+
if (!outDir) return void 0;
|
|
22
|
+
const dir = resolve(outDir);
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
return {
|
|
25
|
+
dir,
|
|
26
|
+
write(path, data) {
|
|
27
|
+
const file = join(dir, path);
|
|
28
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
29
|
+
writeFileSync(file, data);
|
|
30
|
+
return file;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/errors.ts
|
|
37
|
+
/** An expected failure with a message that is safe to print without a stack. */
|
|
38
|
+
var BenchError = class extends Error {
|
|
39
|
+
name = "BenchError";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/browser.ts
|
|
44
|
+
function resolveChromePath(explicit) {
|
|
45
|
+
const chromePath = explicit ?? process.env.CHROME_PATH ?? Launcher.getFirstInstallation();
|
|
46
|
+
if (!chromePath) throw new BenchError("Google Chrome was not found. Install Chrome, pass --chrome-path or set CHROME_PATH.");
|
|
47
|
+
return chromePath;
|
|
48
|
+
}
|
|
49
|
+
async function launchBrowser(chromePath) {
|
|
50
|
+
const { chromium } = await import("playwright-core");
|
|
51
|
+
return chromium.launch({
|
|
52
|
+
executablePath: chromePath,
|
|
53
|
+
headless: true
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Load a page until the network is idle, then scroll through it so lazy images
|
|
58
|
+
* and scroll-triggered content render before anything is measured.
|
|
59
|
+
*/
|
|
60
|
+
async function loadPage(page, url, timeout) {
|
|
61
|
+
await page.goto(url, {
|
|
62
|
+
waitUntil: "networkidle",
|
|
63
|
+
timeout
|
|
64
|
+
});
|
|
65
|
+
await page.evaluate(async () => {
|
|
66
|
+
const step = Math.max(200, Math.floor(window.innerHeight * .8));
|
|
67
|
+
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
|
68
|
+
const limit = 1e5;
|
|
69
|
+
for (let y = 0; y < Math.min(document.documentElement.scrollHeight, limit); y += step) {
|
|
70
|
+
window.scrollTo(0, y);
|
|
71
|
+
await pause();
|
|
72
|
+
}
|
|
73
|
+
window.scrollTo(0, 0);
|
|
74
|
+
await document.fonts.ready;
|
|
75
|
+
});
|
|
76
|
+
await page.waitForLoadState("networkidle", { timeout }).catch(() => {});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/format.ts
|
|
81
|
+
function formatBytes(bytes) {
|
|
82
|
+
if (bytes === null) return "–";
|
|
83
|
+
if (Math.abs(bytes) < 1024) return `${bytes} B`;
|
|
84
|
+
return `${(bytes / 1024).toFixed(2)} KiB`;
|
|
85
|
+
}
|
|
86
|
+
function formatMs(value) {
|
|
87
|
+
if (value === null) return "–";
|
|
88
|
+
return value >= 1e3 ? `${(value / 1e3).toFixed(2)} s` : `${value.toFixed(0)} ms`;
|
|
89
|
+
}
|
|
90
|
+
function formatDelta(original, ported, format) {
|
|
91
|
+
if (original === null || ported === null) return "–";
|
|
92
|
+
const delta = ported - original;
|
|
93
|
+
if (delta === 0) return "±0";
|
|
94
|
+
const sign = delta > 0 ? "+" : "−";
|
|
95
|
+
const relative = original === 0 ? "" : ` (${sign}${Math.abs(delta / original * 100).toFixed(1)}%)`;
|
|
96
|
+
return `${sign}${format(Math.abs(delta))}${relative}`;
|
|
97
|
+
}
|
|
98
|
+
function truncate(value, length) {
|
|
99
|
+
return value.length > length ? `${value.slice(0, length - 1)}…` : value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/stats.ts
|
|
104
|
+
function round(value, digits = 2) {
|
|
105
|
+
const factor = 10 ** digits;
|
|
106
|
+
return Math.round(value * factor) / factor;
|
|
107
|
+
}
|
|
108
|
+
/** Median of the defined values, or null when there are none. */
|
|
109
|
+
function median(values) {
|
|
110
|
+
const sorted = values.filter((value) => typeof value === "number").sort((a, b) => a - b);
|
|
111
|
+
if (sorted.length === 0) return null;
|
|
112
|
+
const middle = Math.floor(sorted.length / 2);
|
|
113
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/metrics.ts
|
|
118
|
+
const FORMATTERS = {
|
|
119
|
+
ms: formatMs,
|
|
120
|
+
bytes: formatBytes,
|
|
121
|
+
count: String,
|
|
122
|
+
score: (value) => String(round(value, 1)),
|
|
123
|
+
cls: (value) => String(round(value, 3)),
|
|
124
|
+
percent: (value) => `${round(value, 3)}%`
|
|
125
|
+
};
|
|
126
|
+
function formatValue(unit, value) {
|
|
127
|
+
return FORMATTERS[unit](value);
|
|
128
|
+
}
|
|
129
|
+
/** Suffixes a budget value may use, by unit. */
|
|
130
|
+
const UNIT_FACTORS = {
|
|
131
|
+
ms: {
|
|
132
|
+
ms: 1,
|
|
133
|
+
s: 1e3
|
|
134
|
+
},
|
|
135
|
+
bytes: {
|
|
136
|
+
b: 1,
|
|
137
|
+
kb: 1024,
|
|
138
|
+
kib: 1024,
|
|
139
|
+
mb: 1024 ** 2,
|
|
140
|
+
mib: 1024 ** 2
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const LIGHTHOUSE = {
|
|
144
|
+
score: {
|
|
145
|
+
label: "Performance score",
|
|
146
|
+
unit: "score",
|
|
147
|
+
digits: 2,
|
|
148
|
+
higherIsBetter: true
|
|
149
|
+
},
|
|
150
|
+
fcp: {
|
|
151
|
+
label: "First Contentful Paint",
|
|
152
|
+
unit: "ms",
|
|
153
|
+
digits: 2,
|
|
154
|
+
higherIsBetter: false
|
|
155
|
+
},
|
|
156
|
+
lcp: {
|
|
157
|
+
label: "Largest Contentful Paint",
|
|
158
|
+
unit: "ms",
|
|
159
|
+
digits: 2,
|
|
160
|
+
higherIsBetter: false
|
|
161
|
+
},
|
|
162
|
+
tbt: {
|
|
163
|
+
label: "Total Blocking Time",
|
|
164
|
+
unit: "ms",
|
|
165
|
+
digits: 2,
|
|
166
|
+
higherIsBetter: false
|
|
167
|
+
},
|
|
168
|
+
cls: {
|
|
169
|
+
label: "Cumulative Layout Shift",
|
|
170
|
+
unit: "cls",
|
|
171
|
+
digits: 4,
|
|
172
|
+
higherIsBetter: false
|
|
173
|
+
},
|
|
174
|
+
si: {
|
|
175
|
+
label: "Speed Index",
|
|
176
|
+
unit: "ms",
|
|
177
|
+
digits: 2,
|
|
178
|
+
higherIsBetter: false
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/budgets.ts
|
|
184
|
+
const lighthouseMetric = (metric) => ({
|
|
185
|
+
kind: "change",
|
|
186
|
+
unit: LIGHTHOUSE[metric].unit,
|
|
187
|
+
read: ({ lighthouse, comparability }) => {
|
|
188
|
+
if (!lighthouse) return "the Lighthouse stage did not run";
|
|
189
|
+
if (!comparability.comparable) return "timings are not comparable between these URLs";
|
|
190
|
+
const original = lighthouse.original.median[metric];
|
|
191
|
+
const ported = lighthouse.ported.median[metric];
|
|
192
|
+
if (original === null || ported === null) return `Lighthouse did not report ${metric}`;
|
|
193
|
+
return {
|
|
194
|
+
original,
|
|
195
|
+
ported
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
const assetMetric = (unit, read) => ({
|
|
200
|
+
kind: "change",
|
|
201
|
+
unit,
|
|
202
|
+
read: ({ assets }) => assets ? {
|
|
203
|
+
original: read(assets.original),
|
|
204
|
+
ported: read(assets.ported)
|
|
205
|
+
} : "the assets stage did not run"
|
|
206
|
+
});
|
|
207
|
+
const METRICS = {
|
|
208
|
+
score: lighthouseMetric("score"),
|
|
209
|
+
fcp: lighthouseMetric("fcp"),
|
|
210
|
+
lcp: lighthouseMetric("lcp"),
|
|
211
|
+
tbt: lighthouseMetric("tbt"),
|
|
212
|
+
cls: lighthouseMetric("cls"),
|
|
213
|
+
si: lighthouseMetric("si"),
|
|
214
|
+
js: assetMetric("bytes", (side) => side.categories.js.size),
|
|
215
|
+
css: assetMetric("bytes", (side) => side.categories.css.size),
|
|
216
|
+
html: assetMetric("bytes", (side) => side.categories.html.size),
|
|
217
|
+
fonts: assetMetric("bytes", (side) => side.categories.fonts.size),
|
|
218
|
+
total: assetMetric("bytes", (side) => side.coreSize),
|
|
219
|
+
requests: assetMetric("count", (side) => side.requests),
|
|
220
|
+
pixels: {
|
|
221
|
+
kind: "absolute",
|
|
222
|
+
unit: "percent",
|
|
223
|
+
read: ({ visual }) => {
|
|
224
|
+
const widths = visual?.widths ?? [];
|
|
225
|
+
if (widths.length === 0) return "the visual stage did not run";
|
|
226
|
+
const worst = widths.reduce((a, b) => b.pixels.mismatch > a.pixels.mismatch ? b : a);
|
|
227
|
+
return {
|
|
228
|
+
value: worst.pixels.mismatch,
|
|
229
|
+
detail: `${formatValue("percent", worst.pixels.mismatch)} of pixels differ at ${worst.width}px`
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const isBudgetMetric = (value) => Object.hasOwn(METRICS, value);
|
|
235
|
+
const BUDGET_HELP = `Budgets compare the ported URL with the original. Size and timing budgets
|
|
236
|
+
use the change (ported − original), absolute or relative with %:
|
|
237
|
+
lcp>10% fail when LCP grows by more than 10%
|
|
238
|
+
js>0 fail when route JavaScript (gzip) grows at all
|
|
239
|
+
total>20kb fail when HTML + CSS + JS + fonts grow by more than 20 KiB
|
|
240
|
+
score<-5 fail when the Lighthouse score drops by more than 5 points
|
|
241
|
+
pixels>1% fail when screenshots differ in more than 1% of pixels (absolute)
|
|
242
|
+
Metrics: ${Object.keys(METRICS).join(", ")}.`;
|
|
243
|
+
function parseBudgets(inputs) {
|
|
244
|
+
return inputs.flatMap((input) => input.split(",")).map((part) => part.trim()).filter(Boolean).map(parseBudget);
|
|
245
|
+
}
|
|
246
|
+
function parseBudget(source) {
|
|
247
|
+
const match = source.match(/^([a-z]+)\s*([<>])\s*(-?\d+(?:\.\d+)?)\s*(%|[a-z]+)?$/i);
|
|
248
|
+
if (!match) throw new BenchError(`Invalid budget "${source}". Expected <metric><op><value>, e.g. lcp>10%.`);
|
|
249
|
+
const metric = match[1].toLowerCase();
|
|
250
|
+
if (!isBudgetMetric(metric)) throw new BenchError(`Unknown budget metric "${metric}" in "${source}". ${Object.keys(METRICS).join(", ")} are supported.`);
|
|
251
|
+
const spec = METRICS[metric];
|
|
252
|
+
const unit = match[4]?.toLowerCase();
|
|
253
|
+
let value = Number(match[3]);
|
|
254
|
+
if (unit && unit !== "%") {
|
|
255
|
+
const factors = UNIT_FACTORS[spec.unit] ?? {};
|
|
256
|
+
if (!Object.hasOwn(factors, unit)) throw new BenchError(`Unit "${unit}" does not apply to ${metric} in "${source}".`);
|
|
257
|
+
value *= factors[unit];
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
source,
|
|
261
|
+
metric,
|
|
262
|
+
op: match[2],
|
|
263
|
+
value,
|
|
264
|
+
relative: unit === "%" && spec.kind === "change"
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const sign = (value) => value > 0 ? "+" : value < 0 ? "−" : "±";
|
|
268
|
+
function evaluateBudget(budget, inputs) {
|
|
269
|
+
const spec = METRICS[budget.metric];
|
|
270
|
+
const result = (actual, detail) => ({
|
|
271
|
+
budget: budget.source,
|
|
272
|
+
status: (budget.op === ">" ? actual > budget.value : actual < budget.value) ? "fail" : "pass",
|
|
273
|
+
detail
|
|
274
|
+
});
|
|
275
|
+
const skip = (reason) => ({
|
|
276
|
+
budget: budget.source,
|
|
277
|
+
status: "skip",
|
|
278
|
+
detail: reason
|
|
279
|
+
});
|
|
280
|
+
if (spec.kind === "absolute") {
|
|
281
|
+
const reading = spec.read(inputs);
|
|
282
|
+
if (typeof reading === "string") return skip(reading);
|
|
283
|
+
return result(reading.value, reading.detail);
|
|
284
|
+
}
|
|
285
|
+
const pair = spec.read(inputs);
|
|
286
|
+
if (typeof pair === "string") return skip(pair);
|
|
287
|
+
const format = (value) => formatValue(spec.unit, value);
|
|
288
|
+
const delta = pair.ported - pair.original;
|
|
289
|
+
const values = `${budget.metric}: ${format(pair.original)} → ${format(pair.ported)}`;
|
|
290
|
+
if (!budget.relative) return result(delta, `${values} (${sign(delta)}${format(Math.abs(delta))})`);
|
|
291
|
+
const relative = pair.original === 0 ? delta === 0 ? 0 : Math.sign(delta) * Infinity : delta / pair.original * 100;
|
|
292
|
+
return result(relative, `${values} (${sign(relative)}${round(Math.abs(relative), 1)}%)`);
|
|
293
|
+
}
|
|
294
|
+
function evaluateBudgets(budgets, inputs) {
|
|
295
|
+
return budgets.map((budget) => evaluateBudget(budget, inputs));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/text.ts
|
|
300
|
+
/**
|
|
301
|
+
* Locate the first region where the visible text of the two pages differs,
|
|
302
|
+
* with `context` words around it and at most `maxWords` words per excerpt.
|
|
303
|
+
*/
|
|
304
|
+
function compareText(original, ported, { context = 12, maxWords = 80 } = {}) {
|
|
305
|
+
const a = original.split(" ").filter(Boolean);
|
|
306
|
+
const b = ported.split(" ").filter(Boolean);
|
|
307
|
+
const result = {
|
|
308
|
+
identical: original === ported,
|
|
309
|
+
originalWords: a.length,
|
|
310
|
+
portedWords: b.length
|
|
311
|
+
};
|
|
312
|
+
if (result.identical) return result;
|
|
313
|
+
let start = 0;
|
|
314
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
315
|
+
let endA = a.length;
|
|
316
|
+
let endB = b.length;
|
|
317
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
318
|
+
endA--;
|
|
319
|
+
endB--;
|
|
320
|
+
}
|
|
321
|
+
const excerpt = (words, end) => {
|
|
322
|
+
const from = Math.max(0, start - context);
|
|
323
|
+
const to = Math.min(words.length, Math.max(end, start) + context, from + maxWords);
|
|
324
|
+
return `${from > 0 ? "… " : ""}${words.slice(from, to).join(" ")}${to < words.length ? " …" : ""}`;
|
|
325
|
+
};
|
|
326
|
+
return {
|
|
327
|
+
...result,
|
|
328
|
+
original: excerpt(a, endA),
|
|
329
|
+
ported: excerpt(b, endB)
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/checks.ts
|
|
335
|
+
const PIXEL_WARNING = 1;
|
|
336
|
+
const check = (id, label, [status, detail]) => ({
|
|
337
|
+
id,
|
|
338
|
+
label,
|
|
339
|
+
status,
|
|
340
|
+
detail
|
|
341
|
+
});
|
|
342
|
+
function list(values, limit = 3) {
|
|
343
|
+
const shown = values.slice(0, limit).join("; ");
|
|
344
|
+
return values.length > limit ? `${shown}; and ${values.length - limit} more` : shown;
|
|
345
|
+
}
|
|
346
|
+
/** Strip the origin so the same path on both servers compares equal. */
|
|
347
|
+
function withoutOrigin(value, finalUrl) {
|
|
348
|
+
const origin = new URL(finalUrl).origin;
|
|
349
|
+
return value.split(origin).join("");
|
|
350
|
+
}
|
|
351
|
+
/** Entries the port has and the original doesn't, compared without origins. */
|
|
352
|
+
function newEntries(original, ported, read, key) {
|
|
353
|
+
const keyOf = (side) => (value) => withoutOrigin(key(value), side.finalUrl);
|
|
354
|
+
const seen = new Set(read(original).map(keyOf(original)));
|
|
355
|
+
return read(ported).filter((value) => !seen.has(keyOf(ported)(value)));
|
|
356
|
+
}
|
|
357
|
+
const describeRequest = ({ url, reason }) => `${url} (${reason})`;
|
|
358
|
+
/** One line per host and reason, so a broken CDN reads as one problem. */
|
|
359
|
+
function groupFailures(requests) {
|
|
360
|
+
const groups = /* @__PURE__ */ new Map();
|
|
361
|
+
for (const request of requests) {
|
|
362
|
+
const key = `${new URL(request.url).host} ${request.reason}`;
|
|
363
|
+
groups.set(key, [...groups.get(key) ?? [], request]);
|
|
364
|
+
}
|
|
365
|
+
return [...groups.values()].map((group) => group.length === 1 ? describeRequest(group[0]) : `${group.length} requests to ${new URL(group[0].url).host} (${group[0].reason})`);
|
|
366
|
+
}
|
|
367
|
+
/** Every check for the stages that produced results, in report order. */
|
|
368
|
+
function reportChecks({ comparability, quality, visual }) {
|
|
369
|
+
return [
|
|
370
|
+
comparabilityCheck(comparability),
|
|
371
|
+
...quality ? qualityChecks(quality) : [],
|
|
372
|
+
...visual ? visualChecks(visual) : []
|
|
373
|
+
];
|
|
374
|
+
}
|
|
375
|
+
function comparabilityCheck(comparability) {
|
|
376
|
+
return check("comparability", "Timing comparability", comparability.comparable ? ["pass", "Both URLs run in the same kind of environment."] : ["warn", comparability.reasons.join(" ")]);
|
|
377
|
+
}
|
|
378
|
+
function qualityChecks({ original, ported, text }) {
|
|
379
|
+
const metadata = [
|
|
380
|
+
"title",
|
|
381
|
+
"description",
|
|
382
|
+
"lang"
|
|
383
|
+
].filter((key) => original[key] !== ported[key]);
|
|
384
|
+
const outline = (side) => side.headings.map((heading) => `h${heading.level} ${heading.text}`);
|
|
385
|
+
const originalOutline = outline(original);
|
|
386
|
+
const portedOutline = outline(ported);
|
|
387
|
+
const mismatch = originalOutline.findIndex((heading, index) => heading !== portedOutline[index]);
|
|
388
|
+
const headingsMatch = originalOutline.length === portedOutline.length && mismatch === -1;
|
|
389
|
+
const firstDifference = mismatch === -1 ? originalOutline.length : mismatch;
|
|
390
|
+
const originalViolations = new Map(original.violations.map((violation) => [violation.id, violation.nodes]));
|
|
391
|
+
const regressions = ported.violations.filter((violation) => violation.nodes > (originalViolations.get(violation.id) ?? 0));
|
|
392
|
+
const newProblems = [...newEntries(original, ported, (side) => side.errors, (error) => error), ...groupFailures(newEntries(original, ported, (side) => side.failedRequests, describeRequest))];
|
|
393
|
+
const difference = compareText(original.text, ported.text, {
|
|
394
|
+
context: 4,
|
|
395
|
+
maxWords: 20
|
|
396
|
+
});
|
|
397
|
+
const portedProblems = ported.errors.length + ported.failedRequests.length;
|
|
398
|
+
return [
|
|
399
|
+
check("text", "Visible text", text.identical ? ["pass", `Identical (${text.portedWords} words).`] : ["fail", `Differs: ${text.originalWords} → ${text.portedWords} words. First difference: “${difference.original}” vs “${difference.ported}”.`]),
|
|
400
|
+
check("metadata", "Title, description and lang", metadata.length === 0 ? ["pass", "Identical."] : ["fail", list(metadata.map((key) => `${key}: ${JSON.stringify(original[key])} → ${JSON.stringify(ported[key])}`))]),
|
|
401
|
+
check("headings", "Heading outline", headingsMatch ? ["pass", `Identical (${originalOutline.length} headings).`] : ["fail", `${originalOutline.length} → ${portedOutline.length} headings. First difference: “${originalOutline[firstDifference] ?? "(none)"}” vs “${portedOutline[firstDifference] ?? "(none)"}”.`]),
|
|
402
|
+
check("accessibility", "Accessibility (axe-core)", regressions.length > 0 ? ["fail", `New or more frequent violations: ${list(regressions.map((violation) => `${violation.id} (${violation.nodes})`))}.`] : ported.violations.length > 0 ? ["warn", `No regressions, but ${ported.violations.length} violations remain from the original: ${list(ported.violations.map((violation) => violation.id))}.`] : ["pass", "No violations."]),
|
|
403
|
+
check("errors", "Browser errors and failed requests", newProblems.length > 0 ? ["fail", list(newProblems)] : portedProblems > 0 ? ["warn", "Only errors that the original also has."] : ["pass", "None."]),
|
|
404
|
+
check("nested-controls", "Nested interactive controls", [ported.nestedInteractiveControls > original.nestedInteractiveControls ? "fail" : "pass", `${original.nestedInteractiveControls} → ${ported.nestedInteractiveControls}.`])
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
function visualChecks(visual) {
|
|
408
|
+
return visual.widths.flatMap((result) => {
|
|
409
|
+
const { pixels } = result;
|
|
410
|
+
const height = pixels.heightDelta === 0 ? "matches" : `changes by ${pixels.heightDelta > 0 ? "+" : ""}${pixels.heightDelta}px`;
|
|
411
|
+
return [check(`geometry-${result.width}`, `Layout geometry at ${result.width}px`, result.differences.length > 0 || result.landmarkDifferences.length > 0 ? ["fail", `${result.matchedElements > 0 ? `${result.differences.length} of ${result.matchedElements} matched components and ` : ""}${result.landmarkDifferences.length} page regions differ by more than ${visual.tolerance}px.`] : result.matchedElements === 0 ? ["warn", `No data-slot components matched (${result.originalElements} named in the original, ${result.portedElements} in the port); only page regions were compared.`] : ["pass", `${result.matchedElements} matched components and all page regions are within ${visual.tolerance}px.`]), check(`pixels-${result.width}`, `Screenshot pixels at ${result.width}px`, [pixels.mismatch > PIXEL_WARNING || pixels.heightDelta !== 0 ? "warn" : "pass", `${pixels.mismatch}% of pixels differ; page height ${height}.`])];
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/types.ts
|
|
417
|
+
const SIDES = ["original", "ported"];
|
|
418
|
+
const STAGES = [
|
|
419
|
+
"assets",
|
|
420
|
+
"quality",
|
|
421
|
+
"visual",
|
|
422
|
+
"lighthouse"
|
|
423
|
+
];
|
|
424
|
+
const ASSET_CATEGORIES = [
|
|
425
|
+
"html",
|
|
426
|
+
"css",
|
|
427
|
+
"js",
|
|
428
|
+
"fonts",
|
|
429
|
+
"images",
|
|
430
|
+
"other"
|
|
431
|
+
];
|
|
432
|
+
const LIGHTHOUSE_METRICS = [
|
|
433
|
+
"score",
|
|
434
|
+
"fcp",
|
|
435
|
+
"lcp",
|
|
436
|
+
"tbt",
|
|
437
|
+
"cls",
|
|
438
|
+
"si"
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/sections.ts
|
|
443
|
+
const cellText = (cell) => typeof cell === "string" ? cell : cell.text;
|
|
444
|
+
const textTable = (headings, rows) => ({
|
|
445
|
+
headings,
|
|
446
|
+
align: headings.map(() => "l"),
|
|
447
|
+
rows
|
|
448
|
+
});
|
|
449
|
+
/** A label column followed by right-aligned values. */
|
|
450
|
+
const valueTable = (headings, rows) => ({
|
|
451
|
+
headings,
|
|
452
|
+
align: headings.map((_, index) => index === 0 ? "l" : "r"),
|
|
453
|
+
rows
|
|
454
|
+
});
|
|
455
|
+
const COMPARISON = [
|
|
456
|
+
"Original",
|
|
457
|
+
"Ported",
|
|
458
|
+
"Change"
|
|
459
|
+
];
|
|
460
|
+
function comparisonRow(label, original, ported, format, higherIsBetter = false) {
|
|
461
|
+
const delta = original === null || ported === null ? 0 : ported - original;
|
|
462
|
+
const change = formatDelta(original, ported, format);
|
|
463
|
+
return [
|
|
464
|
+
label,
|
|
465
|
+
original === null ? "–" : format(original),
|
|
466
|
+
ported === null ? "–" : format(ported),
|
|
467
|
+
delta === 0 ? change : {
|
|
468
|
+
text: change,
|
|
469
|
+
tone: delta > 0 === higherIsBetter ? "better" : "worse"
|
|
470
|
+
}
|
|
471
|
+
];
|
|
472
|
+
}
|
|
473
|
+
function budgetItems(report) {
|
|
474
|
+
return report.budgets.map(({ budget, status, detail }) => ({
|
|
475
|
+
status,
|
|
476
|
+
label: budget,
|
|
477
|
+
detail
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
/** Stages that failed or were skipped. */
|
|
481
|
+
function stageItems(report) {
|
|
482
|
+
return [...report.errors.map(({ stage, message }) => ({
|
|
483
|
+
status: "fail",
|
|
484
|
+
label: stage,
|
|
485
|
+
detail: message
|
|
486
|
+
})), ...report.skipped.map(({ stage, reason }) => ({
|
|
487
|
+
status: "skip",
|
|
488
|
+
label: stage,
|
|
489
|
+
detail: reason
|
|
490
|
+
}))];
|
|
491
|
+
}
|
|
492
|
+
function lighthouseSection(lighthouse, comparability) {
|
|
493
|
+
return {
|
|
494
|
+
title: `Lighthouse ${lighthouse.formFactor}`,
|
|
495
|
+
notes: [`median of ${lighthouse.runs} run${lighthouse.runs === 1 ? "" : "s"}`, ...comparability.comparable ? [] : ["timings not comparable"]],
|
|
496
|
+
table: valueTable(["Metric", ...COMPARISON], LIGHTHOUSE_METRICS.map((metric) => {
|
|
497
|
+
const { label, unit, higherIsBetter } = LIGHTHOUSE[metric];
|
|
498
|
+
return comparisonRow(label, lighthouse.original.median[metric], lighthouse.ported.median[metric], (value) => formatValue(unit, value), higherIsBetter);
|
|
499
|
+
}))
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const ASSET_ROWS = [
|
|
503
|
+
["js", "JavaScript (gzip)"],
|
|
504
|
+
["css", "CSS (gzip)"],
|
|
505
|
+
["html", "HTML (gzip)"],
|
|
506
|
+
["fonts", "Fonts"],
|
|
507
|
+
["images", "Images"],
|
|
508
|
+
["other", "Other"]
|
|
509
|
+
];
|
|
510
|
+
function assetsSection({ original, ported }) {
|
|
511
|
+
const { viewport } = original;
|
|
512
|
+
return {
|
|
513
|
+
title: "Route assets",
|
|
514
|
+
notes: [`cold load at ${viewport.width}×${viewport.height}`],
|
|
515
|
+
table: valueTable(["Asset", ...COMPARISON], [
|
|
516
|
+
...ASSET_ROWS.map(([category, label]) => {
|
|
517
|
+
const a = original.categories[category];
|
|
518
|
+
const b = ported.categories[category];
|
|
519
|
+
return comparisonRow(`${label}, ${a.count} → ${b.count} files`, a.size, b.size, formatBytes);
|
|
520
|
+
}),
|
|
521
|
+
comparisonRow("HTML + CSS + JS + fonts", original.coreSize, ported.coreSize, formatBytes),
|
|
522
|
+
comparisonRow("All requests (transferred)", original.transfer, ported.transfer, formatBytes),
|
|
523
|
+
comparisonRow("Requests", original.requests, ported.requests, String)
|
|
524
|
+
])
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function visualSection(visual) {
|
|
528
|
+
return {
|
|
529
|
+
title: "Visual parity",
|
|
530
|
+
notes: [`within ${visual.tolerance}px`],
|
|
531
|
+
table: valueTable([
|
|
532
|
+
"Width",
|
|
533
|
+
"Matched components",
|
|
534
|
+
"Component diffs",
|
|
535
|
+
"Region diffs",
|
|
536
|
+
"Pixels differing",
|
|
537
|
+
"Height change"
|
|
538
|
+
], visual.widths.map((result) => [
|
|
539
|
+
`${result.width}px`,
|
|
540
|
+
`${result.matchedElements} of ${result.originalElements}`,
|
|
541
|
+
String(result.differences.length),
|
|
542
|
+
String(result.landmarkDifferences.length),
|
|
543
|
+
`${result.pixels.mismatch}%`,
|
|
544
|
+
`${result.pixels.heightDelta > 0 ? "+" : ""}${result.pixels.heightDelta}px`
|
|
545
|
+
]))
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function qualitySection(quality) {
|
|
549
|
+
const { original, ported, viewport } = quality;
|
|
550
|
+
const row = (label, read) => [
|
|
551
|
+
label,
|
|
552
|
+
read(original),
|
|
553
|
+
read(ported)
|
|
554
|
+
];
|
|
555
|
+
return {
|
|
556
|
+
title: "Page quality",
|
|
557
|
+
notes: [`${viewport.width}×${viewport.height}`],
|
|
558
|
+
table: valueTable([
|
|
559
|
+
"Measure",
|
|
560
|
+
"Original",
|
|
561
|
+
"Ported"
|
|
562
|
+
], [
|
|
563
|
+
row("DOM elements", (side) => String(side.domElements)),
|
|
564
|
+
row("Serialized DOM", (side) => formatBytes(side.domBytes)),
|
|
565
|
+
row("axe-core violations", (side) => String(side.violations.length)),
|
|
566
|
+
row("Browser errors", (side) => String(side.errors.length)),
|
|
567
|
+
row("Failed requests", (side) => String(side.failedRequests.length))
|
|
568
|
+
])
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
/** Matched components whose size differs, the first `limit` of them. */
|
|
572
|
+
function componentDifferences(result, { limit, textLength }) {
|
|
573
|
+
return textTable([
|
|
574
|
+
"Component",
|
|
575
|
+
"Text",
|
|
576
|
+
"Original",
|
|
577
|
+
"Ported"
|
|
578
|
+
], result.differences.slice(0, limit).map((difference) => [
|
|
579
|
+
difference.slot,
|
|
580
|
+
truncate(difference.text, textLength),
|
|
581
|
+
difference.original.join(" × "),
|
|
582
|
+
difference.ported.join(" × ")
|
|
583
|
+
]));
|
|
584
|
+
}
|
|
585
|
+
const slotCounts = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([slot, count]) => `${slot} (${count})`).join(", ") || "none";
|
|
586
|
+
/** Slots that only one side has, which explain low match counts. */
|
|
587
|
+
function unmatchedSlots(result) {
|
|
588
|
+
const { unmatchedOriginal, unmatchedPorted } = result;
|
|
589
|
+
if (Object.keys(unmatchedOriginal).length + Object.keys(unmatchedPorted).length === 0) return [];
|
|
590
|
+
return [`Only in original: ${slotCounts(unmatchedOriginal)}`, `Only in ported: ${slotCounts(unmatchedPorted)}`];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/report.ts
|
|
595
|
+
const STATUS_LABEL = {
|
|
596
|
+
pass: "✓ pass",
|
|
597
|
+
fail: "✗ fail",
|
|
598
|
+
warn: "! warn",
|
|
599
|
+
skip: "– skip"
|
|
600
|
+
};
|
|
601
|
+
const MAX_DIFFERENCES = 20;
|
|
602
|
+
function escapeCell(value) {
|
|
603
|
+
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
604
|
+
}
|
|
605
|
+
function markdownTable({ headings, align, rows }) {
|
|
606
|
+
const cells = [headings, ...rows].map((row) => row.map((cell) => escapeCell(cellText(cell))));
|
|
607
|
+
const widths = headings.map((_, index) => Math.max(3, ...cells.map((row) => row[index].length)));
|
|
608
|
+
const pad = (value, index) => align[index] === "r" ? value.padStart(widths[index]) : value.padEnd(widths[index]);
|
|
609
|
+
const line = (row) => `| ${row.map(pad).join(" | ")} |`;
|
|
610
|
+
const divider = `| ${widths.map((width, index) => align[index] === "r" ? `${"-".repeat(width - 1)}:` : "-".repeat(width)).join(" | ")} |`;
|
|
611
|
+
return [
|
|
612
|
+
line(cells[0]),
|
|
613
|
+
divider,
|
|
614
|
+
...cells.slice(1).map(line)
|
|
615
|
+
].join("\n");
|
|
616
|
+
}
|
|
617
|
+
function statusTable(heading, items) {
|
|
618
|
+
return markdownTable(textTable([
|
|
619
|
+
heading,
|
|
620
|
+
"Status",
|
|
621
|
+
"Detail"
|
|
622
|
+
], items.map((item) => [
|
|
623
|
+
item.label,
|
|
624
|
+
STATUS_LABEL[item.status],
|
|
625
|
+
item.detail
|
|
626
|
+
])));
|
|
627
|
+
}
|
|
628
|
+
const box = (value) => value ? `${value.width} × ${value.height} at ${value.x}, ${value.y}` : "missing";
|
|
629
|
+
function renderMarkdown(report, outDir) {
|
|
630
|
+
const link = (label, file) => file ? `[${label}](${relative(outDir, file).split("\\").join("/")})` : label;
|
|
631
|
+
const lines = [];
|
|
632
|
+
const push = (...values) => lines.push(...values.flatMap((value) => [value, ""]));
|
|
633
|
+
push("# UI bench report", [
|
|
634
|
+
`- **Original:** ${report.targets.original.finalUrl}`,
|
|
635
|
+
`- **Ported:** ${report.targets.ported.finalUrl}`,
|
|
636
|
+
`- **Measured:** ${report.measuredAt} with ${report.environment.browser ?? "Chrome"}, Node ${report.environment.node}`
|
|
637
|
+
].join("\n"));
|
|
638
|
+
push(`## Result: ${report.passed ? "PASS" : "FAIL"}`, statusTable("Check", report.checks));
|
|
639
|
+
const budgets = budgetItems(report);
|
|
640
|
+
if (budgets.length > 0) push("### Budgets", statusTable("Budget", budgets));
|
|
641
|
+
const stages = stageItems(report);
|
|
642
|
+
if (stages.length > 0) push("### Stages", statusTable("Stage", stages));
|
|
643
|
+
const { lighthouse, assets, visual, quality, comparability } = report;
|
|
644
|
+
if (lighthouse) {
|
|
645
|
+
const section = lighthouseSection(lighthouse, comparability);
|
|
646
|
+
push(`## ${section.title}`, `Median of ${lighthouse.runs} Lighthouse ${lighthouse.version} runs per URL with simulated throttling, run in ${lighthouse.order} order. These are lab results, not field data.${comparability.comparable ? "" : " **Timings are not comparable:** " + comparability.reasons.join(" ")}`, markdownTable(section.table));
|
|
647
|
+
}
|
|
648
|
+
if (assets) {
|
|
649
|
+
const section = assetsSection(assets);
|
|
650
|
+
const { viewport } = assets.original;
|
|
651
|
+
push(`## ${section.title}`, `Responses of a cold load at ${viewport.width}×${viewport.height}, until the network is idle. HTML, CSS and JavaScript are recompressed with gzip level 9 so servers with different compression compare equally; other assets use their transferred size.`, markdownTable(section.table));
|
|
652
|
+
}
|
|
653
|
+
if (visual) {
|
|
654
|
+
const section = visualSection(visual);
|
|
655
|
+
push(`## ${section.title}`, `Visible \`data-slot\` components are matched by slot name, text and order, and their sizes compared within ${visual.tolerance}px. Top-level header, main, section and footer regions are compared by position and size. Screenshot pixels are compared over the area both pages cover.`, markdownTable(section.table));
|
|
656
|
+
for (const result of visual.widths) {
|
|
657
|
+
const screenshot = (side) => visual.captures.find((item) => item.side === side && item.width === result.width)?.screenshot ?? null;
|
|
658
|
+
push(`### ${result.width}px`, `Screenshots: ${link("original", screenshot("original"))}, ${link("ported", screenshot("ported"))}, ${link("diff", result.pixels.diff)}`);
|
|
659
|
+
if (result.differences.length > 0) {
|
|
660
|
+
const more = result.differences.length - MAX_DIFFERENCES;
|
|
661
|
+
push(markdownTable(componentDifferences(result, {
|
|
662
|
+
limit: MAX_DIFFERENCES,
|
|
663
|
+
textLength: 40
|
|
664
|
+
})) + (more > 0 ? `\n\n…and ${more} more in report.json.` : ""));
|
|
665
|
+
}
|
|
666
|
+
if (result.landmarkDifferences.length > 0) push(markdownTable(textTable([
|
|
667
|
+
"Region",
|
|
668
|
+
"Original",
|
|
669
|
+
"Ported"
|
|
670
|
+
], result.landmarkDifferences.slice(0, MAX_DIFFERENCES).map((difference) => [
|
|
671
|
+
`${difference.index + 1}. ${difference.tag}`,
|
|
672
|
+
box(difference.original),
|
|
673
|
+
box(difference.ported)
|
|
674
|
+
]))));
|
|
675
|
+
const unmatched = unmatchedSlots(result);
|
|
676
|
+
if (unmatched.length > 0) push(unmatched.map((line) => `- ${line}`).join("\n"));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (quality) {
|
|
680
|
+
const section = qualitySection(quality);
|
|
681
|
+
push(`## ${section.title}`, `Measured at ${section.notes.join(", ")}.`, markdownTable(section.table));
|
|
682
|
+
const { text } = quality;
|
|
683
|
+
if (!text.identical) push("### First text difference", `- **Original:** ${text.original}\n- **Ported:** ${text.ported}`);
|
|
684
|
+
for (const label of ["original", "ported"]) {
|
|
685
|
+
const side = quality[label];
|
|
686
|
+
if (side.violations.length > 0) push(`### axe-core violations (${label})`, side.violations.map((violation) => `- \`${violation.id}\` (${violation.impact ?? "unknown"}, ${violation.nodes} nodes): ${violation.help}`).join("\n"));
|
|
687
|
+
const problems = [...side.errors, ...side.failedRequests.map(({ url, reason }) => `${url} (${reason})`)];
|
|
688
|
+
if (problems.length > 0) push(`### Browser errors (${label})`, problems.map((problem) => `- ${truncate(problem, 300)}`).join("\n"));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return `${lines.join("\n").trim()}\n`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
//#endregion
|
|
695
|
+
//#region src/sides.ts
|
|
696
|
+
/**
|
|
697
|
+
* Measure the original, then the ported URL. The sides run one after the
|
|
698
|
+
* other so they never compete for CPU or network while being measured.
|
|
699
|
+
*/
|
|
700
|
+
async function bothSides(measure) {
|
|
701
|
+
return {
|
|
702
|
+
original: await measure("original"),
|
|
703
|
+
ported: await measure("ported")
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region src/stages/assets.ts
|
|
709
|
+
const ASSETS_VIEWPORT = {
|
|
710
|
+
width: 412,
|
|
711
|
+
height: 823
|
|
712
|
+
};
|
|
713
|
+
const COMPRESSIBLE = /* @__PURE__ */ new Set([
|
|
714
|
+
"html",
|
|
715
|
+
"css",
|
|
716
|
+
"js"
|
|
717
|
+
]);
|
|
718
|
+
function categorize(request, isMainFrame) {
|
|
719
|
+
switch (request.resourceType()) {
|
|
720
|
+
case "document": return isMainFrame && request.isNavigationRequest() ? "html" : "other";
|
|
721
|
+
case "stylesheet": return "css";
|
|
722
|
+
case "script": return "js";
|
|
723
|
+
case "font": return "fonts";
|
|
724
|
+
case "image":
|
|
725
|
+
case "media": return "images";
|
|
726
|
+
default: return "other";
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const sum = (entries, read) => entries.reduce((total, entry) => total + read(entry), 0);
|
|
730
|
+
function summarizeAssets(entries) {
|
|
731
|
+
const categories = Object.fromEntries(ASSET_CATEGORIES.map((category) => {
|
|
732
|
+
const files = entries.filter((entry) => entry.category === category);
|
|
733
|
+
return [category, {
|
|
734
|
+
count: files.length,
|
|
735
|
+
transfer: sum(files, (entry) => entry.transfer),
|
|
736
|
+
raw: sum(files, (entry) => entry.raw),
|
|
737
|
+
size: COMPRESSIBLE.has(category) ? sum(files, (entry) => entry.gzip ?? 0) : sum(files, (entry) => entry.transfer)
|
|
738
|
+
}];
|
|
739
|
+
}));
|
|
740
|
+
return {
|
|
741
|
+
categories,
|
|
742
|
+
coreSize: [
|
|
743
|
+
"html",
|
|
744
|
+
"css",
|
|
745
|
+
"js",
|
|
746
|
+
"fonts"
|
|
747
|
+
].reduce((total, category) => total + categories[category].size, 0),
|
|
748
|
+
requests: entries.length,
|
|
749
|
+
transfer: sum(entries, (entry) => entry.transfer)
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Record every response of a cold page load. Compression is recomputed from
|
|
754
|
+
* the decoded bodies so local and remote servers are measured the same way.
|
|
755
|
+
*/
|
|
756
|
+
function measureAssets(browser, urls, timeout) {
|
|
757
|
+
return bothSides((side) => measureSide$1(browser, urls[side], timeout));
|
|
758
|
+
}
|
|
759
|
+
async function measureSide$1(browser, url, timeout) {
|
|
760
|
+
const context = await browser.newContext({
|
|
761
|
+
viewport: ASSETS_VIEWPORT,
|
|
762
|
+
serviceWorkers: "block"
|
|
763
|
+
});
|
|
764
|
+
try {
|
|
765
|
+
const page = await context.newPage();
|
|
766
|
+
const pending = [];
|
|
767
|
+
page.on("requestfinished", (request) => {
|
|
768
|
+
pending.push((async () => {
|
|
769
|
+
const response = await request.response();
|
|
770
|
+
if (!response || response.status() >= 300 || response.status() === 204) return null;
|
|
771
|
+
const category = categorize(request, request.frame() === page.mainFrame());
|
|
772
|
+
const [sizes, body] = await Promise.all([request.sizes(), response.body().catch(() => null)]);
|
|
773
|
+
return {
|
|
774
|
+
url: request.url(),
|
|
775
|
+
category,
|
|
776
|
+
transfer: sizes.responseBodySize,
|
|
777
|
+
raw: body?.byteLength ?? 0,
|
|
778
|
+
gzip: body && COMPRESSIBLE.has(category) ? gzipSync(body, { level: 9 }).byteLength : null
|
|
779
|
+
};
|
|
780
|
+
})().catch(() => null));
|
|
781
|
+
});
|
|
782
|
+
await page.goto(url, {
|
|
783
|
+
waitUntil: "networkidle",
|
|
784
|
+
timeout
|
|
785
|
+
});
|
|
786
|
+
const finalUrl = page.url();
|
|
787
|
+
const origin = new URL(finalUrl).origin;
|
|
788
|
+
const entries = (await Promise.all(pending)).filter((entry) => entry !== null).filter((entry) => !entry.url.startsWith("data:")).map((entry) => ({
|
|
789
|
+
...entry,
|
|
790
|
+
thirdParty: new URL(entry.url).origin !== origin
|
|
791
|
+
})).sort((a, b) => a.category.localeCompare(b.category) || a.url.localeCompare(b.url));
|
|
792
|
+
return {
|
|
793
|
+
finalUrl,
|
|
794
|
+
viewport: ASSETS_VIEWPORT,
|
|
795
|
+
...summarizeAssets(entries),
|
|
796
|
+
entries
|
|
797
|
+
};
|
|
798
|
+
} finally {
|
|
799
|
+
await context.close();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/stages/lighthouse.ts
|
|
805
|
+
/** The score comes from the performance category, the rest from audits. */
|
|
806
|
+
const AUDITS = {
|
|
807
|
+
fcp: "first-contentful-paint",
|
|
808
|
+
lcp: "largest-contentful-paint",
|
|
809
|
+
tbt: "total-blocking-time",
|
|
810
|
+
cls: "cumulative-layout-shift",
|
|
811
|
+
si: "speed-index"
|
|
812
|
+
};
|
|
813
|
+
/** Run order for one round. Alternating cancels out drift such as thermal throttling. */
|
|
814
|
+
function roundOrder(run) {
|
|
815
|
+
return run % 2 === 1 ? [...SIDES] : [...SIDES].reverse();
|
|
816
|
+
}
|
|
817
|
+
const roundMetric = (metric, value) => typeof value === "number" ? round(value, LIGHTHOUSE[metric].digits) : null;
|
|
818
|
+
async function runLighthouse({ urls, runs, formFactor, chromePath, artifacts, onProgress }) {
|
|
819
|
+
const { default: lighthouse, desktopConfig } = await import("lighthouse");
|
|
820
|
+
const { launch } = await import("chrome-launcher");
|
|
821
|
+
const version = createRequire(import.meta.url)("lighthouse/package.json").version;
|
|
822
|
+
const chrome = await launch({
|
|
823
|
+
chromePath,
|
|
824
|
+
chromeFlags: ["--headless=new", "--disable-dev-shm-usage"]
|
|
825
|
+
});
|
|
826
|
+
const results = {
|
|
827
|
+
original: [],
|
|
828
|
+
ported: []
|
|
829
|
+
};
|
|
830
|
+
try {
|
|
831
|
+
for (let run = 1; run <= runs; run++) for (const side of roundOrder(run)) {
|
|
832
|
+
onProgress(`Lighthouse ${formFactor} run ${run}/${runs}: ${side}`);
|
|
833
|
+
const result = await lighthouse(urls[side], {
|
|
834
|
+
port: chrome.port,
|
|
835
|
+
output: "json",
|
|
836
|
+
logLevel: "error",
|
|
837
|
+
onlyCategories: ["performance"],
|
|
838
|
+
throttlingMethod: "simulate",
|
|
839
|
+
maxWaitForLoad: 45e3
|
|
840
|
+
}, formFactor === "desktop" ? desktopConfig : void 0);
|
|
841
|
+
if (!result) throw new Error(`Lighthouse returned no result for the ${side} URL`);
|
|
842
|
+
const { lhr } = result;
|
|
843
|
+
if (lhr.runtimeError) throw new Error(`Lighthouse failed for the ${side} URL: ${lhr.runtimeError.message}`);
|
|
844
|
+
const score = lhr.categories.performance?.score;
|
|
845
|
+
const audit = (metric) => roundMetric(metric, lhr.audits[AUDITS[metric]]?.numericValue);
|
|
846
|
+
results[side].push({
|
|
847
|
+
run,
|
|
848
|
+
report: artifacts?.write(`lighthouse/${side}-run-${run}.json`, typeof result.report === "string" ? result.report : JSON.stringify(lhr)) ?? null,
|
|
849
|
+
score: roundMetric("score", typeof score === "number" ? score * 100 : null),
|
|
850
|
+
fcp: audit("fcp"),
|
|
851
|
+
lcp: audit("lcp"),
|
|
852
|
+
tbt: audit("tbt"),
|
|
853
|
+
cls: audit("cls"),
|
|
854
|
+
si: audit("si")
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
} finally {
|
|
858
|
+
chrome.kill();
|
|
859
|
+
}
|
|
860
|
+
const summarize = (side) => ({
|
|
861
|
+
runs: results[side],
|
|
862
|
+
median: Object.fromEntries(LIGHTHOUSE_METRICS.map((metric) => [metric, roundMetric(metric, median(results[side].map((run) => run[metric])))]))
|
|
863
|
+
});
|
|
864
|
+
return {
|
|
865
|
+
formFactor,
|
|
866
|
+
version,
|
|
867
|
+
runs,
|
|
868
|
+
order: "alternating (original, ported), then (ported, original)",
|
|
869
|
+
original: summarize("original"),
|
|
870
|
+
ported: summarize("ported")
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/stages/problems.ts
|
|
876
|
+
const CORS_BLOCK = /^Access to \w+ at '([^']+)' from origin '[^']+' has been blocked by CORS policy/;
|
|
877
|
+
/**
|
|
878
|
+
* Collects browser errors and broken resources so each problem is reported
|
|
879
|
+
* once: one entry per distinct error message and one per failed URL, with the
|
|
880
|
+
* most specific reason any event gave for it.
|
|
881
|
+
*/
|
|
882
|
+
function createProblemLog() {
|
|
883
|
+
const errors = /* @__PURE__ */ new Set();
|
|
884
|
+
const failures = /* @__PURE__ */ new Map();
|
|
885
|
+
return {
|
|
886
|
+
consoleError(text) {
|
|
887
|
+
if (text.startsWith("Failed to load resource")) return;
|
|
888
|
+
const cors = CORS_BLOCK.exec(text);
|
|
889
|
+
if (cors) failures.set(cors[1], "blocked by CORS");
|
|
890
|
+
else errors.add(text);
|
|
891
|
+
},
|
|
892
|
+
pageError(message) {
|
|
893
|
+
errors.add(message);
|
|
894
|
+
},
|
|
895
|
+
response(url, status) {
|
|
896
|
+
if (status >= 400) failures.set(url, `HTTP ${status}`);
|
|
897
|
+
},
|
|
898
|
+
requestFailed(url, reason) {
|
|
899
|
+
if (reason === "net::ERR_ABORTED" || failures.has(url)) return;
|
|
900
|
+
failures.set(url, reason);
|
|
901
|
+
},
|
|
902
|
+
result() {
|
|
903
|
+
return {
|
|
904
|
+
errors: [...errors],
|
|
905
|
+
failedRequests: [...failures].map(([url, reason]) => ({
|
|
906
|
+
url,
|
|
907
|
+
reason
|
|
908
|
+
}))
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/stages/quality.ts
|
|
916
|
+
const QUALITY_VIEWPORT = {
|
|
917
|
+
width: 1280,
|
|
918
|
+
height: 800
|
|
919
|
+
};
|
|
920
|
+
const require = createRequire(import.meta.url);
|
|
921
|
+
let axeSource;
|
|
922
|
+
function readAxeSource() {
|
|
923
|
+
axeSource ??= readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
|
|
924
|
+
return axeSource;
|
|
925
|
+
}
|
|
926
|
+
async function measureQuality(browser, urls, timeout) {
|
|
927
|
+
const sides = await bothSides((side) => measureSide(browser, urls[side], timeout));
|
|
928
|
+
return {
|
|
929
|
+
viewport: QUALITY_VIEWPORT,
|
|
930
|
+
...sides,
|
|
931
|
+
text: compareText(sides.original.text, sides.ported.text)
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
async function measureSide(browser, url, timeout) {
|
|
935
|
+
const context = await browser.newContext({
|
|
936
|
+
viewport: QUALITY_VIEWPORT,
|
|
937
|
+
locale: "en-US",
|
|
938
|
+
bypassCSP: true,
|
|
939
|
+
serviceWorkers: "block"
|
|
940
|
+
});
|
|
941
|
+
try {
|
|
942
|
+
const page = await context.newPage();
|
|
943
|
+
const problems = createProblemLog();
|
|
944
|
+
page.on("console", (message) => {
|
|
945
|
+
if (message.type() === "error") problems.consoleError(message.text());
|
|
946
|
+
});
|
|
947
|
+
page.on("pageerror", (error) => problems.pageError(error.message));
|
|
948
|
+
page.on("requestfailed", (request) => problems.requestFailed(request.url(), request.failure()?.errorText ?? "failed"));
|
|
949
|
+
page.on("response", (response) => problems.response(response.url(), response.status()));
|
|
950
|
+
await loadPage(page, url, timeout);
|
|
951
|
+
const data = await page.evaluate(() => {
|
|
952
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
953
|
+
return {
|
|
954
|
+
title: document.title,
|
|
955
|
+
description: document.querySelector("meta[name=\"description\"]")?.getAttribute("content") ?? null,
|
|
956
|
+
lang: document.documentElement.getAttribute("lang"),
|
|
957
|
+
text: normalize(document.body.innerText),
|
|
958
|
+
headings: [...document.querySelectorAll("h1, h2, h3, h4, h5, h6")].filter((element) => element.checkVisibility()).map((element) => ({
|
|
959
|
+
level: Number(element.tagName[1]),
|
|
960
|
+
text: normalize(element.textContent ?? "")
|
|
961
|
+
})),
|
|
962
|
+
domElements: document.querySelectorAll("*").length,
|
|
963
|
+
domBytes: new TextEncoder().encode(document.documentElement.outerHTML).byteLength,
|
|
964
|
+
nestedInteractiveControls: document.querySelectorAll("button a, a button, button button, a a, button input, a input, button select, a select").length
|
|
965
|
+
};
|
|
966
|
+
});
|
|
967
|
+
await page.addScriptTag({ content: readAxeSource() });
|
|
968
|
+
const violations = await page.evaluate(async () => {
|
|
969
|
+
return (await globalThis.axe.run(document)).violations.map((violation) => ({
|
|
970
|
+
id: violation.id,
|
|
971
|
+
impact: violation.impact ?? null,
|
|
972
|
+
help: violation.help,
|
|
973
|
+
nodes: violation.nodes.length
|
|
974
|
+
}));
|
|
975
|
+
});
|
|
976
|
+
return {
|
|
977
|
+
finalUrl: page.url(),
|
|
978
|
+
...data,
|
|
979
|
+
violations: violations.sort((a, b) => a.id.localeCompare(b.id)),
|
|
980
|
+
...problems.result()
|
|
981
|
+
};
|
|
982
|
+
} finally {
|
|
983
|
+
await context.close();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
//#endregion
|
|
988
|
+
//#region src/stages/visual.ts
|
|
989
|
+
const VISUAL_HEIGHT = 823;
|
|
990
|
+
async function measureVisual(browser, { urls, widths, tolerance, timeout, artifacts, onProgress }) {
|
|
991
|
+
const captures = [];
|
|
992
|
+
const results = [];
|
|
993
|
+
for (const width of widths) {
|
|
994
|
+
onProgress(`Capturing layout at ${width}px`);
|
|
995
|
+
const { original, ported } = await bothSides((side) => capture(browser, {
|
|
996
|
+
side,
|
|
997
|
+
url: urls[side],
|
|
998
|
+
width,
|
|
999
|
+
timeout,
|
|
1000
|
+
artifacts
|
|
1001
|
+
}));
|
|
1002
|
+
const { comparison, diff } = comparePixels(original.image, ported.image);
|
|
1003
|
+
captures.push(original.capture, ported.capture);
|
|
1004
|
+
results.push({
|
|
1005
|
+
width,
|
|
1006
|
+
...compareGeometry(original.capture, ported.capture, tolerance),
|
|
1007
|
+
pixels: {
|
|
1008
|
+
...comparison,
|
|
1009
|
+
diff: artifacts?.write(`screenshots/diff-${width}.png`, PNG.sync.write(diff)) ?? null
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
tolerance,
|
|
1015
|
+
captures,
|
|
1016
|
+
widths: results
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
async function capture(browser, { side, url, width, timeout, artifacts }) {
|
|
1020
|
+
const context = await browser.newContext({
|
|
1021
|
+
viewport: {
|
|
1022
|
+
width,
|
|
1023
|
+
height: VISUAL_HEIGHT
|
|
1024
|
+
},
|
|
1025
|
+
colorScheme: "light",
|
|
1026
|
+
reducedMotion: "reduce",
|
|
1027
|
+
serviceWorkers: "block"
|
|
1028
|
+
});
|
|
1029
|
+
try {
|
|
1030
|
+
const page = await context.newPage();
|
|
1031
|
+
await loadPage(page, url, timeout);
|
|
1032
|
+
const image = await page.screenshot({
|
|
1033
|
+
fullPage: true,
|
|
1034
|
+
animations: "disabled"
|
|
1035
|
+
});
|
|
1036
|
+
const data = await page.evaluate(() => {
|
|
1037
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
1038
|
+
const box = (element) => {
|
|
1039
|
+
const rect = element.getBoundingClientRect();
|
|
1040
|
+
return {
|
|
1041
|
+
x: round(rect.x + window.scrollX),
|
|
1042
|
+
y: round(rect.y + window.scrollY),
|
|
1043
|
+
width: round(rect.width),
|
|
1044
|
+
height: round(rect.height)
|
|
1045
|
+
};
|
|
1046
|
+
};
|
|
1047
|
+
const elements = [...document.querySelectorAll("[data-slot]")].filter((element) => element.checkVisibility()).map((element) => {
|
|
1048
|
+
const { width, height } = box(element);
|
|
1049
|
+
return {
|
|
1050
|
+
slot: element.getAttribute("data-slot") ?? "",
|
|
1051
|
+
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
|
|
1052
|
+
width,
|
|
1053
|
+
height
|
|
1054
|
+
};
|
|
1055
|
+
});
|
|
1056
|
+
const regions = "header, footer, main, section, article, aside, nav";
|
|
1057
|
+
return {
|
|
1058
|
+
elements,
|
|
1059
|
+
landmarks: [...document.querySelectorAll("header, footer, main, section")].filter((element) => element.checkVisibility()).filter((element) => {
|
|
1060
|
+
const parent = element.parentElement?.closest(regions);
|
|
1061
|
+
return !parent || parent.tagName === "MAIN";
|
|
1062
|
+
}).map((element) => ({
|
|
1063
|
+
tag: element.tagName.toLowerCase(),
|
|
1064
|
+
...box(element)
|
|
1065
|
+
})),
|
|
1066
|
+
pageHeight: document.documentElement.scrollHeight
|
|
1067
|
+
};
|
|
1068
|
+
});
|
|
1069
|
+
return {
|
|
1070
|
+
capture: {
|
|
1071
|
+
side,
|
|
1072
|
+
width,
|
|
1073
|
+
screenshot: artifacts?.write(`screenshots/${side}-${width}.png`, image) ?? null,
|
|
1074
|
+
...data
|
|
1075
|
+
},
|
|
1076
|
+
image
|
|
1077
|
+
};
|
|
1078
|
+
} finally {
|
|
1079
|
+
await context.close();
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Part names that don't identify a component (PrimeReact's root, outer and
|
|
1084
|
+
* inner wrappers). Matching on them pairs unrelated elements, so they are left
|
|
1085
|
+
* out of the comparison and the unmatched lists.
|
|
1086
|
+
*/
|
|
1087
|
+
const GENERIC_SLOTS = /* @__PURE__ */ new Set([
|
|
1088
|
+
"root",
|
|
1089
|
+
"outer",
|
|
1090
|
+
"inner"
|
|
1091
|
+
]);
|
|
1092
|
+
const named = (elements) => elements.filter((element) => !GENERIC_SLOTS.has(element.slot));
|
|
1093
|
+
const slotKey = (element) => `${element.slot}:${element.text.replace(/\s/g, "")}`;
|
|
1094
|
+
function countBySlot(elements) {
|
|
1095
|
+
const counts = {};
|
|
1096
|
+
for (const element of elements) counts[element.slot] = (counts[element.slot] ?? 0) + 1;
|
|
1097
|
+
return counts;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Match named `data-slot` elements by slot name, normalized text and
|
|
1101
|
+
* occurrence order, then compare their sizes. Framework wrappers without a
|
|
1102
|
+
* matching element on the other side are reported but not treated as
|
|
1103
|
+
* differences.
|
|
1104
|
+
*/
|
|
1105
|
+
function compareGeometry(original, ported, tolerance) {
|
|
1106
|
+
const originalElements = named(original.elements);
|
|
1107
|
+
const portedElements = named(ported.elements);
|
|
1108
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1109
|
+
for (const element of portedElements) {
|
|
1110
|
+
const key = slotKey(element);
|
|
1111
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
1112
|
+
buckets.get(key).push(element);
|
|
1113
|
+
}
|
|
1114
|
+
const differences = [];
|
|
1115
|
+
const unmatchedOriginal = [];
|
|
1116
|
+
let matchedElements = 0;
|
|
1117
|
+
for (const element of originalElements) {
|
|
1118
|
+
const other = buckets.get(slotKey(element))?.shift();
|
|
1119
|
+
if (!other) {
|
|
1120
|
+
unmatchedOriginal.push(element);
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
matchedElements++;
|
|
1124
|
+
if (Math.abs(element.width - other.width) > tolerance || Math.abs(element.height - other.height) > tolerance) differences.push({
|
|
1125
|
+
slot: element.slot,
|
|
1126
|
+
text: element.text,
|
|
1127
|
+
original: [element.width, element.height],
|
|
1128
|
+
ported: [other.width, other.height]
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
const landmarkDifferences = [];
|
|
1132
|
+
const count = Math.max(original.landmarks.length, ported.landmarks.length);
|
|
1133
|
+
for (let index = 0; index < count; index++) {
|
|
1134
|
+
const a = original.landmarks[index];
|
|
1135
|
+
const b = ported.landmarks[index];
|
|
1136
|
+
if (!a || !b || a.tag !== b.tag || [
|
|
1137
|
+
"x",
|
|
1138
|
+
"y",
|
|
1139
|
+
"width",
|
|
1140
|
+
"height"
|
|
1141
|
+
].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
|
|
1142
|
+
const strip = (landmark) => landmark ? {
|
|
1143
|
+
x: landmark.x,
|
|
1144
|
+
y: landmark.y,
|
|
1145
|
+
width: landmark.width,
|
|
1146
|
+
height: landmark.height
|
|
1147
|
+
} : null;
|
|
1148
|
+
landmarkDifferences.push({
|
|
1149
|
+
index,
|
|
1150
|
+
tag: (a ?? b).tag,
|
|
1151
|
+
original: strip(a),
|
|
1152
|
+
ported: strip(b)
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
originalElements: originalElements.length,
|
|
1158
|
+
portedElements: portedElements.length,
|
|
1159
|
+
matchedElements,
|
|
1160
|
+
differences,
|
|
1161
|
+
unmatchedOriginal: countBySlot(unmatchedOriginal),
|
|
1162
|
+
unmatchedPorted: countBySlot([...buckets.values()].flat()),
|
|
1163
|
+
landmarkDifferences
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
function crop(png, width, height) {
|
|
1167
|
+
if (png.width === width && png.height === height) return png.data;
|
|
1168
|
+
const data = Buffer.alloc(width * height * 4);
|
|
1169
|
+
for (let y = 0; y < height; y++) png.data.copy(data, y * width * 4, y * png.width * 4, (y * png.width + width) * 4);
|
|
1170
|
+
return data;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Pixel-diff the overlapping area of two full-page screenshots. The diff image
|
|
1174
|
+
* is returned undecoded so it is only encoded when it gets written.
|
|
1175
|
+
*/
|
|
1176
|
+
function comparePixels(original, ported) {
|
|
1177
|
+
const a = PNG.sync.read(original);
|
|
1178
|
+
const b = PNG.sync.read(ported);
|
|
1179
|
+
const width = Math.min(a.width, b.width);
|
|
1180
|
+
const height = Math.min(a.height, b.height);
|
|
1181
|
+
const diff = new PNG({
|
|
1182
|
+
width,
|
|
1183
|
+
height
|
|
1184
|
+
});
|
|
1185
|
+
const mismatchedPixels = pixelmatch(crop(a, width, height), crop(b, width, height), diff.data, width, height, { threshold: .1 });
|
|
1186
|
+
const total = width * height;
|
|
1187
|
+
return {
|
|
1188
|
+
comparison: {
|
|
1189
|
+
comparedWidth: width,
|
|
1190
|
+
comparedHeight: height,
|
|
1191
|
+
heightDelta: b.height - a.height,
|
|
1192
|
+
mismatchedPixels,
|
|
1193
|
+
mismatch: total === 0 ? 0 : Math.round(mismatchedPixels / total * 1e5) / 1e3
|
|
1194
|
+
},
|
|
1195
|
+
diff
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
//#endregion
|
|
1200
|
+
//#region src/targets.ts
|
|
1201
|
+
const PRIVATE_IPV4 = [
|
|
1202
|
+
/^127\./,
|
|
1203
|
+
/^10\./,
|
|
1204
|
+
/^192\.168\./,
|
|
1205
|
+
/^172\.(?:1[6-9]|2\d|3[01])\./,
|
|
1206
|
+
/^0\.0\.0\.0$/
|
|
1207
|
+
];
|
|
1208
|
+
function isLocalHost(hostname) {
|
|
1209
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1210
|
+
return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host === "::1" || PRIVATE_IPV4.some((pattern) => pattern.test(host));
|
|
1211
|
+
}
|
|
1212
|
+
/** Accepts `localhost:4321` or `example.com` as well as full http(s) URLs. */
|
|
1213
|
+
function normalizeUrl(input) {
|
|
1214
|
+
const value = input.trim();
|
|
1215
|
+
if (!value) throw new BenchError("A URL is required.");
|
|
1216
|
+
const hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value);
|
|
1217
|
+
let url;
|
|
1218
|
+
try {
|
|
1219
|
+
url = new URL(hasScheme ? value : `http://${value}`);
|
|
1220
|
+
} catch {
|
|
1221
|
+
throw new BenchError(`Invalid URL: ${input}`);
|
|
1222
|
+
}
|
|
1223
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new BenchError(`Only http and https URLs are supported: ${input}`);
|
|
1224
|
+
if (!hasScheme && !isLocalHost(url.hostname)) url.protocol = "https:";
|
|
1225
|
+
return url;
|
|
1226
|
+
}
|
|
1227
|
+
const DEV_SERVER_MARKERS = [
|
|
1228
|
+
["Astro", /astro\/dist\/runtime\/client\/dev-toolbar|astro-dev-toolbar/],
|
|
1229
|
+
["Vite", /\/@vite\/client/],
|
|
1230
|
+
["Next.js", /\/_next\/static\/[^"']*(?:hmr-client|next-devtools|_browser_dev_|react-refresh)|\/_next\/static\/chunks\/webpack\.js\?v=|\/_next\/webpack-hmr/],
|
|
1231
|
+
["webpack", /webpack-dev-server|__webpack_hmr/],
|
|
1232
|
+
["React Refresh", /@react-refresh|react-refresh-runtime/]
|
|
1233
|
+
];
|
|
1234
|
+
function detectDevServer(html) {
|
|
1235
|
+
return DEV_SERVER_MARKERS.find(([, pattern]) => pattern.test(html))?.[0] ?? null;
|
|
1236
|
+
}
|
|
1237
|
+
/** Bot-protection pages would be measured instead of the site. */
|
|
1238
|
+
const CHALLENGE_HEADERS = ["x-vercel-mitigated", "cf-mitigated"];
|
|
1239
|
+
/**
|
|
1240
|
+
* Load the URL in the browser that takes the measurements. Bot protection on
|
|
1241
|
+
* hosts such as Vercel and Cloudflare challenges plain HTTP clients but lets
|
|
1242
|
+
* Chrome through, so a fetch-based check would reject reachable sites.
|
|
1243
|
+
*/
|
|
1244
|
+
async function inspectTarget(browser, { side, input, url, timeout }) {
|
|
1245
|
+
const context = await browser.newContext({ serviceWorkers: "block" });
|
|
1246
|
+
try {
|
|
1247
|
+
const page = await context.newPage();
|
|
1248
|
+
let response;
|
|
1249
|
+
try {
|
|
1250
|
+
response = await page.goto(url.href, {
|
|
1251
|
+
waitUntil: "domcontentloaded",
|
|
1252
|
+
timeout
|
|
1253
|
+
});
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0].replace(/^page\.goto: /, "");
|
|
1256
|
+
throw new BenchError(`Could not reach the ${side} URL ${url.href}: ${reason}`);
|
|
1257
|
+
}
|
|
1258
|
+
if (!response) throw new BenchError(`The ${side} URL ${url.href} returned no response.`);
|
|
1259
|
+
const headers = response.headers();
|
|
1260
|
+
if (CHALLENGE_HEADERS.some((name) => headers[name] === "challenge")) throw new BenchError(`The ${side} URL ${url.href} is behind a bot challenge, which would be measured instead of the page. Allow the benchmark through or measure a local build.`);
|
|
1261
|
+
if (!response.ok()) throw new BenchError(`The ${side} URL ${url.href} responded with HTTP ${response.status()}.`);
|
|
1262
|
+
const contentType = headers["content-type"] ?? "";
|
|
1263
|
+
const html = await response.text().catch(() => "");
|
|
1264
|
+
if (!/html/i.test(contentType) && !/<html[\s>]/i.test(html)) throw new BenchError(`The ${side} URL ${url.href} did not return an HTML page (${contentType || "no content type"}).`);
|
|
1265
|
+
const finalUrl = new URL(page.url());
|
|
1266
|
+
return {
|
|
1267
|
+
side,
|
|
1268
|
+
input,
|
|
1269
|
+
url: url.href,
|
|
1270
|
+
finalUrl: finalUrl.href,
|
|
1271
|
+
status: response.status(),
|
|
1272
|
+
local: isLocalHost(finalUrl.hostname),
|
|
1273
|
+
devServer: detectDevServer(html)
|
|
1274
|
+
};
|
|
1275
|
+
} finally {
|
|
1276
|
+
await context.close();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
/** Whether timing results from the two targets can be compared fairly. */
|
|
1280
|
+
function assessComparability(original, ported) {
|
|
1281
|
+
const reasons = [];
|
|
1282
|
+
if (original.local !== ported.local) reasons.push(`The ${original.local ? "original" : "ported"} URL is local and the ${original.local ? "ported" : "original"} URL is remote, so network, CDN and protocol differences are mixed into timings.`);
|
|
1283
|
+
for (const target of [original, ported]) if (target.devServer) reasons.push(`The ${target.side} URL is served by a development server (${target.devServer}); use a production build.`);
|
|
1284
|
+
return {
|
|
1285
|
+
comparable: reasons.length === 0,
|
|
1286
|
+
reasons
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region src/run.ts
|
|
1292
|
+
/** Stages that measure performance, which development servers distort. */
|
|
1293
|
+
const PRODUCTION_ONLY = ["assets", "lighthouse"];
|
|
1294
|
+
async function runBench(options, log = () => {}) {
|
|
1295
|
+
const budgets = parseBudgets(options.budgets);
|
|
1296
|
+
const inputs = {
|
|
1297
|
+
original: normalizeUrl(options.original),
|
|
1298
|
+
ported: normalizeUrl(options.ported)
|
|
1299
|
+
};
|
|
1300
|
+
const chromePath = resolveChromePath(options.chromePath);
|
|
1301
|
+
const artifacts = createArtifacts(options.outDir);
|
|
1302
|
+
const measuredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1303
|
+
const browser = await launchBrowser(chromePath);
|
|
1304
|
+
const browserVersion = browser.version();
|
|
1305
|
+
const inspect = (side) => inspectTarget(browser, {
|
|
1306
|
+
side,
|
|
1307
|
+
input: options[side],
|
|
1308
|
+
url: inputs[side],
|
|
1309
|
+
timeout: options.timeout
|
|
1310
|
+
});
|
|
1311
|
+
let targets;
|
|
1312
|
+
try {
|
|
1313
|
+
log("Checking both URLs");
|
|
1314
|
+
const [original, ported] = await Promise.allSettled([inspect("original"), inspect("ported")]);
|
|
1315
|
+
if (original.status === "rejected") throw original.reason;
|
|
1316
|
+
if (ported.status === "rejected") throw ported.reason;
|
|
1317
|
+
targets = {
|
|
1318
|
+
original: original.value,
|
|
1319
|
+
ported: ported.value
|
|
1320
|
+
};
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
await browser.close();
|
|
1323
|
+
throw error;
|
|
1324
|
+
}
|
|
1325
|
+
const urls = {
|
|
1326
|
+
original: targets.original.finalUrl,
|
|
1327
|
+
ported: targets.ported.finalUrl
|
|
1328
|
+
};
|
|
1329
|
+
const comparability = assessComparability(targets.original, targets.ported);
|
|
1330
|
+
const devServer = targets.original.devServer ?? targets.ported.devServer;
|
|
1331
|
+
const blocked = devServer && !options.allowDev ? PRODUCTION_ONLY : [];
|
|
1332
|
+
const stages = options.stages.filter((stage) => !blocked.includes(stage));
|
|
1333
|
+
const skipped = options.stages.filter((stage) => blocked.includes(stage)).map((stage) => ({
|
|
1334
|
+
stage,
|
|
1335
|
+
reason: `A development server (${devServer}) was detected. Measure production builds, or pass --allow-dev to measure anyway.`
|
|
1336
|
+
}));
|
|
1337
|
+
const errors = [];
|
|
1338
|
+
async function stage(name, run) {
|
|
1339
|
+
if (!stages.includes(name)) return void 0;
|
|
1340
|
+
try {
|
|
1341
|
+
return await run();
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1344
|
+
errors.push({
|
|
1345
|
+
stage: name,
|
|
1346
|
+
message
|
|
1347
|
+
});
|
|
1348
|
+
log(`${name} failed: ${message}`);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const { timeout } = options;
|
|
1353
|
+
let measured;
|
|
1354
|
+
try {
|
|
1355
|
+
measured = {
|
|
1356
|
+
assets: await stage("assets", () => {
|
|
1357
|
+
log("Measuring route assets");
|
|
1358
|
+
return measureAssets(browser, urls, timeout);
|
|
1359
|
+
}),
|
|
1360
|
+
quality: await stage("quality", () => {
|
|
1361
|
+
log("Checking text, headings, accessibility and browser errors");
|
|
1362
|
+
return measureQuality(browser, urls, timeout);
|
|
1363
|
+
}),
|
|
1364
|
+
visual: await stage("visual", () => measureVisual(browser, {
|
|
1365
|
+
urls,
|
|
1366
|
+
widths: options.widths,
|
|
1367
|
+
tolerance: options.tolerance,
|
|
1368
|
+
timeout,
|
|
1369
|
+
artifacts,
|
|
1370
|
+
onProgress: log
|
|
1371
|
+
}))
|
|
1372
|
+
};
|
|
1373
|
+
} finally {
|
|
1374
|
+
await browser.close();
|
|
1375
|
+
}
|
|
1376
|
+
const lighthouse = await stage("lighthouse", () => runLighthouse({
|
|
1377
|
+
urls,
|
|
1378
|
+
runs: options.runs,
|
|
1379
|
+
formFactor: options.formFactor,
|
|
1380
|
+
chromePath,
|
|
1381
|
+
artifacts,
|
|
1382
|
+
onProgress: log
|
|
1383
|
+
}));
|
|
1384
|
+
const results = {
|
|
1385
|
+
...measured,
|
|
1386
|
+
lighthouse
|
|
1387
|
+
};
|
|
1388
|
+
const checks = reportChecks({
|
|
1389
|
+
comparability,
|
|
1390
|
+
...results
|
|
1391
|
+
});
|
|
1392
|
+
const budgetResults = evaluateBudgets(budgets, {
|
|
1393
|
+
comparability,
|
|
1394
|
+
...results
|
|
1395
|
+
});
|
|
1396
|
+
const { original: _original, ported: _ported, ...rest } = options;
|
|
1397
|
+
const report = {
|
|
1398
|
+
schemaVersion: 1,
|
|
1399
|
+
tool: {
|
|
1400
|
+
name,
|
|
1401
|
+
version
|
|
1402
|
+
},
|
|
1403
|
+
measuredAt,
|
|
1404
|
+
environment: {
|
|
1405
|
+
node: process.version,
|
|
1406
|
+
platform: process.platform,
|
|
1407
|
+
arch: process.arch,
|
|
1408
|
+
browser: `Chrome ${browserVersion}`
|
|
1409
|
+
},
|
|
1410
|
+
options: {
|
|
1411
|
+
...rest,
|
|
1412
|
+
outDir: artifacts?.dir
|
|
1413
|
+
},
|
|
1414
|
+
targets,
|
|
1415
|
+
comparability,
|
|
1416
|
+
skipped,
|
|
1417
|
+
errors,
|
|
1418
|
+
...results,
|
|
1419
|
+
checks,
|
|
1420
|
+
budgets: budgetResults,
|
|
1421
|
+
passed: errors.length === 0 && checks.every((check) => check.status !== "fail") && budgetResults.every((budget) => budget.status !== "fail")
|
|
1422
|
+
};
|
|
1423
|
+
if (!artifacts) return { report };
|
|
1424
|
+
return {
|
|
1425
|
+
report,
|
|
1426
|
+
files: {
|
|
1427
|
+
json: artifacts.write("report.json", `${JSON.stringify(report, null, 2)}\n`),
|
|
1428
|
+
markdown: artifacts.write("report.md", renderMarkdown(report, artifacts.dir))
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
//#endregion
|
|
1434
|
+
//#region src/summary.ts
|
|
1435
|
+
const STATUS_SYMBOL = {
|
|
1436
|
+
pass: kleur.green("✓"),
|
|
1437
|
+
fail: kleur.red("✗"),
|
|
1438
|
+
warn: kleur.yellow("!"),
|
|
1439
|
+
skip: kleur.dim("–")
|
|
1440
|
+
};
|
|
1441
|
+
const INDENT = " ";
|
|
1442
|
+
const MAX_COMPONENT_DIFFERENCES = 5;
|
|
1443
|
+
const visibleLength = (value) => value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
1444
|
+
function wrap(text, width) {
|
|
1445
|
+
const lines = [];
|
|
1446
|
+
let line = "";
|
|
1447
|
+
for (const word of text.split(" ")) if (line && visibleLength(line) + 1 + visibleLength(word) > width) {
|
|
1448
|
+
lines.push(line);
|
|
1449
|
+
line = word;
|
|
1450
|
+
} else line = line ? `${line} ${word}` : word;
|
|
1451
|
+
if (line) lines.push(line);
|
|
1452
|
+
return lines;
|
|
1453
|
+
}
|
|
1454
|
+
const colorCell = (cell) => typeof cell === "string" ? cell : cell.tone === "worse" ? kleur.red(cell.text) : kleur.green(cell.text);
|
|
1455
|
+
function columns({ headings, align, rows }) {
|
|
1456
|
+
const cells = rows.map((row) => row.map(colorCell));
|
|
1457
|
+
const widths = headings.map((_, index) => Math.max(...[headings, ...cells].map((row) => visibleLength(row[index]))));
|
|
1458
|
+
const line = (row) => INDENT + row.map((cell, index) => {
|
|
1459
|
+
const padding = " ".repeat(widths[index] - visibleLength(cell));
|
|
1460
|
+
return align[index] === "r" ? padding + cell : cell + padding;
|
|
1461
|
+
}).join(" ").trimEnd();
|
|
1462
|
+
return [kleur.dim(line(headings)), ...cells.map(line)].join("\n");
|
|
1463
|
+
}
|
|
1464
|
+
/** Label/detail list where details wrap to the terminal width. */
|
|
1465
|
+
function statusList(items, width) {
|
|
1466
|
+
const labelWidth = Math.max(...items.map((item) => item.label.length));
|
|
1467
|
+
const detailWidth = Math.max(30, width - 2 - labelWidth - 4);
|
|
1468
|
+
return items.map((item) => {
|
|
1469
|
+
const [first = "", ...rest] = wrap(item.detail, detailWidth);
|
|
1470
|
+
const lead = `${INDENT}${STATUS_SYMBOL[item.status]} ${item.label.padEnd(labelWidth)} `;
|
|
1471
|
+
const hang = " ".repeat(4 + labelWidth + 2);
|
|
1472
|
+
return [lead + first, ...rest.map((line) => hang + line)].join("\n");
|
|
1473
|
+
}).join("\n");
|
|
1474
|
+
}
|
|
1475
|
+
function heading(title, notes = []) {
|
|
1476
|
+
return kleur.bold(title) + (notes.length > 0 ? kleur.dim(` · ${notes.join(" · ")}`) : "");
|
|
1477
|
+
}
|
|
1478
|
+
const sectionBlock = ({ title, notes, table }) => `${heading(title, notes)}\n${columns(table)}`;
|
|
1479
|
+
/** Everything a reader needs from the report, formatted for a terminal. */
|
|
1480
|
+
function renderSummary(report, width = process.stdout.columns || 100) {
|
|
1481
|
+
const { original, ported } = report.targets;
|
|
1482
|
+
const sections = [`${report.passed ? kleur.green().bold("PASS") : kleur.red().bold("FAIL")} ${original.finalUrl} ${kleur.dim("→")} ${ported.finalUrl}`, `${heading("Checks")}\n${statusList(report.checks, width)}`];
|
|
1483
|
+
const budgets = budgetItems(report);
|
|
1484
|
+
if (budgets.length > 0) sections.push(`${heading("Budgets")}\n${statusList(budgets, width)}`);
|
|
1485
|
+
const stages = stageItems(report);
|
|
1486
|
+
if (stages.length > 0) sections.push(`${heading("Stages")}\n${statusList(stages, width)}`);
|
|
1487
|
+
const { lighthouse, assets, visual, quality, comparability } = report;
|
|
1488
|
+
if (lighthouse) sections.push(sectionBlock(lighthouseSection(lighthouse, comparability)));
|
|
1489
|
+
if (assets) sections.push(sectionBlock(assetsSection(assets)));
|
|
1490
|
+
if (visual) {
|
|
1491
|
+
const lines = [sectionBlock(visualSection(visual))];
|
|
1492
|
+
for (const result of visual.widths) {
|
|
1493
|
+
if (result.differences.length === 0) continue;
|
|
1494
|
+
const shown = result.differences.length > MAX_COMPONENT_DIFFERENCES ? ` (first ${MAX_COMPONENT_DIFFERENCES} of ${result.differences.length})` : "";
|
|
1495
|
+
lines.push("", `${INDENT}Component differences at ${result.width}px${shown}`, columns(componentDifferences(result, {
|
|
1496
|
+
limit: MAX_COMPONENT_DIFFERENCES,
|
|
1497
|
+
textLength: 32
|
|
1498
|
+
})));
|
|
1499
|
+
}
|
|
1500
|
+
const unmatched = visual.widths[0] ? unmatchedSlots(visual.widths[0]) : [];
|
|
1501
|
+
if (unmatched.length > 0) lines.push("", ...unmatched.flatMap((line) => wrap(line, width - 2)).map((line) => INDENT + kleur.dim(line)));
|
|
1502
|
+
sections.push(lines.join("\n"));
|
|
1503
|
+
}
|
|
1504
|
+
if (quality) sections.push(sectionBlock(qualitySection(quality)));
|
|
1505
|
+
return sections.join("\n\n");
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
//#endregion
|
|
1509
|
+
export { LIGHTHOUSE_METRICS as a, BUDGET_HELP as c, BenchError as d, version as f, ASSET_CATEGORIES as i, evaluateBudgets as l, runBench as n, SIDES as o, renderMarkdown as r, STAGES as s, renderSummary as t, parseBudgets as u };
|
|
1510
|
+
//# sourceMappingURL=summary-063R0d9g.js.map
|