feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #36.
This commit is contained in:
2026-09-26 09:04:15 +00:00
parent 9bd198a531
commit fbbb634e56
104 changed files with 4527 additions and 299 deletions
+97 -66
View File
@@ -1,6 +1,16 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
SEED_GERAET,
SEED_TAG,
SEED_VORLAGE,
anmelden,
kundeWaehlen,
variablenFuellen,
vorlageWaehlen,
} from "./helfer";
/**
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
@@ -10,68 +20,57 @@ import { prisma } from "@/lib/db";
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
* Datenbank steht: durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt.
* Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
* **gebauter** Stand antwortet, `SEED_VORLAGE` ist veroeffentlicht, das
* Beispielgeraet steht, und der Server liest dieselbe Datenbank wie dieser Lauf.
* Gearbeitet wird mit dem Bestand aus `pnpm db:seed` — die Vorlage aus
* `pnpm beispiel:vorlage` braucht hier niemand mehr (#018).
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
async function anmelden(seite: Page) {
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.");
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"]')]);
/**
* Das Beispielgeraet, geholt ueber den **Tag-Code**.
*
* Nicht ueber `{ index: 1 }` und nicht ueber den Hostnamen: die Position in der
* Liste haengt an der Sortierung, und „PC-001“ gibt es in einer benutzten
* Datenbank mehr als einmal. Der Tag-Code gehoert genau einem Geraet — und dass
* es dieses gibt, prueft der Global-Setup.
*/
async function seedGeraetId(): Promise<string> {
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true },
});
return geraet.id;
}
/** Die Vorlage aus `pnpm beispiel:vorlage` – diese Ablaeufe arbeiten mit ihr. */
const BEISPIEL_VORLAGE = "PC-Neuinstallation (Beispiel)";
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
async function laufStarten(seite: Page, titel: string): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
/*
* Die Vorlage wird **am Namen** gewaehlt und nicht ueber `index: 1`. Seit dem
* Seed der sieben Vorlagen (#017) steht dort an erster Stelle, was die
* Sortierung gerade nach vorn stellt — bei einer frisch eingerichteten
* Installation etwa „Mitarbeiter-Offboarding“ mit Pflichtangaben, die dieser
* Helfer nicht kennt. Der Ablauf lief dann in den Zeitablauf, und die Meldung
* sagte nichts ueber die Ursache.
* Erst der Kunde, dann das Geraet — und zwar **der Kunde des Geraets**. Im
* Modus DIENSTLEISTUNG fragt der Dialog danach, und ein Lauf kann strukturell
* kein Geraet eines fremden Kunden waehlen: der zusammengesetzte
* Fremdschluessel `Lauf_geraet_gleicher_kunde` weist das ab. „Irgendein Kunde“
* waere hier also nicht ungenau, sondern rot. Im Modus INTERN gibt es das Feld
* nicht.
*/
const vorlagen = await seite
.locator('select[name="vorlagenVersionId"] option')
.evaluateAll((liste) =>
liste
.filter((option): option is HTMLOptionElement => option instanceof HTMLOptionElement)
.map((option) => ({ wert: option.value, text: option.textContent })),
);
const beispiel = vorlagen.find((eintrag) => (eintrag.text ?? "").includes(BEISPIEL_VORLAGE));
if (!beispiel) {
throw new Error(`Die Vorlage „${BEISPIEL_VORLAGE}“ fehlt. Erst „pnpm beispiel:vorlage“ laufen lassen.`);
}
await seite.selectOption('select[name="vorlagenVersionId"]', beispiel.wert);
await seite.selectOption('select[name="geraetId"]', { index: 1 });
const geraetId = await seedGeraetId();
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { id: geraetId },
select: { kundeId: true },
});
await kundeWaehlen(seite, geraet.kundeId);
// Das Geraet setzt `{{hostname}}` im Befehlsschritt – ohne es bliebe der
// Platzhalter stehen, und der Test darauf pruefte nichts.
await seite.selectOption('select[name="geraetId"]', geraetId);
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
await seite.getByLabel("Titel", { exact: true }).fill(titel);
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
for (const feld of await seite.locator("fieldset select").all()) {
await feld.selectOption({ index: 1 });
}
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
await feld.fill("16");
}
for (const feld of await seite.locator('fieldset input[type="date"]').all()) {
await feld.fill("2026-10-01");
}
for (const feld of await seite.locator('fieldset input[type="text"]').all()) {
// Nur leere Felder: die Namensvorschlaege sollen stehen bleiben.
if ((await feld.inputValue()) === "") await feld.fill("E2E");
}
await variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
@@ -153,8 +152,11 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
await laufStarten(page, `${TITEL} B`);
await page.getByRole("button", { name: "Lauf beginnen" }).click();
// Am Schritt und nicht ueber `.first()`: welcher Schritt zuerst kommt, ist
// eine Annahme ueber die Vorlage (#018).
const schrittZuvor = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
await page.getByRole("button", { name: "nicht zutreffend" }).first().click();
await schrittZuvor.getByRole("button", { name: "nicht zutreffend" }).click();
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
await expect(speichern).toBeVisible();
await expect(speichern).toBeDisabled();
@@ -211,15 +213,26 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
await laufStarten(page, `${TITEL} C`);
/*
* Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem
* zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie
* aufklappt.
* Am **Schritt**, der den Platzhalter traegt, und nicht am ersten `pre.mono`
* der Seite: nur dort beweist „enthaelt `{{hostname}}` nicht“ etwas. Ein
* Befehl ohne Platzhalter waere auch gruen, wenn nie etwas eingesetzt wuerde.
*/
const befehl = page.locator("li pre.mono").first();
const schritt = page.getByRole("listitem").filter({ hasText: "Hostname gesetzt" });
const befehl = schritt.locator("pre.mono").first();
await expect(befehl).toBeVisible();
await expect(befehl).not.toContainText("{{hostname}}");
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible();
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
// Und was stattdessen dasteht: der Name des gewaehlten Geraets.
await expect(befehl).toContainText(SEED_GERAET);
await expect(schritt.getByRole("button", { name: "Befehl kopieren" })).toBeVisible();
/*
* Die Skript-Kopie steckt in einem zugeklappten `details` und steht **je
* Abschnitt**, der Befehle hat — bei dieser Vorlage in dreien. Geprueft wird
* deshalb der Abschnitt dieses Schritts, nicht „irgendwo auf der Seite“:
* ohne die Eingrenzung fand der Test drei Treffer und brach ab (#018).
*/
const abschnitt = page.locator("section").filter({ has: schritt });
await expect(abschnitt.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
@@ -285,7 +298,23 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
*/
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
/*
* Die Namensregeln des Hauses, wie sie vorher dastanden.
*
* Sie werden fuer diese beiden Ablaeufe gesetzt und hinterher **zurueckgestellt**
* — nicht auf `{}` geleert. Genau das tat der Nachlauf vorher, und damit nahm
* er der Datenbank die Regeln, die `pnpm db:seed` eingerichtet hatte: in der
* Entwicklungsdatenbank stand danach `namensregeln = {}`, und der Schnellstart
* eines Onboardings schlug ohne erkennbaren Grund fehl (#018).
*/
let namensregelnVorher: Prisma.JsonValue = {};
test.beforeAll(async () => {
const vorher = await prisma.einstellung.findUniqueOrThrow({
where: { id: "global" },
select: { namensregeln: true },
});
namensregelnVorher = vorher.namensregeln;
await prisma.einstellung.update({
where: { id: "global" },
data: {
@@ -318,21 +347,19 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
});
});
/** Waehlt die Testvorlage – ueber den Wert, nicht ueber die Beschriftung. */
async function vorlageWaehlen(seite: Page) {
const wert = await seite
.locator('select[name="vorlagenVersionId"] option', { hasText: VORLAGE })
.getAttribute("value");
if (!wert) throw new Error(`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl.`);
await seite.selectOption('select[name="vorlagenVersionId"]', wert);
}
test("füllt upn, sam und mail nach den Regeln", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/laeufe/starten");
await vorlageWaehlen(page);
await vorlageWaehlen(page, VORLAGE);
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
/*
* Im Modus DIENSTLEISTUNG braucht der Vorschlag einen Kunden: die Regeln
* koennen je Kunde eigene sein, und `istVergeben` fragt innerhalb seiner
* Grenze. Ohne Auswahl antwortet die Aktion mit „Im Modus Dienstleistung
* muss ein Kunde gewählt werden“ und die Felder bleiben leer.
*/
await kundeWaehlen(page);
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
@@ -351,8 +378,9 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
test.slow();
await anmelden(page);
await page.goto("/laeufe/starten");
await vorlageWaehlen(page);
await vorlageWaehlen(page, VORLAGE);
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
await kundeWaehlen(page);
await page.getByLabel("upn").fill("chefin@firma.de");
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
@@ -367,7 +395,10 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
select: { id: true },
});
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } });
await prisma.einstellung.update({
where: { id: "global" },
data: { namensregeln: namensregelnVorher as Prisma.InputJsonValue },
});
});
});