From 5256d810c34045d043a62be82854c337fb277821 Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:03:48 +0100 Subject: [PATCH] --only: run a subset of the config's routes with their hooks Routes given on the command line replace the config's, so they lose its per-route before/beforeShot hooks and selectors. --only filters the config's routes instead: same path or pair, or the same selector with '/a @sel'. A value naming a file in cwd lists one route per line. Each value or line that matches no config route exits 2 with the routes the config has. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++-- renderdiff.config.example.ts | 11 ++-- skills/renderdiff/SKILL.md | 7 +-- src/cli.ts | 54 +++++++++++++++++++- tests/unit/cli-args.test.ts | 14 ++++++ tests/unit/config-load.test.ts | 92 ++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2cf4e2c..25a23e0 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ renderdiff [options] | `--left-storage-state `, `--right-storage-state ` | That side's storage state, over `--storage-state`. One side alone is fine. | | `--accept ` | Record this run's differences as accepted, with a reason. See **Accepted differences and reruns**. | | `--rerun-failed` | Rerun only what the last run in `--out` didn't find clean or accepted, keeping the rest. | +| `--only ` | Run only the config's routes with this path (or pair), keeping their hooks and selectors; `'/a @sel'` narrows to that selector. Repeatable. A value naming an existing file (relative to cwd) is read as a route list, one per line. Not with positional routes or `--sitemap`; a value that matches no config route is an error. | | `--cache-right` | Replay the right side's network traffic from the last `--cache-right` run in `--out`, when it's under an hour old and covers every shot; otherwise load it live and record it. Keeps `/.cache/` when the out dir is wiped. See **Accepted differences and reruns**. | | `--no-recheck` | Don't reload a differing shot's right side to find content that varies per load. Off anyway under `--cache-right`. | | `--computed` | Also diff the computed style of every rendered element and its `::before`/`::after`, and links' and controls' `:hover`/`:focus-visible`, and say per route whether the markup is identical apart from asset hashes and origin. Pixels still decide the score and exit code. See [docs/output.md](docs/output.md#computed-styles---computed). | @@ -137,7 +138,7 @@ range rather than on a boundary, so a layout switch never flaps between runs. | --- | --- | | `0` | No route errored and no shot differs (with `--threshold`: none scored below it). Accepted differences don't count; nor do unstable pixels or a failed asset alone, which still get their own row. | | `1` | A shot differs, or with `--threshold` scored below it. Wins over `3` when both happen. A difference that can't be accepted (unstable pixels beside it, content that varies per load, a width change, a height change whose rows don't align) exits 1 even on the `--accept` run. | -| `2` | Bad arguments or config, an origin that isn't responding, a `--sitemap` it can't fetch or read, an unreadable storage state or `renderdiff.accepted.json`, an out dir holding files renderdiff didn't write, or a `--rerun-failed` that doesn't repeat the last run. | +| `2` | Bad arguments or config, an origin that isn't responding, a `--sitemap` it can't fetch or read, an unreadable storage state or `renderdiff.accepted.json`, an out dir holding files renderdiff didn't write, a `--rerun-failed` that doesn't repeat the last run, or an `--only` that matches no config route. | | `3` | A route errored, so it went unmeasured, or the run crashed (e.g. no Chromium). | ### Moved pages, components, interactions, sign-in @@ -214,9 +215,9 @@ renderdiff http://localhost:3000 https://example.com / /about --rerun-failed to the config, or in cwd); commit it. Later, a shot whose every difference matches an entry is listed on an `accepted:` line and doesn't count toward the exit code; anything new on the same page still shows. `--rerun-failed` reruns only what the last run in `--out` didn't find clean -or accepted. Against a slow reference, `--cache-right` replays the right side's recorded -traffic for up to an hour; drop it for the final run. Matching rules, why to accept on the -platform that checks, and the cache: +or accepted (with the same `--only`, if it had one). Against a slow reference, `--cache-right` +replays the right side's recorded traffic for up to an hour; drop it for the final run. +Matching rules, why to accept on the platform that checks, and the cache: [docs/accepted.md](https://github.com/tmashara/renderdiff/blob/main/docs/accepted.md). ## Output diff --git a/renderdiff.config.example.ts b/renderdiff.config.example.ts index 31df8a0..b2d79cf 100644 --- a/renderdiff.config.example.ts +++ b/renderdiff.config.example.ts @@ -12,11 +12,12 @@ export default { left: "http://localhost:3001", right: "http://localhost:3002", - // Diffed when you run bare `renderdiff` with no positional routes. `/a::/b` (or - // `{ left: "/a", right: "/b" }`) loads /a on the left and /b on the right — for a page - // the migration moved. `selector` compares one element instead of the page (a string, - // or `{ left, right }`); it must match exactly one, and `--selector` replaces it. List a - // route once per selector to compare several components of it. + // Diffed when you run bare `renderdiff` with no positional routes; `--only /a` runs a + // subset, hooks kept. `/a::/b` (or `{ left: "/a", right: "/b" }`) loads /a on the left + // and /b on the right — for a page the migration moved. `selector` compares one element + // instead of the page (a string, or `{ left, right }`); it must match exactly one, and + // `--selector` replaces it. List a route once per selector to compare several components + // of it. routes: [ "/", "/about", diff --git a/skills/renderdiff/SKILL.md b/skills/renderdiff/SKILL.md index 3e71e83..485614f 100644 --- a/skills/renderdiff/SKILL.md +++ b/skills/renderdiff/SKILL.md @@ -38,9 +38,10 @@ Pixels decide the score and the exit code in every mode. differs varies per load: mask it (a `varies` line prints the mask; `--mask-media` for iframes, `--mask ` or a config mask with a `reason`; video is already frozen; a looping carousel: stop it with a `before` hook on both sides, or mask it), and rerun - until clean. A difference with no `varies` line and nothing unstable: - `renderdiff probe --width ` names what varies between loads; mask that. - Against production instead: open each difference, then + until clean; `--only ` reruns some of a config's routes with their hooks, which + routes typed on the command line don't get. A difference with no `varies` line and + nothing unstable: `renderdiff probe --width ` names what varies + between loads; mask that. Against production instead: open each difference, then `--accept "differs before the change"`. § Masks, accepts and thresholds. 3. **Change, run all routes at default widths, read each row in this order**: `ERROR` → `failed` (a missing stylesheet or font explains the whole row: fix, rerun) → `fonts` → `text` → diff --git a/src/cli.ts b/src/cli.ts index a0ceb15..bd8686a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ import { alive, label, loadConfig, + name, normalize, parseRoutes, resolveConfig, @@ -38,6 +39,8 @@ interface Args extends CliOverrides { version: boolean; config?: string; routes: string[]; + /** --only values: config routes to keep, or files listing them. */ + only?: string[]; /** From --selector / --left-selector / --right-selector; replaces a config route object's own. */ selector?: Sides; /** The reason to accept this run's differences with. */ @@ -116,6 +119,10 @@ function parseArgs(argv: string[]): Args { const s = next().trim(); if (!s) throw new Error("--mask needs a value"); (a.masks ??= []).push(s); + } else if (v === "--only") { + const s = next().trim(); + if (!s) throw new Error("--only needs a value"); + (a.only ??= []).push(s); } else if (v === "--hide") { const s = next().trim(); if (!s) throw new Error("--hide needs a value"); @@ -172,6 +179,10 @@ function parseArgs(argv: string[]): Args { if (a.dryRun && (a.rerunFailed || a.accept !== undefined)) { throw new Error(`${a.rerunFailed ? "--rerun-failed" : "--accept"} needs a run, not --dry-run`); } + if (a.only && (a.routes.length || a.sitemap)) { + const drop = a.routes.length ? "the routes given on the command line" : "--sitemap"; + throw new Error(`--only picks from the config's routes; drop ${drop}`); + } if (state.both || state.left || state.right) { a.storageState = { left: state.left ?? state.both, right: state.right ?? state.both }; } @@ -253,6 +264,9 @@ const HELP = ` --accept Accept this run's differences, scoped by its routes and widths. --rerun-failed Rerun what the last run in --out didn't find clean or accepted, keeping the rest. Same origins, routes and widths as that run. + --only Run only the config's routes with this path (or pair), their + hooks and selectors kept; '/a @sel' narrows to that selector. + Repeatable. A value naming a file in cwd lists one per line. --cache-right Replay the right side's network from the last --cache-right run in --out, if under an hour old and covering every shot; else load it live and record it. The page still renders and is shot fresh. @@ -454,12 +468,47 @@ function takeLoads(argv: string[]): number { return loads; } +/** The config routes the `--only` values pick; a value naming a file is a route list. */ +function only(values: string[], routes: Route[]): Route[] { + const names = routes.map(name); + const has = names.length + ? `the config has: ${names.slice(0, 20).join(", ")}` + + (names.length > 20 ? ` … and ${names.length - 20} more` : "") + : "the config has no routes"; + const picked = new Set(); + for (const v of values) { + const file = fs.statSync(v, { throwIfNoEntry: false })?.isFile(); + const specs = file + ? fs + .readFileSync(v, "utf8") + .split(/\r?\n/) + .map((l) => l.trim()) + .map((l, i) => [l, `--only ${v}: "${l}" (line ${i + 1})`] as const) + .filter(([l]) => l) + : [[v, `--only ${v}`] as const]; + if (file && !specs.length) throw new Error(`--only ${v} lists no routes`); + for (const [spec, shown] of specs) { + const [w] = parseRoutes([spec]) as [Route]; + const hits = routes.filter( + (r) => label(r) === label(w) && (!w.selector || name(r) === name(w)), + ); + if (!hits.length) { + const typo = !file && (!v.includes("/") || /\.\w+$/.test(v)) ? " (and no such file)" : ""; + throw new Error(`${shown} matches no config route${typo}; ${has}`); + } + for (const r of hits) picked.add(r); + } + } + return routes.filter((r) => picked.has(r)); +} + /** `renderdiff probe `: load one URL several times and list what varies between loads. */ async function probe(a: Args, loads: number): Promise { if (!a.left) die("probe needs a URL: renderdiff probe "); if (a.right || a.routes.length) die("probe takes one URL, with its path"); if ((a.widths?.length ?? 0) > 1) die("probe takes one --width"); if (a.selector) die("probe does not take --selector; it walks the whole page"); + if (a.only) die("probe does not take --only"); if (!URL.canParse(a.left)) die(`not a URL: ${a.left}`); const u = new URL(a.left); let route: Route; @@ -525,13 +574,16 @@ try { const rawRoutes = args.routes.length ? args.routes : (fileConfig.routes ?? []); -if (!rawRoutes.length && !args.sitemap) { +if (args.only && !configPath) die("--only filters a config file's routes; none was found"); + +if (!rawRoutes.length && !args.sitemap && !args.only) { die("no routes given."); } let routes: Route[]; try { routes = parseRoutes(rawRoutes, args.selector, fileConfig.before, fileConfig.beforeShot); + if (args.only) routes = only(args.only, routes); } catch (e) { die(e instanceof Error ? e.message : String(e)); } diff --git a/tests/unit/cli-args.test.ts b/tests/unit/cli-args.test.ts index 635fe0b..295d3fe 100644 --- a/tests/unit/cli-args.test.ts +++ b/tests/unit/cli-args.test.ts @@ -172,6 +172,18 @@ describe("flag validation", () => { ); }); + test("--only needs a value and the config's routes alone", async () => { + await rejects(["--left", L, "--right", R, "--dry-run", "--only"], "--only needs a value"); + await rejects( + ["/a", "--left", L, "--right", R, "--dry-run", "--only", "/a"], + "--only picks from the config's routes; drop the routes given on the command line", + ); + await rejects( + ["--left", L, "--right", R, "--dry-run", "--only", "/a", "--sitemap"], + "--only picks from the config's routes; drop --sitemap", + ); + }); + test("--width rejects non-numbers", () => rejects( ["/a", "--left", L, "--right", R, "--width", "wide"], @@ -451,6 +463,8 @@ describe("probe", () => { await rejects(["probe", U, "/b"], "probe takes one URL"); }); + test("refuses --only", () => rejects(["probe", U, "--only", "/a"], "probe does not take --only")); + test("--loads is an integer of at least 2", async () => { await rejects(["probe", U, "--loads", "1"], "--loads expects an integer of at least 2, got 1"); await rejects(["probe", U, "--loads=2.5"], "--loads expects an integer of at least 2"); diff --git a/tests/unit/config-load.test.ts b/tests/unit/config-load.test.ts index ba20203..541f295 100644 --- a/tests/unit/config-load.test.ts +++ b/tests/unit/config-load.test.ts @@ -361,3 +361,95 @@ describe("precedence", () => { assert.ok(r.stdout.includes("http://cfg-right.test/a")); }); }); + +describe("--only", () => { + /** A temp cwd with a config whose /b route has its own selector and hook. */ + function cwdOnly(): string { + const cwd = tempDir("renderdiff-cfg-"); + fs.writeFileSync( + path.join(cwd, "renderdiff.config.mjs"), + 'export default { routes: ["/a", { left: "/a", selector: ".x" }, { left: "/b", selector: ".card", before: async () => {} }, "/c::/d"] };\n', + ); + return cwd; + } + const dryIn = (cwd: string, ...only: string[]) => + runCli(["--dry-run", "--left", L, "--right", R, ...only.flatMap((o) => ["--only", o])], { + cwd, + noOut: true, + }); + + test("keeps a config route's own selector when picked by path", async () => { + const r = await dryIn(cwdOnly(), "/b/"); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /^Routes: 1$/m); + assert.match( + r.stdout, + /^ \/b +http:\/\/left\.test\/b \| http:\/\/right\.test\/b \[\.card\]$/m, + ); + }); + + test("a path picks every selector of it; ' @sel' narrows to one", async () => { + const cwd = cwdOnly(); + const all = await dryIn(cwd, "/a"); + assert.equal(all.code, 0, all.stderr); + assert.match(all.stdout, /^Routes: 2$/m); + const one = await dryIn(cwd, "/a @.x"); + assert.equal(one.code, 0, one.stderr); + assert.match(one.stdout, /^Routes: 1$/m); + assert.match(one.stdout, /\[\.x\]$/m); + }); + + test("repeatable, pairs included, in config order", async () => { + const r = await dryIn(cwdOnly(), "/c::/d", "/b"); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /^Routes: 2$/m); + assert.ok(r.stdout.indexOf("/b") < r.stdout.indexOf("/c::/d"), r.stdout); + }); + + test("a file of blank lines is an error", async () => { + const cwd = cwdOnly(); + fs.writeFileSync(path.join(cwd, "routes.txt"), "\n \n"); + const r = await dryIn(cwd, "routes.txt"); + assert.equal(r.code, 2, r.stdout); + assert.match(r.stderr, /--only routes\.txt lists no routes/); + }); + + test("a file in cwd lists routes one per line", async () => { + const cwd = cwdOnly(); + fs.writeFileSync(path.join(cwd, "routes.txt"), "/a @.x\n\n /b \n"); + const r = await dryIn(cwd, "routes.txt"); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /^Routes: 2$/m); + }); + + test("each value that matches nothing is an error, even beside one that matches", async () => { + const cwd = cwdOnly(); + const r = await dryIn(cwd, "/a", "/typo"); + assert.equal(r.code, 2, r.stdout); + assert.match( + r.stderr, + /--only \/typo matches no config route; the config has: \/a, \/a \[\.x\], \/b \[\.card\], \/c::\/d/, + ); + const typo = await dryIn(cwd, "rotes.txt"); + assert.equal(typo.code, 2); + assert.match(typo.stderr, /--only rotes\.txt matches no config route \(and no such file\)/); + fs.writeFileSync(path.join(cwd, "routes.txt"), "/a\n\n/x\n"); + const line = await dryIn(cwd, "routes.txt"); + assert.equal(line.code, 2); + assert.match(line.stderr, /--only routes\.txt: "\/x" \(line 3\) matches no config route;/); + }); + + test("needs a config file, and says when it has no routes", async () => { + const none = await dryIn(tempDir("renderdiff-cfg-"), "/a"); + assert.equal(none.code, 2); + assert.match(none.stderr, /--only filters a config file's routes; none was found/); + const cwd = tempDir("renderdiff-cfg-"); + fs.writeFileSync( + path.join(cwd, "renderdiff.config.mjs"), + "export default { widths: [800] };\n", + ); + const empty = await dryIn(cwd, "/a"); + assert.equal(empty.code, 2); + assert.match(empty.stderr, /--only \/a matches no config route; the config has no routes/); + }); +});