Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/browser/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
afterAll,
beforeEach,
afterEach,
applyHelper,
mask,
waitFor,
getWaitFailureExplanations,
} from "./stabilization";
Expand All @@ -13,6 +15,8 @@ const ArgosGlobal = {
afterAll,
beforeEach,
afterEach,
applyHelper,
mask,
waitFor,
getWaitFailureExplanations,
getColorScheme: () => getColorScheme(),
Expand Down
122 changes: 122 additions & 0 deletions packages/browser/src/global/mask.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
) {
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;
}
3 changes: 3 additions & 0 deletions packages/browser/src/global/stabilization/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions packages/browser/src/global/stabilization/plugins/argosHelpers.ts
Original file line number Diff line number Diff line change
@@ -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<Element, string | null>();

/**
* 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.
*/
Expand Down Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export type {
PluginOptions as StabilizationPluginOptions,
Context as StabilizationContext,
WaitForBackgroundImagesOptions,
ArgosHelper,
} from "./global/stabilization";
28 changes: 28 additions & 0 deletions packages/cypress/cypress/e2e/argosScreenshot.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
});
});
48 changes: 48 additions & 0 deletions packages/cypress/src/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<ArgosScreenshotOptions, "hide" | "remove">) {
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;
Expand Down Expand Up @@ -204,6 +249,8 @@ Cypress.Commands.add(
baseName,
blackout,
onAfterScreenshot,
hide,
remove,
...cypressOptions
} = options;

Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading