test/scripts/screenshots-capture.test.ts
Rama main · réplica de lectura del repositorio original.
import { Buffer } from "node:buffer";
import { expect } from "@std/expect";
import { afterEach, beforeEach, describe, it as test } from "@std/testing/bdd";
import sharp from "sharp";
import {
capturePreparedLayers,
capturePreparedPage,
} from "#scripts/screenshots/capture.ts";
import { SCREENSHOT_LAYER_NAMES } from "#scripts/screenshots/layers.ts";
import {
blankWhitePng,
whitePngWithBlackBox,
} from "#test/scripts/screenshots-fixture.ts";
import { createGlobalStash } from "#test-utils/happy-dom.ts";
interface CaptureMockConfig {
bodyStyle?: unknown;
elementBox?: { width: number; height: number; x: number; y: number } | null;
screenshot?: (options: { omitBackground?: boolean }) => Promise<Uint8Array>;
}
interface CaptureMockCalls {
locatorSelectors: string[];
screenshotOptions: {
animations?: string;
fullPage: boolean;
omitBackground?: boolean;
}[];
scrollFns: number;
styleRemovals: number;
}
const bodyStyle = (config: CaptureMockConfig): unknown =>
Object.hasOwn(config, "bodyStyle")
? config.bodyStyle
: { backgroundColor: "rgb(255, 255, 255)" };
const buildCaptureMockPage = (
config: CaptureMockConfig,
): { calls: CaptureMockCalls; page: never } => {
const calls: CaptureMockCalls = {
locatorSelectors: [],
screenshotOptions: [],
scrollFns: 0,
styleRemovals: 0,
};
const elementLocator = {
boundingBox: () => Promise.resolve(config.elementBox ?? null),
evaluate: (fn: (node: unknown) => unknown) => {
calls.scrollFns += 1;
return Promise.resolve(fn({ scrollIntoView: () => {} }));
},
first: () => elementLocator,
waitFor: () => Promise.resolve(),
};
const page = {
addStyleTag: () =>
Promise.resolve({
evaluate: (fn: (node: unknown) => unknown) => {
calls.styleRemovals += 1;
return Promise.resolve(
fn({ parentNode: { removeChild: () => undefined } }),
);
},
}),
clock: {
pauseAt: () => Promise.resolve(),
resume: () => Promise.resolve(),
},
evaluate: (fn: (argument: never) => unknown, argument: never) =>
Promise.resolve(fn(argument)),
locator: (selector: string) => {
calls.locatorSelectors.push(selector);
return selector === "body"
? {
evaluate: (fn: (node: unknown) => unknown) =>
Promise.resolve(fn({ style: bodyStyle(config) })),
}
: elementLocator;
},
route: (
_url: string,
handler: (route: { fulfill: () => Promise<void> }) => Promise<void>,
) => handler({ fulfill: () => Promise.resolve() }),
screenshot: async (opts: {
animations?: string;
fullPage: boolean;
omitBackground?: boolean;
}) => {
calls.screenshotOptions.push(opts);
return await (config.screenshot ?? blankWhitePng)(opts);
},
unroute: () => Promise.resolve(),
url: () => "https://tickets.test/page",
};
return { calls, page: page as never };
};
const elementPage = (
overrides: Partial<CaptureMockConfig> = {},
): { calls: CaptureMockCalls; page: never } =>
buildCaptureMockPage({
elementBox: { height: 20, width: 30, x: 35, y: 40 },
screenshot: whitePngWithBlackBox,
...overrides,
});
const globals = createGlobalStash();
beforeEach(() => {
globals.set("__setLayerCaptureFrozen", () => {});
globals.set("ShadowRoot", class {});
globals.set("getComputedStyle", (node: { style: unknown }) => node.style);
const makeElement = (
opacity: string,
parentElement: { closest: () => unknown } | null,
) => {
const attributes = new Set<string>();
return {
attributes,
closest: () => null,
getRootNode: () => document,
hasAttribute: (attribute: string) => attributes.has(attribute),
matches: () => false,
parentElement,
removeAttribute: (attribute: string) => attributes.delete(attribute),
setAttribute: (attribute: string) => attributes.add(attribute),
shadowRoot: null,
style: {
backgroundClip: "border-box",
content: "none",
display: "block",
filter: "none",
getPropertyValue: (name: string) =>
name.includes("mask") || name.includes("backdrop")
? "none"
: "border-box",
maskImage: "none",
mixBlendMode: "normal",
opacity,
visibility: "visible",
},
};
};
const opaque = makeElement("1", null);
const translucent = makeElement("0.5", null);
const nested = makeElement("0.5", translucent);
const elements = [opaque, translucent, nested];
const root = { childNodes: [] };
globals.set("document", {
body: root,
documentElement: root,
querySelectorAll: (selector: string) => (selector === "*" ? elements : []),
});
});
afterEach(() => globals.restore());
describe("capturePreparedPage", () => {
test("returns the body background and viewport screenshot", async () => {
const screenshot = await blankWhitePng();
const { calls, page } = buildCaptureMockPage({
screenshot: () => Promise.resolve(screenshot),
});
const result = await capturePreparedPage(page);
expect(result).toEqual({
background: { b: 255, g: 255, r: 255 },
png: screenshot,
});
expect(calls.screenshotOptions).toEqual([
{
animations: "disabled",
caret: "hide",
fullPage: false,
type: "png",
},
]);
});
test("passes fullPage through for a whole-page capture", async () => {
const { calls, page } = buildCaptureMockPage({});
await capturePreparedPage(page, undefined, true);
expect(calls.screenshotOptions[0]?.fullPage).toBe(true);
});
test("captures and trims the whole page without changing its viewport", async () => {
const { calls, page } = elementPage();
const result = await capturePreparedPage(page, "#element");
expect(calls.locatorSelectors).toEqual(["#element", "body"]);
expect(calls.scrollFns).toBe(1);
expect(calls.screenshotOptions[0]?.fullPage).toBe(true);
expect(await sharp(result.png).metadata()).toEqual(
expect.objectContaining({ format: "png", height: 84, width: 94 }),
);
});
test("throws when the element has no bounding box", async () => {
const { calls, page } = elementPage({ elementBox: null });
await expect(capturePreparedPage(page, "#element")).rejects.toThrow(
"Could not measure screenshot element: #element",
);
expect(calls.screenshotOptions).toEqual([]);
});
test("propagates screenshot failures", async () => {
const { page } = elementPage({
screenshot: () => Promise.reject(new Error("playwright blew up")),
});
await expect(capturePreparedPage(page, "#element")).rejects.toThrow(
"playwright blew up",
);
});
test("throws when computed style returns no style object", async () => {
const { page } = buildCaptureMockPage({ bodyStyle: null });
await expect(capturePreparedPage(page)).rejects.toThrow(
"Could not read the page style.",
);
});
});
describe("capturePreparedLayers", () => {
test("captures transparent background, control and text layers", async () => {
const { calls, page } = buildCaptureMockPage({});
const result = await capturePreparedLayers(page);
expect(Object.keys(result.layers)).toEqual([...SCREENSHOT_LAYER_NAMES]);
expect(result.background).toEqual({ b: 255, g: 255, r: 255 });
expect(result.png).toEqual(await blankWhitePng());
expect(
calls.screenshotOptions.map(({ omitBackground }) => omitBackground),
).toEqual(
SCREENSHOT_LAYER_NAMES.map((layer) =>
layer === "background" ? undefined : true,
),
);
expect(calls.screenshotOptions.map(({ animations }) => animations)).toEqual(
SCREENSHOT_LAYER_NAMES.map(() => undefined),
);
expect(calls.styleRemovals).toBe(SCREENSHOT_LAYER_NAMES.length + 1);
});
test("returns a normal PNG made from the captured layer frames", async () => {
const images = await Promise.all([
blankWhitePng(),
sharp({
create: {
background: { alpha: 1, b: 0, g: 0, r: 255 },
channels: 4,
height: 50,
width: 50,
},
})
.png()
.toBuffer(),
sharp({
create: {
background: { alpha: 0.5, b: 0, g: 255, r: 0 },
channels: 4,
height: 50,
width: 50,
},
})
.png()
.toBuffer(),
sharp({
create: {
background: { alpha: 0.5, b: 255, g: 0, r: 0 },
channels: 4,
height: 50,
width: 50,
},
})
.png()
.toBuffer(),
]);
let capture = 0;
const { page } = buildCaptureMockPage({
screenshot: () => {
const image = images[capture++];
if (!image) throw new Error("Captured more test frames than expected.");
return Promise.resolve(image);
},
});
const result = await capturePreparedLayers(page);
const combined = await sharp(result.layers.background)
.composite([
{ input: Buffer.from(result.layers.controls) },
{ input: Buffer.from(result.layers.text) },
])
.png()
.toBuffer();
expect(result.png).toEqual(new Uint8Array(combined));
});
test("uses one element crop for every layer", async () => {
const { calls, page } = elementPage();
const result = await capturePreparedLayers(page, "#element");
expect(calls.screenshotOptions.every(({ fullPage }) => fullPage)).toBe(
true,
);
expect(calls.styleRemovals).toBe(SCREENSHOT_LAYER_NAMES.length + 1);
for (const png of Object.values(result.layers)) {
expect(await sharp(png).metadata()).toEqual(
expect.objectContaining({ height: 84, width: 94 }),
);
}
const corners = await Promise.all(
Object.values(result.layers).map(async (png) => [
...(await sharp(png)
.ensureAlpha()
.extract({ height: 1, left: 0, top: 0, width: 1 })
.raw()
.toBuffer()),
]),
);
expect(corners).toEqual([
[255, 255, 255, 255],
[0, 0, 0, 0],
[0, 0, 0, 0],
]);
});
test("rejects a layered element that has no visible box", async () => {
const { calls, page } = elementPage({ elementBox: null });
await expect(capturePreparedLayers(page, "#missing")).rejects.toThrow(
"Could not measure screenshot element: #missing",
);
expect(calls.screenshotOptions).toEqual([]);
});
test("removes the layer style when capture fails", async () => {
let screenshots = 0;
const { calls, page } = buildCaptureMockPage({
screenshot: async () => {
screenshots += 1;
if (screenshots === 2) throw new Error("layer capture failed");
return await blankWhitePng();
},
});
await expect(capturePreparedLayers(page)).rejects.toThrow(
"layer capture failed",
);
expect(calls.styleRemovals).toBe(3);
});
test("fails when capture controls were not installed before navigation", async () => {
globals.set("__setLayerCaptureFrozen", undefined);
const { page } = buildCaptureMockPage({});
await expect(capturePreparedLayers(page)).rejects.toThrow(
"Layer capture was not installed before navigation.",
);
});
});