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: 2 additions & 0 deletions packages/browser/src/global/stabilization/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +25,7 @@ export const plugins = [
hideScrollbars,
loadImageSrcset,
pauseGifs,
pauseSvgAnimations,
roundImageSize,
stabilizeSticky,
waitForAriaBusy,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<svg>` elements whose clock can drive an endless animation.
* Chromium runs an animation on the clock of its nearest `<svg>`, where the
* spec and Firefox use the outermost one, so the whole chain is collected.
*/
function getSvgsWithEndlessAnimations(): Set<SVGSVGElement> {
const svgs = new Set<SVGSVGElement>();
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 (`<animate>`, `<animateTransform>`,
* `<animateMotion>`, `<set>`) and run on their `<svg>`'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;
100 changes: 99 additions & 1 deletion packages/playwright/e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -454,6 +454,104 @@ test.describe("#argosScreenshot", () => {
});
});

test.describe("with `pauseSvgAnimations`", () => {
/**
* For each animated square of the fixture: whether the clock of its
* nearest `<svg>` 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"));
Expand Down
54 changes: 54 additions & 0 deletions packages/playwright/fixtures/svg-animation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SVG animation page</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
</head>
<body>
<main>
<h1>SVG animation page</h1>
<!-- A square that jumps 40px to the right every 100ms, forever. -->
<svg width="200" height="40">
<rect id="endless" width="40" height="40" fill="red">
<animate
attributeName="x"
values="0;40;80;120;160"
dur="0.5s"
calcMode="discrete"
repeatCount="indefinite"
/>
</rect>
</svg>

<!-- The same loop inside a nested <svg>: Chromium runs it on the nested
<svg>'s own clock rather than the outer one's. -->
<svg width="200" height="40">
<svg>
<rect id="nested-endless" width="40" height="40" fill="blue">
<animate
attributeName="x"
values="0;40;80;120;160"
dur="0.5s"
calcMode="discrete"
repeatCount="indefinite"
/>
</rect>
</svg>
</svg>

<!-- Runs once and freezes on its last value. -->
<svg width="200" height="40">
<rect id="finite" width="40" height="40" fill="green">
<animate
attributeName="x"
from="0"
to="160"
dur="0.1s"
fill="freeze"
/>
</rect>
</svg>
</main>
</body>
</html>
Loading