renderdiff 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +490 -2
- package/dist/capture.d.ts +34 -0
- package/dist/capture.js +338 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +288 -0
- package/dist/cli.js.map +1 -0
- package/dist/compare.d.ts +93 -0
- package/dist/compare.js +537 -0
- package/dist/compare.js.map +1 -0
- package/dist/config.d.ts +223 -0
- package/dist/config.js +388 -0
- package/dist/config.js.map +1 -0
- package/dist/format.d.ts +20 -0
- package/dist/format.js +26 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/locate.d.ts +24 -0
- package/dist/locate.js +316 -0
- package/dist/locate.js.map +1 -0
- package/dist/report.d.ts +118 -0
- package/dist/report.js +192 -0
- package/dist/report.js.map +1 -0
- package/dist/run.d.ts +17 -0
- package/dist/run.js +390 -0
- package/dist/run.js.map +1 -0
- package/package.json +59 -4
- package/renderdiff.config.example.ts +111 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Timur Mashara
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,491 @@
|
|
|
1
|
-
#
|
|
1
|
+
# renderdiff
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Screenshot diff between two origins.
|
|
4
|
+
|
|
5
|
+
Give it a list of routes and two base URLs. It screenshots each route on both sides at
|
|
6
|
+
several viewport widths, pixel-diffs the pairs, and writes a scored HTML report with
|
|
7
|
+
left / right / diff side by side, plus `results.json` and crops of the changes, sized to stay
|
|
8
|
+
legible to coding agents.
|
|
9
|
+
|
|
10
|
+
Useful for the "did I break anything?" question a test suite can't answer: a framework
|
|
11
|
+
migration, a CSS refactor, a dependency bump, a staging-vs-production check.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
renderdiff http://localhost:3000 https://example.com / /about /pricing
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
On a terminal it prints a line per shot as it lands, then the worst scores and the paths
|
|
18
|
+
to `report.html` and `results.json`. Piped — to a file, CI or a coding agent — it prints
|
|
19
|
+
one summary at the end instead, listing only what differs:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Routes: 3
|
|
23
|
+
clean: /
|
|
24
|
+
/about clean @1152,896,704,375
|
|
25
|
+
@1440 97.41% h-24 band -24@y196 aligned 100.00% text -"Pricing"
|
|
26
|
+
dom -nav > a[href="/pricing"] "Pricing"
|
|
27
|
+
crops about.1440.c1.png
|
|
28
|
+
/pricing
|
|
29
|
+
@1440,1152,896,704,375 99.12%–99.87% 1 reg
|
|
30
|
+
dom @1440 div.plans > a.cta margin-left 24px→16px
|
|
31
|
+
crops pricing.1440.c1.png pricing.1152.c1.png pricing.896.c1.png pricing.704.c1.png pricing.375.c1.png
|
|
32
|
+
renderdiff: 2 of 3 routes differ (6 of 15 shots), 0 errors, worst 97.41% /about@1440 — /Users/you/site/renderdiff-out/results.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A row covers the widths that agree on everything but the score (shown as a range): height
|
|
36
|
+
delta (`h`), bands (`+` rows only left has, `-` rows only right has, at `y`; `~` when the
|
|
37
|
+
widths put it in different places), aligned score, region count, unstable pixels and up to
|
|
38
|
+
three text-diff lines (`-"x"` only on the right, `+"x"` only on the left). Under a row,
|
|
39
|
+
`dom` names up to three elements behind the changes (see **Elements** below; `@w` when the
|
|
40
|
+
row covers several widths: the first one's), and `crops` lists that row's crop files,
|
|
41
|
+
relative to the directory `results.json` is in. Errors print in
|
|
42
|
+
full, grouped across the widths that share a message. Clean means not one pixel differs
|
|
43
|
+
and none went unmeasured as unstable; a route with unstable pixels gets its own row. With
|
|
44
|
+
`--threshold`, the verdict line ends `, B below 97.00%`. `--progress` gets the per-shot
|
|
45
|
+
output when piped, `--no-progress` the summary on a terminal.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install -g renderdiff
|
|
51
|
+
npx playwright install chromium # once, if you don't already have it
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Requires Node >= 22.18. If the bundled Chromium won't launch, renderdiff falls back to an
|
|
55
|
+
installed Google Chrome on both sides and prints a line saying so; `meta.browser` in
|
|
56
|
+
`results.json` records which browser ran.
|
|
57
|
+
|
|
58
|
+
### Agent skill
|
|
59
|
+
|
|
60
|
+
[`skills/renderdiff/SKILL.md`](./skills/renderdiff/SKILL.md) teaches a coding agent to use
|
|
61
|
+
renderdiff without fooling itself: run it with `npx renderdiff@latest`, read the summary
|
|
62
|
+
it prints, then the text diff, the bands and the region crops, and give a verdict that says
|
|
63
|
+
what it looked at — not just a score. The longer reference (triage, rationalizations,
|
|
64
|
+
config, gotchas) sits next to it in `reference.md` and is read only when needed.
|
|
65
|
+
|
|
66
|
+
In Claude Code, as a plugin:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
/plugin marketplace add tmashara/renderdiff
|
|
70
|
+
/plugin install renderdiff@renderdiff
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For other agents (Codex, Cursor and so on), with [skills](https://skills.sh):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx skills add tmashara/renderdiff
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
renderdiff <left-origin> <right-origin> <route…> [options]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Argument or flag | |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `<origin>` | An argument starting with `http://` or `https://`. The first is left, the second right; a third is an error. |
|
|
88
|
+
| `<route>` | A literal path, e.g. `/about`, or a pair `/pricing::/plans` (see below). |
|
|
89
|
+
| `--left <url>` | Left origin, instead of the first positional one. Giving both is an error. |
|
|
90
|
+
| `--right <url>` | Right origin, instead of the second positional one. |
|
|
91
|
+
| `--width <n>` | Viewport width; repeatable. Replaces the defaults. |
|
|
92
|
+
| `--threshold <0..1>` | Exit non-zero if any comparison scores below this. |
|
|
93
|
+
| `--out <dir>` | Output directory, wiped at the start of a run. Default `./renderdiff-out`. A directory holding files renderdiff didn't write is refused. |
|
|
94
|
+
| `--concurrency <n>` | Parallel workers. Default half the CPU cores, between 2 and 8. Lower it when one side is someone's live site. |
|
|
95
|
+
| `--config <path>` | Config file. Default: `renderdiff.config.{ts,mts,js,mjs}` in cwd. |
|
|
96
|
+
| `--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`. |
|
|
97
|
+
| `--left-selector <sel>`, `--right-selector <sel>` | That side's selector, over `--selector`. One without the other side (or `--selector`) is an error. |
|
|
98
|
+
| `--storage-state <file>` | Start both sides with this Playwright storage state (cookies, localStorage), e.g. signed in. Relative to cwd. Over the config's `storageState`. |
|
|
99
|
+
| `--left-storage-state <file>`, `--right-storage-state <file>` | That side's storage state, over `--storage-state`. One side alone is fine. |
|
|
100
|
+
| `--dry-run` | Print the URL pairs and exit. No browser. |
|
|
101
|
+
| `--open` | Open the report when the run finishes. |
|
|
102
|
+
| `--progress` | A line per shot as it finishes, then the worst scores. The default on a terminal; piped, renderdiff prints one compact summary at the end. |
|
|
103
|
+
| `--no-progress` | The compact summary even on a terminal. |
|
|
104
|
+
| `-h, --help` / `-v, --version` | |
|
|
105
|
+
|
|
106
|
+
Routes are **literal paths** — there is no route discovery and globs are rejected. Each
|
|
107
|
+
route is loaded on both origins, unless it's a pair.
|
|
108
|
+
|
|
109
|
+
Default widths are `1440, 1152, 896, 704, 375` — one inside each common responsive band
|
|
110
|
+
rather than on a breakpoint boundary, so a layout switch never flaps between captures.
|
|
111
|
+
|
|
112
|
+
Exit codes: `0` no route errored and none scored below `--threshold` (without one, a diff
|
|
113
|
+
still exits 0) · `1` a route scored below `--threshold` · `2` bad arguments, bad config, or an
|
|
114
|
+
origin that isn't responding · `3` a route errored, so it went unmeasured, or the run
|
|
115
|
+
crashed (e.g. no Chromium). `1` wins over `3` when both happen.
|
|
116
|
+
|
|
117
|
+
## Comparing different routes or components
|
|
118
|
+
|
|
119
|
+
When a migration moved a page, give the route as a pair: `left::right`.
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
renderdiff http://localhost:3000 https://example.com /pricing::/plans /about::/company/about
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`/pricing::/plans` loads `/pricing` on the left and `/plans` on the right. The split is
|
|
126
|
+
on the first `::` followed by `/`, so `/q?x=std::vector` and `/wiki/Talk:Foo` stay single
|
|
127
|
+
routes, and `pricing::plans` (no slash) is a literal path; `--dry-run` shows how each
|
|
128
|
+
route was read. Each side is normalized on its own, a pair with identical sides is the
|
|
129
|
+
plain route, and an empty side (`/pricing::`) is an error.
|
|
130
|
+
|
|
131
|
+
A pair is named `/pricing::/plans` in stdout, `results.json` (`url`, plus `left` and
|
|
132
|
+
`right`) and the report; its images are `pricing~plans.{width}.*.png`. A mask whose
|
|
133
|
+
`urlPath` is either side's path is painted on both sides. In a config file, a route can
|
|
134
|
+
also be an object, `{ left: "/pricing", right: "/plans" }`, where `right` defaults to
|
|
135
|
+
`left`.
|
|
136
|
+
|
|
137
|
+
To compare one component instead of the whole page, give a selector:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
renderdiff http://localhost:3000 https://example.com /pricing --selector "[data-test=plans]"
|
|
141
|
+
renderdiff http://localhost:3000 https://example.com /pricing --left-selector .plans-v2 --right-selector .plans
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Any Playwright selector works, `xpath=` and `>>` chains included, and it's never split on
|
|
145
|
+
`::`. It must match **exactly one** element on each side; otherwise the route errors at once
|
|
146
|
+
with the count (`selector ".card" matched 6 elements on left (/pricing), expected 1`). The
|
|
147
|
+
shot, the score, the regions and the text diff are that element's, in its own pixels; a
|
|
148
|
+
mask is painted where it overlaps the element.
|
|
149
|
+
|
|
150
|
+
A CLI selector applies to every route and replaces the config's. In a config file a route
|
|
151
|
+
object takes `selector: ".plans"` or `selector: { left: ".plans-v2", right: ".plans" }`;
|
|
152
|
+
to compare several components of one page, list the route once per selector. Such a route
|
|
153
|
+
is `/pricing [.plans]` in the summary and the report, keeps `/pricing @1440` on a
|
|
154
|
+
`--progress` row with the selector on the line under it, has `selector` in `results.json`
|
|
155
|
+
(`{ left, right }`, or `null` for a whole page), and its images are
|
|
156
|
+
`pricing@_plans.{width}.*.png`.
|
|
157
|
+
|
|
158
|
+
Things to know:
|
|
159
|
+
|
|
160
|
+
- The whole page still loads and settles first, so a component costs about what its page does.
|
|
161
|
+
- It's shot at scroll 0 with the rest of the page, so a fixed or sticky header over it is
|
|
162
|
+
in the shot. Hide it with `hideSelectors`.
|
|
163
|
+
- An element inside a shorter scroll container includes whatever paints below the container.
|
|
164
|
+
- A transformed element is shot at its axis-aligned bounding box.
|
|
165
|
+
- Pseudo-elements (`::before`) are rejected by Playwright; elements inside iframes can't be
|
|
166
|
+
reached. Open shadow roots are pierced.
|
|
167
|
+
- A selector that matches an element with no box (`display: none`) or one off the page is
|
|
168
|
+
an error. The render gate stays page-level: a textless element on a rendered page is fine.
|
|
169
|
+
|
|
170
|
+
## Interactions and signed-in pages
|
|
171
|
+
|
|
172
|
+
When the state worth comparing is behind a click (an open menu, a dismissed modal both
|
|
173
|
+
sides show), give the route a `before` hook in the config. It runs on each side after the
|
|
174
|
+
page loads and its HTTP status is checked, and before renderdiff settles, masks and shoots
|
|
175
|
+
it, with the clock and seeded `Math.random` already in force:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
export default {
|
|
179
|
+
routes: [
|
|
180
|
+
{ left: "/", before: async (page) => { await page.click("[aria-label=Menu]"); } },
|
|
181
|
+
],
|
|
182
|
+
// For every route without its own `before` (which replaces it), CLI routes included.
|
|
183
|
+
before: async (page, { side, route, width }) => {
|
|
184
|
+
const accept = page.getByRole("button", { name: "Accept" });
|
|
185
|
+
if (await accept.isVisible()) await accept.click();
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`side` is `"left"` or `"right"`, `route` the route's name (`/a` or `/a::/b`), `width` the
|
|
191
|
+
viewport width. Use `side` only where the markup legitimately differs (the button has a
|
|
192
|
+
different selector on each side), never to make the sides look alike. A hook that throws
|
|
193
|
+
is a route error, `before hook failed on left: …`, and isn't retried. A route that ran one
|
|
194
|
+
has `"hook": true` in `results.json` and a `before hook` badge on its report card.
|
|
195
|
+
Actions in a hook use Playwright's 30s default timeout, so check before clicking what may
|
|
196
|
+
not be there (as above); `hideSelectors` and `extraCss` apply after the hook.
|
|
197
|
+
|
|
198
|
+
For pages behind a login, save a Playwright storage state once and pass it:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
npx playwright codegen --save-storage=auth.json https://example.com/login
|
|
202
|
+
renderdiff http://localhost:3000 https://example.com /account --storage-state auth.json
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`--left-storage-state` / `--right-storage-state` give each side its own file, and the
|
|
206
|
+
config takes `storageState: "auth.json"` or `{ left, right }`, relative to the config file.
|
|
207
|
+
A missing or non-JSON file, or one that isn't a storage state, exits 2 before the browser starts. The file's contents are never
|
|
208
|
+
printed; `meta.storageState` and the report name only each side's file. It holds live
|
|
209
|
+
session tokens: **never commit it**, and add it to `.gitignore`. The out dir then holds
|
|
210
|
+
signed-in screenshots, and their text in `results.json`, so don't publish or commit that
|
|
211
|
+
either; add the out dir to `.gitignore` too.
|
|
212
|
+
|
|
213
|
+
## Config file
|
|
214
|
+
|
|
215
|
+
Flags cover a one-off run. A `renderdiff.config.ts` (or `.mjs`) in the working directory
|
|
216
|
+
covers a repo you diff repeatedly, and expresses the things flags can't: **masks** for
|
|
217
|
+
content that legitimately drifts, extra selectors to hide, extra URLs to block.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
export default {
|
|
221
|
+
left: "http://localhost:3000",
|
|
222
|
+
right: "https://example.com",
|
|
223
|
+
routes: [
|
|
224
|
+
"/",
|
|
225
|
+
"/about",
|
|
226
|
+
"/pricing::/plans",
|
|
227
|
+
{ left: "/blog", right: "/posts" },
|
|
228
|
+
{ left: "/pricing", selector: "[data-test=plans]" },
|
|
229
|
+
],
|
|
230
|
+
widths: [1440, 896, 375],
|
|
231
|
+
threshold: 0.97,
|
|
232
|
+
|
|
233
|
+
hideSelectors: [".site-cookie-banner"],
|
|
234
|
+
blockUrlPatterns: ["**://*.my-tracker.example/**"],
|
|
235
|
+
|
|
236
|
+
masks: [
|
|
237
|
+
{ selector: "[data-star-count]", reason: "live star count", urlPath: "/pricing" },
|
|
238
|
+
],
|
|
239
|
+
|
|
240
|
+
storageState: "auth.json", // relative to this file; never commit it
|
|
241
|
+
};
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
A mask needs a `reason` and exactly one of `selector` (a Playwright selector) or
|
|
245
|
+
`locate: (page) => Locator`, the escape hatch for what a selector can't say. Without
|
|
246
|
+
`urlPath` it applies to every route.
|
|
247
|
+
|
|
248
|
+
See [`renderdiff.config.example.ts`](./renderdiff.config.example.ts) for every field, annotated.
|
|
249
|
+
|
|
250
|
+
**Precedence: CLI flag > config file > built-in default.**
|
|
251
|
+
|
|
252
|
+
`export default { … }` needs no import. `import type { RenderdiffConfig } from "renderdiff"`
|
|
253
|
+
with `satisfies RenderdiffConfig` is for editor types only; Node erases it, so the file loads
|
|
254
|
+
without renderdiff installed in that project.
|
|
255
|
+
|
|
256
|
+
## How it reads
|
|
257
|
+
|
|
258
|
+
The score is the share of pixels that match, per route per width. Green above 99.5%,
|
|
259
|
+
amber to 97%, red below. It is a triage signal, not a verdict — open the report and
|
|
260
|
+
look at the diff column.
|
|
261
|
+
|
|
262
|
+
Under each score, `--progress` output and the report card say where to look:
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
[3/8] 97.14% /pricing @1440 (height -24px)
|
|
266
|
+
band -24px at y=196 (right)
|
|
267
|
+
aligned 100.00%
|
|
268
|
+
text: 0 added, 1 removed
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
- **Regions** are connected patches of differing pixels, on a 16px grid, largest first. On
|
|
272
|
+
a long page a missing link barely moves the score; it is still a region. Only the area
|
|
273
|
+
both sides cover is searched: padding is reported by the height delta and the bands. With
|
|
274
|
+
bands, regions come from the realigned rows, so content a band only pushed down isn't
|
|
275
|
+
one (above, the band is the whole difference, so there are none). The
|
|
276
|
+
card lists the top five with their pixel counts; up to three crops cover the bands first,
|
|
277
|
+
then the largest regions.
|
|
278
|
+
- **Bands** are rows only one side has, found by aligning the two images row by row. `-24px
|
|
279
|
+
at y=196 (right)` means the right side has 24 rows there that the left lacks — signed like
|
|
280
|
+
the height delta. When rows of identical content repeat, the band's `y` can slide along
|
|
281
|
+
them, so treat it as approximate. `aligned` is the score over the rows the alignment pairs,
|
|
282
|
+
i.e. what the page would score if the bands were cut out; it only shows when there are
|
|
283
|
+
bands. Pages of different heights that share too few identical rows say `rows too
|
|
284
|
+
different to align`. A horizontal shift is never a band. Alignment works on exact row
|
|
285
|
+
matches, so it gives up when the edits total more than 2,000 rows (a single inserted or
|
|
286
|
+
removed block aligns at any height), or when sub-threshold shade noise touches every row.
|
|
287
|
+
- **Elements** name what is under the first five bands and regions, up to three each, read
|
|
288
|
+
from the live pages right after the shot. Each is a CSS selector unique on its side, so
|
|
289
|
+
`--selector` takes it. `-sel "text"` exists only on the right, `+sel` only on the left;
|
|
290
|
+
`sel margin-left 24px→16px` is on both, with the computed properties that differ, left
|
|
291
|
+
first; `sel x+8` moved without a style of its own changing, so the cause is a neighbour or
|
|
292
|
+
its parent, which is listed after it when one of them changed; a bare `sel` is on both
|
|
293
|
+
sides with nothing compared changed or moved, so its own pixels did (an image's source, a
|
|
294
|
+
canvas). An element without text is only paired when its box is the same size on both
|
|
295
|
+
sides; `rightSelector` is set when the right side was found by its text under another
|
|
296
|
+
selector. A band names only what one side added, not what it pushed down, so it can name
|
|
297
|
+
nothing; what fixed chrome covers isn't named. Probing gives up after 5s, leaving the
|
|
298
|
+
elements out. A computed
|
|
299
|
+
value is a symptom: find the rule that set it. Shadow DOM and iframes aren't searched.
|
|
300
|
+
- **Text** is a line diff of each side's `document.body.innerText` (the element's, with a
|
|
301
|
+
selector). Left is the changed version: *added* lines are on the left only, *removed* lines on the right only. The card
|
|
302
|
+
lists the first 20. Masked elements are left out. Unmasked text that changes per load
|
|
303
|
+
(a ticker, a clock) shows up here whenever the two sides differed at capture time, even
|
|
304
|
+
when its pixels were painted out as unstable.
|
|
305
|
+
|
|
306
|
+
**Magenta means padding.** When one side is taller or wider, the shorter image is padded
|
|
307
|
+
with magenta, and that padding counts against the score on purpose: a page 400px shorter
|
|
308
|
+
than the one it's compared against has not reached parity. The `height ±Npx` badge on the
|
|
309
|
+
row names the cause.
|
|
310
|
+
|
|
311
|
+
**Errors are not failures.** A route that threw produced no measurement, so it is
|
|
312
|
+
neither a pass nor a fail. It's listed separately in the report — and it still exits non-zero
|
|
313
|
+
(`3`), because an unmeasured route is not a verified one.
|
|
314
|
+
|
|
315
|
+
## Output
|
|
316
|
+
|
|
317
|
+
Everything lands in the out dir (`./renderdiff-out` by default):
|
|
318
|
+
|
|
319
|
+
- `report.html` — the scored report.
|
|
320
|
+
- `{slug}.{width}.{left,right,diff}.png` — full-page shots and the diff. `/` slugs to
|
|
321
|
+
`index`, `/blog/foo` to `blog-foo`, other characters to `_`, a pair `/a::/b` to `a~b`; a
|
|
322
|
+
clash gets a `-2` suffix.
|
|
323
|
+
- `{slug}.{width}.c{n}.png` — up to three crops per comparison: bands (rows only one side
|
|
324
|
+
has), tallest first, then the largest regions. Each shows left and right at native scale,
|
|
325
|
+
padded by 48px and at least 320px wide, with a 1px red box around the change; on the side
|
|
326
|
+
that lacks a band's rows, a 2px red seam is inserted where they would be, hiding nothing.
|
|
327
|
+
Left is always on top (`stacked`, for wide changes) or on the left (`side`, for tall
|
|
328
|
+
ones). A crop stays within 1,568px on its long edge and 1,568 image tokens
|
|
329
|
+
(⌈w/28⌉·⌈h/28⌉) and is never scaled below 0.75×; a change too big for that keeps its top
|
|
330
|
+
rows (or left columns) and is marked `clipped`. Height padding is never cropped.
|
|
331
|
+
- `results.json` — everything above as data, written on every run, errors included, one
|
|
332
|
+
result per line, scores rounded down to four decimals (so a shot that differs never
|
|
333
|
+
reads as `1`). Its types (`Results` and friends) are exported
|
|
334
|
+
from the package.
|
|
335
|
+
|
|
336
|
+
```jsonc
|
|
337
|
+
{
|
|
338
|
+
"meta": {
|
|
339
|
+
"tool": "renderdiff", "version": "0.2.0", "generatedAt": "…",
|
|
340
|
+
"left": "http://localhost:3000", "right": "https://example.com",
|
|
341
|
+
"widths": [1440, 375], "threshold": null,
|
|
342
|
+
"clock": "…", // the instant both sides' clocks start at
|
|
343
|
+
"blockUrlPatterns": ["…"], "hideSelectors": ["…"],
|
|
344
|
+
"masks": [{ "urlPath": "/pricing", "reason": "live star count" }], // no urlPath: every route
|
|
345
|
+
"browser": { "name": "chromium", "version": "…" }, // "chrome" when it fell back
|
|
346
|
+
"storageState": { "left": "auth.json", "right": null } // file names only
|
|
347
|
+
},
|
|
348
|
+
"results": [
|
|
349
|
+
{
|
|
350
|
+
"url": "/about", "left": "/about", "right": "/about", // a pair: "/a::/b", "/a", "/b"
|
|
351
|
+
"selector": null, "hook": false, // hook: a before hook ran
|
|
352
|
+
"width": 1440, "status": "compared",
|
|
353
|
+
"score": 0.9714, // share of measured pixels that match
|
|
354
|
+
"alignedScore": 1, // score over the aligned rows; null unless there are bands
|
|
355
|
+
"diffPixels": 33120, "totalPixels": 1158000, "unstablePixels": 0,
|
|
356
|
+
"heightDelta": -24, "widthDelta": 0, // left − right
|
|
357
|
+
"regions": [], // left-page px; with bands, from the realigned rows
|
|
358
|
+
"bands": [{ "side": "right", "y": 196, "h": 24, // null: too different to align
|
|
359
|
+
"elements": [{ // regions have them too; absent: not looked at
|
|
360
|
+
"selector": "nav > a[href=\"/pricing\"]", "side": "right", // or "left", "both"
|
|
361
|
+
"left": null, "right": { "x": 256, "y": 192, "w": 928, "h": 24 },
|
|
362
|
+
"changed": {}, // "margin-left": ["24px", "16px"], left first; "text" too
|
|
363
|
+
"text": "Pricing" // "rightSelector" when the right one differs
|
|
364
|
+
}]
|
|
365
|
+
}],
|
|
366
|
+
"text": { "added": [], "removed": ["Pricing"] },
|
|
367
|
+
"images": { "left": "about.1440.left.png", "right": "about.1440.right.png", "diff": "about.1440.diff.png" },
|
|
368
|
+
"crops": [{
|
|
369
|
+
"path": "about.1440.c1.png", "kind": "band", // or "region"
|
|
370
|
+
"left": { "x": 256, "y": 196, "w": 68, "h": 0 }, // h 0: where the rows would go
|
|
371
|
+
"right": { "x": 256, "y": 196, "w": 68, "h": 24 },
|
|
372
|
+
"layout": "stacked", "scale": 1, "clipped": false // left on top, or on the left ("side")
|
|
373
|
+
}]
|
|
374
|
+
},
|
|
375
|
+
{ "url": "/blank", "left": "/blank", "right": "/blank", "width": 1440, "status": "error", "error": "page did not render …" }
|
|
376
|
+
],
|
|
377
|
+
"summary": {
|
|
378
|
+
"compared": 1, "errors": 1, "mean": 0.9714,
|
|
379
|
+
"worst": { "url": "/about", "width": 1440, "score": 0.9714 },
|
|
380
|
+
"belowThreshold": 0
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Paths are relative to the out dir. Results are in route-then-width order.
|
|
386
|
+
|
|
387
|
+
## What it does to make two loads comparable
|
|
388
|
+
|
|
389
|
+
- **Refuses to start** unless both origins answer a HEAD request. Two error pages diffed
|
|
390
|
+
against each other score ~100%, which is the likeliest way a tool like this lies to you.
|
|
391
|
+
- **Refuses a shot** it can't trust: an HTTP status of 400 or above on either side is an
|
|
392
|
+
error naming the status. A route where *both* sides look blank — under 200 characters of
|
|
393
|
+
text and no visible images, SVG, canvas, video, iframes or form controls — errors instead
|
|
394
|
+
of quietly scoring as a match. If only one side is blank, the diff shows it.
|
|
395
|
+
- **Blocks analytics, consent and A/B tooling** at the network layer on both sides —
|
|
396
|
+
GTM, GA, Segment, Intercom, Hotjar, LinkedIn, Meta, OneTrust, CookieLaw, VWO. Blocked
|
|
397
|
+
rather than hidden, because A/B tools rewrite arbitrary DOM and CSS can't pre-empt an
|
|
398
|
+
experiment you haven't seen. Blocking must stay symmetric: doing it on one side only
|
|
399
|
+
is itself a diff.
|
|
400
|
+
- **Pins what varies between loads**, identically on both sides: the clock starts at one
|
|
401
|
+
instant per run (floored to the minute) on every load and then runs, `Math.random` is
|
|
402
|
+
seeded, timezone (UTC), locale (en-US), colour scheme
|
|
403
|
+
(light) and `prefers-reduced-motion` are fixed, and videos are paused at frame 0. A clock
|
|
404
|
+
that shows seconds can still tick between the two sides and may need a mask.
|
|
405
|
+
- **Kills animation, transitions and caret blink**, and hides common consent banners and
|
|
406
|
+
dev toolbars.
|
|
407
|
+
- **Settles the page** — makes native lazy images eager, scrolls every viewport-height
|
|
408
|
+
slice a frame at a time so script lazy loaders fire, waits for every image to decode,
|
|
409
|
+
`document.fonts.ready` and up to 3s for the network to go 250ms with nothing in flight
|
|
410
|
+
(server-sent events and beacons don't count), then re-injects the stylesheet to
|
|
411
|
+
catch late-injected chrome.
|
|
412
|
+
- **Shoots until stable** — repeats the screenshot until two in a row match (at most 3; 2
|
|
413
|
+
for tiled pages). Pixels still changing between the last two shots of the same load are
|
|
414
|
+
noise from that origin: they're painted magenta on both sides, reported per comparison
|
|
415
|
+
as `unstable`, and excluded from the measurement — the score is over the remaining
|
|
416
|
+
pixels. A route more than 25% unstable errors as `page did not settle`.
|
|
417
|
+
- **Isolates state** — one browser context per origin, so cookies and localStorage never
|
|
418
|
+
cross (a storage state file seeds each context on its own). Both sides of a route load at
|
|
419
|
+
the same time.
|
|
420
|
+
- **Retries once** per shot; a live site can time out on a cold edge.
|
|
421
|
+
- **Stitches tall pages.** A single screenshot past 16,384px can come out truncated or
|
|
422
|
+
wrapped (Chrome repeats the top of the page; recent headless Chromium does not), so taller
|
|
423
|
+
pages, and element shots taller than that, are captured as tiles through CDP and stitched
|
|
424
|
+
back together. Masks are
|
|
425
|
+
painted onto the final image from each element's bounding box, so they apply the same
|
|
426
|
+
way on both paths. Past 40,000px it errors — that's a broken layout, not a page.
|
|
427
|
+
|
|
428
|
+
Every run's report ends with a "What was neutralized" section listing the actual block
|
|
429
|
+
patterns, mask reasons and pinned settings in effect, so the report can't drift from the
|
|
430
|
+
config.
|
|
431
|
+
|
|
432
|
+
## Development
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
npm install # builds via the prepare script
|
|
436
|
+
npm run build # tsc → dist/
|
|
437
|
+
npm run typecheck # src + tests, no emit
|
|
438
|
+
npm install -g . # install this working copy globally
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Source is TypeScript in `src/`, compiled to ESM in `dist/`. Import specifiers keep their
|
|
442
|
+
`.ts` extension in source (`rewriteRelativeImportExtensions` rewrites them on emit), so
|
|
443
|
+
`node src/cli.ts` also runs directly.
|
|
444
|
+
|
|
445
|
+
## Tests
|
|
446
|
+
|
|
447
|
+
```bash
|
|
448
|
+
npm test # unit tier — no browser, ~4s
|
|
449
|
+
npm run test:visual # drives Chromium over the fixture corpus, ~40s
|
|
450
|
+
npm run test:all
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Built on `node:test`, no test dependencies. Running them needs a Chromium for the visual tier:
|
|
454
|
+
`npx playwright install chromium`.
|
|
455
|
+
|
|
456
|
+
The suite is black-box: it spawns `node src/cli.ts` and reads results back from stdout,
|
|
457
|
+
`report.html`, `results.json`, and the written PNGs. Nothing under `src/` is imported at
|
|
458
|
+
runtime (only the `results.json` types), so what is asserted is what a user gets. The output
|
|
459
|
+
formats it depends on are parsed in exactly one place, `tests/helpers/parse.ts`.
|
|
460
|
+
|
|
461
|
+
**`tests/unit/`** — argument parsing, exit codes, route normalization, and config discovery,
|
|
462
|
+
validation and precedence. All of it through `--dry-run`, which exits before the browser.
|
|
463
|
+
|
|
464
|
+
**`tests/visual/`** — real captures against `tests/fixtures/`, a two-origin site served on
|
|
465
|
+
ephemeral ports. Three groups:
|
|
466
|
+
|
|
467
|
+
- *Equivalence* — six pairs of pages that are visually identical but built with different CSS:
|
|
468
|
+
flex against grid, floats against flex, CSS tables against grid, transform-centring against
|
|
469
|
+
`place-items`, inline-block against flex. These assert a **zero-pixel** diff, not a
|
|
470
|
+
threshold. They are the corpus's central claim: the score tracks pixels, not markup, so a
|
|
471
|
+
refactor that changed nothing visible reads as nothing changed.
|
|
472
|
+
- *Controls* — an 8px shift, an extra section, one recoloured box, one changed margin. These
|
|
473
|
+
must score low. Without them a tool that returned 100% unconditionally would pass the
|
|
474
|
+
suite. They also pin the elements each change names.
|
|
475
|
+
- *Guard rails* — one per defence above: the blank-page refusal (and a short sign-in page
|
|
476
|
+
that must not be refused), the 18,000px tiled capture with masks in both tiles, animation
|
|
477
|
+
stilling, the shared clock start and seeded randomness, the unstable-pixel check, lazy-image
|
|
478
|
+
settling (native and script-driven, 4,800px below the fold), consent hiding, masks,
|
|
479
|
+
network blocking, `before` hooks, storage state, and naming fixed chrome over what it
|
|
480
|
+
covers. Masks
|
|
481
|
+
and blocking each run twice, with and without their config, so the mechanism is shown doing
|
|
482
|
+
something rather than a page merely happening to match.
|
|
483
|
+
|
|
484
|
+
Fixture authoring rules — integer-pixel geometry above all — are in
|
|
485
|
+
[`tests/fixtures/pages/README.md`](./tests/fixtures/pages/README.md), and
|
|
486
|
+
`tests/unit/fixtures.test.ts` enforces what can be enforced. Read them before adding a pair:
|
|
487
|
+
a fractional box is the difference between a clean comparison and sub-pixel noise.
|
|
488
|
+
|
|
489
|
+
## License
|
|
490
|
+
|
|
491
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import { type ResolvedConfig, type Route } from "./config.ts";
|
|
3
|
+
/** Loads a page, settles it, and screenshots it. */
|
|
4
|
+
export interface Shot {
|
|
5
|
+
png: Buffer;
|
|
6
|
+
/** One byte per pixel of `png`, set where the last two shots of this load disagreed. */
|
|
7
|
+
unstable: Uint8Array | null;
|
|
8
|
+
/** Why this side looks blank, or null. Only an error when both sides do. */
|
|
9
|
+
blank: string | null;
|
|
10
|
+
/**
|
|
11
|
+
* `document.body.innerText` (the element's, with a selector), whitespace collapsed per
|
|
12
|
+
* line, empty lines dropped.
|
|
13
|
+
*/
|
|
14
|
+
text: string[];
|
|
15
|
+
/** Page px of the shot's top-left: the element's for a selector route, else 0,0. */
|
|
16
|
+
origin: {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** A `before` hook threw. Never retried: the hook may have half-run against the page. */
|
|
22
|
+
export declare class HookError extends Error {
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Install network blocking and tracking, the fake clock and seeded randomness. Once per
|
|
26
|
+
* page, before any navigation.
|
|
27
|
+
*/
|
|
28
|
+
export declare function prepare(page: Page, cfg: ResolvedConfig, now: number): Promise<void>;
|
|
29
|
+
export declare function capture(page: Page, url: string, route: Route, side: "left" | "right", width: number, cfg: ResolvedConfig, now: number): Promise<Shot>;
|
|
30
|
+
/**
|
|
31
|
+
* Paint every pixel either side found unstable onto both sides, so that noise is left
|
|
32
|
+
* out of the measurement. Rewrites `left.png` / `right.png`; returns the painted pixel count.
|
|
33
|
+
*/
|
|
34
|
+
export declare function cancelUnstable(left: Shot, right: Shot): Promise<number>;
|