Laufzettel läuft jetzt in zwei Betriebsmodi, aber nicht in zwei Codepfaden. Jede kundenbezogene Zeile trägt ohnehin schon eine Kunden-Zuordnung; im Modus INTERN ist das die eine Organisation, die in der Oberfläche nirgends erscheint. Der Wechsel ist deshalb eine Datenänderung und keine Migration — dieselbe Zeile bleibt, nichts hängt um. src/server/mandant.ts ist die eine Stelle, an der die Mandantenregel steht: kundeErmitteln verwirft im Modus INTERN eine mitgeschickte kundeId, verlangt im Modus DIENSTLEISTUNG eine Wahl und prüft, ob der Benutzer den Kunden überhaupt sehen darf. Die Sichtbarkeit steckte zunächst in einer zweiten Funktion daneben, an die jeder künftige Aufrufer selbst hätte denken müssen; da #009 diese Signatur übernimmt, ist sie jetzt eingebaut. Der unabhängige Review fand drei Blocker, alle behoben. Die Lesemodelle wandten die Mandantensicht nicht an. kundenListe gab jedem alle Kunden zurück, und sichtbareKundenIds hatte in der Produktion keinen einzigen Aufrufer — ein gebundener Techniker hätte Namen, Notizen und Bestandszahlen fremder Kunden gesehen. Die Funktionen verlangen jetzt den Benutzer und filtern selbst, wie CLAUDE.md es verlangt. Mein Kommentar "Alles in einer Transaktion" war falsch: der Protokolleintrag stand dahinter. Schlug er fehl, war der Modus gewechselt und der Vorgang stand nirgends. Und Prüfung und Vollzug lagen auseinander, sodass zwei gleichzeitige Wechsel beide durchliefen und ein zwischendurch angelegter Kunde zu Modus INTERN bei zwei Kunden führte. Beides reproduziert, beides geschlossen: alles in einer Transaktion, mit pg_advisory_xact_lock als erster Anweisung. Dazu sechs kleinere Befunde: ASCII-Faltung in Texten, die Menschen lesen (die Regel gilt für Bezeichner, nicht für Fließtext — steht jetzt so in CLAUDE.md); eine Erfolgsmeldung, die nach dem Wechsel unsichtbar blieb; eine Zusage im Dialog, die der Code nicht durchsetzt; fehlende Fehlerbehandlung beim Archivieren; ein Handy-Screenshot, der das Navigationsmenü statt des Dialogs zeigte. Der Wechsel wurde nicht nur im Service geprüft, sondern per Playwright durch die Oberfläche gefahren: anmelden, Dialog aufklappen, bestätigen, und danach steht die Navigation auf Kunden. 336 Tests. Closes #8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
6.9 KiB
TypeScript
189 lines
6.9 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 (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, 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" },
|
||
];
|
||
|
||
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.");
|