/** * 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 Browser, 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, }, { name: "10-geraet-neu", pfad: "/geraete/neu", beschreibung: "Formular für ein neues Gerät", angemeldet: true, }, // Phase 2 — Wartung und Prüfung { name: "11-fristen", pfad: "/pruefungen", beschreibung: "Fristen-Dashboard mit Zeitfenstern und Bündelung", angemeldet: true, }, { name: "12-vorlagen", pfad: "/pruefungen/vorlagen", beschreibung: "Wartungsvorlagen mit Intervall und Prüfvorschrift", angemeldet: true, }, { name: "13-maengel", pfad: "/maengel", beschreibung: "Mängel von der Meldung bis zur Behebung", angemeldet: true, }, { name: "14-rueckrufe", pfad: "/rueckrufe", beschreibung: "Rückrufe des Herstellers", angemeldet: true, }, { name: "15-lebensdauer", pfad: "/pruefungen/lebensdauer", beschreibung: "Geräte am Ende ihrer Nutzungsdauer", angemeldet: true, }, { name: "16-protokolle", pfad: "/pruefungen/verlauf", beschreibung: "Abgeschlossene Prüfprotokolle", angemeldet: true, }, // Phase 3 — Einsätze und Übungen { name: "19-einsaetze", pfad: "/einsaetze", beschreibung: "Einsatzliste mit Schnellerfassung", angemeldet: true, }, { name: "20-statistik", pfad: "/einsaetze/statistik", beschreibung: "Einsatzstatistik mit Hilfsfristen und Beteiligung", angemeldet: true, }, { name: "21-uebungen", pfad: "/uebungen", beschreibung: "Übungen mit Thema und Teilnehmerzahl", angemeldet: true, }, // Phase 4 — Terminal im Gerätehaus { name: "25-kiosk-kacheln", pfad: "/kiosk", beschreibung: "Kachelschirm des Terminals", angemeldet: true, }, { name: "26-kiosk-mangel", pfad: "/kiosk/mangel", beschreibung: "Mangel melden in drei Schritten", angemeldet: true, }, { name: "27-kiosk-monitor", pfad: "/kiosk/monitor", beschreibung: "Hallenmonitor im Vollbild", angemeldet: true, }, { name: "28-terminal-uebersicht", pfad: "/terminal", beschreibung: "Was am Terminal zusammenkommt — Sicht der Verwaltung", angemeldet: true, }, { name: "29-anmeldemarke", pfad: "/anmeldemarke", beschreibung: "Anmeldemarke fürs Handy", angemeldet: true, }, // Phase 5 — Atemschutz nach FwDV 7 { name: "31-atemschutz", pfad: "/atemschutz", beschreibung: "Atemschutz: laufende Überwachung und Tauglichkeit der Geräteträger", angemeldet: true, }, { name: "32-atemschutz-nachweise", pfad: "/atemschutz/nachweise", beschreibung: "G26.3 und Belastungsübungen erfassen", angemeldet: true, }, // Phase 6 — Kartenmodule { name: "34-karte", pfad: "/karte", beschreibung: "Karte mit Löschwasser, Objekten und Umkreissuche", angemeldet: true, }, { name: "35-objekte", pfad: "/karte/objekte", beschreibung: "Objekte mit Feuerwehrplan", angemeldet: true, }, { name: "36-wasserfoerderung", pfad: "/karte/wasserfoerderung", beschreibung: "Wasserförderung über lange Wegstrecke", angemeldet: true, }, // Phase 7 — Benachrichtigungen und Termine { name: "37-termine", pfad: "/termine", beschreibung: "Terminkalender mit Rückmeldung", angemeldet: true, }, { name: "38-benachrichtigungen", pfad: "/benachrichtigungen", beschreibung: "Eigene Zustellwege und Kalenderabonnement", angemeldet: true, }, { name: "39-vorlagen", pfad: "/benachrichtigungen/vorlagen", beschreibung: "Textvorlagen mit Platzhaltern und Vorschau", angemeldet: true, }, { name: "40-dienstausweis", pfad: "/ausweis", beschreibung: "Digitaler Dienstausweis mit QR-Code", 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; /** * Zusätzliche Bedingung an die Beschriftung des Links. Nötig bei den * Protokollen: dort stehen auch Entwürfe in der Liste, und die zeigen ein * halb leeres Bild. */ textMuster?: 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}$/, }, { name: "17-checkliste", beschreibung: "Checkliste einer Wartungsvorlage", von: "/pruefungen/vorlagen", linkMuster: /^\/pruefungen\/vorlagen\/[0-9a-f-]{36}$/, }, { name: "18-pruefprotokoll", beschreibung: "Abgeschlossenes Prüfprotokoll mit Checkliste und Unterschrift", von: "/pruefungen/verlauf", linkMuster: /^\/pruefungen\/[0-9a-f-]{36}$/, // Nur fertige Protokolle: die tragen eine Nummer, Entwürfe nicht. textMuster: /^\d{4}-\d{4}$/, }, { name: "22-einsatz", beschreibung: "Einsatzdetail mit Zeitleiste, Stärke und Verlauf", von: "/einsaetze", linkMuster: /^\/einsaetze\/[0-9a-f-]{36}$/, }, { name: "23-uebung", beschreibung: "Übung mit Anwesenheitsliste", von: "/uebungen", linkMuster: /^\/uebungen\/[0-9a-f-]{36}$/, }, { name: "33-atemschutz-ueberwachung", beschreibung: "Überwachungsschirm mit Trupps, Drücken und Rückzugsdruck", von: "/atemschutz", linkMuster: /^\/atemschutz\/[0-9a-f-]{36}$/, // Nicht die Überwachungen aus den E2E-Tests: die tragen "E2E" im Namen und // stehen ganz oben, weil sie nie beendet werden. Die Beschriftung des // Verweises enthält noch anderes, deshalb wird der ganze Text geprüft. textMuster: /^(?!.*E2E)/s, }, ]; /** * 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"); // Die Entwicklungsanzeige von Next.js gehört nicht in eine Anleitung. await seite .addStyleTag({ content: "nextjs-portal { display: none !important; }" }) .catch(() => undefined); 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); } /** * Anmelden — mit Wiederholung, weil der erste Versuch bei kaltem .next * regelmaessig ins Leere geht. * * Der Grund ist keine Schwaeche der Anwendung, sondern der * Entwicklungsserver: "networkidle" sagt, dass die Skripte geladen sind, nicht * dass React sie schon ausgefuehrt hat. Wird davor geklickt, laeuft der * onSubmit-Handler nicht, und das Formular schickt sich als gewoehnliches GET * ab — erkennbar daran, dass Adresse und Passwort in der Adresszeile stehen. * * Statt blind eine Wartezeit einzubauen, wird das erkannt und der Versuch * wiederholt. Beim zweiten Mal ist die Seite uebersetzt und hydriert. */ async function anmelden(seite: Page): Promise { for (let versuch = 1; versuch <= 3; versuch += 1) { 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(); try { // Grosszuegig: bei kaltem .next uebersetzt "next dev" jede Route erst // beim ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung. await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 }); await seite.waitForLoadState("networkidle"); return; } catch (fehler) { const nichtHydriert = seite.url().includes("?email="); if (versuch === 3 || !nichtHydriert) throw fehler; console.warn( `[screenshots] Anmeldung lief ins Leere (Seite war noch nicht hydriert) — Versuch ${versuch + 1}`, ); } } } /** * Das Einsatzportal lässt sich nicht über eine feste Adresse aufrufen — der * Link entsteht erst, wenn ihn jemand erzeugt. Also erzeugt ihn dieses Skript * selbst, im ersten Einsatz der Liste, und fotografiert das Ergebnis. */ async function portalAufnehmen(seite: Page, darstellung: string): Promise { try { await seite.goto(`${BASIS}/einsaetze`, { waitUntil: "networkidle" }); await fertigGeladen(seite); const ziele = await seite .locator("a[href]") .evaluateAll((elemente) => elemente.map((element) => element.getAttribute("href") ?? "").filter(Boolean), ); const einsatz = ziele.find((href) => /^\/einsaetze\/[0-9a-f-]{36}$/.test(href)); if (!einsatz) { console.warn("[screenshots] Kein Einsatz für das Portal gefunden — übersprungen."); return; } await seite.goto(`${BASIS}${einsatz}`, { waitUntil: "networkidle" }); await fertigGeladen(seite); await seite.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen"); await seite.getByRole("button", { name: "Erzeugen" }).click(); const linkfeld = seite.locator("input[readonly]").first(); await linkfeld.waitFor({ timeout: 15_000 }); const adresse = await linkfeld.inputValue(); await seite.goto(adresse, { waitUntil: "networkidle" }); await fertigGeladen(seite); const datei = path.join(ZIEL, `24-portal-${darstellung}.png`); await seite.screenshot({ path: datei, fullPage: true }); console.info(`[screenshots] Einsatzportal für Fremdwehren (${darstellung}) -> ${datei}`); } catch (fehler) { console.warn("[screenshots] Portalaufnahme fehlgeschlagen:", fehler); } } /** * Der Anmeldeschirm des Terminals. * * Braucht das Gegenteil aller anderen Aufnahmen: **keine** Sitzung, dafür ein * eingerichtetes Gerät. Beides zusammen gibt es nur in einem eigenen Kontext, * in dem vor dem ersten Aufruf die Geräteangaben im localStorage stehen. */ async function kioskAnmeldungAufnehmen( browser: Browser, darstellung: "light" | "dark", ): Promise { const kontext = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, locale: "de-DE", timezoneId: "Europe/Berlin", colorScheme: darstellung, }); try { await kontext.addInitScript( (angaben: { schluessel: string; wehr: string }) => { // Dieselben Schlüssel wie in lib/kiosk/geraet.ts. window.localStorage.setItem("florian.kiosk.geraeteschluessel", angaben.schluessel); window.localStorage.setItem("florian.kiosk.wehr", angaben.wehr); }, { schluessel: process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis", wehr: "ff-musterhausen", }, ); const seite = await kontext.newPage(); await seite.goto(`${BASIS}/kiosk`, { waitUntil: "networkidle" }); await fertigGeladen(seite); const datei = path.join(ZIEL, `30-kiosk-anmeldung-${darstellung}.png`); await seite.screenshot({ path: datei, fullPage: true }); console.info(`[screenshots] Anmeldung am Terminal (${darstellung}) -> ${datei}`); } catch (fehler) { console.warn("[screenshots] Anmeldeschirm des Terminals fehlgeschlagen:", fehler); } finally { await kontext.close(); } } async function main(): Promise { await mkdir(ZIEL, { recursive: true }); const browser = await chromium.launch({ // Der Mangel-Wizard startet die Kamera von selbst. Ohne Gerät stünde dort // eine Fehlermeldung; mit dem Testbild von Chromium zeigt das Bild, was am // Tablet zu sehen ist. args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"], }); 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) => ({ href: element.getAttribute("href") ?? "", text: (element.textContent ?? "").trim(), })) .filter((eintrag) => eintrag.href.startsWith("/")), ); const treffer = ziele.find( (eintrag) => aufnahme.linkMuster.test(eintrag.href) && (!aufnahme.textMuster || aufnahme.textMuster.test(eintrag.text)), )?.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 portalAufnehmen(seite, darstellung); await kontext.close(); // Eigener Kontext, weil dieser hier angemeldet ist. await kioskAnmeldungAufnehmen(browser, darstellung); } await browser.close(); console.info("[screenshots] Fertig."); } main().catch((fehler) => { console.error("[screenshots] Abbruch:", fehler); process.exit(1); });