From 962d72f019cadd44712f4af8f82406f7801e0c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 09:55:39 +0200 Subject: [PATCH 1/8] fix(playwright): keep the blackout helper when passing `mask` Passing `mask` to `argosScreenshot` replaced the mask applied to `[data-visual-test="blackout"]` elements instead of adding to it, so those elements were no longer masked. Co-Authored-By: Claude Opus 5.5 --- packages/playwright/src/screenshot.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/playwright/src/screenshot.ts b/packages/playwright/src/screenshot.ts index b439176f..0c05b19c 100644 --- a/packages/playwright/src/screenshot.ts +++ b/packages/playwright/src/screenshot.ts @@ -76,6 +76,13 @@ export type ArgosScreenshotOptions = { */ argosCSS?: string; + /** + * Elements to mask with a box when the screenshot is taken, like with the + * `data-visual-test="blackout"` helper. + * @see https://playwright.dev/docs/api/class-page#page-screenshot-option-mask + */ + mask?: Locator[]; + /** * Disable hover effects by moving the mouse to the top-left corner of the document. * @default true @@ -180,6 +187,7 @@ export async function argosScreenshot( ariaSnapshot, disableHover = true, baseName, + mask = [], ...playwrightOptions } = options; @@ -302,9 +310,9 @@ export async function argosScreenshot( path: screenshotPath, type: "png", fullPage, - mask: [handler.locator('[data-visual-test="blackout"]')], animations: "disabled", ...playwrightOptions, + mask: [handler.locator('[data-visual-test="blackout"]'), ...mask], }), writeMetadata(screenshotPath, metadata), ]); From e387ec274074af3c3921bb611ea6a4b32e281cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 09:55:40 +0200 Subject: [PATCH 2/8] feat: add `hide` and `remove` screenshot options Hide or remove elements straight from `argosScreenshot`, without adding `data-visual-test` attributes to the application. The options apply the `transparent` and `removed` Argos helpers to the matched elements during the screenshot and restore them afterwards. - Playwright: `Locator`s or Playwright selectors (also used by the Vitest plugin options and Storybook). - Puppeteer: `ElementHandle`s or Puppeteer selectors. - Cypress: selectors. - Vitest: selectors, in the per-call options. Selectors that match nothing are ignored. When an element is both hidden and removed, it is removed. Refs ARG-520 Co-Authored-By: Claude Opus 5.5 --- packages/browser/src/global/index.ts | 2 + .../browser/src/global/stabilization/index.ts | 17 ++++ .../stabilization/plugins/argosHelpers.ts | 42 ++++++++ packages/browser/src/index.ts | 1 + .../cypress/cypress/e2e/argosScreenshot.cy.ts | 28 ++++++ packages/cypress/src/support.ts | 47 +++++++++ packages/playwright/e2e.spec.ts | 96 ++++++++++++++++++- packages/playwright/fixtures/helpers.html | 28 ++++++ packages/playwright/src/screenshot.ts | 26 +++++ packages/playwright/src/util.ts | 43 ++++++++- packages/puppeteer/fixtures/helpers.html | 28 ++++++ packages/puppeteer/src/index.ts | 60 ++++++++++++ packages/puppeteer/test.spec.js | 66 ++++++++++++- packages/vitest/e2e/screenshot.test.ts | 32 +++++++ packages/vitest/src/options.ts | 16 ++++ 15 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 packages/playwright/fixtures/helpers.html create mode 100644 packages/puppeteer/fixtures/helpers.html diff --git a/packages/browser/src/global/index.ts b/packages/browser/src/global/index.ts index 1120dd13..9b417e5d 100644 --- a/packages/browser/src/global/index.ts +++ b/packages/browser/src/global/index.ts @@ -4,6 +4,7 @@ import { afterAll, beforeEach, afterEach, + applyHelper, waitFor, getWaitFailureExplanations, } from "./stabilization"; @@ -13,6 +14,7 @@ const ArgosGlobal = { afterAll, beforeEach, afterEach, + applyHelper, waitFor, getWaitFailureExplanations, getColorScheme: () => getColorScheme(), diff --git a/packages/browser/src/global/stabilization/index.ts b/packages/browser/src/global/stabilization/index.ts index 6e6f2233..c7629bfa 100644 --- a/packages/browser/src/global/stabilization/index.ts +++ b/packages/browser/src/global/stabilization/index.ts @@ -1,5 +1,12 @@ import type { ViewportOption } from "../../viewport"; import { corePlugins, plugins, type PluginName } from "./plugins"; +import { + applyHelper as applyHelperToElements, + restoreHelpers, + type ArgosHelper, +} from "./plugins/argosHelpers"; + +export type { ArgosHelper }; type Cleanup = () => void; @@ -177,6 +184,16 @@ export function afterEach() { beforeEachCleanups.clear(); } +/** + * Apply an Argos helper to elements, as if they had the matching + * `data-visual-test` attribute, until `afterEach` runs. + * Lets the SDKs hide elements matched by their own selectors and locators. + */ +export function applyHelper(elements: Element[], helper: ArgosHelper) { + applyHelperToElements(elements, helper); + beforeEachCleanups.add(restoreHelpers); +} + /** * Get the stabilization state of the document. */ diff --git a/packages/browser/src/global/stabilization/plugins/argosHelpers.ts b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts index 15e57467..724ee732 100644 --- a/packages/browser/src/global/stabilization/plugins/argosHelpers.ts +++ b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts @@ -1,6 +1,48 @@ import type { Plugin } from ".."; import { injectGlobalStyles } from "../util"; +const HELPER_ATTRIBUTE = "data-visual-test"; + +/** + * Argos helpers that can be applied to elements from the SDKs, as if the + * element had the matching `data-visual-test` attribute. + */ +export type ArgosHelper = "transparent" | "removed"; + +/** + * Original `data-visual-test` value of the elements a helper was applied to, + * `null` when the element had no attribute. + */ +const originalHelpers = new Map(); + +/** + * Apply an Argos helper to elements. + * When an element receives several helpers, the last one wins. + */ +export function applyHelper(elements: Iterable, helper: ArgosHelper) { + for (const element of elements) { + if (!originalHelpers.has(element)) { + originalHelpers.set(element, element.getAttribute(HELPER_ATTRIBUTE)); + } + element.setAttribute(HELPER_ATTRIBUTE, helper); + } +} + +/** + * Restore the `data-visual-test` attribute of every element a helper was + * applied to. + */ +export function restoreHelpers() { + originalHelpers.forEach((value, element) => { + if (value === null) { + element.removeAttribute(HELPER_ATTRIBUTE); + } else { + element.setAttribute(HELPER_ATTRIBUTE, value); + } + }); + originalHelpers.clear(); +} + /** * Inject CSS for Argos helpers. */ diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index aa07cd4f..a92c34aa 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -4,4 +4,5 @@ export type { PluginOptions as StabilizationPluginOptions, Context as StabilizationContext, WaitForBackgroundImagesOptions, + ArgosHelper, } from "./global/stabilization"; diff --git a/packages/cypress/cypress/e2e/argosScreenshot.cy.ts b/packages/cypress/cypress/e2e/argosScreenshot.cy.ts index 19177915..7f6642b9 100644 --- a/packages/cypress/cypress/e2e/argosScreenshot.cy.ts +++ b/packages/cypress/cypress/e2e/argosScreenshot.cy.ts @@ -105,4 +105,32 @@ describe("argosScreenshot", () => { .should("eq", "supports onAfterScreenshot option"); }); }); + + it("supports hide and remove options", () => { + cy.visit("cypress/pages/index.html"); + cy.document().then((doc) => { + const getHelpers = () => + ["h1", ".specific-target"].map( + (selector) => + doc.querySelector(selector)?.getAttribute("data-visual-test") ?? + null, + ); + let helpersDuringScreenshot: (string | null)[] = []; + cy.argosScreenshot("hide-remove-options", { + hide: [".specific-target", "h1"], + // Removing wins over hiding. + remove: ["h1", ".does-not-exist"], + onBeforeScreenshot: () => { + helpersDuringScreenshot = getHelpers(); + }, + }).then(() => { + expect(helpersDuringScreenshot).to.deep.equal([ + "removed", + "transparent", + ]); + // The page is restored after the screenshot. + expect(getHelpers()).to.deep.equal([null, null]); + }); + }); + }); }); diff --git a/packages/cypress/src/support.ts b/packages/cypress/src/support.ts index c2ca7363..6f4ff58f 100644 --- a/packages/cypress/src/support.ts +++ b/packages/cypress/src/support.ts @@ -1,6 +1,7 @@ import "cypress-wait-until"; import { type ArgosGlobal, + type ArgosHelper, resolveViewport, type StabilizationContext, type StabilizationPluginOptions, @@ -32,6 +33,26 @@ type ArgosScreenshotOptions = Partial< */ argosCSS?: string; + /** + * Selectors of the elements to hide from the screenshot. + * Hidden elements keep their space in the layout, like with the + * `data-visual-test="transparent"` helper. + * Selectors that match nothing are ignored. + * @example + * cy.argosScreenshot("home", { hide: ["#clock", ".avatar"] }) + */ + hide?: string[]; + + /** + * Selectors of the elements to remove from the screenshot. + * Removed elements no longer take space in the layout, like with the + * `data-visual-test="removed"` helper. + * Selectors that match nothing are ignored. + * @example + * cy.argosScreenshot("home", { remove: [".cookie-banner"] }) + */ + remove?: string[]; + /** * Sensitivity threshold between 0 and 1. * The higher the threshold, the less sensitive the diff will be. @@ -156,6 +177,29 @@ function beforeEach(options: ArgosScreenshotOptions) { }; } +/** + * Apply the `hide` and `remove` options until `afterEach` runs, as if the + * matched elements had the `data-visual-test` helper attribute. + */ +function applyHelpers(options: ArgosScreenshotOptions) { + const helpers: [ArgosHelper, string[] | undefined][] = [ + ["transparent", options.hide], + // Applied last, so an element both hidden and removed is removed. + ["removed", options.remove], + ]; + + cy.window({ log: false }).then((window) => { + for (const [helper, selectors] of helpers) { + for (const selector of selectors ?? []) { + ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( + Cypress.$(selector, window.document).toArray(), + helper, + ); + } + } + }); +} + function getRetries(value: unknown) { if (typeof value !== "number" || !Number.isFinite(value)) { return 0; @@ -204,6 +248,8 @@ Cypress.Commands.add( baseName, blackout, onAfterScreenshot, + hide: _hide, + remove: _remove, ...cypressOptions } = options; @@ -234,6 +280,7 @@ Cypress.Commands.add( function stabilizeAndScreenshot(name: string, baseNames: string[] | null) { waitForReadiness(options); const afterEach = beforeEach(options); + applyHelpers(options); waitForReadiness(options); const ref: any = {}; diff --git a/packages/playwright/e2e.spec.ts b/packages/playwright/e2e.spec.ts index 5225c215..374e0ad0 100644 --- a/packages/playwright/e2e.spec.ts +++ b/packages/playwright/e2e.spec.ts @@ -1,6 +1,6 @@ import { test, expect, type BrowserContext, type Page } from "@playwright/test"; import { fileURLToPath } from "node:url"; -import { stat } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { argosScreenshot } from "./dist/index.mjs"; import { argosScreenshot as typedArgosScreenshot } from "./src"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -158,6 +158,100 @@ test.describe("#argosScreenshot", () => { }); }); + test.describe("with `hide`, `remove` and `mask`", () => { + /** + * Read the color of some pixels of a PNG, decoded by the browser. + */ + async function getPixels( + page: Page, + path: string, + points: { x: number; y: number }[], + ) { + const data = await readFile(path, "base64"); + return page.evaluate( + async ({ data, points }) => { + 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 MASK = { r: 255, g: 0, b: 255 }; + const BLACK = { r: 0, g: 0, b: 0 }; + + test.beforeEach(async ({ page }) => { + await page.goto(fixture("helpers.html")); + }); + + test("hides, removes and masks elements", async ({ page }) => { + const [screenshot] = await argosScreenshot(page, "helpers", { + fullPage: false, + hide: ["#hidden", ".does-not-exist"], + remove: [page.locator("#removed")], + mask: [page.locator("#masked")], + }); + + // Each row is 50px high: `#removed` no longer takes space, so the rows + // below it move up. + const pixels = await getPixels(page, screenshot!.path, [ + // `#hidden` keeps its space but is transparent. + { x: 10, y: 25 }, + // `#masked`, masked by the `mask` option. + { x: 10, y: 75 }, + // `#blackout`, still masked by its `data-visual-test` attribute. + { x: 10, y: 125 }, + // `#last`. + { x: 10, y: 175 }, + ]); + expect(pixels).toEqual([WHITE, MASK, MASK, BLACK]); + + // The page is restored after the screenshot. + await expect(page.locator("#hidden")).not.toHaveAttribute( + "data-visual-test", + ); + await expect(page.locator("#removed")).not.toHaveAttribute( + "data-visual-test", + ); + }); + + test("restores the original helper attribute", async ({ page }) => { + const [screenshot] = await argosScreenshot(page, "helpers-restore", { + fullPage: false, + hide: ["#blackout", "#last"], + // Removing wins over hiding. + remove: ["#last"], + }); + + const pixels = await getPixels(page, screenshot!.path, [ + // `#blackout` is hidden instead of masked. + { x: 10, y: 175 }, + // `#last` is removed. + { x: 10, y: 225 }, + ]); + expect(pixels).toEqual([WHITE, WHITE]); + + await expect(page.locator("#blackout")).toHaveAttribute( + "data-visual-test", + "blackout", + ); + await expect(page.locator("#last")).not.toHaveAttribute( + "data-visual-test", + ); + }); + }); + test.describe("viewports", () => { test("takes screenshots on different viewports", async ({ page }) => { await page.goto(fixture("basic.html")); diff --git a/packages/playwright/fixtures/helpers.html b/packages/playwright/fixtures/helpers.html new file mode 100644 index 00000000..c9733ac9 --- /dev/null +++ b/packages/playwright/fixtures/helpers.html @@ -0,0 +1,28 @@ + + + + + Helpers page + + + + +
+
+
+
+
+ + diff --git a/packages/playwright/src/screenshot.ts b/packages/playwright/src/screenshot.ts index 0c05b19c..ba6ded25 100644 --- a/packages/playwright/src/screenshot.ts +++ b/packages/playwright/src/screenshot.ts @@ -22,6 +22,7 @@ import { } from "@argos-ci/util"; import { getAttachmentName, type ArgosAttachment } from "./attachment"; import { + applyHelpers, attachAttachments, beforeAll, beforeEach, @@ -76,6 +77,28 @@ export type ArgosScreenshotOptions = { */ argosCSS?: string; + /** + * Elements to hide from the screenshot, as `Locator`s or string selectors. + * Hidden elements keep their space in the layout, like with the + * `data-visual-test="transparent"` helper. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(page, "home", { + * hide: ["#clock", page.getByTestId("avatar")], + * }) + */ + hide?: (string | Locator)[]; + + /** + * Elements to remove from the screenshot, as `Locator`s or string selectors. + * Removed elements no longer take space in the layout, like with the + * `data-visual-test="removed"` helper. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(page, "home", { remove: [".cookie-banner"] }) + */ + remove?: (string | Locator)[]; + /** * Elements to mask with a box when the screenshot is taken, like with the * `data-visual-test="blackout"` helper. @@ -187,6 +210,8 @@ export async function argosScreenshot( ariaSnapshot, disableHover = true, baseName, + hide, + remove, mask = [], ...playwrightOptions } = options; @@ -265,6 +290,7 @@ export async function argosScreenshot( await waitForReadiness(handler, context); const afterEach = await beforeEach(handler, context); + await applyHelpers(handler, { hide, remove }); await waitForReadiness(handler, context); const [snapshotPath] = await Promise.all([ diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index c40d9afc..01949c14 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -1,4 +1,10 @@ -import type { ElementHandle, Frame, Page, TestInfo } from "@playwright/test"; +import type { + ElementHandle, + Frame, + Locator, + Page, + TestInfo, +} from "@playwright/test"; import type { TestCase, TestResult } from "@playwright/test/reporter"; import { createRequire } from "node:module"; import { @@ -9,6 +15,7 @@ import { import { getGlobalScript, type ArgosGlobal, + type ArgosHelper, type StabilizationContext, type ViewportSize, } from "@argos-ci/browser"; @@ -410,6 +417,40 @@ export async function beforeEach( }; } +/** + * Apply the `hide` and `remove` options until `afterEach` runs, as if the + * matched elements had the `data-visual-test` helper attribute. + */ +export async function applyHelpers( + handler: Page | Frame, + options: { + hide?: (string | Locator)[]; + remove?: (string | Locator)[]; + }, +) { + const helpers: [ArgosHelper, (string | Locator)[] | undefined][] = [ + ["transparent", options.hide], + // Applied last, so an element both hidden and removed is removed. + ["removed", options.remove], + ]; + for (const [helper, targets] of helpers) { + await Promise.all( + (targets ?? []).map((target) => { + const locator = + typeof target === "string" ? handler.locator(target) : target; + return locator.evaluateAll( + (elements, helper) => + ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( + elements, + helper, + ), + helper, + ); + }), + ); + } +} + /** * Increase the timeout for the test x3. * Returns a function to reset the timeout. diff --git a/packages/puppeteer/fixtures/helpers.html b/packages/puppeteer/fixtures/helpers.html new file mode 100644 index 00000000..84d0f4e0 --- /dev/null +++ b/packages/puppeteer/fixtures/helpers.html @@ -0,0 +1,28 @@ + + + + + Helpers page + + + + +
+
+
+
+
+ + diff --git a/packages/puppeteer/src/index.ts b/packages/puppeteer/src/index.ts index c054cc33..116434a2 100644 --- a/packages/puppeteer/src/index.ts +++ b/packages/puppeteer/src/index.ts @@ -6,6 +6,7 @@ import { type ViewportOption, resolveViewport, type ArgosGlobal, + type ArgosHelper, getGlobalScript, type ViewportSize, type StabilizationPluginOptions, @@ -57,6 +58,26 @@ export type ArgosScreenshotOptions = Omit< */ argosCSS?: string; + /** + * Elements to hide from the screenshot, as `ElementHandle`s or string selectors. + * Hidden elements keep their space in the layout, like with the + * `data-visual-test="transparent"` helper. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(page, "home", { hide: ["#clock", ".avatar"] }) + */ + hide?: (string | ElementHandle)[]; + + /** + * Elements to remove from the screenshot, as `ElementHandle`s or string selectors. + * Removed elements no longer take space in the layout, like with the + * `data-visual-test="removed"` helper. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(page, "home", { remove: [".cookie-banner"] }) + */ + remove?: (string | ElementHandle)[]; + /** * Disable hover effects by moving the mouse to the top-left corner of the page. * @default true @@ -214,6 +235,42 @@ async function beforeEach(page: Page, options: ArgosScreenshotOptions) { }; } +/** + * Apply the `hide` and `remove` options until `afterEach` runs, as if the + * matched elements had the `data-visual-test` helper attribute. + */ +async function applyHelpers(page: Page, options: ArgosScreenshotOptions) { + const helpers: [ArgosHelper, (string | ElementHandle)[] | undefined][] = [ + ["transparent", options.hide], + // Applied last, so an element both hidden and removed is removed. + ["removed", options.remove], + ]; + for (const [helper, targets] of helpers) { + await Promise.all( + (targets ?? []).map((target) => + typeof target === "string" + ? page.$$eval( + target, + (elements, helper) => + ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( + elements, + helper, + ), + helper, + ) + : target.evaluate( + (element, helper) => + ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( + [element], + helper, + ), + helper, + ), + ), + ); + } +} + /** * Wait for the UI to be ready before taking the screenshot. */ @@ -270,6 +327,8 @@ export async function argosScreenshot( viewports, argosCSS: _argosCSS, baseName, + hide: _hide, + remove: _remove, ...puppeteerOptions } = options; @@ -360,6 +419,7 @@ export async function argosScreenshot( ) { await waitForReadiness(page, options); const afterEach = await beforeEach(page, options); + await applyHelpers(page, options); await waitForReadiness(page, options); const [screenshotPath, metadata] = await Promise.all([ diff --git a/packages/puppeteer/test.spec.js b/packages/puppeteer/test.spec.js index 79d4f454..f9bc054c 100644 --- a/packages/puppeteer/test.spec.js +++ b/packages/puppeteer/test.spec.js @@ -1,6 +1,6 @@ import puppeteer from "puppeteer"; import { fileURLToPath } from "node:url"; -import { stat } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { argosScreenshot } from "./dist/index.mjs"; import { argosScreenshot as argosScreenshotCjs } from "./dist/index.cjs"; @@ -133,6 +133,70 @@ 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, + ); + } + + const WHITE = { r: 255, g: 255, b: 255 }; + const BLACK = { r: 0, g: 0, b: 0 }; + + beforeAll(async () => { + await page.goto(new URL("fixtures/helpers.html", import.meta.url).href); + }); + + it("hides and removes elements", async () => { + await argosScreenshot(page, "helpers", { + fullPage: false, + hide: ["#hidden", ".does-not-exist", await page.$("#handle")], + // Removing wins over hiding. + remove: ["#removed", "#blackout", "#hidden"], + }); + + // Each row is 50px high: removed rows no longer take space, so the rows + // below them move up. + const pixels = await getPixels("helpers", [ + // `#handle` is hidden. + { x: 10, y: 25 }, + // `#last`. + { x: 10, y: 75 }, + ]); + expect(pixels).toEqual([WHITE, BLACK]); + + // The page is restored after the screenshot. + const attributes = await page.$$eval("div", (divs) => + divs.map((div) => div.getAttribute("data-visual-test")), + ); + expect(attributes).toEqual([null, null, null, "blackout", null]); + }); + }); + describe("with cjs version", () => { it("works", async () => { await argosScreenshotCjs(page, "cjs"); diff --git a/packages/vitest/e2e/screenshot.test.ts b/packages/vitest/e2e/screenshot.test.ts index 290e61b3..287e955d 100644 --- a/packages/vitest/e2e/screenshot.test.ts +++ b/packages/vitest/e2e/screenshot.test.ts @@ -182,6 +182,38 @@ test("supports custom CSS and full page", async () => { expect(attachments.length).toBe(2); }); +test("hides and removes elements", async () => { + mount( + `
` + + `
` + + `
`, + ); + const attachments = await argosScreenshot("helpers", { + argosCSS: "body { margin: 0; background: white; }", + hide: ["#hidden"], + remove: ["#removed"], + }); + const screenshot = attachments.find((a) => a.path.endsWith("helpers.png")); + expect(screenshot).toBeDefined(); + const image = await readPngImageData(screenshot!); + const getPixel = (x: number, y: number) => { + const index = (y * image.width + x) * 4; + return Array.from(image.data.slice(index, index + 3)); + }; + // `#hidden` keeps its space but is transparent. + expect(getPixel(10, 25)).toEqual([255, 255, 255]); + // `#removed` no longer takes space, so `#last` moves up. + expect(getPixel(10, 75)).toEqual([0, 0, 0]); + + // The page is restored after the screenshot. + expect( + document.getElementById("hidden")!.getAttribute("data-visual-test"), + ).toBeNull(); + expect( + document.getElementById("removed")!.getAttribute("data-visual-test"), + ).toBeNull(); +}); + test("grows the iframe to capture content wider than the viewport", async () => { mount( `
`, diff --git a/packages/vitest/src/options.ts b/packages/vitest/src/options.ts index 2baabd98..ba5643d7 100644 --- a/packages/vitest/src/options.ts +++ b/packages/vitest/src/options.ts @@ -55,6 +55,22 @@ export interface VitestScreenshotOptions { */ argosCSS?: string; + /** + * Selectors of the elements to hide from the screenshot. + * Hidden elements keep their space in the layout, like with the + * `data-visual-test="transparent"` helper. + * Selectors that match nothing are ignored. + */ + hide?: string[]; + + /** + * Selectors of the elements to remove from the screenshot. + * Removed elements no longer take space in the layout, like with the + * `data-visual-test="removed"` helper. + * Selectors that match nothing are ignored. + */ + remove?: string[]; + /** * Sensitivity threshold between 0 and 1. * The higher the threshold, the less sensitive the diff will be. From 8d11d981b0b3cb0efc84a24659ea1d513d75a282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 10:14:39 +0200 Subject: [PATCH 3/8] refactor: simplify the hide and remove helpers - Keep the strongest helper in `applyHelper` (removed over transparent) instead of relying on call order in every SDK, so each SDK applies all targets in one batch. - Restore attributes from the `argosHelpers` core plugin `beforeEach` cleanup instead of a wrapper registering it by hand. - Apply the helpers before `beforeEach`, so plugins measuring the layout see it as with the `data-visual-test` attributes. - Share the Puppeteer in-page callback, skip the Cypress command when there is nothing to apply, and drop the redundant Playwright `mask` type. Co-Authored-By: Claude Opus 5.5 --- .../browser/src/global/stabilization/index.ts | 17 +---- .../stabilization/plugins/argosHelpers.ts | 23 +++++-- packages/cypress/src/support.ts | 37 +++++------ packages/playwright/src/screenshot.ts | 10 +-- packages/playwright/src/util.ts | 38 +++++------- packages/puppeteer/src/index.ts | 62 +++++++++---------- 6 files changed, 86 insertions(+), 101 deletions(-) diff --git a/packages/browser/src/global/stabilization/index.ts b/packages/browser/src/global/stabilization/index.ts index c7629bfa..4972d103 100644 --- a/packages/browser/src/global/stabilization/index.ts +++ b/packages/browser/src/global/stabilization/index.ts @@ -1,12 +1,7 @@ import type { ViewportOption } from "../../viewport"; import { corePlugins, plugins, type PluginName } from "./plugins"; -import { - applyHelper as applyHelperToElements, - restoreHelpers, - type ArgosHelper, -} from "./plugins/argosHelpers"; -export type { ArgosHelper }; +export { applyHelper, type ArgosHelper } from "./plugins/argosHelpers"; type Cleanup = () => void; @@ -184,16 +179,6 @@ export function afterEach() { beforeEachCleanups.clear(); } -/** - * Apply an Argos helper to elements, as if they had the matching - * `data-visual-test` attribute, until `afterEach` runs. - * Lets the SDKs hide elements matched by their own selectors and locators. - */ -export function applyHelper(elements: Element[], helper: ArgosHelper) { - applyHelperToElements(elements, helper); - beforeEachCleanups.add(restoreHelpers); -} - /** * Get the stabilization state of the document. */ diff --git a/packages/browser/src/global/stabilization/plugins/argosHelpers.ts b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts index 724ee732..32255aeb 100644 --- a/packages/browser/src/global/stabilization/plugins/argosHelpers.ts +++ b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts @@ -4,10 +4,12 @@ import { injectGlobalStyles } from "../util"; const HELPER_ATTRIBUTE = "data-visual-test"; /** - * Argos helpers that can be applied to elements from the SDKs, as if the - * element had the matching `data-visual-test` attribute. + * Argos helpers that can be applied to elements from the SDKs, from the + * weakest to the strongest. */ -export type ArgosHelper = "transparent" | "removed"; +const HELPERS = ["transparent", "removed"] as const; + +export type ArgosHelper = (typeof HELPERS)[number]; /** * Original `data-visual-test` value of the elements a helper was applied to, @@ -16,13 +18,19 @@ export type ArgosHelper = "transparent" | "removed"; const originalHelpers = new Map(); /** - * Apply an Argos helper to elements. - * When an element receives several helpers, the last one wins. + * Apply an Argos helper to elements, as if they had the matching + * `data-visual-test` attribute, until `afterEach` runs. + * An element receiving several helpers keeps the strongest one. */ -export function applyHelper(elements: Iterable, helper: ArgosHelper) { +export function applyHelper(elements: Element[], helper: ArgosHelper) { for (const element of elements) { if (!originalHelpers.has(element)) { originalHelpers.set(element, element.getAttribute(HELPER_ATTRIBUTE)); + } else if ( + HELPERS.indexOf(element.getAttribute(HELPER_ATTRIBUTE) as ArgosHelper) > + HELPERS.indexOf(helper) + ) { + continue; } element.setAttribute(HELPER_ATTRIBUTE, helper); } @@ -70,4 +78,7 @@ export const plugin = { return injectGlobalStyles(styles, "argos-helpers"); }, + beforeEach() { + return restoreHelpers; + }, } satisfies Plugin; diff --git a/packages/cypress/src/support.ts b/packages/cypress/src/support.ts index 6f4ff58f..c8447986 100644 --- a/packages/cypress/src/support.ts +++ b/packages/cypress/src/support.ts @@ -1,7 +1,6 @@ import "cypress-wait-until"; import { type ArgosGlobal, - type ArgosHelper, resolveViewport, type StabilizationContext, type StabilizationPluginOptions, @@ -181,22 +180,24 @@ function beforeEach(options: ArgosScreenshotOptions) { * Apply the `hide` and `remove` options until `afterEach` runs, as if the * matched elements had the `data-visual-test` helper attribute. */ -function applyHelpers(options: ArgosScreenshotOptions) { - const helpers: [ArgosHelper, string[] | undefined][] = [ - ["transparent", options.hide], - // Applied last, so an element both hidden and removed is removed. - ["removed", options.remove], - ]; +function applyHelpers({ + hide = [], + remove = [], +}: Pick) { + if (hide.length === 0 && remove.length === 0) { + return; + } cy.window({ log: false }).then((window) => { - for (const [helper, selectors] of helpers) { - for (const selector of selectors ?? []) { - ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( - Cypress.$(selector, window.document).toArray(), - helper, - ); - } - } + const argos = (window as any).__ARGOS__ as ArgosGlobal; + const select = (selector: string) => + Cypress.$(selector, window.document).toArray(); + hide.forEach((selector) => + argos.applyHelper(select(selector), "transparent"), + ); + remove.forEach((selector) => + argos.applyHelper(select(selector), "removed"), + ); }); } @@ -248,8 +249,8 @@ Cypress.Commands.add( baseName, blackout, onAfterScreenshot, - hide: _hide, - remove: _remove, + hide, + remove, ...cypressOptions } = options; @@ -279,8 +280,8 @@ Cypress.Commands.add( function stabilizeAndScreenshot(name: string, baseNames: string[] | null) { waitForReadiness(options); + applyHelpers({ hide, remove }); const afterEach = beforeEach(options); - applyHelpers(options); waitForReadiness(options); const ref: any = {}; diff --git a/packages/playwright/src/screenshot.ts b/packages/playwright/src/screenshot.ts index ba6ded25..5ee58437 100644 --- a/packages/playwright/src/screenshot.ts +++ b/packages/playwright/src/screenshot.ts @@ -99,13 +99,6 @@ export type ArgosScreenshotOptions = { */ remove?: (string | Locator)[]; - /** - * Elements to mask with a box when the screenshot is taken, like with the - * `data-visual-test="blackout"` helper. - * @see https://playwright.dev/docs/api/class-page#page-screenshot-option-mask - */ - mask?: Locator[]; - /** * Disable hover effects by moving the mouse to the top-left corner of the document. * @default true @@ -289,8 +282,8 @@ export async function argosScreenshot( }); await waitForReadiness(handler, context); - const afterEach = await beforeEach(handler, context); await applyHelpers(handler, { hide, remove }); + const afterEach = await beforeEach(handler, context); await waitForReadiness(handler, context); const [snapshotPath] = await Promise.all([ @@ -338,6 +331,7 @@ export async function argosScreenshot( fullPage, animations: "disabled", ...playwrightOptions, + // Extend the `blackout` helper mask, don't replace it. mask: [handler.locator('[data-visual-test="blackout"]'), ...mask], }), writeMetadata(screenshotPath, metadata), diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 01949c14..d1266825 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -428,27 +428,23 @@ export async function applyHelpers( remove?: (string | Locator)[]; }, ) { - const helpers: [ArgosHelper, (string | Locator)[] | undefined][] = [ - ["transparent", options.hide], - // Applied last, so an element both hidden and removed is removed. - ["removed", options.remove], - ]; - for (const [helper, targets] of helpers) { - await Promise.all( - (targets ?? []).map((target) => { - const locator = - typeof target === "string" ? handler.locator(target) : target; - return locator.evaluateAll( - (elements, helper) => - ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( - elements, - helper, - ), - helper, - ); - }), - ); - } + const apply = (targets: (string | Locator)[] = [], helper: ArgosHelper) => + targets.map((target) => { + const locator = + typeof target === "string" ? handler.locator(target) : target; + return locator.evaluateAll( + (elements, helper) => + ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( + elements, + helper, + ), + helper, + ); + }); + await Promise.all([ + ...apply(options.hide, "transparent"), + ...apply(options.remove, "removed"), + ]); } /** diff --git a/packages/puppeteer/src/index.ts b/packages/puppeteer/src/index.ts index 116434a2..982341aa 100644 --- a/packages/puppeteer/src/index.ts +++ b/packages/puppeteer/src/index.ts @@ -235,40 +235,38 @@ async function beforeEach(page: Page, options: ArgosScreenshotOptions) { }; } +/** + * Apply an Argos helper in the page, to the elements matched by a selector or + * to the element of a handle. + */ +function applyHelperInPage(elements: Element | Element[], helper: ArgosHelper) { + ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( + [elements].flat(), + helper, + ); +} + /** * Apply the `hide` and `remove` options until `afterEach` runs, as if the * matched elements had the `data-visual-test` helper attribute. */ -async function applyHelpers(page: Page, options: ArgosScreenshotOptions) { - const helpers: [ArgosHelper, (string | ElementHandle)[] | undefined][] = [ - ["transparent", options.hide], - // Applied last, so an element both hidden and removed is removed. - ["removed", options.remove], - ]; - for (const [helper, targets] of helpers) { - await Promise.all( - (targets ?? []).map((target) => - typeof target === "string" - ? page.$$eval( - target, - (elements, helper) => - ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( - elements, - helper, - ), - helper, - ) - : target.evaluate( - (element, helper) => - ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( - [element], - helper, - ), - helper, - ), - ), +async function applyHelpers( + page: Page, + options: Pick, +) { + const apply = ( + targets: (string | ElementHandle)[] = [], + helper: ArgosHelper, + ) => + targets.map((target) => + typeof target === "string" + ? page.$$eval(target, applyHelperInPage, helper) + : target.evaluate(applyHelperInPage, helper), ); - } + await Promise.all([ + ...apply(options.hide, "transparent"), + ...apply(options.remove, "removed"), + ]); } /** @@ -327,8 +325,8 @@ export async function argosScreenshot( viewports, argosCSS: _argosCSS, baseName, - hide: _hide, - remove: _remove, + hide, + remove, ...puppeteerOptions } = options; @@ -418,8 +416,8 @@ export async function argosScreenshot( baseNames: string[] | null, ) { await waitForReadiness(page, options); + await applyHelpers(page, { hide, remove }); const afterEach = await beforeEach(page, options); - await applyHelpers(page, options); await waitForReadiness(page, options); const [screenshotPath, metadata] = await Promise.all([ From 29bbd244e7a1e8dccfe0743b7cbffaea7a3fa464 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 4/8] 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"); From 612ffe5640470e7c94064482c6ec894c9787456c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 10:11:27 +0200 Subject: [PATCH 5/8] feat(webdriverio): add `hide`, `remove` and selector masks Bring the WebdriverIO SDK to parity with the other SDKs for hiding regions. `argosScreenshot` now injects the Argos browser script and runs its core plugins only (`__argos__` class, `argosCSS`, `data-visual-test` helpers), so no stabilization waits are added. The page is restored after the screenshot. - `hide` and `remove` take WebdriverIO selectors and apply the `transparent` and `removed` helpers to the matched elements. Removing wins over hiding. - `mask` also takes selectors. Their areas are measured in the viewport, which is what `saveScreenshot` captures, and scaled by the device pixel ratio. Elements with `data-visual-test="blackout"` are always masked, as with Playwright and Cypress. Areas are clipped to the screenshot. - `argosCSS` injects custom CSS during the screenshot. Existing `data-visual-test` attributes now take effect, so screenshots of pages using them will change. The e2e runner resolves the built `@argos-ci/*` packages, as the browser script is only embedded at build time. Refs ARG-520 Co-Authored-By: Claude Opus 5.5 --- packages/webdriverio/fixtures/helpers.html | 37 +++ packages/webdriverio/package.json | 1 + packages/webdriverio/src/index.ts | 294 +++++++++++++++----- packages/webdriverio/test/specs/test.e2e.ts | 176 +++++++++++- packages/webdriverio/wdio.conf.ts | 2 + pnpm-lock.yaml | 3 + 6 files changed, 439 insertions(+), 74 deletions(-) create mode 100644 packages/webdriverio/fixtures/helpers.html diff --git a/packages/webdriverio/fixtures/helpers.html b/packages/webdriverio/fixtures/helpers.html new file mode 100644 index 00000000..135d2d56 --- /dev/null +++ b/packages/webdriverio/fixtures/helpers.html @@ -0,0 +1,37 @@ + + + + + Helpers page + + + + +
+
+
+
+
+
+
+ + diff --git a/packages/webdriverio/package.json b/packages/webdriverio/package.json index 6899ac0e..c1944277 100644 --- a/packages/webdriverio/package.json +++ b/packages/webdriverio/package.json @@ -48,6 +48,7 @@ "webdriverio": "^6 || ^7 || ^8 || ^9 || ^10" }, "dependencies": { + "@argos-ci/browser": "workspace:*", "sharp": "^0.35.5" }, "devDependencies": { diff --git a/packages/webdriverio/src/index.ts b/packages/webdriverio/src/index.ts index f5e36c32..944b8c97 100644 --- a/packages/webdriverio/src/index.ts +++ b/packages/webdriverio/src/index.ts @@ -1,52 +1,64 @@ import sharp from "sharp"; import { resolve } from "node:path"; -import { mkdir } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; +import { + type ArgosGlobal, + getGlobalScript, + type StabilizationContext, +} from "@argos-ci/browser"; type Size = { width: number; height: number }; type Coords = { x: number; y: number; width: number; height: number }; -// Function to create a mask with Sharp -async function createMask( - dimensions: Size, +/** + * Elements with this attribute are always masked. + */ +const BLACKOUT_SELECTOR = '[data-visual-test="blackout"]'; + +/** + * Clip an area to the image, on whole pixels. + * Returns `null` when the area is outside of the image. + */ +function clipArea(area: Coords, dimensions: Size): Coords | null { + const left = Math.max(0, Math.floor(area.x)); + const top = Math.max(0, Math.floor(area.y)); + const right = Math.min(dimensions.width, Math.ceil(area.x + area.width)); + const bottom = Math.min(dimensions.height, Math.ceil(area.y + area.height)); + if (right <= left || bottom <= top) { + return null; + } + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +/** + * Overlay areas of the screenshot with the mask color. + */ +async function maskScreenshot( + image: Buffer, areas: Coords[], maskColor: string, ) { - // Start with a black image - let mask = sharp({ - create: { - width: dimensions.width, - height: dimensions.height, - channels: 4, - background: { r: 0, g: 0, b: 0, alpha: 0 }, - }, - }); - - const areasImages = areas.map((coords) => ({ - input: Buffer.from( - '`, - ), - top: coords.y, - left: coords.x, - })); - - mask = mask.composite(areasImages); - - return mask.png().toBuffer(); -} - -async function applyMask(image: Buffer, mask: Buffer) { try { - return await sharp(image) - .composite([ - { - input: mask, - blend: "over", - }, - ]) + const screenshot = sharp(image); + const dimensions = await screenshot.metadata(); + return await screenshot + .composite( + areas + // Sharp can't composite an image larger than the screenshot. + .map((area) => clipArea(area, dimensions)) + .filter((coords) => coords !== null) + .map((coords) => ({ + input: Buffer.from( + '`, + ), + top: coords.y, + left: coords.x, + })), + ) .png() .toBuffer(); } catch (err) { @@ -54,19 +66,6 @@ async function applyMask(image: Buffer, mask: Buffer) { } } -async function getImageDimensions(buffer: Buffer): Promise { - try { - const imageInfo = await sharp(buffer).metadata(); - const { width, height } = imageInfo; - if (!width || !height) { - throw new Error("Dimensions not found."); - } - return { width, height }; - } catch (err) { - throw new Error("Error getting image dimensions", { cause: err }); - } -} - async function getScreenshotPath(name: string) { if (name.endsWith(".png")) { return name; @@ -79,37 +78,171 @@ async function getScreenshotPath(name: string) { export type ArgosScreenshotOptions = { /** - * Specify ares that should be masked when the screenshot is taken. - * Masked elements will be overlaid with a pink box #FF00FF (customized by maskColor) - * that completely covers its bounding box. + * Areas to mask when the screenshot is taken, as rectangles in pixels of the + * screenshot or as WebdriverIO selectors of the elements to mask. + * Masked areas are overlaid with a pink box #FF00FF (customized by `maskColor`). + * Elements with the `data-visual-test="blackout"` attribute are always masked. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(browser, "home", { + * mask: ["#clock", { x: 0, y: 0, width: 100, height: 100 }], + * }) */ - mask?: { x: number; y: number; width: number; height: number }[]; + mask?: (string | Coords)[]; + /** * Specify the color of the overlay box for masked elements, in CSS color format. * Default color is pink #FF00FF. * @default "#FF00FF" */ maskColor?: string; + + /** + * WebdriverIO selectors of the elements to hide from the screenshot. + * Hidden elements keep their space in the layout, like with the + * `data-visual-test="transparent"` helper. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(browser, "home", { hide: ["#clock", ".avatar"] }) + */ + hide?: string[]; + + /** + * WebdriverIO selectors of the elements to remove from the screenshot. + * Removed elements no longer take space in the layout, like with the + * `data-visual-test="removed"` helper. + * Selectors that match nothing are ignored. + * @example + * argosScreenshot(browser, "home", { remove: [".cookie-banner"] }) + */ + remove?: string[]; + + /** + * Custom CSS evaluated during the screenshot process. + */ + argosCSS?: string; }; /** - * Take a screenshot of the current page, optionally masking certain areas. - * @param browser A WebdriverIO `browser` object. - * @param name The name of the screenshot or the full path to the screenshot. - * @param options Options to customize the screenshot. - * @param options.mask - * Specify ares that should be masked when the screenshot is taken. - * Masked elements will be overlaid with a pink box #FF00FF (customized by maskColor) - * that completely covers its bounding box. - * @param options.maskColor - * Specify the color of the overlay box for masked elements, in CSS color format. - * Default color is pink #FF00FF. + * Find the elements matched by WebdriverIO selectors. + */ +async function findElements( + browser: WebdriverIO.Browser, + selectors: string[] = [], +) { + const results = await Promise.all( + selectors.map((selector) => browser.$$(selector)), + ); + return results.flatMap((elements) => [...elements]); +} + +/** + * Inject Argos script into the page. + * Checked in the page, to save a round trip to the browser. + */ +async function injectArgos(browser: WebdriverIO.Browser) { + await browser.execute( + `if (typeof window.__ARGOS__ === "undefined") {\n${getGlobalScript()}\n}`, + ); +} + +/** + * Prepare the page for the screenshot: add the `__argos__` class, inject + * `argosCSS`, enable the `data-visual-test` helpers and apply the `hide` and + * `remove` options. The stabilization plugins are not run. + * Returns a function restoring the page. + */ +async function setup( + browser: WebdriverIO.Browser, + options: ArgosScreenshotOptions, +) { + const context: StabilizationContext = { + argosCSS: options.argosCSS, + options: false, + }; + + const [, hidden, removed] = await Promise.all([ + injectArgos(browser), + findElements(browser, options.hide), + findElements(browser, options.remove), + ]); + + await browser.execute( + (context, hiddenCount, ...elements) => { + const argos = (window as any).__ARGOS__ as ArgosGlobal; + argos.beforeAll(context); + argos.applyHelper(elements.slice(0, hiddenCount), "transparent"); + argos.applyHelper(elements.slice(hiddenCount), "removed"); + argos.beforeEach(context); + }, + context, + hidden.length, + ...hidden, + ...removed, + ); + + return async () => { + await browser.execute(() => { + const argos = (window as any).__ARGOS__ as ArgosGlobal; + argos.afterEach(); + argos.afterAll(); + }); + }; +} + +/** + * Get the areas covered by the elements to mask, in pixels of the screenshot. + * The screenshot only captures the viewport. + */ +async function getMaskedElementsAreas( + browser: WebdriverIO.Browser, + selectors: string[], +): Promise { + const elements = await findElements(browser, selectors); + if (elements.length === 0) { + return []; + } + return browser.execute( + (...elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + const ratio = window.devicePixelRatio; + return { + x: rect.x * ratio, + y: rect.y * ratio, + width: rect.width * ratio, + height: rect.height * ratio, + }; + }), + ...elements, + ); +} + +/** + * Take a screenshot of the current page, optionally hiding, removing or + * masking some elements. + * The `data-visual-test` helper attributes are applied. + * + * @example + * argosScreenshot(browser, "my-screenshot") + * @see https://argos-ci.com/docs/reference/webdriverio */ export async function argosScreenshot( + /** + * WebdriverIO `browser` object. + */ browser: WebdriverIO.Browser, + /** + * Name of the screenshot or full path to the screenshot. + */ name: string, - { mask, maskColor = "#FF00FF" }: ArgosScreenshotOptions = {}, + /** + * Options for the screenshot. + */ + options: ArgosScreenshotOptions = {}, ) { + const { mask = [], maskColor = "#FF00FF" } = options; + if (!browser) { throw new Error("A WebdriverIO `browser` object is required."); } @@ -117,16 +250,31 @@ export async function argosScreenshot( throw new Error("The `name` argument is required."); } + const teardown = await setup(browser, options); + + // Measured once the elements are hidden or removed, as the layout may change. + const maskedElementsAreas = await getMaskedElementsAreas(browser, [ + BLACKOUT_SELECTOR, + ...mask.filter((area) => typeof area === "string"), + ]); + const areas = [ + ...mask.filter((area) => typeof area !== "string"), + ...maskedElementsAreas, + ]; + const screenshotPath = await getScreenshotPath(name); const imageBuffer = await browser.saveScreenshot(screenshotPath); - if (!mask) { + if (areas.length === 0) { + await teardown(); return imageBuffer; } - const dimensions = await getImageDimensions(imageBuffer); - const maskBuffer = await createMask(dimensions, mask, maskColor); - const maskedBuffer = await applyMask(imageBuffer, maskBuffer); - await sharp(maskedBuffer).toFile(screenshotPath); + // Restore the page while masking the screenshot. + const [maskedBuffer] = await Promise.all([ + maskScreenshot(imageBuffer, areas, maskColor), + teardown(), + ]); + await writeFile(screenshotPath, maskedBuffer); return maskedBuffer; } diff --git a/packages/webdriverio/test/specs/test.e2e.ts b/packages/webdriverio/test/specs/test.e2e.ts index 57a71083..b6502f23 100644 --- a/packages/webdriverio/test/specs/test.e2e.ts +++ b/packages/webdriverio/test/specs/test.e2e.ts @@ -1,7 +1,33 @@ -import { browser } from "@wdio/globals"; +import { browser, expect } from "@wdio/globals"; +import sharp from "sharp"; import { argosScreenshot } from "../../src/index.js"; const url = new URL("../../fixtures/dummy.html", import.meta.url).href; +const helpersUrl = new URL("../../fixtures/helpers.html", import.meta.url).href; + +/** + * Read the color of some pixels of a screenshot. + */ +async function getPixels( + screenshotName: string, + points: { x: number; y: number }[], +) { + const { data, info } = await sharp(`screenshots/argos/${screenshotName}.png`) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + return points.map(({ x, y }) => { + const index = (y * info.width + x) * info.channels; + return "#" + data.subarray(index, index + 3).toString("hex"); + }); +} + +const WHITE = "#ffffff"; +const RED = "#ff0000"; +const LIME = "#00ff00"; +const CYAN = "#00ffff"; +const BLACK = "#000000"; +const PINK = "#ff00ff"; it("take a simple screenshot of the page", async () => { await browser.url(url); @@ -26,3 +52,151 @@ describe("with a name ending with `.png`", () => { await argosScreenshot(browser, "screenshots/fullpath.png"); }); }); + +// Each row of the helpers page is 50px high. +describe("helpers", () => { + beforeEach(async () => { + await browser.url(helpersUrl); + }); + + it("applies the `data-visual-test` helpers", async () => { + await argosScreenshot(browser, "helpers-attributes"); + + const pixels = await getPixels("helpers-attributes", [ + // `#hidden` + { x: 10, y: 25 }, + // `#transparent` + { x: 10, y: 125 }, + // `#blackout` + { x: 10, y: 175 }, + ]); + expect(pixels).toEqual([RED, WHITE, PINK]); + + // The page is restored after the screenshot. + const state = await browser.execute(() => ({ + className: document.documentElement.className, + opacity: getComputedStyle(document.getElementById("transparent")!) + .opacity, + })); + expect(state).toEqual({ className: "", opacity: "1" }); + }); + + it("hides and removes elements", async () => { + await argosScreenshot(browser, "helpers-hide-remove", { + hide: ["#hidden", "#masked", ".does-not-exist"], + // Removing wins over hiding. + remove: ["#removed", "#hidden"], + }); + + // Removed rows no longer take space, so the rows below them move up. + const pixels = await getPixels("helpers-hide-remove", [ + // `#blackout` + { x: 10, y: 75 }, + // `#masked` is hidden. + { x: 10, y: 125 }, + // `#last` + { x: 10, y: 175 }, + ]); + expect(pixels).toEqual([PINK, WHITE, BLACK]); + + // The page is restored after the screenshot. + const attributes = await browser.execute(() => + Array.from(document.querySelectorAll("div"), (div) => + div.getAttribute("data-visual-test"), + ), + ); + expect(attributes).toEqual([ + null, + null, + "transparent", + "blackout", + null, + null, + ]); + }); + + it("masks elements matched by selectors", async () => { + await argosScreenshot(browser, "helpers-mask-selectors", { + mask: [ + "#masked", + ".does-not-exist", + { x: 0, y: 0, width: 20, height: 20 }, + ], + maskColor: LIME, + }); + + const pixels = await getPixels("helpers-mask-selectors", [ + // Rectangle + { x: 10, y: 10 }, + // `#hidden` + { x: 30, y: 25 }, + // `#blackout` + { x: 10, y: 175 }, + // `#masked` + { x: 10, y: 225 }, + // `#last` + { x: 10, y: 275 }, + ]); + expect(pixels).toEqual([LIME, RED, LIME, LIME, BLACK]); + }); + + it("masks elements of a scrolled page", async () => { + await browser.execute(() => window.scrollTo(0, 100)); + await argosScreenshot(browser, "helpers-mask-scrolled", { + mask: ["#masked"], + }); + + // The screenshot only captures the viewport. + const pixels = await getPixels("helpers-mask-scrolled", [ + // `#blackout` + { x: 10, y: 75 }, + // `#masked` + { x: 10, y: 125 }, + // `#last` + { x: 10, y: 175 }, + ]); + expect(pixels).toEqual([PINK, PINK, BLACK]); + }); + + it("masks elements on high density screens", async () => { + const viewport = await browser.execute(() => ({ + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + })); + await browser.setViewport({ ...viewport, devicePixelRatio: 2 }); + try { + await argosScreenshot(browser, "helpers-mask-dpr", { + mask: ["#masked"], + }); + } finally { + await browser.setViewport(viewport); + } + + const pixels = await getPixels("helpers-mask-dpr", [ + // `#blackout` + { x: 20, y: 350 }, + // `#masked` + { x: 20, y: 450 }, + // `#last` + { x: 20, y: 550 }, + ]); + expect(pixels).toEqual([PINK, PINK, BLACK]); + }); + + it("injects `argosCSS`", async () => { + await argosScreenshot(browser, "helpers-argos-css", { + argosCSS: "#hidden { background: cyan !important; }", + }); + + const pixels = await getPixels("helpers-argos-css", [{ x: 10, y: 25 }]); + expect(pixels).toEqual([CYAN]); + + // The page is restored after the screenshot. + const background = await browser.execute( + () => + getComputedStyle(document.getElementById("hidden")!).backgroundColor, + ); + expect(background).toBe("rgb(255, 0, 0)"); + }); +}); diff --git a/packages/webdriverio/wdio.conf.ts b/packages/webdriverio/wdio.conf.ts index a69807c2..6de86ce0 100644 --- a/packages/webdriverio/wdio.conf.ts +++ b/packages/webdriverio/wdio.conf.ts @@ -8,6 +8,8 @@ export const config: Options.Testrunner & { // ==================== // WebdriverIO supports running e2e tests as well as unit and component tests. runner: "local", + // Resolve the built `@argos-ci/*` packages, which embed the Argos browser script. + tsConfigPath: "../../tsconfig.build.json", // // ================== diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a30fcdc7..125b51a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -429,6 +429,9 @@ importers: packages/webdriverio: dependencies: + '@argos-ci/browser': + specifier: workspace:* + version: link:../browser sharp: specifier: ^0.35.5 version: 0.35.5(@types/node@26.6.3) From 6ad557f9a9c5496f782dee7611c3e6b9a047946f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 11:06:20 +0200 Subject: [PATCH 6/8] refactor: restore masks like helpers and share the Puppeteer mask callback - Remove the mask boxes from the `argosHelpers` core plugin `beforeEach` cleanup, like the helpers, instead of registering the cleanup by hand. - Share the Puppeteer in-page mask callback between selectors and element handles, like `applyHelpers` does. Co-Authored-By: Claude Opus 5.5 --- packages/browser/src/global/mask.ts | 7 +++++-- .../browser/src/global/stabilization/index.ts | 13 +----------- .../stabilization/plugins/argosHelpers.ts | 7 ++++++- packages/puppeteer/src/index.ts | 21 +++++++++---------- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/browser/src/global/mask.ts b/packages/browser/src/global/mask.ts index db8952e5..11d0494a 100644 --- a/packages/browser/src/global/mask.ts +++ b/packages/browser/src/global/mask.ts @@ -92,9 +92,12 @@ function updateBoxes(overlay: MaskOverlay) { } /** - * Cover elements with a box of the given color. + * 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 maskElements(elements: Iterable, color: string) { +export function maskElements(elements: Element[], color: string) { overlay ??= createOverlay(); for (const element of elements) { const box = document.createElement("div"); diff --git a/packages/browser/src/global/stabilization/index.ts b/packages/browser/src/global/stabilization/index.ts index ccecbd62..9a7482c4 100644 --- a/packages/browser/src/global/stabilization/index.ts +++ b/packages/browser/src/global/stabilization/index.ts @@ -1,8 +1,8 @@ import type { ViewportOption } from "../../viewport"; -import { maskElements, unmaskElements } from "../mask"; import { corePlugins, plugins, type PluginName } from "./plugins"; export { applyHelper, type ArgosHelper } from "./plugins/argosHelpers"; +export { maskElements as mask } from "../mask"; type Cleanup = () => void; @@ -180,17 +180,6 @@ 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/browser/src/global/stabilization/plugins/argosHelpers.ts b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts index 32255aeb..7c5022b7 100644 --- a/packages/browser/src/global/stabilization/plugins/argosHelpers.ts +++ b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts @@ -1,4 +1,5 @@ import type { Plugin } from ".."; +import { unmaskElements } from "../../mask"; import { injectGlobalStyles } from "../util"; const HELPER_ATTRIBUTE = "data-visual-test"; @@ -79,6 +80,10 @@ export const plugin = { return injectGlobalStyles(styles, "argos-helpers"); }, beforeEach() { - return restoreHelpers; + // Undo the helpers and masks applied by the SDK for this screenshot. + return () => { + restoreHelpers(); + unmaskElements(); + }; }, } satisfies Plugin; diff --git a/packages/puppeteer/src/index.ts b/packages/puppeteer/src/index.ts index 1da74f1c..e1ecf6c9 100644 --- a/packages/puppeteer/src/index.ts +++ b/packages/puppeteer/src/index.ts @@ -285,6 +285,14 @@ async function applyHelpers( ]); } +/** + * Mask in the page the elements matched by a selector or the element of a + * handle. + */ +function maskInPage(elements: Element | Element[], color: string) { + ((window as any).__ARGOS__ as ArgosGlobal).mask([elements].flat(), color); +} + /** * Cover the `data-visual-test="blackout"` helper and the `mask` option with * boxes until `afterEach` runs, since Puppeteer has no native mask. @@ -299,17 +307,8 @@ async function applyMask( 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, - ), + ? page.$$eval(target, maskInPage, maskColor) + : target.evaluate(maskInPage, maskColor), ), ); } From 4ff968f7dac02a3e97ced7ce429bc13d257bd68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 11:06:20 +0200 Subject: [PATCH 7/8] feat: accept selectors in the Playwright and Vitest `mask` option `mask` now takes string selectors as well as `Locator`s in the Playwright SDK, like `hide`, `remove` and `element`, and like `mask` in the Puppeteer and WebdriverIO SDKs. Vitest gets `mask` and `maskColor` in its per-call options, so a browser test can mask elements too. Co-Authored-By: Claude Opus 5.5 --- packages/playwright/e2e.spec.ts | 2 +- packages/playwright/src/screenshot.ts | 17 +++++++++++++++-- packages/playwright/src/util.ts | 17 +++++++++++------ packages/vitest/e2e/screenshot.test.ts | 10 +++++++--- packages/vitest/src/options.ts | 13 +++++++++++++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/playwright/e2e.spec.ts b/packages/playwright/e2e.spec.ts index 374e0ad0..c190835d 100644 --- a/packages/playwright/e2e.spec.ts +++ b/packages/playwright/e2e.spec.ts @@ -200,7 +200,7 @@ test.describe("#argosScreenshot", () => { fullPage: false, hide: ["#hidden", ".does-not-exist"], remove: [page.locator("#removed")], - mask: [page.locator("#masked")], + mask: ["#masked", page.locator(".does-not-exist")], }); // Each row is 50px high: `#removed` no longer takes space, so the rows diff --git a/packages/playwright/src/screenshot.ts b/packages/playwright/src/screenshot.ts index 5ee58437..a95f543a 100644 --- a/packages/playwright/src/screenshot.ts +++ b/packages/playwright/src/screenshot.ts @@ -39,6 +39,7 @@ import { screenshotToSnapshotPath, setViewportSize, takeCaptureIndex, + toLocator, waitForReadiness, } from "./util"; import { writeFile } from "node:fs/promises"; @@ -49,7 +50,7 @@ type LocatorOptions = Parameters[1]; type ScreenshotOptions< TBase extends PageScreenshotOptions | LocatorScreenshotOptions, -> = Omit; +> = Omit; export type ArgosScreenshotOptions = { /** @@ -99,6 +100,16 @@ export type ArgosScreenshotOptions = { */ remove?: (string | Locator)[]; + /** + * Elements to cover with a box when the screenshot is taken, as `Locator`s + * or string selectors, like with the `data-visual-test="blackout"` helper. + * Selectors that match nothing are ignored. + * @see https://playwright.dev/docs/api/class-page#page-screenshot-option-mask + * @example + * argosScreenshot(page, "home", { mask: ["#map", page.getByRole("img")] }) + */ + mask?: (string | Locator)[]; + /** * Disable hover effects by moving the mouse to the top-left corner of the document. * @default true @@ -332,7 +343,9 @@ export async function argosScreenshot( animations: "disabled", ...playwrightOptions, // Extend the `blackout` helper mask, don't replace it. - mask: [handler.locator('[data-visual-test="blackout"]'), ...mask], + mask: ['[data-visual-test="blackout"]', ...mask].map((target) => + toLocator(handler, target), + ), }), writeMetadata(screenshotPath, metadata), ]); diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index d1266825..564c2754 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -417,6 +417,13 @@ export async function beforeEach( }; } +/** + * Get the `Locator` of a string selector, or the `Locator` itself. + */ +export function toLocator(handler: Page | Frame, target: string | Locator) { + return typeof target === "string" ? handler.locator(target) : target; +} + /** * Apply the `hide` and `remove` options until `afterEach` runs, as if the * matched elements had the `data-visual-test` helper attribute. @@ -429,18 +436,16 @@ export async function applyHelpers( }, ) { const apply = (targets: (string | Locator)[] = [], helper: ArgosHelper) => - targets.map((target) => { - const locator = - typeof target === "string" ? handler.locator(target) : target; - return locator.evaluateAll( + targets.map((target) => + toLocator(handler, target).evaluateAll( (elements, helper) => ((window as any).__ARGOS__ as ArgosGlobal).applyHelper( elements, helper, ), helper, - ); - }); + ), + ); await Promise.all([ ...apply(options.hide, "transparent"), ...apply(options.remove, "removed"), diff --git a/packages/vitest/e2e/screenshot.test.ts b/packages/vitest/e2e/screenshot.test.ts index 287e955d..5cccc963 100644 --- a/packages/vitest/e2e/screenshot.test.ts +++ b/packages/vitest/e2e/screenshot.test.ts @@ -182,16 +182,19 @@ test("supports custom CSS and full page", async () => { expect(attachments.length).toBe(2); }); -test("hides and removes elements", async () => { +test("hides, removes and masks elements", async () => { mount( `
` + `
` + + `
` + `
`, ); const attachments = await argosScreenshot("helpers", { argosCSS: "body { margin: 0; background: white; }", hide: ["#hidden"], remove: ["#removed"], + mask: ["#masked"], + maskColor: "#00ffff", }); const screenshot = attachments.find((a) => a.path.endsWith("helpers.png")); expect(screenshot).toBeDefined(); @@ -202,8 +205,9 @@ test("hides and removes elements", async () => { }; // `#hidden` keeps its space but is transparent. expect(getPixel(10, 25)).toEqual([255, 255, 255]); - // `#removed` no longer takes space, so `#last` moves up. - expect(getPixel(10, 75)).toEqual([0, 0, 0]); + // `#removed` no longer takes space, so the rows below it move up. + expect(getPixel(10, 75)).toEqual([0, 255, 255]); + expect(getPixel(10, 125)).toEqual([0, 0, 0]); // The page is restored after the screenshot. expect( diff --git a/packages/vitest/src/options.ts b/packages/vitest/src/options.ts index ba5643d7..1816d62f 100644 --- a/packages/vitest/src/options.ts +++ b/packages/vitest/src/options.ts @@ -71,6 +71,19 @@ export interface VitestScreenshotOptions { */ remove?: string[]; + /** + * Selectors of the elements to cover with a box when the screenshot is + * taken, like with the `data-visual-test="blackout"` helper. + * Selectors that match nothing are ignored. + */ + mask?: string[]; + + /** + * Color of the box covering the masked elements, in CSS color format. + * @default "#FF00FF" + */ + maskColor?: string; + /** * Sensitivity threshold between 0 and 1. * The higher the threshold, the less sensitive the diff will be. From 5c5b175596dd996c7a40f61565c5888f49e2cb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 11:10:19 +0200 Subject: [PATCH 8/8] fix(webdriverio): keep capturing native apps without the Argos script `argosScreenshot` now injects the Argos script to apply the helpers, which fails in an Appium native app context where no script can run. Detect it (`isNativeContext`, or `getContext()` before WebdriverIO 9) and capture the screen as is, with rectangle masks. The options needing a web page throw a clear error there. Co-Authored-By: Claude Opus 5.5 --- packages/webdriverio/package.json | 2 + packages/webdriverio/src/index.test.ts | 99 ++++++++++++++++++++++++++ packages/webdriverio/src/index.ts | 45 ++++++++++-- pnpm-lock.yaml | 3 + 4 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 packages/webdriverio/src/index.test.ts diff --git a/packages/webdriverio/package.json b/packages/webdriverio/package.json index c1944277..a790b62e 100644 --- a/packages/webdriverio/package.json +++ b/packages/webdriverio/package.json @@ -60,6 +60,7 @@ "@wdio/spec-reporter": "^10.0.0", "@wdio/types": "^10.0.0", "tsx": "^4.23.15", + "vitest": "catalog:", "webdriverio": "^10.0.0" }, "scripts": { @@ -68,6 +69,7 @@ "argos-upload": "pnpm exec argos upload screenshots --build-name \"argos-webdriverio-e2e-node-$NODE_VERSION-$OS\"", "e2e": "pnpm run test-e2e && pnpm run argos-upload", "check-types": "tsc", + "test": "vitest run src", "check-format": "prettier --check --ignore-unknown --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .", "lint": "eslint ." } diff --git a/packages/webdriverio/src/index.test.ts b/packages/webdriverio/src/index.test.ts new file mode 100644 index 00000000..5976495a --- /dev/null +++ b/packages/webdriverio/src/index.test.ts @@ -0,0 +1,99 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { argosScreenshot } from "./index"; + +/** + * A white 4x4 PNG. + */ +const PNG = await sharp({ + create: { + width: 4, + height: 4, + channels: 3, + background: { r: 255, g: 255, b: 255 }, + }, +}) + .png() + .toBuffer(); + +/** + * A WebdriverIO `browser` driving a native app, where no script can run. + */ +function createNativeAppBrowser(context: { + isNativeContext?: boolean; + getContext?: () => Promise; +}) { + return { + isMobile: true, + ...context, + saveScreenshot: vi.fn(async () => PNG), + execute: vi.fn(async () => { + throw new Error("No script can run in a native app."); + }), + } as unknown as WebdriverIO.Browser; +} + +describe("argosScreenshot in a native app", () => { + let path: string; + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "argos-webdriverio-")); + path = join(dir, "native.png"); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("captures the screen as is", async () => { + const browser = createNativeAppBrowser({ isNativeContext: true }); + const buffer = await argosScreenshot(browser, path); + expect(buffer).toBe(PNG); + expect(browser.saveScreenshot).toHaveBeenCalledWith(path); + expect(browser.execute).not.toHaveBeenCalled(); + }); + + it("detects the native context before WebdriverIO 9", async () => { + const browser = createNativeAppBrowser({ + getContext: async () => "NATIVE_APP", + }); + await argosScreenshot(browser, path); + expect(browser.execute).not.toHaveBeenCalled(); + }); + + it("masks rectangles", async () => { + const browser = createNativeAppBrowser({ isNativeContext: true }); + const buffer = await argosScreenshot(browser, path, { + mask: [{ x: 0, y: 0, width: 2, height: 2 }], + }); + const { data, info } = await sharp(buffer) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const getPixel = (x: number, y: number) => { + const index = (y * info.width + x) * info.channels; + return data.subarray(index, index + 3).toString("hex"); + }; + expect([getPixel(1, 1), getPixel(3, 3)]).toEqual(["ff00ff", "ffffff"]); + expect(browser.execute).not.toHaveBeenCalled(); + }); + + it("rejects the options that need a web page", async () => { + const browser = createNativeAppBrowser({ isNativeContext: true }); + for (const options of [ + { hide: ["~clock"] }, + { remove: ["~banner"] }, + { argosCSS: "* { color: red }" }, + { mask: ["~map"] }, + ]) { + await expect(argosScreenshot(browser, path, options)).rejects.toThrow( + "not supported in a native app context", + ); + } + expect(browser.saveScreenshot).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webdriverio/src/index.ts b/packages/webdriverio/src/index.ts index 944b8c97..29afbca4 100644 --- a/packages/webdriverio/src/index.ts +++ b/packages/webdriverio/src/index.ts @@ -190,6 +190,20 @@ async function setup( }; } +/** + * Check if the session drives a native mobile app, where no script can run. + */ +async function checkIsNativeContext(browser: WebdriverIO.Browser) { + if (!browser.isMobile) { + return false; + } + // `isNativeContext` is only available from WebdriverIO 9. + if (typeof browser.isNativeContext === "boolean") { + return browser.isNativeContext; + } + return (await browser.getContext()) === "NATIVE_APP"; +} + /** * Get the areas covered by the elements to mask, in pixels of the screenshot. * The screenshot only captures the viewport. @@ -250,13 +264,30 @@ export async function argosScreenshot( throw new Error("The `name` argument is required."); } - const teardown = await setup(browser, options); + const selectorMasks = mask.filter((area) => typeof area === "string"); + + // A native app has no page to run the Argos script in: capture it as is. + const isNativeContext = await checkIsNativeContext(browser); + if ( + isNativeContext && + (options.hide || + options.remove || + options.argosCSS || + selectorMasks.length > 0) + ) { + throw new Error( + "The `hide`, `remove` and `argosCSS` options and selectors in `mask` are not supported in a native app context.", + ); + } + const teardown = isNativeContext ? null : await setup(browser, options); // Measured once the elements are hidden or removed, as the layout may change. - const maskedElementsAreas = await getMaskedElementsAreas(browser, [ - BLACKOUT_SELECTOR, - ...mask.filter((area) => typeof area === "string"), - ]); + const maskedElementsAreas = isNativeContext + ? [] + : await getMaskedElementsAreas(browser, [ + BLACKOUT_SELECTOR, + ...selectorMasks, + ]); const areas = [ ...mask.filter((area) => typeof area !== "string"), ...maskedElementsAreas, @@ -266,14 +297,14 @@ export async function argosScreenshot( const imageBuffer = await browser.saveScreenshot(screenshotPath); if (areas.length === 0) { - await teardown(); + await teardown?.(); return imageBuffer; } // Restore the page while masking the screenshot. const [maskedBuffer] = await Promise.all([ maskScreenshot(imageBuffer, areas, maskColor), - teardown(), + teardown?.(), ]); await writeFile(screenshotPath, maskedBuffer); return maskedBuffer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 125b51a4..5d7f7b90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,6 +460,9 @@ importers: tsx: specifier: ^4.23.15 version: 4.23.15 + vitest: + specifier: 'catalog:' + version: 5.0.2(@types/node@26.6.3)(@vitest/browser-playwright@5.0.2)(@vitest/coverage-v8@5.0.2)(@vitest/ui@5.0.2)(msw@2.15.0(@types/node@26.6.3))(vite@7.3.6(@types/node@26.6.3)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) webdriverio: specifier: ^10.0.0 version: 10.0.0(puppeteer-core@25.12.0(yauzl@3.4.0))(supports-color@8.1.1)(yauzl@3.4.0)