From 3862eac173d3ac4f8df22e5f33238deeb325d81e Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Tue, 20 Oct 2026 15:42:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20check=20CLI=20audit=20dials=20=E2=80=94?= =?UTF-8?q?=20--viewport=20=20(default=201280x800)=20+=20--warning-fl?= =?UTF-8?q?oor-px=20=20(default=2050);=20defaults=20reproduce=20v0.1.5?= =?UTF-8?q?=20classification=20byte-identically=20(port/duration-normalise?= =?UTF-8?q?d=20selftest=20diff=20empty,=20sha256=20b770453d=E2=80=A6);=20i?= =?UTF-8?q?nvalid=20values=20exit=202;=20check=20--help=20lists=20both=20w?= =?UTF-8?q?ith=20code-sourced=20defaults;=20=5Fquarto-utils=20PR=20#44=20w?= =?UTF-8?q?orkflow=20passthrough=20is=20the=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 19 +++++++++++ src/checks/overflow.mjs | 6 ++-- src/cli.mjs | 73 +++++++++++++++++++++++++++++++++++++++-- src/runner.mjs | 22 ++++++++++--- 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e5042bc..d95e49e 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,30 @@ Requires Node >= 18 (native `fetch` is used for the CDN reachability probe). # check a rendered site directory (served locally on a free port via # python3 -m http.server, unless --url points at an already-running server) node src/cli.mjs check --site [--url ] [--filter ] --out [--mode ] + [--viewport ] [--warning-floor-px ] # self-test over fixtures/ (no external site needed) node src/cli.mjs selftest [--out ] # or: node selftest.mjs ``` +### `check` audit dials + +Both flags are optional; **omitting them reproduces the pre-dial (v0.1.5) classification +byte-for-byte** — the defaults below *are* the old hardcoded values. + +| flag | default | effect | +|---|---|---| +| `--viewport ` | `1280x800` | Browser page viewport in CSS px for every checked page (e.g. `1920x1080`). Positive integers only; invalid values exit 2 with a usage error. Note: `overflow.slide-viewport` still measures against the *Reveal-configured* deck viewport read from the page (`Reveal.getConfig()`), not this browser viewport — the dial changes the rendering context (scrollbox/blank/image evidence), not the deck comparison basis. | +| `--warning-floor-px ` | `50` | `overflow.slide-viewport` severity floor: residuals `< N` px are classified `severity: "warning"` instead of `"error"`. `N` must be a number ≥ 0; invalid values exit 2 with a usage error. | + +Example — audit at a smaller projector-safe viewport with a stricter noise floor: + +```bash +node src/cli.mjs check --site ./render --out ./out-vqa --viewport 1024x768 --warning-floor-px 30 +``` + +`node src/cli.mjs check --help` lists all options with their defaults. + `check` writes into `--out`: - `violations.jsonl` — one row per finding: `{page, check, selector, detail, screenshot}` diff --git a/src/checks/overflow.mjs b/src/checks/overflow.mjs index 25c9ac1..f03a468 100644 --- a/src/checks/overflow.mjs +++ b/src/checks/overflow.mjs @@ -41,11 +41,13 @@ export const BI_ICON_CLASS_TOKEN = "bi"; /** * Run the overflow evaluation inside the page (POC checkOverflowInPage, verbatim logic). * session: { page, url, violations, scrollers, addViolation, vId } - * opts: { scope: 'deck' | 'doc', viewport: {width,height} | null } + * opts: { scope: 'deck' | 'doc', viewport: {width,height} | null, warningFloorPx?: number } * Returns the defect list (with vqaId); violations rows are already appended. */ export async function checkOverflowInPage(session, opts) { const { page, url, violations, scrollers, addViolation } = session; + // CLI dial (--warning-floor-px): default = module constant (one source of truth). + const warningFloorPx = opts.warningFloorPx ?? SLIDE_VIEWPORT_WARNING_FLOOR_PX; const added = await page.evaluate(({ scope, viewport, startId, eps, srOnlyMaxDimPx, biToken }) => { // v0.1.5 noise-class exclusions — see SCROLLER constants in module scope. const biClassRe = new RegExp("(^|\\s)" + biToken + "(\\s|$)"); @@ -151,7 +153,7 @@ export async function checkOverflowInPage(session, opts) { // warning, >= floor stays error. scrollbox rows carry no severity field and // fall back to the registry severity (error) for gate consumers. ...(d.kind === "slide-viewport" - ? { severity: d.overflowPx >= SLIDE_VIEWPORT_WARNING_FLOOR_PX ? "error" : "warning" } + ? { severity: d.overflowPx >= warningFloorPx ? "error" : "warning" } : {}), selector: d.selector, detail: `axis=${d.axis} overflowPx=${d.overflowPx} rect=${d.rect.w}x${d.rect.h}`, diff --git a/src/cli.mjs b/src/cli.mjs index dbc9660..2788190 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -2,12 +2,16 @@ /** * visual-qa CLI * visual-qa check --site [--url ] [--filter ] --out [--mode ] + * [--viewport ] [--warning-floor-px ] * visual-qa selftest [--out ] * `check` serves on a free port unless --url points at an already-running server. + * `check --help` prints the full option list (with defaults) and exits 0. */ +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { runCheck } from "./runner.mjs"; +import { runCheck, DEFAULT_VIEWPORT } from "./runner.mjs"; +import { SLIDE_VIEWPORT_WARNING_FLOOR_PX } from "./checks/overflow.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -23,16 +27,80 @@ function parseFlags(argv) { function usage() { console.error(`usage: visual-qa check --site [--url ] [--filter ] --out [--mode ] + [--viewport ] [--warning-floor-px ] visual-qa selftest [--out ]`); process.exit(2); } +/** Full help for `check` (stdout, exit 0). Defaults read from the code constants. */ +function checkHelp() { + console.log(`usage: visual-qa check --site [--url ] [--filter ] --out + [--mode ] [--viewport ] [--warning-floor-px ] + +options: + --site rendered site directory to serve and walk (required) + --url already-running server base URL (skips the local server) + --filter page-name/rel-path regex restricting the walk + --out output directory (required): violations.jsonl, scrollers.jsonl, + summary.json, shots/ + --mode label recorded in summary.json (default: "check") + --viewport browser page viewport in CSS pixels, e.g. 1920x1080 + (default: ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}) + --warning-floor-px overflow.slide-viewport severity floor: residuals < N px are + classified severity="warning" instead of "error" (default: + ${SLIDE_VIEWPORT_WARNING_FLOOR_PX}; N must be a number >= 0) + +Invalid --viewport / --warning-floor-px values exit 2 with a usage error.`); +} + +/** `--viewport 1280x800` → {width:1280,height:800}; invalid → null. */ +export function parseViewport(value) { + const m = /^(\d+)x(\d+)$/.exec(value ?? ""); + if (!m) return null; + const width = Number(m[1]); + const height = Number(m[2]); + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return null; + return { width, height }; +} + +/** `--warning-floor-px 50` → 50; invalid (non-numeric or < 0) → null. */ +export function parseWarningFloorPx(value) { + const n = Number(value); + if (value === undefined || value === "" || !Number.isFinite(n) || n < 0) return null; + return n; +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.normalize(fs.realpathSync(process.argv[1])); + +if (isMain) await main(); + +async function main() { const [cmd, ...rest] = process.argv.slice(2); const args = parseFlags(rest); if (cmd === "check") { + if ("help" in args) { checkHelp(); process.exit(0); } if (!args.site || !args.out) usage(); - const summary = await runCheck({ site: args.site, url: args.url, filter: args.filter, out: args.out, mode: args.mode || "check" }); + let viewport; + if ("viewport" in args) { + viewport = parseViewport(args.viewport); + if (!viewport) { + console.error(`error: invalid --viewport "${args.viewport ?? ""}" — expected with positive integers, e.g. 1920x1080`); + usage(); + } + } + let warningFloorPx; + if ("warning-floor-px" in args) { + warningFloorPx = parseWarningFloorPx(args["warning-floor-px"]); + if (warningFloorPx === null) { + console.error(`error: invalid --warning-floor-px "${args["warning-floor-px"] ?? ""}" — expected a number >= 0`); + usage(); + } + } + const summary = await runCheck({ + site: args.site, url: args.url, filter: args.filter, out: args.out, mode: args.mode || "check", + viewport, warningFloorPx, + }); console.log(JSON.stringify(summary.totals, null, 2)); console.log("mermaid elements examined:", summary.mermaidExamined, "| intentional scrollers:", summary.totals.intentionalScrollers, "| duration(s):", summary.durationSec); console.log("out:", path.resolve(args.out)); @@ -43,3 +111,4 @@ if (cmd === "check") { } else { usage(); } +} diff --git a/src/runner.mjs b/src/runner.mjs index 9935078..fdf2237 100644 --- a/src/runner.mjs +++ b/src/runner.mjs @@ -23,6 +23,13 @@ import * as deadrefChecks from "./checks/deadref.mjs"; const SETTLE_MS = 900; // mermaid renders on window 'load' (POC) +/** + * Browser page viewport used for every checked page (CSS px). Hardcoded since the + * POC port; now the DEFAULT behind `check --viewport ` (single source of truth — + * cli.mjs reads this constant for --help). Omitting the flag keeps v0.1.5 behaviour. + */ +export const DEFAULT_VIEWPORT = { width: 1280, height: 800 }; + function rel(u) { return u.replace(/^\/+/, "") || "index.html"; } function pageKey(u) { return rel(new URL(u).pathname); } @@ -98,13 +105,18 @@ function listPages(site, base, filterRe) { /** * Run the visual-QA check over a site directory. - * opts: { site, url?, filter?, out, mode? } → writes /{violations,scrollers}.jsonl, - * /summary.json, /shots/*.png; returns the summary object. + * opts: { site, url?, filter?, out, mode?, viewport?, warningFloorPx? } → writes + * /{violations,scrollers}.jsonl, /summary.json, /shots/*.png; + * returns the summary object. + * viewport / warningFloorPx default to DEFAULT_VIEWPORT / SLIDE_VIEWPORT_WARNING_FLOOR_PX + * (omitting them reproduces pre-dial classification exactly). */ export async function runCheck(opts) { const SITE = path.resolve(opts.site); const OUT = path.resolve(opts.out); const MODE = opts.mode || "check"; + const VIEWPORT = opts.viewport ?? DEFAULT_VIEWPORT; + const WARNING_FLOOR_PX = opts.warningFloorPx ?? overflowChecks.SLIDE_VIEWPORT_WARNING_FLOOR_PX; const SHOTS = path.join(OUT, "shots"); fs.rmSync(SHOTS, { recursive: true, force: true }); // stale shots must not outlive their violations fs.mkdirSync(SHOTS, { recursive: true }); @@ -139,7 +151,7 @@ export async function runCheck(opts) { } async function runPage(browser, pg) { - const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + const page = await browser.newPage({ viewport: VIEWPORT }); const failed = imageChecks.watchSubresources(page, BASE); const session = { page, url: pg.url, BASE, SITE, addViolation, violations, scrollers, shot, @@ -183,14 +195,14 @@ export async function runCheck(opts) { for (let i = 0; i < 100 && Reveal.nextFragment(); i++) await new Promise((r) => setTimeout(r, 5)); }); await page.waitForTimeout(80); - const defects = await overflowChecks.checkOverflowInPage(session, { scope: "deck", viewport: cfg }); + const defects = await overflowChecks.checkOverflowInPage(session, { scope: "deck", viewport: cfg, warningFloorPx: WARNING_FLOOR_PX }); await overflowChecks.attachDeckOverflowShots(session, defects, `slide${ih}-${iv}`); await mermaidChecks.checkZeroSizeSvgsDeck(session, ih, iv); } } } else { // notes: whole-document overflow + visible svg zero-size - const defects = await overflowChecks.checkOverflowInPage(session, { scope: "doc", viewport: null }); + const defects = await overflowChecks.checkOverflowInPage(session, { scope: "doc", viewport: null, warningFloorPx: WARNING_FLOOR_PX }); await overflowChecks.attachDocOverflowShots(session, defects); await mermaidChecks.checkZeroSizeSvgsDoc(session); }