diff --git a/packages/browser/src/global/stabilization/index.ts b/packages/browser/src/global/stabilization/index.ts index 9253f1c0..6e6f2233 100644 --- a/packages/browser/src/global/stabilization/index.ts +++ b/packages/browser/src/global/stabilization/index.ts @@ -8,12 +8,6 @@ export interface Plugin { * Name of the plugin. */ name: string; - /** - * When `true`, the plugin is disabled unless explicitly enabled through the - * options (e.g. `{ [name]: true }`). Use it for plugins that are too - * expensive to run by default. - */ - optIn?: boolean; /** * Run before taking all screenshots. */ @@ -67,7 +61,7 @@ export interface WaitForBackgroundImagesOptions { /** * CSS selector scoping which elements are scanned for background images. * Scoping avoids a full-document `getComputedStyle` sweep on large pages. - * @default "*" + * @default "[data-visual-test-wait-bg-img], [data-visual-test-wait-bg-img] *" */ selector?: string; } @@ -77,9 +71,11 @@ export type PluginOptions = { } & { /** * Wait for CSS background images to be loaded. - * Disabled by default. Enable with `true` to scan the whole document, or - * pass an object to scope it to a selector. - * @default false + * Enabled by default, scoped to elements flagged with the + * `data-visual-test-wait-bg-img` attribute (and their descendants). Pass + * `true` to scan the whole document, an object to target a custom selector, + * or `false` to disable it entirely. + * @default true */ waitForBackgroundImages?: boolean | WaitForBackgroundImagesOptions; }; @@ -110,10 +106,6 @@ function getPlugins(context: Context): Plugin[] { return false; } const pluginOptions = getPluginOptions(context, plugin.name); - if ((plugin as Plugin).optIn) { - // Opt-in plugins stay disabled unless explicitly enabled. - return Boolean(pluginOptions); - } if (pluginOptions === false) { return false; } diff --git a/packages/browser/src/global/stabilization/plugins/pauseGifs.ts b/packages/browser/src/global/stabilization/plugins/pauseGifs.ts index 478b139d..a4d260c9 100644 --- a/packages/browser/src/global/stabilization/plugins/pauseGifs.ts +++ b/packages/browser/src/global/stabilization/plugins/pauseGifs.ts @@ -2,6 +2,12 @@ import type { Plugin } from ".."; const BACKUP_ATTRIBUTE = "data-argos-gif-src"; +/** + * Attribute authors can set to flag an image as a GIF explicitly, e.g. + * ``. + */ +const IMAGE_TYPE_ATTRIBUTE = "data-image-type"; + /** * GIFs currently being frozen for this screenshot cycle. * Freezing is asynchronous (we load a fresh copy to grab its first frame), so @@ -13,9 +19,14 @@ const pendingFreezes = new Set(); /** * Detect animated GIFs from their resolved source. There is no cheap way to * inspect the decoded bytes, so we rely on the URL (file extension or the - * `data:image/gif` MIME type). + * `data:image/gif` MIME type). Authors can also opt an image in explicitly with + * `data-image-type="gif"`, which is the only way to catch GIFs served from URLs + * that carry no `.gif` extension (e.g. a CDN endpoint). */ function isGif(img: HTMLImageElement): boolean { + if (img.getAttribute(IMAGE_TYPE_ATTRIBUTE) === "gif") { + return true; + } const src = img.currentSrc || img.src; return /^data:image\/gif[;,]/i.test(src) || /\.gif(?:$|[?#])/i.test(src); } diff --git a/packages/browser/src/global/stabilization/plugins/waitForBackgroundImages.ts b/packages/browser/src/global/stabilization/plugins/waitForBackgroundImages.ts index cc97ec17..969204d4 100644 --- a/packages/browser/src/global/stabilization/plugins/waitForBackgroundImages.ts +++ b/packages/browser/src/global/stabilization/plugins/waitForBackgroundImages.ts @@ -9,6 +9,15 @@ const preloadedImages = new Set(); const URL_REGEX = /url\((['"]?)([^'")]+)\1\)/g; +/** + * By default, only elements opted in with the `data-visual-test-wait-bg-img` + * attribute (and their descendants) are scanned. This keeps the otherwise + * expensive `getComputedStyle` sweep cheap while still letting authors flag the + * regions whose background images must be loaded before the screenshot. + */ +const DEFAULT_SELECTOR = + "[data-visual-test-wait-bg-img], [data-visual-test-wait-bg-img] *"; + /** * Extract non-data background-image URLs from a computed `background-image` * value (which may contain several layered backgrounds and gradients). @@ -33,16 +42,22 @@ function collectUrls( } /** - * Resolve the selector scoping the scan, defaulting to the whole document. + * Resolve the selector scoping the scan. Defaults to the opt-in attribute + * selector; `true` widens the scan to the whole document, and an explicit + * `selector` overrides both. */ function resolveSelector(options: unknown): string { + // `true` opts every element into the scan (whole-document sweep). + if (options === true) { + return "*"; + } if (options && typeof options === "object") { const { selector } = options as WaitForBackgroundImagesOptions; if (typeof selector === "string" && selector.trim() !== "") { return selector; } } - return "*"; + return DEFAULT_SELECTOR; } /** @@ -51,17 +66,17 @@ function resolveSelector(options: unknown): string { * There is no native load event for CSS background images, so URLs are * discovered with `getComputedStyle` and preloaded through `Image` objects. * - * This plugin is opt-in because the scan is resource-intensive. Enable it with - * `stabilize: { waitForBackgroundImages: true }` to scan the whole document, or - * scope it on large pages with - * `stabilize: { waitForBackgroundImages: { selector: ".hero, [data-bg]" } }`. + * A full-document `getComputedStyle` sweep is expensive, so by default the scan + * is limited to elements opted in with the `data-visual-test-wait-bg-img` + * attribute (and their descendants). Widen it to the whole document with + * `stabilize: { waitForBackgroundImages: true }`, or target a custom selector + * with `stabilize: { waitForBackgroundImages: { selector: ".hero, [data-bg]" } }`. * - * The expensive scan runs only once per viewport in `beforeEach`; `wait.for` - * just polls the preloaded images. + * The scan runs only once per viewport in `beforeEach`; `wait.for` just polls + * the preloaded images. */ export const plugin = { name: "waitForBackgroundImages" as const, - optIn: true, beforeEach(_context, options) { const selector = resolveSelector(options); const urls = new Set(); diff --git a/packages/playwright/e2e.spec.ts b/packages/playwright/e2e.spec.ts index efdb4a05..4d009cd6 100644 --- a/packages/playwright/e2e.spec.ts +++ b/packages/playwright/e2e.spec.ts @@ -270,7 +270,9 @@ test.describe("#argosScreenshot", () => { waitUntil: "domcontentloaded", }); - // The plugin is opt-in, so this resolves even though the image is stuck. + // `#background-image` is not flagged with `data-visual-test-wait-bg-img`, + // so the default scan ignores it and stabilization resolves even though + // the image is stuck. await argosScreenshot(page, "background-images-disabled", { fullPage: false, }); @@ -278,10 +280,81 @@ test.describe("#argosScreenshot", () => { // Release the still-pending request to avoid leaking it. releaseImage(); }); + + test("waits for flagged background images by default", async ({ + page, + context, + }) => { + await delayFonts(context); + + // Gate the flagged element's background image so we control when it loads. + let releaseImage = () => {}; + const imageGate = new Promise((resolve) => { + releaseImage = resolve; + }); + let imageRequested = () => {}; + const imageRequestedPromise = new Promise((resolve) => { + imageRequested = resolve; + }); + + await context.route(/flagged-background\.png/, async (route) => { + imageRequested(); + await imageGate; + await route.fulfill({ + status: 200, + contentType: "image/png", + body: PNG, + }); + }); + + await page.goto(fixture("background-image.html"), { + waitUntil: "domcontentloaded", + }); + + let resolved = false; + // No `waitForBackgroundImages` option: the attribute alone opts the + // element in. + const screenshotPromise = argosScreenshot( + page, + "background-images-flagged", + { + fullPage: false, + }, + ) + .then(() => { + resolved = true; + }) + .catch(() => { + // Swallow so a failure surfaces through the assertions below. + }); + + // The plugin must have triggered the flagged image request… + await imageRequestedPromise; + // …and stabilization must still be blocked on it (well past the other + // stabilizers: the 1000ms loader and the 500ms delayed fonts). + await page.waitForTimeout(2000); + expect(resolved).toBe(false); + + // Once the image loads, stabilization completes. + releaseImage(); + await screenshotPromise; + expect(resolved).toBe(true); + }); }); test.describe("with `pauseGifs`", () => { - test.beforeEach(async ({ page }) => { + // The 2-frame animated GIF (frame 0 red, frame 1 lime) from the fixture, + // served from an extension-less URL so only `data-image-type="gif"` can + // flag it. + const GIF = Buffer.from( + "R0lGODlhZABkAPAAAP8AAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQAFAAAACwAAAAAZABkAAACc4SPqcvtD6OctNqLs968+w+G4kiW5omm6sq27gvH8kzX9o3n+s73/g8MCofEovGITCqXzKbzCY1Kp9Sq9YrNarfcrvcLDovH5LL5jE6r1+y2+w2Py+f0uv2Oz+v3/L7/DxgoOEhYaHiImKi4yNjo+AhpWAAAIfkEABQAAAAsAAAAAGQAZACAAP8AAAAAAnOEj6nL7Q+jnLTai7PevPsPhuJIluaJpurKtu4Lx/JM1/aN5/rO9/4PDAqHxKLxiEwql8ym8wmNSqfUqvWKzWq33K73Cw6Lx+Sy+YxOq9fstvsNj8vn9Lr9js/r9/y+/w8YKDhIWGh4iJiouMjY6PgIaVgAADs=", + "base64", + ); + + test.beforeEach(async ({ page, context }) => { + await context.route(/masked-gif-endpoint/, (route) => + route.fulfill({ status: 200, contentType: "image/gif", body: GIF }), + ); await page.goto(fixture("gif.html")); }); @@ -338,6 +411,31 @@ test.describe("#argosScreenshot", () => { expect(await gif.getAttribute("src")).toBe(originalSrc); }); + test('pauses GIFs flagged with `data-image-type="gif"`', async ({ + page, + }) => { + const gif = page.locator("#masked-gif"); + + // The URL has no `.gif` extension, so only the attribute can flag it. + expect(await gif.getAttribute("src")).toBe("masked-gif-endpoint"); + + await argosScreenshot(page, "with-masked-gif", { fullPage: false }); + + await page.evaluate(() => (window as any).__ARGOS__.beforeEach({})); + await page.waitForFunction(() => (window as any).__ARGOS__.waitFor({})); + await page.waitForFunction(() => { + const img = document.getElementById("masked-gif") as HTMLImageElement; + return img.src.startsWith("data:image/png") && img.complete; + }); + + // The animated GIF is now a static PNG snapshot, frozen on the red frame. + expect(await gif.getAttribute("src")).toMatch(/^data:image\/png/); + + // Cleanup restores the original GIF (as a resolved absolute URL). + await page.evaluate(() => (window as any).__ARGOS__.afterEach()); + expect(await gif.getAttribute("src")).toMatch(/masked-gif-endpoint$/); + }); + test("does not pause GIFs when disabled", async ({ page }) => { await argosScreenshot(page, "with-gif-disabled", { fullPage: false }); diff --git a/packages/playwright/fixtures/background-image.html b/packages/playwright/fixtures/background-image.html index 3c53be60..dcfa7fa0 100644 --- a/packages/playwright/fixtures/background-image.html +++ b/packages/playwright/fixtures/background-image.html @@ -34,6 +34,12 @@ background-image: url("slow-background.png"); background-size: cover; } + #flagged-background-image { + width: 200px; + height: 100px; + background-image: url("flagged-background.png"); + background-size: cover; + } @@ -48,6 +54,9 @@

Background image page

The font is loaded before the screenshot.

+ + +