#!/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 als **gebauter Stand** (pnpm build && pnpm start). * Adresse ueber E2E_APP_URL, Standard http://127.0.0.1:3000. * * Ausdruecklich nicht gegen `pnpm dev`: dort haengt das Entwickler-Overlay im * Bild, und in dieser Umgebung hydriert der Entwicklungsmodus gar nicht — ein * Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas * tut. Siehe CLAUDE.md, Abschnitt „Oberflaeche pruefen“. */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { chromium, type Browser, type Cookie, type Locator, 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; /** * Hell aufnehmen statt dunkel. Fuer Druckseiten: ein Etikett ist immer hell, * und ein Bild im dunklen Farbschema zeigte etwas, das so nie aus einem * Drucker kommt (#014). */ readonly hell?: boolean; /** * Nur diesen Ausschnitt aufnehmen statt der ganzen Seite. Fuer Bilder, die * ein einzelnes Element zeigen sollen — eine ganze Seite waere dasselbe Bild * wie nebenan, nur groesser. */ readonly ausschnitt?: (tab: Page) => Locator; }; /** 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" }, { name: "vorlagen-liste", pfad: "/vorlagen" }, /* * Vorlagenseite und Editor haengen an Kennungen, die je Datenbank anders sind. * Statt sie zu erraten, geht das Skript den Weg, den auch ein Redakteur geht: * von der Liste aus klicken. Bricht der Weg, bricht der Screenshot — und das * ist die richtige Antwort, denn dann ist die Oberflaeche kaputt. */ { name: "vorlagen-seite", pfad: "/vorlagen", vorbereiten: async (tab) => { await tab .getByRole("link", { name: /PC-Neuinstallation/ }) .first() .click(); await tab.waitForURL(/\/vorlagen\/[^/]+$/); }, }, { name: "vorlagen-editor", pfad: "/vorlagen", vorbereiten: async (tab) => { await tab .getByRole("link", { name: /Entwurf öffnen/ }) .first() .click(); await tab.waitForURL(/\/vorlagen\/version\/[^/]+$/); }, }, { name: "mitarbeiter-liste", pfad: "/mitarbeiter" }, { name: "mitarbeiter-seite", pfad: "/mitarbeiter", vorbereiten: async (tab) => { await tab .getByRole("link", { name: /Maria Huber/ }) .first() .click(); await tab.waitForURL(/\/mitarbeiter\/[^/]+$/); }, }, { // Die gesperrten Stammdaten einer Person aus dem Verzeichnis – der Fall, // den man sonst erst nach dem ersten LDAP-Abgleich zu sehen bekaeme. name: "mitarbeiter-verzeichnis", pfad: "/mitarbeiter", vorbereiten: async (tab) => { await tab .getByRole("link", { name: /Katrin Winter/ }) .first() .click(); await tab.waitForURL(/\/mitarbeiter\/[^/]+$/); }, }, { name: "geraete-liste", pfad: "/geraete" }, { name: "laeufe-liste", pfad: "/laeufe" }, { name: "lauf-starten", pfad: "/laeufe/starten" }, { name: "lauf-abarbeiten", pfad: "/laeufe", vorbereiten: async (tab) => { await tab .getByRole("link", { name: /NB-001/ }) .first() .click(); await tab.waitForURL(/\/laeufe\/[^/]+$/); }, }, { // Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy // stuende er sonst weit unterhalb des Bildrands. name: "lauf-befehlsschritt", pfad: "/laeufe", vorbereiten: async (tab) => { await tab .getByRole("link", { name: /NB-001/ }) .first() .click(); await tab.waitForURL(/\/laeufe\/[^/]+$/); const schritt = tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }); await schritt.scrollIntoViewIfNeeded(); await tab.waitForTimeout(150); }, ausschnitt: (tab) => tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }), }, { name: "abteilungen", pfad: "/admin/abteilungen" }, { name: "scannen", pfad: "/scannen" }, { name: "tags", pfad: "/tags" }, { name: "geraeteseite", pfad: "/geraete", vorbereiten: async (tab) => { await tab.getByRole("link", { name: "NB-001" }).first().click(); await tab.waitForURL(/\/geraete\/[^/]+$/); }, }, { /* * Das Registrierformular gibt es nur zu einem Tag ohne Zuordnung. Welcher * das ist, weiss nur die Datenbank — also den Weg gehen, den auch ein * Techniker geht: von der Tag-Liste aus. */ name: "tag-registrieren", pfad: "/tags", vorbereiten: async (tab) => { await tab.getByRole("link", { name: /^LZ-/ }).first().click(); await tab.waitForURL(/\/t\/LZ-/); }, }, { name: "archiv", pfad: "/archiv?suche=Inventar" }, { name: "verzeichnis", pfad: "/admin/verzeichnis", nurModus: "INTERN" }, { /* * Der Verbindungstest zeigt seinen Wert erst mit Ergebnis: die Tabelle der * gemappten Felder. Also wird der Knopf gedrueckt und gewartet, bis der * Server geantwortet hat — ohne Container steht dort eine Fehlermeldung, * und auch die ist ein ehrliches Bild. */ name: "verbindungstest", pfad: "/admin/verzeichnis", nurModus: "INTERN", vorbereiten: async (tab) => { const knopf = tab.getByRole("button", { name: "Verbindung testen" }).first(); if ((await knopf.count()) === 0) return; await knopf.click(); await tab.waitForTimeout(4000); }, }, { /* * Das Abgleich-Log braucht einen Lauf, damit ueberhaupt etwas darin steht. * Also erst „Jetzt abgleichen“ druecken und dann aufklappen — ob der Lauf * gelingt, haengt am Testcontainer, und beide Ausgaenge sind ein ehrliches * Bild: die Zeile zeigt Zahlen oder den Grund. */ name: "abgleich-log", pfad: "/admin/verzeichnis", nurModus: "INTERN", vorbereiten: async (tab) => { const knopf = tab.getByRole("button", { name: "Jetzt abgleichen" }).first(); if ((await knopf.count()) > 0) { await knopf.click(); await tab.waitForTimeout(5000); await tab.reload({ waitUntil: "load" }); } const aufklappen = tab.locator("summary", { hasText: "Abgleich-Log" }).first(); if ((await aufklappen.count()) === 0) return; await aufklappen.click(); await tab.waitForTimeout(150); }, }, { name: "personen-import", pfad: "/mitarbeiter", vorbereiten: async (tab) => { await tab.locator("summary", { hasText: "CSV" }).first().click(); await tab.waitForTimeout(150); }, }, { name: "abteilungen-vorschlaege", pfad: "/admin/abteilungen" }, { /* * Die Druckansicht braucht einen Lauf, und welcher das ist, weiss nur die * Datenbank. Also den Weg gehen, den auch ein Mensch geht: von der Laufliste * aus zum Lauf und von dort auf „Drucken“. */ name: "druckansicht", pfad: "/laeufe", hell: true, vorbereiten: async (tab) => { await tab .getByRole("link", { name: /NB-001/ }) .first() .click(); await tab.waitForURL(/\/laeufe\/[^/]+$/); await tab.getByRole("link", { name: "Drucken" }).click(); await tab.waitForURL(/\/drucken\/lauf\//); }, }, { name: "etikett-62x29", pfad: "/drucken/etiketten?layout=etikett", hell: true }, { name: "etiketten-a4", pfad: "/drucken/etiketten?layout=bogen", hell: true }, ]; 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. * * Geschrieben wird nur, wenn sich die Bytes wirklich unterscheiden. Vorher legte * jeder Screenshot-Lauf zwei geaenderte PNG-Dateien in den Arbeitsbaum, obwohl das * Bild dasselbe war — und `git status` zeigte Aenderungen, die niemand gemacht * hatte (Reviewbefund zu #016). */ 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" }); const neu = await seite.screenshot({ omitBackground: false }); await seite.close(); const datei = join("public", `icon-${kante}.png`); const alt = await readFile(datei).catch(() => undefined); if (alt && alt.equals(neu)) { console.log(` ${datei} (unverändert)`); continue; } await writeFile(datei, neu); console.log(` ${datei}`); } } 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: seite.hell ? "light" : "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`); if (seite.ausschnitt) { await seite.ausschnitt(tab).screenshot({ path: datei }); } else { 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.");