import { expect, type Locator, type Page } from "@playwright/test"; import "dotenv/config"; /** * Was alle Ende-zu-Ende-Abläufe teilen (#018). * * Bis hierher hatte jede Datei ihr eigenes `anmelden()` — zehnmal dasselbe, und * jede Verbesserung musste zehnmal nachgezogen werden. Wichtiger als die * Wiederholung ist aber, was hier sonst noch steht: **die Voraussetzungen eines * Laufs, geprüft statt im Kopfkommentar behauptet.** * * In #017 kostete beides einen halben Abend. Die Abläufe wählten ihre Vorlage * über die Position in der Liste, und der Testprozess schrieb seine Daten in * eine andere Datenbank, als der Server las. Beide Male sagte die Meldung nichts * über die Ursache: einmal „Timeout 30000ms exceeded“, einmal „0 Treffer“. */ /** Die Vorlage, mit der die Abläufe arbeiten — aus dem Seed, nicht aus einem Skript. */ export const SEED_VORLAGE = "PC Neuinstallation"; /** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */ export const SEED_GERAET = "PC-001"; /** * Ein Konto der Rolle `MITARBEITER`, nur fuer die Ablaeufe (#037). * * **Nicht im Seed**, obwohl das kuerzer waere: ein Konto mit bekanntem Passwort * gehoert nicht in einen Seed, der auch produktiv laeuft. Der E2E-Vorlauf legt * es an, der Nachlauf nimmt es wieder mit — es traegt `ZZ` im Namen und faellt * damit unter `tests/e2e/aufraeumen.ts`. * * Gebraucht wird es, weil `/meine-geraete` das Recht `selbstauskunft` verlangt * und das **nur** `MITARBEITER` hat: ein Administrator saehe dort „kein * Zugriff“, und ein Ablauf haette die Verweigerungsseite gemessen. */ export const E2E_MITARBEITER_MAIL = "zz.e2e.mitarbeiter@firma.example"; export const E2E_MITARBEITER_PASSWORT = "zz-e2e-nur-fuer-ablaeufe"; export const E2E_MITARBEITER_NAME = "ZZ E2E Mitarbeiterkonto"; export const SEED_TAG = "LZ-7K3F9Q"; export async function anmelden(seite: Page): Promise { 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 fehlen. Sie stehen in der .env und müssen zu dem " + "Administrator passen, den der Seed angelegt hat.", ); } /* * Wartet auf `/`, und zwar genau so streng wie vorher. Lockerer („irgendwas * ausser /anmelden“) sah harmlos aus und machte zwei Ablaeufe im vollen * Durchgang unzuverlaessig: der naechste Schritt begann, bevor die Uebersicht * stand. Der Admin landet immer auf `/` — fuer ihn gibt es keinen Grund, das * aufzuweichen. */ await anmeldenMit(seite, email, passwort, "**/"); } /** * Anmelden mit beliebigen Zugangsdaten. * * `ziel` ist das Muster, auf das gewartet wird. Ohne Angabe genuegt es, dass die * Anmeldeseite verlassen ist: ein `MITARBEITER` landet auf `/meine-geraete`, * weil `startseite(rolle)` das Ziel an der Rolle entscheidet (#035), und ein * Warten auf `/` liefe dort in den Zeitablauf. */ export async function anmeldenMit( seite: Page, email: string, passwort: string, ziel?: string, ): Promise { await seite.goto("/anmelden"); await seite.fill('input[name="email"]', email); await seite.fill('input[name="passwort"]', passwort); await Promise.all([ seite.waitForURL(ziel ?? ((adresse) => !adresse.pathname.startsWith("/anmelden"))), seite.click('button[type="submit"]'), ]); // Und bis die Seite wirklich steht: ein `waitForURL` endet an der Adresse, // nicht am geladenen Dokument. await seite.waitForLoadState("load"); } /** * Wählt eine Vorlage im Start-Dialog **am Namen**. * * Nicht über `{ index: 1 }`: das ist eine Annahme über die Sortierung der * Vorlagenliste, und sie hielt genau so lange, wie es eine einzige Vorlage gab. * Steht die gesuchte nicht zur Auswahl, sagt die Meldung, welche fehlt — nicht * „Timeout beim Warten auf die Adresse“. */ export async function vorlageWaehlen(seite: Page, name: string): Promise { const optionen = await seite .locator('select[name="vorlagenVersionId"] option') .evaluateAll((liste) => liste .filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement) .map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })), ); const treffer = optionen.find((eintrag) => eintrag.text.includes(name)); if (!treffer) { const vorhanden = optionen.map((eintrag) => eintrag.text).join(", "); throw new Error(`Die Vorlage „${name}“ steht nicht zur Auswahl. Zur Wahl stehen: ${vorhanden}`); } await seite.selectOption('select[name="vorlagenVersionId"]', treffer.wert); // Die Variablenfelder kommen nach, sobald der Server die Vorlage geliefert hat. await expect(seite.locator("fieldset")).toBeVisible(); } /** * Füllt alle Pflichtfelder des Start-Dialogs, die noch leer sind. * * Bewusst **nur leere**: ein Vorschlag aus den Namensregeln soll stehen bleiben, * sonst prüfte der Ablauf danach seine eigene Eingabe statt der Regel. */ export async function variablenFuellen(seite: Page, ausnahmen: readonly string[] = []): Promise { const felder = seite.locator("fieldset label"); for (let nummer = 0; nummer < (await felder.count()); nummer += 1) { const feld = felder.nth(nummer); const beschriftung = (await feld.locator("span").first().innerText()).replace("*", "").trim(); if (ausnahmen.some((ausnahme) => beschriftung.includes(ausnahme))) continue; const auswahl = feld.locator("select"); if ((await auswahl.count()) > 0) { await auswahl.selectOption({ index: 1 }); continue; } const eingabe = feld.locator("input"); if ((await eingabe.count()) === 0) continue; if ((await eingabe.inputValue()) !== "") continue; const typ = await eingabe.getAttribute("type"); if (typ === "date") await eingabe.fill("2026-10-01"); else if (typ === "number") await eingabe.fill("16"); else await eingabe.fill("E2E"); } } /** * Waehlt den Kunden, wo das Formular danach fragt. * * Im Modus INTERN gibt es das Feld nicht — die eigene Organisation ist gesetzt, * und der Server verwirft eine mitgeschickte Kennung ohnehin (#008). Im Modus * DIENSTLEISTUNG ist es Pflicht, und ohne Auswahl antwortet der Dienst mit * „Im Modus Dienstleistung muss ein Kunde gewählt werden“. * * `kundeId` gibt man an, wenn es auf **diesen** Kunden ankommt: ein Lauf kann * strukturell kein Geraet und keine Abteilung eines fremden Kunden waehlen, das * halten zusammengesetzte Fremdschluessel in der Datenbank fest. Ohne Angabe * nimmt der Helfer den ersten — richtig ueberall dort, wo nur irgendein Kunde * gebraucht wird. */ export async function kundeWaehlen( /** * Seite **oder** der Bereich, in dem das Feld steht. Der Bereich zaehlt, wo * mehrere Formulare nebeneinander stehen: die Geraeteseite hat eines zum * Anlegen und eines zum Einlesen, und `.first()` traefe dann das falsche. */ bereich: Page | Locator, kundeId?: string, ): Promise { const feld = bereich.locator('select[name="kundeId"]').first(); if ((await feld.count()) === 0 || !(await feld.isVisible())) return false; await feld.selectOption(kundeId ?? { index: 1 }); return true; } /** * Der Betriebsmodus der Installation unter Test. * * Gesetzt von `global-setup.ts` aus der Datenbank. Ein Ablauf, der nur in einem * der beiden Modi Sinn ergibt, fragt hier und ueberspringt sich mit einem Grund * — statt in einer Oberflaeche zu suchen, die es dort nicht gibt. */ export function betriebsmodus(): "INTERN" | "DIENSTLEISTUNG" { return process.env["E2E_BETRIEBSMODUS"] === "DIENSTLEISTUNG" ? "DIENSTLEISTUNG" : "INTERN"; } /** Das Beispielgerät heisst im Modus DIENSTLEISTUNG anders — dieselbe Regel wie im Seed. */ export function geraetName(): string { return betriebsmodus() === "DIENSTLEISTUNG" ? "MUS-PC-001" : SEED_GERAET; }