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)