#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen. Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in CLAUDE.md. Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt. tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen (Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch die Gegenrichtung. #037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf, dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird. Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen. Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen Durchgang haette ich zwei rote Ablaeufe gemergt. 1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur noch #024, und das verlangt einen echten Proxmox-Host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
7.8 KiB
TypeScript
185 lines
7.8 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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;
|
|
}
|