Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,19 @@ External scenario modules own one specific screenshot's data, custom CSS, and
browser steps while reusing this runner's temporary site and high-resolution
capture. Pass their path with `--scenario`; the scenario's `name` sets the PNG
filename directly inside `--output`. A scenario can set its own viewport when a
wider layout is part of the screenshot.
wider layout is part of the screenshot. Set `adminSetup: false` when the
scenario needs to capture the site before setup. The runner skips admin setup,
Stripe, and the saved theme, then applies the scenario's CSS after its browser
steps.

Pass `--layers` with an external scenario to also write transparent background,
controls, and text layers. They are named
`<scenario-name>__layer-<layer>.png` next to the normal screenshot.

```bash
deno task screenshot --scenario ../tickets-site/scripts/screenshots/charity-events.js \
--layers --output ../tickets-site/images/screenshots
```

Pass `--social` (one of `facebook`, `instagram-landscape`,
`instagram-portrait`, `instagram-square`, a comma-separated list, or `all`) to
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"test": "deno run --v8-flags=--expose-gc -A scripts/run-tests.ts",
"test:coverage": "deno run --v8-flags=--expose-gc -A scripts/run-tests.ts --coverage",
"test:files": "deno run --v8-flags=--expose-gc -A scripts/run-test-files.ts",
"test:screenshot-contract": "deno test --no-check -A test/scripts/screenshots-browser.contract.ts",
"test:screenshot-contract": "deno test --no-check -A test/scripts/*-browser.contract.ts",
"specs": "deno run --v8-flags=--expose-gc -A scripts/run-specs.ts",
"specs:evidence": "deno run --v8-flags=--expose-gc -A scripts/run-spec-evidence.ts",
"specs:check": "deno run --allow-read scripts/check-specs.ts",
Expand Down
46 changes: 33 additions & 13 deletions scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import {
startScreenshotAppServer,
} from "./screenshots/app-server.ts";
import { chromiumExecutable } from "./screenshots/browser.ts";
import { capturePreparedPage } from "./screenshots/capture.ts";
import {
capturePreparedLayers,
capturePreparedPage,
} from "./screenshots/capture.ts";
import { isCompactWidth, isolateElementCss } from "./screenshots/checks.ts";
import type { Rgb } from "./screenshots/color.ts";
import {
addScreenshotStyle,
SCREENSHOT_LAYER_NAMES,
} from "./screenshots/layers.ts";
import {
parseScreenshotOptions,
type ScreenshotName,
Expand Down Expand Up @@ -252,20 +259,17 @@ const captureScenario = async (
server: ScreenshotAppServer,
outputDir: string,
social: readonly SocialTargetName[] = [],
layers = false,
): Promise<void> => {
const { baseUrl } = server;
await setupAdmin(page, baseUrl, scenario.setupUsername);
await server.enableStripe();
await applyTheme(
page,
baseUrl,
"default",
`${scenario.css}\n${
scenario.elementSelector
? isolateElementCss(scenario.elementSelector)
: ""
}`,
);
const captureCss = `${scenario.css}\n${
scenario.elementSelector ? isolateElementCss(scenario.elementSelector) : ""
}`;
if (scenario.adminSetup !== false) {
await setupAdmin(page, baseUrl, scenario.setupUsername);
await server.enableStripe();
Comment thread
stefan-burke marked this conversation as resolved.
Resolved
await applyTheme(page, baseUrl, "default", captureCss);
}
await scenario.run({
balancePathFor: async (attendeeId) => {
const { signBalanceToken } = await import("#shared/balance-link.ts");
Expand All @@ -275,6 +279,9 @@ const captureScenario = async (
page,
submit: (formSelector) => submit(page, formSelector),
});
if (scenario.adminSetup === false) {
await addScreenshotStyle(page, captureCss);
}
await waitForScreenshotPage(page);
const outputPath = join(outputDir, `${scenario.name}.png`);
const background = await captureAndWrite(
Expand All @@ -284,6 +291,18 @@ const captureScenario = async (
scenario.fullPage,
);
console.log(`${scenario.name}.png`);
if (layers) {
const captures = await capturePreparedLayers(
page,
scenario.elementSelector,
scenario.fullPage,
);
for (const layer of SCREENSHOT_LAYER_NAMES) {
const name = `${scenario.name}__layer-${layer}.png`;
await Deno.writeFile(join(outputDir, name), captures[layer]);
console.log(name);
}
}
await writeSocialVariants(
outputPath,
outputDir,
Expand Down Expand Up @@ -319,6 +338,7 @@ const main = async (): Promise<void> => {
server,
outputDir,
options.social ?? [],
options.layers ?? false,
);
return;
}
Expand Down
134 changes: 109 additions & 25 deletions scripts/screenshots/capture.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
import type { Page } from "playwright";
import { parseRgb, type Rgb } from "./color.ts";
import { trimElementPng } from "./image.ts";
import {
cropElementLayerPng,
elementTrimBounds,
trimElementPng,
} from "./image.ts";
import {
SCREENSHOT_LAYER_NAMES,
type ScreenshotLayerName,
withScreenshotLayer,
} from "./layers.ts";

export interface PreparedScreenshot {
background: Rgb;
png: Uint8Array;
}

type CaptureArguments = readonly [
page: Page,
elementSelector?: string,
fullPage?: boolean,
];

type CaptureFunction<Result> = (...args: CaptureArguments) => Promise<Result>;

interface CaptureContext {
background: Rgb;
elementSelector: string | undefined;
fullPage: boolean;
page: Page;
}

const readBodyBackground = async (page: Page): Promise<Rgb> =>
parseRgb(
await page.locator("body").evaluate((node) => {
Expand All @@ -19,38 +43,98 @@ const readBodyBackground = async (page: Page): Promise<Rgb> =>
}),
);

const pagePng = async (page: Page, fullPage: boolean): Promise<Uint8Array> =>
const pagePng = async (
page: Page,
fullPage: boolean,
omitBackground = false,
): Promise<Uint8Array> =>
new Uint8Array(
await page.screenshot({
animations: "disabled",
caret: "hide",
fullPage,
...(omitBackground ? { omitBackground } : {}),
type: "png",
}),
);

export const capturePreparedPage = async (
const prepareCapture = async (
page: Page,
elementSelector?: string,
fullPage = false,
): Promise<PreparedScreenshot> => {
const background = await readBodyBackground(page);
if (!elementSelector) {
return { background, png: await pagePng(page, fullPage) };
}
const element = page.locator(elementSelector).first();
await element.waitFor({ state: "attached" });
const initialBox = await element.boundingBox();
if (!initialBox) {
throw new Error(`Could not measure screenshot element: ${elementSelector}`);
}
await element.evaluate((node) =>
Reflect.apply(Reflect.get(node, "scrollIntoView"), node, [
{ block: "center" },
]),
elementSelector: string | undefined,
fullPage: boolean,
): Promise<{ background: Rgb; fullPage: boolean }> => ({
background: await readBodyBackground(page),
fullPage: elementSelector ? true : fullPage,
});

const withPreparedCapture =
<Result>(
capture: (context: CaptureContext) => Promise<Result>,
): CaptureFunction<Result> =>
async (...[page, elementSelector, fullPage = false]) =>
capture({
...(await prepareCapture(page, elementSelector, fullPage)),
elementSelector,
page,
});

export const capturePreparedLayers: CaptureFunction<
Record<ScreenshotLayerName, Uint8Array>
> = withPreparedCapture(
async ({ background, elementSelector, fullPage, page }) => {
const normal = await pagePng(page, fullPage);
const bounds = elementSelector
? await elementTrimBounds(normal, background)
: undefined;
const entries: [ScreenshotLayerName, Uint8Array][] = [];
const transparent = { alpha: 0, b: 0, g: 0, r: 0 } as const;
const paddingByLayer: Record<
ScreenshotLayerName,
Rgb & { alpha?: number }
> = { background, controls: transparent, text: transparent };
for (const layer of SCREENSHOT_LAYER_NAMES) {
const png = await withScreenshotLayer(page, layer, () =>
pagePng(page, fullPage, true),
);
Comment thread
stefan-burke marked this conversation as resolved.
Resolved
entries.push([
layer,
bounds
? await cropElementLayerPng(png, bounds, paddingByLayer[layer])
: png,
]);
}
return Object.fromEntries(entries) as Record<
ScreenshotLayerName,
Uint8Array
>;
},
);

export const capturePreparedPage: CaptureFunction<PreparedScreenshot> =
withPreparedCapture(
async ({ background, elementSelector, fullPage, page }) => {
if (!elementSelector) {
return {
background,
png: await pagePng(page, fullPage),
};
}
const element = page.locator(elementSelector).first();
await element.waitFor({ state: "attached" });
const initialBox = await element.boundingBox();
if (!initialBox) {
throw new Error(
`Could not measure screenshot element: ${elementSelector}`,
);
}
await element.evaluate((node) =>
Reflect.apply(Reflect.get(node, "scrollIntoView"), node, [
{ block: "center" },
]),
);
return {
background,
png: await trimElementPng(await pagePng(page, fullPage), background),
};
},
);
return {
background,
png: await trimElementPng(await pagePng(page, true), background),
};
};
57 changes: 50 additions & 7 deletions scripts/screenshots/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@ import { requireValue } from "#shared/required-value.ts";

const ELEMENT_PADDING = 32;

export interface ElementTrimBounds {
height: number;
left: number;
top: number;
width: number;
}

const padded = (background: Rgb & { alpha?: number }) => ({
background,
bottom: ELEMENT_PADDING,
left: ELEMENT_PADDING,
right: ELEMENT_PADDING,
top: ELEMENT_PADDING,
});

export const elementTrimBounds = async (
png: Uint8Array,
background: Rgb,
): Promise<ElementTrimBounds> => {
const result = await sharp(png)
.trim({ background, threshold: 5 })
.toBuffer({ resolveWithObject: true });
return {
height: result.info.height,
left: -requireValue(
result.info.trimOffsetLeft,
"The trimmed screenshot has no left offset.",
),
top: -requireValue(
result.info.trimOffsetTop,
"The trimmed screenshot has no top offset.",
),
width: result.info.width,
};
};

export const cropElementLayerPng = async (
png: Uint8Array,
bounds: ElementTrimBounds,
background: Rgb & { alpha?: number },
): Promise<Uint8Array> =>
new Uint8Array(
await sharp(png)
.extract(bounds)
.extend(padded(background))
.png()
.toBuffer(),
);

export const trimElementPng = async (
png: Uint8Array,
background: Rgb,
Expand All @@ -15,13 +64,7 @@ export const trimElementPng = async (
requireValue(source.height, "The screenshot PNG has no height.");
const result = await image
.trim({ background, threshold: 5 })
.extend({
background,
bottom: ELEMENT_PADDING,
left: ELEMENT_PADDING,
right: ELEMENT_PADDING,
top: ELEMENT_PADDING,
})
.extend(padded(background))
.png()
.toBuffer({ resolveWithObject: true });
if (!wasImageTrimmed(source, result.info, ELEMENT_PADDING)) {
Expand Down
Loading