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
2 changes: 1 addition & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ honestly. This is what renderdiff does to make sure of that.
- **Pins what varies between loads**, identically on both sides: the clock starts at one
instant per run (floored to the minute) on every load and then runs, `Math.random` is
seeded, timezone (UTC), locale (en-US), colour scheme (light) and
`prefers-reduced-motion` are fixed, and videos are kept from playing and, once their metadata loads, rewound to frame 0 (one that never loads shows its poster) with video.js captions and control bars hidden (`--no-freeze-video` opts out). A clock that shows
`prefers-reduced-motion` are fixed, and videos are kept from playing and, once their metadata loads, rewound to frame 0 (one that never loads shows its poster) with video.js captions and control bars hidden (`--no-freeze-video` opts out); with `lottieFrame`, every `lottie-player` is stopped on that frame once it loads (waited for up to 2s). A clock that shows
seconds can still tick between the two sides and may need a mask.
- **Kills animation, transitions and caret blink**, and hides common consent banners and
dev toolbars. Animated images (GIF, WebP, APNG) and SVG animations show their first frame;
Expand Down
2 changes: 1 addition & 1 deletion docs/scoping.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ is a route error, `before hook failed on left: …` (or `beforeShot …`), and i
A route that ran one has `"hook": true` in `results.json` and a `hook` badge on its report
card.
Actions in a hook use Playwright's 30s default timeout, so check before clicking what may
not be there (as above); `hideSelectors` and `extraCss` apply after `before` and before `beforeShot`.
not be there (as above); `hideSelectors`, `extraCss` and `lottieFrame` apply after `before` and before `beforeShot`.

The settle scrolls the whole page to load lazy content, so a state the scroll changes (a
scroll-spy nav, a container re-measured on scroll) doesn't survive a `before` hook. Set it in
Expand Down
4 changes: 4 additions & 0 deletions renderdiff.config.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export default {
// captions and control bars are hidden. false, or --no-freeze-video, lets them play.
freezeVideo: true,

// Hold every lottie-player on this frame once it loads (frame 0 is often blank). Unset,
// they keep playing.
// lottieFrame: 60,

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

Expand Down
3 changes: 2 additions & 1 deletion skills/renderdiff/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export default {
masks: [{ selector: "[data-star-count]", reason: "live star count", urlPath: "/pricing" }],
maskMedia: false, // --mask-media
freezeVideo: true, // --no-freeze-video sets false
lottieFrame: 60, // hold lottie-player on frame 60; unset leaves it playing
computed: false, // --computed
};
```
Expand Down Expand Up @@ -331,7 +332,7 @@ For scripts and drilling into one route; the loop doesn't need it. One result pe
and with `--computed` `computed` (each change once with `at: [{ url, widths }]`).
- **`meta`:** origins, widths, threshold, `clock`, block patterns, hidden selectors, mask
reasons, `browser` (`chromium`, or `chrome` after a fallback), storage state file names,
`config` hash, `rightReplayedFrom`, `freezeVideo`, `platform`, `rerun` (`{ rerun, reused }` or
`config` hash, `rightReplayedFrom`, `freezeVideo`, `lottieFrame` (only when set), `platform`, `rerun` (`{ rerun, reused }` or
null).

## Gotchas
Expand Down
23 changes: 23 additions & 0 deletions src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,28 @@ function maskRects(els: Element[]) {
});
}

/**
* Runs in the page: once each light-DOM lottie-player loads (waited for up to 2s), stop it
* and seek it to `frame`. One that never loads, or throws, is left alone.
*/
async function freezeLottie(frame: number) {
type Player = Element & {
getLottie?(): { isLoaded?: boolean } | null | undefined;
stop(): void;
seek(f: number): void;
};
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
await Promise.allSettled(
Array.from(document.querySelectorAll<Player>("lottie-player")).map(async (p) => {
for (let i = 0; i < 40 && !p.getLottie?.()?.isLoaded; i++) await tick(50);
if (!p.getLottie?.()?.isLoaded) return;
await tick(0); // lottie-web fires DOMLoaded, and with it autoplay's play(), a tick after isLoaded
p.stop();
p.seek(frame);
}),
);
}

/**
* Chromium's screenshot backing store caps at this in any dimension; a single
* `fullPage` or clip shot past it can come out truncated or wrapped. Rather than refuse, a
Expand Down Expand Up @@ -305,6 +327,7 @@ export async function capture(
if (cfg.freezeVideo) await page.evaluate(freezeVideo);
const failed = [...new Set([...network.get(page)!.failed, ...fonts])];
await page.addStyleTag({ content: cfg.neutralizeCss }); // anything injected late
if (cfg.lottieFrame !== undefined) await page.evaluate(freezeLottie, cfg.lottieFrame);
await hook("beforeShot");
step.set(page, "settle");

Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ export interface RenderdiffConfig {
maskMedia?: boolean;
/** Keep every video paused on frame 0 (its poster if it never loads) and hide video.js overlays. Default true. */
freezeVideo?: boolean;
/** Hold every lottie-player on this frame once it loads. Unset: left playing. */
lottieFrame?: number;
}

/** A fully merged config. Everything the run needs, nothing optional. */
Expand All @@ -301,6 +303,8 @@ export interface ResolvedConfig {
storageState: { left: string | null; right: string | null };
computed: boolean;
freezeVideo: boolean;
/** Optional, not null, so an unset value leaves the config hash unchanged. */
lottieFrame?: number;
}

/** package.json's version; the same relative path resolves from `src/` and `dist/`. */
Expand Down Expand Up @@ -511,6 +515,11 @@ function validate(c: Record<string, unknown>, file: string) {
if (c.maskMedia !== undefined && typeof c.maskMedia !== "boolean") bad("maskMedia", "a boolean");
if (c.freezeVideo !== undefined && typeof c.freezeVideo !== "boolean")
bad("freezeVideo", "a boolean");
if (
c.lottieFrame !== undefined &&
!(Number.isSafeInteger(c.lottieFrame) && (c.lottieFrame as number) >= 0)
)
bad("lottieFrame", "a whole number ≥ 0");
}

/** CLI-supplied values. `undefined` means "not passed", so the config file wins. */
Expand Down Expand Up @@ -584,6 +593,7 @@ export function resolveConfig(file: RenderdiffConfig, cli: CliOverrides): Resolv
},
computed: cli.computed ?? file.computed ?? false,
freezeVideo,
lottieFrame: file.lottieFrame,
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export interface RunMeta {
/** The oldest `rightReplayedFrom` among the results, or null when every right side loaded live. */
rightReplayedFrom: string | null;
freezeVideo: boolean;
lottieFrame?: number;
/** `process.platform` of the run; accepted entries and pixels are OS-specific. */
platform: string;
/** With `--rerun-failed`: shots rerun and shots reused from the previous run; else null. */
Expand Down Expand Up @@ -551,6 +552,10 @@ const neutralized = (meta: RunMeta, results: RouteResult[]) => {
scheme, reduced motion, CSS animations and transitions off, animated images and SVG animations held on their
first frame${
meta.freezeVideo ? ", videos kept from playing with video.js overlays hidden" : ""
}${
meta.lottieFrame !== undefined
? `, every lottie-player held on frame ${meta.lottieFrame}`
: ""
}.</p>
<p>Unstable pixels — ones that changed between two shots of the same load — are painted
magenta on both sides, counted on each card, and excluded from the measurement. A route
Expand Down
1 change: 1 addition & 0 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export async function run({
hideSelectors: cfg.hideSelectors,
masks: cfg.masks.map(({ urlPath, reason }) => ({ urlPath, reason })),
freezeVideo: cfg.freezeVideo,
lottieFrame: cfg.lottieFrame,
platform: process.platform,
storageState: {
left: cfg.storageState.left && path.basename(cfg.storageState.left),
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/configs/lottie.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default { lottieFrame: 60 };
7 changes: 4 additions & 3 deletions tests/fixtures/pages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Three groups:
elements each change names (`margin`, `color`, `nav`), and a lost `::before` line and a
re-parented pairing (`divider`).
- **Guard rails** — `blank`, `tall`, `animated`, `lazy`, `lazy-deep`, `lazy-js`, `lazy-grow`, `consent`,
`drifting`, `tracker`, `clock`, `ticker`, `unsettled`, `unsettled-once`, `sprite`, `short`, `tall-region`, `reorder`, `stripe`, `frameset`, `menu`, `scroll-spy`, `signed-in`, `fixed`, `inserted`, `labelled`, `broken-asset`, `media`, `long`, `video`, `video-seek`, `hang`, `origin`, `cooperate`, `mask-geometry`.
`drifting`, `tracker`, `clock`, `ticker`, `unsettled`, `unsettled-once`, `sprite`, `short`, `tall-region`, `reorder`, `stripe`, `frameset`, `menu`, `scroll-spy`, `signed-in`, `fixed`, `inserted`, `labelled`, `broken-asset`, `media`, `long`, `video`, `video-seek`, `lottie`, `hang`, `origin`, `cooperate`, `mask-geometry`.
One per defence in `src/capture.ts` / `src/config.ts`.

## Authoring rules
Expand Down Expand Up @@ -60,8 +60,9 @@ Breaking any of these produces a flaky suite, and the flake looks like a renderd
9. **No animation** except in `animated`, which exists to prove `STILL_CSS` and the
image-animation launch flag kill it, `ticker`, whose timer exists to be caught by the
stability check, `unsettled`, which recolours the whole page to trip the
unstable-pixel cap, `unsettled-once`, which does so on its first render only, and
`mask-geometry`, whose box moves once after settle.
unstable-pixel cap, `unsettled-once`, which does so on its first render only,
`mask-geometry`, whose box moves once after settle, and `lottie`, whose script-driven
player exists to be held on one frame.

## Adding a route

Expand Down
61 changes: 61 additions & 0 deletions tests/fixtures/pages/lottie/left.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lottie guard rail</title>
<link rel="stylesheet" href="/shared/base.css">
<style>
/* GUARD RAIL for lottieFrame. An inline stand-in for LottieFiles' lottie-player, mirroring its
getLottie()/stop()/seek() and lottie-web's load order: isLoaded is set at the end of a long
task, then autoplay's play() runs a tick later. Frame 0 is blank and every later frame has its own colour. It loads later
on the right than the left, so held, both show the configured frame; left alone, it plays. */
lottie-player { position: absolute; top: 0; right: 0; width: 200px; height: 200px; }
</style>
</head>
<body>
<div class="page">
<h1>Lottie guard rail</h1>
<p class="lede">An autoplaying, looping lottie-player whose first frame is blank. It finishes loading later on one side than the other, so a run that let it play, or stopped it on frame 0, would compare different or empty frames.</p>
<lottie-player autoplay loop></lottie-player>
<script>
const LOAD_MS = 50;
customElements.define("lottie-player", class extends HTMLElement {
connectedCallback() {
this.frame = 0;
setTimeout(() => {
// A long task, as building the renderer is, so a poll timer is overdue when it ends.
for (const end = performance.now() + 100; performance.now() < end; );
this._lottie = { isLoaded: true, totalFrames: 120 };
setTimeout(() => this.hasAttribute("autoplay") && this.play(), 0);
}, LOAD_MS);
}
getLottie() { return this._lottie; }
paint() {
const n = this.frame;
this.style.background = n ? `rgb(${(n * 53) % 256}, ${(n * 97) % 256}, ${(n * 29) % 256})` : "transparent";
}
play() {
clearInterval(this.timer);
this.timer = setInterval(() => { this.frame = (this.frame % 119) + 1; this.paint(); }, 16);
}
stop() {
if (!this._lottie) return;
clearInterval(this.timer);
this.frame = 0;
this.paint();
}
seek(value) {
const m = /^(\d+)(%?)$/.exec(value.toString());
if (!m) return;
this.frame = m[2] ? Math.round((+m[1] / 100) * 120) : +m[1];
this.paint();
}
});
</script>
<section class="notes">
<h2>Notes</h2>
<p>Lottie animations are driven by script, not CSS, so the stylesheet that stills CSS animation cannot reach them. Holding every player on one frame once it loads is what makes the two sides comparable.</p>
</section>
</div>
</body>
</html>
61 changes: 61 additions & 0 deletions tests/fixtures/pages/lottie/right.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lottie guard rail</title>
<link rel="stylesheet" href="/shared/base.css">
<style>
/* GUARD RAIL for lottieFrame. An inline stand-in for LottieFiles' lottie-player, mirroring its
getLottie()/stop()/seek() and lottie-web's load order: isLoaded is set at the end of a long
task, then autoplay's play() runs a tick later. Frame 0 is blank and every later frame has its own colour. It loads later
on the right than the left, so held, both show the configured frame; left alone, it plays. */
lottie-player { position: absolute; top: 0; right: 0; width: 200px; height: 200px; }
</style>
</head>
<body>
<div class="page">
<h1>Lottie guard rail</h1>
<p class="lede">An autoplaying, looping lottie-player whose first frame is blank. It finishes loading later on one side than the other, so a run that let it play, or stopped it on frame 0, would compare different or empty frames.</p>
<lottie-player autoplay loop></lottie-player>
<script>
const LOAD_MS = 1200;
customElements.define("lottie-player", class extends HTMLElement {
connectedCallback() {
this.frame = 0;
setTimeout(() => {
// A long task, as building the renderer is, so a poll timer is overdue when it ends.
for (const end = performance.now() + 100; performance.now() < end; );
this._lottie = { isLoaded: true, totalFrames: 120 };
setTimeout(() => this.hasAttribute("autoplay") && this.play(), 0);
}, LOAD_MS);
}
getLottie() { return this._lottie; }
paint() {
const n = this.frame;
this.style.background = n ? `rgb(${(n * 53) % 256}, ${(n * 97) % 256}, ${(n * 29) % 256})` : "transparent";
}
play() {
clearInterval(this.timer);
this.timer = setInterval(() => { this.frame = (this.frame % 119) + 1; this.paint(); }, 16);
}
stop() {
if (!this._lottie) return;
clearInterval(this.timer);
this.frame = 0;
this.paint();
}
seek(value) {
const m = /^(\d+)(%?)$/.exec(value.toString());
if (!m) return;
this.frame = m[2] ? Math.round((+m[1] / 100) * 120) : +m[1];
this.paint();
}
});
</script>
<section class="notes">
<h2>Notes</h2>
<p>Lottie animations are driven by script, not CSS, so the stylesheet that stills CSS animation cannot reach them. Holding every player on one frame once it loads is what makes the two sides comparable.</p>
</section>
</div>
</body>
</html>
1 change: 1 addition & 0 deletions tests/helpers/corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const GUARDRAILS = [
"long",
"video",
"video-seek",
"lottie",
"hang",
"origin",
"cooperate",
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,9 @@ describe("keys", () => {
assert.match(cfg().neutralizeCss, /\.vjs-control-bar \*\{visibility:hidden/);
assert.doesNotMatch(cfg({ freezeVideo: false }).neutralizeCss, /vjs/);
});

test("lottie players are left playing unless the config names a frame", () => {
assert.equal(cfg().lottieFrame, undefined);
assert.equal(cfg({}, { lottieFrame: 60 }).lottieFrame, 60);
});
});
2 changes: 2 additions & 0 deletions tests/unit/config-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ describe("validation", () => {
],
['{ maskMedia: "yes" }', /`maskMedia` must be a boolean/],
['{ freezeVideo: "no" }', /`freezeVideo` must be a boolean/],
["{ lottieFrame: 1.5 }", /`lottieFrame` must be a whole number/],
["{ lottieFrame: -1 }", /`lottieFrame` must be a whole number/],
["{ storageState: 1 }", /`storageState` must be a non-empty path/],
[
'{ storageState: { left: "a.json", center: "b.json" } }',
Expand Down
26 changes: 26 additions & 0 deletions tests/visual/guardrails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import {
import { PAGES_DIR, startFixtureSite, type FixtureSite } from "../helpers/server.ts";
import { EXPECT_HEIGHT } from "../fixtures/pages/tall/page.ts";

/** Inside the lottie fixture's top-right player box on the 1440 shot. */
const [LOTTIE_X, LOTTIE_Y] = [1340, 100];

const CONFIGS = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
"..",
Expand Down Expand Up @@ -572,6 +575,29 @@ describe("selector masks", () => {
assert.deepEqual([r.diffPixels, r.unstablePixels], [0, 0], context(run));
});

test("lottie-player is held on lottieFrame on both sides", async () => {
const run = await capture(["/lottie"], { config: path.join(CONFIGS, "lottie.mjs") });
assert.equal(run.code, 0, context(run));
const r = result(results(run.outDir), "/lottie", 1440);
assert.deepEqual([r.diffPixels, r.unstablePixels], [0, 0], context(run));
for (const side of ["left", "right"]) {
const px = await sharp(run.file(`lottie.1440.${side}.png`))
.extract({ left: LOTTIE_X, top: LOTTIE_Y, width: 1, height: 1 })
.removeAlpha()
.raw()
.toBuffer();
// Frame 60's colour, so the late side was seeked, not just stopped on blank frame 0.
assert.deepEqual([...px], [108, 188, 204], `${side}${context(run)}`);
}
assert.match(parseReport(run.report()!).neutralized, /lottie-player held on frame 60/);
});

test("without lottieFrame a lottie-player keeps playing", async () => {
const run = await capture(["/lottie"]);
const r = result(results(run.outDir), "/lottie", 1440);
assert.ok(r.unstablePixels > 0, context(run));
});

test("--mask-media paints iframes; freezing video hides video.js overlays", async () => {
const bare = await capture(["/media"]);
assert.ok(result(results(bare.outDir), "/media", 1440).diffPixels > 0, context(bare));
Expand Down
Loading