rolldown-perf-kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AGENTS.md +345 -0
  2. package/LICENSE +25 -0
  3. package/README.md +621 -2
  4. package/harness.mjs +4860 -0
  5. package/lib/build.mjs +57 -0
  6. package/lib/cdp.mjs +217 -0
  7. package/lib/chunk-discovery.mjs +226 -0
  8. package/lib/coverage.mjs +498 -0
  9. package/lib/field/analysis.mjs +225 -0
  10. package/lib/field/browser.mjs +232 -0
  11. package/lib/field/build-match.mjs +86 -0
  12. package/lib/field/capture.mjs +501 -0
  13. package/lib/field/config.mjs +84 -0
  14. package/lib/field/run.mjs +612 -0
  15. package/lib/gen-app.mjs +399 -0
  16. package/lib/graph-diff.mjs +136 -0
  17. package/lib/interrupt.mjs +25 -0
  18. package/lib/measure.mjs +540 -0
  19. package/lib/min-cut.mjs +218 -0
  20. package/lib/module-graph.mjs +318 -0
  21. package/lib/network-evidence.mjs +84 -0
  22. package/lib/node/launch.mjs +351 -0
  23. package/lib/node/measure.mjs +289 -0
  24. package/lib/paint-diagnostics.mjs +76 -0
  25. package/lib/parity/anchor.mjs +186 -0
  26. package/lib/parity/capture.mjs +527 -0
  27. package/lib/parity/compare.mjs +813 -0
  28. package/lib/parity/png.mjs +166 -0
  29. package/lib/parity/run.mjs +145 -0
  30. package/lib/profile.mjs +281 -0
  31. package/lib/replay/cert.mjs +45 -0
  32. package/lib/replay/model.mjs +656 -0
  33. package/lib/replay/run.mjs +1140 -0
  34. package/lib/replay/server.mjs +499 -0
  35. package/lib/replay/shaper.mjs +158 -0
  36. package/lib/rolldown-support.mjs +161 -0
  37. package/lib/serve.mjs +142 -0
  38. package/lib/worker/attrib.mjs +125 -0
  39. package/lib/worker/config.mjs +116 -0
  40. package/lib/worker/launch.mjs +337 -0
  41. package/lib/worker/measure.mjs +296 -0
  42. package/package.json +38 -4
  43. package/replay.mjs +79 -0
@@ -0,0 +1,813 @@
1
+ // Rendered-output comparison - the pure half of parity. Every function here takes
2
+ // plain data (decoded screenshots, the snapshots lib/parity/capture.mjs collects)
3
+ // and returns plain data, so each decision is unit-testable without a browser.
4
+ //
5
+ // The rule the thresholds serve: an LCP optimization must not change what the page
6
+ // renders. A visual-regression service compares runs from different machines and
7
+ // needs a percentage tolerance; here the original build and the candidate render
8
+ // back to back in ONE browser session, so the tolerance is the noise we MEASURE
9
+ // (two captures of the original under perturbed clocks and random seeds), never a
10
+ // share of the screen we guess. A guessed 0.5% lets a missing 24px icon through.
11
+
12
+ import crypto from 'node:crypto';
13
+
14
+ /** A pixel counts as changed when any channel moves by more than this (of 255). */
15
+ export const CHANNEL_TOLERANCE = 10;
16
+ /**
17
+ * Pixels that differ between the two captures of the original grow by this radius
18
+ * before they mask anything - the anti-aliased fringe of content that moves.
19
+ */
20
+ export const UNSTABLE_GROW_PX = 2;
21
+ /** Fewer changed pixels than this is raster noise, not a rendering change. */
22
+ export const MIN_CHANGED_PIXELS = 20;
23
+ /**
24
+ * A view whose unstable share exceeds this cannot be vouched for: "nothing changed"
25
+ * would only mean "nothing changed in the part that holds still".
26
+ */
27
+ export const UNSTABLE_LIMIT = 0.3;
28
+ /** Region clustering grid, px. */
29
+ export const CELL_PX = 8;
30
+ /** An LCP element smaller than this share of the original's is a different paint. */
31
+ export const LCP_SHRINK_LIMIT = 0.75;
32
+
33
+ /**
34
+ * Per-pixel change mask between two RGBA images over their common area (or the
35
+ * given bounds, so masks from different image pairs line up).
36
+ */
37
+ export function changedPixels(a, b, tolerance = CHANNEL_TOLERANCE, bounds = null) {
38
+ const width = bounds?.width ?? Math.min(a.width, b.width);
39
+ const height = bounds?.height ?? Math.min(a.height, b.height);
40
+ const mask = new Uint8Array(width * height);
41
+ const ad = a.data;
42
+ const bd = b.data;
43
+ let count = 0;
44
+ for (let y = 0; y < height; y++) {
45
+ let ia = y * a.width * 4;
46
+ let ib = y * b.width * 4;
47
+ const row = y * width;
48
+ for (let x = 0; x < width; x++, ia += 4, ib += 4) {
49
+ if (
50
+ Math.abs(ad[ia] - bd[ib]) > tolerance ||
51
+ Math.abs(ad[ia + 1] - bd[ib + 1]) > tolerance ||
52
+ Math.abs(ad[ia + 2] - bd[ib + 2]) > tolerance ||
53
+ Math.abs(ad[ia + 3] - bd[ib + 3]) > tolerance
54
+ ) {
55
+ mask[row + x] = 1;
56
+ count++;
57
+ }
58
+ }
59
+ }
60
+ return { width, height, mask, count };
61
+ }
62
+
63
+ /** Square dilation of a 0/1 mask (two running-sum passes, O(pixels)). */
64
+ export function grow({ width, height, mask }, radius) {
65
+ if (radius <= 0) return mask.slice();
66
+ const horizontal = new Uint8Array(mask.length);
67
+ for (let y = 0; y < height; y++) {
68
+ const row = y * width;
69
+ let run = 0;
70
+ for (let x = 0; x < Math.min(radius, width); x++) run += mask[row + x];
71
+ for (let x = 0; x < width; x++) {
72
+ if (x + radius < width) run += mask[row + x + radius];
73
+ if (run > 0) horizontal[row + x] = 1;
74
+ if (x - radius >= 0) run -= mask[row + x - radius];
75
+ }
76
+ }
77
+ const out = new Uint8Array(mask.length);
78
+ const runs = new Int32Array(width);
79
+ const addRow = (y, sign) => {
80
+ const row = y * width;
81
+ for (let x = 0; x < width; x++) runs[x] += sign * horizontal[row + x];
82
+ };
83
+ for (let y = 0; y < Math.min(radius, height); y++) addRow(y, 1);
84
+ for (let y = 0; y < height; y++) {
85
+ if (y + radius < height) addRow(y + radius, 1);
86
+ const row = y * width;
87
+ for (let x = 0; x < width; x++) if (runs[x] > 0) out[row + x] = 1;
88
+ if (y - radius >= 0) addRow(y - radius, -1);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Connected regions of changed pixels on a CELL_PX grid. Cells up to two apart
95
+ * join, so the words of one changed line of text (gaps between words exceed one
96
+ * cell in a wide font) form one region. Also returns every changed cell - the
97
+ * approval signature keys on exactly these.
98
+ */
99
+ export function clusterRegions({ width, height, mask }, cell = CELL_PX) {
100
+ const cols = Math.ceil(width / cell);
101
+ const rows = Math.ceil(height / cell);
102
+ const counts = new Uint32Array(cols * rows);
103
+ for (let y = 0; y < height; y++) {
104
+ const rowCell = Math.floor(y / cell) * cols;
105
+ const row = y * width;
106
+ for (let x = 0; x < width; x++) if (mask[row + x]) counts[rowCell + Math.floor(x / cell)]++;
107
+ }
108
+ const seen = new Uint8Array(counts.length);
109
+ const queue = new Int32Array(counts.length);
110
+ const regions = [];
111
+ const cells = [];
112
+ for (let start = 0; start < counts.length; start++) {
113
+ if (!counts[start] || seen[start]) continue;
114
+ let head = 0;
115
+ let tail = 0;
116
+ queue[tail++] = start;
117
+ seen[start] = 1;
118
+ let minX = Infinity;
119
+ let minY = Infinity;
120
+ let maxX = -1;
121
+ let maxY = -1;
122
+ let pixels = 0;
123
+ while (head < tail) {
124
+ const c = queue[head++];
125
+ cells.push(c);
126
+ const cx = c % cols;
127
+ const cy = Math.floor(c / cols);
128
+ pixels += counts[c];
129
+ minX = Math.min(minX, cx);
130
+ maxX = Math.max(maxX, cx);
131
+ minY = Math.min(minY, cy);
132
+ maxY = Math.max(maxY, cy);
133
+ for (let dy = -2; dy <= 2; dy++) {
134
+ for (let dx = -2; dx <= 2; dx++) {
135
+ const nx = cx + dx;
136
+ const ny = cy + dy;
137
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
138
+ const n = ny * cols + nx;
139
+ if (counts[n] && !seen[n]) {
140
+ seen[n] = 1;
141
+ queue[tail++] = n;
142
+ }
143
+ }
144
+ }
145
+ }
146
+ regions.push({
147
+ x: minX * cell,
148
+ y: minY * cell,
149
+ w: Math.min(width, (maxX + 1) * cell) - minX * cell,
150
+ h: Math.min(height, (maxY + 1) * cell) - minY * cell,
151
+ pixels,
152
+ });
153
+ }
154
+ regions.sort((a, b) => b.pixels - a.pixels);
155
+ cells.sort((a, b) => a - b);
156
+ return { regions, cells, cols };
157
+ }
158
+
159
+ /** Paint the declared mask rectangles opaque so masked content can never differ. */
160
+ export function paintRects(img, rects) {
161
+ for (const r of rects) {
162
+ const x0 = Math.max(0, Math.floor(r.x));
163
+ const y0 = Math.max(0, Math.floor(r.y));
164
+ const x1 = Math.min(img.width, Math.ceil(r.x + r.w));
165
+ const y1 = Math.min(img.height, Math.ceil(r.y + r.h));
166
+ for (let y = y0; y < y1; y++) {
167
+ for (let x = x0; x < x1; x++) {
168
+ const i = (y * img.width + x) * 4;
169
+ img.data[i] = 255;
170
+ img.data[i + 1] = 0;
171
+ img.data[i + 2] = 255;
172
+ img.data[i + 3] = 255;
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * One view (viewport or full page) of one page. a1/a2 are two captures of the
180
+ * original under different clocks and seeds; c1 is the candidate under a1's; c2 is
181
+ * an optional re-capture of the candidate - a pixel then only counts when BOTH
182
+ * candidate captures disagree with the original, so a one-off flake cannot open a
183
+ * lead. Changes inside the grown unstable mask are counted but never judged.
184
+ */
185
+ export function compareView({ a1, a2, c1, c2 = null }) {
186
+ const width = Math.min(a1.width, a2.width, c1.width, c2?.width ?? Infinity);
187
+ const height = Math.min(a1.height, a2.height, c1.height, c2?.height ?? Infinity);
188
+ const bounds = { width, height };
189
+ const unstable = changedPixels(a1, a2, CHANNEL_TOLERANCE, bounds);
190
+ const unstableGrown = grow(unstable, UNSTABLE_GROW_PX);
191
+ const changed = changedPixels(a1, c1, CHANNEL_TOLERANCE, bounds);
192
+ const confirm = c2 ? changedPixels(a1, c2, CHANNEL_TOLERANCE, bounds) : null;
193
+ const real = new Uint8Array(width * height);
194
+ let changedCount = 0;
195
+ let maskedCount = 0;
196
+ for (let i = 0; i < real.length; i++) {
197
+ if (!changed.mask[i]) continue;
198
+ if (unstableGrown[i]) {
199
+ maskedCount++;
200
+ continue;
201
+ }
202
+ if (confirm && !confirm.mask[i]) continue;
203
+ real[i] = 1;
204
+ changedCount++;
205
+ }
206
+ const { regions, cells } = clusterRegions({ width, height, mask: real });
207
+ const total = Math.max(1, width * height);
208
+ const heightUnstable = a1.height !== a2.height;
209
+ const candidateHeights = c2 ? [c1.height, c2.height] : [c1.height];
210
+ const heightChange =
211
+ !heightUnstable && candidateHeights.every((h) => h !== a1.height)
212
+ ? { from: a1.height, to: c1.height }
213
+ : null;
214
+ return {
215
+ width,
216
+ height,
217
+ changedPixels: changedCount,
218
+ changedRatio: changedCount / total,
219
+ maskedPixels: maskedCount,
220
+ unstableRatio: unstable.count / total,
221
+ heightUnstable,
222
+ heightChange,
223
+ regions,
224
+ cells,
225
+ masks: { real, unstable: unstableGrown },
226
+ };
227
+ }
228
+
229
+ /** open = the candidate renders differently; unknown = the original itself won't hold still. */
230
+ export function viewState(view) {
231
+ if (view.changedPixels >= MIN_CHANGED_PIXELS || view.heightChange) return 'open';
232
+ if (view.unstableRatio > UNSTABLE_LIMIT || view.heightUnstable) return 'unknown';
233
+ return 'clear';
234
+ }
235
+
236
+ /**
237
+ * Diagnostic image: the candidate faded to gray, changed pixels red, unstable
238
+ * (unjudged) pixels blue, rows past the original's height pink.
239
+ */
240
+ export function renderDiffImage(candidate, view) {
241
+ const { width, height } = candidate;
242
+ const out = new Uint8Array(width * height * 4);
243
+ const src = candidate.data;
244
+ for (let y = 0; y < height; y++) {
245
+ for (let x = 0; x < width; x++) {
246
+ const i = (y * width + x) * 4;
247
+ const gray = 0.3 * src[i] + 0.59 * src[i + 1] + 0.11 * src[i + 2];
248
+ const faded = 255 - (255 - gray) * 0.35;
249
+ let r = faded;
250
+ let g = faded;
251
+ let b = faded;
252
+ if (x < view.width && y < view.height) {
253
+ const m = y * view.width + x;
254
+ if (view.masks.real[m]) {
255
+ r = 230;
256
+ g = 0;
257
+ b = 60;
258
+ } else if (view.masks.unstable[m]) {
259
+ r = faded * 0.75;
260
+ g = faded * 0.85;
261
+ b = 255;
262
+ }
263
+ } else if (y >= view.height) {
264
+ g = faded * 0.7;
265
+ b = faded * 0.8;
266
+ r = 255;
267
+ }
268
+ out[i] = r;
269
+ out[i + 1] = g;
270
+ out[i + 2] = b;
271
+ out[i + 3] = 255;
272
+ }
273
+ }
274
+ return { width, height, data: out };
275
+ }
276
+
277
+ // --- content ------------------------------------------------------------------
278
+
279
+ function counts(list) {
280
+ const m = new Map();
281
+ for (const item of list) m.set(item, (m.get(item) ?? 0) + 1);
282
+ return m;
283
+ }
284
+
285
+ /** Items whose multiplicity differs between two captures of the original. */
286
+ export function unstableItems(a, b) {
287
+ const ca = counts(a);
288
+ const cb = counts(b);
289
+ const out = new Set();
290
+ for (const [item, n] of ca) if (cb.get(item) !== n) out.add(item);
291
+ for (const item of cb.keys()) if (!ca.has(item)) out.add(item);
292
+ return out;
293
+ }
294
+
295
+ /** Order-insensitive diff: what the candidate lost and gained, unstable items skipped. */
296
+ export function multisetDiff(before, after, unstable = new Set()) {
297
+ const cb = counts(before);
298
+ const ca = counts(after);
299
+ const fmt = (item, n) => (n > 1 ? `${item} (x${n})` : item);
300
+ const missing = [];
301
+ const added = [];
302
+ for (const [item, n] of cb) {
303
+ const d = n - (ca.get(item) ?? 0);
304
+ if (d > 0 && !unstable.has(item)) missing.push(fmt(item, d));
305
+ }
306
+ for (const [item, n] of ca) {
307
+ const d = n - (cb.get(item) ?? 0);
308
+ if (d > 0 && !unstable.has(item)) added.push(fmt(item, d));
309
+ }
310
+ return { missing, added };
311
+ }
312
+
313
+ /** innerText -> comparable lines (whitespace collapsed, blanks dropped). */
314
+ export function textLines(text) {
315
+ return String(text ?? '')
316
+ .split('\n')
317
+ .map((line) => line.replace(/\s+/g, ' ').trim())
318
+ .filter(Boolean);
319
+ }
320
+
321
+ /**
322
+ * Runtime error text comparable across builds: no origin/port, no line:column
323
+ * (every bundle change moves them), hashed asset names reduced to their stem,
324
+ * first line only (stacks carry minified frame names that differ per build).
325
+ */
326
+ export function normalizeRuntimeError(text) {
327
+ return String(text ?? '')
328
+ .split('\n')[0]
329
+ .replace(/https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/g, '')
330
+ .replace(/([\w.]+)-[\w-]{8,}(\.[a-z0-9]+)\b/gi, '$1$2')
331
+ .replace(/:\d+(?::\d+)?\b/g, '')
332
+ .replace(/\s+/g, ' ')
333
+ .trim()
334
+ .slice(0, 240);
335
+ }
336
+
337
+ // --- elements -----------------------------------------------------------------
338
+
339
+ const sameRect = (a, b) =>
340
+ Math.abs(a.x - b.x) <= 1 &&
341
+ Math.abs(a.y - b.y) <= 1 &&
342
+ Math.abs(a.w - b.w) <= 1 &&
343
+ Math.abs(a.h - b.h) <= 1;
344
+
345
+ const isVisible = (e) =>
346
+ e.style.visibility !== 'hidden' && e.style.opacity !== '0' && e.style.display !== 'none';
347
+
348
+ export function styleChanges(a, b) {
349
+ const out = [];
350
+ for (const prop of Object.keys(a)) {
351
+ if (a[prop] !== b[prop]) out.push({ prop, from: a[prop], to: b[prop] });
352
+ }
353
+ return out;
354
+ }
355
+
356
+ /** Element paths whose rect, style or text differ between two captures of the original. */
357
+ export function unstableElements(a1, a2) {
358
+ const other = new Map(a2.map((e) => [e.path, e]));
359
+ const out = new Set();
360
+ for (const e of a1) {
361
+ const o = other.get(e.path);
362
+ if (
363
+ !o ||
364
+ e.text !== o.text ||
365
+ !sameRect(e.rect, o.rect) ||
366
+ styleChanges(e.style, o.style).length
367
+ ) {
368
+ out.add(e.path);
369
+ }
370
+ other.delete(e.path);
371
+ }
372
+ for (const path of other.keys()) out.add(path);
373
+ return out;
374
+ }
375
+
376
+ /**
377
+ * Match candidate elements to the original's: by structural path first, then -
378
+ * for a DOM that gained or lost a wrapper - by tag + own text when that pair is
379
+ * unique on both sides.
380
+ */
381
+ export function matchElements(anchorEls, currentEls) {
382
+ const byPath = new Map(currentEls.map((e) => [e.path, e]));
383
+ const byText = new Map();
384
+ for (const e of currentEls) {
385
+ if (!e.text) continue;
386
+ const key = `${e.tag}|${e.text}`;
387
+ byText.set(key, byText.has(key) ? null : e);
388
+ }
389
+ const anchorTextCount = counts(anchorEls.filter((e) => e.text).map((e) => `${e.tag}|${e.text}`));
390
+ const pairs = new Map();
391
+ for (const a of anchorEls) {
392
+ let c = byPath.get(a.path);
393
+ const key = `${a.tag}|${a.text}`;
394
+ if (!c && a.text && anchorTextCount.get(key) === 1) c = byText.get(key) ?? undefined;
395
+ if (c) pairs.set(a.path, c);
396
+ }
397
+ return pairs;
398
+ }
399
+
400
+ /**
401
+ * Explanations, not verdicts: which matched elements changed computed style or
402
+ * moved, which visible ones vanished or appeared. Pixels and text decide; this
403
+ * says why.
404
+ */
405
+ export function compareElements(anchorEls, currentEls, unstable = new Set()) {
406
+ const pairs = matchElements(anchorEls, currentEls);
407
+ const matched = new Set(Array.from(pairs.values(), (c) => c.path));
408
+ const changed = [];
409
+ const moved = [];
410
+ const missing = [];
411
+ for (const a of anchorEls) {
412
+ if (unstable.has(a.path)) continue;
413
+ const c = pairs.get(a.path);
414
+ if (!c) {
415
+ if (isVisible(a)) missing.push(a);
416
+ continue;
417
+ }
418
+ if (!isVisible(a) && !isVisible(c)) continue;
419
+ const changes = styleChanges(a.style, c.style);
420
+ if (changes.length) changed.push({ element: a, current: c, changes });
421
+ else if (!sameRect(a.rect, c.rect)) moved.push({ element: a, current: c });
422
+ }
423
+ const added = currentEls.filter(
424
+ (c) => !matched.has(c.path) && !unstable.has(c.path) && isVisible(c),
425
+ );
426
+ return { changed, moved, missing, added };
427
+ }
428
+
429
+ /** The elements that best explain a changed region (tightest overlap first). */
430
+ export function elementsAt(region, elements, limit = 2) {
431
+ const regionArea = region.w * region.h;
432
+ const scored = [];
433
+ for (const e of elements) {
434
+ const overlapW =
435
+ Math.min(region.x + region.w, e.rect.x + e.rect.w) - Math.max(region.x, e.rect.x);
436
+ const overlapH =
437
+ Math.min(region.y + region.h, e.rect.y + e.rect.h) - Math.max(region.y, e.rect.y);
438
+ if (overlapW <= 0 || overlapH <= 0) continue;
439
+ const overlap = overlapW * overlapH;
440
+ scored.push({ e, score: overlap / (regionArea + e.rect.w * e.rect.h - overlap) });
441
+ }
442
+ scored.sort((a, b) => b.score - a.score);
443
+ return scored.slice(0, limit).map((s) => s.e);
444
+ }
445
+
446
+ // --- LCP element --------------------------------------------------------------
447
+
448
+ const assetPath = (url) => String(url ?? '').replace(/^https?:\/\/[^/]+/, '');
449
+
450
+ /**
451
+ * The LCP gain is only real when LCP still timestamps the same content. Three ways
452
+ * it stops doing so, each a cheaper paint than the original's:
453
+ * - a different element became the LCP (the hero was hidden, shrunk or replaced);
454
+ * - the same element, much smaller;
455
+ * - a placeholder: LCP fired on content the element later replaced (a low-quality
456
+ * image swapped for the real one, skeleton text swapped for copy, a skeleton node
457
+ * removed for the real hero) - LCP emits no new entry for a same-size swap, so
458
+ * the timestamp belongs to the placeholder. Only counts when the original did
459
+ * not already do it.
460
+ * An image URL change alone is not one (a format conversion renames the file).
461
+ */
462
+ export function compareLcp(anchor, current) {
463
+ if (!anchor?.tag || !current?.tag) return { state: 'unknown', anchor, current };
464
+ const placeholder = (d) =>
465
+ d.detached === true ||
466
+ (d.url && d.finalUrl && assetPath(d.url) !== assetPath(d.finalUrl)) ||
467
+ (!d.url && d.finalText != null && d.text !== d.finalText);
468
+ const shownText = (d) => d.finalText ?? d.text ?? '';
469
+ const shownUrl = (d) => assetPath(d.finalUrl ?? d.url);
470
+ // Path first; a restructured DOM falls back to what the element shows. Two
471
+ // textless images are only the same element when they show the same file.
472
+ const sameElement =
473
+ (anchor.path && anchor.path === current.path) ||
474
+ (anchor.tag === current.tag &&
475
+ ((shownText(anchor) !== '' && shownText(anchor) === shownText(current)) ||
476
+ (shownUrl(anchor) !== '' && shownUrl(anchor) === shownUrl(current))));
477
+ if (!sameElement) {
478
+ // A page whose LCP element already flips between loads can't convict a change.
479
+ const flips = (d) => d.runs != null && d.of != null && d.runs < d.of;
480
+ return { state: flips(anchor) || flips(current) ? 'unstable' : 'changed', anchor, current };
481
+ }
482
+ if (placeholder(current) && !placeholder(anchor))
483
+ return { state: 'placeholder', anchor, current };
484
+ if (anchor.size > 0 && current.size < anchor.size * LCP_SHRINK_LIMIT) {
485
+ return { state: 'shrank', anchor, current };
486
+ }
487
+ return { state: 'same', anchor, current };
488
+ }
489
+
490
+ /** "img.hero 1280x600 (/assets/hero.jpg)" */
491
+ export function describeLcp(d) {
492
+ if (!d?.tag) return 'n/a';
493
+ const box = d.rect ? ` ${d.rect.w}x${d.rect.h}` : '';
494
+ const what = d.url ? ` (${assetPath(d.url)})` : d.text ? ` "${d.text.slice(0, 40)}"` : '';
495
+ return `${d.label ?? d.tag}${box}${what}`;
496
+ }
497
+
498
+ export function lcpReason(lcp) {
499
+ const from = describeLcp(lcp.anchor);
500
+ const to = describeLcp(lcp.current);
501
+ if (lcp.state === 'changed') return `LCP element changed: ${from} -> ${to}`;
502
+ if (lcp.state === 'shrank') return `LCP element shrank: ${from} -> ${to}`;
503
+ const replacement = lcp.current.detached
504
+ ? 'content that replaced it (the element was removed)'
505
+ : lcp.current.url
506
+ ? assetPath(lcp.current.finalUrl)
507
+ : `"${lcp.current.finalText}"`;
508
+ return `LCP fired on a placeholder: ${to}, later replaced by ${replacement}`;
509
+ }
510
+
511
+ // --- one page -----------------------------------------------------------------
512
+
513
+ const pct = (ratio) => `${(ratio * 100).toFixed(ratio < 0.001 ? 3 : 1)}%`;
514
+ const area = (e) => e.rect.w * e.rect.h;
515
+ const byAreaDesc = (a, b) => area(b) - area(a);
516
+ const brief = (e) => (e.text ? `${e.label} "${e.text.slice(0, 40)}"` : e.label);
517
+
518
+ const intersectDiffs = (a, b) => ({
519
+ missing: a.missing.filter((item) => b.missing.includes(item)),
520
+ added: a.added.filter((item) => b.added.includes(item)),
521
+ });
522
+
523
+ /** A page that crosses the viewport height in some captures only: heights, no pixels. */
524
+ function heightOnlyView(a1h, a2h, c1h) {
525
+ return {
526
+ width: 0,
527
+ height: 0,
528
+ changedPixels: 0,
529
+ changedRatio: 0,
530
+ maskedPixels: 0,
531
+ unstableRatio: 0,
532
+ heightUnstable: a1h !== a2h,
533
+ heightChange: a1h === a2h && c1h !== a1h ? { from: a1h, to: c1h } : null,
534
+ regions: [],
535
+ cells: [],
536
+ masks: null,
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Everything parity knows about one page, from two captures of the original (a1
542
+ * under condition A, a2 under B) and the candidate under A (c1, plus the
543
+ * confirmation re-capture c2 when there is one). Captures carry decoded images.
544
+ *
545
+ * Judged (any one opens the page): pixels and height of the first screen and the
546
+ * full page, visible text, accessible names, where the page ended up, new runtime
547
+ * errors, and - for the measured page - the LCP element. Explanatory only: computed
548
+ * styles, rendered fonts, images, loaded font faces, and the elements under each
549
+ * changed region. A legitimate deferral changes none of the judged facts; it may
550
+ * well change explanatory ones (a wrapper element, a renamed image file).
551
+ */
552
+ export function comparePage({ a1, a2, c1, c2 = null, lcp = null }) {
553
+ const caps = [a1, a2, c1, c2].filter(Boolean);
554
+ const viewportMasks = caps.flatMap((c) => c.first.masks);
555
+ const fullMasks = caps.flatMap((c) => c.page.masks);
556
+ for (const c of new Set(caps)) {
557
+ paintRects(c.images.viewport, viewportMasks);
558
+ if (c.images.full) paintRects(c.images.full, fullMasks);
559
+ }
560
+
561
+ const views = {
562
+ viewport: compareView({
563
+ a1: a1.images.viewport,
564
+ a2: a2.images.viewport,
565
+ c1: c1.images.viewport,
566
+ c2: c2?.images.viewport ?? null,
567
+ }),
568
+ };
569
+ const fullShots = caps.map((c) => c.images.full);
570
+ if (fullShots.every(Boolean)) {
571
+ views.full = compareView({
572
+ a1: a1.images.full,
573
+ a2: a2.images.full,
574
+ c1: c1.images.full,
575
+ c2: c2?.images.full ?? null,
576
+ });
577
+ } else if (fullShots.some(Boolean)) {
578
+ views.full = heightOnlyView(
579
+ a1.page.contentHeight,
580
+ a2.page.contentHeight,
581
+ c1.page.contentHeight,
582
+ );
583
+ }
584
+
585
+ const maskedLines = new Set(caps.flatMap((c) => textLines(c.page.maskedText.join('\n'))));
586
+ const lines = (c) => textLines(c.page.text).filter((line) => !maskedLines.has(line));
587
+ const unstableText = unstableItems(lines(a1), lines(a2));
588
+ let text = multisetDiff(lines(a1), lines(c1), unstableText);
589
+ if (c2) text = intersectDiffs(text, multisetDiff(lines(a1), lines(c2), unstableText));
590
+
591
+ let a11y = { missing: [], added: [] };
592
+ if (caps.every((c) => c.a11y)) {
593
+ const unstableNames = unstableItems(a1.a11y, a2.a11y);
594
+ a11y = multisetDiff(a1.a11y, c1.a11y, unstableNames);
595
+ if (c2) a11y = intersectDiffs(a11y, multisetDiff(a1.a11y, c2.a11y, unstableNames));
596
+ }
597
+
598
+ const known = new Set([...a1.errors, ...a2.errors].map((e) => normalizeRuntimeError(e)));
599
+ const fresh = (c) =>
600
+ new Set(c.errors.map((e) => normalizeRuntimeError(e)).filter((e) => e && !known.has(e)));
601
+ let newErrors = fresh(c1);
602
+ if (c2) {
603
+ const again = fresh(c2);
604
+ newErrors = new Set(Array.from(newErrors).filter((e) => again.has(e)));
605
+ }
606
+ const runtime = { added: Array.from(newErrors) };
607
+
608
+ const location =
609
+ a1.page.location === a2.page.location && c1.page.location !== a1.page.location
610
+ ? { from: a1.page.location, to: c1.page.location }
611
+ : null;
612
+
613
+ // --- explanations
614
+ const firstPairs = matchElements(a1.first.elements, c1.first.elements);
615
+ const pagePairs = matchElements(a1.page.elements, c1.page.elements);
616
+ const unstableFirst = unstableElements(a1.first.elements, a2.first.elements);
617
+ const unstablePage = unstableElements(a1.page.elements, a2.page.elements);
618
+ const explain = (region, anchorEls, currentEls, pairs, unstable) => {
619
+ const matchedCurrent = new Set(Array.from(pairs.values(), (c) => c.path));
620
+ const items = [];
621
+ const seen = new Set();
622
+ for (const a of elementsAt(region, anchorEls)) {
623
+ const c = pairs.get(a.path);
624
+ if (c) seen.add(c.path);
625
+ if (unstable.has(a.path)) continue;
626
+ const item = { label: a.label, text: a.text.slice(0, 60) };
627
+ if (!c) item.gone = true;
628
+ else {
629
+ const changes = styleChanges(a.style, c.style);
630
+ if (changes.length) item.changes = changes.slice(0, 4);
631
+ const from = a1.fonts[a.path];
632
+ const to = c1.fonts[c.path];
633
+ if (from && to && from !== to) item.font = { from, to };
634
+ if (!sameRect(a.rect, c.rect)) item.moved = { from: a.rect, to: c.rect };
635
+ }
636
+ items.push(item);
637
+ }
638
+ for (const c of elementsAt(region, currentEls)) {
639
+ if (seen.has(c.path)) continue;
640
+ items.push({
641
+ label: c.label,
642
+ text: c.text.slice(0, 60),
643
+ ...(matchedCurrent.has(c.path) ? { movedIn: true } : { added: true }),
644
+ });
645
+ }
646
+ // Name what changed, strongest first. A box that only moved (a container
647
+ // resized by its changed child) is named only when nothing stronger is
648
+ // there, and an unchanged container only when nothing at all explains it.
649
+ const strong = items.filter((i) => i.gone || i.added || i.changes || i.font);
650
+ const weak = items.filter((i) => !strong.includes(i) && (i.movedIn || i.moved));
651
+ if (strong.length) return strong.slice(0, 3);
652
+ return weak.length ? weak.slice(0, 2) : items.slice(0, 1);
653
+ };
654
+ for (const region of views.viewport.regions.slice(0, 4)) {
655
+ region.elements = explain(
656
+ region,
657
+ a1.first.elements,
658
+ c1.first.elements,
659
+ firstPairs,
660
+ unstableFirst,
661
+ );
662
+ }
663
+ for (const region of views.full?.regions.slice(0, 4) ?? []) {
664
+ region.elements = explain(region, a1.page.elements, c1.page.elements, pagePairs, unstablePage);
665
+ }
666
+
667
+ const fonts = [];
668
+ for (const [anchorPath, c] of firstPairs) {
669
+ const from = a1.fonts[anchorPath];
670
+ const to = c1.fonts[c.path];
671
+ if (!from || !to || from === to || a2.fonts[anchorPath] !== from) continue;
672
+ fonts.push({ label: c.label, text: c.text.slice(0, 60), from, to });
673
+ }
674
+ const images = multisetDiff(
675
+ a1.page.images,
676
+ c1.page.images,
677
+ unstableItems(a1.page.images, a2.page.images),
678
+ );
679
+ const loaded = (c) =>
680
+ c.page.faces.filter((f) => f.endsWith(': loaded')).map((f) => f.slice(0, -': loaded'.length));
681
+ const faces = multisetDiff(loaded(a1), loaded(c1), unstableItems(loaded(a1), loaded(a2)));
682
+ const elements = compareElements(a1.page.elements, c1.page.elements, unstablePage);
683
+ const styles = {
684
+ changed: elements.changed
685
+ .sort((x, y) => byAreaDesc(x.element, y.element))
686
+ .slice(0, 8)
687
+ .map((d) => ({
688
+ label: d.element.label,
689
+ text: d.element.text.slice(0, 60),
690
+ changes: d.changes.slice(0, 4),
691
+ })),
692
+ changedCount: elements.changed.length,
693
+ movedCount: elements.moved.length,
694
+ missing: elements.missing.sort(byAreaDesc).slice(0, 5).map(brief),
695
+ missingCount: elements.missing.length,
696
+ added: elements.added.sort(byAreaDesc).slice(0, 5).map(brief),
697
+ addedCount: elements.added.length,
698
+ };
699
+
700
+ // --- verdict
701
+ const reasons = [];
702
+ const notes = [];
703
+ for (const [name, view] of Object.entries(views)) {
704
+ view.state = viewState(view);
705
+ const where = name === 'viewport' ? 'first screen' : 'full page';
706
+ if (view.heightChange) {
707
+ reasons.push(
708
+ `${where}: page height ${view.heightChange.from}px -> ${view.heightChange.to}px`,
709
+ );
710
+ }
711
+ if (view.changedPixels >= MIN_CHANGED_PIXELS) {
712
+ const n = view.regions.length;
713
+ reasons.push(
714
+ `${where}: ${pct(view.changedRatio)} of pixels changed (${n} region${n === 1 ? '' : 's'})`,
715
+ );
716
+ }
717
+ if (view.state === 'unknown') {
718
+ notes.push(
719
+ view.heightUnstable
720
+ ? `${where}: the original's page height changes between loads - not judged`
721
+ : `${where}: ${pct(view.unstableRatio)} of the original's pixels change between two loads - too unstable to vouch for (declare masks for the moving parts)`,
722
+ );
723
+ }
724
+ if (view.maskedPixels > 0) {
725
+ notes.push(
726
+ `${where}: ${view.maskedPixels} changed pixel(s) lie where the original itself varies between loads - not judged`,
727
+ );
728
+ }
729
+ }
730
+ if (location) {
731
+ reasons.push(`the page ended at ${location.to} (the original ended at ${location.from})`);
732
+ }
733
+ if (text.missing.length || text.added.length) {
734
+ reasons.push(`text: ${text.missing.length} line(s) missing, ${text.added.length} new`);
735
+ }
736
+ if (a11y.missing.length || a11y.added.length) {
737
+ reasons.push(`accessible names: ${a11y.missing.length} missing, ${a11y.added.length} new`);
738
+ }
739
+ if (runtime.added.length) reasons.push(`${runtime.added.length} new runtime error(s)`);
740
+ if (lcp && ['changed', 'shrank', 'placeholder'].includes(lcp.state)) reasons.push(lcpReason(lcp));
741
+ if (lcp?.state === 'unstable') {
742
+ notes.push(
743
+ `the LCP element varies between loads (${describeLcp(lcp.anchor)} / ${describeLcp(lcp.current)}) - not compared`,
744
+ );
745
+ }
746
+ if (a1.fullTruncated)
747
+ notes.push(`full page compared down to its first ${a1.images.full.height}px`);
748
+ if (a1.page.truncated || c1.page.truncated) {
749
+ notes.push('element list truncated - style explanations cover the first elements only');
750
+ }
751
+ for (const selector of new Set(caps.flatMap((c) => c.first.badMasks))) {
752
+ notes.push(`mask selector does not parse, ignored: ${selector}`);
753
+ }
754
+ const state = reasons.length
755
+ ? 'open'
756
+ : Object.values(views).some((v) => v.state === 'unknown')
757
+ ? 'unknown'
758
+ : 'clear';
759
+ return {
760
+ state,
761
+ reasons,
762
+ notes,
763
+ views,
764
+ text,
765
+ a11y,
766
+ runtime,
767
+ location,
768
+ lcp,
769
+ fonts,
770
+ images,
771
+ faces,
772
+ styles,
773
+ };
774
+ }
775
+
776
+ // --- signature ----------------------------------------------------------------
777
+
778
+ /**
779
+ * What a human approval is keyed on: the exact changed picture (the candidate's
780
+ * pixels inside every changed cell of each view) plus every judged difference.
781
+ * Any further change to the page - a new region, a different wrong color, one
782
+ * more missing line - produces a new signature, so an old approval stops applying.
783
+ */
784
+ export function pageSignature(result, candidateImages) {
785
+ const hash = crypto.createHash('sha256');
786
+ for (const name of ['viewport', 'full']) {
787
+ const view = result.views[name];
788
+ const img = candidateImages[name];
789
+ if (!view || !img) continue;
790
+ hash.update(`${name}:${view.cells.length}:${JSON.stringify(view.heightChange)}`);
791
+ const cols = Math.ceil(view.width / CELL_PX);
792
+ for (const cell of view.cells) {
793
+ const x0 = (cell % cols) * CELL_PX;
794
+ const y0 = Math.floor(cell / cols) * CELL_PX;
795
+ hash.update(`${cell};`);
796
+ for (let y = y0; y < Math.min(y0 + CELL_PX, view.height); y++) {
797
+ const start = (y * img.width + x0) * 4;
798
+ hash.update(img.data.subarray(start, start + Math.min(CELL_PX, view.width - x0) * 4));
799
+ }
800
+ }
801
+ }
802
+ hash.update(
803
+ JSON.stringify({
804
+ reasons: result.reasons,
805
+ text: result.text,
806
+ a11y: result.a11y,
807
+ runtime: result.runtime,
808
+ location: result.location,
809
+ lcp: result.lcp ? { state: result.lcp.state, current: result.lcp.current } : null,
810
+ }),
811
+ );
812
+ return hash.digest('hex').slice(0, 16);
813
+ }