Ctrl K
ComunidadGuías…

scripts/screenshots.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<void>;
  verify?: (page: Page) => Promise<void>;
}

const at =
  (path: string): Scene["path"] =>
  () =>
    path;

const SCENES: Record<ScreenshotName, Scene> = {
  "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<string, string> = {
        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<void> => {
  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<void> => {
  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<SceneContext> => {
  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<string> =>
  theme === "default"
    ? ""
    : await Deno.readTextFile(
        join(ROOT, "scripts", "screenshots", "themes", `${theme}.css`),
      );

const applyTheme = async (
  page: Page,
  baseUrl: string,
  theme: ThemeName,
  extraCss = "",
): Promise<void> => {
  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<Rgb> => {
  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<Rgb> => {
  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<void> => {
  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<void> => {
  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<void> =>
  layers ? installLayerCaptureClock(page) : Promise.resolve();

const elementCaptureCss = (selector: string | undefined): string =>
  selector ? isolateElementCss(selector) : "";

const main = async (): Promise<void> => {
  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<ReturnType<typeof chromium.launch>> | 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();