Files
easy-png-tools/web/scripts/audit-cdp.mjs
T

1015 lines
27 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";
});
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 {}
}
// Strip state pseudo-classes that depend on the current pointer/focus state.
const STATE = /:(hover|active|focus|focus-visible|focus-within|visited)\b/g;
// 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.replace(STATE, ""), ",")) {
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
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);
});