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");