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.
- package/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- 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
|
+
}
|