From 7d03fcea5da76eb43b66c6200ccaeb420fbd78e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 10:06:37 +0200 Subject: [PATCH] feat(puppeteer): support the blackout helper and add `mask` option Puppeteer has no native mask, so `[data-visual-test="blackout"]` was silently ignored. Cover masked elements with boxes drawn by the browser script, like Playwright does: a glass pane in the top layer (above modal dialogs) whose boxes follow the elements until the screenshot is taken, since Puppeteer scrolls the element into view for element screenshots. The boxes are applied once the page is stable and removed by `afterEach`. `mask` accepts selectors or `ElementHandle`s, and `maskColor` defaults to Co-Authored-By: Claude Opus 5.5 --- packages/browser/src/global/index.ts | 2 + packages/browser/src/global/mask.ts | 119 ++++++++++++ .../browser/src/global/stabilization/index.ts | 12 ++ packages/puppeteer/fixtures/mask.html | 48 +++++ packages/puppeteer/src/index.ts | 49 +++++ packages/puppeteer/test.spec.js | 170 ++++++++++++++---- 6 files changed, 368 insertions(+), 32 deletions(-) create mode 100644 packages/browser/src/global/mask.ts create mode 100644 packages/puppeteer/fixtures/mask.html diff --git a/packages/browser/src/global/index.ts b/packages/browser/src/global/index.ts index 9b417e5d..b3981b10 100644 --- a/packages/browser/src/global/index.ts +++ b/packages/browser/src/global/index.ts @@ -5,6 +5,7 @@ import { beforeEach, afterEach, applyHelper, + mask, waitFor, getWaitFailureExplanations, } from "./stabilization"; @@ -15,6 +16,7 @@ const ArgosGlobal = { beforeEach, afterEach, applyHelper, + mask, waitFor, getWaitFailureExplanations, getColorScheme: () => getColorScheme(), diff --git a/packages/browser/src/global/mask.ts b/packages/browser/src/global/mask.ts new file mode 100644 index 00000000..db8952e5 --- /dev/null +++ b/packages/browser/src/global/mask.ts @@ -0,0 +1,119 @@ +/** + * Overlay drawing a box over each masked element. + */ +interface MaskOverlay { + /** + * Element covering the viewport, shown in the top layer. + */ + glass: HTMLElement; + /** + * Shadow root holding the boxes, isolated from the page styles. + */ + root: ShadowRoot; + /** + * Masked elements and the box covering each of them. + */ + boxes: { element: Element; box: HTMLElement }[]; + /** + * Animation frame request keeping the boxes in place. + */ + frame: number; +} + +let overlay: MaskOverlay | null = null; + +/** + * Set inline styles that win over any page style. + */ +function setImportantStyles( + element: HTMLElement, + styles: Record, +) { + for (const [property, value] of Object.entries(styles)) { + element.style.setProperty(property, value, "important"); + } +} + +/** + * Create the overlay, like Playwright does for its `mask` option: a glass + * pane covering the viewport, in the top layer so it is drawn above modal + * dialogs and popovers. + */ +function createOverlay(): MaskOverlay { + const glass = document.createElement("argos-mask"); + glass.setAttribute("popover", "manual"); + setImportantStyles(glass, { + display: "block", + position: "fixed", + inset: "0", + width: "100%", + height: "100%", + "max-width": "none", + "max-height": "none", + margin: "0", + padding: "0", + border: "none", + overflow: "visible", + background: "transparent", + opacity: "1", + transform: "none", + "pointer-events": "none", + "z-index": "2147483647", + }); + const root = glass.attachShadow({ mode: "open" }); + document.documentElement.appendChild(glass); + if (typeof glass.showPopover === "function") { + glass.showPopover(); + } + + const overlay: MaskOverlay = { glass, root, boxes: [], frame: 0 }; + // Puppeteer scrolls the element into view before an element screenshot and + // resizes the viewport for some full page screenshots: follow the masked + // elements until the screenshot is taken. + const follow = () => { + updateBoxes(overlay); + overlay.frame = requestAnimationFrame(follow); + }; + overlay.frame = requestAnimationFrame(follow); + return overlay; +} + +/** + * Move each box over its element. + */ +function updateBoxes(overlay: MaskOverlay) { + for (const { element, box } of overlay.boxes) { + const rect = element.getBoundingClientRect(); + box.style.left = `${rect.left}px`; + box.style.top = `${rect.top}px`; + box.style.width = `${rect.width}px`; + box.style.height = `${rect.height}px`; + } +} + +/** + * Cover elements with a box of the given color. + */ +export function maskElements(elements: Iterable, color: string) { + overlay ??= createOverlay(); + for (const element of elements) { + const box = document.createElement("div"); + box.style.position = "absolute"; + box.style.background = color; + overlay.root.appendChild(box); + overlay.boxes.push({ element, box }); + } + updateBoxes(overlay); +} + +/** + * Remove the boxes covering the masked elements. + */ +export function unmaskElements() { + if (!overlay) { + return; + } + cancelAnimationFrame(overlay.frame); + overlay.glass.remove(); + overlay = null; +} diff --git a/packages/browser/src/global/stabilization/index.ts b/packages/browser/src/global/stabilization/index.ts index 4972d103..ccecbd62 100644 --- a/packages/browser/src/global/stabilization/index.ts +++ b/packages/browser/src/global/stabilization/index.ts @@ -1,4 +1,5 @@ import type { ViewportOption } from "../../viewport"; +import { maskElements, unmaskElements } from "../mask"; import { corePlugins, plugins, type PluginName } from "./plugins"; export { applyHelper, type ArgosHelper } from "./plugins/argosHelpers"; @@ -179,6 +180,17 @@ export function afterEach() { beforeEachCleanups.clear(); } +/** + * Cover elements with a box of the given color, like the + * `data-visual-test="blackout"` helper does, until `afterEach` runs. + * Call it once the page is stable, right before taking the screenshot, for + * SDKs whose automation library has no native mask. + */ +export function mask(elements: Element[], color: string) { + maskElements(elements, color); + beforeEachCleanups.add(unmaskElements); +} + /** * Get the stabilization state of the document. */ diff --git a/packages/puppeteer/fixtures/mask.html b/packages/puppeteer/fixtures/mask.html new file mode 100644 index 00000000..e3c6345d --- /dev/null +++ b/packages/puppeteer/fixtures/mask.html @@ -0,0 +1,48 @@ + + + + + Mask page + + + + +
+
+
+
+
+
+ +
+
+ + diff --git a/packages/puppeteer/src/index.ts b/packages/puppeteer/src/index.ts index 982341aa..1da74f1c 100644 --- a/packages/puppeteer/src/index.ts +++ b/packages/puppeteer/src/index.ts @@ -78,6 +78,22 @@ export type ArgosScreenshotOptions = Omit< */ remove?: (string | ElementHandle)[]; + /** + * Elements to cover with a box when the screenshot is taken, as + * `ElementHandle`s or string selectors, like with the + * `data-visual-test="blackout"` helper. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(page, "home", { mask: ["#map", ".ad"] }) + */ + mask?: (string | ElementHandle)[]; + + /** + * Color of the box covering the masked elements, in CSS color format. + * @default "#FF00FF" + */ + maskColor?: string; + /** * Disable hover effects by moving the mouse to the top-left corner of the page. * @default true @@ -269,6 +285,35 @@ async function applyHelpers( ]); } +/** + * Cover the `data-visual-test="blackout"` helper and the `mask` option with + * boxes until `afterEach` runs, since Puppeteer has no native mask. + */ +async function applyMask( + page: Page, + { + mask = [], + maskColor = "#FF00FF", + }: Pick, +) { + await Promise.all( + ['[data-visual-test="blackout"]', ...mask].map((target) => + typeof target === "string" + ? page.$$eval( + target, + (elements, color) => + ((window as any).__ARGOS__ as ArgosGlobal).mask(elements, color), + maskColor, + ) + : target.evaluate( + (element, color) => + ((window as any).__ARGOS__ as ArgosGlobal).mask([element], color), + maskColor, + ), + ), + ); +} + /** * Wait for the UI to be ready before taking the screenshot. */ @@ -327,6 +372,8 @@ export async function argosScreenshot( baseName, hide, remove, + mask, + maskColor, ...puppeteerOptions } = options; @@ -419,6 +466,8 @@ export async function argosScreenshot( await applyHelpers(page, { hide, remove }); const afterEach = await beforeEach(page, options); await waitForReadiness(page, options); + // Measured once the page is stable, so the boxes match the elements. + await applyMask(page, { mask, maskColor }); const [screenshotPath, metadata] = await Promise.all([ getScreenshotPath(name), diff --git a/packages/puppeteer/test.spec.js b/packages/puppeteer/test.spec.js index f9bc054c..37b56b5e 100644 --- a/packages/puppeteer/test.spec.js +++ b/packages/puppeteer/test.spec.js @@ -133,40 +133,44 @@ describe("argosScreenshot", () => { }); }); - describe("with hide and remove options", () => { - /** - * Read the color of some pixels of a screenshot, decoded by the browser. - */ - async function getPixels(screenshotName, points) { - const data = await readFile( - new URL(`screenshots/argos/${screenshotName}.png`, import.meta.url), - "base64", - ); - return page.evaluate( - async (data, points) => { - // Runs in the browser. - const { document, Image } = globalThis; - const img = new Image(); - img.src = `data:image/png;base64,${data}`; - await img.decode(); - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext("2d"); - ctx.drawImage(img, 0, 0); - return points.map(({ x, y }) => { - const [r, g, b] = ctx.getImageData(x, y, 1, 1).data; - return { r, g, b }; - }); - }, - data, - points, - ); - } + /** + * Read the color of some pixels of a screenshot, decoded by the browser. + */ + async function getPixels(screenshotName, points) { + const data = await readFile( + new URL(`screenshots/argos/${screenshotName}.png`, import.meta.url), + "base64", + ); + return page.evaluate( + async (data, points) => { + // Runs in the browser. + const { document, Image } = globalThis; + const img = new Image(); + img.src = `data:image/png;base64,${data}`; + await img.decode(); + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + return points.map(({ x, y }) => { + const [r, g, b] = ctx.getImageData(x, y, 1, 1).data; + return { r, g, b }; + }); + }, + data, + points, + ); + } - const WHITE = { r: 255, g: 255, b: 255 }; - const BLACK = { r: 0, g: 0, b: 0 }; + const WHITE = { r: 255, g: 255, b: 255 }; + const BLACK = { r: 0, g: 0, b: 0 }; + const BLUE = { r: 0, g: 0, b: 255 }; + const RED = { r: 255, g: 0, b: 0 }; + const MAGENTA = { r: 255, g: 0, b: 255 }; + const CYAN = { r: 0, g: 255, b: 255 }; + describe("with hide and remove options", () => { beforeAll(async () => { await page.goto(new URL("fixtures/helpers.html", import.meta.url).href); }); @@ -197,6 +201,108 @@ describe("argosScreenshot", () => { }); }); + describe("with mask option", () => { + beforeAll(async () => { + await page.goto(new URL("fixtures/helpers.html", import.meta.url).href); + }); + + it("masks the blackout helper and the given elements", async () => { + await argosScreenshot(page, "mask", { + fullPage: false, + mask: ["#hidden", ".does-not-exist", await page.$("#handle")], + }); + + // Each row is 50px high. + const pixels = await getPixels("mask", [ + // `#hidden`, masked. + { x: 10, y: 0 }, + { x: 10, y: 49 }, + // `#removed`, not masked. + { x: 10, y: 50 }, + { x: 10, y: 99 }, + // `#handle`, masked. + { x: 10, y: 100 }, + // `#blackout`, masked by the helper. + { x: 10, y: 199 }, + // `#last`, not masked. + { x: 10, y: 200 }, + ]); + expect(pixels).toEqual([ + MAGENTA, + MAGENTA, + BLUE, + BLUE, + MAGENTA, + MAGENTA, + BLACK, + ]); + + // The page is restored after the screenshot. + expect(await page.$("argos-mask")).toBe(null); + }); + + it("supports maskColor option", async () => { + await argosScreenshot(page, "mask-color", { + fullPage: false, + maskColor: "#00FFFF", + }); + + const pixels = await getPixels("mask-color", [{ x: 10, y: 175 }]); + expect(pixels).toEqual([CYAN]); + }); + + describe("on a long page", () => { + beforeAll(async () => { + await page.goto(new URL("fixtures/mask.html", import.meta.url).href); + }); + + it("masks elements below the fold in a full page screenshot", async () => { + // The boxes follow the elements whatever the scroll position. + await page.evaluate(() => globalThis.scrollTo(0, 1000)); + await argosScreenshot(page, "mask-full-page"); + + // `#container` starts at 2100px, `#masked` is inside its 20px padding. + const pixels = await getPixels("mask-full-page", [ + { x: 10, y: 50 }, + { x: 10, y: 2110 }, + { x: 100, y: 2120 }, + { x: 100, y: 2169 }, + { x: 100, y: 2175 }, + ]); + expect(pixels).toEqual([RED, BLUE, MAGENTA, MAGENTA, BLUE]); + }); + + it("masks elements in an element screenshot", async () => { + // Puppeteer scrolls the element into view before taking the screenshot. + await page.evaluate(() => globalThis.scrollTo(0, 0)); + await argosScreenshot(page, "mask-element", { element: "#container" }); + + const pixels = await getPixels("mask-element", [ + { x: 10, y: 10 }, + { x: 100, y: 20 }, + { x: 100, y: 69 }, + { x: 100, y: 75 }, + ]); + expect(pixels).toEqual([BLUE, MAGENTA, MAGENTA, BLUE]); + }); + + it("masks elements in a modal dialog", async () => { + await page.$eval("#dialog", (dialog) => dialog.showModal()); + const { x, y } = await page.$eval("#in-dialog", (element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }); + await argosScreenshot(page, "mask-dialog", { fullPage: false }); + await page.$eval("#dialog", (dialog) => dialog.close()); + + const pixels = await getPixels("mask-dialog", [ + { x: Math.round(x), y: Math.round(y) }, + ]); + expect(pixels).toEqual([MAGENTA]); + }); + }); + }); + describe("with cjs version", () => { it("works", async () => { await argosScreenshotCjs(page, "cjs");