Files
Laufzettel/scripts/screenshots.ts
T
florian.wildeandClaude Opus 5 40e95dbe5f
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 01:35:02 +00:00

156 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env tsx
/**
* Erzeugt die Screenshots reproduzierbar neu – fuer die Meilenstein-Belege unter
* docs/screenshots/<meilenstein>/ 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 (pnpm start oder pnpm dev). Adresse ueber
* E2E_APP_URL, Standard http://127.0.0.1:3000.
*/
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { chromium, type Browser, type Cookie } 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;
};
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
const SEITEN: readonly Seite[] = [
{ name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true },
{ name: "uebersicht", pfad: "/" },
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
];
function argument(name: string): string | undefined {
const index = process.argv.indexOf(`--${name}`);
return index >= 0 ? process.argv[index + 1] : undefined;
}
async function warteAufApp(): Promise<void> {
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<Cookie[]> {
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. */
async function kachelnErzeugen(browser: Browser): Promise<void> {
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" });
await seite.screenshot({ path: join("public", `icon-${kante}.png`), omitBackground: false });
await seite.close();
console.log(` public/icon-${kante}.png`);
}
}
const zielMeilenstein = argument("ziel");
const nur = argument("nur");
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);
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: "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" });
// 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`);
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.");