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
20 changes: 6 additions & 14 deletions packages/browser/src/global/stabilization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
};
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 12 additions & 1 deletion packages/browser/src/global/stabilization/plugins/pauseGifs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* `<img data-image-type="gif">`.
*/
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
Expand All @@ -13,9 +19,14 @@ const pendingFreezes = new Set<HTMLImageElement>();
/**
* 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ const preloadedImages = new Set<HTMLImageElement>();

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).
Expand All @@ -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;
}

/**
Expand All @@ -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<string>();
Expand Down
102 changes: 100 additions & 2 deletions packages/playwright/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,18 +270,91 @@ 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,
});

// 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<void>((resolve) => {
releaseImage = resolve;
});
let imageRequested = () => {};
const imageRequestedPromise = new Promise<void>((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"));
});

Expand Down Expand Up @@ -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 });

Expand Down
9 changes: 9 additions & 0 deletions packages/playwright/fixtures/background-image.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
</style>
</head>
<body>
Expand All @@ -48,6 +54,9 @@ <h1>Background image page</h1>
<p class="paragraph">The font is loaded before the screenshot.</p>

<div id="background-image"></div>

<!-- Opted into background-image waiting by default via the attribute. -->
<div id="flagged-background-image" data-visual-test-wait-bg-img></div>
</main>

<script>
Expand Down
11 changes: 11 additions & 0 deletions packages/playwright/fixtures/gif.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ <h1>Animated GIF page</h1>
height="100"
src="data:image/gif;base64,R0lGODlhZABkAPAAAP8AAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQAFAAAACwAAAAAZABkAAACc4SPqcvtD6OctNqLs968+w+G4kiW5omm6sq27gvH8kzX9o3n+s73/g8MCofEovGITCqXzKbzCY1Kp9Sq9YrNarfcrvcLDovH5LL5jE6r1+y2+w2Py+f0uv2Oz+v3/L7/DxgoOEhYaHiImKi4yNjo+AhpWAAAIfkEABQAAAAsAAAAAGQAZACAAP8AAAAAAnOEj6nL7Q+jnLTai7PevPsPhuJIluaJpurKtu4Lx/JM1/aN5/rO9/4PDAqHxKLxiEwql8ym8wmNSqfUqvWKzWq33K73Cw6Lx+Sy+YxOq9fstvsNj8vn9Lr9js/r9/y+/w8YKDhIWGh4iJiouMjY6PgIaVgAADs="
/>

<!-- Same animated GIF, but served from an extension-less URL so the src
heuristics can't detect it. Opted in explicitly with
`data-image-type="gif"`. -->
<img
id="masked-gif"
width="100"
height="100"
data-image-type="gif"
src="masked-gif-endpoint"
/>
</main>
</body>
</html>
Loading