From fb37d3d07a11518f1070e68cf283f7be1a1c40a8 Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:37:13 +0100 Subject: [PATCH 1/3] Hold lottie-player on a configurable frame Opt-in lottieFrame config option: once each lottie-player loads, stop it and seek it to that frame, waiting one tick past isLoaded so lottie-web's autoplay play() cannot restart it. Fixes #78 Co-Authored-By: Claude Opus 5.5 --- docs/how-it-works.md | 2 +- docs/scoping.md | 2 +- renderdiff.config.example.ts | 4 ++ skills/renderdiff/reference.md | 3 +- src/capture.ts | 23 ++++++++++ src/config.ts | 10 +++++ src/report.ts | 5 +++ src/run.ts | 1 + tests/fixtures/configs/lottie.mjs | 1 + tests/fixtures/pages/README.md | 5 ++- tests/fixtures/pages/lottie/left.html | 61 ++++++++++++++++++++++++++ tests/fixtures/pages/lottie/right.html | 61 ++++++++++++++++++++++++++ tests/helpers/corpus.ts | 1 + tests/unit/cache.test.ts | 5 +++ tests/unit/config-load.test.ts | 2 + tests/visual/guardrails.test.ts | 26 +++++++++++ 16 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/configs/lottie.mjs create mode 100644 tests/fixtures/pages/lottie/left.html create mode 100644 tests/fixtures/pages/lottie/right.html diff --git a/docs/how-it-works.md b/docs/how-it-works.md index b9678bb..7741bcc 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -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; diff --git a/docs/scoping.md b/docs/scoping.md index f3b0063..ad47167 100644 --- a/docs/scoping.md +++ b/docs/scoping.md @@ -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 diff --git a/renderdiff.config.example.ts b/renderdiff.config.example.ts index a5eb2b2..f383852 100644 --- a/renderdiff.config.example.ts +++ b/renderdiff.config.example.ts @@ -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, diff --git a/skills/renderdiff/reference.md b/skills/renderdiff/reference.md index f5e2d0f..b99f26a 100644 --- a/skills/renderdiff/reference.md +++ b/skills/renderdiff/reference.md @@ -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 }; ``` @@ -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 diff --git a/src/capture.ts b/src/capture.ts index 5b0a1f8..56beada 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -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 is left alone. + */ +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)); + return Promise.all( + Array.from(document.querySelectorAll("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 @@ -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"); diff --git a/src/config.ts b/src/config.ts index c8f6667..cbc08ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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. */ @@ -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/`. */ @@ -511,6 +515,11 @@ function validate(c: Record, 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. */ @@ -584,6 +593,7 @@ export function resolveConfig(file: RenderdiffConfig, cli: CliOverrides): Resolv }, computed: cli.computed ?? file.computed ?? false, freezeVideo, + lottieFrame: file.lottieFrame, }; } diff --git a/src/report.ts b/src/report.ts index 7e7b742..1f5c304 100644 --- a/src/report.ts +++ b/src/report.ts @@ -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. */ @@ -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}` + : "" }.

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 diff --git a/src/run.ts b/src/run.ts index 50ca74e..9b6db3a 100644 --- a/src/run.ts +++ b/src/run.ts @@ -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), diff --git a/tests/fixtures/configs/lottie.mjs b/tests/fixtures/configs/lottie.mjs new file mode 100644 index 0000000..b5dec66 --- /dev/null +++ b/tests/fixtures/configs/lottie.mjs @@ -0,0 +1 @@ +export default { lottieFrame: 60 }; diff --git a/tests/fixtures/pages/README.md b/tests/fixtures/pages/README.md index bd9edea..1b50b00 100644 --- a/tests/fixtures/pages/README.md +++ b/tests/fixtures/pages/README.md @@ -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 @@ -61,7 +61,8 @@ Breaking any of these produces a flaky suite, and the flake looks like a renderd 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. + `mask-geometry`, whose box moves once after settle, and `lottie`, whose script-driven + player exists to be held on one frame. ## Adding a route diff --git a/tests/fixtures/pages/lottie/left.html b/tests/fixtures/pages/lottie/left.html new file mode 100644 index 0000000..3c9e500 --- /dev/null +++ b/tests/fixtures/pages/lottie/left.html @@ -0,0 +1,61 @@ + + + + +Lottie guard rail + + + + +

+

Lottie guard rail

+

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.

+ + +
+

Notes

+

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.

+
+
+ + diff --git a/tests/fixtures/pages/lottie/right.html b/tests/fixtures/pages/lottie/right.html new file mode 100644 index 0000000..9e3c614 --- /dev/null +++ b/tests/fixtures/pages/lottie/right.html @@ -0,0 +1,61 @@ + + + + +Lottie guard rail + + + + +
+

Lottie guard rail

+

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.

+ + +
+

Notes

+

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.

+
+
+ + diff --git a/tests/helpers/corpus.ts b/tests/helpers/corpus.ts index 995cb39..dec5811 100644 --- a/tests/helpers/corpus.ts +++ b/tests/helpers/corpus.ts @@ -56,6 +56,7 @@ export const GUARDRAILS = [ "long", "video", "video-seek", + "lottie", "hang", "origin", "cooperate", diff --git a/tests/unit/cache.test.ts b/tests/unit/cache.test.ts index 0745972..f63d79f 100644 --- a/tests/unit/cache.test.ts +++ b/tests/unit/cache.test.ts @@ -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); + }); }); diff --git a/tests/unit/config-load.test.ts b/tests/unit/config-load.test.ts index e8f7300..ba20203 100644 --- a/tests/unit/config-load.test.ts +++ b/tests/unit/config-load.test.ts @@ -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" } }', diff --git a/tests/visual/guardrails.test.ts b/tests/visual/guardrails.test.ts index f3805cb..41b073d 100644 --- a/tests/visual/guardrails.test.ts +++ b/tests/visual/guardrails.test.ts @@ -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), "..", @@ -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)); From a2f5d7aa035ee822de75a29422645616eedc408c Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:48:10 +0100 Subject: [PATCH 2/3] Leave a lottie-player that throws alone instead of failing the shot Co-Authored-By: Claude Opus 5.5 --- src/capture.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/capture.ts b/src/capture.ts index 56beada..446561b 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -108,16 +108,16 @@ 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 is left alone. + * and seek it to `frame`. One that never loads, or throws, is left alone. */ -function freezeLottie(frame: number) { +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)); - return Promise.all( + await Promise.allSettled( Array.from(document.querySelectorAll("lottie-player")).map(async (p) => { for (let i = 0; i < 40 && !p.getLottie?.()?.isLoaded; i++) await tick(50); if (!p.getLottie?.()?.isLoaded) return; From e21497bffb6f9a8aebff0d8e77a6412c1d5a4ee4 Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:48:57 +0100 Subject: [PATCH 3/3] List lottie with mask-geometry in the fixture animation rule Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/pages/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/pages/README.md b/tests/fixtures/pages/README.md index 1b50b00..6cb6e78 100644 --- a/tests/fixtures/pages/README.md +++ b/tests/fixtures/pages/README.md @@ -60,7 +60,7 @@ 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 + 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.