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
2 changes: 2 additions & 0 deletions packages/browser/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
beforeEach,
afterEach,
applyHelper,
mask,
waitFor,
getWaitFailureExplanations,
} from "./stabilization";
Expand All @@ -15,6 +16,7 @@ const ArgosGlobal = {
beforeEach,
afterEach,
applyHelper,
mask,
waitFor,
getWaitFailureExplanations,
getColorScheme: () => getColorScheme(),
Expand Down
119 changes: 119 additions & 0 deletions packages/browser/src/global/mask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Overlay drawing a box over each masked element.
*/
interface MaskOverlay {
/**
* Element covering the viewport, shown in the top layer.
*/
glass: HTMLElement;
/**
* Shadow root holding the boxes, isolated from the page styles.
*/
root: ShadowRoot;
/**
* Masked elements and the box covering each of them.
*/
boxes: { element: Element; box: HTMLElement }[];
/**
* Animation frame request keeping the boxes in place.
*/
frame: number;
}

let overlay: MaskOverlay | null = null;

/**
* Set inline styles that win over any page style.
*/
function setImportantStyles(
element: HTMLElement,
styles: Record<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.
*/
export function maskElements(elements: Iterable<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;
}
12 changes: 12 additions & 0 deletions packages/browser/src/global/stabilization/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ViewportOption } from "../../viewport";
import { maskElements, unmaskElements } from "../mask";
import { corePlugins, plugins, type PluginName } from "./plugins";

export { applyHelper, type ArgosHelper } from "./plugins/argosHelpers";
Expand Down Expand Up @@ -179,6 +180,17 @@ export function afterEach() {
beforeEachCleanups.clear();
}

/**
* Cover elements with a box of the given color, like the
* `data-visual-test="blackout"` helper does, until `afterEach` runs.
* Call it once the page is stable, right before taking the screenshot, for
* SDKs whose automation library has no native mask.
*/
export function mask(elements: Element[], color: string) {
maskElements(elements, color);
beforeEachCleanups.add(unmaskElements);
}

/**
* Get the stabilization state of the document.
*/
Expand Down
48 changes: 48 additions & 0 deletions packages/puppeteer/fixtures/mask.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Mask page</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
body {
margin: 0;
background: white;
}
#top {
height: 100px;
background: red;
}
#spacer {
height: 2000px;
}
#container {
padding: 20px;
background: blue;
}
#masked {
height: 50px;
background: lime;
}
#bottom {
height: 200px;
}
#in-dialog {
width: 100px;
height: 100px;
background: lime;
}
</style>
</head>
<body>
<div id="top"></div>
<div id="spacer"></div>
<div id="container">
<div id="masked" data-visual-test="blackout"></div>
</div>
<div id="bottom"></div>
<dialog id="dialog">
<div id="in-dialog" data-visual-test="blackout"></div>
</dialog>
</body>
</html>
49 changes: 49 additions & 0 deletions packages/puppeteer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ export type ArgosScreenshotOptions = Omit<
*/
remove?: (string | ElementHandle)[];

/**
* Elements to cover with a box when the screenshot is taken, as
* `ElementHandle`s or string selectors, like with the
* `data-visual-test="blackout"` helper.
* Selectors that match nothing are ignored.
* @example
* argosScreenshot(page, "home", { mask: ["#map", ".ad"] })
*/
mask?: (string | ElementHandle)[];

/**
* Color of the box covering the masked elements, in CSS color format.
* @default "#FF00FF"
*/
maskColor?: string;

/**
* Disable hover effects by moving the mouse to the top-left corner of the page.
* @default true
Expand Down Expand Up @@ -269,6 +285,35 @@ async function applyHelpers(
]);
}

/**
* Cover the `data-visual-test="blackout"` helper and the `mask` option with
* boxes until `afterEach` runs, since Puppeteer has no native mask.
*/
async function applyMask(
page: Page,
{
mask = [],
maskColor = "#FF00FF",
}: Pick<ArgosScreenshotOptions, "mask" | "maskColor">,
) {
await Promise.all(
['[data-visual-test="blackout"]', ...mask].map((target) =>
typeof target === "string"
? page.$$eval(
target,
(elements, color) =>
((window as any).__ARGOS__ as ArgosGlobal).mask(elements, color),
maskColor,
)
: target.evaluate(
(element, color) =>
((window as any).__ARGOS__ as ArgosGlobal).mask([element], color),
maskColor,
),
),
);
}

/**
* Wait for the UI to be ready before taking the screenshot.
*/
Expand Down Expand Up @@ -327,6 +372,8 @@ export async function argosScreenshot(
baseName,
hide,
remove,
mask,
maskColor,
...puppeteerOptions
} = options;

Expand Down Expand Up @@ -419,6 +466,8 @@ export async function argosScreenshot(
await applyHelpers(page, { hide, remove });
const afterEach = await beforeEach(page, options);
await waitForReadiness(page, options);
// Measured once the page is stable, so the boxes match the elements.
await applyMask(page, { mask, maskColor });

const [screenshotPath, metadata] = await Promise.all([
getScreenshotPath(name),
Expand Down
Loading