import { expect, test, type Locator, type Page } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; import { anmelden, geraetName, SEED_VORLAGE, variablenFuellen, vorlageWaehlen } from "./helfer"; /** * Der Hauptablauf des ganzen Auftrags, in einem Zug (#018). * * Anmelden → „PC Neuinstallation“ für das Beispielgerät starten → abarbeiten → * einen Schritt mit Begründung überspringen → abschließen → archivieren → im * Archiv über ein Wort aus der Begründung wiederfinden → und danach ändert sich * nichts mehr. * * Zwei Dinge unterscheiden diesen Ablauf von einem Klickprotokoll: * * 1. **Geprüft wird gegen die Datenbank.** Nach jeder Änderung wird neu geladen * und in der Datenbank nachgesehen. Ein Klick, der nur im Browser wirkt, sieht * auf einem Screenshot genauso aus wie einer, der gespeichert wurde — und * genau dieser Unterschied ist der Grund, warum es diese Datei gibt * (CLAUDE.md, „Oberfläche prüfen“). * 2. **Die Absage wird erzwungen.** „Nicht zutreffend“ ohne Begründung sperrt die * Oberfläche nur über einen deaktivierten Knopf. Ein deaktivierter Knopf ist * keine Zusage: dieser Ablauf entfernt ihn und schickt trotzdem ab, damit die * Absage vom **Server** bewiesen ist und nicht von einem HTML-Attribut. * * Der Ablauf legt an, was er braucht (einen eigenen Lauf mit `ZZ E2E `-Titel) und * räumt ihn hinterher weg. Vorausgesetzt wird nur der Seed-Bestand, den * `global-setup.ts` prüft. */ const STEMPEL = Date.now(); const TITEL = `ZZ E2E Hauptablauf ${STEMPEL}`; /** Der Schritt, der übersprungen wird — Pflicht, damit auch der Abschluss etwas davon hat. */ const UEBERSPRUNGEN = "Secure Boot und TPM 2.0 aktiv"; /** Der Schritt, in dem ein Wert erfasst wird. Typ TEXT in der Seed-Vorlage. */ const WERTSCHRITT = "Sondersoftware und Lizenzen des Benutzers notiert"; const WERT = `ZZ E2E Adobe Acrobat ${STEMPEL}`; /** * Das Wertfeld eines Schritts – ohne Annahme über das Element. * * `TEXT` mit `mehrzeilig` ist ein `textarea`, alles andere ein `input`. Ein * `input[name="wert"]` lief deshalb in den Zeitablauf, und die Meldung sprach von * einem fehlenden Element statt von der falschen Annahme. */ const WERTFELD = '[name="wert"]'; /** * Ein Wort, das es in der ganzen Datenbank nur einmal gibt. * * Über dieses Wort wird der Lauf später im Archiv wiedergefunden. Ohne Stempel * fände die Suche auch die Notizen früherer Durchläufe, und der Treffer sagte * nichts mehr über diesen hier. */ const NOTIZWORT = `Fabrikneu${STEMPEL}`; const NOTIZ = `${NOTIZWORT}: das Gerät kam versiegelt aus dem Karton, TPM war ab Werk aktiv.`; /** * Legt den Lauf über die Oberfläche an und gibt seine Adresse zurück. * * Bewusst nicht über Prisma: das Anlegen **ist** Teil des Hauptablaufs. Die * Vorlage wird am Namen gewählt (`vorlageWaehlen`), das Gerät am angezeigten * Namen — in beiden Betriebsmodi, denn im Modus DIENSTLEISTUNG heißt das * Beispielgerät anders und der Dialog fragt zusätzlich nach dem Kunden. */ async function laufStarten(seite: Page): Promise { await seite.goto("/laeufe/starten"); await vorlageWaehlen(seite, SEED_VORLAGE); const gesucht = geraetName(); const geraete = await seite .locator('select[name="geraetId"] option') .evaluateAll((liste) => liste .filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement) .map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })), ); const treffer = geraete.find((eintrag) => eintrag.text.includes(gesucht)); if (!treffer) { throw new Error( `Das Gerät „${gesucht}“ steht nicht zur Auswahl. Zur Wahl stehen: ` + `${geraete.map((eintrag) => eintrag.text).join(", ")}`, ); } await seite.selectOption('select[name="geraetId"]', treffer.wert); /* * Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden, und die Datenbank * verlangt, dass es **der Kunde des Geräts** ist: ein zusammengesetzter * Fremdschlüssel lässt keinen Lauf mit fremdem Gerät zu. Ein „nimm den * ersten“ wäre also nicht nur ungenau, sondern im Zweifel rot. */ const kundenWahl = seite.locator('select[name="kundeId"]'); if ((await kundenWahl.count()) > 0) { const geraet = await prisma.geraet.findFirst({ where: { OR: [{ hostname: gesucht }, { bezeichnung: gesucht }] }, select: { kunde: { select: { name: true } } }, }); if (!geraet) throw new Error(`Das Gerät „${gesucht}“ steht nicht in der Datenbank.`); await kundenWahl.selectOption({ label: geraet.kunde.name }); } const weitere = seite.locator("details").filter({ hasText: "Titel, Ticket" }); await weitere.locator("summary").click(); await weitere.locator('input[name="titel"]').fill(TITEL); await variablenFuellen(seite); await seite.getByRole("button", { name: "Lauf anlegen" }).click(); await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 }); return new URL(seite.url()).pathname; } /** Die Abschnitte der Abarbeitungsansicht – am aufklappbaren Kopf erkannt. */ function abschnitteVon(seite: Page): Locator { return seite.locator("section").filter({ has: seite.locator("button[aria-expanded]") }); } test.describe("Hauptablauf", () => { test("starten, abarbeiten, abschließen, archivieren, wiederfinden", async ({ page }) => { /* * Die Seed-Vorlage hat über dreißig Schritte, und jeder speichert einzeln — * so arbeitet ein Techniker, und so soll es geprüft werden. Das dauert; die * Voreinstellung von 30 Sekunden reicht dafür nicht. */ test.setTimeout(420_000); await anmelden(page); const pfad = await laufStarten(page); const laufId = pfad.split("/").pop() as string; // Ein neuer Lauf ist **offen**: das ist die Zuweisung, nicht der Start. expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("OFFEN"); await page.getByRole("button", { name: "Lauf beginnen" }).click(); const abschliessen = page.getByRole("button", { name: "Lauf abschließen" }); await expect(abschliessen).toBeVisible(); await expect(abschliessen).toBeDisabled(); // ---- Ein Wert in einem Textschritt -------------------------------------- const wertzeile = page.getByRole("listitem").filter({ hasText: WERTSCHRITT }); await wertzeile.locator(WERTFELD).fill(WERT); await wertzeile.getByRole("button", { name: "Wert speichern" }).click(); /* * Auf die **Datenbank** warten, nicht auf den Bildschirm: das Feld trägt den * Wert schon, bevor irgendetwas gespeichert ist — es ist ein kontrolliertes * Eingabefeld. Und ein `reload` mitten in der Aktion bricht sie ab, also erst * warten, dann neu laden. */ await expect .poll( async () => ( await prisma.schrittergebnis.findFirst({ where: { laufId, vorlagenSchritt: { titel: WERTSCHRITT } }, select: { wert: true }, }) )?.wert, { timeout: 30_000, message: `Der Wert des Schritts „${WERTSCHRITT}“ steht nicht in der Datenbank.` }, ) .toBe(WERT); await page.reload(); await expect(wertzeile.locator(WERTFELD)).toHaveValue(WERT); // ---- „Nicht zutreffend“ verlangt eine Begründung ------------------------ const zeile = page.getByRole("listitem").filter({ hasText: UEBERSPRUNGEN }); const pille = zeile.getByRole("button", { name: "nicht zutreffend" }); await pille.click(); const mitBegruendung = zeile.getByRole("button", { name: "Mit Begründung speichern" }); await expect(mitBegruendung).toBeVisible(); await expect(mitBegruendung).toBeDisabled(); /* * Und jetzt der eigentliche Nachweis: der gesperrte Knopf wird entsperrt und * das Pflichtfeld entschärft, dann wird abgeschickt. Ein deaktivierter Knopf * ist Bequemlichkeit — die Zusage („im Archiv steht kein übersprungener * Schritt ohne Grund“) muss der Server halten, und nur so ist bewiesen, dass * er es tut. */ await zeile.locator('textarea[name="notiz"]').evaluate((feld) => feld.removeAttribute("required")); await mitBegruendung.evaluate((knopf) => knopf.removeAttribute("disabled")); await mitBegruendung.click(); await expect(zeile.getByRole("alert")).toContainText("braucht eine Begründung"); expect(await prisma.schrittergebnis.count({ where: { laufId, zustand: "NICHT_ZUTREFFEND" } })).toBe(0); // Mit Begründung geht es – und danach steht sie in der Datenbank. await zeile.locator('textarea[name="notiz"]').fill(NOTIZ); await zeile.getByRole("button", { name: "Mit Begründung speichern" }).click(); await expect(pille).toHaveAttribute("aria-pressed", "true"); await expect .poll( async () => prisma.schrittergebnis.findFirst({ where: { laufId, vorlagenSchritt: { titel: UEBERSPRUNGEN } }, select: { zustand: true, notiz: true }, }), { timeout: 30_000, message: `Der Schritt „${UEBERSPRUNGEN}“ steht nicht als übersprungen da.` }, ) .toEqual({ zustand: "NICHT_ZUTREFFEND", notiz: NOTIZ }); await page.reload(); await expect(zeile.locator('textarea[name="notiz"]')).toHaveValue(NOTIZ); // ---- Den Rest abhaken -------------------------------------------------- const abschnitte = abschnitteVon(page); const anzahlAbschnitte = await abschnitte.count(); expect(anzahlAbschnitte).toBeGreaterThan(0); for (let nummer = 0; nummer < anzahlAbschnitte; nummer += 1) { const abschnitt = abschnitte.nth(nummer); const kopf = abschnitt.locator("button[aria-expanded]").first(); // Nach dem Neuladen ist zu, was fertig ist – erst aufklappen. if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); const zeilen = abschnitt.locator("> ul > li"); const anzahlZeilen = await zeilen.count(); for (let stelle = 0; stelle < anzahlZeilen; stelle += 1) { const schritt = zeilen.nth(stelle); // Was schon vom Tisch ist, bleibt es: ein zweiter Klick nähme es zurück. if ((await schritt.locator('button[aria-pressed="true"]').count()) > 0) continue; const haken = schritt.getByRole("button", { name: "erledigt", exact: true }); if ((await haken.count()) === 0) continue; await haken.click(); // Auf den **gespeicherten** Stand warten, nicht auf den nächsten Klick: // ohne das trifft er einen Knopf aus dem alten Baum. await expect(haken).toHaveAttribute("aria-pressed", "true"); } } // ---- Abschließen und archivieren --------------------------------------- await expect(abschliessen).toBeEnabled(); await abschliessen.click(); const archivieren = page.getByRole("button", { name: "archivieren" }); await expect(archivieren).toBeVisible(); expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ABGESCHLOSSEN"); await archivieren.click(); await page.getByRole("button", { name: "Ja, archivieren" }).click(); await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible(); expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ARCHIVIERT"); // ---- Im Archiv wiederfinden -------------------------------------------- await page.goto("/archiv"); await page.getByLabel("Suchbegriff").fill(NOTIZWORT); await page.getByRole("button", { name: "Suchen" }).click(); const treffer = page.getByRole("listitem").filter({ hasText: TITEL }); await expect(treffer).toHaveCount(1); // Und der Grund steht dabei – ein Treffer ohne sichtbare Fundstelle ist wertlos. await expect(treffer.getByText("Notiz")).toBeVisible(); await expect(treffer.getByText(new RegExp(NOTIZWORT))).toBeVisible(); // ---- Und danach greift der Schreibschutz ------------------------------- await treffer.getByRole("link").first().click(); await page.waitForURL(`**/laeufe/${laufId}`); await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible(); /* * **Erst aufklappen, dann prüfen.** An einem abgeschlossenen Lauf ist jeder * Abschnitt zugeklappt, und in einem zugeklappten Abschnitt steht überhaupt * nichts im Baum. Ein „keine Knöpfe gefunden“ wäre dort kein Nachweis für den * Schreibschutz, sondern nur einer dafür, dass niemand hingesehen hat. */ const archiviert = abschnitteVon(page); const anzahlArchiviert = await archiviert.count(); expect(anzahlArchiviert).toBeGreaterThan(0); for (let nummer = 0; nummer < anzahlArchiviert; nummer += 1) { const kopf = archiviert.nth(nummer).locator("button[aria-expanded]").first(); if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); await expect(kopf).toHaveAttribute("aria-expanded", "true"); } for (const knopf of [ "erledigt", "nicht zutreffend", "fehlgeschlagen", "Wert speichern", "Kommentar hinzufügen", "Abschnitt trifft nicht zu", "Lauf beginnen", "Lauf abschließen", ]) { await expect( page.getByRole("button", { name: knopf, exact: true }), `Am archivierten Lauf steht noch der Knopf „${knopf}“.`, ).toHaveCount(0); } /* * Genau **ein** Formular bleibt stehen, und das ist das Zurückholen: das Archiv * ist die Zusage, dass sich ein abgeschlossener Lauf nicht mehr ändert, und nur * ein Administrator darf sie aufheben. Gezählt wird deshalb auf eins und nicht * auf null — sonst hieße „grün“ hier irgendwann, dass auch der eine legitime * Weg verschwunden ist. */ await expect(page.getByRole("button", { name: "aus dem Archiv zurückholen" })).toBeVisible(); await expect(page.locator("main form")).toHaveCount(1); /* * Der Wert und die Begründung stehen weiter da – lesbar, nur nicht änderbar. * Ein Archiv, das seine Inhalte beim Sperren verliert, wäre kein Archiv. */ await expect(page.getByText(WERT)).toBeVisible(); await expect(page.getByText(NOTIZ)).toBeVisible(); }); }); test.afterAll(async () => { /* * Aufgeräumt wird über die Datenbank: für einen Lauf gibt es in der Anwendung * keinen Löschweg, und das ist richtig so. `Protokoll.laufId` steht auf * `Restrict` — ohne diese Zeile bleibt der Lauf stehen und der Fremdschlüssel * meldet sich. */ const laeufe = await prisma.lauf.findMany({ where: { titel: { startsWith: "ZZ E2E Hauptablauf" } }, select: { id: true }, }); const kennungen = laeufe.map((lauf) => lauf.id); if (kennungen.length > 0) { await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } }); await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } }); await prisma.protokoll.deleteMany({ where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] }, }); await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } }); } await prisma.$disconnect(); });