#!/usr/bin/env tsx /** * Erzeugt die Screenshots reproduzierbar neu – fuer die Meilenstein-Belege unter * docs/screenshots// und fuer die Anleitung unter docs/anleitung/bilder/. * * Bilder werden nie von Hand nachbearbeitet. Aendert sich die Oberflaeche, laeuft * dieses Skript erneut. * * pnpm docs:screenshots alle Seiten nach docs/anleitung/bilder/ * pnpm docs:screenshots --ziel m0 nach docs/screenshots/m0/ * pnpm docs:screenshots --nur uebersicht nur diese Seite * * Voraussetzung: die App laeuft (pnpm start oder pnpm dev). Adresse ueber * E2E_APP_URL, Standard http://127.0.0.1:3000. */ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { chromium, type Browser, type Cookie, type Page } from "@playwright/test"; const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000"; const ANSICHTEN = [ { name: "desktop", breite: 1440, hoehe: 900 }, { name: "handy", breite: 390, hoehe: 844 }, ] as const; type Seite = { readonly name: string; readonly pfad: string; /** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */ readonly ohneAnmeldung?: boolean; /** * Nur aufnehmen, wenn der Betriebsmodus passt. Der Modus steht in der * Datenbank; wer beide Seiten braucht, laesst das Skript zweimal laufen und * stellt dazwischen um. */ readonly nurModus?: "INTERN" | "DIENSTLEISTUNG"; /** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */ readonly vorbereiten?: (tab: Page) => Promise; }; /** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */ const SEITEN: readonly Seite[] = [ { name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true }, { name: "uebersicht", pfad: "/" }, { name: "admin-einstellungen", pfad: "/admin" }, { name: "admin-moduswechsel", pfad: "/admin", nurModus: "INTERN", vorbereiten: async (tab) => { // Gezielt den Wechsel-Dialog aufklappen. `getByRole("group").first()` // traf bei 390 px das Navigationsmenue, weil das dort das erste details ist. await tab.locator("summary", { hasText: "wechseln" }).click(); await tab.waitForTimeout(150); }, }, { // Mit mehr als einem nicht archivierten Kunden ist der Rueckweg gesperrt. // Genau das soll die Anleitung zeigen. name: "admin-moduswechsel-abgelehnt", pfad: "/admin", nurModus: "DIENSTLEISTUNG", }, { name: "admin-benutzer", pfad: "/admin/benutzer" }, { name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" }, ]; function argument(name: string): string | undefined { const index = process.argv.indexOf(`--${name}`); return index >= 0 ? process.argv[index + 1] : undefined; } async function warteAufApp(): Promise { for (let versuch = 1; versuch <= 30; versuch += 1) { try { const antwort = await fetch(APP_URL, { signal: AbortSignal.timeout(2000) }); if (antwort.ok) return; } catch { // noch nicht da } await new Promise((f) => setTimeout(f, 1000)); } throw new Error(`Die App antwortet nicht auf ${APP_URL}. Erst "pnpm start" laufen lassen.`); } /** * Meldet sich mit dem Seed-Administrator an und gibt den Sitzungs-Cookie zurueck. * * Geht ueber das echte Formular statt ueber einen gesetzten Cookie: so faellt * sofort auf, wenn die Anmeldung selbst kaputt ist. */ async function anmelden(browser: Browser): Promise { const email = process.env["SEED_ADMIN_EMAIL"]; const passwort = process.env["SEED_ADMIN_PASSWORD"]; if (!email || !passwort) { throw new Error( "SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD werden gebraucht, um die geschuetzten Seiten " + "aufzunehmen. Siehe .env.example.", ); } const kontext = await browser.newContext({ baseURL: APP_URL }); const tab = await kontext.newPage(); await tab.goto("/anmelden", { waitUntil: "load" }); await tab.fill('input[name="email"]', email); await tab.fill('input[name="passwort"]', passwort); await Promise.all([tab.waitForURL("**/"), tab.click('button[type="submit"]')]); const kekse = await kontext.cookies(); if (!kekse.some((keks) => keks.name === "lz_sitzung")) { throw new Error("Die Anmeldung hat kein Sitzungscookie gesetzt."); } await kontext.close(); return kekse; } /** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */ async function kachelnErzeugen(browser: Browser): Promise { const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString(); for (const kante of [192, 512]) { const seite = await browser.newPage({ viewport: { width: kante, height: kante } }); await seite.goto(svg, { waitUntil: "load" }); await seite.screenshot({ path: join("public", `icon-${kante}.png`), omitBackground: false }); await seite.close(); console.log(` public/icon-${kante}.png`); } } const zielMeilenstein = argument("ziel"); const nur = argument("nur"); /** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */ const modus = (argument("modus") ?? "INTERN") as "INTERN" | "DIENSTLEISTUNG"; const ziel = zielMeilenstein ? join("docs", "screenshots", zielMeilenstein) : join("docs", "anleitung", "bilder"); await warteAufApp(); await mkdir(ziel, { recursive: true }); const browser = await chromium.launch(); try { await kachelnErzeugen(browser); const auswahl = SEITEN.filter( (s) => (!nur || s.name === nur) && (s.nurModus === undefined || s.nurModus === modus), ); const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : []; for (const seite of auswahl) { for (const ansicht of ANSICHTEN) { const kontext = await browser.newContext({ viewport: { width: ansicht.breite, height: ansicht.hoehe }, colorScheme: "dark", locale: "de-DE", timezoneId: "Europe/Berlin", deviceScaleFactor: 2, }); if (!seite.ohneAnmeldung) await kontext.addCookies(kekse); const tab = await kontext.newPage(); await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" }); if (seite.vorbereiten) await seite.vorbereiten(tab); // Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen. await tab.evaluate(() => document.fonts.ready); await tab.waitForLoadState("domcontentloaded"); const datei = join(ziel, `${seite.name}-${ansicht.name}.png`); await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" }); // Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft. if (ansicht.name === "handy") { const masse = await tab.evaluate(() => ({ scroll: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, })); if (masse.scroll > masse.sichtbar) { throw new Error(`${seite.pfad} scrollt am Handy horizontal: ${masse.scroll} > ${masse.sichtbar}`); } } console.log(` ${datei}`); await kontext.close(); } } } finally { await browser.close(); } console.log("Screenshots erzeugt.");