import { dirname, fromFileUrl, join, resolve } from "@std/path"; import { chromium, type Page } from "playwright"; import { defineScreenshotBrowserLauncher } from "./browser-options.ts"; import { type ScreenshotAppServer, startScreenshotAppServer, } from "./screenshots/app-server.ts"; import { chromiumExecutable } from "./screenshots/browser.ts"; import { capturePreparedLayers, capturePreparedPage, installLayerCaptureClock, } from "./screenshots/capture.ts"; import { isCompactWidth, isolateElementCss } from "./screenshots/checks.ts"; import type { Rgb } from "./screenshots/color.ts"; import { SCREENSHOT_LAYER_NAMES } from "./screenshots/layers.ts"; import { parseScreenshotOptions, type ScreenshotName, type ThemeName, } from "./screenshots/options.ts"; import { MOBILE_SCREENSHOT_PROFILE, screenshotContextOptions, } from "./screenshots/profile.ts"; import { waitForScreenshotPage } from "./screenshots/readiness.ts"; import { loadScreenshotScenario, type ScreenshotScenario, } from "./screenshots/scenario.ts"; import { applySocialTarget, type SocialTargetName, } from "./screenshots/social.ts"; import { addScreenshotStyle } from "./screenshots/style.ts"; const ROOT = dirname(dirname(fromFileUrl(import.meta.url))); const USERNAME = "screenshots"; const PASSWORD = "screenshots-password"; const TIMEOUT_MS = 60_000; const launchScreenshotBrowser = defineScreenshotBrowserLauncher( chromium, chromiumExecutable, ); interface SceneContext { listingId: string; publicPath: string; } interface Scene { element?: (context: SceneContext) => string; path: (context: SceneContext) => string; prepare?: (page: Page) => Promise; verify?: (page: Page) => Promise; } const at = (path: string): Scene["path"] => () => path; const SCENES: Record = { "activity-log": { path: at("/admin/log") }, "add-attendee-form": { element: ({ listingId }) => `form[action="/admin/listing/${listingId}/attendee"]`, path: ({ listingId }) => `/admin/listing/${listingId}/attendees`, }, "attendees-list": { path: at("/admin/attendees") }, calendar: { path: at("/admin/calendar") }, dashboard: { path: at("/admin/") }, groups: { path: at("/admin/groups") }, guide: { path: at("/admin/guide") }, listing: { path: ({ listingId }) => `/admin/listing/${listingId}`, verify: async (page) => { const table = await page .locator(".listing-breakdown-table") .boundingBox(); const section = await page.locator("#income-ledger").boundingBox(); if (!table || !section) { throw new Error("Could not measure the listing money summary."); } if (!isCompactWidth(table.width, section.width)) { throw new Error("The listing money summary fills the page width."); } }, }, "listing-attendees": { path: ({ listingId }) => `/admin/listing/${listingId}/attendees`, }, "listing-form": { element: at('form[action="/admin/listing"]'), path: at("/admin/listing/new?template=custom"), prepare: async (page) => { const values: Record = { location: "The Lantern Hall, Bristol", max_attendees: "240", max_quantity: "8", name: "Summer Sessions 2026", unit_price: "18.50", }; for (const [name, value] of Object.entries(values)) { await page .locator(`[name="${name}"]`) .first() .fill(value, { force: true }); } await page .locator(".md-editor .ProseMirror") .fill( "A full evening of live music, local food and independent artists.", ); }, }, "public-listing": { path: ({ publicPath }) => publicPath }, sessions: { path: at("/admin/sessions") }, settings: { path: at("/admin/settings") }, users: { path: at("/admin/users") }, }; const submit = async (page: Page, formSelector: string): Promise => { const form = page.locator(formSelector); await Promise.all([ page.waitForNavigation({ waitUntil: "domcontentloaded" }), form.evaluate((element) => Reflect.apply(Reflect.get(element, "submit"), element, []), ), ]); }; const setupAdmin = async ( page: Page, baseUrl: string, username = USERNAME, ): Promise => { await page.goto(`${baseUrl}/setup/`); await page.locator('[name="admin_username"]').fill(username); await page.locator('[name="admin_password"]').fill(PASSWORD); await page.locator('[name="admin_password_confirm"]').fill(PASSWORD); await page.locator('[name="accept_agreement"]').check(); await submit(page, 'form[action="/setup/"]'); await page.goto(`${baseUrl}/`); await page.locator('[name="username"]').fill(username); await page.locator('[name="password"]').fill(PASSWORD); await submit(page, 'form[action="/admin/login"]'); await page.goto(`${baseUrl}/admin/settings`); await page.locator('[name="business_email"]').fill("hello@example.com"); await submit(page, 'form[action="/admin/settings/business-email"]'); }; const setupApp = async ( page: Page, server: ScreenshotAppServer, ): Promise => { const { baseUrl } = server; await setupAdmin(page, baseUrl); await server.enableStripe(); await page.goto(`${baseUrl}/admin/seeds`); await page.locator('[name="listing_count"]').fill("5"); await page.locator('[name="attendees_per_listing"]').fill("8"); await submit(page, 'form[action="/admin/seeds"]'); await page.goto(`${baseUrl}/admin/`); const listingHref = await page .locator('a[href^="/admin/listing/"]:not([href*="/new"])') .first() .getAttribute("href"); const listingId = listingHref?.match(/^\/admin\/listing\/(\d+)/)?.[1]; if (!listingId) throw new Error("Could not find a seeded listing."); await page.goto(`${baseUrl}/admin/listing/${listingId}`); const publicHref = await page .locator('a[href*="/ticket/"]:not([href$="/qr"])') .first() .getAttribute("href"); if (!publicHref) throw new Error("Could not find the listing's public link."); const publicPath = new URL(publicHref, baseUrl).pathname; return { listingId, publicPath }; }; const cssFor = async (theme: ThemeName): Promise => theme === "default" ? "" : await Deno.readTextFile( join(ROOT, "scripts", "screenshots", "themes", `${theme}.css`), ); const applyTheme = async ( page: Page, baseUrl: string, theme: ThemeName, extraCss = "", ): Promise => { await page.goto(`${baseUrl}/admin/settings-advanced`); const form = page.locator('form[action="/admin/settings/custom-css"]'); await form .locator('[name="custom_css"]') .fill(`${await cssFor(theme)}\n${extraCss}`, { force: true }); await submit(page, 'form[action="/admin/settings/custom-css"]'); }; const captureAndWrite = async ( page: Page, outputPath: string, elementSelector?: string, fullPage = false, ): Promise => { const screenshot = await capturePreparedPage(page, elementSelector, fullPage); await Deno.writeFile(outputPath, screenshot.png); return screenshot.background; }; const capture = async ( page: Page, baseUrl: string, context: SceneContext, name: ScreenshotName, outputPath: string, elementSelector?: string, ): Promise => { const scene = SCENES[name]; await page.goto(`${baseUrl}${scene.path(context)}`, { waitUntil: "networkidle", }); await scene.prepare?.(page); await waitForScreenshotPage(page); await scene.verify?.(page); return captureAndWrite(page, outputPath, elementSelector); }; const writeSocialVariants = async ( sourcePath: string, outputDir: string, baseName: string, logPrefix: string, background: Rgb, targets: readonly SocialTargetName[], ): Promise => { for (const target of targets) { const variantName = `${baseName}__${target}`; await applySocialTarget( sourcePath, join(outputDir, `${variantName}.png`), target, background, ); console.log(`${logPrefix}${variantName}.png`); } }; const captureScenario = async ( scenario: ScreenshotScenario, page: Page, server: ScreenshotAppServer, outputDir: string, social: readonly SocialTargetName[] = [], layers = false, ): Promise => { const { baseUrl } = server; const captureCss = `${scenario.css}\n${ scenario.elementSelector ? isolateElementCss(scenario.elementSelector) : "" }`; if (scenario.adminSetup !== false) { await setupAdmin(page, baseUrl, scenario.setupUsername); await server.enableStripe(); await applyTheme(page, baseUrl, "default", captureCss); } await scenario.run({ balancePathFor: async (attendeeId) => { const { signBalanceToken } = await import("#shared/balance-link.ts"); return `/pay/${await signBalanceToken(attendeeId)}`; }, baseUrl, 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 layered = layers ? await capturePreparedLayers( page, scenario.elementSelector, scenario.fullPage, ) : undefined; const background = layered ? layered.background : await captureAndWrite( page, outputPath, scenario.elementSelector, scenario.fullPage, ); if (layered) await Deno.writeFile(outputPath, layered.png); console.log(`${scenario.name}.png`); if (layered) { for (const layer of SCREENSHOT_LAYER_NAMES) { const name = `${scenario.name}__layer-${layer}.png`; await Deno.writeFile(join(outputDir, name), layered.layers[layer]); console.log(name); } } await writeSocialVariants( outputPath, outputDir, scenario.name, "", background, social, ); }; const installLayerClock = (page: Page, layers: boolean): Promise => layers ? installLayerCaptureClock(page) : Promise.resolve(); const elementCaptureCss = (selector: string | undefined): string => selector ? isolateElementCss(selector) : ""; const main = async (): Promise => { Deno.env.set("PW_TEST_SCREENSHOT_NO_FONTS_READY", "1"); const options = parseScreenshotOptions(Deno.args); const outputDir = resolve(ROOT, options.outputDir); await Deno.mkdir(outputDir, { recursive: true }); const scenario = options.scenarioPath ? await loadScreenshotScenario(resolve(ROOT, options.scenarioPath)) : undefined; const server = await startScreenshotAppServer(); let browser: Awaited> | undefined; try { browser = await launchScreenshotBrowser(); const context = await browser.newContext({ baseURL: server.baseUrl, ...screenshotContextOptions(MOBILE_SCREENSHOT_PROFILE), }); const page = await context.newPage(); await installLayerClock(page, options.layers ?? false); page.setDefaultTimeout(TIMEOUT_MS); if (scenario) { await captureScenario( scenario, page, server, outputDir, options.social ?? [], options.layers ?? false, ); return; } const sceneContext = await setupApp(page, server); for (const theme of options.themes) { const themeDir = join(outputDir, theme); await Deno.mkdir(themeDir, { recursive: true }); for (const name of options.names) { const elementSelector = options.elementSelector ?? SCENES[name].element?.(sceneContext); await applyTheme( page, server.baseUrl, theme, elementCaptureCss(elementSelector), ); const outputPath = join(themeDir, `${name}.png`); const background = await capture( page, server.baseUrl, sceneContext, name, outputPath, elementSelector, ); console.log(`${theme}/${name}.png`); await writeSocialVariants( outputPath, themeDir, name, `${theme}/`, background, options.social ?? [], ); } } } finally { await browser?.close(); await server.stop(); } }; if (import.meta.main) await main();