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..446561b 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, 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
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..6cb6e78 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 @@ -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 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 @@ + + +
+ +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.
+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.
+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.
+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.
+