/** * Erzeugt die Bilder für docs/ANLEITUNG.md. * * Läuft gegen die laufende Anwendung, meldet sich mit dem Seed-Administrator an * und legt die Bilder unter docs/screenshots ab — jeweils in heller und dunkler * Darstellung, damit die Anleitung zu beiden Einstellungen passt. * * docker compose up -d * npm run dev * npm run screenshots * * Die Bilder werden mit eingecheckt: die Anleitung soll auch dann lesbar sein, * wenn gerade kein Stack läuft. */ import "dotenv/config"; import { mkdir } from "node:fs/promises"; import path from "node:path"; import { chromium, type Page } from "@playwright/test"; const BASIS = process.env.E2E_BASIS_URL ?? "http://localhost:3100"; const ZIEL = path.join(process.cwd(), "docs", "screenshots"); const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"; const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"; interface Aufnahme { name: string; pfad: string; beschreibung: string; /** true, wenn vorher angemeldet werden muss. */ angemeldet: boolean; } const AUFNAHMEN: Aufnahme[] = [ { name: "01-anmeldung", pfad: "/anmelden", beschreibung: "Anmeldeseite mit lokalem Login", angemeldet: false, }, { name: "02-uebersicht", pfad: "/", beschreibung: "Übersicht nach der Anmeldung", angemeldet: true, }, { name: "03-geraeteliste", pfad: "/geraete", beschreibung: "Geräteliste mit Suche, Filter und Spaltenauswahl", angemeldet: true, }, { name: "05-fahrzeuge", pfad: "/fahrzeuge", beschreibung: "Fahrzeugübersicht", angemeldet: true, }, { name: "07-mitglieder", pfad: "/mitglieder", beschreibung: "Mitgliederliste nach Abteilung", angemeldet: true, }, { name: "08-qualifikationen", pfad: "/qualifikationen", beschreibung: "Qualifikationen mit Ablaufwarnung", angemeldet: true, }, { name: "09-import", pfad: "/geraete/importieren", beschreibung: "Import-Assistent", angemeldet: true, }, ]; /** * Seiten, deren Adresse erst zur Laufzeit feststeht — die Kennung eines * Geräts oder Fahrzeugs kennt das Skript vorher nicht. */ const DYNAMISCHE_AUFNAHMEN: Array<{ name: string; beschreibung: string; /** Findet den Link auf der Übersichtsseite. */ von: string; linkMuster: RegExp; }> = [ { name: "04-geraet", beschreibung: "Gerätedetail mit QR-Code und Verlauf", von: "/geraete", linkMuster: /^\/geraete\/[0-9a-f-]{36}$/, }, { name: "06-fahrzeug", beschreibung: "Fahrzeugdetail mit Beladung nach Geräteräumen", von: "/fahrzeuge", linkMuster: /^\/fahrzeuge\/[0-9a-f-]{36}$/, }, ]; /** * Wartet, bis die Seite wirklich fertig ist. * * "networkidle" genügt nicht: Listen holen ihre Daten über tRPC nach, und der * Aufruf startet erst, nachdem React im Browser gelaufen ist. Ohne dieses * Warten zeigen die Bilder Platzhalterbalken statt echter Daten. */ async function fertigGeladen(seite: Page): Promise { await seite.waitForLoadState("networkidle"); try { await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, { timeout: 15_000, }); } catch { console.warn("[screenshots] Ladeanzeige blieb stehen — Bild wird trotzdem gemacht."); } await seite.waitForTimeout(500); } async function anmelden(seite: Page): Promise { await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" }); await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT); await seite.getByRole("button", { name: "Anmelden" }).click(); await seite.waitForURL(`${BASIS}/`, { timeout: 20_000 }); await seite.waitForLoadState("networkidle"); } async function main(): Promise { await mkdir(ZIEL, { recursive: true }); const browser = await chromium.launch(); for (const darstellung of ["light", "dark"] as const) { const kontext = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, locale: "de-DE", timezoneId: "Europe/Berlin", colorScheme: darstellung, }); const seite = await kontext.newPage(); let istAngemeldet = false; for (const aufnahme of AUFNAHMEN) { if (aufnahme.angemeldet && !istAngemeldet) { await anmelden(seite); istAngemeldet = true; } await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" }); await fertigGeladen(seite); const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`); await seite.screenshot({ path: datei, fullPage: true }); console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`); } // Detailseiten: die erste Kennung von der jeweiligen Übersicht holen. for (const aufnahme of DYNAMISCHE_AUFNAHMEN) { await seite.goto(`${BASIS}${aufnahme.von}`, { waitUntil: "networkidle" }); await fertigGeladen(seite); const ziele = await seite .locator("a[href]") .evaluateAll((elemente) => elemente .map((element) => element.getAttribute("href") ?? "") .filter((href) => href.startsWith("/")), ); const treffer = ziele.find((href) => aufnahme.linkMuster.test(href)); if (!treffer) { console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`); continue; } await seite.goto(`${BASIS}${treffer}`, { waitUntil: "networkidle" }); await fertigGeladen(seite); const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`); await seite.screenshot({ path: datei, fullPage: true }); console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`); } await kontext.close(); } await browser.close(); console.info("[screenshots] Fertig."); } main().catch((fehler) => { console.error("[screenshots] Abbruch:", fehler); process.exit(1); });