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)