1028 lines
28 KiB
JavaScript
1028 lines
28 KiB
JavaScript
// audit-cdp.mjs
|
|
// Visual audit over multiple routes comparing SPECIFIED styles (what the CSS
|
|
// actually declares, after cascade + var() resolution + inheritance) instead
|
|
// of computed layout values — so sizes driven by differing page content don't
|
|
// produce false positives. Geometry (box) is only reported where a dimension
|
|
// is explicitly locked by CSS, not where it just follows the content flow.
|
|
// Replicates the route→ref mapping from audit-css.mjs (the 4 targets that have
|
|
// a corresponding ref HTML file), starts a dev server (unless --no-serve),
|
|
// captures each pair, diffs, and writes a report to web/audit/cdp-audit.txt.
|
|
//
|
|
// Usage:
|
|
// node scripts/audit-cdp.mjs # serve + audit all 4 targets
|
|
// node scripts/audit-cdp.mjs --no-serve # assume a server is already running
|
|
// node scripts/audit-cdp.mjs --ours http://127.0.0.1:5173
|
|
// node scripts/audit-cdp.mjs --route /preview/demo --ref demo.html
|
|
import { chromium } from "playwright";
|
|
import { diffArrays } from "diff";
|
|
import { spawn } from "node:child_process";
|
|
import { writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
|
import { resolve, join } from "node:path";
|
|
|
|
const argv = (() => {
|
|
const a = { viewports: [] };
|
|
for (let i = 0; i < process.argv.length; i++) {
|
|
const v = process.argv[i];
|
|
if (v === "--no-serve") a.noServe = true;
|
|
else if (v === "--ours") a.ours = process.argv[++i];
|
|
else if (v === "--ref") a.ref = process.argv[++i];
|
|
else if (v === "--route") a.route = process.argv[++i];
|
|
else if (v === "--port") a.port = Number(process.argv[++i]);
|
|
else if (v === "--viewport")
|
|
a.viewports.push(
|
|
...(process.argv[++i] ?? "").split(",").filter(Boolean),
|
|
);
|
|
}
|
|
return a;
|
|
})();
|
|
|
|
const WEB = process.cwd();
|
|
const PORT = argv.port || 5179;
|
|
const REFS_DIR = resolve(WEB, "../refs-html");
|
|
const PX_TOL = 1;
|
|
|
|
// Requested viewports; repeatable/comma-separated "--viewport WxH".
|
|
// Deduplicated, preserves order. Defaults to 1440x900 when the flag is absent.
|
|
const viewports = (() => {
|
|
const raw = argv.viewports.length ? argv.viewports : ["1440x900"];
|
|
const out = [];
|
|
for (const s of raw) {
|
|
const m = /^(\d+)x(\d+)$/.exec(s.trim());
|
|
if (!m) {
|
|
console.error(`[cdp-audit] invalid viewport: ${s} (expected "WxH")`);
|
|
process.exit(1);
|
|
}
|
|
const vp = { width: +m[1], height: +m[2] };
|
|
if (!out.some((o) => o.width === vp.width && o.height === vp.height))
|
|
out.push(vp);
|
|
}
|
|
return out;
|
|
})();
|
|
const vpLabel = (vp) => `${vp.width}x${vp.height}`;
|
|
|
|
// Explicit --viewport means per-resolution files; default run keeps the
|
|
// backward-compatible single cdp-audit.txt name.
|
|
const perResFile = argv.viewports.length > 0;
|
|
const reportPath = (vp) =>
|
|
resolve(
|
|
WEB,
|
|
"audit",
|
|
perResFile ? `cdp-audit-${vpLabel(vp)}.txt` : "cdp-audit.txt",
|
|
);
|
|
|
|
const PROPS = [
|
|
"display",
|
|
"position",
|
|
"top",
|
|
"right",
|
|
"bottom",
|
|
"left",
|
|
"z-index",
|
|
"float",
|
|
"flex-direction",
|
|
"flex-wrap",
|
|
"justify-content",
|
|
"align-items",
|
|
"align-self",
|
|
"row-gap",
|
|
"column-gap",
|
|
"grid-template-columns",
|
|
"grid-template-rows",
|
|
"margin-top",
|
|
"margin-right",
|
|
"margin-bottom",
|
|
"margin-left",
|
|
"padding-top",
|
|
"padding-right",
|
|
"padding-bottom",
|
|
"padding-left",
|
|
"width",
|
|
"height",
|
|
"min-width",
|
|
"min-height",
|
|
"max-width",
|
|
"max-height",
|
|
"box-sizing",
|
|
"overflow",
|
|
"opacity",
|
|
"visibility",
|
|
"transform",
|
|
"box-shadow",
|
|
"border-top-width",
|
|
"border-top-style",
|
|
"border-top-color",
|
|
"border-radius",
|
|
"background-color",
|
|
"background-image",
|
|
"background-size",
|
|
"background-position",
|
|
"color",
|
|
"font-family",
|
|
"font-size",
|
|
"font-weight",
|
|
"font-style",
|
|
"line-height",
|
|
"letter-spacing",
|
|
"text-align",
|
|
"text-transform",
|
|
"white-space",
|
|
"vertical-align",
|
|
];
|
|
|
|
// --- capture: open a page and snapshot its tree via getComputedStyle ---
|
|
|
|
async function capture(browser, target, viewport) {
|
|
const url = target.startsWith("http")
|
|
? target
|
|
: "file:///" + target.replace(/\\/g, "/");
|
|
const page = await browser.newPage({ viewport });
|
|
await page.goto(url, { waitUntil: "networkidle" });
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
});
|
|
// Normalize both sides to light theme so we measure real design diffs,
|
|
// not a theme inversion.
|
|
await page.evaluate(() => {
|
|
document.documentElement.dataset.theme = "light";
|
|
});
|
|
// Get deterministic base state: no element under the cursor and no focused
|
|
// element, so pointer/keyboard state pseudo-classes (:hover/:focus/:active)
|
|
// never match. Hover rules are simply not part of a static snapshot.
|
|
await page.mouse.move(-10, -10);
|
|
await page.evaluate(() => document.activeElement?.blur?.());
|
|
await page.waitForTimeout(400);
|
|
|
|
const tree = await page.evaluate((props) => {
|
|
const NOISE_TAGS = new Set([
|
|
"script",
|
|
"template",
|
|
"style",
|
|
"link",
|
|
"noscript",
|
|
]);
|
|
// Properties that inherit by default — their specified value flows to children.
|
|
const INHERITED = new Set([
|
|
"color",
|
|
"cursor",
|
|
"direction",
|
|
"font-family",
|
|
"font-size",
|
|
"font-style",
|
|
"font-variant",
|
|
"font-weight",
|
|
"letter-spacing",
|
|
"line-height",
|
|
"text-align",
|
|
"text-indent",
|
|
"text-shadow",
|
|
"text-transform",
|
|
"visibility",
|
|
"white-space",
|
|
"word-spacing",
|
|
]);
|
|
|
|
// ---- collect author rules (stylesheets) preserving cascade order ----
|
|
const rules = [];
|
|
let ruleOrder = 0;
|
|
const collect = (list) => {
|
|
for (const r of list || []) {
|
|
if (r == null) continue;
|
|
if (r.type === 1) rules.push({ rule: r, order: ruleOrder++ });
|
|
else if (r.type === 3 && r.styleSheet) {
|
|
try {
|
|
collect(r.styleSheet.cssRules);
|
|
} catch {}
|
|
} else if (r.type === 4) {
|
|
if (r.media?.matches) {
|
|
try {
|
|
collect(r.cssRules);
|
|
} catch {}
|
|
}
|
|
} else if (r.type === 12) {
|
|
try {
|
|
if (CSS.supports(r.conditionText)) collect(r.cssRules);
|
|
} catch {
|
|
try {
|
|
collect(r.cssRules);
|
|
} catch {}
|
|
}
|
|
} else if (r.type === 15) {
|
|
try {
|
|
collect(r.cssRules);
|
|
} catch {}
|
|
}
|
|
}
|
|
};
|
|
for (const sheet of document.styleSheets) {
|
|
try {
|
|
collect(sheet.cssRules);
|
|
} catch {}
|
|
}
|
|
|
|
|
|
|
|
// Selector specificity parser (approximation of the CSS algorithm):
|
|
// ids, classes, attributes, elements, plus :is/:not/:has (max of args)
|
|
// and :where (zero).
|
|
const splitTop = (s, sep) => {
|
|
const parts = [];
|
|
let depth = 0,
|
|
cur = "";
|
|
const isSep = (ch) =>
|
|
sep === "," ? ch === "," : ch === ">" || ch === "+" || ch === "~" || /\s/.test(ch);
|
|
for (const ch of s) {
|
|
if (ch === "(" || ch === "[") depth++;
|
|
else if (ch === ")" || ch === "]") depth--;
|
|
if (depth === 0 && isSep(ch)) {
|
|
if (cur.trim()) parts.push(cur.trim());
|
|
cur = "";
|
|
} else cur += ch;
|
|
}
|
|
if (cur.trim()) parts.push(cur.trim());
|
|
return parts;
|
|
};
|
|
const specCompound = (full) => {
|
|
let [A, B, C] = [0, 0, 0];
|
|
for (const comp of splitTop(full, " ")) {
|
|
const n = comp.length;
|
|
let i = 0;
|
|
const ident = (ch) => /[\w\u00A0-\uFFFF-]/.test(ch);
|
|
const readName = () => {
|
|
let o = "";
|
|
while (i < n && ident(comp[i])) o += comp[i++];
|
|
return o;
|
|
};
|
|
const endParen = () => {
|
|
let d = 1;
|
|
i++;
|
|
while (i < n) {
|
|
if (comp[i] === "(" || comp[i] === "[") d++;
|
|
else if (comp[i] === ")") {
|
|
d--;
|
|
if (!d) return i;
|
|
}
|
|
i++;
|
|
}
|
|
return n;
|
|
};
|
|
while (i < n) {
|
|
const ch = comp[i];
|
|
if (ch === "#") {
|
|
A++;
|
|
i++;
|
|
readName();
|
|
} else if (ch === ".") {
|
|
B++;
|
|
i++;
|
|
readName();
|
|
} else if (ch === "*") i++;
|
|
else if (ch === "[") {
|
|
B++;
|
|
while (i < n && comp[i] !== "]") i++;
|
|
i++;
|
|
} else if (ch === ":") {
|
|
if (comp[i + 1] === ":") {
|
|
C++;
|
|
i += 2;
|
|
readName();
|
|
} else {
|
|
i++;
|
|
const name = readName();
|
|
if (comp[i] === "(") {
|
|
const inner = comp.slice(i + 1, endParen());
|
|
// :is/:not/:has take the most specific argument.
|
|
if (name !== "where") {
|
|
let a = 0,
|
|
b = 0,
|
|
c = 0;
|
|
for (const arg of splitTop(inner, ",")) {
|
|
const [aa, bb, cc] = specCompound(arg);
|
|
a = Math.max(a, aa);
|
|
b = Math.max(b, bb);
|
|
c = Math.max(c, cc);
|
|
}
|
|
A += a;
|
|
B += b;
|
|
C += c;
|
|
}
|
|
} else B++;
|
|
}
|
|
} else if (/[a-zA-Z]/.test(ch)) {
|
|
C++;
|
|
readName();
|
|
} else i++;
|
|
}
|
|
}
|
|
return [A, B, C];
|
|
};
|
|
|
|
// All declarations that apply to `el`, in cascade terms.
|
|
const matched = (el) => {
|
|
const out = [];
|
|
for (const { rule, order } of rules) {
|
|
const sel = rule.selectorText;
|
|
if (!sel) continue;
|
|
// Specificity is per complex selector: a comma list like
|
|
// ".a, .b .c" does NOT sum. Match each complex selector on its
|
|
// own and use its specificity; ties are broken by source order.
|
|
for (const cs of splitTop(sel, ",")) {
|
|
let ok = false;
|
|
try {
|
|
ok = el.matches(cs);
|
|
} catch {
|
|
try {
|
|
ok = el.matches(sel);
|
|
} catch {}
|
|
}
|
|
if (!ok) continue;
|
|
const [a, b, c] = specCompound(cs);
|
|
const st = rule.style;
|
|
for (let k = 0; k < st.length; k++) {
|
|
const name = st.item(k);
|
|
out.push({
|
|
name,
|
|
value: st.getPropertyValue(name).trim(),
|
|
important: st.getPropertyPriority(name) === "important",
|
|
a,
|
|
b,
|
|
c,
|
|
order,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const inline = el.getAttribute("style");
|
|
if (inline) {
|
|
const st = el.style;
|
|
for (let k = 0; k < st.length; k++) {
|
|
const name = st.item(k);
|
|
out.push({
|
|
name,
|
|
value: st.getPropertyValue(name).trim(),
|
|
important: st.getPropertyPriority(name) === "important",
|
|
a: 1e6,
|
|
b: 0,
|
|
c: 0,
|
|
order: 1e6,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
const keyOf = (d) => [d.important ? 1 : 0, d.a, d.b, d.c, d.order];
|
|
const betterThan = (x, y) => {
|
|
const kx = keyOf(x),
|
|
ky = keyOf(y);
|
|
for (let i = 0; i < kx.length; i++)
|
|
if (kx[i] !== ky[i]) return kx[i] > ky[i] ? 1 : -1;
|
|
return 0;
|
|
};
|
|
// Pick the winning declaration per property (inline > !important > cascade).
|
|
const resolve = (decls) => {
|
|
const map = new Map();
|
|
for (const d of decls) {
|
|
const cur = map.get(d.name);
|
|
if (!cur || betterThan(d, cur) > 0) map.set(d.name, d);
|
|
}
|
|
return map;
|
|
};
|
|
// Inherited entries must always lose against a real declaration.
|
|
// Custom properties inherit too, so they propagate like inherited props.
|
|
const inheritedEntries = (m) => {
|
|
const out = [];
|
|
for (const [k, v] of m)
|
|
if (
|
|
(INHERITED.has(k) || k.startsWith("--")) &&
|
|
typeof v === "object" &&
|
|
v !== null
|
|
)
|
|
out.push([
|
|
k,
|
|
{
|
|
name: k,
|
|
value: v.value ?? "",
|
|
important: false,
|
|
a: -1,
|
|
b: -1,
|
|
c: -1,
|
|
order: -1,
|
|
},
|
|
]);
|
|
return out;
|
|
};
|
|
// Substitute var(--name[, fallback]) with the resolved custom property value
|
|
// from getComputedStyle (custom props inherit, so per-element lookup works).
|
|
const resolveVars = (value, cs, depth = 0) => {
|
|
if (depth > 8 || !value.includes("var(")) return value;
|
|
return value.replace(
|
|
/var\((--[\w-]+)(?:,([^)]*))?\)/g,
|
|
(_, name, fb) => {
|
|
const v = cs.getPropertyValue(name).trim();
|
|
if (v) return resolveVars(v, cs, depth + 1);
|
|
return fb ? resolveVars(fb, cs, depth + 1).trim() : "";
|
|
},
|
|
);
|
|
};
|
|
|
|
const snap = (el, inherited) => {
|
|
const tag = el.tagName.toLowerCase();
|
|
if (
|
|
NOISE_TAGS.has(tag) ||
|
|
tag === "vite-error-overlay" ||
|
|
el.id === "svelte-announcer"
|
|
)
|
|
return null;
|
|
const specMap = new Map(inheritedEntries(inherited));
|
|
for (const [name, d] of resolve(matched(el))) {
|
|
const cur = specMap.get(name);
|
|
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
|
|
}
|
|
const cs = getComputedStyle(el);
|
|
const styles = {};
|
|
const raw = {};
|
|
const computed = {};
|
|
for (const p of props) {
|
|
const c = cs.getPropertyValue(p);
|
|
computed[p] = c;
|
|
const rv = specMap.get(p)?.value ?? "";
|
|
raw[p] = rv;
|
|
let v = rv;
|
|
if (v.includes("var(")) v = resolveVars(v, cs);
|
|
styles[p] = v;
|
|
}
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
tag,
|
|
classes: (el.getAttribute("class") ?? "")
|
|
.split(/\s+/)
|
|
.filter((c) => c && !c.startsWith("svelte-")),
|
|
text: [...el.childNodes]
|
|
.filter((n) => n.nodeType === 3)
|
|
.map((n) => n.textContent.trim())
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
box: {
|
|
x: +r.x.toFixed(1),
|
|
y: +r.y.toFixed(1),
|
|
w: +r.width.toFixed(1),
|
|
h: +r.height.toFixed(1),
|
|
},
|
|
styles,
|
|
raw,
|
|
computed,
|
|
children:
|
|
tag === "svg"
|
|
? []
|
|
: [...el.children].map((ch) => snap(ch, specMap)).filter(Boolean),
|
|
};
|
|
};
|
|
const htmlNode = snap(document.documentElement, new Map());
|
|
const findBody = (n) =>
|
|
n?.tag === "body" ? n : (n?.children ?? []).map(findBody).find(Boolean);
|
|
const body = findBody(htmlNode);
|
|
if (!body) throw new Error("capture: body not found");
|
|
return body;
|
|
}, PROPS);
|
|
|
|
await page.close();
|
|
return tree;
|
|
}
|
|
|
|
// --- capture via CDP DOMSnapshot (alternative path, kept for reference) ---
|
|
|
|
async function captureCdp(browser, target) {
|
|
const url = target.startsWith("http")
|
|
? target
|
|
: "file:///" + target.replace(/\\/g, "/");
|
|
const page = await browser.newPage({
|
|
viewport: { width: 1440, height: 900 },
|
|
});
|
|
await page.goto(url, { waitUntil: "networkidle" });
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
});
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const snap = await cdp.send("DOMSnapshot.captureSnapshot", {
|
|
computedStyles: PROPS,
|
|
});
|
|
await page.close();
|
|
if (!snap?.documents?.length)
|
|
throw new Error(
|
|
"captureSnapshot: no documents, keys: " + Object.keys(snap ?? {}),
|
|
);
|
|
return toTree(snap);
|
|
}
|
|
|
|
function toTree(snap) {
|
|
const S = snap.strings;
|
|
const doc = snap.documents[0];
|
|
const nodes = doc.nodes;
|
|
const layout = doc.layout;
|
|
const deref = (v) => (typeof v === "number" ? S[v] : v);
|
|
|
|
if (layout.nodeIndex.length !== layout.bounds.length)
|
|
throw new Error("toTree: nodeIndex and bounds are not parallel");
|
|
|
|
// styles: flat array of VALUE indices, positional to PROPS.
|
|
// Property names are not stored in strings — mapping only via whitelist order.
|
|
const stylesByNode = new Map();
|
|
for (let k = 0; k < layout.nodeIndex.length; k++) {
|
|
const raw = layout.styles[k] ?? [];
|
|
if (raw.length && raw.length !== PROPS.length)
|
|
console.warn(
|
|
`toTree: node ${layout.nodeIndex[k]}: ${raw.length} values ` +
|
|
`for ${PROPS.length} requested — possible shift, node styles unreliable`,
|
|
);
|
|
const styles = {};
|
|
PROPS.forEach((prop, j) => {
|
|
if (raw[j] != null) styles[prop] = S[raw[j]] ?? "";
|
|
});
|
|
stylesByNode.set(layout.nodeIndex[k], styles);
|
|
}
|
|
|
|
const boxByNode = new Map(
|
|
layout.nodeIndex.map((domIdx, k) => {
|
|
const b = layout.bounds[k];
|
|
return [
|
|
domIdx,
|
|
{
|
|
x: +b[0].toFixed(1),
|
|
y: +b[1].toFixed(1),
|
|
w: +b[2].toFixed(1),
|
|
h: +b[3].toFixed(1),
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
|
|
const pseudoByNode = new Set(nodes.pseudoType?.index ?? []);
|
|
|
|
const kids = new Map();
|
|
for (let i = 0; i < nodes.nodeType.length; i++) {
|
|
const p = nodes.parentIndex?.[i];
|
|
if (p == null || p < 0) continue;
|
|
if (!kids.has(p)) kids.set(p, []);
|
|
kids.get(p).push(i);
|
|
}
|
|
|
|
const NOISE_TAGS = new Set([
|
|
"script",
|
|
"template",
|
|
"style",
|
|
"link",
|
|
"noscript",
|
|
]);
|
|
|
|
const build = (i) => {
|
|
if (nodes.nodeType[i] !== 1 || pseudoByNode.has(i)) return null;
|
|
const a = nodes.attributes?.[i] ?? [];
|
|
const attrs = {};
|
|
for (let k = 0; k + 1 < a.length; k += 2)
|
|
attrs[deref(a[k])] = deref(a[k + 1]);
|
|
const tag = deref(nodes.nodeName[i]).toLowerCase();
|
|
if (
|
|
NOISE_TAGS.has(tag) ||
|
|
tag === "vite-error-overlay" ||
|
|
attrs.id === "svelte-announcer"
|
|
)
|
|
return null;
|
|
const text = (kids.get(i) ?? [])
|
|
.filter((c) => nodes.nodeType[c] === 3)
|
|
.map((c) => (deref(nodes.nodeValue[c]) ?? "").trim())
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return {
|
|
tag: deref(nodes.nodeName[i]).toLowerCase(),
|
|
classes: (attrs.class ?? "")
|
|
.split(/\s+/)
|
|
.filter((c) => c && !c.startsWith("svelte-")),
|
|
text,
|
|
box: boxByNode.get(i), // undefined = node not rendered
|
|
styles: stylesByNode.get(i) ?? {},
|
|
children:
|
|
tag === "svg"
|
|
? []
|
|
: (kids.get(i) ?? []).map(build).filter(Boolean),
|
|
};
|
|
};
|
|
|
|
const rootIdx = nodes.nodeType.indexOf(9);
|
|
const htmlIdx = (kids.get(rootIdx) ?? []).find(
|
|
(i) => nodes.nodeType[i] === 1,
|
|
);
|
|
const bodyIdx = (kids.get(htmlIdx) ?? []).find(
|
|
(i) => nodes.nodeType[i] === 1 && deref(nodes.nodeName[i]) === "BODY",
|
|
);
|
|
const tree = build(bodyIdx);
|
|
if (!tree) throw new Error("toTree: body not found");
|
|
// At least one rendered node must have the full set of values.
|
|
if (
|
|
![...stylesByNode.values()].some(
|
|
(s) => Object.keys(s).length === PROPS.length,
|
|
)
|
|
)
|
|
throw new Error(
|
|
"toTree: no node has a full set of styles — whitelist/indexing broken",
|
|
);
|
|
return tree;
|
|
}
|
|
|
|
// node signature for matching: tag + classes (no svelte suffixes), or text
|
|
const sig = (n) =>
|
|
n.tag +
|
|
(n.classes.length
|
|
? "." + [...n.classes].sort().join(".")
|
|
: n.text
|
|
? ":" + n.text.slice(0, 30)
|
|
: "");
|
|
|
|
const sameNode = (x, y) => {
|
|
if (x.tag !== y.tag) return false;
|
|
if (x.tag === "svg") return true; // icons: design vs library classes differ
|
|
// Leaf controls (buttons/links/labels) are identified by their text label:
|
|
// the ref and the app may name the same control with different classes
|
|
// (e.g. "selected" vs "segment selected"), so a class-list match alone
|
|
// mis-pairs them (and a classless ref button would otherwise match any
|
|
// sibling). Matching by label keeps the pairings stable.
|
|
if (
|
|
(x.tag === "button" || x.tag === "a" || x.tag === "label") && x.text && y.text
|
|
)
|
|
return x.text === y.text;
|
|
const cx = [...x.classes].sort().join(" ");
|
|
const cy = [...y.classes].sort().join(" ");
|
|
return cx === cy || !x.classes.length || !y.classes.length;
|
|
};
|
|
|
|
// CSS selector segment: nth-of-type only when several such tags under parent
|
|
const seg = (node, i, siblings) => {
|
|
if (siblings.filter((c) => c.tag === node.tag).length < 2) return node.tag;
|
|
const nth = siblings.slice(0, i + 1).filter((c) => c.tag === node.tag).length;
|
|
return `${node.tag}:nth-of-type(${nth})`;
|
|
};
|
|
|
|
// human-readable annotation: classes + text, to make the node easy to find
|
|
const annotate = (node) => {
|
|
if (!node) return "";
|
|
const cls = node.classes.length
|
|
? " [" + [...node.classes].sort().join(" ") + "]"
|
|
: "";
|
|
const txt = node.text ? ` "${node.text.slice(0, 40)}"` : "";
|
|
return cls + txt;
|
|
};
|
|
|
|
function diffStyles(a, b) {
|
|
const out = [];
|
|
if (!a?.styles || !b?.styles)
|
|
return [
|
|
`⚠ node without styles (a.tag=${a?.tag}, b.tag=${b?.tag}) — matching bug, inspect manually`,
|
|
];
|
|
|
|
// Layout-sensitive props must never fall back to computed: keeping them
|
|
// specified (e.g. "auto") is what makes this audit content-size independent.
|
|
const LAYOUT = new Set([
|
|
"width",
|
|
"height",
|
|
"min-width",
|
|
"min-height",
|
|
"max-width",
|
|
"max-height",
|
|
"top",
|
|
"right",
|
|
"bottom",
|
|
"left",
|
|
"margin-top",
|
|
"margin-right",
|
|
"margin-bottom",
|
|
"margin-left",
|
|
"row-gap",
|
|
"column-gap",
|
|
]);
|
|
const DEFAULTISH = (v) =>
|
|
v === "" ||
|
|
v === "inherit" ||
|
|
v === "initial" ||
|
|
v === "unset" ||
|
|
v === "revert";
|
|
// Geometry drift along an axis only matters when that dimension is actually
|
|
// locked by CSS; otherwise it is just content flow (text lengths, images).
|
|
const flowDriven = (node, k) => {
|
|
if (!node?.styles) return true;
|
|
if (k === "w") return DEFAULTISH(node.styles.width);
|
|
if (k === "h") return DEFAULTISH(node.styles.height);
|
|
if (k === "x") return DEFAULTISH(node.styles.left) && DEFAULTISH(node.styles.right);
|
|
if (k === "y") return DEFAULTISH(node.styles.top) && DEFAULTISH(node.styles.bottom);
|
|
return true;
|
|
};
|
|
|
|
if (!!a.box !== !!b.box)
|
|
out.push(
|
|
`render: node is rendered only on one side ` +
|
|
`(no layout box — usually display:none on one side)`,
|
|
);
|
|
|
|
// If a side is declared via CSS variables, append the raw declaration so
|
|
// the defining rule is easy to find (e.g. "color: var(--accent)").
|
|
const fmtSide = (n, k) => {
|
|
const res = n?.styles?.[k] ?? "";
|
|
const r = n?.raw?.[k] ?? "";
|
|
if (r && r.includes("var(") && r !== res)
|
|
return `${res || "—"} [${r}]`;
|
|
return res;
|
|
};
|
|
// Which style props were already reported as diffs — box axes governed by
|
|
// them are redundant then.
|
|
const styleDiffKeys = new Set();
|
|
for (const k of new Set([
|
|
...Object.keys(a.styles),
|
|
...Object.keys(b.styles),
|
|
])) {
|
|
const va = a.styles[k] ?? "",
|
|
vb = b.styles[k] ?? "";
|
|
if (va !== vb) {
|
|
// One side relied on a default (UA/inherit) while the other spelled it
|
|
// out, but they render identically — not a real difference. Skip for
|
|
// content-independent props by comparing the computed values.
|
|
if (
|
|
!LAYOUT.has(k) &&
|
|
(DEFAULTISH(va) || DEFAULTISH(vb)) &&
|
|
a.computed?.[k] === b.computed?.[k]
|
|
)
|
|
continue;
|
|
out.push(`${k}: ${fmtSide(a, k) || "—"} → ${fmtSide(b, k) || "—"}`);
|
|
styleDiffKeys.add(k);
|
|
}
|
|
}
|
|
|
|
// Box axes map to style props: w→width, h→height, x→left/right, y→top/bottom.
|
|
// If the corresponding style diff is already above, the geometry line is a
|
|
// duplicate — drop it. Keep geometry only for changes the styles don't
|
|
// explain (e.g. different border/padding with the same declared width).
|
|
if (a.box && b.box)
|
|
for (const k of ["x", "y", "w", "h"]) {
|
|
const gov =
|
|
k === "w"
|
|
? ["width"]
|
|
: k === "h"
|
|
? ["height"]
|
|
: k === "x"
|
|
? ["left", "right"]
|
|
: ["top", "bottom"];
|
|
if (gov.some((p) => styleDiffKeys.has(p))) continue;
|
|
const d = +(b.box[k] - a.box[k]).toFixed(1);
|
|
if (Math.abs(d) > PX_TOL && !(flowDriven(a, k) && flowDriven(b, k)))
|
|
out.push(
|
|
`${k}: ${a.box[k]} → ${b.box[k]} (${d > 0 ? "+" : ""}${d}px)`,
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function compare(a, b, path, report) {
|
|
const sel = path.join(" > ");
|
|
const changes = diffStyles(a, b);
|
|
if (changes.length)
|
|
report.push(
|
|
sel +
|
|
annotate(b ?? a) +
|
|
"\n" +
|
|
changes.map((c) => " " + c).join("\n"),
|
|
);
|
|
|
|
const removed = [],
|
|
added = [];
|
|
let ai = 0,
|
|
bi = 0;
|
|
for (const g of diffArrays(a.children, b.children, {
|
|
comparator: sameNode,
|
|
})) {
|
|
if (g.removed)
|
|
for (let k = 0; k < g.count; k++, ai++)
|
|
removed.push([a.children[ai], ai]);
|
|
else if (g.added)
|
|
for (let k = 0; k < g.count; k++, bi++) added.push([b.children[bi], bi]);
|
|
else
|
|
for (let k = 0; k < g.count; k++, ai++, bi++)
|
|
compare(a.children[ai], b.children[bi], [
|
|
...path,
|
|
seg(a.children[ai], ai, a.children),
|
|
], report);
|
|
}
|
|
|
|
// rescue: pair leftovers by tag instead of reporting ✗/+
|
|
const rest = [];
|
|
for (const [node, i] of removed) {
|
|
const j = added.findIndex(([n]) => n.tag === node.tag);
|
|
if (j < 0) {
|
|
rest.push([node, i]);
|
|
continue;
|
|
}
|
|
const [[bNode]] = added.splice(j, 1);
|
|
compare(node, bNode, [...path, seg(node, i, a.children)], report);
|
|
}
|
|
for (const [node, i] of rest)
|
|
report.push(
|
|
`${[...path, seg(node, i, a.children)].join(" >")}${annotate(node)}\n ✗ present in reference, absent in project`,
|
|
);
|
|
for (const [node, i] of added)
|
|
report.push(
|
|
`${[...path, seg(node, i, b.children)].join(" >")}${annotate(node)}\n + present in project, absent in reference`,
|
|
);
|
|
}
|
|
|
|
// --- target discovery (mirrors audit-css.mjs) ---
|
|
|
|
// Dynamic segments: explicit id values + id→ref-file mapping.
|
|
const DYNAMIC = {
|
|
"tools/[id]": {
|
|
ids: ["linear-gradient-png", "remove-background-png"],
|
|
refFor: (id) =>
|
|
id === "linear-gradient-png"
|
|
? "gradient.html"
|
|
: id === "remove-background-png"
|
|
? "background-remover.html"
|
|
: null,
|
|
},
|
|
};
|
|
|
|
const EXCLUDE = new Set(["/preview"]);
|
|
const refNameFor = (route) => {
|
|
if (route === "/preview") return "index.html";
|
|
const last = route.split("/").filter(Boolean).pop();
|
|
return `${last}.html`;
|
|
};
|
|
|
|
function discoverRoutes() {
|
|
const base = resolve(WEB, "src/routes/preview");
|
|
const out = [];
|
|
const walk = (dir) => {
|
|
let ents;
|
|
try {
|
|
ents = readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const e of ents) {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) walk(p);
|
|
else if (e.name === "+page.svelte") {
|
|
const rel = p.slice(base.length).replace(/\\/g, "/");
|
|
const parts = rel.split("/").filter(Boolean);
|
|
parts.pop();
|
|
out.push(parts.length ? `/preview/${parts.join("/")}` : "/preview");
|
|
}
|
|
}
|
|
};
|
|
walk(base);
|
|
return out;
|
|
}
|
|
|
|
function refsList() {
|
|
try {
|
|
return readdirSync(REFS_DIR).filter((f) => f.endsWith(".html"));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
const refExists = (name) => refsList().includes(name);
|
|
|
|
function buildTargets() {
|
|
if (argv.route) {
|
|
const ref = argv.ref || refNameFor(argv.route);
|
|
return [{ route: argv.route, ref }];
|
|
}
|
|
const targets = [];
|
|
const seen = new Set();
|
|
const add = (route, ref) => {
|
|
if (!ref || seen.has(route)) return;
|
|
if (!refExists(ref)) {
|
|
console.warn(`[cdp-audit] ref not found, skipped: ${route} -> ${ref}`);
|
|
return;
|
|
}
|
|
seen.add(route);
|
|
targets.push({ route, ref });
|
|
};
|
|
for (const route of discoverRoutes()) {
|
|
if (route.includes("[")) continue;
|
|
if (EXCLUDE.has(route)) {
|
|
console.log(`[cdp-audit] excluded (no relevant ref): ${route}`);
|
|
continue;
|
|
}
|
|
add(route, refNameFor(route));
|
|
}
|
|
for (const [seg, cfg] of Object.entries(DYNAMIC)) {
|
|
for (const id of cfg.ids) {
|
|
add(`/preview/${seg.replace("[id]", id)}`, cfg.refFor(id));
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
const waitFor = async (url, ms = 60000) => {
|
|
const t = Date.now();
|
|
while (Date.now() - t < ms) {
|
|
try {
|
|
const r = await fetch(url);
|
|
if (r.ok) return;
|
|
if (r.status >= 400) {
|
|
throw new Error(
|
|
`route ${url} returned HTTP ${r.status} (server is up, page failed to render)`,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof Error && e.message.includes("returned HTTP")) throw e;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error("dev server not up: " + url);
|
|
};
|
|
|
|
async function auditOne(browser, target, viewport) {
|
|
const oursUrl = argv.ours || `http://127.0.0.1:${PORT}${target.route}`;
|
|
const refUrl = resolve(REFS_DIR, target.ref);
|
|
|
|
const ours = await capture(browser, oursUrl, viewport);
|
|
const ref = await capture(browser, refUrl, viewport);
|
|
|
|
const report = [];
|
|
compare(ref, ours, ["body"], report);
|
|
return { route: target.route, ref: target.ref, viewport, lines: report };
|
|
}
|
|
|
|
async function main() {
|
|
const targets = buildTargets();
|
|
if (!targets.length) {
|
|
console.error("no audit targets (refs-html/*.html missing?)");
|
|
process.exit(1);
|
|
}
|
|
console.log(`[cdp-audit] targets: ${targets.map((t) => t.route).join(", ")}`);
|
|
|
|
let server;
|
|
if (!argv.noServe) {
|
|
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
|
|
cwd: WEB,
|
|
stdio: "ignore",
|
|
shell: true,
|
|
detached: process.platform !== "win32",
|
|
});
|
|
await waitFor(argv.ours || `http://127.0.0.1:${PORT}${targets[0].route}`);
|
|
}
|
|
|
|
const browser = await chromium.launch();
|
|
const reports = [];
|
|
try {
|
|
for (const vp of viewports) {
|
|
for (const target of targets) {
|
|
try {
|
|
const rep = await auditOne(browser, target, vp);
|
|
reports.push(rep);
|
|
console.log(
|
|
`[cdp-audit] [${vpLabel(vp)}] ${rep.route} (vs ${rep.ref}): ${rep.lines.length} differences`,
|
|
);
|
|
} catch (e) {
|
|
console.error(
|
|
`[cdp-audit] [${vpLabel(vp)}] ${target.route} failed: ${e.message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
if (server) {
|
|
try {
|
|
if (process.platform === "win32")
|
|
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
|
|
stdio: "ignore",
|
|
});
|
|
else server.kill("SIGTERM");
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
const outDir = resolve(WEB, "audit");
|
|
mkdirSync(outDir, { recursive: true });
|
|
for (const vp of viewports) {
|
|
const vReps = reports.filter(
|
|
(r) => r.viewport.width === vp.width && r.viewport.height === vp.height,
|
|
);
|
|
const header =
|
|
`CDP visual audit\n${new Date().toISOString()}\n` +
|
|
`viewport ${vpLabel(vp)}, tolerance ${PX_TOL}px, theme light\n\n`;
|
|
const body = vReps
|
|
.map((r) => {
|
|
const title = `=== [${vpLabel(vp)}] ${r.route} (vs ${r.ref}) — ${r.lines.length} differences ===`;
|
|
if (!r.lines.length) return title + "\n ✅ no differences found";
|
|
return title + "\n\n" + r.lines.join("\n\n");
|
|
})
|
|
.join("\n\n\n");
|
|
const outPath = reportPath(vp);
|
|
writeFileSync(outPath, header + body + "\n", "utf8");
|
|
console.log(`report: ${outPath}`);
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|