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/CHANGELOG.md +58 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +132 -2
- package/bin/inspectra.mjs +68 -0
- package/client/index.d.mts +85 -0
- package/client/index.mjs +2 -0
- package/compare.js +439 -0
- package/dom-compare.js +263 -0
- package/examples/README.md +105 -0
- package/examples/figma-scan.json +12 -0
- package/examples/png-metadata.json +5 -0
- package/examples/runner-scan.json +7 -0
- package/node/analysis-worker.mjs +33 -0
- package/node/dev-config.mjs +18 -0
- package/node/event-stream.mjs +10 -0
- package/node/index.d.mts +114 -0
- package/node/index.mjs +100 -0
- package/node/job-manager.mjs +172 -0
- package/node/png-highlights.mjs +30 -0
- package/node/runner.mjs +135 -0
- package/package.json +136 -4
- package/pages.json +8 -0
- package/scan.js +27 -0
- package/scanner/README.md +70 -0
- package/scanner/accessibility.mjs +10 -0
- package/scanner/browser-snapshot.js +128 -0
- package/scanner/figma-snapshot.mjs +221 -0
- package/scanner/legacy-image-tools.cjs +14 -0
- package/scanner/ocr/local.mjs +52 -0
- package/scanner/ocr/vision.swift +29 -0
- package/scanner/png-regions.mjs +53 -0
- package/scanner/png-snapshot.mjs +192 -0
- package/scripts/check-build.mjs +12 -0
- package/server.js +361 -0
- package/ui/compare-2.html +946 -0
- package/ui/compare-3.html +1222 -0
- package/ui/compare.html +1408 -0
- package/ui/index.html +136 -0
- package/ui-check.js +1493 -0
package/ui-check.js
ADDED
|
@@ -0,0 +1,1493 @@
|
|
|
1
|
+
import imageTools from "./scanner/legacy-image-tools.cjs";
|
|
2
|
+
const { getPNG, getPixelmatch } = imageTools;
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { OUTPUT_DIR, normalizeImageSize } from "./compare.js";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
export const VIEWPORTS = {
|
|
16
|
+
desktop1920: { name: "Desktop Full HD", width: 1920, height: 1080 },
|
|
17
|
+
desktop: { name: "Desktop", width: 1440, height: 900 },
|
|
18
|
+
laptop1366: { name: "Laptop", width: 1366, height: 768 },
|
|
19
|
+
tablet1024: { name: "Tablet Landscape", width: 1024, height: 768 },
|
|
20
|
+
tablet: { name: "Tablet", width: 768, height: 1024 },
|
|
21
|
+
mobile375: { name: "Mobile", width: 375, height: 667 },
|
|
22
|
+
mobile320: { name: "Mobile Small", width: 320, height: 568 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const STYLE_PROPS = [
|
|
26
|
+
"fontFamily",
|
|
27
|
+
"fontSize",
|
|
28
|
+
"fontWeight",
|
|
29
|
+
"lineHeight",
|
|
30
|
+
"letterSpacing",
|
|
31
|
+
"wordSpacing",
|
|
32
|
+
"color",
|
|
33
|
+
"backgroundColor",
|
|
34
|
+
"borderRadius",
|
|
35
|
+
"borderTopWidth",
|
|
36
|
+
"paddingTop",
|
|
37
|
+
"paddingRight",
|
|
38
|
+
"paddingBottom",
|
|
39
|
+
"paddingLeft",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs inside the browser. Builds an inventory of UI components, finds layout problems and
|
|
44
|
+
* collects visible text. Never reports the *content* of dynamic copy/images, only structure.
|
|
45
|
+
*/
|
|
46
|
+
export function inspectPage({ props, ignore, snapshot = null }) {
|
|
47
|
+
const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
48
|
+
const skipTags = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK"]);
|
|
49
|
+
|
|
50
|
+
// Steppers / progress trackers have a different number of steps per flow, so they are never compared.
|
|
51
|
+
const AUTO_IGNORE =
|
|
52
|
+
"[class*=stepper i], [class*=step-i i], [class*=steps i], [class*=wizard i], [class*=progress i], [class*=breadcrumb i], [id*=stepper i], [role=progressbar], [aria-label*=stepper i], [aria-label*=progress i], [aria-label*=breadcrumb i]";
|
|
53
|
+
function isIgnored(el) {
|
|
54
|
+
try {
|
|
55
|
+
if (el.closest(AUTO_IGNORE)) return true;
|
|
56
|
+
} catch {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
if (!ignore) return false;
|
|
60
|
+
try {
|
|
61
|
+
return Boolean(el.closest(ignore));
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function clippedByAncestor(el) {
|
|
67
|
+
const r = el.getBoundingClientRect();
|
|
68
|
+
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
69
|
+
const cs = getComputedStyle(p);
|
|
70
|
+
const pr = p.getBoundingClientRect();
|
|
71
|
+
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return true;
|
|
72
|
+
if (/hidden|clip|auto|scroll/.test(cs.overflowX) && (r.right <= pr.left || r.left >= pr.right)) return true;
|
|
73
|
+
if (/hidden|clip|auto|scroll/.test(cs.overflowY) && (r.bottom <= pr.top || r.top >= pr.bottom)) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
function containedHorizontally(el) {
|
|
78
|
+
const r = el.getBoundingClientRect();
|
|
79
|
+
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
80
|
+
const cs = getComputedStyle(p), pr = p.getBoundingClientRect();
|
|
81
|
+
if (/hidden|clip|auto|scroll/.test(cs.overflowX) && pr.left >= -2 && pr.right <= document.documentElement.clientWidth + 2 && (r.left < pr.left || r.right > pr.right)) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
function visible(el) {
|
|
86
|
+
const cs = getComputedStyle(el);
|
|
87
|
+
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false;
|
|
88
|
+
const r = el.getBoundingClientRect();
|
|
89
|
+
return r.width > 2 && r.height > 2 && !clippedByAncestor(el);
|
|
90
|
+
}
|
|
91
|
+
function label(el) {
|
|
92
|
+
let s = el.tagName.toLowerCase();
|
|
93
|
+
if (el.id) s += `#${el.id}`;
|
|
94
|
+
const cls = typeof el.className === "string" ? el.className.trim().split(/\s+/).filter(Boolean).slice(0, 2) : [];
|
|
95
|
+
if (cls.length) s += "." + cls.join(".");
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
function ownText(el) {
|
|
99
|
+
let t = "";
|
|
100
|
+
for (const c of el.childNodes) if (c.nodeType === 3) t += c.textContent;
|
|
101
|
+
return t.replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
function categoryKey(el, cs, repeated = null) {
|
|
104
|
+
const text = ownText(el);
|
|
105
|
+
if (!text || text.length > 80 || !/^inline/.test(cs.display) || el.closest("h1,h2,h3,h4,h5,h6") ||
|
|
106
|
+
el.matches("h1,h2,h3,h4,h5,h6,a[href],button,input,select,textarea,[role=button]") ||
|
|
107
|
+
["transparent", "rgba(0, 0, 0, 0)"].includes(cs.backgroundColor)) return null;
|
|
108
|
+
if (repeated === null) {
|
|
109
|
+
repeated = false;
|
|
110
|
+
for (let group = el.parentElement; group && group !== document.body; group = group.parentElement) {
|
|
111
|
+
if (!/flex|grid/.test(getComputedStyle(group).display) && !group.matches("ul,ol,[role=list]")) continue;
|
|
112
|
+
const items = [...group.children].filter(visible);
|
|
113
|
+
const width = items[0]?.getBoundingClientRect().width;
|
|
114
|
+
if (items.length >= 2 && width && items.filter(item => Math.abs(item.getBoundingClientRect().width - width) < 3).length >= Math.max(2, items.length * 0.6)) {
|
|
115
|
+
repeated = true; break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return repeated ? text.normalize("NFKC").toLowerCase().replace(/\s+/gu, " ").trim() : null;
|
|
120
|
+
}
|
|
121
|
+
function fullText(el) {
|
|
122
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
|
|
123
|
+
}
|
|
124
|
+
function accName(el) {
|
|
125
|
+
return (
|
|
126
|
+
fullText(el) ||
|
|
127
|
+
el.getAttribute("aria-label") ||
|
|
128
|
+
el.getAttribute("title") ||
|
|
129
|
+
el.getAttribute("alt") ||
|
|
130
|
+
el.getAttribute("value") ||
|
|
131
|
+
""
|
|
132
|
+
).slice(0, 80);
|
|
133
|
+
}
|
|
134
|
+
// Box around the rendered glyphs only (a heading block is often much wider than its text).
|
|
135
|
+
// Built from the text runs, since a block child (<h2><p>…</p></h2>) would stretch a range over the whole row.
|
|
136
|
+
function rangeRect(node, exact = false, isVisible = null) {
|
|
137
|
+
const texts = [];
|
|
138
|
+
if (node.nodeType === 3) texts.push(node);
|
|
139
|
+
else for (let tw = document.createTreeWalker(node, NodeFilter.SHOW_TEXT), t = tw.nextNode(); t; t = tw.nextNode()) if (t.textContent.trim()) texts.push(t);
|
|
140
|
+
let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
|
|
141
|
+
const range = document.createRange();
|
|
142
|
+
for (const tn of texts) {
|
|
143
|
+
if (isVisible && !isVisible(tn.parentElement)) continue;
|
|
144
|
+
range.selectNodeContents(tn);
|
|
145
|
+
for (const q of range.getClientRects()) {
|
|
146
|
+
if (!q.width || !q.height) continue;
|
|
147
|
+
l = Math.min(l, q.left); t = Math.min(t, q.top); r = Math.max(r, q.right); b = Math.max(b, q.bottom);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (l === Infinity) return null;
|
|
151
|
+
const round = exact ? (v) => v : Math.round;
|
|
152
|
+
return { x: round(l + window.scrollX), y: round(t + window.scrollY), width: round(r - l), height: round(b - t) };
|
|
153
|
+
}
|
|
154
|
+
function rectOf(el, exact = false) {
|
|
155
|
+
const r = el.getBoundingClientRect();
|
|
156
|
+
const round = exact ? (v) => v : Math.round;
|
|
157
|
+
return {
|
|
158
|
+
x: round(r.left + window.scrollX),
|
|
159
|
+
y: round(r.top + window.scrollY),
|
|
160
|
+
width: round(r.width),
|
|
161
|
+
height: round(r.height),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Full scene extraction is opt-in; legacy comparison heuristics remain below.
|
|
165
|
+
if (snapshot) {
|
|
166
|
+
const evidence = (method, kind = "computed") => ({ sourceType: "url", kind, method });
|
|
167
|
+
const known = (value, method) => ({ status: "known", value, provenance: evidence(method), confidence: { score: 1 } });
|
|
168
|
+
const unknown = (method) => ({ status: "unknown", provenance: evidence(method, "unknown"), confidence: { score: null } });
|
|
169
|
+
const length = (value, property) => /^-?(?:\d+\.?\d*|\.\d+)px$/.test(value)
|
|
170
|
+
? known(Number.parseFloat(value), `getComputedStyle.${property}`)
|
|
171
|
+
: unknown(`Unresolved getComputedStyle.${property}: ${value}`);
|
|
172
|
+
const colors = new Map();
|
|
173
|
+
let colorContext;
|
|
174
|
+
function measuredColor(value) {
|
|
175
|
+
if (colors.has(value)) return colors.get(value);
|
|
176
|
+
const rgb = value.match(/^rgba?\(([^)]+)\)$/i), srgb = value.match(/^color\(srgb\s+([^)]+)\)$/i);
|
|
177
|
+
const parts = (rgb || srgb)?.[1].replace(/[,/]/g, " ").trim().split(/\s+/);
|
|
178
|
+
let result;
|
|
179
|
+
if (parts && [3, 4].includes(parts.length) && parts.every(p => /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?%?$/i.test(p))) {
|
|
180
|
+
const channels = parts.slice(0, 3).map(p => Number.parseFloat(p) * (p.endsWith("%") ? 255 / 100 : srgb ? 255 : 1));
|
|
181
|
+
const alpha = parts.length === 4 ? Number.parseFloat(parts[3]) / (parts[3].endsWith("%") ? 100 : 1) : 1;
|
|
182
|
+
if (channels.every(n => Number.isFinite(n) && n >= 0 && n <= 255) && Number.isFinite(alpha) && alpha >= 0 && alpha <= 1) {
|
|
183
|
+
result = known({ r: channels[0], g: channels[1], b: channels[2], a: alpha }, "getComputedStyle.color; exact sRGB components");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!result && CSS.supports("color", value)) {
|
|
187
|
+
// Convert modern CSS spaces through the local browser renderer. This is
|
|
188
|
+
// one color normalization, never screenshot/pixel comparison.
|
|
189
|
+
colorContext ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
190
|
+
if (colorContext) {
|
|
191
|
+
colorContext.clearRect(0, 0, 1, 1);
|
|
192
|
+
colorContext.fillStyle = value;
|
|
193
|
+
colorContext.fillRect(0, 0, 1, 1);
|
|
194
|
+
const [r, g, b, a] = colorContext.getImageData(0, 0, 1, 1).data;
|
|
195
|
+
result = known({ r, g, b, a: a / 255 }, "getComputedStyle.color; browser-rendered sRGB 8-bit normalization");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
result ??= unknown(`Unresolved getComputedStyle.color: ${value}`);
|
|
199
|
+
colors.set(value, result);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
const clean = (s) => String(s || "").replace(/\s+/g, " ").trim().slice(0, 2048);
|
|
203
|
+
function snapshotVisible(el) {
|
|
204
|
+
if (excluded.has(el.tagName) || ignore && el.closest(ignore)) return false;
|
|
205
|
+
const cs = getComputedStyle(el), r = el.getBoundingClientRect();
|
|
206
|
+
if (!r.width || !r.height || cs.visibility !== "visible" || clippedByAncestor(el)) return false;
|
|
207
|
+
for (let p = el; p; p = p.parentElement) {
|
|
208
|
+
const s = getComputedStyle(p);
|
|
209
|
+
if (s.display === "none" || Number(s.opacity) === 0 || s.contentVisibility === "hidden") return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
function visibleText(el) {
|
|
214
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), parts = [];
|
|
215
|
+
for (let tn = walker.nextNode(); tn; tn = walker.nextNode()) {
|
|
216
|
+
if (!tn.textContent.trim() || excluded.has(tn.parentElement.tagName)) continue;
|
|
217
|
+
if (ignore && tn.parentElement.closest(ignore)) continue;
|
|
218
|
+
if (snapshotVisible(tn.parentElement)) parts.push(tn.textContent);
|
|
219
|
+
}
|
|
220
|
+
return clean(parts.join(" "));
|
|
221
|
+
}
|
|
222
|
+
const nodes = [], relationships = [], details = {};
|
|
223
|
+
const ids = new Map(), siblingCounts = new Map(), labelRefs = new Map();
|
|
224
|
+
const roleOf = (el) => {
|
|
225
|
+
const explicit = el.getAttribute("role")?.trim().split(/\s+/)[0];
|
|
226
|
+
if (explicit && explicit.length <= 256) return explicit;
|
|
227
|
+
const tag = el.localName;
|
|
228
|
+
if (/^h[1-6]$/.test(tag)) return "heading";
|
|
229
|
+
if (tag === "a") return el.hasAttribute("href") ? "link" : "generic";
|
|
230
|
+
if (tag === "input") {
|
|
231
|
+
const type = el.type;
|
|
232
|
+
if (["button", "submit", "reset", "image"].includes(type)) return "button";
|
|
233
|
+
return ({ checkbox: "checkbox", radio: "radio", range: "slider", number: "spinbutton", search: "searchbox" })[type]
|
|
234
|
+
|| (["text", "email", "tel", "url", "password"].includes(type) ? "textbox" : "generic");
|
|
235
|
+
}
|
|
236
|
+
if (tag === "select") return el.multiple || el.size > 1 ? "listbox" : "combobox";
|
|
237
|
+
if (tag === "section" || tag === "form") return el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") || el.hasAttribute("title")
|
|
238
|
+
? (tag === "section" ? "region" : "form") : "generic";
|
|
239
|
+
if ((tag === "header" || tag === "footer") && el.closest("article, aside, main, nav, section")) return "generic";
|
|
240
|
+
return ({ body: "document", main: "main", nav: "navigation", header: "banner", footer: "contentinfo",
|
|
241
|
+
section: "region", article: "article", aside: "complementary", p: "paragraph", button: "button",
|
|
242
|
+
form: "form", textarea: "textbox", img: "img", svg: "img", video: "video", audio: "audio",
|
|
243
|
+
ul: "list", ol: "list", li: "listitem", table: "table", tr: "row", th: "columnheader", td: "cell",
|
|
244
|
+
fieldset: "group", progress: "progressbar", meter: "meter", dialog: "dialog" })[tag] || "generic";
|
|
245
|
+
};
|
|
246
|
+
// Structural paths identify nodes within a capture without CSS class names or page mutation.
|
|
247
|
+
const pathOf = (el) => {
|
|
248
|
+
if (el === document.body) return "body";
|
|
249
|
+
const parts = [];
|
|
250
|
+
for (let p = el; p && p !== document.body; p = p.parentElement) {
|
|
251
|
+
parts.push(Array.prototype.indexOf.call(p.parentElement.children, p));
|
|
252
|
+
}
|
|
253
|
+
return `body/${parts.reverse().join("/")}`;
|
|
254
|
+
};
|
|
255
|
+
const excluded = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK"]);
|
|
256
|
+
const candidates = [document.body, ...document.body.querySelectorAll("*")];
|
|
257
|
+
for (const el of candidates) {
|
|
258
|
+
if (excluded.has(el.tagName) || el.closest("svg") && el.localName !== "svg") continue;
|
|
259
|
+
if (ignore && el.closest(ignore)) continue;
|
|
260
|
+
const cs = getComputedStyle(el);
|
|
261
|
+
// Keep offscreen document content, but exclude hidden and fully clipped boxes.
|
|
262
|
+
if (!snapshotVisible(el)) continue;
|
|
263
|
+
if (nodes.length >= snapshot.maxNodes) throw new Error(`UISnapshot node limit exceeded (${snapshot.maxNodes})`);
|
|
264
|
+
const id = `n${nodes.length}`, semanticRole = roleOf(el);
|
|
265
|
+
const category = semanticRole === "generic" ? categoryKey(el, cs) : null;
|
|
266
|
+
const role = category ? "category-badge" : semanticRole;
|
|
267
|
+
ids.set(el, id);
|
|
268
|
+
const typography = { fontFamily: cs.fontFamily.length <= 256 ? known(cs.fontFamily, "getComputedStyle.fontFamily") : unknown("fontFamily exceeds schema limit") };
|
|
269
|
+
for (const prop of ["fontSize", "lineHeight", "letterSpacing", "wordSpacing"]) typography[prop] = length(cs[prop], prop);
|
|
270
|
+
typography.fontWeight = /^\d+(\.\d+)?$/.test(cs.fontWeight)
|
|
271
|
+
? known(Number(cs.fontWeight), "getComputedStyle.fontWeight") : unknown(`Unresolved fontWeight: ${cs.fontWeight}`);
|
|
272
|
+
typography.color = structuredClone(measuredColor(cs.color));
|
|
273
|
+
const layout = { display: known(cs.display, "getComputedStyle.display") };
|
|
274
|
+
for (const prop of ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "marginTop", "marginRight", "marginBottom", "marginLeft", "rowGap", "columnGap"]) layout[prop] = length(cs[prop], prop);
|
|
275
|
+
const text = snapshot.includeText ? visibleText(el) : "";
|
|
276
|
+
const bounds = rectOf(el, true);
|
|
277
|
+
if (Object.values(bounds).some((v) => !Number.isFinite(v) || Math.abs(v) > 1e9) || Math.abs(bounds.x + bounds.width) > 1e9 || Math.abs(bounds.y + bounds.height) > 1e9) {
|
|
278
|
+
throw new Error("Rendered bounds exceed the Shivax coordinate range");
|
|
279
|
+
}
|
|
280
|
+
nodes.push({ id, role, ...(text ? { text } : {}), bounds: known(bounds, "getBoundingClientRect + window.scroll"), typography, layout });
|
|
281
|
+
let parent = el.parentElement;
|
|
282
|
+
while (parent && !ids.has(parent)) parent = parent.parentElement;
|
|
283
|
+
const parentId = ids.get(parent) || null;
|
|
284
|
+
if (parentId) relationships.push({ from: parentId, to: id, type: "parent-child", provenance: evidence("DOM nearest captured ancestor"), confidence: { score: 1 } });
|
|
285
|
+
const labelledBy = (el.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
|
|
286
|
+
labelRefs.set(id, labelledBy.map((key) => document.getElementById(key)));
|
|
287
|
+
const name = clean(labelledBy.map((key) => document.getElementById(key)?.textContent || "").join(" ")
|
|
288
|
+
|| el.getAttribute("aria-label") || [...(el.labels || [])].map((l) => l.innerText).join(" ")
|
|
289
|
+
|| el.getAttribute("alt") || el.getAttribute("title") || (role === "button" ? el.innerText || el.getAttribute("value") : ""));
|
|
290
|
+
const glyphBounds = snapshot.includeText ? rangeRect(el, true, snapshotVisible) : null;
|
|
291
|
+
details[id] = {
|
|
292
|
+
tag: el.localName, path: pathOf(el), parentId,
|
|
293
|
+
...(snapshot.includeText && category ? { category: { status: "known", value: category,
|
|
294
|
+
provenance: evidence("Painted inline text inside a repeated content group; inferred category identity", "estimated"), confidence: { score: 0.8 } } } : {}),
|
|
295
|
+
siblingIndex: siblingCounts.get(parentId) || 0,
|
|
296
|
+
domSiblingIndex: el.parentElement ? Array.prototype.indexOf.call(el.parentElement.children, el) : 0,
|
|
297
|
+
...(snapshot.includeText && name ? { accessibleName: { status: "known", value: name, provenance: evidence("DOM label attributes/text (partial accessible-name algorithm)", "estimated"), confidence: { score: 0.8 } } } : {}),
|
|
298
|
+
textBounds: glyphBounds ? known(glyphBounds, "Range.getClientRects union") : unknown("No visible text glyphs"),
|
|
299
|
+
flexGrid: Object.fromEntries(["flexDirection", "flexWrap", "flexGrow", "flexShrink", "flexBasis", "order", "justifyContent", "alignItems", "alignSelf", "alignContent", "gridTemplateColumns", "gridTemplateRows", "gridAutoFlow", "gridColumn", "gridRow"].map((p) => [p, known(cs[p], `getComputedStyle.${p}`)])),
|
|
300
|
+
scroll: known({ x: el.scrollLeft, y: el.scrollTop, width: el.scrollWidth, height: el.scrollHeight }, "DOM element scroll metrics"),
|
|
301
|
+
...(el.localName === "img" ? { media: { kind: "image", loaded: el.complete && el.naturalWidth > 0, naturalWidth: el.naturalWidth, naturalHeight: el.naturalHeight } } : {}),
|
|
302
|
+
...(["video", "audio", "svg", "canvas"].includes(el.localName) ? { media: { kind: el.localName } } : {}),
|
|
303
|
+
};
|
|
304
|
+
siblingCounts.set(parentId, (siblingCounts.get(parentId) || 0) + 1);
|
|
305
|
+
}
|
|
306
|
+
for (const [from, labels] of labelRefs) {
|
|
307
|
+
for (const to of new Set(labels.map((el) => ids.get(el)).filter((id) => id && id !== from))) {
|
|
308
|
+
relationships.push({ from, to, type: "labelled-by", provenance: evidence("DOM.aria-labelledby"), confidence: { score: 1 } });
|
|
309
|
+
}
|
|
310
|
+
if (relationships.length > 40000) throw new Error("UISnapshot relationship limit exceeded (40000)");
|
|
311
|
+
}
|
|
312
|
+
const previous = new Map();
|
|
313
|
+
for (const node of nodes) {
|
|
314
|
+
const d = details[node.id], prev = previous.get(d.parentId);
|
|
315
|
+
d.previousSiblingId = prev?.id || null;
|
|
316
|
+
d.nextSiblingId = null;
|
|
317
|
+
if (prev) {
|
|
318
|
+
details[prev.id].nextSiblingId = node.id;
|
|
319
|
+
const a = prev.bounds.value, b = node.bounds.value;
|
|
320
|
+
d.gapFromPrevious = known({ horizontal: b.x - a.x - a.width, vertical: b.y - a.y - a.height }, "Signed edge distances from getBoundingClientRect");
|
|
321
|
+
}
|
|
322
|
+
previous.set(d.parentId, node);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
snapshot: { version: 1, sourceType: "url", scope: snapshot.scope,
|
|
326
|
+
viewport: { width: innerWidth, height: innerHeight, devicePixelRatio },
|
|
327
|
+
coordinates: { unit: "css-px", unitsPerCssPixel: 1, origin: "document" }, nodes, relationships },
|
|
328
|
+
metadata: { nodes: details, scroll: { x: scrollX, y: scrollY, width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight },
|
|
329
|
+
assets: { fonts: document.fonts.status, failedFonts: [...document.fonts].filter((f) => f.status === "error").map((f) => f.family),
|
|
330
|
+
failedImages: [...document.images].filter((i) => !i.complete || i.currentSrc && i.naturalWidth === 0).length } },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// Browsers report colours in the format they were written in (oklch, lab, hex...). The same colour written two
|
|
334
|
+
// ways must compare equal, so every colour is converted to rgb()/rgba() by painting it on a 1px canvas.
|
|
335
|
+
const colorCtx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
336
|
+
const colorCache = new Map();
|
|
337
|
+
function toRgb(v) {
|
|
338
|
+
if (!v || /^rgba?\(/.test(v) || v === "none" || v === "transparent") return v;
|
|
339
|
+
if (colorCache.has(v)) return colorCache.get(v);
|
|
340
|
+
let out = v;
|
|
341
|
+
try {
|
|
342
|
+
colorCtx.clearRect(0, 0, 1, 1);
|
|
343
|
+
colorCtx.fillStyle = "#000";
|
|
344
|
+
colorCtx.fillStyle = v;
|
|
345
|
+
colorCtx.fillRect(0, 0, 1, 1);
|
|
346
|
+
const [r, g, b, a] = colorCtx.getImageData(0, 0, 1, 1).data;
|
|
347
|
+
out = a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 100) / 100})`;
|
|
348
|
+
} catch {
|
|
349
|
+
// keep the original string
|
|
350
|
+
}
|
|
351
|
+
colorCache.set(v, out);
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
const COLOR_PROPS = new Set(["color", "backgroundColor", "iconColor", "iconFill", "borderTopColor"]);
|
|
355
|
+
function styleOf(el) {
|
|
356
|
+
const cs = getComputedStyle(el);
|
|
357
|
+
const o = {};
|
|
358
|
+
for (const p of props) {
|
|
359
|
+
let v = cs[p];
|
|
360
|
+
if (p === "fontFamily") v = v.split(",")[0].replace(/["']/g, "").trim();
|
|
361
|
+
if (COLOR_PROPS.has(p)) v = toRgb(v);
|
|
362
|
+
o[p] = v;
|
|
363
|
+
}
|
|
364
|
+
return o;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const all = [...document.body.querySelectorAll("*")].filter(
|
|
368
|
+
(el) => !skipTags.has(el.tagName) && !isIgnored(el) && visible(el)
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
// ---- Inventory ----
|
|
372
|
+
const count = (sel) => all.filter((el) => el.matches(sel)).length;
|
|
373
|
+
const counts = {
|
|
374
|
+
header: count("header, [role=banner]"),
|
|
375
|
+
nav: count("nav, [role=navigation]"),
|
|
376
|
+
main: count("main, [role=main]"),
|
|
377
|
+
footer: count("footer, [role=contentinfo]"),
|
|
378
|
+
aside: count("aside, [role=complementary]"),
|
|
379
|
+
sections: count("section, [role=region]"),
|
|
380
|
+
forms: count("form"),
|
|
381
|
+
inputs: count("input:not([type=hidden]), textarea, select"),
|
|
382
|
+
images: count("img, picture, [role=img]:not(svg)"),
|
|
383
|
+
icons: count("svg"),
|
|
384
|
+
lists: count("ul, ol"),
|
|
385
|
+
tables: count("table"),
|
|
386
|
+
videos: count("video, iframe"),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const buttonSel =
|
|
390
|
+
"button, [role=button], input[type=button], input[type=submit]";
|
|
391
|
+
const isButton = (el) => {
|
|
392
|
+
if (el.matches(buttonSel)) return true;
|
|
393
|
+
if (!el.matches("a[href]")) return false;
|
|
394
|
+
const cs = getComputedStyle(el);
|
|
395
|
+
const painted = !["transparent", "rgba(0, 0, 0, 0)"].includes(cs.backgroundColor) ||
|
|
396
|
+
(parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== "none");
|
|
397
|
+
return painted && (parseFloat(cs.paddingLeft) > 0 || parseFloat(cs.paddingTop) > 0);
|
|
398
|
+
};
|
|
399
|
+
const buttons = all
|
|
400
|
+
.filter(isButton)
|
|
401
|
+
.map((el) => ({ label: accName(el), rect: rectOf(el), iconOnly: !fullText(el), el }));
|
|
402
|
+
const navLinks = all
|
|
403
|
+
.filter((el) => el.matches("nav a, [role=navigation] a, header a"))
|
|
404
|
+
.map((el) => ({ label: accName(el), rect: rectOf(el), el }));
|
|
405
|
+
const headings = all
|
|
406
|
+
.filter((el) => /^H[1-6]$/.test(el.tagName))
|
|
407
|
+
.map((el) => ({ level: Number(el.tagName[1]), label: fullText(el).slice(0, 80), rect: rectOf(el), el }));
|
|
408
|
+
|
|
409
|
+
counts.buttons = buttons.length;
|
|
410
|
+
counts.navLinks = navLinks.length;
|
|
411
|
+
counts.headings = headings.length;
|
|
412
|
+
|
|
413
|
+
// Page text (for the expected-checklist lookup)
|
|
414
|
+
const labelBag = new Set();
|
|
415
|
+
for (const el of all) {
|
|
416
|
+
if (el.matches("a, button, h1, h2, h3, h4, h5, h6, label, legend, summary, th, [role=button], [role=tab]")) {
|
|
417
|
+
labelBag.add(norm(fullText(el)));
|
|
418
|
+
}
|
|
419
|
+
const aria = el.getAttribute("aria-label");
|
|
420
|
+
if (aria) labelBag.add(norm(aria));
|
|
421
|
+
const alt = el.getAttribute("alt");
|
|
422
|
+
if (alt) labelBag.add(norm(alt));
|
|
423
|
+
const ph = el.getAttribute("placeholder");
|
|
424
|
+
if (ph) labelBag.add(norm(ph));
|
|
425
|
+
}
|
|
426
|
+
const bodyText = norm(document.body.innerText);
|
|
427
|
+
|
|
428
|
+
// ---- Styles of components, grouped by kind (text is dynamic, so it is never used to match them) ----
|
|
429
|
+
const styled = [];
|
|
430
|
+
for (const b of buttons) styled.push({ kind: "button", label: b.label, rect: b.rect, styles: styleOf(b.el) });
|
|
431
|
+
for (const h of headings) styled.push({ kind: `h${h.level}`, label: h.label, rect: h.rect, styles: styleOf(h.el) });
|
|
432
|
+
for (const n of navLinks) styled.push({ kind: "nav link", label: n.label, rect: n.rect, styles: styleOf(n.el) });
|
|
433
|
+
|
|
434
|
+
// ---- Page sections: the top-level blocks of the page (header, hero, services, footer...) ----
|
|
435
|
+
const isVis = (el) => visible(el) && !isIgnored(el) && !skipTags.has(el.tagName);
|
|
436
|
+
const kidsOf = (el) => [...el.children].filter(isVis);
|
|
437
|
+
const unwrap = (el) => {
|
|
438
|
+
let cur = el;
|
|
439
|
+
for (let i = 0; i < 6; i++) {
|
|
440
|
+
const ks = kidsOf(cur);
|
|
441
|
+
if (ks.length !== 1) break;
|
|
442
|
+
cur = ks[0];
|
|
443
|
+
}
|
|
444
|
+
return cur;
|
|
445
|
+
};
|
|
446
|
+
const blocks = [];
|
|
447
|
+
for (const k of kidsOf(unwrap(document.body))) {
|
|
448
|
+
if (k.matches("main, [role=main]")) blocks.push(...kidsOf(unwrap(k)));
|
|
449
|
+
else blocks.push(k);
|
|
450
|
+
}
|
|
451
|
+
// Layout containers inside a section (the section itself, rows, columns, cards) with their spacing,
|
|
452
|
+
// keyed by child-index path so the same container can be paired between reference and target.
|
|
453
|
+
const SPACE_PROPS = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "marginTop", "marginBottom", "rowGap", "columnGap"];
|
|
454
|
+
function spacingNodes(root) {
|
|
455
|
+
const out = [];
|
|
456
|
+
const walk = (el, path, depth, uniq, chain) => {
|
|
457
|
+
if (out.length >= 600) return;
|
|
458
|
+
if (depth > 0) {
|
|
459
|
+
// Visually hidden (sr-only) and hairline elements are not visible UI.
|
|
460
|
+
const br = el.getBoundingClientRect();
|
|
461
|
+
if (br.width <= 2 || br.height <= 2) return;
|
|
462
|
+
}
|
|
463
|
+
const cs = getComputedStyle(el);
|
|
464
|
+
const visibleKids = kidsOf(el);
|
|
465
|
+
// Media and empty image/decorative wrappers carry CMS content, not text UI.
|
|
466
|
+
if (depth > 0 && !fullText(el) && (el.matches("img, picture, svg, video") || el.querySelector("img, picture, svg, video") || cs.position === "absolute")) return;
|
|
467
|
+
// Collapse neutral wrappers so moving CSS onto an equivalent wrapper does not
|
|
468
|
+
// create a second visual component at the same rectangle.
|
|
469
|
+
const neutralWrapper = depth > 0 && visibleKids.length === 1 && !ownText(el) &&
|
|
470
|
+
!/^H[1-6]$/.test(el.tagName) &&
|
|
471
|
+
["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "marginTop", "marginBottom"].every(p => parseFloat(cs[p]) === 0) &&
|
|
472
|
+
["transparent", "rgba(0, 0, 0, 0)"].includes(cs.backgroundColor) && parseFloat(cs.borderTopWidth) === 0 &&
|
|
473
|
+
Math.abs(el.getBoundingClientRect().width - visibleKids[0].getBoundingClientRect().width) <= 2;
|
|
474
|
+
// How many items the parent and grandparent hold: a structural fingerprint used instead of text for pairing.
|
|
475
|
+
const shape = [el.parentElement, el.parentElement && el.parentElement.parentElement].map((p) => (p ? kidsOf(p).length : 0)).join(",");
|
|
476
|
+
let repeatCount = null, repeatItem = null;
|
|
477
|
+
for (let group = el.parentElement; group && root.contains(group); group = group.parentElement) {
|
|
478
|
+
const gs = getComputedStyle(group), items = kidsOf(group);
|
|
479
|
+
if ((!/flex|grid/.test(gs.display) && !group.matches("ul,ol,[role=list]")) || items.length < 2) continue;
|
|
480
|
+
const sample = items[0], sw = sample.getBoundingClientRect().width;
|
|
481
|
+
// Cards in a grid of 3+ may differ by an optional badge or price tag, so their child counts can vary slightly.
|
|
482
|
+
const sameShape = (k) => kidsOf(k).length === kidsOf(sample).length ||
|
|
483
|
+
(items.length >= 3 && k.tagName === sample.tagName && Math.abs(kidsOf(k).length - kidsOf(sample).length) <= 2);
|
|
484
|
+
if (sw && items.filter(k => Math.abs(k.getBoundingClientRect().width - sw) < 3 && sameShape(k)).length >= Math.max(2, items.length * 0.6)) {
|
|
485
|
+
repeatCount = items.length; repeatItem = items.find(item => item === el || item.contains(el)); break;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const interactive = el.matches("a[href], button, [role=button], input, select, textarea");
|
|
489
|
+
const list = el.closest("ul, ol");
|
|
490
|
+
const listSize = list ? kidsOf(list).length : null;
|
|
491
|
+
const nm = () => {
|
|
492
|
+
const t = ownText(el).slice(0, 28);
|
|
493
|
+
return el.tagName.toLowerCase() + (t ? ` “${t}”` : "");
|
|
494
|
+
};
|
|
495
|
+
const hasText = ownText(el).length > 0 || (/^H[1-6]$/.test(el.tagName) && fullText(el).length > 0);
|
|
496
|
+
const isBtn = isButton(el);
|
|
497
|
+
const visualBox =
|
|
498
|
+
(cs.backgroundColor !== "rgba(0, 0, 0, 0)" && cs.backgroundColor !== "transparent") ||
|
|
499
|
+
(parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== "none");
|
|
500
|
+
const parentStyle = el.parentElement ? getComputedStyle(el.parentElement) : null;
|
|
501
|
+
// A plain block wrapper can constrain the content width without padding or flex/grid.
|
|
502
|
+
const blockContainer = !hasText && kidsOf(el).length > 0 && cs.display === "block";
|
|
503
|
+
const layoutBox = blockContainer || /flex|grid/.test(cs.display) || SPACE_PROPS.some((p) => /^[1-9]/.test(cs[p]) && p.startsWith("padding"));
|
|
504
|
+
const contentSized = /^inline/.test(cs.display) ||
|
|
505
|
+
(parentStyle && /flex/.test(parentStyle.display) && Number(cs.flexGrow) === 0 && cs.flexBasis === "auto");
|
|
506
|
+
if (/^(IMG|SVG|PICTURE|VIDEO)$/i.test(el.tagName)) {
|
|
507
|
+
// Photos, videos and image markup are dynamic content; only SVG icons are UI.
|
|
508
|
+
if (el.tagName.toLowerCase() !== "svg") return;
|
|
509
|
+
const r = el.getBoundingClientRect();
|
|
510
|
+
const styles = { iconWidth: `${Math.round(r.width)}px`, iconHeight: `${Math.round(r.height)}px` };
|
|
511
|
+
if (/^svg$/i.test(el.tagName)) {
|
|
512
|
+
styles.iconColor = toRgb(cs.color);
|
|
513
|
+
styles.iconFill = toRgb(cs.fill);
|
|
514
|
+
}
|
|
515
|
+
out.push({ path, label: label(el), name: nm(), shape, listSize, repeatCount, interactive, tagPath: chain, tag: el.tagName.toLowerCase(), rect: rectOf(el), styles, widthPct: null, kind: "media", uniq });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (!neutralWrapper && (depth === 0 || layoutBox || hasText || isBtn || visualBox || el.matches("input, textarea, select"))) {
|
|
519
|
+
const styles = {};
|
|
520
|
+
const accentRects = [];
|
|
521
|
+
let textRect = null;
|
|
522
|
+
for (const p of SPACE_PROPS) styles[p] = cs[p];
|
|
523
|
+
for (const p of ["marginTop", "marginBottom"]) {
|
|
524
|
+
if (el.computedStyleMap?.().get(p.replace(/[A-Z]/g, m => "-" + m.toLowerCase()))?.toString() === "auto") styles[p] = "auto";
|
|
525
|
+
}
|
|
526
|
+
const itemCount = visibleKids.length + (ownText(el) ? 1 : 0);
|
|
527
|
+
// Gaps only have an effect between rendered flex/grid items.
|
|
528
|
+
if (!/flex|grid/.test(cs.display) || itemCount < 2) {
|
|
529
|
+
styles.rowGap = "0px"; styles.columnGap = "0px";
|
|
530
|
+
} else if (/flex/.test(cs.display) && cs.flexWrap === "nowrap") {
|
|
531
|
+
styles[cs.flexDirection.startsWith("column") ? "columnGap" : "rowGap"] = "0px";
|
|
532
|
+
}
|
|
533
|
+
if (/^H[1-6]$/.test(el.tagName)) { styles.rowGap = "0px"; styles.columnGap = "0px"; }
|
|
534
|
+
if (hasText) {
|
|
535
|
+
// Padding/margins may move to a sole-child rich-text wrapper without
|
|
536
|
+
// changing the rendered component. Compare the combined inset.
|
|
537
|
+
for (let parent = el.parentElement; parent && parent !== root && kidsOf(parent).length === 1; parent = parent.parentElement) {
|
|
538
|
+
const ps = getComputedStyle(parent);
|
|
539
|
+
for (const p of ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "marginTop", "marginBottom"]) {
|
|
540
|
+
if (styles[p] !== "auto") styles[p] = `${(parseFloat(styles[p]) || 0) + (parseFloat(ps[p]) || 0)}px`;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
for (const p of ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "wordSpacing", "color"]) {
|
|
544
|
+
styles[p] = p === "fontFamily" ? cs[p].split(",")[0].replace(/["']/g, "").trim() : p === "color" ? toRgb(cs[p]) : cs[p];
|
|
545
|
+
}
|
|
546
|
+
if (/^H[1-6]$/.test(el.tagName)) {
|
|
547
|
+
// Headings are not walked into, so a highlighted word (<span style="color:pink">) is recorded here.
|
|
548
|
+
const accents = new Set();
|
|
549
|
+
const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
550
|
+
for (let tn = tw.nextNode(); tn; tn = tw.nextNode()) {
|
|
551
|
+
if (!tn.textContent.trim() || !tn.parentElement) continue;
|
|
552
|
+
const c = toRgb(getComputedStyle(tn.parentElement).color);
|
|
553
|
+
if (c !== styles.color) { accents.add(c); const ar = rangeRect(tn); if (ar) accentRects.push(ar); }
|
|
554
|
+
}
|
|
555
|
+
styles.accentColor = accents.size ? [...accents].sort().join(" + ") : "none";
|
|
556
|
+
}
|
|
557
|
+
textRect = rangeRect(el);
|
|
558
|
+
}
|
|
559
|
+
if ((layoutBox && !hasText) || visualBox) {
|
|
560
|
+
styles.backgroundColor = toRgb(cs.backgroundColor);
|
|
561
|
+
styles.borderRadius = cs.borderRadius;
|
|
562
|
+
}
|
|
563
|
+
const sr = depth === 0 ? { width: document.documentElement.clientWidth } : root.getBoundingClientRect();
|
|
564
|
+
// Width as a share of the section, only for block-level layout containers (text boxes shrink to content).
|
|
565
|
+
const widthPct = layoutBox && !hasText && !contentSized && sr.width ? Math.round((el.getBoundingClientRect().width / sr.width) * 1000) / 10 : null;
|
|
566
|
+
const gapRects = { rowGap: [], columnGap: [] };
|
|
567
|
+
const renderedGaps = { rowGap: null, columnGap: null };
|
|
568
|
+
const distributedGap = /space-between|space-around|space-evenly/.test(cs.justifyContent);
|
|
569
|
+
const flow = visibleKids.filter(k => !["absolute", "fixed"].includes(getComputedStyle(k).position)).map(rectOf);
|
|
570
|
+
for (let i = 0; i < flow.length; i++) for (let j = i + 1; j < flow.length; j++) {
|
|
571
|
+
const a = flow[i], b = flow[j];
|
|
572
|
+
const iy = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
|
|
573
|
+
const ix = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
|
|
574
|
+
const dx = b.x - a.x - a.width, dy = b.y - a.y - a.height;
|
|
575
|
+
if (iy > 0 && dx >= 0) renderedGaps.columnGap = Math.min(renderedGaps.columnGap ?? Infinity, dx);
|
|
576
|
+
if (ix > 0 && dy >= 0) renderedGaps.rowGap = Math.min(renderedGaps.rowGap ?? Infinity, dy);
|
|
577
|
+
if (iy > 0 && dx > 0 && dx <= (parseFloat(cs.columnGap) || 0) + 2 && gapRects.columnGap.length < 3)
|
|
578
|
+
gapRects.columnGap.push({ x: a.x + a.width, y: Math.max(a.y, b.y), width: dx, height: iy });
|
|
579
|
+
if (ix > 0 && dy > 0 && dy <= (parseFloat(cs.rowGap) || 0) + 2 && gapRects.rowGap.length < 3)
|
|
580
|
+
gapRects.rowGap.push({ x: Math.max(a.x, b.x), y: a.y + a.height, width: ix, height: dy });
|
|
581
|
+
}
|
|
582
|
+
const category = categoryKey(el, cs, Boolean(repeatCount));
|
|
583
|
+
const recordHeading = category ? repeatItem?.querySelector("h1,h2,h3,h4,h5,h6") : null;
|
|
584
|
+
out.push({ path, label: label(el), name: nm(), textLen: (ownText(el) || fullText(el)).length,
|
|
585
|
+
categoryKey: category,
|
|
586
|
+
recordKey: recordHeading ? fullText(recordHeading).normalize("NFKC").toLowerCase().replace(/\s+/gu, " ").trim() : null,
|
|
587
|
+
shape, listSize, repeatCount, interactive, tagPath: chain, tag: el.tagName.toLowerCase(), rect: rectOf(el), textRect, accentRects, styles, widthPct, gapRects, renderedGaps, distributedGap, kind: isBtn ? "button" : el.matches("input, textarea, select") ? "input" : hasText ? "text" : "box", uniq, visualBox });
|
|
588
|
+
}
|
|
589
|
+
if (depth >= 12) return;
|
|
590
|
+
if (/^H[1-6]$/.test(el.tagName)) return;
|
|
591
|
+
const kids = kidsOf(el);
|
|
592
|
+
const seen = {};
|
|
593
|
+
kids.forEach((k) => (seen[k.tagName] = (seen[k.tagName] || 0) + 1));
|
|
594
|
+
kids.forEach((k, i) => walk(k, `${path}/${i}`, depth + 1, seen[k.tagName] === 1, `${chain}>${k.tagName.toLowerCase()}`));
|
|
595
|
+
};
|
|
596
|
+
walk(root, "", 0, true, root.tagName.toLowerCase());
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
const cnt = (el, sel) => [...el.querySelectorAll(sel)].filter(isVis).length;
|
|
600
|
+
const roleOf = (el) =>
|
|
601
|
+
el.matches("header, [role=banner]") ? "header"
|
|
602
|
+
: el.matches("footer, [role=contentinfo]") ? "footer"
|
|
603
|
+
: el.matches("nav, [role=navigation]") ? "nav"
|
|
604
|
+
: "block";
|
|
605
|
+
const sections = blocks
|
|
606
|
+
.filter((el) => {
|
|
607
|
+
const r = el.getBoundingClientRect();
|
|
608
|
+
return getComputedStyle(el).position !== "fixed" && r.height >= 40 && r.width >= document.documentElement.clientWidth * 0.4;
|
|
609
|
+
})
|
|
610
|
+
.slice(0, 40)
|
|
611
|
+
.map((el, index) => {
|
|
612
|
+
const h = el.querySelector("h1,h2,h3,h4,h5,h6");
|
|
613
|
+
const heading = h && isVis(h) ? fullText(h).slice(0, 80) : "";
|
|
614
|
+
return {
|
|
615
|
+
index,
|
|
616
|
+
role: roleOf(el),
|
|
617
|
+
label: label(el),
|
|
618
|
+
heading,
|
|
619
|
+
counts: {
|
|
620
|
+
buttons: [el, ...el.querySelectorAll("*")].filter((n) => isVis(n) && isButton(n)).length,
|
|
621
|
+
images: cnt(el, "img, picture, [role=img]:not(svg)"),
|
|
622
|
+
links: cnt(el, "a[href]"),
|
|
623
|
+
headings: cnt(el, "h1,h2,h3,h4,h5,h6"),
|
|
624
|
+
inputs: cnt(el, "input:not([type=hidden]), textarea, select"),
|
|
625
|
+
},
|
|
626
|
+
rect: rectOf(el),
|
|
627
|
+
spacing: spacingNodes(el),
|
|
628
|
+
};
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// ---- Layout problems ----
|
|
632
|
+
const vw = document.documentElement.clientWidth;
|
|
633
|
+
const layout = [];
|
|
634
|
+
const sw = document.documentElement.scrollWidth;
|
|
635
|
+
if (sw > vw + 1) {
|
|
636
|
+
const culpritEls = all
|
|
637
|
+
.filter((el) => el.getBoundingClientRect().right > vw + 1 && getComputedStyle(el).position !== "fixed")
|
|
638
|
+
.slice(0, 5);
|
|
639
|
+
const culprits = culpritEls.map(label);
|
|
640
|
+
layout.push({
|
|
641
|
+
severity: "high",
|
|
642
|
+
type: "Horizontal overflow",
|
|
643
|
+
detail: `Page is ${sw}px wide in a ${vw}px viewport (horizontal scrollbar).`,
|
|
644
|
+
elements: culprits,
|
|
645
|
+
rects: culpritEls.map(rectOf),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
for (const el of all) {
|
|
650
|
+
if (layout.length > 120) break;
|
|
651
|
+
const cs = getComputedStyle(el);
|
|
652
|
+
const hasText = ownText(el).length > 0;
|
|
653
|
+
|
|
654
|
+
if (hasText && (cs.overflowX === "hidden" || cs.overflowX === "clip") && cs.textOverflow !== "ellipsis") {
|
|
655
|
+
if (el.scrollWidth > el.clientWidth + 2) {
|
|
656
|
+
layout.push({
|
|
657
|
+
severity: "medium",
|
|
658
|
+
type: "Clipped text",
|
|
659
|
+
detail: `Text is cut off horizontally (${el.scrollWidth}px content in ${el.clientWidth}px box).`,
|
|
660
|
+
elements: [label(el)],
|
|
661
|
+
rects: [rectOf(el)],
|
|
662
|
+
text: ownText(el).slice(0, 60),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (hasText && (cs.overflowY === "hidden" || cs.overflowY === "clip") && el.scrollHeight > el.clientHeight + 4 && cs.webkitLineClamp === "none") {
|
|
667
|
+
layout.push({
|
|
668
|
+
severity: "medium",
|
|
669
|
+
type: "Clipped text",
|
|
670
|
+
detail: `Text is cut off vertically (${el.scrollHeight}px content in ${el.clientHeight}px box).`,
|
|
671
|
+
elements: [label(el)],
|
|
672
|
+
rects: [rectOf(el)],
|
|
673
|
+
text: ownText(el).slice(0, 60),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if (el.tagName === "IMG" && el.complete && el.naturalWidth === 0 && el.currentSrc) {
|
|
677
|
+
layout.push({
|
|
678
|
+
severity: "medium",
|
|
679
|
+
type: "Broken image",
|
|
680
|
+
detail: "Image failed to load.",
|
|
681
|
+
elements: [label(el)],
|
|
682
|
+
rects: [rectOf(el)],
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (hasText && cs.position !== "fixed" && !containedHorizontally(el)) {
|
|
686
|
+
const r = el.getBoundingClientRect();
|
|
687
|
+
if (r.left < -2 || r.right > vw + 2) {
|
|
688
|
+
if (sw <= vw + 1 || r.right > vw + 2) {
|
|
689
|
+
layout.push({
|
|
690
|
+
severity: "medium",
|
|
691
|
+
type: "Outside viewport",
|
|
692
|
+
detail: `Text element extends beyond the ${vw}px viewport (left ${Math.round(r.left)}, right ${Math.round(r.right)}).`,
|
|
693
|
+
elements: [label(el)],
|
|
694
|
+
text: ownText(el).slice(0, 60),
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Overlapping text-bearing elements / buttons (not ancestors of each other)
|
|
702
|
+
const boxes = all
|
|
703
|
+
.filter((el) => ownText(el).length > 0 || el.matches(buttonSel))
|
|
704
|
+
.filter((el) => getComputedStyle(el).position !== "fixed")
|
|
705
|
+
.slice(0, 700)
|
|
706
|
+
.map((el) => ({ el, r: el.getBoundingClientRect() }));
|
|
707
|
+
let overlaps = 0;
|
|
708
|
+
for (let i = 0; i < boxes.length && overlaps < 25; i++) {
|
|
709
|
+
for (let j = i + 1; j < boxes.length && overlaps < 25; j++) {
|
|
710
|
+
const a = boxes[i];
|
|
711
|
+
const b = boxes[j];
|
|
712
|
+
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
713
|
+
const ix = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
|
|
714
|
+
const iy = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
|
|
715
|
+
if (ix <= 2 || iy <= 2) continue;
|
|
716
|
+
const smaller = Math.min(a.r.width * a.r.height, b.r.width * b.r.height);
|
|
717
|
+
if ((ix * iy) / smaller > 0.3) {
|
|
718
|
+
overlaps++;
|
|
719
|
+
layout.push({
|
|
720
|
+
severity: "medium",
|
|
721
|
+
type: "Overlapping elements",
|
|
722
|
+
detail: `${label(a.el)} overlaps ${label(b.el)} (${Math.round(ix)}×${Math.round(iy)}px).`,
|
|
723
|
+
elements: [label(a.el), label(b.el)],
|
|
724
|
+
rects: [rectOf(a.el), rectOf(b.el)],
|
|
725
|
+
text: `${ownText(a.el).slice(0, 30)} / ${ownText(b.el).slice(0, 30)}`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---- Visible text for the spelling check (headings, buttons, labels, nav, copy) ----
|
|
732
|
+
const texts = [];
|
|
733
|
+
const seen = new Set();
|
|
734
|
+
for (const el of all) {
|
|
735
|
+
if (el.closest("svg, code, pre")) continue;
|
|
736
|
+
const t = ownText(el);
|
|
737
|
+
if (t.length < 2 || seen.has(t)) continue;
|
|
738
|
+
seen.add(t);
|
|
739
|
+
texts.push({ tag: el.tagName.toLowerCase(), text: t.slice(0, 3000), rect: rectOf(el) });
|
|
740
|
+
if (texts.length >= 2500) break;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
counts,
|
|
745
|
+
buttons: buttons.map(({ label, key, rect, iconOnly }) => ({ label, key, rect, iconOnly })),
|
|
746
|
+
navLinks: navLinks.map(({ label, key, rect }) => ({ label, key, rect })),
|
|
747
|
+
headings: headings.map(({ level, label, key, rect }) => ({ level, label, key, rect })),
|
|
748
|
+
styled,
|
|
749
|
+
sections,
|
|
750
|
+
labelBag: [...labelBag],
|
|
751
|
+
bodyText,
|
|
752
|
+
layout,
|
|
753
|
+
texts,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const RUNS_DIR = path.join(OUTPUT_DIR, "runs");
|
|
758
|
+
const KEEP_RUNS = 20;
|
|
759
|
+
|
|
760
|
+
// Each run writes its screenshots to its own folder so concurrent or later runs never overwrite them.
|
|
761
|
+
function newRunDir() {
|
|
762
|
+
const id = `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
763
|
+
const dir = path.join(RUNS_DIR, id);
|
|
764
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
765
|
+
try {
|
|
766
|
+
const old = fs.readdirSync(RUNS_DIR).sort().slice(0, -KEEP_RUNS);
|
|
767
|
+
for (const d of old) fs.rmSync(path.join(RUNS_DIR, d), { recursive: true, force: true });
|
|
768
|
+
} catch {
|
|
769
|
+
// cleanup is best effort
|
|
770
|
+
}
|
|
771
|
+
return { id, dir };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Brings a page to its fully loaded, final visual state before it is measured and captured:
|
|
776
|
+
* lazy images loaded, scroll-reveal content shown, fonts ready, animations finished.
|
|
777
|
+
*/
|
|
778
|
+
export async function settlePage(page, disableAnimations = true, readinessTimeout = 15000, revealFallback = true) {
|
|
779
|
+
if (disableAnimations) {
|
|
780
|
+
// Finish animations instantly instead of removing them: `animation: none` would put keyframe-revealed
|
|
781
|
+
// elements back to their hidden starting state.
|
|
782
|
+
await page.addStyleTag({
|
|
783
|
+
content:
|
|
784
|
+
"*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;animation-iteration-count:1!important;transition-duration:0s!important;transition-delay:0s!important;scroll-behavior:auto!important}",
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
await page.evaluate(() => {
|
|
788
|
+
for (const img of document.querySelectorAll("img[loading=lazy]")) img.loading = "eager";
|
|
789
|
+
});
|
|
790
|
+
// Scroll down one screen at a time, slowly enough for IntersectionObserver-based lazy loading and
|
|
791
|
+
// reveal animations to fire. The page may grow while scrolling, so the height is re-read each step.
|
|
792
|
+
await page.evaluate(async () => {
|
|
793
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
794
|
+
const step = Math.max(300, Math.floor(window.innerHeight * 0.75));
|
|
795
|
+
for (let y = 0, i = 0; y < document.documentElement.scrollHeight && i < 200; y += step, i++) {
|
|
796
|
+
window.scrollTo(0, y);
|
|
797
|
+
await sleep(180);
|
|
798
|
+
}
|
|
799
|
+
window.scrollTo(0, document.documentElement.scrollHeight);
|
|
800
|
+
await sleep(400);
|
|
801
|
+
window.scrollTo(0, 0);
|
|
802
|
+
});
|
|
803
|
+
await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
|
|
804
|
+
// Wait for every image (including CSS-free <img> swapped in by lazy loaders) to finish loading.
|
|
805
|
+
await page
|
|
806
|
+
.evaluate(async () => {
|
|
807
|
+
const imgs = [...document.images].filter((i) => !i.complete || (i.currentSrc && i.naturalWidth === 0));
|
|
808
|
+
await Promise.race([
|
|
809
|
+
Promise.all(imgs.map((i) => (i.decode ? i.decode().catch(() => {}) : Promise.resolve()))),
|
|
810
|
+
new Promise((r) => setTimeout(r, 10000)),
|
|
811
|
+
]);
|
|
812
|
+
})
|
|
813
|
+
.catch(() => {});
|
|
814
|
+
await page.waitForFunction(() => document.fonts.status === "loaded", null, { timeout: readinessTimeout });
|
|
815
|
+
// Reveal-on-scroll libraries (AOS, Framer Motion, WOW, GSAP...) leave content at opacity 0 when they
|
|
816
|
+
// did not fire. Force those elements to their final state; real hidden UI (menus, modals) uses
|
|
817
|
+
// display:none / visibility and is left alone.
|
|
818
|
+
if (revealFallback) await page.evaluate(() => {
|
|
819
|
+
const revealHint = /\b(aos|reveal|fade|animate|wow|motion|in-view|inview|scroll)/i;
|
|
820
|
+
for (const el of document.body.querySelectorAll("*")) {
|
|
821
|
+
const cs = getComputedStyle(el);
|
|
822
|
+
if (cs.display === "none" || Number(cs.opacity) > 0.01) continue;
|
|
823
|
+
const r = el.getBoundingClientRect();
|
|
824
|
+
if (r.width < 4 || r.height < 4) continue;
|
|
825
|
+
const inlineHidden = /opacity:\s*0(\.0+)?(;|$)/.test(el.getAttribute("style") || "");
|
|
826
|
+
const hinted = el.hasAttribute("data-aos") || revealHint.test(typeof el.className === "string" ? el.className : "");
|
|
827
|
+
if (!inlineHidden && !hinted) continue;
|
|
828
|
+
el.style.setProperty("opacity", "1", "important");
|
|
829
|
+
el.style.setProperty("transform", "none", "important");
|
|
830
|
+
if (cs.visibility === "hidden") el.style.setProperty("visibility", "visible", "important");
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
await page.waitForTimeout(300);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export async function createInspectionPage(browser, viewport, deviceScaleFactor = 1) {
|
|
837
|
+
const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor });
|
|
838
|
+
try { return await context.newPage(); }
|
|
839
|
+
catch (error) { await context.close(); throw error; }
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export async function capturePageScreenshot(page, filePath) {
|
|
843
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
844
|
+
await page.screenshot({ path: filePath, fullPage: true });
|
|
845
|
+
return page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function inspect(browser, url, viewport, ignore, shotName, disableAnimations = true, run = null) {
|
|
849
|
+
const page = await createInspectionPage(browser, viewport);
|
|
850
|
+
try {
|
|
851
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
|
|
852
|
+
await settlePage(page, disableAnimations);
|
|
853
|
+
const data = await page.evaluate(inspectPage, { props: STYLE_PROPS, ignore });
|
|
854
|
+
if (shotName) {
|
|
855
|
+
try {
|
|
856
|
+
const dir = run ? run.dir : OUTPUT_DIR;
|
|
857
|
+
const size = await capturePageScreenshot(page, path.join(dir, shotName));
|
|
858
|
+
data.shot = { image: run ? `visual-reports/runs/${run.id}/${shotName}` : `visual-reports/${shotName}`, ...size };
|
|
859
|
+
} catch {
|
|
860
|
+
data.shot = null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return data;
|
|
864
|
+
} finally {
|
|
865
|
+
await page.close();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
870
|
+
|
|
871
|
+
function pxNum(v) {
|
|
872
|
+
const m = /^(-?[\d.]+)px$/.exec(v);
|
|
873
|
+
return m ? Number(m[1]) : null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function rgbNum(v) {
|
|
877
|
+
const m = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(String(v || ""));
|
|
878
|
+
return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Colours a few units apart (rounding between colour spaces, e.g. Tailwind v3 hex vs v4 oklch) look identical.
|
|
882
|
+
const COLOR_TOL = 8;
|
|
883
|
+
|
|
884
|
+
function sameStyle(a, b) {
|
|
885
|
+
if (a === b) return true;
|
|
886
|
+
const ca = rgbNum(a);
|
|
887
|
+
const cb = rgbNum(b);
|
|
888
|
+
if (ca && cb) return Math.max(...[0, 1, 2].map((i) => Math.abs(ca[i] - cb[i]))) <= COLOR_TOL && Math.abs(ca[3] - cb[3]) <= 0.05;
|
|
889
|
+
const x = pxNum(a);
|
|
890
|
+
const y = pxNum(b);
|
|
891
|
+
return x !== null && y !== null && Math.abs(x - y) <= 1;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Raw tag and asset counts describe the implementation and dynamic data, not visual parity.
|
|
895
|
+
// Compare matched visual sections and their UI components instead.
|
|
896
|
+
export function checkCompleteness(ref, tgt) {
|
|
897
|
+
return dedupe(checkStructure(ref, tgt));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Compares page structure: top-level sections and the kinds of elements inside them.
|
|
902
|
+
* Anything present in the reference but absent from the target is reported as high severity.
|
|
903
|
+
*/
|
|
904
|
+
function checkStructure(ref, tgt) {
|
|
905
|
+
const findings = [];
|
|
906
|
+
const rs = ref.sections || [];
|
|
907
|
+
const ts = tgt.sections || [];
|
|
908
|
+
if (!rs.length || !ts.length) return findings;
|
|
909
|
+
const title = (sec) => sec.heading || `section ${sec.index + 1}`;
|
|
910
|
+
|
|
911
|
+
// Match sections in document order, never by how many CMS items they contain.
|
|
912
|
+
// With the same number of sections, changing cards, heading tags or button tags
|
|
913
|
+
// cannot make an existing section appear missing or moved.
|
|
914
|
+
const usedT = new Set();
|
|
915
|
+
const matchOf = new Map();
|
|
916
|
+
if (rs.length === ts.length) {
|
|
917
|
+
rs.forEach((_, i) => { matchOf.set(i, i); usedT.add(i); });
|
|
918
|
+
} else {
|
|
919
|
+
let next = 0;
|
|
920
|
+
rs.forEach((r, i) => {
|
|
921
|
+
const j = ts.findIndex((t, j) => j >= next && (t.role === r.role || t.role === "block" || r.role === "block"));
|
|
922
|
+
if (j >= 0) { matchOf.set(i, j); usedT.add(j); next = j + 1; }
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const missing = rs.filter((_, i) => !matchOf.has(i));
|
|
927
|
+
if (missing.length) {
|
|
928
|
+
findings.push({
|
|
929
|
+
severity: "high",
|
|
930
|
+
type: `Sections missing in target (${missing.length})`,
|
|
931
|
+
detail: "These sections are in the reference but no section with the same structure was found at that position in the target.",
|
|
932
|
+
elements: missing.map(title),
|
|
933
|
+
refRects: missing.map((m) => m.rect),
|
|
934
|
+
tgtRects: rs.flatMap((m, i) => {
|
|
935
|
+
if (matchOf.has(i)) return [];
|
|
936
|
+
let prevJ = -1;
|
|
937
|
+
for (let k = i - 1; k >= 0; k--) if (matchOf.has(k)) { prevJ = matchOf.get(k); break; }
|
|
938
|
+
const at = prevJ >= 0 ? ts[prevJ].rect : null;
|
|
939
|
+
return [{ x: at ? at.x : 0, y: at ? at.y + at.height : 0, width: at ? at.width : m.rect.width, height: Math.min(m.rect.height, 120) }];
|
|
940
|
+
}),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
const extra = ts.filter((_, j) => !usedT.has(j));
|
|
944
|
+
if (extra.length) {
|
|
945
|
+
findings.push({
|
|
946
|
+
severity: "medium",
|
|
947
|
+
type: `Extra sections in target (${extra.length})`,
|
|
948
|
+
detail: "These sections are in the target but not in the reference (page structure differs).",
|
|
949
|
+
elements: extra.map(title),
|
|
950
|
+
tgtRects: extra.map((e) => e.rect),
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const pairs = [...matchOf.entries()].sort((a, b) => a[0] - b[0]);
|
|
955
|
+
findings.push(...checkSpacing(rs, ts, pairs));
|
|
956
|
+
findings.push(...checkMissingElements(rs, ts, pairs));
|
|
957
|
+
|
|
958
|
+
return findings;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Pairs visual components by kind and relative position. Tags, classes and wrapper depth
|
|
963
|
+
* are only weak tie breakers. Data-driven category badges additionally use their
|
|
964
|
+
* category label and, for duplicate labels, the owning repeated item's heading.
|
|
965
|
+
*/
|
|
966
|
+
function pairNodes(refNodes = [], tgtNodes = [], strict = false) {
|
|
967
|
+
const rRoot = refNodes.find(n => n.path === "");
|
|
968
|
+
const tRoot = tgtNodes.find(n => n.path === "");
|
|
969
|
+
const pairs = [], tgtOf = new Map(), usedR = new Set(), usedT = new Set();
|
|
970
|
+
const add = (r, t) => { pairs.push([r, t]); tgtOf.set(r, t); usedR.add(r); usedT.add(t); };
|
|
971
|
+
if (rRoot && tRoot) add(rRoot, tRoot);
|
|
972
|
+
// A category's color follows its data identity, not a CMS card's current slot.
|
|
973
|
+
// Resolve duplicate labels only when the owning article title disambiguates them.
|
|
974
|
+
for (const byRecord of [true, false]) {
|
|
975
|
+
const groups = nodes => {
|
|
976
|
+
const index = new Map();
|
|
977
|
+
for (const node of nodes) if (node.categoryKey && (!byRecord || node.recordKey)) {
|
|
978
|
+
const key = JSON.stringify([node.categoryKey, byRecord ? node.recordKey : null]);
|
|
979
|
+
const list = index.get(key) || []; list.push(node); index.set(key, list);
|
|
980
|
+
}
|
|
981
|
+
return index;
|
|
982
|
+
};
|
|
983
|
+
const refs = groups(refNodes), targets = groups(tgtNodes);
|
|
984
|
+
for (const [key, nodes] of refs) {
|
|
985
|
+
const other = targets.get(key);
|
|
986
|
+
if (nodes.length === 1 && other?.length === 1 && !usedR.has(nodes[0]) && !usedT.has(other[0])) add(nodes[0], other[0]);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const rel = (n, root) => ({
|
|
990
|
+
x: root?.rect.width ? (n.rect.x - root.rect.x) / root.rect.width : 0,
|
|
991
|
+
y: root?.rect.height ? (n.rect.y - root.rect.y) / root.rect.height : 0,
|
|
992
|
+
w: root?.rect.width ? n.rect.width / root.rect.width : 0,
|
|
993
|
+
});
|
|
994
|
+
const distance = (r, t) => [...new Set([...Object.keys(r.styles), ...Object.keys(t.styles)])].reduce((sum, p) => {
|
|
995
|
+
const a = r.styles[p], b = t.styles[p];
|
|
996
|
+
if (sameStyle(a, b) || (["rowGap", "columnGap", "letterSpacing", "wordSpacing"].includes(p) && [a, b].every(v => v === "normal" || v === "0px"))) return sum;
|
|
997
|
+
return sum + (a === undefined || b === undefined ? 0.1 : 1);
|
|
998
|
+
}, 0);
|
|
999
|
+
const inside = (n, root) => root.path === "" || n.path.startsWith(root.path + "/");
|
|
1000
|
+
const paletteCache = new Map();
|
|
1001
|
+
const palette = (node, nodes) => {
|
|
1002
|
+
if (!paletteCache.has(node)) paletteCache.set(node, new Set(nodes.filter(n =>
|
|
1003
|
+
["text", "button"].includes(n.kind) && inside(n, node)).map(n =>
|
|
1004
|
+
["fontFamily", "fontSize", "fontWeight", "lineHeight", "color"].map(p => n.styles[p]).join("|"))));
|
|
1005
|
+
return paletteCache.get(node);
|
|
1006
|
+
};
|
|
1007
|
+
// Establish visual containers first; text is then matched inside its corresponding
|
|
1008
|
+
// container, so a heading cannot be paired with a card caption elsewhere.
|
|
1009
|
+
for (const boxes of [true, false]) {
|
|
1010
|
+
const candidates = [];
|
|
1011
|
+
for (const r of refNodes) {
|
|
1012
|
+
if (usedR.has(r) || (r.kind === "box") !== boxes) continue;
|
|
1013
|
+
const host = boxes ? rRoot : [...tgtOf.keys()]
|
|
1014
|
+
.filter(n => n.kind === "box" && inside(r, n))
|
|
1015
|
+
.sort((a, b) => b.path.length - a.path.length)[0] || rRoot;
|
|
1016
|
+
const targetHost = tgtOf.get(host) || tRoot;
|
|
1017
|
+
const a = rel(r, host);
|
|
1018
|
+
for (const t of tgtNodes) {
|
|
1019
|
+
if (usedT.has(t) || (t.kind === "box") !== boxes) continue;
|
|
1020
|
+
const textLike = n => ["text", "button"].includes(n.kind);
|
|
1021
|
+
if (r.kind !== t.kind && !(textLike(r) && textLike(t))) continue;
|
|
1022
|
+
if (!boxes && r.interactive !== undefined && t.interactive !== undefined && r.interactive !== t.interactive) continue;
|
|
1023
|
+
if (!boxes && targetHost && !inside(t, targetHost)) continue;
|
|
1024
|
+
const b = rel(t, targetHost);
|
|
1025
|
+
let position = Math.abs(a.x - b.x) + 0.4 * Math.abs(a.y - b.y);
|
|
1026
|
+
if (boxes) position += Math.abs(a.w - b.w);
|
|
1027
|
+
if (position > 0.75) continue;
|
|
1028
|
+
const styleDistance = distance(r, t);
|
|
1029
|
+
// A large mismatch without a reliable counterpart is ambiguous content.
|
|
1030
|
+
if (strict && styleDistance > 4 && r.path !== t.path) continue;
|
|
1031
|
+
let paletteCost = 0;
|
|
1032
|
+
if (boxes) {
|
|
1033
|
+
const rp = palette(r, refNodes), tp = palette(t, tgtNodes);
|
|
1034
|
+
// A decoration with no text (a badge dot, for example) cannot be a card.
|
|
1035
|
+
if (Boolean(rp.size) !== Boolean(tp.size)) continue;
|
|
1036
|
+
const union = new Set([...rp, ...tp]);
|
|
1037
|
+
if (union.size) paletteCost = 0.4 * [...union].filter(v => !rp.has(v) || !tp.has(v)).length / union.size;
|
|
1038
|
+
}
|
|
1039
|
+
candidates.push({r, t, cost: position + 0.15 * styleDistance + paletteCost});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
candidates.sort((a, b) => a.cost - b.cost);
|
|
1043
|
+
for (const {r, t} of candidates) if (!usedR.has(r) && !usedT.has(t)) add(r, t);
|
|
1044
|
+
}
|
|
1045
|
+
return { pairs, tgtOf, unpaired: refNodes.filter(n => n.path !== "" && !usedR.has(n)) };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const SPACE_LABELS = {
|
|
1049
|
+
paddingTop: "padding-top", paddingRight: "padding-right", paddingBottom: "padding-bottom", paddingLeft: "padding-left",
|
|
1050
|
+
marginTop: "margin-top", marginBottom: "margin-bottom", rowGap: "row gap", columnGap: "column gap",
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Compares spacing (section padding / margins, gaps between rows and columns, padding inside cards) of matched
|
|
1055
|
+
* sections. Only the containers whose spacing differs are reported, so the highlight is small and exact.
|
|
1056
|
+
* Content differences (more or fewer cards, longer text) never produce a finding here.
|
|
1057
|
+
*/
|
|
1058
|
+
function highlightRects(node, diffs) {
|
|
1059
|
+
if (diffs.every(d => ["row gap", "column gap"].includes(d.prop))) {
|
|
1060
|
+
const rects = diffs.flatMap(d => node.gapRects?.[d.prop === "row gap" ? "rowGap" : "columnGap"] || []);
|
|
1061
|
+
if (rects.length) return rects;
|
|
1062
|
+
}
|
|
1063
|
+
if (diffs.every(d => d.prop === "container width")) {
|
|
1064
|
+
const r = node.rect;
|
|
1065
|
+
return [{ ...r, width: 3 }, { ...r, x: r.x + r.width - 3, width: 3 }];
|
|
1066
|
+
}
|
|
1067
|
+
return [node.rect];
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const COLOUR_PROPS = new Set(["color", "accentColor", "backgroundColor", "iconColor", "iconFill"]);
|
|
1071
|
+
const COLOUR_LABELS = { color: "text colour", accentColor: "highlighted words colour", backgroundColor: "background colour", iconColor: "icon colour", iconFill: "icon fill" };
|
|
1072
|
+
|
|
1073
|
+
// Text colour: box the glyphs. Highlighted words only: box those words. Background / icons: box the element.
|
|
1074
|
+
function colourRects(node, diffs) {
|
|
1075
|
+
const props = new Set(diffs.map((d) => d.prop));
|
|
1076
|
+
const text = node.textRect && node.textRect.width > 0 ? node.textRect : null;
|
|
1077
|
+
if (props.has("accentColor") && !props.has("color") && node.accentRects?.length) {
|
|
1078
|
+
return props.size === 1 ? node.accentRects : [node.rect];
|
|
1079
|
+
}
|
|
1080
|
+
if ([...props].every((p) => p === "color" || p === "accentColor") && text) return [text];
|
|
1081
|
+
return [node.rect];
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function checkSpacing(rs, ts, pairs) {
|
|
1085
|
+
const findings = [];
|
|
1086
|
+
const TOL = 2;
|
|
1087
|
+
for (const [i, j] of pairs) {
|
|
1088
|
+
const r = rs[i];
|
|
1089
|
+
const t = ts[j];
|
|
1090
|
+
for (const [rn, tn] of pairNodes(r.spacing, t.spacing, true).pairs) {
|
|
1091
|
+
if (rn.kind === "media" || tn.kind === "media") continue;
|
|
1092
|
+
const diffs = [];
|
|
1093
|
+
for (const p of Object.keys(SPACE_LABELS)) {
|
|
1094
|
+
const a = rn.styles[p];
|
|
1095
|
+
const b = tn.styles[p];
|
|
1096
|
+
if (a === b) continue;
|
|
1097
|
+
if ((p === "rowGap" || p === "columnGap") && rn.renderedGaps && tn.renderedGaps) {
|
|
1098
|
+
const x = rn.renderedGaps[p], y = tn.renderedGaps[p];
|
|
1099
|
+
if (rn.distributedGap || tn.distributedGap || x === null || y === null || Math.abs(x - y) <= TOL) continue;
|
|
1100
|
+
}
|
|
1101
|
+
if ((p === "marginTop" || p === "marginBottom") && (a === "auto" || b === "auto")) continue;
|
|
1102
|
+
const x = pxNum(a);
|
|
1103
|
+
const y = pxNum(b);
|
|
1104
|
+
if (x !== null && y !== null && Math.abs(x - y) <= TOL) continue;
|
|
1105
|
+
// margin: auto resolves to a fractional, content-driven pixel value; not a design difference.
|
|
1106
|
+
if ((p === "marginTop" || p === "marginBottom") && [pxNum(a), pxNum(b)].some((v) => v !== null && v >= 24 && Math.abs(v - Math.round(v)) > 0.2)) continue;
|
|
1107
|
+
// "normal" gap vs 0px is not a visible difference
|
|
1108
|
+
if ((a === "normal" && y === 0) || (b === "normal" && x === 0)) continue;
|
|
1109
|
+
diffs.push({ prop: SPACE_LABELS[p], ref: a, act: b });
|
|
1110
|
+
}
|
|
1111
|
+
for (const p of ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "wordSpacing", "color", "backgroundColor", "borderRadius", "iconWidth", "iconHeight", "iconColor", "iconFill", "accentColor"]) {
|
|
1112
|
+
if (COLOUR_PROPS.has(p) && (rn.categoryKey || tn.categoryKey) && rn.categoryKey !== tn.categoryKey) continue;
|
|
1113
|
+
const a = rn.styles[p];
|
|
1114
|
+
const b = tn.styles[p];
|
|
1115
|
+
if (a === undefined || b === undefined || sameStyle(a, b)) continue;
|
|
1116
|
+
if (p === "borderRadius" && Math.abs(pxNum(a) - pxNum(b)) <= 2) continue;
|
|
1117
|
+
if (p === "accentColor") {
|
|
1118
|
+
const ca = a === "none" ? [] : a.split(" + "), cb = b === "none" ? [] : b.split(" + ");
|
|
1119
|
+
if (ca.length === cb.length && ca.every(x => cb.some(y => sameStyle(x, y)))) continue;
|
|
1120
|
+
}
|
|
1121
|
+
if ((p === "wordSpacing" || p === "letterSpacing") && (a === "normal" || a === "0px") && (b === "normal" || b === "0px")) continue;
|
|
1122
|
+
if (p === "iconWidth" || p === "iconHeight") {
|
|
1123
|
+
// Photos are dynamic content: only small icons (both sides <= 96px) are compared by size.
|
|
1124
|
+
if (Math.max(pxNum(rn.styles.iconWidth), pxNum(rn.styles.iconHeight), pxNum(tn.styles.iconWidth), pxNum(tn.styles.iconHeight)) > 96) continue;
|
|
1125
|
+
if (Math.abs(pxNum(a) - pxNum(b)) <= 3) continue;
|
|
1126
|
+
}
|
|
1127
|
+
diffs.push({ prop: p, ref: a, act: b });
|
|
1128
|
+
}
|
|
1129
|
+
if (rn.repeatCount === tn.repeatCount && rn.widthPct !== null && tn.widthPct !== null && Math.abs(rn.widthPct - tn.widthPct) > 3) {
|
|
1130
|
+
diffs.push({ prop: "container width", ref: `${rn.widthPct}%`, act: `${tn.widthPct}%` });
|
|
1131
|
+
}
|
|
1132
|
+
if (!diffs.length) continue;
|
|
1133
|
+
// Colour changes get their own finding, boxed tightly around the coloured text so they are easy to see.
|
|
1134
|
+
const colourDiffs = diffs.filter((d) => COLOUR_PROPS.has(d.prop));
|
|
1135
|
+
const otherDiffs = diffs.filter((d) => !COLOUR_PROPS.has(d.prop));
|
|
1136
|
+
if (colourDiffs.length) {
|
|
1137
|
+
findings.push({
|
|
1138
|
+
locate: true,
|
|
1139
|
+
severity: "medium",
|
|
1140
|
+
type: `Colour mismatch in "${title0(r)}"`,
|
|
1141
|
+
detail: `${rn.name}: ${colourDiffs.map((d) => `${COLOUR_LABELS[d.prop]} ${d.ref} vs ${d.act}`).join(", ")}.`,
|
|
1142
|
+
diffs: colourDiffs,
|
|
1143
|
+
nodeTag: rn.tag,
|
|
1144
|
+
refRects: colourRects(rn, colourDiffs),
|
|
1145
|
+
tgtRects: colourRects(tn, colourDiffs),
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
if (!otherDiffs.length) continue;
|
|
1149
|
+
findings.push({
|
|
1150
|
+
locate: true,
|
|
1151
|
+
severity: "medium",
|
|
1152
|
+
type: `UI style mismatch in "${title0(r)}"`,
|
|
1153
|
+
detail: `${rn.name}: ${otherDiffs.map((d) => `${d.prop} ${d.ref} vs ${d.act}`).join(", ")}.`,
|
|
1154
|
+
diffs: otherDiffs,
|
|
1155
|
+
nodeTag: rn.tag,
|
|
1156
|
+
refRects: highlightRects(rn, otherDiffs),
|
|
1157
|
+
tgtRects: highlightRects(tn, otherDiffs),
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
// Vertical space between consecutive matched sections
|
|
1162
|
+
const ordered = [...pairs].sort((a, b) => a[0] - b[0]);
|
|
1163
|
+
for (let k = 1; k < ordered.length; k++) {
|
|
1164
|
+
const [pi, pj] = ordered[k - 1];
|
|
1165
|
+
const [ci, cj] = ordered[k];
|
|
1166
|
+
if (ci !== pi + 1 || cj !== pj + 1) continue;
|
|
1167
|
+
const refGap = rs[ci].rect.y - (rs[pi].rect.y + rs[pi].rect.height);
|
|
1168
|
+
const tgtGap = ts[cj].rect.y - (ts[pj].rect.y + ts[pj].rect.height);
|
|
1169
|
+
if (Math.abs(refGap - tgtGap) <= TOL * 2) continue;
|
|
1170
|
+
const band = (a, b) => ({ x: a.x, y: a.y + a.height, width: a.width, height: Math.max(b.y - (a.y + a.height), 4) });
|
|
1171
|
+
findings.push({
|
|
1172
|
+
locate: true,
|
|
1173
|
+
severity: "medium",
|
|
1174
|
+
type: `Section spacing mismatch`,
|
|
1175
|
+
detail: `Space between "${title0(rs[pi])}" and "${title0(rs[ci])}" is ${refGap}px in reference, ${tgtGap}px in target.`,
|
|
1176
|
+
refRects: [band(rs[pi].rect, rs[ci].rect)],
|
|
1177
|
+
tgtRects: [band(ts[pj].rect, ts[cj].rect)],
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
return findings;
|
|
1181
|
+
}
|
|
1182
|
+
const title0 = (sec) => sec.heading || `section ${sec.index + 1}`;
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* The reference is the baseline. A button, form field, label/heading text or whole block that exists in a reference
|
|
1186
|
+
* section but has no counterpart at the same place in the matching target section is reported as missing, and
|
|
1187
|
+
* highlighted in the target where it should be. The same check run the other way reports text and buttons that only
|
|
1188
|
+
* the target has (an added eyebrow label, for example). Items in repeated lists (cards...) and long copy are ignored
|
|
1189
|
+
* because they are dynamic content.
|
|
1190
|
+
*/
|
|
1191
|
+
function checkMissingElements(rs, ts, pairs) {
|
|
1192
|
+
const findings = [];
|
|
1193
|
+
for (const [i, j] of pairs) {
|
|
1194
|
+
findings.push(...unmatchedNodes(rs[i], ts[j], "missing"));
|
|
1195
|
+
findings.push(...unmatchedNodes(ts[j], rs[i], "extra"));
|
|
1196
|
+
}
|
|
1197
|
+
return findings;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function unmatchedNodes(fromSec, toSec, mode) {
|
|
1201
|
+
const findings = [];
|
|
1202
|
+
const nodes = fromSec.spacing || [];
|
|
1203
|
+
const { unpaired, tgtOf } = pairNodes(nodes, toSec.spacing);
|
|
1204
|
+
const unpairedSet = new Set(unpaired);
|
|
1205
|
+
const byPath = new Map(nodes.map((n) => [n.path, n]));
|
|
1206
|
+
const below = (n) => nodes.filter((d) => d.path.startsWith(n.path + "/"));
|
|
1207
|
+
const shortText = (n) => n.kind === "text" && !n.listSize && (/^h[1-6]$/.test(n.tag) || (n.textLen > 0 && n.textLen <= 40));
|
|
1208
|
+
const reportable = (n) => {
|
|
1209
|
+
if (n.path === "" || n.repeatCount) return false;
|
|
1210
|
+
if (["button", "input"].includes(n.kind)) return n.uniq;
|
|
1211
|
+
if (n.kind === "text") return shortText(n);
|
|
1212
|
+
if (n.kind === "box") {
|
|
1213
|
+
// A block is missing only when none of its text found a counterpart elsewhere (not merely re-wrapped).
|
|
1214
|
+
const content = below(n).filter((d) => ["text", "button", "input"].includes(d.kind));
|
|
1215
|
+
return content.length > 0 && content.every((d) => unpairedSet.has(d) && !d.repeatCount);
|
|
1216
|
+
}
|
|
1217
|
+
return false;
|
|
1218
|
+
};
|
|
1219
|
+
const reported = new Set();
|
|
1220
|
+
for (const rn of unpaired) {
|
|
1221
|
+
if (!reportable(rn)) continue;
|
|
1222
|
+
// Report only the topmost missing item; its children are missing with it.
|
|
1223
|
+
const parts = rn.path.split("/");
|
|
1224
|
+
let ancestorReported = false;
|
|
1225
|
+
let host = null;
|
|
1226
|
+
for (let n = parts.length - 1; n >= 1; n--) {
|
|
1227
|
+
const an = byPath.get(parts.slice(0, n).join("/"));
|
|
1228
|
+
if (!an) continue;
|
|
1229
|
+
if (reported.has(an)) { ancestorReported = true; break; }
|
|
1230
|
+
if (tgtOf.has(an)) { host = tgtOf.get(an); break; }
|
|
1231
|
+
}
|
|
1232
|
+
if (ancestorReported) continue;
|
|
1233
|
+
host = host || tgtOf.get(byPath.get(""));
|
|
1234
|
+
if (!host) continue;
|
|
1235
|
+
reported.add(rn);
|
|
1236
|
+
const firstText = rn.kind === "box" ? below(rn).find((d) => d.name.includes("“")) : null;
|
|
1237
|
+
const name = firstText ? `${rn.tag} with ${firstText.name}` : rn.name;
|
|
1238
|
+
const noun = { button: "button", text: /^h[1-6]$/.test(rn.tag) ? "heading" : "text", box: "block", input: "form field" }[rn.kind];
|
|
1239
|
+
const where = `"${title0(mode === "missing" ? fromSec : toSec)}"`;
|
|
1240
|
+
if (mode === "extra") {
|
|
1241
|
+
findings.push({
|
|
1242
|
+
locate: true,
|
|
1243
|
+
severity: "medium",
|
|
1244
|
+
type: `Extra ${noun} in ${where}`,
|
|
1245
|
+
detail: `${name} is in the target but not in the reference.`,
|
|
1246
|
+
tgtRects: [rn.rect],
|
|
1247
|
+
});
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
// Place the highlight right after the previous sibling when it exists in the target.
|
|
1251
|
+
const idx = Number(parts[parts.length - 1]);
|
|
1252
|
+
const prevRef = idx > 0 ? byPath.get([...parts.slice(0, -1), idx - 1].join("/")) : null;
|
|
1253
|
+
const prev = prevRef ? tgtOf.get(prevRef) : null;
|
|
1254
|
+
const rect = prev
|
|
1255
|
+
? { x: prev.rect.x, y: prev.rect.y + prev.rect.height, width: Math.max(prev.rect.width, rn.rect.width), height: Math.max(rn.rect.height, 8) }
|
|
1256
|
+
: { x: host.rect.x, y: host.rect.y, width: Math.max(rn.rect.width, 8), height: Math.max(rn.rect.height, 8) };
|
|
1257
|
+
findings.push({
|
|
1258
|
+
locate: true,
|
|
1259
|
+
severity: "high",
|
|
1260
|
+
type: `Missing ${noun} in ${where}`,
|
|
1261
|
+
detail: `${name} is in the reference but has no counterpart in the target. Highlighted where it should be.`,
|
|
1262
|
+
refRects: [rn.rect],
|
|
1263
|
+
tgtRects: [rect],
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
return findings;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function checkChecklist(tgt, checklist) {
|
|
1270
|
+
const findings = [];
|
|
1271
|
+
for (const raw of checklist) {
|
|
1272
|
+
const item = norm(raw);
|
|
1273
|
+
if (!item) continue;
|
|
1274
|
+
const found = tgt.labelBag.some((l) => l && (l === item || l.includes(item))) || tgt.bodyText.includes(item);
|
|
1275
|
+
if (!found) {
|
|
1276
|
+
findings.push({
|
|
1277
|
+
severity: "high",
|
|
1278
|
+
type: "Expected item not found",
|
|
1279
|
+
detail: `"${raw}" from your checklist was not found on the target page.`,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return findings;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** Compare matched visual components; global tag frequencies vary with CMS content. */
|
|
1287
|
+
function checkStyling(ref, tgt) {
|
|
1288
|
+
const typography = new Set(["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "wordSpacing"]);
|
|
1289
|
+
return checkCompleteness(ref, tgt)
|
|
1290
|
+
.filter(f => f.diffs?.some(d => typography.has(d.prop)))
|
|
1291
|
+
.map(f => ({ ...f, type: `Style mismatch: ${f.nodeTag || "component"}` }));
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function dedupe(list) {
|
|
1295
|
+
const seen = new Set();
|
|
1296
|
+
return list.filter((f) => {
|
|
1297
|
+
const k = `${f.type}|${f.detail}`;
|
|
1298
|
+
if (seen.has(k)) return false;
|
|
1299
|
+
seen.add(k);
|
|
1300
|
+
return true;
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** Reuse the UI-check inventory and engines on an already-captured local page. */
|
|
1305
|
+
export async function inspectUICheckPage(page, ignoreSelectors = []) {
|
|
1306
|
+
return page.evaluate(inspectPage, { props: STYLE_PROPS, ignore: ignoreSelectors.join(",") });
|
|
1307
|
+
}
|
|
1308
|
+
export function collectUICheck(ref, tgt, { checklist = [], styling = true } = {}) {
|
|
1309
|
+
const completeness = [...(ref ? checkCompleteness(ref, tgt) : []), ...checkChecklist(tgt, checklist)];
|
|
1310
|
+
const findings = ref && styling ? checkStyling(ref, tgt) : [];
|
|
1311
|
+
const styleKeys = new Set(findings.map(f => JSON.stringify([f.refRects, f.tgtRects, f.diffs])));
|
|
1312
|
+
return { completeness: completeness.filter(f => !styleKeys.has(JSON.stringify([f.refRects, f.tgtRects, f.diffs]))), layout: dedupe(tgt.layout), styling: findings };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Spell-checks the visible text with cspell (en + en-GB). Custom words are never flagged.
|
|
1317
|
+
*/
|
|
1318
|
+
export async function checkSpelling(texts, customWords = [], signal) {
|
|
1319
|
+
signal?.throwIfAborted();
|
|
1320
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "uicheck-"));
|
|
1321
|
+
try {
|
|
1322
|
+
const textFile = path.join(dir, "text.txt");
|
|
1323
|
+
const cfgFile = path.join(dir, "cspell.json");
|
|
1324
|
+
// Only spell-check Latin-script text; other scripts (Arabic, CJK, ...) would all be flagged.
|
|
1325
|
+
const nonLatin = /(?!\p{Script=Latin})\p{L}/u;
|
|
1326
|
+
texts = texts.filter((t) => !nonLatin.test(t.text));
|
|
1327
|
+
fs.writeFileSync(textFile, texts.map((t) => t.text).join("\n"));
|
|
1328
|
+
fs.writeFileSync(
|
|
1329
|
+
cfgFile,
|
|
1330
|
+
JSON.stringify({
|
|
1331
|
+
language: "en,en-GB",
|
|
1332
|
+
words: customWords,
|
|
1333
|
+
// Domain names / paths such as pharmacyregulation.org are not prose.
|
|
1334
|
+
ignoreRegExpList: ["/\\b[\\w-]+(\\.[\\w-]+)*\\.(org|com|net|uk|co|io|gov|nhs|info|app|dev)\\b[\\w./?=&%-]*/i"],
|
|
1335
|
+
})
|
|
1336
|
+
);
|
|
1337
|
+
const bin = path.join(__dirname, "node_modules", ".bin", "cspell");
|
|
1338
|
+
let stdout = "";
|
|
1339
|
+
try {
|
|
1340
|
+
({ stdout } = await execFileAsync(bin, ["lint", "--no-progress", "--no-summary", "--show-suggestions", "-c", "cspell.json", "text.txt"], { cwd: dir, maxBuffer: 10 * 1024 * 1024, signal }));
|
|
1341
|
+
} catch (e) {
|
|
1342
|
+
// cspell exits with code 1 when it finds unknown words; that is a normal result.
|
|
1343
|
+
if (e.code === 1 && typeof e.stdout === "string") stdout = e.stdout;
|
|
1344
|
+
else throw e;
|
|
1345
|
+
}
|
|
1346
|
+
// Lines look like: text.txt:1:1 - Unknown word (Maleria) Suggestions: [Malaria, Materia, ...]
|
|
1347
|
+
const found = new Map();
|
|
1348
|
+
for (const line of stdout.split("\n")) {
|
|
1349
|
+
const m = /Unknown word \((.+?)\)(?:\s+Suggestions: \[(.*)\])?/.exec(line);
|
|
1350
|
+
if (!m || found.has(m[1])) continue;
|
|
1351
|
+
const seen = new Set();
|
|
1352
|
+
const suggestions = (m[2] || "")
|
|
1353
|
+
.split(",")
|
|
1354
|
+
.map((x) => x.trim().replace(/\*$/, ""))
|
|
1355
|
+
.filter((x) => {
|
|
1356
|
+
const k = x.toLowerCase();
|
|
1357
|
+
if (!x || k === m[1].toLowerCase() || seen.has(k)) return false;
|
|
1358
|
+
seen.add(k);
|
|
1359
|
+
return true;
|
|
1360
|
+
})
|
|
1361
|
+
.slice(0, 4);
|
|
1362
|
+
found.set(m[1], suggestions);
|
|
1363
|
+
}
|
|
1364
|
+
return [...found.entries()].map(([w, suggestions]) => {
|
|
1365
|
+
const re = new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
|
1366
|
+
const hits = texts.filter((t) => re.test(t.text));
|
|
1367
|
+
const hit = hits[0];
|
|
1368
|
+
let context = "";
|
|
1369
|
+
if (hit) {
|
|
1370
|
+
// Show a window of text around the word instead of cutting the start of the string.
|
|
1371
|
+
const at = hit.text.search(re);
|
|
1372
|
+
const from = Math.max(0, at - 50);
|
|
1373
|
+
context = (from > 0 ? "…" : "") + hit.text.slice(from, at + w.length + 50) + (at + w.length + 50 < hit.text.length ? "…" : "");
|
|
1374
|
+
}
|
|
1375
|
+
return {
|
|
1376
|
+
word: w,
|
|
1377
|
+
suggestions,
|
|
1378
|
+
tag: hit?.tag || "",
|
|
1379
|
+
context,
|
|
1380
|
+
rects: hits.slice(0, 5).map((h) => h.rect).filter(Boolean),
|
|
1381
|
+
};
|
|
1382
|
+
});
|
|
1383
|
+
} finally {
|
|
1384
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Optional "where to look" layer: a transparent PNG with only the differing pixels painted, to overlay on the
|
|
1390
|
+
* target screenshot. Content differences show up too, so it never produces findings by itself.
|
|
1391
|
+
*/
|
|
1392
|
+
function pixelDiffLayer(run, key, refShot, tgtShot) {
|
|
1393
|
+
if (!refShot || !tgtShot) return null;
|
|
1394
|
+
try {
|
|
1395
|
+
const read = (img) => getPNG().sync.read(fs.readFileSync(path.join(OUTPUT_DIR, "..", img)));
|
|
1396
|
+
const a = read(refShot.image);
|
|
1397
|
+
const b = read(tgtShot.image);
|
|
1398
|
+
const width = Math.max(a.width, b.width);
|
|
1399
|
+
const height = Math.max(a.height, b.height);
|
|
1400
|
+
const diff = new (getPNG())({ width, height });
|
|
1401
|
+
const changed = getPixelmatch()(normalizeImageSize(a, width, height).data, normalizeImageSize(b, width, height).data, diff.data, width, height, {
|
|
1402
|
+
threshold: 0.1,
|
|
1403
|
+
includeAA: false,
|
|
1404
|
+
diffMask: true,
|
|
1405
|
+
diffColor: [244, 63, 94],
|
|
1406
|
+
});
|
|
1407
|
+
const name = `pixeldiff-${key}.png`;
|
|
1408
|
+
fs.writeFileSync(path.join(run.dir, name), getPNG().sync.write(diff));
|
|
1409
|
+
return {
|
|
1410
|
+
image: `visual-reports/runs/${run.id}/${name}`,
|
|
1411
|
+
width,
|
|
1412
|
+
height,
|
|
1413
|
+
percent: Math.round((changed / (width * height)) * 10000) / 100,
|
|
1414
|
+
};
|
|
1415
|
+
} catch {
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
export async function runUiCheck({
|
|
1421
|
+
referenceUrl = "",
|
|
1422
|
+
targetUrl,
|
|
1423
|
+
viewports = ["desktop"],
|
|
1424
|
+
checklist = [],
|
|
1425
|
+
customWords = [],
|
|
1426
|
+
ignoreSelectors = [],
|
|
1427
|
+
spelling = false,
|
|
1428
|
+
styling = true,
|
|
1429
|
+
pixelDiff = false,
|
|
1430
|
+
}) {
|
|
1431
|
+
if (!targetUrl) throw new Error("Target URL is required");
|
|
1432
|
+
if (!referenceUrl && !checklist.length) {
|
|
1433
|
+
throw new Error("Provide a Reference URL, an expected-items checklist, or both");
|
|
1434
|
+
}
|
|
1435
|
+
const ignore = ignoreSelectors.join(",");
|
|
1436
|
+
const browser = await chromium.launch({ headless: true });
|
|
1437
|
+
const run = newRunDir();
|
|
1438
|
+
try {
|
|
1439
|
+
const results = [];
|
|
1440
|
+
let spellTexts = null;
|
|
1441
|
+
let spellViewport = "desktop";
|
|
1442
|
+
for (const key of viewports) {
|
|
1443
|
+
const vp = VIEWPORTS[key];
|
|
1444
|
+
if (!vp) continue;
|
|
1445
|
+
const tgt = await inspect(browser, targetUrl, vp, ignore, `target-${key}.png`, true, run);
|
|
1446
|
+
const ref = referenceUrl ? await inspect(browser, referenceUrl, vp, ignore, `reference-${key}.png`, true, run) : null;
|
|
1447
|
+
if (key === "desktop" || !spellTexts) {
|
|
1448
|
+
spellTexts = tgt.texts;
|
|
1449
|
+
spellViewport = key;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const checks = collectUICheck(ref, tgt, { checklist, styling });
|
|
1453
|
+
results.push({
|
|
1454
|
+
viewport: { key, ...vp },
|
|
1455
|
+
counts: { reference: ref?.counts || null, target: tgt.counts },
|
|
1456
|
+
reference: ref?.shot || null,
|
|
1457
|
+
target: tgt.shot || null,
|
|
1458
|
+
...checks,
|
|
1459
|
+
pixelDiff: pixelDiff ? pixelDiffLayer(run, key, ref?.shot, tgt.shot) : null,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const spell = spelling && spellTexts ? await checkSpelling(spellTexts, customWords) : [];
|
|
1464
|
+
|
|
1465
|
+
const all = [
|
|
1466
|
+
...results.flatMap((r) => [...r.completeness, ...r.layout, ...r.styling]),
|
|
1467
|
+
...spell.map(() => ({ severity: "medium" })),
|
|
1468
|
+
];
|
|
1469
|
+
const summary = {
|
|
1470
|
+
high: all.filter((f) => f.severity === "high").length,
|
|
1471
|
+
medium: all.filter((f) => f.severity === "medium").length,
|
|
1472
|
+
low: all.filter((f) => f.severity === "low").length,
|
|
1473
|
+
info: all.filter((f) => f.severity === "info").length,
|
|
1474
|
+
misspellings: spell.length,
|
|
1475
|
+
};
|
|
1476
|
+
return { runId: run.id, referenceUrl, targetUrl, summary, results, spelling: spell, spellingRan: Boolean(spelling), spellingViewport: spellViewport, timestamp: new Date().toISOString() };
|
|
1477
|
+
} finally {
|
|
1478
|
+
await browser.close();
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Spell-checks the visible text of one URL (used by the DOM/CSS page).
|
|
1484
|
+
*/
|
|
1485
|
+
export async function spellCheckUrl({ url, viewport = VIEWPORTS.desktop, customWords = [], ignoreSelectors = [] }) {
|
|
1486
|
+
const browser = await chromium.launch({ headless: true });
|
|
1487
|
+
try {
|
|
1488
|
+
const page = await inspect(browser, url, viewport, ignoreSelectors.join(","), null);
|
|
1489
|
+
return await checkSpelling(page.texts, customWords);
|
|
1490
|
+
} finally {
|
|
1491
|
+
await browser.close();
|
|
1492
|
+
}
|
|
1493
|
+
}
|