@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.
@@ -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