chore: update cdp audit script
This commit is contained in:
+268
-194
@@ -13,13 +13,17 @@
|
||||
// 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";
|
||||
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const argv = (() => {
|
||||
// ==== CLI / config =========================================================
|
||||
|
||||
const args = parseCli();
|
||||
|
||||
function parseCli() {
|
||||
const a = { viewports: [] };
|
||||
for (let i = 0; i < process.argv.length; i++) {
|
||||
const v = process.argv[i];
|
||||
@@ -29,22 +33,26 @@ const argv = (() => {
|
||||
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),
|
||||
);
|
||||
a.viewports.push(...(process.argv[++i] ?? "").split(",").filter(Boolean));
|
||||
}
|
||||
return a;
|
||||
})();
|
||||
}
|
||||
|
||||
const WEB = process.cwd();
|
||||
const PORT = argv.port || 5179;
|
||||
const PORT = args.port || 5179;
|
||||
const REFS_DIR = resolve(WEB, "../refs-html");
|
||||
const PX_TOL = 1;
|
||||
// Per-page navigation timeout (ms). Guards against hanging indefinitely when a
|
||||
// served URL is unreachable (e.g. --no-serve with no server actually running).
|
||||
const NAV_TIMEOUT = 20_000;
|
||||
|
||||
// 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 viewports = parseViewports();
|
||||
const vpLabel = (vp) => `${vp.width}x${vp.height}`;
|
||||
|
||||
function parseViewports() {
|
||||
const raw = args.viewports.length ? args.viewports : ["1440x900"];
|
||||
const out = [];
|
||||
for (const s of raw) {
|
||||
const m = /^(\d+)x(\d+)$/.exec(s.trim());
|
||||
@@ -57,12 +65,11 @@ const viewports = (() => {
|
||||
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 perResFile = args.viewports.length > 0;
|
||||
const reportPath = (vp) =>
|
||||
resolve(
|
||||
WEB,
|
||||
@@ -70,6 +77,7 @@ const reportPath = (vp) =>
|
||||
perResFile ? `cdp-audit-${vpLabel(vp)}.txt` : "cdp-audit.txt",
|
||||
);
|
||||
|
||||
// Properties snapshot for every node, in both specified and computed form.
|
||||
const PROPS = [
|
||||
"display",
|
||||
"position",
|
||||
@@ -129,39 +137,11 @@ const PROPS = [
|
||||
"vertical-align",
|
||||
];
|
||||
|
||||
// --- capture: open a page and snapshot its tree via getComputedStyle ---
|
||||
// Tags/elements never included in the snapshot.
|
||||
const NOISE_TAGS = new Set(["script", "template", "style", "link", "noscript"]);
|
||||
|
||||
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([
|
||||
// Properties that inherit by default — their specified value flows to children.
|
||||
const INHERITED = new Set([
|
||||
"color",
|
||||
"cursor",
|
||||
"direction",
|
||||
@@ -179,36 +159,95 @@ async function capture(browser, target, viewport) {
|
||||
"visibility",
|
||||
"white-space",
|
||||
"word-spacing",
|
||||
]);
|
||||
]);
|
||||
|
||||
// ==== 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",
|
||||
// Fail fast instead of hanging when the served URL is unreachable
|
||||
// (e.g. --no-serve but no server actually running).
|
||||
timeout: NAV_TIMEOUT,
|
||||
});
|
||||
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);
|
||||
|
||||
// Pass Sets as plain arrays (page.evaluate serializes, losing Set-ness) and
|
||||
// rebuild them inside the browser context.
|
||||
const tree = await page.evaluate(snapshotDocument, {
|
||||
PROPS,
|
||||
NOISE_TAGS: [...NOISE_TAGS],
|
||||
INHERITED: [...INHERITED],
|
||||
});
|
||||
await page.close();
|
||||
return tree;
|
||||
}
|
||||
|
||||
// Browser-side cascade engine. Snapshot the document tree and, for every node,
|
||||
// compute the SPECIFIED value of each requested property — what the CSS
|
||||
// declares after cascade + inheritance + var() resolution — alongside the
|
||||
// computed value from getComputedStyle. Runs inside the page via
|
||||
// page.evaluate, so it must stay self-contained (browser globals + the arg).
|
||||
function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED }) {
|
||||
// page.evaluate hand-serialized the Sets to plain arrays — restore them.
|
||||
NOISE_TAGS = new Set(NOISE_TAGS);
|
||||
INHERITED = new Set(INHERITED);
|
||||
|
||||
// ---- collect author rules (stylesheets) preserving cascade order ----
|
||||
// Recurse into container rules AND native CSS nesting (Svelte emits nested
|
||||
// "& output:where(.svelte-x)" rules whose declarations would otherwise be
|
||||
// invisible), expanding the "&" reference to the parent selector so the
|
||||
// rule still matches via el.matches() and parses for specificity.
|
||||
function collectRules() {
|
||||
const rules = [];
|
||||
let ruleOrder = 0;
|
||||
const collect = (list) => {
|
||||
const collect = (list, parentSel = "") => {
|
||||
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) {
|
||||
if (r.type === 1) {
|
||||
let sel = r.selectorText;
|
||||
if (parentSel && sel?.includes("&"))
|
||||
sel = sel.replace(/&/g, parentSel);
|
||||
rules.push({ rule: r, effective: sel, order: ruleOrder++ });
|
||||
if (r.cssRules?.length) collect(r.cssRules, sel ?? parentSel);
|
||||
} else if (r.type === 3 && r.styleSheet) {
|
||||
try {
|
||||
collect(r.styleSheet.cssRules);
|
||||
collect(r.styleSheet.cssRules, parentSel);
|
||||
} catch {}
|
||||
} else if (r.type === 4) {
|
||||
if (r.media?.matches) {
|
||||
try {
|
||||
collect(r.cssRules);
|
||||
collect(r.cssRules, parentSel);
|
||||
} catch {}
|
||||
}
|
||||
} else if (r.type === 12) {
|
||||
try {
|
||||
if (CSS.supports(r.conditionText)) collect(r.cssRules);
|
||||
if (CSS.supports(r.conditionText)) collect(r.cssRules, parentSel);
|
||||
} catch {
|
||||
try {
|
||||
collect(r.cssRules);
|
||||
collect(r.cssRules, parentSel);
|
||||
} catch {}
|
||||
}
|
||||
} else if (r.type === 15) {
|
||||
try {
|
||||
collect(r.cssRules);
|
||||
collect(r.cssRules, parentSel);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -218,18 +257,20 @@ async function capture(browser, target, viewport) {
|
||||
collect(sheet.cssRules);
|
||||
} catch {}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Selector specificity parser (approximation of the CSS algorithm):
|
||||
// ---- selector specificity (approximation of the CSS algorithm) ----
|
||||
// ids, classes, attributes, elements, plus :is/:not/:has (max of args)
|
||||
// and :where (zero).
|
||||
const splitTop = (s, sep) => {
|
||||
function splitTop(s, sep) {
|
||||
const parts = [];
|
||||
let depth = 0,
|
||||
cur = "";
|
||||
const isSep = (ch) =>
|
||||
sep === "," ? ch === "," : ch === ">" || ch === "+" || ch === "~" || /\s/.test(ch);
|
||||
sep === ","
|
||||
? ch === ","
|
||||
: ch === ">" || ch === "+" || ch === "~" || /\s/.test(ch);
|
||||
for (const ch of s) {
|
||||
if (ch === "(" || ch === "[") depth++;
|
||||
else if (ch === ")" || ch === "]") depth--;
|
||||
@@ -240,8 +281,9 @@ async function capture(browser, target, viewport) {
|
||||
}
|
||||
if (cur.trim()) parts.push(cur.trim());
|
||||
return parts;
|
||||
};
|
||||
const specCompound = (full) => {
|
||||
}
|
||||
|
||||
function specCompound(full) {
|
||||
let [A, B, C] = [0, 0, 0];
|
||||
for (const comp of splitTop(full, " ")) {
|
||||
const n = comp.length;
|
||||
@@ -288,7 +330,7 @@ async function capture(browser, target, viewport) {
|
||||
} else {
|
||||
i++;
|
||||
const name = readName();
|
||||
if (comp[i] === "(") {
|
||||
if (comp[i] === "(") {
|
||||
const inner = comp.slice(i + 1, endParen());
|
||||
// :is/:not/:has take the most specific argument.
|
||||
if (name !== "where") {
|
||||
@@ -314,13 +356,14 @@ if (comp[i] === "(") {
|
||||
}
|
||||
}
|
||||
return [A, B, C];
|
||||
};
|
||||
}
|
||||
|
||||
// ---- cascade resolution ----------------------------------------------
|
||||
// All declarations that apply to `el`, in cascade terms.
|
||||
const matched = (el) => {
|
||||
function matchedDeclarations(el, rules) {
|
||||
const out = [];
|
||||
for (const { rule, order } of rules) {
|
||||
const sel = rule.selectorText;
|
||||
for (const { rule, order, effective } of rules) {
|
||||
const sel = effective ?? 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
|
||||
@@ -368,27 +411,28 @@ if (comp[i] === "(") {
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
const keyOf = (d) => [d.important ? 1 : 0, d.a, d.b, d.c, d.order];
|
||||
const betterThan = (x, y) => {
|
||||
function 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) => {
|
||||
function 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) => {
|
||||
function inheritedEntries(m) {
|
||||
const out = [];
|
||||
for (const [k, v] of m)
|
||||
if (
|
||||
@@ -409,22 +453,20 @@ if (comp[i] === "(") {
|
||||
},
|
||||
]);
|
||||
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) => {
|
||||
function resolveVars(value, cs, depth = 0) {
|
||||
if (depth > 8 || !value.includes("var(")) return value;
|
||||
return value.replace(
|
||||
/var\((--[\w-]+)(?:,([^)]*))?\)/g,
|
||||
(_, name, fb) => {
|
||||
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) => {
|
||||
// ---- tree building ----------------------------------------------------
|
||||
function snap(el, inherited, rules) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (
|
||||
NOISE_TAGS.has(tag) ||
|
||||
@@ -433,7 +475,7 @@ if (comp[i] === "(") {
|
||||
)
|
||||
return null;
|
||||
const specMap = new Map(inheritedEntries(inherited));
|
||||
for (const [name, d] of resolve(matched(el))) {
|
||||
for (const [name, d] of resolve(matchedDeclarations(el, rules))) {
|
||||
const cur = specMap.get(name);
|
||||
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
|
||||
}
|
||||
@@ -441,7 +483,7 @@ if (comp[i] === "(") {
|
||||
const styles = {};
|
||||
const raw = {};
|
||||
const computed = {};
|
||||
for (const p of props) {
|
||||
for (const p of PROPS) {
|
||||
const c = cs.getPropertyValue(p);
|
||||
computed[p] = c;
|
||||
const rv = specMap.get(p)?.value ?? "";
|
||||
@@ -473,22 +515,22 @@ if (comp[i] === "(") {
|
||||
children:
|
||||
tag === "svg"
|
||||
? []
|
||||
: [...el.children].map((ch) => snap(ch, specMap)).filter(Boolean),
|
||||
: [...el.children]
|
||||
.map((ch) => snap(ch, specMap, rules))
|
||||
.filter(Boolean),
|
||||
};
|
||||
};
|
||||
const htmlNode = snap(document.documentElement, new Map());
|
||||
}
|
||||
|
||||
const findBody = (n) =>
|
||||
n?.tag === "body" ? n : (n?.children ?? []).map(findBody).find(Boolean);
|
||||
|
||||
const htmlNode = snap(document.documentElement, new Map(), collectRules());
|
||||
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) ---
|
||||
// ==== capture via CDP DOMSnapshot (alternative path, kept for reference) ===
|
||||
|
||||
async function captureCdp(browser, target) {
|
||||
const url = target.startsWith("http")
|
||||
@@ -523,9 +565,25 @@ function toTree(snap) {
|
||||
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();
|
||||
const tree = buildCdpNode(bodyIndex(snap), snap);
|
||||
if (!tree) throw new Error("toTree: body not found");
|
||||
// At least one rendered node must have the full set of values.
|
||||
if (
|
||||
![...stylesByNode(snap, layout).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;
|
||||
}
|
||||
|
||||
// CDP bookkeeping helpers (kept small on purpose — they are only reachable
|
||||
// through the experimental captureCdp path).
|
||||
function stylesByNode(snap, layout) {
|
||||
const S = snap.strings;
|
||||
const m = new Map();
|
||||
for (let k = 0; k < layout.nodeIndex.length; k++) {
|
||||
const raw = layout.styles[k] ?? [];
|
||||
if (raw.length && raw.length !== PROPS.length)
|
||||
@@ -537,9 +595,37 @@ function toTree(snap) {
|
||||
PROPS.forEach((prop, j) => {
|
||||
if (raw[j] != null) styles[prop] = S[raw[j]] ?? "";
|
||||
});
|
||||
stylesByNode.set(layout.nodeIndex[k], styles);
|
||||
m.set(layout.nodeIndex[k], styles);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function bodyIndex(snap) {
|
||||
const nodes = snap.documents[0].nodes;
|
||||
const S = snap.strings;
|
||||
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 rootIdx = nodes.nodeType.indexOf(9);
|
||||
const htmlIdx = (kids.get(rootIdx) ?? []).find(
|
||||
(i) => nodes.nodeType[i] === 1,
|
||||
);
|
||||
return (kids.get(htmlIdx) ?? []).find(
|
||||
(i) => nodes.nodeType[i] === 1 && deref(nodes.nodeName[i]) === "BODY",
|
||||
);
|
||||
}
|
||||
|
||||
function buildCdpNode(bodyIdx, snap) {
|
||||
const S = snap.strings;
|
||||
const nodes = snap.documents[0].nodes;
|
||||
const layout = snap.documents[0].layout;
|
||||
const deref = (v) => (typeof v === "number" ? S[v] : v);
|
||||
|
||||
const stylesByNode = stylesByNode(snap, layout);
|
||||
const boxByNode = new Map(
|
||||
layout.nodeIndex.map((domIdx, k) => {
|
||||
const b = layout.bounds[k];
|
||||
@@ -554,9 +640,7 @@ function toTree(snap) {
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
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];
|
||||
@@ -565,14 +649,6 @@ function toTree(snap) {
|
||||
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] ?? [];
|
||||
@@ -600,33 +676,14 @@ function toTree(snap) {
|
||||
box: boxByNode.get(i), // undefined = node not rendered
|
||||
styles: stylesByNode.get(i) ?? {},
|
||||
children:
|
||||
tag === "svg"
|
||||
? []
|
||||
: (kids.get(i) ?? []).map(build).filter(Boolean),
|
||||
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;
|
||||
return build(bodyIdx);
|
||||
}
|
||||
|
||||
// ==== diffing / matching helpers ===========================================
|
||||
|
||||
// node signature for matching: tag + classes (no svelte suffixes), or text
|
||||
const sig = (n) =>
|
||||
n.tag +
|
||||
@@ -636,7 +693,7 @@ const sig = (n) =>
|
||||
? ":" + n.text.slice(0, 30)
|
||||
: "");
|
||||
|
||||
const sameNode = (x, y) => {
|
||||
function 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:
|
||||
@@ -645,41 +702,36 @@ const sameNode = (x, y) => {
|
||||
// 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
|
||||
(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) => {
|
||||
function 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) => {
|
||||
function 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([
|
||||
// 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",
|
||||
@@ -696,23 +748,42 @@ function diffStyles(a, b) {
|
||||
"margin-left",
|
||||
"row-gap",
|
||||
"column-gap",
|
||||
]);
|
||||
const DEFAULTISH = (v) =>
|
||||
]);
|
||||
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) => {
|
||||
|
||||
// Geometry drift along an axis only matters when that dimension is actually
|
||||
// locked by CSS; otherwise it is just content flow (text lengths, images).
|
||||
function 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);
|
||||
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 side is declared via CSS variables, append the raw declaration so the
|
||||
// defining rule is easy to find (e.g. "color: var(--accent)").
|
||||
function 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;
|
||||
}
|
||||
|
||||
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`,
|
||||
];
|
||||
|
||||
if (!!a.box !== !!b.box)
|
||||
out.push(
|
||||
@@ -720,15 +791,6 @@ function diffStyles(a, b) {
|
||||
`(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();
|
||||
@@ -782,10 +844,7 @@ function compare(a, b, path, report) {
|
||||
const changes = diffStyles(a, b);
|
||||
if (changes.length)
|
||||
report.push(
|
||||
sel +
|
||||
annotate(b ?? a) +
|
||||
"\n" +
|
||||
changes.map((c) => " " + c).join("\n"),
|
||||
sel + annotate(b ?? a) + "\n" + changes.map((c) => " " + c).join("\n"),
|
||||
);
|
||||
|
||||
const removed = [],
|
||||
@@ -802,10 +861,12 @@ function compare(a, b, path, report) {
|
||||
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);
|
||||
compare(
|
||||
a.children[ai],
|
||||
b.children[bi],
|
||||
[...path, seg(a.children[ai], ai, a.children)],
|
||||
report,
|
||||
);
|
||||
}
|
||||
|
||||
// rescue: pair leftovers by tag instead of reporting ✗/+
|
||||
@@ -829,7 +890,7 @@ function compare(a, b, path, report) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- target discovery (mirrors audit-css.mjs) ---
|
||||
// ==== target discovery (mirrors audit-css.mjs) =============================
|
||||
|
||||
// Dynamic segments: explicit id values + id→ref-file mapping.
|
||||
const DYNAMIC = {
|
||||
@@ -886,9 +947,9 @@ function refsList() {
|
||||
const refExists = (name) => refsList().includes(name);
|
||||
|
||||
function buildTargets() {
|
||||
if (argv.route) {
|
||||
const ref = argv.ref || refNameFor(argv.route);
|
||||
return [{ route: argv.route, ref }];
|
||||
if (args.route) {
|
||||
const ref = args.ref || refNameFor(args.route);
|
||||
return [{ route: args.route, ref }];
|
||||
}
|
||||
const targets = [];
|
||||
const seen = new Set();
|
||||
@@ -917,6 +978,8 @@ function buildTargets() {
|
||||
return targets;
|
||||
}
|
||||
|
||||
// ==== orchestration ========================================================
|
||||
|
||||
const waitFor = async (url, ms = 60000) => {
|
||||
const t = Date.now();
|
||||
while (Date.now() - t < ms) {
|
||||
@@ -937,7 +1000,7 @@ const waitFor = async (url, ms = 60000) => {
|
||||
};
|
||||
|
||||
async function auditOne(browser, target, viewport) {
|
||||
const oursUrl = argv.ours || `http://127.0.0.1:${PORT}${target.route}`;
|
||||
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
|
||||
const refUrl = resolve(REFS_DIR, target.ref);
|
||||
|
||||
const ours = await capture(browser, oursUrl, viewport);
|
||||
@@ -948,6 +1011,27 @@ async function auditOne(browser, target, viewport) {
|
||||
return { route: target.route, ref: target.ref, viewport, lines: report };
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
return spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
|
||||
cwd: WEB,
|
||||
stdio: "ignore",
|
||||
shell: true,
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
}
|
||||
|
||||
function stopServer(server) {
|
||||
if (!server) return;
|
||||
try {
|
||||
if (process.platform === "win32")
|
||||
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
else server.kill("SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const targets = buildTargets();
|
||||
if (!targets.length) {
|
||||
@@ -957,15 +1041,9 @@ async function main() {
|
||||
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}`);
|
||||
if (!args.noServe) {
|
||||
server = startServer();
|
||||
await waitFor(args.ours || `http://127.0.0.1:${PORT}${targets[0].route}`);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
@@ -988,17 +1066,13 @@ async function main() {
|
||||
}
|
||||
} 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 {}
|
||||
}
|
||||
stopServer(server);
|
||||
}
|
||||
|
||||
writeReports(reports);
|
||||
}
|
||||
|
||||
function writeReports(reports) {
|
||||
const outDir = resolve(WEB, "audit");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
for (const vp of viewports) {
|
||||
|
||||
Reference in New Issue
Block a user