Files
Laufzettel/scripts/screenshots.ts
T
florian.wilde fbbb634e56
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
2026-09-26 09:04:15 +00:00

515 lines
19 KiB
TypeScript
Raw Permalink 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 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<void>;
/**
* 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;
/**
* Bereiche, die im Bild hervorgehoben werden – Rahmen in der Signal-Lime mit
* einer Nummer, auf die der Text der Anleitung sich beziehen kann (#018).
* Ohne Angabe entsteht genau dasselbe Bild wie vorher.
*/
readonly markierungen?: readonly Markierung[];
};
/**
* Eine Markierung im Bild. Die Nummer kommt aus der Reihenfolge – sie im Text
* der Anleitung zu nennen ist der ganze Zweck („(1) zeigt den Fortschritt“).
*/
type Markierung = {
readonly ziel: (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",
// (1) Vorlage, (2) Geraet – die zwei Felder, ueber die das Kapitel „Lauf
// starten“ redet; alles darunter ist freiwillig.
markierungen: [
{ ziel: (tab) => tab.locator('select[name="vorlagenVersionId"]') },
{ ziel: (tab) => tab.locator('select[name="geraetId"]') },
],
},
{
name: "lauf-abarbeiten",
pfad: "/laeufe",
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /NB-001/ })
.first()
.click();
await tab.waitForURL(/\/laeufe\/[^/]+$/);
},
// (1) Fortschritt, (2) Zustandspillen des ersten Schritts, (3) Kommentare.
markierungen: [
{ ziel: (tab) => tab.getByRole("progressbar") },
{
ziel: (tab) =>
tab
.getByRole("listitem")
.filter({ has: tab.getByRole("button", { name: "erledigt", exact: true }) })
.first()
.getByRole("button", { name: "erledigt", exact: true })
// Nicht die Pille allein, sondern die Zeile mit allen drei Zustaenden.
.locator(".."),
},
{
ziel: (tab) =>
tab.locator("section").filter({ has: tab.getByRole("heading", { name: "Kommentare zum Lauf" }) }),
},
],
},
{
// 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\/[^/]+$/);
},
// (1) der Weg zum QR-Code, (2) was schon zugewiesen ist,
// (3) der Weg zu einer weiteren Vorlage.
markierungen: [
// Der Code selbst steht in einem zugeklappten `details` und waere im Bild
// nicht zu sehen – markiert wird deshalb der Griff, der ihn aufklappt.
{ ziel: (tab) => tab.locator("summary", { hasText: "QR-Code anzeigen" }) },
{ ziel: (tab) => tab.getByRole("heading", { name: "Zugewiesen" }).locator("..") },
{ ziel: (tab) => tab.getByRole("heading", { name: "Vorlage zuweisen" }).locator("..") },
],
},
{
/*
* 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<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.
*
* 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<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" });
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}`);
}
}
/**
* Legt die Markierungen als Overlay in die Seite, bevor das Bild entsteht.
*
* Gezeichnet wird im Browser und in Dokumentkoordinaten (`getBoundingClientRect`
* plus `scrollX`/`scrollY`), damit der Rahmen auch auf einem `fullPage`-Bild dort
* sitzt, wo er hingehoert. Nachbearbeitet wird kein Bild – wer die Oberflaeche
* aendert, laesst dieses Skript erneut laufen, und die Rahmen wandern mit.
*
* Findet eine Markierung ihr Ziel nicht, bricht der Lauf ab: ein Bild mit
* fehlender „(2)“ macht den Text der Anleitung falsch.
*/
async function markierungenEinspritzen(
tab: Page,
seitenname: string,
markierungen: readonly Markierung[],
): Promise<void> {
for (const [stelle, markierung] of markierungen.entries()) {
const ziel = markierung.ziel(tab).first();
if ((await ziel.count()) === 0) {
throw new Error(`Markierung ${stelle + 1} von "${seitenname}" findet ihr Ziel nicht.`);
}
await ziel.evaluate((element, nummer) => {
const kasten = element.getBoundingClientRect();
const links = kasten.left + window.scrollX;
const oben = kasten.top + window.scrollY;
const rahmen = document.createElement("div");
Object.assign(rahmen.style, {
position: "absolute",
left: `${links - 5}px`,
top: `${oben - 5}px`,
width: `${kasten.width + 10}px`,
height: `${kasten.height + 10}px`,
border: "2px solid #C6F135",
borderRadius: "14px",
pointerEvents: "none",
zIndex: "2147483647",
});
const marke = document.createElement("div");
marke.textContent = String(nummer);
Object.assign(marke.style, {
position: "absolute",
// Am linken Rand rutscht die Nummer nach innen, sonst schnitte sie das Bild ab.
left: links < 24 ? "4px" : "-14px",
top: "-14px",
width: "26px",
height: "26px",
borderRadius: "999px",
background: "#C6F135",
color: "#0B0D10",
font: "700 15px/26px ui-sans-serif, system-ui, sans-serif",
textAlign: "center",
});
rahmen.append(marke);
document.body.append(rahmen);
}, stelle + 1);
}
}
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");
if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen);
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.");