Left is your change, right is the reference. + means only in or more in your change,
- only in or more in the reference.
On a terminal renderdiff prints a line per shot as it lands, then the worst scores and the
paths to report.html and results.json. Piped (to a file, CI or a coding agent) it
prints one summary at the end instead, listing only what differs:
Routes: 3
clean: /
/about clean @1152,896,704,375
@1440 97.41% h-24 band -24@y196 aligned 100.00% text -"Pricing"
dom -nav > a[href="/pricing"] "Pricing"
crops about.1440.c1.png
/pricing
@1440,1152,896,704,375 99.12%–99.87% 1 reg
dom @1440 div.plans > a.cta margin-left 24px→16px
crops pricing.1440.c1.png pricing.1152.c1.png pricing.896.c1.png pricing.704.c1.png pricing.375.c1.png
renderdiff: 2 of 3 routes differ (6 of 15 shots), 0 errors, worst 97.41% /about@1440 — /Users/you/site/renderdiff-out/results.json
A row covers the widths that agree on height and width delta, bands (side and height),
aligned score and text; the score and region count are then shown as ranges, and unstable
as the largest. Under a row, failed lists stylesheets and font faces a side failed to load
(fix those first), fonts the web font faces only one side loaded, dom names up to three
elements behind the changes, two properties each (@w when the row covers several widths: the
first one's), and crops lists that row's crop files. Errors print in full, grouped across the widths that share a message. Clean means not
one pixel differs, none went unmeasured as unstable and no stylesheet or font failed; a route
with unstable pixels or a failed asset gets its own row. A shot whose only difference varied
between two loads of the right side is clean, but listed under varies. --progress gets
the per-shot output when piped, --no-progress the summary on a terminal.
| Token | Meaning |
|---|---|
97.41% |
Share of measured pixels that match. A range (99.12%–99.87%) when one row covers several widths. A triage signal, not a verdict. |
@1440,1152 |
The widths a row covers. They agree on height and width delta, bands, aligned score and text; score and region count are ranges. |
h-24 / w+8 |
Height / width delta, left − right. The smaller image is padded with magenta, and the padding counts against the score on purpose. |
band -24@y196 |
24 rows only the reference has, at y=196 (+: only your change has them). @y~196: the row's widths put it at different y; the first width's is shown. |
aligned 100.00% |
The score with the bands cut out. 100% means the band is the whole difference. |
unaligned |
Heights differ but too few rows match to find bands. Use the height delta and regions. |
line -1px@y812 |
A 1–2px line across the page only the right draws (+: only the left; no sign: both, differently). A lost border or divider; not counted in reg. |
3 reg |
Regions: connected patches of differing pixels. A missing link barely moves the score, but it is still a region. |
unstable 1,204px |
Pixels that kept changing between shots of the same load (a ticker, a carousel). Excluded from the score, so the route is never clean. |
varies /a @1440 148,480px painted out div.drift |
After the routes: the right side differed between two fresh loads there (a counter, a rotating banner). Those elements' whole boxes are painted out on both sides like a mask and left out of the score, so the count can exceed the pixels that changed; the shot can't be accepted. (not located): no element was named, its region was painted. |
--mask 'div.drift' |
The flag that masks the varies elements, for the next run. Only selectors ending in an id, class or attribute are suggested: a bare p:nth-of-type(14) would hide real changes too. |
masks: [{ selector: … }] |
The same as config masks entries, with reason: "varies per load". |
failed left: font Inter 700 |
A stylesheet or font face that side failed to load (blocked URLs aside). Fix that first: a fallback font changes every line of text. |
fonts -"Inter 600" +"Roboto" |
@font-face or FontFace faces only one side requested (-: only the reference, +: only your change), as family, weight, style and stretch, normal left out. One still loading when read counts as requested. Web fonts only: a system font a side fell back to isn't listed. Usually the same on every route, so see findings. |
text -"Pricing" |
A line of visible text only the reference has (+: only your change). Up to three, then (+N more). |
dom sel prop a→b |
Element on both sides whose computed prop differs, left value first. |
dom +sel / -sel |
Element only on the left / only on the right. +sel::before / -sel::after: a pseudo-element only that side generates. |
dom sel x+8 |
It moved without its own style changing; the neighbour or parent listed after it is the cause. |
dom sel |
On both sides and unchanged in style or position, so its own pixels changed (an image or canvas). |
(re-parented) |
After a dom entry: paired across a structure change, so its properties may not be the cause. |
1 accepted |
That many of the shot's changes are accepted; the rest are new. |
findings: N repeated on 2+ routes |
After the routes: the same dom entry, text line, fonts face or element-less band delta (band -24px) on two or more routes, once per line with ×N routes and where (/a@1440,375, then +N routes), most widespread first, accepted ones aside. Top 20; summary.findings in results.json has them all. One cause behind many rows: fix it once. A selector names an element per page, so the same path may be a different element on another route: check one crop. |
computed: N findings |
With --computed: each key prop left→right once, with the routes and widths it was seen at; dom identical: the markup matches apart from asset hashes and generated class names, so look at CSS or script. See below. |
aria: identical on N of M route(s) |
With --aria: routes whose accessibility tree matches at every width, then each differing route's lines, + left only, - right only. See below. |
crops … |
Up to 3 PNGs per comparison, bands first, relative to results.json. Left is on top or on the left, and the change is boxed in red. |
ERROR … |
Unmeasured, which is neither a pass nor a fail. Exits 3. |
renderdiff: … |
The verdict line: routes and shots that differ (accepted ones aside), errors, the worst score, and with --threshold how many scored below it. |
accepted: / stale accepted: |
Routes whose every difference was accepted, and accepted entries the run no longer saw. See accepted.md. |
rerun: |
With --rerun-failed: how many shots were rerun and how many reused. |
right: |
With --cache-right: whether the right side was replayed, and from when, or loaded live and recorded. |
The score is the share of measured pixels that match, per route per width. In the report it's green above 99.5%, amber to 97%, red below. It is a triage signal, not a verdict: open the crops and look.
Magenta means padding. When one side is taller or wider, the shorter image is padded with magenta, and that padding counts against the score on purpose: a page 400px shorter than the one it's compared against has not reached parity. The height and width deltas name the cause.
Errors are not failures. A route that threw produced no measurement, so it is neither a
pass nor a fail. It's listed separately in the report, and it still exits non-zero (3),
because an unmeasured route is not a verified one.
--progress |
Summary | results.json |
|
|---|---|---|---|
| Height delta | (height -24px) |
h-24 |
heightDelta: -24 |
| Width delta | (not shown) | w+8 |
widthDelta: 8 |
| Band | band -24px at y=196 (right) |
band -24@y196 |
bands: [{ side: "right", y: 196, h: 24 }] |
| Can't align | rows too different to align |
unaligned |
bands: null |
| Text | text: 0 added, 1 removed |
text -"Pricing" |
text: { added: [], removed: ["Pricing"] } |
| Regions | 3 regions, largest 64×32 at (256,192) |
3 reg |
regions: [...] |
| Line | line -1px@y812 |
line -1px@y812 |
regions[i].line: { y: 812, h: 1, side: "right" } |
| Unstable | (unstable 1,204px) |
unstable 1,204px |
unstablePixels: 1204 |
| Failed assets | failed left: font Inter 700 |
failed left: font Inter 700 |
failed: { left: ["font Inter 700"], right: [] } |
| Font faces | fonts -"Inter 600" |
fonts -"Inter 600" |
fonts: { added: [], removed: ["Inter 600"] } |
Under each score, --progress output and the report card say where to look:
[3/8] 97.14% /pricing @1440 (height -24px)
band -24px at y=196 (right)
aligned 100.00%
text: 0 added, 1 removed
Regions say where pixels differ, dom says which element and property caused it, and
crops are pictures of the first three changes. Read in that order: text, dom, then crops
for anything dom doesn't explain.
Connected patches of differing pixels, on a 16px grid, largest first. On a long page a missing link barely moves the score; it is still a region. Only the area both sides cover is searched: padding is reported by the height delta and the bands. With bands, regions come from the realigned rows, so content a band only pushed down isn't one (above, the band is the whole difference, so there are none). The card lists the top five with their pixel counts; up to three crops cover the bands first, then the largest regions.
A region whose differing pixels sit in one or two rows spanning at least half the page is a
line: a divider, border or focus ring that one side lost. line -1px@y812 means only the
right (the reference) draws it, + only the left, and no sign that both do, differently.
A line barely moves the score, so 99.97% line -1px@y812 is worth opening.
Rows only one side has, found by aligning the two images row by row. -24px at y=196 (right) means the right side has 24 rows there that the left lacks, signed like the height
delta. When rows of identical content repeat, the band's y can slide along them, so treat
it as approximate. aligned is the score after cutting the bands out: how the page would
score if the inserted or removed rows weren't there. aligned 100.00% means the band is the
entire difference; it only shows when there are bands. Pages of different heights that share
too few identical rows can't be aligned (unaligned). A horizontal shift is never a band.
Alignment works on exact row matches, so it gives up when the edits total more than 2,000
rows (a single inserted or removed block aligns at any height), or when sub-threshold shade
noise touches every row.
What is under the first five bands and regions, up to three each, read from the live pages
right after the shot. Each is a CSS selector unique on its side, so --selector takes it.
-sel "text"exists only on the right,+selonly on the left.sel margin-left 24px→16pxis on both, with the computed properties that differ, left first. A computed value is a symptom: find the rule that set it.sel x+8moved without a style of its own changing, so the cause is a neighbour or its parent, which is listed after it when one of them changed.-sel::before/+sel::beforeis a pseudo-element only the right / left generates, andsel::after height 1px→2pxone on both whosecontent, size, background, top or bottom border, orbox-shadowdiffer. It carries(re-parented)when its element does.(re-parented)after an entry means its ancestors' tags differ between the sides, so the pairing crossed a structure change: its properties may not be the cause.- A bare
selis on both sides with nothing compared changed or moved, so its own pixels did (an image's source, a canvas).
An element without text is only paired when its box is the same size on both sides;
rightSelector is set when the right side was found by its text under another selector. A
band names only what one side added, not what it pushed down, so it can name nothing; what
fixed chrome covers isn't named. Probing gives up after 5s, leaving the elements out. Shadow
DOM and iframes aren't searched.
With --computed, after the shot renderdiff reads the computed style of every rendered
element on both sides, and of ::before and ::after wherever they generate content.
Masked subtrees and script/style-like tags
are skipped; a display: none element is read, its subtree isn't. Script-driven
animations are finished first.
It reads a fixed list of properties, not every one, so 0 findings means none of these
changed:
- Layout:
display,position,float,flex-direction,flex-wrap,justify-content,align-items,gap,grid-template-columns,z-index. - Box:
margin-*,padding-*,border-*-width,border-top-color,border-top-left-radius,outline,width,height. - Text:
font-family,font-size,font-weight,line-height,letter-spacing,text-align,text-transform,text-decoration-line,white-space,word-break,overflow-wrap,hyphens,text-wrap-style,color. - Paint:
background-color,background-image,box-shadow,opacity,transform,visibility,clip,clip-path,mask,content.
The summary gets one block, after the rows:
computed: 2 findings; dom identical on 2 of 3 route(s), differs /pricing (1), so findings there are hints
body > div.page > nav.chips > a[href="#blog"] margin-left 24px→16px /about@1440,375 /@1440,375
body > footer > a.cta::after content "→"→none /about@1440
unmatched /pricing@1440 -body > main > section.plans:nth-of-type(3)
- Each finding is
key property left→right, left first likedom, followed by every route and width it was seen at. The same change on 300 pages is one line, most widespread first, accepted shots included. The summary shows 20;summary.computedin results.json has them all. Each result'scomputed.changeskeeps its first 500, andtruncatedcounts the rest (the summary says where). - The key is a selector path from
body, or from:scopewith--selector, where only the selected element's subtree is walked. Each step is the id, or the tag with a naming attribute and up to two classes that don't look generated (sorted).:nth-of-typeis added only where siblings would otherwise share the step, a duplicated id included. The key works withquerySelector.- Elements are matched by key, not by position, so a cookie banner injected on one side doesn't misalign everything after it.
- An inserted sibling of the same tag and classes still shifts the
:nth-of-typeof the ones after it. - A class added to, removed from or renamed on a wrapper re-keys its whole subtree. A
style change under it then shows only as
dom differsandunmatched, not as a finding.
- One change high in the tree is one finding. An inherited property (
font-*,line-height,letter-spacing,text-*,white-space,word-break,overflow-wrap,hyphens,color,visibility) is dropped where the parent has the same change, so abodyfont change is one line, not one per element. Awidthis dropped inside a parent whose width changed, and aheightaround a child whose height changed, so the box that caused it is what's left. A box whose own width or height is set in px on either side is kept (so a child widened inside aninline-blockis, at the cost of the odd fixed-size child a parent squeezed), and aheight: 100%child is dropped instead of the px-height parent it follows. - An element shown on one side and
display: noneon the other is onedisplayfinding; its subtree and its::before/::aftercount neither as findings, unmatched nor againstdom identical. - An
automargin reads asauto, not the px Chrome resolved it to, since that number changes with the layout (a grid item's reads0px). Centring swapped for a fixed margin ismargin-left auto→60px. - A
::before/::afterthat only one side generates shows ascontentagainstnone. That is how a removed rule that drew an icon shows up. dom identicalmeans that on every width, each walked element's tag, attributes and own text are the same on both sides.- Bundler hashes in
src/href/srcsetare ignored (hero.3f9a8c1d.png,?v=…), and so are generated class names (css-1q2w3e,Button_root__a1b2c). - Absolute URLs on the left origin are read as the right origin's (keys, attributes,
url()values), so two ports or a local build against production don't differ by origin alone. - A pixel difference on such a route comes from CSS or script, not from walked markup.
SVG internals, shadow DOM, iframes and
display: nonesubtrees aren't walked. - Hash-shaped tokens only: 8 or more letters and digits that alternate, between
separators in a file name.
team_photo_2024.jpgand/p/abcdef123456count as changes.
- Bundler hashes in
dom differs (N)counts the elements that differ or that one side lacks. Treat that route's findings as hints. A re-keyed element counts once on each side, so one renamed wrapper with three descendants isdom differs (8).unmatchedlists the outermost elements only one side has:+left,-right. The summary shows five routes, five each.- The HTML report shows the same block, collapsed, and puts
dom identical/dom differs (N)on each card. A snapshot that fails, takes over 15s or meets more than 50,000 elements is reported as failed, with why (computed: null,computedError), and the pixel result stands. Pixels still decide the score and the exit code.
Links and controls (a[href], button, input, select, textarea, summary,
[tabindex]) are also read in :hover and :focus-visible, with their subtrees:
- The key gets the state:
body > nav > a[href="#x"]:hover > span.icon.:focus-visibleis keyboard focus, forced as:focusplus:focus-visible. - Only what differs from resting is a finding: a resting change isn't repeated per state.
- The first 500 per page are read;
states:in the summary says where there were more (computed.uncheckedin results.json). One with more than 50 descendants is skipped. - All of them are forced at once, so a rule relating two of them
(
ul:has(a:hover) a:not(:hover)) isn't seen, nor:hoveron an ancestor (li:hovermenus),:active,:focus-withinor script hover handlers.
With --aria, after the shot (and --computed) renderdiff takes Playwright's ARIA snapshot
of each side, the accessibility tree a screen reader gets, of body or the --selector
element, and diffs it line by line:
aria: identical on 2 of 3 route(s), differs /pricing
/pricing@1440,375
+- text: Buy now
-- button "Buy now"
+lines only the left (your change) has,-only the right. Above, adivstyled like the button it replaced: the same pixels, and the button role lost.- The same diff at several widths is one group; a mobile-only nav change gets its own.
Ten lines per group, 20 groups; each result keeps up to 200 lines per side, and
truncatedcounts the rest. - Masked elements are left out (marked
aria-hiddenonce the shot is taken), and so are bundler hashes in/url:values; the left origin reads as the right's, as with--computed. - Lines keep their indentation, so a landmark added or removed around content (
mainswapped for adiv) re-indents, and lists, every line under it. - Iframes aren't descended into. A snapshot that fails or takes over 15s is reported as
failed, with why (
aria: null,ariaError), and the pixel result stands. Pixels still decide the score and the exit code.
A line diff of each side's visible text (document.body.innerText, or the element's with a
selector). + / text.added: lines only your change (left) has. - / text.removed:
lines only the reference (right) has. The card lists the first 20. Masked elements are left
out. Unmasked text that changes per load (a ticker, a clock) shows up here whenever the two
sides differed at capture time, even when its pixels were painted out as unstable.
Pixels that changed between two shots of the same page load, so they're noise from that
origin (a ticker, a carousel). They're painted magenta on both sides and excluded from the
score, and a route with any is never on the clean: line. More than 25% unstable is an
error, page did not settle, after one retry, with the two frames saved as
{slug}.{width}.{side}.unsettled-a.png/-b.png.
{slug}.{width}.c{n}.png: up to three per comparison, bands (rows only one side has) first,
tallest first, then the largest regions. Each shows left and right at native scale, padded by
48px and at least 320px wide, with a 1px red box around the change; on the side that lacks a
band's rows, a 2px red seam is inserted where they would be, hiding nothing. Left is always on
top (stacked, for wide changes) or on the left (side, for tall ones). A crop stays within
1,568px on its long edge and 1,568 image tokens (⌈w/28⌉·⌈h/28⌉) and is never scaled below
0.75×; a change too big for that keeps its top rows (or left columns) and is marked
clipped. Height padding is never cropped.
Written on every run, for browsing it; it loads the PNGs beside it. A sidebar lists every
route, filterable by substring, with a link per width to that shot's card
(#{slug}.{width}; an error links to the errors list). A width is green when the shot is
identical or accepted, amber when it differs but doesn't fail (at or above --threshold),
red when it fails or errored; a route takes its worst width's colour. Without a threshold
any difference fails, so there is no amber. The summary on top counts routes, shots,
differing shots, errors, accepted and stale accepted; gives the mean and the worst score,
linked to its card; and lists only the differing shots, by route. Findings repeated on two
or more routes, and with --computed the style findings, sit collapsed under it. Then a
card per shot, lowest score first, showing left, right and diff side by side; the sidebar's
Slider view toggle swaps those for both shots stacked, tops aligned, with a divider you
drag anywhere on the shot to reveal left on one side and right on the other.
renderdiff report <out-dir> [--verdict verdict.md] [--title "..."] reads results.json,
rewrites report.html from it, and writes evidence.html beside it: one file with no
outside references, so it can be attached to a ticket or published on its own. It opens with
a banner: Parity (every shot clean or accepted, no errors), Differs (a shot that
would exit 1), or Not verified (errors, unstable pixels, failed assets, diffs under
--threshold, a --rerun-failed run that reused shots, or a merged run). Then the renderdiff: line,
the verdict file as plain text, coverage (clean and accepted routes, errors, stale accepts),
and a card per shot that isn't clean, then one per accepted shot, with crops embedded as
WebP. Crops past 12MB, or missing from the out dir, are left out and the banner says so.
Build it after a fresh full run.
renderdiff report <out-dir> --markdown <file> also writes a Markdown file for documenting an
intended change: a ## <route> section per route with the before (right) and after (left)
URLs, and a | Width | Before | After | table linking each width's full-page shots, with
(h±Npx) when the heights differ and the error in place of an errored shot. Links are
relative to the Markdown file, or <url>/<shot> with --base-url <url>: upload the out dir
anywhere, then pass its URL so the images render in a PR description. evidence.html is
self-contained and can go anywhere on its own.
Never upload <out>/.cache/: it holds --cache-right's recorded traffic, cookies and auth
headers included. Cloudflare Pages gives report.html a per-PR URL:
npx wrangler pages project create <p> --production-branch main # once
rm -rf renderdiff-out/.cache # wrangler has no exclude; the next --cache-right run loads live
npx wrangler pages deploy renderdiff-out --project-name <p> --branch pr-123
renderdiff report renderdiff-out --markdown pr.md --base-url https://pr-123.<p>.pages.devIn CI, wrangler needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. Keep branch names
simple like pr-123: the alias is lowercased, sanitized and truncated. Cloudflare Access can
keep report.html private, but then the images won't render in the PR: GitHub fetches
images through its anonymous camo proxy. For private inline images, use a reports repo:
git clone --depth 1 https://github.com/<owner>/<repo> reports
mkdir -p reports/pr-123 && cp -R renderdiff-out/. reports/pr-123/
rm -rf reports/pr-123/.cache
git -C reports add . && git -C reports commit -m pr-123 && git -C reports push
renderdiff report renderdiff-out --markdown pr.md \
--base-url https://github.com/<owner>/<repo>/raw/main/pr-123Those links render for viewers signed in with read access to the reports repo. Don't copy
raw.githubusercontent.com/...?token= URLs instead: the token expires. Use a separate repo,
not a branch of the main one: every clone of the main repo would download the screenshots, and
its history keeps them after the branch is deleted.
renderdiff merge <full-out> <partial-out> --out <dir> writes a new out dir: the full run's
results, each shot the partial run also took replaced by the partial run's (files included),
and the partial run's other shots added after them. No browser. Each shot taken from the
partial run gets from, that run's generatedAt. meta is the full run's, with widths,
masks, hideSelectors and blockUrlPatterns the union of both, and rightReplayedFrom
recomputed; the summary is recomputed against the full run's threshold. Each shot keeps the
accepted flag its own run gave it: renderdiff.accepted.json is not reread. The shots
were taken at different times, so evidence.html says Not verified; chain
renderdiff report <dir> --verdict … for one. Exits 2 when the two runs compare different
origins, --out is or is inside an input or holds files renderdiff didn't write, a referenced file is missing, or two shots would
write the same file (run both into one --out instead). A merged out dir can't be continued
with --rerun-failed: rerun into a new --out and merge again.
Written on every run, errors included, one result per line (so grep '"url":"/about"'
pulls a route; the lines sit inside one JSON document and end in commas, so parse the whole
file, not each line), scores rounded down to four decimals (so a shot that differs never reads as
1). Paths are relative to the out dir; results are in route-then-width order. Its types
(Results and friends) are exported from the package.
{ "meta": { "tool": "renderdiff", "version": "0.14.0", "generatedAt": "…", "left": "http://localhost:3000", "right": "https://example.com", "widths": [1440, 375], "threshold": null, "clock": "…", // the instant both sides' clocks start at "blockUrlPatterns": ["…"], "hideSelectors": ["…"], "masks": [{ "urlPath": "/pricing", "reason": "live star count" }], // no urlPath: every route "browser": { "name": "chromium", "version": "…" }, // "chrome" when it fell back "storageState": { "left": "auth.json", "right": null }, // file names only "config": "…", // hash of what decides a shot; --rerun-failed needs it unchanged "rightReplayedFrom": null, // --cache-right: when the replayed recording was made "freezeVideo": true, "platform": "darwin", // process.platform: pixels and accepts are OS-specific "rerun": null // --rerun-failed: { "rerun": 3, "reused": 12 } }, "results": [ { "url": "/about", "left": "/about", "right": "/about", // a pair: "/a::/b", "/a", "/b" "from": "…", // renderdiff merge: generatedAt of the run this shot came from; absent otherwise "selector": null, "hook": false, // hook: a before or beforeShot hook ran "width": 1440, "status": "compared", "score": 0.9714, // share of measured pixels that match "alignedScore": 1, // score over the aligned rows; null unless there are bands "diffPixels": 33120, "totalPixels": 1158000, "unstablePixels": 0, "varies": [], "variesPixels": 0, // absent unless a second right load differed: its regions, with elements, and the px painted out "heightDelta": -24, "widthDelta": 0, // left − right "regions": [], // left-page px; with bands, from the realigned rows "bands": [{ "side": "right", "y": 196, "h": 24, // null: too different to align "elements": [{ // regions have them too; absent: not looked at "selector": "nav > a[href=\"/pricing\"]", "side": "right", // or "left", "both" "left": null, "right": { "x": 256, "y": 192, "w": 928, "h": 24 }, "changed": {}, // "margin-left": ["24px", "16px"], left first; "text" too "text": "Pricing" // "rightSelector" when the right one differs }] }], "text": { "added": [], "removed": ["Pricing"] }, // added: left only; removed: right only "accepted": false, // every band and region matched renderdiff.accepted.json "failed": { "left": [], "right": [] }, // stylesheets and font faces that didn't load: "font Inter 700", "stylesheet https://…" "fonts": { "added": [], "removed": ["Inter 600"] }, // web font faces one side loaded; absent when equal "masked": ["rotating quote (data-renderdiff-mask)"], // absent unless a side marked elements data-renderdiff-mask "computed": { "dom": 1, "changes": [], "truncated": 0, "unmatched": { "left": [], "right": ["body > nav > a[href=\"/pricing\"]"] } }, // --computed only; null: snapshot failed, "computedError" says why "aria": { "added": ["- text: Buy now"], "removed": ["- button \"Buy now\""], "truncated": 0 }, // --aria only; both empty: identical; null: snapshot failed, "ariaError" says why "images": { "left": "about.1440.left.png", "right": "about.1440.right.png", "diff": "about.1440.diff.png" }, "crops": [{ "path": "about.1440.c1.png", "kind": "band", // or "region" "left": { "x": 256, "y": 196, "w": 68, "h": 0 }, // h 0: where the rows would go "right": { "x": 256, "y": 196, "w": 68, "h": 24 }, "layout": "stacked", "scale": 1, "clipped": false // left on top, or on the left ("side") }] }, { "url": "/blank", "left": "/blank", "right": "/blank", "width": 1440, "status": "error", "error": "page did not render …" } ], "summary": { "compared": 1, "errors": 1, "mean": 0.9714, "worst": { "url": "/about", "width": 1440, "score": 0.9714 }, "belowThreshold": 0, // accepted shots aside "differing": 1, // shots with any differing pixel, accepted ones aside "accepted": 0, // shots whose every difference was accepted "stale": [], // accepted entries the run measured but didn't see "findings": [], // { finding, selector?, at: [{ url, widths }] }, seen on 2+ routes "computed": [] // --computed: { key, prop, values: [left, right], at: [{ url, widths }] } } }