Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <siteDir> [--url <url>] [--filter <regex>] --out <dir> [--mode <name>]
[--viewport <WxH>] [--warning-floor-px <N>]

# self-test over fixtures/ (no external site needed)
node src/cli.mjs selftest [--out <dir>] # 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 <WxH>` | `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 <N>` | `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}`
Expand Down
6 changes: 4 additions & 2 deletions src/checks/overflow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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|$)");
Expand Down Expand Up @@ -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}`,
Expand Down
73 changes: 71 additions & 2 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
/**
* visual-qa CLI
* visual-qa check --site <dir> [--url <url>] [--filter <regex>] --out <dir> [--mode <name>]
* [--viewport <WxH>] [--warning-floor-px <N>]
* visual-qa selftest [--out <dir>]
* `check` serves <dir> 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));

Expand All @@ -23,16 +27,80 @@ function parseFlags(argv) {
function usage() {
console.error(`usage:
visual-qa check --site <dir> [--url <url>] [--filter <regex>] --out <dir> [--mode <name>]
[--viewport <WxH>] [--warning-floor-px <N>]
visual-qa selftest [--out <dir>]`);
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 <dir> [--url <url>] [--filter <regex>] --out <dir>
[--mode <name>] [--viewport <WxH>] [--warning-floor-px <N>]

options:
--site <dir> rendered site directory to serve and walk (required)
--url <url> already-running server base URL (skips the local server)
--filter <regex> page-name/rel-path regex restricting the walk
--out <dir> output directory (required): violations.jsonl, scrollers.jsonl,
summary.json, shots/
--mode <name> label recorded in summary.json (default: "check")
--viewport <WxH> browser page viewport in CSS pixels, e.g. 1920x1080
(default: ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height})
--warning-floor-px <N> 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 <WxH> 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));
Expand All @@ -43,3 +111,4 @@ if (cmd === "check") {
} else {
usage();
}
}
22 changes: 17 additions & 5 deletions src/runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <WxH>` (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); }

Expand Down Expand Up @@ -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 <out>/{violations,scrollers}.jsonl,
* <out>/summary.json, <out>/shots/*.png; returns the summary object.
* opts: { site, url?, filter?, out, mode?, viewport?, warningFloorPx? } → writes
* <out>/{violations,scrollers}.jsonl, <out>/summary.json, <out>/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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
Loading