Skip to content
Merged
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,15 @@ renderdiff <left-origin> <right-origin> <route…> [options]
| Argument or flag | |
| --- | --- |
| `<origin>` | An argument starting with `http://` or `https://`. The first is left (your change), the second right (the reference); a third is an error. |
| `<route>` | A literal path, e.g. `/about`, or a pair `/pricing::/plans` (see below). |
| `<route>` | A literal path, e.g. `/about`, or a pair `/pricing::/plans` (see below), or `'/pricing @.cta'` for one element on that route. |
| `--left <url>` | Left origin, instead of the first positional one. Giving both is an error. |
| `--right <url>` | Right origin, instead of the second positional one. |
| `--width <n>` | Viewport width; repeatable. Replaces the defaults. |
| `--threshold <0..1>` | Exit 1 only if a comparison scores below this, instead of on any difference. `0` never fails on one. |
| `--out <dir>` | Output directory, wiped at the start of a run. Default `./renderdiff-out`. A directory holding files renderdiff didn't write is refused. |
| `--concurrency <n>` | Parallel workers. Default half the CPU cores, between 2 and 8. Lower it when one side is someone's live site. |
| `--config <path>` | Config file. Default: `renderdiff.config.{ts,mts,js,mjs}` in cwd. |
| `--selector <sel>` | 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 <sel>` | 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 <sel>`, `--right-selector <sel>` | That side's selector, over `--selector`. One without the other side (or `--selector`) is an error. |
| `--mask <sel>` | 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. |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/github-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 5 additions & 1 deletion docs/scoping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/renderdiff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sel>`. |
| One component | pixels with `--selector <sel>` or `'/route @sel'`. |

Pixels decide the score and the exit code in every mode.

Expand Down
6 changes: 4 additions & 2 deletions skills/renderdiff/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sel>` (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
Expand Down
6 changes: 4 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 21 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export type Hook = (
) => Promise<void>;

/**
* 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
Expand All @@ -65,8 +65,10 @@ export const name = (r: Pick<Route, "left" | "right" | "selector">) =>
/**
* 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`,
* which beats a config object's own. A route's own `before` (or `beforeShot`) replaces the
* top-level one.
*/
export function parseRoutes(
specs: RouteSpec[],
Expand All @@ -77,9 +79,21 @@ export function parseRoutes(
const out = new Map<string, Route>();
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];
}
Expand All @@ -95,7 +109,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,
};
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/cli-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "/[email protected]", '/c @[href="mailto:[email protected]"]']);
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:[email protected]"]]'), 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", () => {
Expand Down
Loading