Files
FireAdministration/scripts/screenshots.ts
T

617 lines
18 KiB
TypeScript

/**
* 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,
},
// Phase 8 — Lokale KI
{
name: "41-wissen",
pfad: "/wiki",
beschreibung: "Wiki mit Wortsuche und Frage an die Unterlagen",
angemeldet: true,
},
{
name: "42-belege",
pfad: "/belege",
beschreibung: "Belege fotografieren und gegenzeichnen",
angemeldet: true,
},
{
name: "43-ki-protokoll",
pfad: "/ki-protokoll",
beschreibung: "Was die Maschine getan hat",
angemeldet: true,
},
{
name: "44-wiki-seite",
pfad: "/wiki/atemschutz-merkblatt",
beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf",
angemeldet: true,
},
// Phase 9 — Kassenverwaltung
{
name: "45-kasse",
pfad: "/kasse",
beschreibung: "Kassenübersicht mit den Beständen aller Konten",
angemeldet: true,
},
{
name: "46-kassenbuch",
pfad: "/kasse/buchungen",
beschreibung: "Das Kassenbuch mit Summen über alle Treffer",
angemeldet: true,
},
{
name: "47-beitraege",
pfad: "/kasse/beitraege",
beschreibung: "Mitgliedsbeiträge mit Rückstandsliste",
angemeldet: true,
},
{
name: "48-rechnungen",
pfad: "/kasse/rechnungen",
beschreibung: "Rechnungen an Dritte mit Nummernkreis",
angemeldet: true,
},
{
name: "49-kassenabschluss",
pfad: "/kasse/abschluss",
beschreibung: "Kassenprüfung mit Differenz und Erläuterung",
angemeldet: true,
},
// Phase 10 — Mandantenfähigkeit
{
name: "50-wehren",
pfad: "/wehren",
beschreibung: "Wehren dieser Installation, Konten und ihre Zuordnungen",
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<void> {
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);
/**
* Erst warten, dass React überhaupt gelaufen ist.
*
* Ohne diese Pause war die Bedingung unten in einem Fall erfüllt, in dem sie
* nichts bedeutet: „keine Ladeanzeige“ trifft auch zu, *bevor* sie erscheint.
* Bei einer Route, die der Entwicklungsserver erst übersetzen muss, kam der
* Auslöser genau in diesem Moment — und das Bild zeigte eine leere Seite mit
* Überschriften.
*/
await seite.waitForTimeout(1_500);
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);
}
/**
* 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<void> {
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<void> {
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<void> {
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<void> {
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);
});