Files
Laufzettel/scripts/screenshots.ts
T
florian.wildeandClaude Opus 5 ab5777a57e
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Der Einstieg am Schreibtisch und das Gedaechtnis der Anwendung. Die Uebersicht
sortiert nach Dringlichkeit — ueberfaellig zuerst, und das ist das Einzige in
Warnfarbe; alles andere auf der Seite ist Auskunft.

Das Archiv sucht ueber Titel, Notizen, erfasste Werte und Kommentare in einer
SQL-Anweisung: `Schrittergebnis.wert` ist ein Json, und nur `("wert")::text`
trifft den Trigramm-Index aus #006. Vor allem aber gehoert die Fundstelle zum
Treffer — ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.

Die Druckansicht ist ein eigenes Lesemodell und kein Schalter an
`laufZumAbarbeiten`: dort entsteht beim Fuellen der Befehle ein Passwort, und auf
einem Blatt, das jemand abheftet, darf keines stehen. Vom Mitarbeiter holt sie
nur Name, UPN und Abteilung — nicht alles und dann gefiltert.

Der unabhaengige Review fand zwei Blocker, beide im Dashboard: „ueberfaellig“
verglich mit dem Augenblick statt mit dem Tagesbeginn, und der Schnellstart
zaehlte Laeufe fremder Kunden mit. Was „ueberfaellig“ heisst, steht seitdem in
`src/lib/fristen.ts` — eine Regel fuer Lesemodell und Anzeige.

Closes #15

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

341 lines
12 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 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 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;
};
/** 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" },
{
/*
* 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. */
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: 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.");