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
15 changes: 15 additions & 0 deletions .github/workflows/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ jobs:
out: accept-out
artifact-name: accept

- id: webkit
continue-on-error: true
uses: ./
with:
left: ${{ steps.site.outputs.left }}
right: ${{ steps.site.outputs.right }}
routes: /box-decoration-break
widths: "1440"
browser: webkit
out: webkit-out
artifact-name: webkit

- id: down
continue-on-error: true
uses: ./
Expand All @@ -71,12 +83,15 @@ jobs:
DOWN: ${{ steps.down.outputs.exit-code }}
CLEAN: ${{ steps.clean.outputs.exit-code }}
DIFFER: ${{ steps.differ.outputs.exit-code }}
WEBKIT: ${{ steps.webkit.outputs.exit-code }}
OUTCOME: ${{ steps.differ.outcome }}
run: |
test "$CLEAN" = 0
test "$DIFFER" = 1
test "$OUTCOME" = failure
test "$DOWN" = 2
test "$WEBKIT" = 1
node -e 'const { meta } = require("./webkit-out/results.json"); if (meta.browser.name !== "webkit") throw meta.browser'
node -e 'const r = require("./differ-out/results.json").results; for (const p of ["/color", "/tint"]) if (!(r.find((x) => x.url === p)?.score < 0.999)) throw p'
grep -q '"/color"' accept-out/renderdiff.accepted.json
grep -q '<!-- renderdiff:differ -->' "$RUNNER_TEMP/renderdiff-comment-differ.md"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npx playwright install --with-deps chromium
- run: npx playwright install --with-deps chromium webkit
- run: npm run typecheck
- run: npm run test:all
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ the agent needs a cheap, trustworthy loop: change → `renderdiff` → explain e
**Don't use it for:**

- intentional redesigns: when everything is meant to change, the score says nothing
- cross-browser testing (Chromium only) or stored-baseline snapshot tests (both origins must be live)
- cross-browser testing (each run shoots both sides in one engine, Chromium or WebKit, never one against the other) or stored-baseline snapshot tests (both origins must be live)
- discovering routes: you list them, or `--sitemap` reads the reference's sitemap; there is no crawler
- component libraries in isolation; Storybook-based tools fit that better

Expand Down Expand Up @@ -88,7 +88,8 @@ production on every pull request, with the result in the job summary:
Node >= 22.18 and Chromium (`npx playwright install chromium`). `npm install -g renderdiff`
installs it globally. If the bundled Chromium won't launch, renderdiff falls back to an
installed Google Chrome on both sides and says so; `meta.browser` in `results.json` records
which ran.
which ran. `--browser webkit` needs WebKit too (`npx playwright install webkit`, with
`--with-deps` on Linux).

## Usage

Expand All @@ -107,6 +108,7 @@ renderdiff <left-origin> <right-origin> <route…> [options]
| `--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. |
| `--browser <name>` | `chromium` (default) or `webkit`: both sides are shot in it, for CSS that only Safari reads differently, such as a dropped `-webkit-` prefix Chromium no longer needs. WebKit writes to `./renderdiff-out-webkit` unless `--out` or `outDir` say otherwise and accepts into `renderdiff.accepted.webkit.json`, so a Chromium and a WebKit run keep separate results. On WebKit, animated GIFs keep playing (usually caught as unstable and left unmeasured; mask one if it flips), a load still open after 5s is stopped and recorded as `stopped`, and `--computed` doesn't force `:hover`/`:focus-visible`. In the GitHub Action its `browser` input decides, not the config's. |
| `--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. |
Expand Down
16 changes: 11 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ inputs:
routes:
description: Routes separated by spaces or newlines, e.g. "/ /about /pricing::/plans". Split on spaces, so a per-route selector ('/route @sel') doesn't work here; put it in the config. Optional when the config lists them.
default: ""
browser:
description: chromium or webkit; both sides are shot in it. For both, use two steps with their own out and artifact-name.
default: chromium
config:
description: Path to a renderdiff config file. Default renderdiff.config.{ts,mts,js,mjs} in the workspace.
default: ""
Expand All @@ -31,7 +34,7 @@ inputs:
description: Contents of a Playwright storage-state JSON for both sides, from a secret. Written to a 0600 temp file, deleted after the run.
default: ""
accept:
description: Record this run's differences as accepted, with this reason. Commit the renderdiff.accepted.json from the artifact.
description: Record this run's differences as accepted, with this reason. Commit the renderdiff.accepted.json (renderdiff.accepted.webkit.json with browser webkit) from the artifact.
default: ""
wait-timeout:
description: Whole seconds to wait for left to answer (any status under 500) before running. Past it, exit-code is 2.
Expand Down Expand Up @@ -70,15 +73,17 @@ runs:
- uses: actions/cache@v4
with:
path: ${{ runner.tool_cache }}/renderdiff-ms-playwright
key: renderdiff-chromium-${{ runner.os }}-${{ runner.arch }}-${{ steps.pw.outputs.version }}
key: renderdiff-${{ inputs.browser }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.pw.outputs.version }}

- shell: bash
working-directory: ${{ github.action_path }}
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ runner.tool_cache }}/renderdiff-ms-playwright
BROWSER: ${{ inputs.browser }}
run: |
npm ci --omit=dev --ignore-scripts --no-audit --no-fund
npx playwright install --with-deps chromium
case "$BROWSER" in chromium|webkit) ;; *) echo "browser must be chromium or webkit, got '$BROWSER'"; exit 2 ;; esac
npx playwright install --with-deps "$BROWSER"

- id: run
shell: bash
Expand All @@ -89,6 +94,7 @@ runs:
RIGHT: ${{ inputs.right }}
ROUTES: ${{ inputs.routes }}
CONFIG: ${{ inputs.config }}
BROWSER: ${{ inputs.browser }}
WIDTHS: ${{ inputs.widths }}
THRESHOLD: ${{ inputs.threshold }}
CONCURRENCY: ${{ inputs.concurrency }}
Expand Down Expand Up @@ -119,7 +125,7 @@ runs:
fi

if [ "$code" = 0 ]; then
args=("--left=$LEFT" "--right=$RIGHT" "--out=$OUT" --no-progress)
args=("--left=$LEFT" "--right=$RIGHT" "--out=$OUT" "--browser=$BROWSER" --no-progress)
read -ra routes <<< "$(echo "$ROUTES" | tr '\n' ' ')"
args+=("${routes[@]}")
for w in ${WIDTHS//,/ }; do args+=("--width=$w"); done
Expand All @@ -139,7 +145,7 @@ runs:
set -e
rm -f "${state:-}"

accepted="$(dirname "${CONFIG:-./_}")/renderdiff.accepted.json"
accepted="$(dirname "${CONFIG:-./_}")/renderdiff.accepted$([ "$BROWSER" = chromium ] || echo ".$BROWSER").json"
if [ -n "$ACCEPT" ] && [ -f "$accepted" ] && [ -d "$OUT" ]; then cp "$accepted" "$OUT/"; fi
fi
echo "exit-code=$code" >> "$GITHUB_OUTPUT"
Expand Down
2 changes: 2 additions & 0 deletions docs/accepted.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ It records every unaccepted change in that scope, not only the ones you meant, s
unintended ones before accepting, or check `git diff renderdiff.accepted.json` afterwards and
delete the lines you didn't mean.
Commit the file; it is one entry per line, so its diff reads as what was accepted.
A `--browser webkit` run reads and writes `renderdiff.accepted.webkit.json` instead: an
entry's pixel hash only ever matches the engine it was taken in, so each engine keeps its own.

A shot whose every difference matches an accepted one is `accepted`: it is listed on an
`accepted:` line like `clean:`, doesn't count as differing or toward the exit code, and has
Expand Down
1 change: 1 addition & 0 deletions docs/github-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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; per-route selectors go in the config, since the input splits on spaces. Optional when the config lists routes. |
| `browser` | `chromium` (default) or `webkit`. It decides: the config's `browser` key is ignored in the Action. Both sides are shot in it, and `accept` records into `renderdiff.accepted.webkit.json` for WebKit. For both engines, run the action twice. |
| `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
13 changes: 12 additions & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,18 @@ honestly. This is what renderdiff does to make sure of that.
- **Kills animation, transitions, caret blink and smooth scrolling** (CSS `scroll-behavior` and
`behavior: "smooth"` in script scrolls, from before any hook), and hides common consent banners and
dev toolbars. Animated images (GIF, WebP, APNG) and SVG animations show their first frame;
it is a Blink setting, so cross-origin and CSS background images freeze too.
it is a Blink setting, so cross-origin and CSS background images freeze too. WebKit
(`--browser webkit`) has no such setting: there an animated image keeps playing, which is
usually caught as unstable (painted out of the score, never accepted). One with slow
frames can hold still for both shots and land on a different frame per side: mask it if
it flips.
- **On WebKit, stops a load that never ends**, as a last resort. WebKit's `fonts.ready`,
which its screenshot waits on, holds until the load event, so a frame that never loads
would hang the shot. When a shot takes over 5s and the load event hasn't fired, renderdiff
calls `window.stop()`, which lets it finish. That fails the page's open requests (a pending
`fetch` rejects, images break), so like a failed asset the shot is never clean or reused
and doesn't change the exit code: `stopped loading on right` under the row and in
report.html, `stopped` in results.json, and evidence.html says Not verified.
- **Settles the page** — makes native lazy images eager, scrolls every viewport-height
slice a frame at a time, to the bottom even as the page grows, so script lazy loaders fire, waits for every image to decode,
`document.fonts.ready` (at most 5s) and up to 3s for the network to go 250ms with nothing in flight
Expand Down
3 changes: 2 additions & 1 deletion docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ file, not each line), scores rounded down to four decimals (so a shot that diffe
"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
"browser": { "name": "chromium", "version": "…" }, // "chrome" when it fell back; "webkit" with --browser webkit
"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
Expand Down Expand Up @@ -559,6 +559,7 @@ file, not each line), scores rounded down to four decimals (so a shot that diffe
"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
"stopped": ["right"], // WebKit only, absent unless a load still open after 5s was stopped for the shot
"computed": { "dom": 1, "changes": [], "truncated": 0, "unmatched": { "left": [], "right": ["body > nav > a[href=\"/pricing\"]"] },
"rules": [{ "side": "left", "sheet": "docs.css", "conditions": ["@media (width >= 60em)"], "rules": 5, "elements": 67 }],
"prefixes": { "left": ["-webkit-user-select: none"], "right": [] } }, // --computed only; null: snapshot failed, "computedError" says why; rules: one-sided rule groups, elements they could style here; prefixes: { left, right }, prefixed declarations only that side's CSS source has
Expand Down
4 changes: 4 additions & 0 deletions renderdiff.config.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export default {
// they keep playing.
// lottieFrame: 60,

// Shoot both sides in WebKit instead of Chromium, for CSS only Safari reads differently.
// --browser overrides it; in the GitHub Action the browser input decides and this is ignored.
// browser: "webkit",

// Mask every iframe on every route (embeds that differ per load). Same as --mask-media.
maskMedia: false,

Expand Down
7 changes: 6 additions & 1 deletion skills/renderdiff/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,14 +463,19 @@ commas: `JSON.parse` the whole file, not each line. Paths are relative to the ou
(each rule group once with `matched` and `unmatched` `[{ url, widths }]`) and `prefixes`
(each declaration once: `side`, `declaration`, `at`).
- **`meta`:** origins, widths, threshold, `clock`, block patterns, hidden selectors, mask
reasons, `browser` (`chromium`, or `chrome` after a fallback), storage state file names,
reasons, `browser` (`chromium`, `chrome` after a fallback, or `webkit`), storage state file names,
`config` hash, `rightReplayedFrom`, `freezeVideo`, `lottieFrame` (only when set), `platform`, `rerun` (`{ rerun, reused }` or
null), with `--computed` `unreadableSheets` `{ left, right }`.

## Gotchas

- **Browser:** if the bundled Chromium won't launch, the system Chrome is used on both sides
and the run says so; otherwise it prints the `npx playwright install chromium` hint.
`--browser webkit` shoots both sides in WebKit (`npx playwright install webkit`) into
`renderdiff-out-webkit`, accepting into `renderdiff.accepted.webkit.json`: for CSS only
Safari reads differently. There animated GIFs aren't frozen (usually caught as unstable;
mask one if it flips), a load still open after 5s is stopped (`stopped loading on <side>`), and
`--computed` forces no `:hover`/`:focus-visible`. `merge` refuses to mix engines.
- **Pinned on both sides:** clock (one instant per run, floored to the minute), seeded
`Math.random`, UTC, en-US, light scheme, reduced motion; animations and transitions stilled,
animated images and SVG animations held on their first frame.
Expand Down
2 changes: 0 additions & 2 deletions src/accept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { name } from "./config.ts";
import { fmtElements } from "./format.ts";
import type { ComparedResult, RouteResult } from "./report.ts";

export const ACCEPTED_FILE = "renderdiff.accepted.json";

/**
* One band or region, without its `y`: a change above it moves it down the page without
* changing it. `hash` and `elements` tell a new change in the same spot from the accepted one.
Expand Down
Loading
Loading