Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35 Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue Abhaengigkeit. Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf, Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf achtzehn Seiten. Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie, warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen Quelle um, in einer echten Installation also der produktiven. 101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
5.9 KiB
TypeScript
139 lines
5.9 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";
|
|
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.",
|
|
);
|
|
}
|
|
await seite.goto("/anmelden");
|
|
await seite.fill('input[name="email"]', email);
|
|
await seite.fill('input[name="passwort"]', passwort);
|
|
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|