chore: update cdp audit script

This commit is contained in:
2026-08-31 03:02:29 +05:00
parent 8eff9a9483
commit 6cefd5c0a1
+268 -194
View File
@@ -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) {