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 @@ -113,6 +113,7 @@ renderdiff <left-origin> <right-origin> <route…> [options]
| `--left-storage-state <file>`, `--right-storage-state <file>` | That side's storage state, over `--storage-state`. One side alone is fine. |
| `--accept <reason>` | 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 <route\|file>` | 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 `<out>/.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). |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions renderdiff.config.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions skills/renderdiff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sel>` 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 <right><route> --width <w>` names what varies between loads; mask that.
Against production instead: open each difference, then
until clean; `--only <route>` 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 <right><route> --width <w>` 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` →
Expand Down
54 changes: 53 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
alive,
label,
loadConfig,
name,
normalize,
parseRoutes,
resolveConfig,
Expand All @@ -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. */
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -253,6 +264,9 @@ const HELP = `
--accept <reason> 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 <route|file> 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.
Expand Down Expand Up @@ -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<Route>();
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 <url>`: load one URL several times and list what varies between loads. */
async function probe(a: Args, loads: number): Promise<never> {
if (!a.left) die("probe needs a URL: renderdiff probe <url>");
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;
Expand Down Expand Up @@ -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));
}
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/cli-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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");
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/config-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Loading