diff --git a/packages/browser/src/global/stabilization/plugins/index.ts b/packages/browser/src/global/stabilization/plugins/index.ts index d78872b2..52c831ce 100644 --- a/packages/browser/src/global/stabilization/plugins/index.ts +++ b/packages/browser/src/global/stabilization/plugins/index.ts @@ -8,6 +8,7 @@ import { plugin as hideCarets } from "./hideCarets"; import { plugin as hideScrollbars } from "./hideScrollbars"; import { plugin as loadImageSrcset } from "./loadImageSrcset"; import { plugin as pauseGifs } from "./pauseGifs"; +import { plugin as pauseSvgAnimations } from "./pauseSvgAnimations"; import { plugin as roundImageSize } from "./roundImageSize"; import { plugin as stabilizeSticky } from "./stabilizeSticky"; import { plugin as waitForAriaBusy } from "./waitForAriaBusy"; @@ -24,6 +25,7 @@ export const plugins = [ hideScrollbars, loadImageSrcset, pauseGifs, + pauseSvgAnimations, roundImageSize, stabilizeSticky, waitForAriaBusy, diff --git a/packages/browser/src/global/stabilization/plugins/pauseSvgAnimations.ts b/packages/browser/src/global/stabilization/plugins/pauseSvgAnimations.ts new file mode 100644 index 00000000..8e002455 --- /dev/null +++ b/packages/browser/src/global/stabilization/plugins/pauseSvgAnimations.ts @@ -0,0 +1,59 @@ +import type { Plugin } from ".."; + +/** + * SMIL animations that never end: `repeatCount` and `repeatDur` are the two + * ways to repeat one forever. + */ +const ENDLESS_ANIMATION_SELECTOR = + '[repeatCount="indefinite"], [repeatDur="indefinite"]'; + +/** + * Get the `` elements whose clock can drive an endless animation. + * Chromium runs an animation on the clock of its nearest ``, where the + * spec and Firefox use the outermost one, so the whole chain is collected. + */ +function getSvgsWithEndlessAnimations(): Set { + const svgs = new Set(); + document.querySelectorAll(ENDLESS_ANIMATION_SELECTOR).forEach((element) => { + if (!(element instanceof SVGAnimationElement)) { + return; + } + for (let svg = element.ownerSVGElement; svg; svg = svg.ownerSVGElement) { + svgs.add(svg); + } + }); + return svgs; +} + +/** + * Pause SVG animations that repeat forever, such as a spinning loader, on their + * first frame. They are SMIL animations (``, ``, + * ``, ``) and run on their ``'s own clock, which + * neither CSS nor the browser's animation controls (Playwright's + * `animations: "disabled"`) reach, so the screenshot would otherwise catch + * whichever frame the loop had reached. + * + * Animations that end are left alone: they settle by themselves, and rewinding + * one that has finished would capture its start instead of its end. + */ +export const plugin = { + name: "pauseSvgAnimations" as const, + beforeEach() { + const restores = Array.from(getSvgsWithEndlessAnimations(), (svg) => { + const time = svg.getCurrentTime(); + const paused = svg.animationsPaused(); + svg.pauseAnimations(); + svg.setCurrentTime(0); + return () => { + svg.setCurrentTime(time); + if (!paused) { + svg.unpauseAnimations(); + } + }; + }); + + return () => { + restores.forEach((restore) => restore()); + }; + }, +} satisfies Plugin; diff --git a/packages/playwright/e2e.spec.ts b/packages/playwright/e2e.spec.ts index 4d009cd6..5225c215 100644 --- a/packages/playwright/e2e.spec.ts +++ b/packages/playwright/e2e.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type BrowserContext } from "@playwright/test"; +import { test, expect, type BrowserContext, type Page } from "@playwright/test"; import { fileURLToPath } from "node:url"; import { stat } from "node:fs/promises"; import { argosScreenshot } from "./dist/index.mjs"; @@ -454,6 +454,104 @@ test.describe("#argosScreenshot", () => { }); }); + test.describe("with `pauseSvgAnimations`", () => { + /** + * For each animated square of the fixture: whether the clock of its + * nearest `` is paused, where that clock stands, and the square's `x`. + */ + function readAnimations(page: Page) { + return page.evaluate(() => { + const read = (id: string) => { + const rect = document.getElementById(id) as unknown as SVGRectElement; + const svg = rect.ownerSVGElement!; + return { + paused: svg.animationsPaused(), + time: svg.getCurrentTime(), + x: rect.x.animVal.value, + }; + }; + return { + endless: read("endless"), + nestedEndless: read("nested-endless"), + finite: read("finite"), + }; + }); + } + + test.beforeEach(async ({ page }) => { + await page.goto(fixture("svg-animation.html")); + // Let the loops move off their first frame, and the finite one end. + await page.waitForFunction(() => { + const x = (id: string) => + (document.getElementById(id) as unknown as SVGRectElement).x.animVal + .value; + return ( + x("endless") > 0 && x("nested-endless") > 0 && x("finite") === 160 + ); + }); + }); + + test("pauses endless SVG animations on their first frame", async ({ + page, + }) => { + await argosScreenshot(page, "with-svg-animation", { fullPage: false }); + + const timeBefore = await page.evaluate(() => { + const rect = document.getElementById( + "endless", + ) as unknown as SVGRectElement; + const time = rect.ownerSVGElement!.getCurrentTime(); + (window as any).__ARGOS__.beforeEach({}); + return time; + }); + + // Both loops are held on their first frame, the nested one included. + const paused = await readAnimations(page); + expect(paused.endless).toEqual({ paused: true, time: 0, x: 0 }); + expect(paused.nestedEndless).toEqual({ paused: true, time: 0, x: 0 }); + + // Cleanup hands the clocks back where they were, running. + await page.evaluate(() => (window as any).__ARGOS__.afterEach()); + const restored = await readAnimations(page); + expect(restored.endless.paused).toBe(false); + expect(restored.nestedEndless.paused).toBe(false); + // `getCurrentTime()` returns a single-precision float, so the round trip + // can land a microsecond early. + expect(restored.endless.time).toBeGreaterThan(timeBefore - 0.001); + }); + + test("leaves SVG animations that end alone", async ({ page }) => { + await argosScreenshot(page, "with-finite-svg-animation", { + fullPage: false, + }); + + await page.evaluate(() => (window as any).__ARGOS__.beforeEach({})); + + // Still on its last value, on a clock nobody paused. + const { finite } = await readAnimations(page); + expect(finite).toMatchObject({ paused: false, x: 160 }); + + await page.evaluate(() => (window as any).__ARGOS__.afterEach()); + }); + + test("does not pause SVG animations when disabled", async ({ page }) => { + await argosScreenshot(page, "with-svg-animation-disabled", { + fullPage: false, + }); + + await page.evaluate(() => + (window as any).__ARGOS__.beforeEach({ + options: { pauseSvgAnimations: false }, + }), + ); + + const { endless } = await readAnimations(page); + expect(endless.paused).toBe(false); + + await page.evaluate(() => (window as any).__ARGOS__.afterEach()); + }); + }); + test.describe("with argosCSS", () => { test("evaluate custom CSS", async ({ page }) => { await page.goto(fixture("basic.html")); diff --git a/packages/playwright/fixtures/svg-animation.html b/packages/playwright/fixtures/svg-animation.html new file mode 100644 index 00000000..8a027d55 --- /dev/null +++ b/packages/playwright/fixtures/svg-animation.html @@ -0,0 +1,54 @@ + + + + + SVG animation page + + + +
+

SVG animation page

+ + + + + + + + + + + + + + + + + + + + + + +
+ +