mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
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 --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 --ours http://127.0.0.1:5173
|
||||||
// node scripts/audit-cdp.mjs --route /preview/demo --ref demo.html
|
// node scripts/audit-cdp.mjs --route /preview/demo --ref demo.html
|
||||||
import { chromium } from "playwright";
|
|
||||||
import { diffArrays } from "diff";
|
import { diffArrays } from "diff";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||||
import { resolve, join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
const argv = (() => {
|
// ==== CLI / config =========================================================
|
||||||
|
|
||||||
|
const args = parseCli();
|
||||||
|
|
||||||
|
function parseCli() {
|
||||||
const a = { viewports: [] };
|
const a = { viewports: [] };
|
||||||
for (let i = 0; i < process.argv.length; i++) {
|
for (let i = 0; i < process.argv.length; i++) {
|
||||||
const v = process.argv[i];
|
const v = process.argv[i];
|
||||||
@@ -29,22 +33,26 @@ const argv = (() => {
|
|||||||
else if (v === "--route") a.route = 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 === "--port") a.port = Number(process.argv[++i]);
|
||||||
else if (v === "--viewport")
|
else if (v === "--viewport")
|
||||||
a.viewports.push(
|
a.viewports.push(...(process.argv[++i] ?? "").split(",").filter(Boolean));
|
||||||
...(process.argv[++i] ?? "").split(",").filter(Boolean),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
})();
|
}
|
||||||
|
|
||||||
const WEB = process.cwd();
|
const WEB = process.cwd();
|
||||||
const PORT = argv.port || 5179;
|
const PORT = args.port || 5179;
|
||||||
const REFS_DIR = resolve(WEB, "../refs-html");
|
const REFS_DIR = resolve(WEB, "../refs-html");
|
||||||
const PX_TOL = 1;
|
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".
|
// Requested viewports; repeatable/comma-separated "--viewport WxH".
|
||||||
// Deduplicated, preserves order. Defaults to 1440x900 when the flag is absent.
|
// Deduplicated, preserves order. Defaults to 1440x900 when the flag is absent.
|
||||||
const viewports = (() => {
|
const viewports = parseViewports();
|
||||||
const raw = argv.viewports.length ? argv.viewports : ["1440x900"];
|
const vpLabel = (vp) => `${vp.width}x${vp.height}`;
|
||||||
|
|
||||||
|
function parseViewports() {
|
||||||
|
const raw = args.viewports.length ? args.viewports : ["1440x900"];
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const s of raw) {
|
for (const s of raw) {
|
||||||
const m = /^(\d+)x(\d+)$/.exec(s.trim());
|
const m = /^(\d+)x(\d+)$/.exec(s.trim());
|
||||||
@@ -57,12 +65,11 @@ const viewports = (() => {
|
|||||||
out.push(vp);
|
out.push(vp);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
})();
|
}
|
||||||
const vpLabel = (vp) => `${vp.width}x${vp.height}`;
|
|
||||||
|
|
||||||
// Explicit --viewport means per-resolution files; default run keeps the
|
// Explicit --viewport means per-resolution files; default run keeps the
|
||||||
// backward-compatible single cdp-audit.txt name.
|
// backward-compatible single cdp-audit.txt name.
|
||||||
const perResFile = argv.viewports.length > 0;
|
const perResFile = args.viewports.length > 0;
|
||||||
const reportPath = (vp) =>
|
const reportPath = (vp) =>
|
||||||
resolve(
|
resolve(
|
||||||
WEB,
|
WEB,
|
||||||
@@ -70,6 +77,7 @@ const reportPath = (vp) =>
|
|||||||
perResFile ? `cdp-audit-${vpLabel(vp)}.txt` : "cdp-audit.txt",
|
perResFile ? `cdp-audit-${vpLabel(vp)}.txt` : "cdp-audit.txt",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Properties snapshot for every node, in both specified and computed form.
|
||||||
const PROPS = [
|
const PROPS = [
|
||||||
"display",
|
"display",
|
||||||
"position",
|
"position",
|
||||||
@@ -129,39 +137,11 @@ const PROPS = [
|
|||||||
"vertical-align",
|
"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) {
|
// Properties that inherit by default — their specified value flows to children.
|
||||||
const url = target.startsWith("http")
|
const INHERITED = new Set([
|
||||||
? 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",
|
"color",
|
||||||
"cursor",
|
"cursor",
|
||||||
"direction",
|
"direction",
|
||||||
@@ -179,36 +159,95 @@ async function capture(browser, target, viewport) {
|
|||||||
"visibility",
|
"visibility",
|
||||||
"white-space",
|
"white-space",
|
||||||
"word-spacing",
|
"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 ----
|
// ---- 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 = [];
|
const rules = [];
|
||||||
let ruleOrder = 0;
|
let ruleOrder = 0;
|
||||||
const collect = (list) => {
|
const collect = (list, parentSel = "") => {
|
||||||
for (const r of list || []) {
|
for (const r of list || []) {
|
||||||
if (r == null) continue;
|
if (r == null) continue;
|
||||||
if (r.type === 1) rules.push({ rule: r, order: ruleOrder++ });
|
if (r.type === 1) {
|
||||||
else if (r.type === 3 && r.styleSheet) {
|
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 {
|
try {
|
||||||
collect(r.styleSheet.cssRules);
|
collect(r.styleSheet.cssRules, parentSel);
|
||||||
} catch {}
|
} catch {}
|
||||||
} else if (r.type === 4) {
|
} else if (r.type === 4) {
|
||||||
if (r.media?.matches) {
|
if (r.media?.matches) {
|
||||||
try {
|
try {
|
||||||
collect(r.cssRules);
|
collect(r.cssRules, parentSel);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
} else if (r.type === 12) {
|
} else if (r.type === 12) {
|
||||||
try {
|
try {
|
||||||
if (CSS.supports(r.conditionText)) collect(r.cssRules);
|
if (CSS.supports(r.conditionText)) collect(r.cssRules, parentSel);
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
collect(r.cssRules);
|
collect(r.cssRules, parentSel);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
} else if (r.type === 15) {
|
} else if (r.type === 15) {
|
||||||
try {
|
try {
|
||||||
collect(r.cssRules);
|
collect(r.cssRules, parentSel);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,18 +257,20 @@ async function capture(browser, target, viewport) {
|
|||||||
collect(sheet.cssRules);
|
collect(sheet.cssRules);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- selector specificity (approximation of the CSS algorithm) ----
|
||||||
|
|
||||||
// Selector specificity parser (approximation of the CSS algorithm):
|
|
||||||
// ids, classes, attributes, elements, plus :is/:not/:has (max of args)
|
// ids, classes, attributes, elements, plus :is/:not/:has (max of args)
|
||||||
// and :where (zero).
|
// and :where (zero).
|
||||||
const splitTop = (s, sep) => {
|
function splitTop(s, sep) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let depth = 0,
|
let depth = 0,
|
||||||
cur = "";
|
cur = "";
|
||||||
const isSep = (ch) =>
|
const isSep = (ch) =>
|
||||||
sep === "," ? ch === "," : ch === ">" || ch === "+" || ch === "~" || /\s/.test(ch);
|
sep === ","
|
||||||
|
? ch === ","
|
||||||
|
: ch === ">" || ch === "+" || ch === "~" || /\s/.test(ch);
|
||||||
for (const ch of s) {
|
for (const ch of s) {
|
||||||
if (ch === "(" || ch === "[") depth++;
|
if (ch === "(" || ch === "[") depth++;
|
||||||
else 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());
|
if (cur.trim()) parts.push(cur.trim());
|
||||||
return parts;
|
return parts;
|
||||||
};
|
}
|
||||||
const specCompound = (full) => {
|
|
||||||
|
function specCompound(full) {
|
||||||
let [A, B, C] = [0, 0, 0];
|
let [A, B, C] = [0, 0, 0];
|
||||||
for (const comp of splitTop(full, " ")) {
|
for (const comp of splitTop(full, " ")) {
|
||||||
const n = comp.length;
|
const n = comp.length;
|
||||||
@@ -288,7 +330,7 @@ async function capture(browser, target, viewport) {
|
|||||||
} else {
|
} else {
|
||||||
i++;
|
i++;
|
||||||
const name = readName();
|
const name = readName();
|
||||||
if (comp[i] === "(") {
|
if (comp[i] === "(") {
|
||||||
const inner = comp.slice(i + 1, endParen());
|
const inner = comp.slice(i + 1, endParen());
|
||||||
// :is/:not/:has take the most specific argument.
|
// :is/:not/:has take the most specific argument.
|
||||||
if (name !== "where") {
|
if (name !== "where") {
|
||||||
@@ -314,13 +356,14 @@ if (comp[i] === "(") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [A, B, C];
|
return [A, B, C];
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// ---- cascade resolution ----------------------------------------------
|
||||||
// All declarations that apply to `el`, in cascade terms.
|
// All declarations that apply to `el`, in cascade terms.
|
||||||
const matched = (el) => {
|
function matchedDeclarations(el, rules) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const { rule, order } of rules) {
|
for (const { rule, order, effective } of rules) {
|
||||||
const sel = rule.selectorText;
|
const sel = effective ?? rule.selectorText;
|
||||||
if (!sel) continue;
|
if (!sel) continue;
|
||||||
// Specificity is per complex selector: a comma list like
|
// Specificity is per complex selector: a comma list like
|
||||||
// ".a, .b .c" does NOT sum. Match each complex selector on its
|
// ".a, .b .c" does NOT sum. Match each complex selector on its
|
||||||
@@ -368,27 +411,28 @@ if (comp[i] === "(") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
}
|
||||||
|
|
||||||
const keyOf = (d) => [d.important ? 1 : 0, d.a, d.b, d.c, d.order];
|
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),
|
const kx = keyOf(x),
|
||||||
ky = keyOf(y);
|
ky = keyOf(y);
|
||||||
for (let i = 0; i < kx.length; i++)
|
for (let i = 0; i < kx.length; i++)
|
||||||
if (kx[i] !== ky[i]) return kx[i] > ky[i] ? 1 : -1;
|
if (kx[i] !== ky[i]) return kx[i] > ky[i] ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
};
|
}
|
||||||
// Pick the winning declaration per property (inline > !important > cascade).
|
// Pick the winning declaration per property (inline > !important > cascade).
|
||||||
const resolve = (decls) => {
|
function resolve(decls) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const d of decls) {
|
for (const d of decls) {
|
||||||
const cur = map.get(d.name);
|
const cur = map.get(d.name);
|
||||||
if (!cur || betterThan(d, cur) > 0) map.set(d.name, d);
|
if (!cur || betterThan(d, cur) > 0) map.set(d.name, d);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
};
|
}
|
||||||
// Inherited entries must always lose against a real declaration.
|
// Inherited entries must always lose against a real declaration.
|
||||||
// Custom properties inherit too, so they propagate like inherited props.
|
// Custom properties inherit too, so they propagate like inherited props.
|
||||||
const inheritedEntries = (m) => {
|
function inheritedEntries(m) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const [k, v] of m)
|
for (const [k, v] of m)
|
||||||
if (
|
if (
|
||||||
@@ -409,22 +453,20 @@ if (comp[i] === "(") {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
return out;
|
return out;
|
||||||
};
|
}
|
||||||
// Substitute var(--name[, fallback]) with the resolved custom property value
|
// Substitute var(--name[, fallback]) with the resolved custom property value
|
||||||
// from getComputedStyle (custom props inherit, so per-element lookup works).
|
// 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;
|
if (depth > 8 || !value.includes("var(")) return value;
|
||||||
return value.replace(
|
return value.replace(/var\((--[\w-]+)(?:,([^)]*))?\)/g, (_, name, fb) => {
|
||||||
/var\((--[\w-]+)(?:,([^)]*))?\)/g,
|
|
||||||
(_, name, fb) => {
|
|
||||||
const v = cs.getPropertyValue(name).trim();
|
const v = cs.getPropertyValue(name).trim();
|
||||||
if (v) return resolveVars(v, cs, depth + 1);
|
if (v) return resolveVars(v, cs, depth + 1);
|
||||||
return fb ? resolveVars(fb, cs, depth + 1).trim() : "";
|
return fb ? resolveVars(fb, cs, depth + 1).trim() : "";
|
||||||
},
|
});
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const snap = (el, inherited) => {
|
// ---- tree building ----------------------------------------------------
|
||||||
|
function snap(el, inherited, rules) {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
if (
|
if (
|
||||||
NOISE_TAGS.has(tag) ||
|
NOISE_TAGS.has(tag) ||
|
||||||
@@ -433,7 +475,7 @@ if (comp[i] === "(") {
|
|||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
const specMap = new Map(inheritedEntries(inherited));
|
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);
|
const cur = specMap.get(name);
|
||||||
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
|
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
|
||||||
}
|
}
|
||||||
@@ -441,7 +483,7 @@ if (comp[i] === "(") {
|
|||||||
const styles = {};
|
const styles = {};
|
||||||
const raw = {};
|
const raw = {};
|
||||||
const computed = {};
|
const computed = {};
|
||||||
for (const p of props) {
|
for (const p of PROPS) {
|
||||||
const c = cs.getPropertyValue(p);
|
const c = cs.getPropertyValue(p);
|
||||||
computed[p] = c;
|
computed[p] = c;
|
||||||
const rv = specMap.get(p)?.value ?? "";
|
const rv = specMap.get(p)?.value ?? "";
|
||||||
@@ -473,22 +515,22 @@ if (comp[i] === "(") {
|
|||||||
children:
|
children:
|
||||||
tag === "svg"
|
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) =>
|
const findBody = (n) =>
|
||||||
n?.tag === "body" ? n : (n?.children ?? []).map(findBody).find(Boolean);
|
n?.tag === "body" ? n : (n?.children ?? []).map(findBody).find(Boolean);
|
||||||
|
|
||||||
|
const htmlNode = snap(document.documentElement, new Map(), collectRules());
|
||||||
const body = findBody(htmlNode);
|
const body = findBody(htmlNode);
|
||||||
if (!body) throw new Error("capture: body not found");
|
if (!body) throw new Error("capture: body not found");
|
||||||
return body;
|
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) {
|
async function captureCdp(browser, target) {
|
||||||
const url = target.startsWith("http")
|
const url = target.startsWith("http")
|
||||||
@@ -523,9 +565,25 @@ function toTree(snap) {
|
|||||||
if (layout.nodeIndex.length !== layout.bounds.length)
|
if (layout.nodeIndex.length !== layout.bounds.length)
|
||||||
throw new Error("toTree: nodeIndex and bounds are not parallel");
|
throw new Error("toTree: nodeIndex and bounds are not parallel");
|
||||||
|
|
||||||
// styles: flat array of VALUE indices, positional to PROPS.
|
const tree = buildCdpNode(bodyIndex(snap), snap);
|
||||||
// Property names are not stored in strings — mapping only via whitelist order.
|
if (!tree) throw new Error("toTree: body not found");
|
||||||
const stylesByNode = new Map();
|
// 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++) {
|
for (let k = 0; k < layout.nodeIndex.length; k++) {
|
||||||
const raw = layout.styles[k] ?? [];
|
const raw = layout.styles[k] ?? [];
|
||||||
if (raw.length && raw.length !== PROPS.length)
|
if (raw.length && raw.length !== PROPS.length)
|
||||||
@@ -537,9 +595,37 @@ function toTree(snap) {
|
|||||||
PROPS.forEach((prop, j) => {
|
PROPS.forEach((prop, j) => {
|
||||||
if (raw[j] != null) styles[prop] = S[raw[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(
|
const boxByNode = new Map(
|
||||||
layout.nodeIndex.map((domIdx, k) => {
|
layout.nodeIndex.map((domIdx, k) => {
|
||||||
const b = layout.bounds[k];
|
const b = layout.bounds[k];
|
||||||
@@ -554,9 +640,7 @@ function toTree(snap) {
|
|||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const pseudoByNode = new Set(nodes.pseudoType?.index ?? []);
|
const pseudoByNode = new Set(nodes.pseudoType?.index ?? []);
|
||||||
|
|
||||||
const kids = new Map();
|
const kids = new Map();
|
||||||
for (let i = 0; i < nodes.nodeType.length; i++) {
|
for (let i = 0; i < nodes.nodeType.length; i++) {
|
||||||
const p = nodes.parentIndex?.[i];
|
const p = nodes.parentIndex?.[i];
|
||||||
@@ -565,14 +649,6 @@ function toTree(snap) {
|
|||||||
kids.get(p).push(i);
|
kids.get(p).push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOISE_TAGS = new Set([
|
|
||||||
"script",
|
|
||||||
"template",
|
|
||||||
"style",
|
|
||||||
"link",
|
|
||||||
"noscript",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const build = (i) => {
|
const build = (i) => {
|
||||||
if (nodes.nodeType[i] !== 1 || pseudoByNode.has(i)) return null;
|
if (nodes.nodeType[i] !== 1 || pseudoByNode.has(i)) return null;
|
||||||
const a = nodes.attributes?.[i] ?? [];
|
const a = nodes.attributes?.[i] ?? [];
|
||||||
@@ -600,33 +676,14 @@ function toTree(snap) {
|
|||||||
box: boxByNode.get(i), // undefined = node not rendered
|
box: boxByNode.get(i), // undefined = node not rendered
|
||||||
styles: stylesByNode.get(i) ?? {},
|
styles: stylesByNode.get(i) ?? {},
|
||||||
children:
|
children:
|
||||||
tag === "svg"
|
tag === "svg" ? [] : (kids.get(i) ?? []).map(build).filter(Boolean),
|
||||||
? []
|
|
||||||
: (kids.get(i) ?? []).map(build).filter(Boolean),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
return build(bodyIdx);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==== diffing / matching helpers ===========================================
|
||||||
|
|
||||||
// node signature for matching: tag + classes (no svelte suffixes), or text
|
// node signature for matching: tag + classes (no svelte suffixes), or text
|
||||||
const sig = (n) =>
|
const sig = (n) =>
|
||||||
n.tag +
|
n.tag +
|
||||||
@@ -636,7 +693,7 @@ const sig = (n) =>
|
|||||||
? ":" + n.text.slice(0, 30)
|
? ":" + n.text.slice(0, 30)
|
||||||
: "");
|
: "");
|
||||||
|
|
||||||
const sameNode = (x, y) => {
|
function sameNode(x, y) {
|
||||||
if (x.tag !== y.tag) return false;
|
if (x.tag !== y.tag) return false;
|
||||||
if (x.tag === "svg") return true; // icons: design vs library classes differ
|
if (x.tag === "svg") return true; // icons: design vs library classes differ
|
||||||
// Leaf controls (buttons/links/labels) are identified by their text label:
|
// 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
|
// mis-pairs them (and a classless ref button would otherwise match any
|
||||||
// sibling). Matching by label keeps the pairings stable.
|
// sibling). Matching by label keeps the pairings stable.
|
||||||
if (
|
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;
|
return x.text === y.text;
|
||||||
const cx = [...x.classes].sort().join(" ");
|
const cx = [...x.classes].sort().join(" ");
|
||||||
const cy = [...y.classes].sort().join(" ");
|
const cy = [...y.classes].sort().join(" ");
|
||||||
return cx === cy || !x.classes.length || !y.classes.length;
|
return cx === cy || !x.classes.length || !y.classes.length;
|
||||||
};
|
}
|
||||||
|
|
||||||
// CSS selector segment: nth-of-type only when several such tags under parent
|
// 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;
|
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;
|
const nth = siblings.slice(0, i + 1).filter((c) => c.tag === node.tag).length;
|
||||||
return `${node.tag}:nth-of-type(${nth})`;
|
return `${node.tag}:nth-of-type(${nth})`;
|
||||||
};
|
}
|
||||||
|
|
||||||
// human-readable annotation: classes + text, to make the node easy to find
|
// human-readable annotation: classes + text, to make the node easy to find
|
||||||
const annotate = (node) => {
|
function annotate(node) {
|
||||||
if (!node) return "";
|
if (!node) return "";
|
||||||
const cls = node.classes.length
|
const cls = node.classes.length
|
||||||
? " [" + [...node.classes].sort().join(" ") + "]"
|
? " [" + [...node.classes].sort().join(" ") + "]"
|
||||||
: "";
|
: "";
|
||||||
const txt = node.text ? ` "${node.text.slice(0, 40)}"` : "";
|
const txt = node.text ? ` "${node.text.slice(0, 40)}"` : "";
|
||||||
return cls + txt;
|
return cls + txt;
|
||||||
};
|
}
|
||||||
|
|
||||||
function diffStyles(a, b) {
|
// Layout-sensitive props must never fall back to computed: keeping them
|
||||||
const out = [];
|
// specified (e.g. "auto") is what makes this audit content-size independent.
|
||||||
if (!a?.styles || !b?.styles)
|
const LAYOUT = new Set([
|
||||||
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",
|
"width",
|
||||||
"height",
|
"height",
|
||||||
"min-width",
|
"min-width",
|
||||||
@@ -696,23 +748,42 @@ function diffStyles(a, b) {
|
|||||||
"margin-left",
|
"margin-left",
|
||||||
"row-gap",
|
"row-gap",
|
||||||
"column-gap",
|
"column-gap",
|
||||||
]);
|
]);
|
||||||
const DEFAULTISH = (v) =>
|
const DEFAULTISH = (v) =>
|
||||||
v === "" ||
|
v === "" ||
|
||||||
v === "inherit" ||
|
v === "inherit" ||
|
||||||
v === "initial" ||
|
v === "initial" ||
|
||||||
v === "unset" ||
|
v === "unset" ||
|
||||||
v === "revert";
|
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).
|
// Geometry drift along an axis only matters when that dimension is actually
|
||||||
const flowDriven = (node, k) => {
|
// locked by CSS; otherwise it is just content flow (text lengths, images).
|
||||||
|
function flowDriven(node, k) {
|
||||||
if (!node?.styles) return true;
|
if (!node?.styles) return true;
|
||||||
if (k === "w") return DEFAULTISH(node.styles.width);
|
if (k === "w") return DEFAULTISH(node.styles.width);
|
||||||
if (k === "h") return DEFAULTISH(node.styles.height);
|
if (k === "h") return DEFAULTISH(node.styles.height);
|
||||||
if (k === "x") return DEFAULTISH(node.styles.left) && DEFAULTISH(node.styles.right);
|
if (k === "x")
|
||||||
if (k === "y") return DEFAULTISH(node.styles.top) && DEFAULTISH(node.styles.bottom);
|
return DEFAULTISH(node.styles.left) && DEFAULTISH(node.styles.right);
|
||||||
|
if (k === "y")
|
||||||
|
return DEFAULTISH(node.styles.top) && DEFAULTISH(node.styles.bottom);
|
||||||
return true;
|
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)
|
if (!!a.box !== !!b.box)
|
||||||
out.push(
|
out.push(
|
||||||
@@ -720,15 +791,6 @@ function diffStyles(a, b) {
|
|||||||
`(no layout box — usually display:none 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
|
// Which style props were already reported as diffs — box axes governed by
|
||||||
// them are redundant then.
|
// them are redundant then.
|
||||||
const styleDiffKeys = new Set();
|
const styleDiffKeys = new Set();
|
||||||
@@ -782,10 +844,7 @@ function compare(a, b, path, report) {
|
|||||||
const changes = diffStyles(a, b);
|
const changes = diffStyles(a, b);
|
||||||
if (changes.length)
|
if (changes.length)
|
||||||
report.push(
|
report.push(
|
||||||
sel +
|
sel + annotate(b ?? a) + "\n" + changes.map((c) => " " + c).join("\n"),
|
||||||
annotate(b ?? a) +
|
|
||||||
"\n" +
|
|
||||||
changes.map((c) => " " + c).join("\n"),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const removed = [],
|
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]);
|
for (let k = 0; k < g.count; k++, bi++) added.push([b.children[bi], bi]);
|
||||||
else
|
else
|
||||||
for (let k = 0; k < g.count; k++, ai++, bi++)
|
for (let k = 0; k < g.count; k++, ai++, bi++)
|
||||||
compare(a.children[ai], b.children[bi], [
|
compare(
|
||||||
...path,
|
a.children[ai],
|
||||||
seg(a.children[ai], ai, a.children),
|
b.children[bi],
|
||||||
], report);
|
[...path, seg(a.children[ai], ai, a.children)],
|
||||||
|
report,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// rescue: pair leftovers by tag instead of reporting ✗/+
|
// 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.
|
// Dynamic segments: explicit id values + id→ref-file mapping.
|
||||||
const DYNAMIC = {
|
const DYNAMIC = {
|
||||||
@@ -886,9 +947,9 @@ function refsList() {
|
|||||||
const refExists = (name) => refsList().includes(name);
|
const refExists = (name) => refsList().includes(name);
|
||||||
|
|
||||||
function buildTargets() {
|
function buildTargets() {
|
||||||
if (argv.route) {
|
if (args.route) {
|
||||||
const ref = argv.ref || refNameFor(argv.route);
|
const ref = args.ref || refNameFor(args.route);
|
||||||
return [{ route: argv.route, ref }];
|
return [{ route: args.route, ref }];
|
||||||
}
|
}
|
||||||
const targets = [];
|
const targets = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -917,6 +978,8 @@ function buildTargets() {
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==== orchestration ========================================================
|
||||||
|
|
||||||
const waitFor = async (url, ms = 60000) => {
|
const waitFor = async (url, ms = 60000) => {
|
||||||
const t = Date.now();
|
const t = Date.now();
|
||||||
while (Date.now() - t < ms) {
|
while (Date.now() - t < ms) {
|
||||||
@@ -937,7 +1000,7 @@ const waitFor = async (url, ms = 60000) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function auditOne(browser, target, viewport) {
|
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 refUrl = resolve(REFS_DIR, target.ref);
|
||||||
|
|
||||||
const ours = await capture(browser, oursUrl, viewport);
|
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 };
|
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() {
|
async function main() {
|
||||||
const targets = buildTargets();
|
const targets = buildTargets();
|
||||||
if (!targets.length) {
|
if (!targets.length) {
|
||||||
@@ -957,15 +1041,9 @@ async function main() {
|
|||||||
console.log(`[cdp-audit] targets: ${targets.map((t) => t.route).join(", ")}`);
|
console.log(`[cdp-audit] targets: ${targets.map((t) => t.route).join(", ")}`);
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
if (!argv.noServe) {
|
if (!args.noServe) {
|
||||||
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
server = startServer();
|
||||||
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
|
await waitFor(args.ours || `http://127.0.0.1:${PORT}${targets[0].route}`);
|
||||||
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 browser = await chromium.launch();
|
||||||
@@ -988,17 +1066,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
if (server) {
|
stopServer(server);
|
||||||
try {
|
|
||||||
if (process.platform === "win32")
|
|
||||||
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
else server.kill("SIGTERM");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeReports(reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeReports(reports) {
|
||||||
const outDir = resolve(WEB, "audit");
|
const outDir = resolve(WEB, "audit");
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
for (const vp of viewports) {
|
for (const vp of viewports) {
|
||||||
|
|||||||
Reference in New Issue
Block a user