From c5e524c4285d9493d3eaceaf0294f757387936a3 Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:17:29 +0100 Subject: [PATCH 1/2] Per-route selector in the route string: '/pricing @section.cta' A route string's side may end in whitespace + @selector; a pair takes one per side. The required whitespace keeps /@user, /npm/@scope/pkg and ?e=a@b plain paths. A selector in the route beats --selector, which still replaces a config route object's selector. Fixes #26 Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++---- docs/github-action.md | 2 +- docs/scoping.md | 6 +++++- skills/renderdiff/SKILL.md | 2 +- skills/renderdiff/reference.md | 6 ++++-- src/cli.ts | 4 +++- src/config.ts | 26 ++++++++++++++++++++------ tests/unit/cli-args.test.ts | 28 ++++++++++++++++++++++++++++ 8 files changed, 67 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8fd1e18..5d0bf80 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ renderdiff [options] | Argument or flag | | | --- | --- | | `` | An argument starting with `http://` or `https://`. The first is left (your change), the second right (the reference); a third is an error. | -| `` | A literal path, e.g. `/about`, or a pair `/pricing::/plans` (see below). | +| `` | A literal path, e.g. `/about`, or a pair `/pricing::/plans` (see below), or `'/pricing @.cta'` for one element on that route. | | `--left ` | Left origin, instead of the first positional one. Giving both is an error. | | `--right ` | Right origin, instead of the second positional one. | | `--width ` | Viewport width; repeatable. Replaces the defaults. | @@ -103,7 +103,7 @@ renderdiff [options] | `--out ` | Output directory, wiped at the start of a run. Default `./renderdiff-out`. A directory holding files renderdiff didn't write is refused. | | `--concurrency ` | Parallel workers. Default half the CPU cores, between 2 and 8. Lower it when one side is someone's live site. | | `--config ` | Config file. Default: `renderdiff.config.{ts,mts,js,mjs}` in cwd. | -| `--selector ` | Compare the one element this Playwright selector matches, on both sides, instead of the whole page. Applies to every route, over the config's per-route `selector`. | +| `--selector ` | Compare the one element this Playwright selector matches, on both sides, instead of the whole page. Applies to every route without its own `@selector`, over the config's per-route `selector`. | | `--left-selector `, `--right-selector ` | That side's selector, over `--selector`. One without the other side (or `--selector`) is an error. | | `--mask ` | Paint this Playwright selector over on both sides, on every route; repeatable. Added to the config's masks and listed in the report as coverage given up. | | `--mask-media` | Mask every `iframe` on every route, as config `maskMedia: true` does. Video isn't masked: it is frozen by default, so the player's box is still compared. | @@ -143,12 +143,13 @@ range rather than on a boundary, so a layout switch never flaps between runs. ``` renderdiff http://localhost:3000 https://example.com /pricing::/plans renderdiff http://localhost:3000 https://example.com /pricing --selector "[data-test=plans]" +renderdiff http://localhost:3000 https://example.com / '/pricing @section.cta' ``` A pair `left::right` loads `/pricing` on the left and `/plans` on the right. `--selector` compares one element, which must match exactly once per side (`--left-selector` / -`--right-selector` when the markup differs). A state behind a click takes a config `before` -hook. For pages behind a login: +`--right-selector` when the markup differs); `'/pricing @section.cta'` scopes just that route. +A state behind a click takes a config `before` hook. For pages behind a login: ``` npx playwright codegen --save-storage=auth.json https://example.com/login diff --git a/docs/github-action.md b/docs/github-action.md index 48d72b7..0cd58a0 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -22,7 +22,7 @@ exits non-zero. | --- | --- | | `left` | Your change: a preview URL or `http://localhost:3000`. Required. | | `right` | The reference, usually production. Required. | -| `routes` | Separated by spaces or newlines; pairs (`/pricing::/plans`) work. Optional when the config lists routes. | +| `routes` | Separated by spaces or newlines; pairs (`/pricing::/plans`) work; per-route selectors go in the config, since the input splits on spaces. Optional when the config lists routes. | | `config` | Config file path. Default: `renderdiff.config.{ts,mts,js,mjs}` in the workspace. | | `widths` | Spaces or commas. Default renderdiff's five. | | `threshold` | Fail only below this score. Without it, any difference fails the job (exit 1); `"0"` never fails on one. | diff --git a/docs/scoping.md b/docs/scoping.md index ad47167..723bcd5 100644 --- a/docs/scoping.md +++ b/docs/scoping.md @@ -29,6 +29,7 @@ To compare one component instead of the whole page, give a selector: ``` renderdiff http://localhost:3000 https://example.com /pricing --selector "[data-test=plans]" renderdiff http://localhost:3000 https://example.com /pricing --left-selector .plans-v2 --right-selector .plans +renderdiff http://localhost:3000 https://example.com / '/pricing @section.cta' '/pricing @.plans-v2::/plans @.plans' ``` Any Playwright selector works, `xpath=` and `>>` chains included, and it's never split on @@ -37,7 +38,10 @@ with the count (`selector ".card" matched 6 elements on left (/pricing), expecte shot, the score, the regions and the text diff are that element's, in its own pixels; a mask is painted where it overlaps the element. -A CLI selector applies to every route and replaces the config's. In a config file a route +A route string can carry its own selector after whitespace and `@`: `'/pricing @section.cta'`, +or one per side, `'/pricing @.plans-v2::/plans @.plans'`. The space before `@` is required, +so `/@user` stays a path. A selector written in the route beats `--selector`, which applies +to every other route and replaces the config's route-object selectors. In a config file a route object takes `selector: ".plans"` or `selector: { left: ".plans-v2", right: ".plans" }`; to compare several components of one page, list the route once per selector. Such a route is `/pricing [.plans]` in the summary and the report, keeps `/pricing @1440` on a diff --git a/skills/renderdiff/SKILL.md b/skills/renderdiff/SKILL.md index e3d2a39..5e68763 100644 --- a/skills/renderdiff/SKILL.md +++ b/skills/renderdiff/SKILL.md @@ -22,7 +22,7 @@ the § named at each step. | CSS only, markup untouched (refactor, dead-CSS removal, token swap) | pixels + `--computed`. Expect `dom identical` on every route: then each `computed:` finding is a real style change, even on a clean route. `dom differs`: often a wrapper's class changed, re-keying its subtree (§ `--computed`). Hover, focus and open menus aren't shot without a `before` hook, and dead-CSS removal breaks exactly those. | | Markup changes (framework, template or component migration) | pixels. `--computed` findings would only be hints. | | Dependency or build upgrade | pixels, plus `--computed` if the markup should be identical. | -| One component | pixels with `--selector `. | +| One component | pixels with `--selector ` or `'/route @sel'`. | Pixels decide the score and the exit code in every mode. diff --git a/skills/renderdiff/reference.md b/skills/renderdiff/reference.md index 68f7974..96506a5 100644 --- a/skills/renderdiff/reference.md +++ b/skills/renderdiff/reference.md @@ -251,8 +251,10 @@ computed: 2 findings; dom identical on 2 of 3 route(s), differs /pricing (1), so first `::` followed by `/`, so `/q?x=std::vector` and `pricing::plans` are single routes. Named `/a::/b` in stdout and `url`; files `{a}~{b}.{width}.*.png`. - **One component:** `--selector ` (or `--left-selector` + `--right-selector`, or a route - object's `selector: ".x" | { left, right }`) compares the one element it matches, exactly one - per side, in its own pixels. A CLI selector applies to every route and replaces the config's. + object's `selector: ".x" | { left, right }`, or `'/a @.x'` / `'/a @.l::/b @.r'` in the route + string) compares the one element it matches, exactly one per side, in its own pixels. A + selector in the route string beats `--selector`, which applies to every other route and + replaces a route object's. Named `/a [.x]`; files `{slug}@{sel}.{width}.*.png`. The whole page still loads; it's shot at scroll 0, so fixed or sticky overlays over it are included (`hideSelectors` them); a transformed element is shot at its axis-aligned box. `xpath=` and `>>` work; pseudo-elements diff --git a/src/cli.ts b/src/cli.ts index a4aa756..85f04da 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -187,10 +187,12 @@ const HELP = ` An argument starting with http:// or https:// is an origin: the first is left (your change), the second right (the reference); or pass --left / --right. A route is a literal path, loaded on both sides; /a::/b loads /a on the left and /b on the right. + '/a @sel' compares one element on that route ('/a @sel::/b @sel2' per side). No globs, no discovery. With --selector, each side compares the one element it matches instead of the page; - it must match exactly one. It applies to every route, over the config's own selectors. + it must match exactly one. It applies to every route without its own @sel, over the + config's own selectors. With --storage-state, each side starts with that Playwright storage state (cookies, localStorage), e.g. signed in. Make one with: diff --git a/src/config.ts b/src/config.ts index cbc08ba..96b4c7c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,8 +38,8 @@ export type Hook = ( ) => Promise; /** - * A route as written: `/a`, the pair `/a::/b`, or `{ left, right?, selector? }` (right - * defaults to left). + * A route as written: `/a`, the pair `/a::/b`, either with ` @sel` after a path, or + * `{ left, right?, selector? }` (right defaults to left). */ export type RouteSpec = | string @@ -65,8 +65,9 @@ export const name = (r: Pick) => /** * Specs -> normalized, deduplicated routes; throws on an empty side or a glob. A string is a * pair when it has a `::` followed by `/` (or ending it, so `/a::` is an empty side, not a - * literal), which keeps `?q=std::vector` and `/wiki/Talk:Foo` single routes. A CLI - * `selector` replaces every route's own; a route's own `before` (or `beforeShot`) replaces the top-level one. + * literal), which keeps `?q=std::vector` and `/wiki/Talk:Foo` single routes. Each side may end + * in whitespace + `@sel` (the whitespace keeps `/@user` a path), which beats a CLI `selector`; + * that replaces a config object's own; a route's own `before` (or `beforeShot`) replaces the top-level one. */ export function parseRoutes( specs: RouteSpec[], @@ -77,9 +78,21 @@ export function parseRoutes( const out = new Map(); for (const spec of specs) { let sides: [string, string]; + let atSel: Sides | undefined; if (typeof spec === "string") { const i = spec.search(/::(?=\/|$)/); - sides = i === -1 ? [spec, spec] : [spec.slice(0, i), spec.slice(i + 2)]; + const at = (s: string): [string, string?] => { + const m = /\s+@/.exec(s); + return m ? [s.slice(0, m.index), s.slice(m.index + m[0].length).trim()] : [s]; + }; + const [l, ls] = at(i === -1 ? spec : spec.slice(0, i)); + const [r, rs] = i === -1 ? [l, ls] : at(spec.slice(i + 2)); + sides = [l, r]; + if ((ls === undefined) !== (rs === undefined)) { + throw new Error(`route ${spec}: give a selector on both sides or neither`); + } + if (ls === "" || rs === "") throw new Error(`route ${spec} has an empty selector`); + if (ls && rs) atSel = { left: ls, right: rs }; } else { sides = [spec.left, spec.right ?? spec.left]; } @@ -95,7 +108,8 @@ export function parseRoutes( const route = { left: normalize(sides[0]), right: normalize(sides[1]), - selector: selector ?? (typeof own === "string" ? { left: own, right: own } : (own ?? null)), + selector: + atSel ?? selector ?? (typeof own === "string" ? { left: own, right: own } : (own ?? null)), before: (typeof spec === "string" ? undefined : spec.before) ?? before ?? null, beforeShot: (typeof spec === "string" ? undefined : spec.beforeShot) ?? beforeShot ?? null, }; diff --git a/tests/unit/cli-args.test.ts b/tests/unit/cli-args.test.ts index 52505e7..894fe9d 100644 --- a/tests/unit/cli-args.test.ts +++ b/tests/unit/cli-args.test.ts @@ -330,6 +330,34 @@ describe("selectors", () => { test("an empty selector exits 2", () => rejects(["--dry-run", L, R, "/a", "--selector="], "--selector needs a value")); + + test("a route's own @selector beats --selector", async () => { + const r = await dry(["/a @.x", "/b", "--selector", ".card"]); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /^ \/a +\S+ \| \S+ \[\.x\]$/m); + assert.match(r.stdout, /^ \/b +\S+ \| \S+ \[\.card\]$/m); + }); + + test("a pair takes an @selector per side", async () => { + const r = await dry(["/a @.n::/b @.o"]); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /^ \/a::\/b +\S+ \| \S+ \[\.n \| \.o\]$/m); + }); + + test("@ without whitespace before it stays part of the path", async () => { + const r = await dry(["/@me", "/a?e=x@y.z", '/c @[href="mailto:x@y.z"]']); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /^Routes: 3$/m); + assert.match(r.stdout, /^ \/@me +\S+ \| \S+$/m); + assert.match(r.stdout, /^ \/a\?e=x@y\.z +\S+ \| \S+$/m); + assert.ok(r.stdout.includes('[[href="mailto:x@y.z"]]'), r.stdout); + }); + + test("an @selector on one side of a pair exits 2", () => + rejects(["--dry-run", L, R, "/a::/b @.o"], "give a selector on both sides or neither")); + + test("an empty @selector exits 2", () => + rejects(["--dry-run", L, R, "/a @"], "has an empty selector")); }); describe("storage state", () => { From cd002eaabf992581e1c3018766a37c3a1db4f56e Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:25:53 +0100 Subject: [PATCH 2/2] Fix selector-precedence comments for route @sel Co-Authored-By: Claude Opus 5.5 --- src/cli.ts | 2 +- src/config.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 85f04da..d56191a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,7 +37,7 @@ interface Args extends CliOverrides { version: boolean; config?: string; routes: string[]; - /** From --selector / --left-selector / --right-selector; replaces every route's own. */ + /** From --selector / --left-selector / --right-selector; replaces a config route object's own. */ selector?: Sides; /** The reason to accept this run's differences with. */ accept?: string; diff --git a/src/config.ts b/src/config.ts index 96b4c7c..0127b74 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,8 +66,9 @@ export const name = (r: Pick) => * Specs -> normalized, deduplicated routes; throws on an empty side or a glob. A string is a * pair when it has a `::` followed by `/` (or ending it, so `/a::` is an empty side, not a * literal), which keeps `?q=std::vector` and `/wiki/Talk:Foo` single routes. Each side may end - * in whitespace + `@sel` (the whitespace keeps `/@user` a path), which beats a CLI `selector`; - * that replaces a config object's own; a route's own `before` (or `beforeShot`) replaces the top-level one. + * in whitespace + `@sel` (the whitespace keeps `/@user` a path), which beats a CLI `selector`, + * which beats a config object's own. A route's own `before` (or `beforeShot`) replaces the + * top-level one. */ export function parseRoutes( specs: RouteSpec[],