import { expect, test } from "@playwright/test"; import { readFileSync } from "fs"; import { join } from "path"; /** * Animation capture tests - loads the built clarity.min.js in a browser and verifies * that Clarity's Web Animations instrumentation skips pseudo-element animations. * * Regression test for issue #1112: calling getKeyframes()/getTiming() on a * pseudo-element-targeted effect (e.g. ::view-transition-*) crashes Firefox's content * process during View Transitions. Clarity must not read keyframes/timing for any * animation whose effect targets a pseudo-element. */ // Use the minified browser build which exposes window.clarity. const clarityJsPath = join(__dirname, "../build/clarity.min.js"); interface CaptureResult { // pseudoElement values seen by getKeyframes()/getTiming() while Clarity is running. getKeyframesPseudos: string[]; getTimingPseudos: string[]; // Whether the browser actually applied the pseudoElement to the effect (sanity check). pseudoElementSet: string | null; // Confirms a normal (non-pseudo) animation is still captured. normalCaptured: boolean; } test.describe("animation - pseudo-element capture", () => { test.beforeEach(async ({ page }) => { await page.goto("data:text/html,
hi
"); const clarityJs = readFileSync(clarityJsPath, "utf-8"); await page.evaluate((code) => { eval(code); }, clarityJs); }); test("skips pseudo-element animations but still captures normal animations", async ({ page }) => { const result: CaptureResult = await page.evaluate(() => { return new Promise((resolve) => { (window as any).clarity("start", { projectId: "test", track: false, upload: false }); // Let Clarity discover the DOM, then spy on the crash-triggering calls and // exercise both a pseudo-element animation and a normal animation. setTimeout(() => { const getKeyframesPseudos: string[] = []; const getTimingPseudos: string[] = []; const KE: any = (window as any).KeyframeEffect; const origGetKeyframes = KE.prototype.getKeyframes; const origGetTiming = KE.prototype.getTiming; KE.prototype.getKeyframes = function (): Keyframe[] { getKeyframesPseudos.push(this.pseudoElement || "(none)"); return origGetKeyframes.apply(this, arguments); }; KE.prototype.getTiming = function (): EffectTiming { getTimingPseudos.push(this.pseudoElement || "(none)"); return origGetTiming.apply(this, arguments); }; const frames: Keyframe[] = [{ opacity: 0 }, { opacity: 1 }]; const timing = { duration: 200 }; // Pseudo-element animation (::before) - stands in for ::view-transition-*. const effect = new KE(document.body, frames, { ...timing, pseudoElement: "::before" }); const pseudoElementSet: string | null = effect.pseudoElement; const pseudoAnim = new Animation(effect, document.timeline); pseudoAnim.play(); pseudoAnim.finish(); // Normal element animation - must still be captured. const normalAnim = (document.getElementById("box") as HTMLElement).animate(frames, timing); normalAnim.finish(); setTimeout(() => { resolve({ getKeyframesPseudos, getTimingPseudos, pseudoElementSet, normalCaptured: getKeyframesPseudos.indexOf("(none)") !== -1, }); }, 100); }, 300); }); }); // Sanity check: the browser supports animating a pseudo-element via WAAPI. test.skip(result.pseudoElementSet !== "::before", "KeyframeEffect pseudoElement option not supported in this browser"); expect(result.pseudoElementSet).toBe("::before"); // Clarity must never read keyframes/timing for a pseudo-element effect. const pseudoKeyframeCalls = result.getKeyframesPseudos.filter((p) => p !== "(none)"); const pseudoTimingCalls = result.getTimingPseudos.filter((p) => p !== "(none)"); expect(pseudoKeyframeCalls).toEqual([]); expect(pseudoTimingCalls).toEqual([]); // Normal (non-pseudo) animations are still captured. expect(result.normalCaptured).toBe(true); }); });