617 lines
18 KiB
TypeScript
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);
|
|
});
|