Der Redakteur baut eine Vorlage jetzt vollstaendig in der Oberflaeche: Liste mit Suche und Filtern, Editor mit Abschnitten, Schritten und Variablen, Vorschau mit Beispielwerten, Veroeffentlichen mit Aenderungsnotiz, Versionsgeschichte. Sortiert wird mit dem Finger, der Maus, der Tastatur **und** zwei Knoepfen an jeder Zeile — ein Touch-Drag gelingt auf einem 390-px-Geraet nicht jedem, und die Reihenfolge ist fachlich zu wichtig fuer nur einen Weg. Schritte, Variablen und Abschnitte klappen zu: dieselbe Vorlage misst am Handy 3 482 statt 16 986 px. - src/lib/platzhalter.ts als einzige Quelle der eingebauten Platzhalter; der Editor warnt vor unbekannten, blockiert aber nichts - Hilfetexte als Markdown ohne rohes HTML, Bilder als Alternativtext - Erste Playwright-Tests: der Editor wird durch die Oberflaeche gefahren Wichtigster Befund des Meilensteins: `next dev` hydriert in dieser Umgebung ueberhaupt nicht — die Seiten sehen richtig aus, aber kein Knopf tut etwas (2 von 1302 Elementen tragen React-Knoten, im gebauten Stand 1281 von 1296). Nicht an diesem Code: auch ohne proxy.ts und ohne output: standalone. Ein Screenshot beweist damit nichts ueber Bedienbarkeit. Oberflaechen werden ab sofort gegen `pnpm build && pnpm start` abgenommen, und was nur mit angehaengtem React gelingt, gehoert in einen Playwright-Test. Der unabhaengige Review fand zwei Blocker: eine abgelaufene Anmeldung haette im Editor jede ungespeicherte Arbeit vernichtet (der Rechtefehler lief bis in Nexts Fehlergrenze), und das Speichern der Kopfdaten loeste eine archivierte Kundenzuordnung stillschweigend auf. Dazu sechs weitere Befunde, alle behoben. 496 Tests gruen (vorher 428), dazu 5 E2E-Tests. Schliesst #010 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
#!/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 } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { chromium, type Browser, type Cookie, 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>;
|
||
};
|
||
|
||
/** 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\/[^/]+$/);
|
||
},
|
||
},
|
||
];
|
||
|
||
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");
|
||
/** 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: "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`);
|
||
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.");
|