inspectra-engine 0.0.0-stage → 1.0.2

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/compare.js ADDED
@@ -0,0 +1,439 @@
1
+ import imageTools from "./scanner/legacy-image-tools.cjs";
2
+ const { getPNG, getPixelmatch } = imageTools;
3
+ import { chromium } from "playwright";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ export const OUTPUT_DIR = process.env.INSPECTRA_REPORT_DIR || path.join(__dirname, "visual-reports");
12
+
13
+ /**
14
+ * Detects vertical clusters of differing pixels to help users locate diffs on tall pages.
15
+ */
16
+ export function detectDiffRegions(diff, width, height) {
17
+ const data = diff.data;
18
+ const rowCounts = new Uint32Array(height);
19
+ for (let y = 0; y < height; y++) {
20
+ const rowStart = y * width * 4;
21
+ for (let x = 0; x < width; x++) {
22
+ const idx = rowStart + x * 4;
23
+ // pixelmatch writes diff pixels in red/yellow: red=255, blue=0, alpha=255
24
+ if (data[idx] === 255 && data[idx + 2] === 0 && data[idx + 3] === 255) {
25
+ rowCounts[y]++;
26
+ }
27
+ }
28
+ }
29
+
30
+ const regions = [];
31
+ let inRegion = false;
32
+ let startY = 0;
33
+ let endY = 0;
34
+ let clusterPixels = 0;
35
+ let gap = 0;
36
+
37
+ for (let y = 0; y < height; y++) {
38
+ if (rowCounts[y] > 0) {
39
+ if (!inRegion) {
40
+ inRegion = true;
41
+ startY = y;
42
+ clusterPixels = 0;
43
+ }
44
+ endY = y;
45
+ clusterPixels += rowCounts[y];
46
+ gap = 0;
47
+ } else if (inRegion) {
48
+ gap++;
49
+ if (gap > 60 || y === height - 1) {
50
+ if (clusterPixels >= 30) {
51
+ regions.push({
52
+ startY,
53
+ endY,
54
+ height: endY - startY + 1,
55
+ diffPixels: clusterPixels,
56
+ pctY: Number(((startY / height) * 100).toFixed(1)),
57
+ });
58
+ }
59
+ inRegion = false;
60
+ }
61
+ }
62
+ }
63
+ if (inRegion && clusterPixels >= 30) {
64
+ regions.push({
65
+ startY,
66
+ endY,
67
+ height: endY - startY + 1,
68
+ diffPixels: clusterPixels,
69
+ pctY: Number(((startY / height) * 100).toFixed(1)),
70
+ });
71
+ }
72
+
73
+ return regions;
74
+ }
75
+
76
+ /**
77
+ * Normalizes an image to the target dimensions by padding with transparent pixels.
78
+ */
79
+ export function normalizeImageSize(src, targetWidth, targetHeight) {
80
+ if (src.width === targetWidth && src.height === targetHeight) return src;
81
+ const target = new (getPNG())({ width: targetWidth, height: targetHeight });
82
+ target.data.fill(0);
83
+ for (let y = 0; y < src.height; y++) {
84
+ for (let x = 0; x < src.width; x++) {
85
+ const srcIdx = (y * src.width + x) * 4;
86
+ const tgtIdx = (y * targetWidth + x) * 4;
87
+ target.data[tgtIdx] = src.data[srcIdx];
88
+ target.data[tgtIdx + 1] = src.data[srcIdx + 1];
89
+ target.data[tgtIdx + 2] = src.data[srcIdx + 2];
90
+ target.data[tgtIdx + 3] = src.data[srcIdx + 3];
91
+ }
92
+ }
93
+ return target;
94
+ }
95
+
96
+ /**
97
+ * Parses a Figma design URL into { fileKey, nodeId }.
98
+ */
99
+ export function parseFigmaUrl(url) {
100
+ let u;
101
+ try {
102
+ u = new URL(url);
103
+ } catch {
104
+ throw new Error("Invalid Figma URL");
105
+ }
106
+ if (!/(^|\.)figma\.com$/.test(u.hostname)) throw new Error("Not a figma.com URL");
107
+ const m = u.pathname.match(/\/(?:file|design|proto)\/([A-Za-z0-9]+)/);
108
+ if (!m) throw new Error("Could not find a file key in the Figma URL");
109
+ const rawNode = u.searchParams.get("node-id");
110
+ if (!rawNode) {
111
+ throw new Error("Figma URL must include a node-id (right-click a frame > Copy link to selection)");
112
+ }
113
+ return { fileKey: m[1], nodeId: rawNode.replace(/-/g, ":") };
114
+ }
115
+
116
+ /**
117
+ * Renders a Figma frame to PNG via the Figma REST API and returns it as a data URL.
118
+ */
119
+ export async function fetchFigmaImage(source) {
120
+ const token = source.token || process.env.FIGMA_TOKEN;
121
+ if (!token) {
122
+ throw new Error("Figma access token required (enter it in the form or set FIGMA_TOKEN)");
123
+ }
124
+ const { fileKey, nodeId } = parseFigmaUrl(source.value || source.url);
125
+ const apiUrl = `https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(nodeId)}&format=png&scale=1`;
126
+ const res = await fetch(apiUrl, { headers: { "X-Figma-Token": token } });
127
+ const json = await res.json().catch(() => ({}));
128
+ if (!res.ok || json.err) {
129
+ throw new Error(`Figma API error: ${json.err || json.message || res.status}`);
130
+ }
131
+ const imageUrl = json.images?.[nodeId];
132
+ if (!imageUrl) throw new Error("Figma did not return an image for that node-id");
133
+ const imgRes = await fetch(imageUrl);
134
+ if (!imgRes.ok) throw new Error(`Failed to download Figma image (${imgRes.status})`);
135
+ const buf = Buffer.from(await imgRes.arrayBuffer());
136
+ return `data:image/png;base64,${buf.toString("base64")}`;
137
+ }
138
+
139
+ /**
140
+ * Loads an image from a URL (via Playwright screenshot) or from an uploaded file / data URL.
141
+ */
142
+ export async function loadOrCaptureImage(browser, source, filename, options = {}) {
143
+ const {
144
+ viewport = { width: 1441, height: 900 },
145
+ fullPage = false,
146
+ disableAnimations = true,
147
+ hideSelectors = [],
148
+ } = options;
149
+
150
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
151
+ const outputPath = path.join(OUTPUT_DIR, filename);
152
+
153
+ if (typeof source === "object" && source?.type === "figma") {
154
+ source = { type: "image", value: await fetchFigmaImage(source), name: source.name };
155
+ }
156
+
157
+ const isImageUpload =
158
+ (typeof source === "object" && source?.type === "image") ||
159
+ (typeof source === "string" && source.startsWith("data:image/"));
160
+
161
+ if (isImageUpload) {
162
+ const rawVal = typeof source === "object" ? source.value : source;
163
+ let buffer = null;
164
+
165
+ if (typeof rawVal === "string" && rawVal.startsWith("data:")) {
166
+ const base64Data = rawVal.replace(/^data:image\/[^;]+;base64,/, "");
167
+ buffer = Buffer.from(base64Data, "base64");
168
+ } else if (Buffer.isBuffer(rawVal)) {
169
+ buffer = rawVal;
170
+ } else if (typeof rawVal === "string" && fs.existsSync(rawVal)) {
171
+ buffer = fs.readFileSync(rawVal);
172
+ }
173
+
174
+ // Try reading directly with pngjs
175
+ if (buffer) {
176
+ try {
177
+ const png = getPNG().sync.read(buffer);
178
+ fs.writeFileSync(outputPath, getPNG().sync.write(png));
179
+ return png;
180
+ } catch {
181
+ // Fall through to browser rendering for non-PNG or exotic formats (JPG, WEBP, SVG)
182
+ }
183
+ }
184
+
185
+ // Render in headless browser to convert to standard PNG
186
+ const page = await browser.newPage();
187
+ try {
188
+ const dataUrl =
189
+ typeof rawVal === "string" && rawVal.startsWith("data:")
190
+ ? rawVal
191
+ : buffer
192
+ ? `data:image/png;base64,${buffer.toString("base64")}`
193
+ : "";
194
+
195
+ await page.setContent(`<!DOCTYPE html><html><body style="margin:0;padding:0;overflow:hidden;background:#fff"><img id="preview" src="${dataUrl}" style="display:block"></body></html>`);
196
+ await page.waitForSelector("#preview");
197
+ const dim = await page.evaluate(() => {
198
+ const img = document.getElementById("preview");
199
+ return {
200
+ width: img.naturalWidth || img.width || 800,
201
+ height: img.naturalHeight || img.height || 600,
202
+ };
203
+ });
204
+ await page.setViewportSize({
205
+ width: Math.max(1, dim.width),
206
+ height: Math.max(1, dim.height),
207
+ });
208
+ const screenshot = await page.screenshot({ path: outputPath, type: "png" });
209
+ return getPNG().sync.read(screenshot);
210
+ } finally {
211
+ await page.close();
212
+ }
213
+ }
214
+
215
+ // URL mode
216
+ const url = typeof source === "object" ? source.value || source.url : source;
217
+ const page = await browser.newPage({
218
+ viewport: { width: viewport.width || 1441, height: viewport.height || 900 },
219
+ deviceScaleFactor: 1,
220
+ });
221
+
222
+ try {
223
+ await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
224
+ await page.evaluate(() => document.fonts.ready);
225
+
226
+ // If fullPage capture, smoothly scroll through document to trigger IntersectionObserver,
227
+ // scroll-based animations (Framer Motion, AOS, etc.), and lazy-loaded images.
228
+ if (fullPage) {
229
+ await page.evaluate(async () => {
230
+ await new Promise((resolve) => {
231
+ let current = 0;
232
+ const step = 600;
233
+ const timer = setInterval(() => {
234
+ window.scrollBy(0, step);
235
+ current += step;
236
+ if (current >= document.documentElement.scrollHeight) {
237
+ clearInterval(timer);
238
+ window.scrollTo(0, 0);
239
+ resolve();
240
+ }
241
+ }, 45);
242
+ });
243
+ });
244
+ await page.waitForTimeout(600);
245
+ // Ensure all scroll-animated elements are visible
246
+ await page.evaluate(() => {
247
+ const all = document.querySelectorAll('*');
248
+ for (const el of all) {
249
+ const style = window.getComputedStyle(el);
250
+ if (style.opacity === '0' || style.visibility === 'hidden') {
251
+ el.style.opacity = '1';
252
+ el.style.visibility = 'visible';
253
+ if (style.transform !== 'none') el.style.transform = 'none';
254
+ }
255
+ }
256
+ });
257
+ await page.waitForTimeout(300);
258
+ }
259
+
260
+ if (disableAnimations) {
261
+ await page.addStyleTag({
262
+ content: `
263
+ *,
264
+ *::before,
265
+ *::after {
266
+ animation: none !important;
267
+ transition: none !important;
268
+ caret-color: transparent !important;
269
+ }
270
+ `,
271
+ });
272
+ }
273
+
274
+ if (hideSelectors.length) {
275
+ await page.addStyleTag({ content: `${hideSelectors.join(",")}{visibility:hidden!important}` });
276
+ }
277
+
278
+ const screenshot = await page.screenshot({
279
+ path: outputPath,
280
+ fullPage: Boolean(fullPage),
281
+ animations: disableAnimations ? "disabled" : "allow",
282
+ });
283
+
284
+ return getPNG().sync.read(screenshot);
285
+ } finally {
286
+ await page.close();
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Compares two visual inputs (URLs or uploaded images) and saves comparison artifacts.
292
+ */
293
+ export async function compareVisuals(options = {}) {
294
+ const {
295
+ reference,
296
+ actual,
297
+ viewport = { width: 1441, height: 900 },
298
+ threshold = 0.1,
299
+ fullPage = false,
300
+ disableAnimations = true,
301
+ hideSelectors = [],
302
+ } = options;
303
+
304
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
305
+ const browser = await chromium.launch({ headless: true });
306
+
307
+ try {
308
+ const refRaw = await loadOrCaptureImage(browser, reference, "reference.png", {
309
+ viewport,
310
+ fullPage,
311
+ disableAnimations,
312
+ hideSelectors,
313
+ });
314
+
315
+ const actRaw = await loadOrCaptureImage(browser, actual, "actual.png", {
316
+ viewport,
317
+ fullPage,
318
+ disableAnimations,
319
+ hideSelectors,
320
+ });
321
+
322
+ const targetWidth = Math.max(refRaw.width, actRaw.width);
323
+ const targetHeight = Math.max(refRaw.height, actRaw.height);
324
+
325
+ const ref = normalizeImageSize(refRaw, targetWidth, targetHeight);
326
+ const act = normalizeImageSize(actRaw, targetWidth, targetHeight);
327
+
328
+ const diff = new (getPNG())({ width: targetWidth, height: targetHeight });
329
+
330
+ const differentPixels = getPixelmatch()(
331
+ ref.data,
332
+ act.data,
333
+ diff.data,
334
+ targetWidth,
335
+ targetHeight,
336
+ { threshold: Number(threshold) || 0.1 }
337
+ );
338
+
339
+ const diffPath = path.join(OUTPUT_DIR, "difference.png");
340
+ fs.writeFileSync(diffPath, getPNG().sync.write(diff));
341
+
342
+ const totalPixels = targetWidth * targetHeight;
343
+ const differencePercent = (differentPixels / totalPixels) * 100;
344
+
345
+ const diffRegions = detectDiffRegions(diff, targetWidth, targetHeight);
346
+
347
+ const result = {
348
+ referenceType:
349
+ typeof reference === "object"
350
+ ? reference.type
351
+ : typeof reference === "string" && reference.startsWith("http")
352
+ ? "url"
353
+ : "image",
354
+ actualType:
355
+ typeof actual === "object"
356
+ ? actual.type
357
+ : typeof actual === "string" && actual.startsWith("http")
358
+ ? "url"
359
+ : "image",
360
+ referenceUrl:
361
+ reference?.type === "figma"
362
+ ? null
363
+ : typeof reference === "string" && reference.startsWith("http")
364
+ ? reference
365
+ : reference?.url || (reference?.type === "url" ? reference.value : null),
366
+ actualUrl:
367
+ actual?.type === "figma"
368
+ ? null
369
+ : typeof actual === "string" && actual.startsWith("http")
370
+ ? actual
371
+ : actual?.url || (actual?.type === "url" ? actual.value : null),
372
+ referenceName: reference?.name || (typeof reference === "string" ? reference : "reference"),
373
+ actualName: actual?.name || (typeof actual === "string" ? actual : "actual"),
374
+ viewport: `${targetWidth}x${targetHeight}`,
375
+ threshold: Number(threshold) || 0.1,
376
+ totalPixels,
377
+ differentPixels,
378
+ differencePercent: Number(differencePercent.toFixed(2)),
379
+ referenceImage: "visual-reports/reference.png",
380
+ actualImage: "visual-reports/actual.png",
381
+ reportImage: "visual-reports/difference.png",
382
+ diffRegions,
383
+ sizeMismatch:
384
+ refRaw.width !== actRaw.width || refRaw.height !== actRaw.height
385
+ ? {
386
+ reference: `${refRaw.width}x${refRaw.height}`,
387
+ actual: `${actRaw.width}x${actRaw.height}`,
388
+ }
389
+ : null,
390
+ timestamp: new Date().toISOString(),
391
+ };
392
+
393
+ fs.writeFileSync(
394
+ path.join(OUTPUT_DIR, "result.json"),
395
+ JSON.stringify(result, null, 2)
396
+ );
397
+
398
+ console.log(result);
399
+ return result;
400
+ } finally {
401
+ await browser.close();
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Capture single page screenshot (backwards compatible helper).
407
+ */
408
+ export async function capturePage(browser, url, filename) {
409
+ return loadOrCaptureImage(browser, { type: "url", value: url }, filename);
410
+ }
411
+
412
+ /**
413
+ * Compares two URLs (backwards compatible CLI helper).
414
+ */
415
+ export async function comparePages(referenceUrl, actualUrl, options = {}) {
416
+ return compareVisuals({
417
+ reference: { type: "url", value: referenceUrl },
418
+ actual: { type: "url", value: actualUrl },
419
+ ...options,
420
+ });
421
+ }
422
+
423
+ const isDirectCli =
424
+ process.argv[1] &&
425
+ fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
426
+
427
+ if (isDirectCli) {
428
+ const [referenceUrl, actualUrl] = process.argv.slice(2);
429
+
430
+ if (!referenceUrl || !actualUrl) {
431
+ console.error('Usage: node compare.js "REFERENCE_URL" "ACTUAL_URL"');
432
+ process.exitCode = 1;
433
+ } else {
434
+ comparePages(referenceUrl, actualUrl).catch((error) => {
435
+ console.error(error);
436
+ process.exitCode = 1;
437
+ });
438
+ }
439
+ }
package/dom-compare.js ADDED
@@ -0,0 +1,263 @@
1
+ import { chromium } from "playwright";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { OUTPUT_DIR } from "./compare.js";
5
+
6
+ // Only typography and spacing are compared. Content (text, images) and colours are intentionally ignored.
7
+ const TYPOGRAPHY_PROPS = ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "wordSpacing"];
8
+ const SPACING_PROPS = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "marginTop", "marginBottom", "gap"];
9
+ const STYLE_PROPS = [...TYPOGRAPHY_PROPS, ...SPACING_PROPS];
10
+
11
+ const MAX_ELEMENTS = 4000;
12
+ const MAX_REPORTED = 600;
13
+
14
+ /**
15
+ * Runs inside the browser: collects every visible element with its geometry and computed styles.
16
+ */
17
+ function extractElements({ props, max, ignore }) {
18
+ const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "META", "LINK", "HEAD", "TEMPLATE", "BR", "PATH", "DEFS", "G"]);
19
+ const out = [];
20
+
21
+ function pathOf(el) {
22
+ const parts = [];
23
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
24
+ const tag = n.tagName.toLowerCase();
25
+ let idx = 1;
26
+ for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
27
+ if (s.tagName === n.tagName) idx++;
28
+ }
29
+ parts.unshift(`${tag}:nth-of-type(${idx})`);
30
+ }
31
+ return parts.join(" > ");
32
+ }
33
+
34
+ function label(el) {
35
+ let s = el.tagName.toLowerCase();
36
+ if (el.id) s += `#${el.id}`;
37
+ const cls = typeof el.className === "string" ? el.className.trim().split(/\s+/).filter(Boolean).slice(0, 2) : [];
38
+ if (cls.length) s += "." + cls.join(".");
39
+ return s;
40
+ }
41
+
42
+ function ownText(el) {
43
+ let t = "";
44
+ for (const c of el.childNodes) if (c.nodeType === 3) t += c.textContent;
45
+ return t.replace(/\s+/g, " ").trim().slice(0, 80);
46
+ }
47
+
48
+ const all = document.body.querySelectorAll("*");
49
+ for (const el of all) {
50
+ if (out.length >= max) break;
51
+ if (SKIP.has(el.tagName.toUpperCase())) continue;
52
+ if (el.closest("svg") && el.tagName.toLowerCase() !== "svg") continue;
53
+ if (ignore) {
54
+ try {
55
+ if (el.closest(ignore)) continue;
56
+ } catch {
57
+ // invalid selector: treat as no-op
58
+ }
59
+ }
60
+ const cs = getComputedStyle(el);
61
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
62
+ const r = el.getBoundingClientRect();
63
+ if (r.width === 0 && r.height === 0) continue;
64
+ const text = ownText(el);
65
+ if (!text && !/^(INPUT|BUTTON|TEXTAREA|SELECT)$/.test(el.tagName)) continue;
66
+
67
+ const styles = {};
68
+ for (const p of props) {
69
+ let v = cs[p];
70
+ if (p === "fontFamily") v = v.split(",")[0].replace(/["']/g, "").trim();
71
+ styles[p] = v;
72
+ }
73
+ out.push({
74
+ path: pathOf(el),
75
+ label: label(el),
76
+ tag: el.tagName.toLowerCase(),
77
+ text,
78
+ rect: {
79
+ x: Math.round((r.left + window.scrollX) * 10) / 10,
80
+ y: Math.round((r.top + window.scrollY) * 10) / 10,
81
+ width: Math.round(r.width * 10) / 10,
82
+ height: Math.round(r.height * 10) / 10,
83
+ },
84
+ styles,
85
+ });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ async function collect(browser, url, { viewport, fullPage, disableAnimations, ignore, shotName }) {
91
+ const page = await browser.newPage({ viewport, deviceScaleFactor: 1 });
92
+ try {
93
+ await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
94
+ await page.evaluate(() => document.fonts.ready);
95
+ if (fullPage) {
96
+ // Scroll through the page so lazy-loaded / scroll-triggered content is rendered before measuring.
97
+ await page.evaluate(async () => {
98
+ await new Promise((resolve) => {
99
+ let current = 0;
100
+ const timer = setInterval(() => {
101
+ window.scrollBy(0, 600);
102
+ current += 600;
103
+ if (current >= document.documentElement.scrollHeight) {
104
+ clearInterval(timer);
105
+ window.scrollTo(0, 0);
106
+ resolve();
107
+ }
108
+ }, 45);
109
+ });
110
+ });
111
+ await page.waitForTimeout(600);
112
+ }
113
+ if (disableAnimations) {
114
+ await page.addStyleTag({
115
+ content: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
116
+ });
117
+ }
118
+ const nodes = await page.evaluate(extractElements, { props: STYLE_PROPS, max: MAX_ELEMENTS, ignore });
119
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
120
+ await page.screenshot({ path: path.join(OUTPUT_DIR, shotName), fullPage: true });
121
+ const size = await page.evaluate(() => ({
122
+ width: document.documentElement.scrollWidth,
123
+ height: document.documentElement.scrollHeight,
124
+ }));
125
+ return { nodes, image: `visual-reports/${shotName}`, ...size };
126
+ } finally {
127
+ await page.close();
128
+ }
129
+ }
130
+
131
+ function pxNum(v) {
132
+ const m = /^(-?[\d.]+)px$/.exec(v);
133
+ return m ? Number(m[1]) : null;
134
+ }
135
+
136
+ function sameValue(prop, a, b, tolerance) {
137
+ if (a === b) return true;
138
+ const x = pxNum(a);
139
+ const y = pxNum(b);
140
+ if (x === null || y === null) return false;
141
+ // Typography is compared tightly; spacing gets the user-set tolerance.
142
+ const tol = TYPOGRAPHY_PROPS.includes(prop) ? 0.5 : tolerance;
143
+ return Math.abs(x - y) <= tol;
144
+ }
145
+
146
+ function diffNodes(a, b, tolerance) {
147
+ const diffs = [];
148
+ for (const p of STYLE_PROPS) {
149
+ if (!sameValue(p, a.styles[p], b.styles[p], tolerance)) {
150
+ diffs.push({
151
+ kind: TYPOGRAPHY_PROPS.includes(p) ? "typography" : "spacing",
152
+ prop: p,
153
+ ref: a.styles[p],
154
+ act: b.styles[p],
155
+ });
156
+ }
157
+ }
158
+ return diffs;
159
+ }
160
+
161
+ /**
162
+ * Pairs elements between two pages by DOM path (same structure), then by tag + nearest position for leftovers,
163
+ * and reports typography / spacing differences. Content differences are never reported.
164
+ */
165
+ export function matchAndDiff(refNodes, actNodes, tolerance = 2) {
166
+ const actByPath = new Map(actNodes.map((n) => [n.path, n]));
167
+ const usedAct = new Set();
168
+ const pairs = [];
169
+ const unmatchedRef = [];
170
+
171
+ for (const r of refNodes) {
172
+ const a = actByPath.get(r.path);
173
+ if (a && a.tag === r.tag && !usedAct.has(a)) {
174
+ usedAct.add(a);
175
+ pairs.push([r, a]);
176
+ } else {
177
+ unmatchedRef.push(r);
178
+ }
179
+ }
180
+
181
+ // Text is dynamic in the target, so leftovers are paired by tag and nearest position, never by text.
182
+ const MAX_DIST = 200;
183
+ const cands = [];
184
+ unmatchedRef.forEach((r, ri) => {
185
+ for (const a of actNodes) {
186
+ if (usedAct.has(a) || a.tag !== r.tag) continue;
187
+ const d = Math.abs(a.rect.x - r.rect.x) + Math.abs(a.rect.y - r.rect.y);
188
+ if (d <= MAX_DIST) cands.push({ ri, a, d });
189
+ }
190
+ });
191
+ cands.sort((x, y) => x.d - y.d);
192
+ const usedRef = new Set();
193
+ for (const c of cands) {
194
+ if (usedRef.has(c.ri) || usedAct.has(c.a)) continue;
195
+ usedRef.add(c.ri);
196
+ usedAct.add(c.a);
197
+ pairs.push([unmatchedRef[c.ri], c.a]);
198
+ }
199
+
200
+ const items = [];
201
+ let typographyDiffs = 0;
202
+ let spacingDiffs = 0;
203
+ for (const [r, a] of pairs) {
204
+ const diffs = diffNodes(r, a, tolerance);
205
+ if (!diffs.length) continue;
206
+ for (const d of diffs) d.kind === "typography" ? typographyDiffs++ : spacingDiffs++;
207
+ items.push({
208
+ label: a.label,
209
+ path: a.path,
210
+ text: (a.text || r.text || "").slice(0, 60),
211
+ refRect: r.rect,
212
+ actRect: a.rect,
213
+ y: a.rect.y,
214
+ diffs,
215
+ });
216
+ }
217
+ items.sort((x, y) => x.y - y.y);
218
+
219
+ return {
220
+ summary: {
221
+ referenceElements: refNodes.length,
222
+ actualElements: actNodes.length,
223
+ matched: pairs.length,
224
+ changedElements: items.length,
225
+ typographyDiffs,
226
+ spacingDiffs,
227
+ tolerance,
228
+ truncated: items.length > MAX_REPORTED,
229
+ },
230
+ items: items.slice(0, MAX_REPORTED),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Compares the DOM geometry and computed CSS of two live URLs.
236
+ */
237
+ export async function compareDom({
238
+ referenceUrl,
239
+ actualUrl,
240
+ viewport = { width: 1440, height: 900 },
241
+ tolerance = 2,
242
+ ignoreSelectors = [],
243
+ fullPage = true,
244
+ disableAnimations = true,
245
+ }) {
246
+ const browser = await chromium.launch({ headless: true });
247
+ try {
248
+ const base = { viewport, fullPage, disableAnimations, ignore: ignoreSelectors.join(",") };
249
+ const [ref, act] = await Promise.all([
250
+ collect(browser, referenceUrl, { ...base, shotName: "ui-reference.png" }),
251
+ collect(browser, actualUrl, { ...base, shotName: "ui-target.png" }),
252
+ ]);
253
+ const tol = Number(tolerance) >= 0 ? Number(tolerance) : 2;
254
+ const diff = matchAndDiff(ref.nodes, act.nodes, tol);
255
+ return {
256
+ ...diff,
257
+ reference: { image: ref.image, width: ref.width, height: ref.height },
258
+ target: { image: act.image, width: act.width, height: act.height },
259
+ };
260
+ } finally {
261
+ await browser.close();
262
+ }
263
+ }