diff --git a/packages/browser/src/global/index.ts b/packages/browser/src/global/index.ts index 1120dd13..b3981b10 100644 --- a/packages/browser/src/global/index.ts +++ b/packages/browser/src/global/index.ts @@ -4,6 +4,8 @@ import { afterAll, beforeEach, afterEach, + applyHelper, + mask, waitFor, getWaitFailureExplanations, } from "./stabilization"; @@ -13,6 +15,8 @@ const ArgosGlobal = { afterAll, 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..11d0494a --- /dev/null +++ b/packages/browser/src/global/mask.ts @@ -0,0 +1,122 @@ +/** + * 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, 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: Element[], 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 6e6f2233..9a7482c4 100644 --- a/packages/browser/src/global/stabilization/index.ts +++ b/packages/browser/src/global/stabilization/index.ts @@ -1,6 +1,9 @@ import type { ViewportOption } from "../../viewport"; import { corePlugins, plugins, type PluginName } from "./plugins"; +export { applyHelper, type ArgosHelper } from "./plugins/argosHelpers"; +export { maskElements as mask } from "../mask"; + type Cleanup = () => void; export interface Plugin { diff --git a/packages/browser/src/global/stabilization/plugins/argosHelpers.ts b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts index 15e57467..7c5022b7 100644 --- a/packages/browser/src/global/stabilization/plugins/argosHelpers.ts +++ b/packages/browser/src/global/stabilization/plugins/argosHelpers.ts @@ -1,6 +1,57 @@ import type { Plugin } from ".."; +import { unmaskElements } from "../../mask"; import { injectGlobalStyles } from "../util"; +const HELPER_ATTRIBUTE = "data-visual-test"; + +/** + * Argos helpers that can be applied to elements from the SDKs, from the + * weakest to the strongest. + */ +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, + * `null` when the element had no attribute. + */ +const originalHelpers = new Map(); + +/** + * 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: 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); + } +} + +/** + * 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. */ @@ -28,4 +79,11 @@ export const plugin = { return injectGlobalStyles(styles, "argos-helpers"); }, + beforeEach() { + // Undo the helpers and masks applied by the SDK for this screenshot. + return () => { + restoreHelpers(); + unmaskElements(); + }; + }, } satisfies Plugin; 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..c8447986 100644 --- a/packages/cypress/src/support.ts +++ b/packages/cypress/src/support.ts @@ -32,6 +32,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 +176,31 @@ 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({ + hide = [], + remove = [], +}: Pick) { + if (hide.length === 0 && remove.length === 0) { + return; + } + + cy.window({ log: false }).then((window) => { + 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"), + ); + }); +} + function getRetries(value: unknown) { if (typeof value !== "number" || !Number.isFinite(value)) { return 0; @@ -204,6 +249,8 @@ Cypress.Commands.add( baseName, blackout, onAfterScreenshot, + hide, + remove, ...cypressOptions } = options; @@ -233,6 +280,7 @@ Cypress.Commands.add( function stabilizeAndScreenshot(name: string, baseNames: string[] | null) { waitForReadiness(options); + applyHelpers({ hide, remove }); const afterEach = beforeEach(options); waitForReadiness(options); diff --git a/packages/playwright/e2e.spec.ts b/packages/playwright/e2e.spec.ts index 5225c215..c190835d 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: ["#masked", page.locator(".does-not-exist")], + }); + + // 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 b439176f..a95f543a 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, @@ -38,6 +39,7 @@ import { screenshotToSnapshotPath, setViewportSize, takeCaptureIndex, + toLocator, waitForReadiness, } from "./util"; import { writeFile } from "node:fs/promises"; @@ -48,7 +50,7 @@ type LocatorOptions = Parameters[1]; type ScreenshotOptions< TBase extends PageScreenshotOptions | LocatorScreenshotOptions, -> = Omit; +> = Omit; export type ArgosScreenshotOptions = { /** @@ -76,6 +78,38 @@ 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 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 @@ -180,6 +214,9 @@ export async function argosScreenshot( ariaSnapshot, disableHover = true, baseName, + hide, + remove, + mask = [], ...playwrightOptions } = options; @@ -256,6 +293,7 @@ export async function argosScreenshot( }); await waitForReadiness(handler, context); + await applyHelpers(handler, { hide, remove }); const afterEach = await beforeEach(handler, context); await waitForReadiness(handler, context); @@ -302,9 +340,12 @@ export async function argosScreenshot( path: screenshotPath, type: "png", fullPage, - mask: [handler.locator('[data-visual-test="blackout"]')], animations: "disabled", ...playwrightOptions, + // Extend the `blackout` helper mask, don't replace it. + 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 c40d9afc..564c2754 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,41 @@ 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. + */ +export async function applyHelpers( + handler: Page | Frame, + options: { + hide?: (string | Locator)[]; + remove?: (string | Locator)[]; + }, +) { + const apply = (targets: (string | Locator)[] = [], helper: ArgosHelper) => + 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"), + ]); +} + /** * 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/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 c054cc33..e1ecf6c9 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,42 @@ 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)[]; + + /** + * 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 @@ -214,6 +251,68 @@ 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: 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"), + ]); +} + +/** + * 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. + */ +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, maskInPage, maskColor) + : target.evaluate(maskInPage, maskColor), + ), + ); +} + /** * Wait for the UI to be ready before taking the screenshot. */ @@ -270,6 +369,10 @@ export async function argosScreenshot( viewports, argosCSS: _argosCSS, baseName, + hide, + remove, + mask, + maskColor, ...puppeteerOptions } = options; @@ -359,8 +462,11 @@ export async function argosScreenshot( baseNames: string[] | null, ) { await waitForReadiness(page, options); + 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 79d4f454..37b56b5e 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,176 @@ describe("argosScreenshot", () => { }); }); + /** + * 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 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); + }); + + 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 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"); diff --git a/packages/vitest/e2e/screenshot.test.ts b/packages/vitest/e2e/screenshot.test.ts index 290e61b3..5cccc963 100644 --- a/packages/vitest/e2e/screenshot.test.ts +++ b/packages/vitest/e2e/screenshot.test.ts @@ -182,6 +182,42 @@ test("supports custom CSS and full page", async () => { expect(attachments.length).toBe(2); }); +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(); + 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 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( + 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..1816d62f 100644 --- a/packages/vitest/src/options.ts +++ b/packages/vitest/src/options.ts @@ -55,6 +55,35 @@ 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[]; + + /** + * 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. 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..a790b62e 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": { @@ -59,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": { @@ -67,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 f5e36c32..29afbca4 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,185 @@ 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(); + }); + }; +} + +/** + * 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. + */ +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 +264,48 @@ export async function argosScreenshot( throw new Error("The `name` argument is required."); } + 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 = isNativeContext + ? [] + : await getMaskedElementsAreas(browser, [ + BLACKOUT_SELECTOR, + ...selectorMasks, + ]); + 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..5d7f7b90 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) @@ -457,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)