import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; /** * Ein Lauf von vorn bis hinten durch die Oberflaeche (#013). * * Das ist der Hauptablauf des ganzen Auftrags: Lauf starten, abarbeiten, * abschliessen, archivieren — und danach ist er unveraenderlich. Geprueft wird, * 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. */ 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"]')]); } /** 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 { await seite.goto("/laeufe/starten"); /* * 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. */ 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 }); 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 seite.getByRole("button", { name: "Lauf anlegen" }).click(); await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 }); return new URL(seite.url()).pathname; } test.describe("Ein Lauf von vorn bis hinten", () => { test("starten, abarbeiten, abschließen, archivieren", async ({ page }) => { test.slow(); await anmelden(page); const pfad = await laufStarten(page, TITEL); // Ein neuer Lauf ist **offen** – das ist die Zuweisung, nicht der Start. await expect(page.getByRole("button", { name: "Lauf beginnen" })).toBeVisible(); await expect(page.getByRole("button", { name: "Lauf abschließen" })).toHaveCount(0); await page.getByRole("button", { name: "Lauf beginnen" }).click(); await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible(); // Solange Pflichtschritte offen sind, ist der Abschluss gesperrt. await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeDisabled(); /* * Alle Schritte erledigen – einzeln und abschnittsweise, so wie ein * Techniker es täte. Nach jedem Haken wird auf den **Zähler im Kopf des * Abschnitts** gewartet: der steht erst, wenn der Server gespeichert hat. * Ohne dieses Warten trifft der nächste Klick einen Knopf aus dem alten * Baum — und weil ein Klick auf einen schon gehakten Schritt ihn * zurücknimmt, zählte der Test rückwärts. */ const abschnitte = page.locator("section").filter({ has: page.locator("button[aria-expanded]") }); const anzahlAbschnitte = await abschnitte.count(); expect(anzahlAbschnitte).toBeGreaterThan(0); for (let i = 0; i < anzahlAbschnitte; i += 1) { const abschnitt = abschnitte.nth(i); const kopf = abschnitt.locator("button[aria-expanded]").first(); if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); // Über die Haken zählen, nicht über `listitem`: die Kommentare eines // Schritts stehen ebenfalls in einer Liste und zählten sonst mit. const schritte = await abschnitt.getByRole("button", { name: "erledigt", exact: true }).count(); for (let j = 0; j < schritte; j += 1) { await abschnitt.getByRole("button", { name: "erledigt", exact: true }).nth(j).click(); await expect(kopf).toContainText(`${j + 1} von ${schritte}`); } } /* * Der Beweis, dass es nicht nur im Browser steht: neu laden. Alles ist vom * Tisch, also sind alle Abschnitte zugeklappt — erst wieder aufklappen und * dann nachsehen. */ await page.reload(); const ersterAbschnitt = abschnitte.first(); await ersterAbschnitt.locator("button[aria-expanded]").first().click(); await expect( ersterAbschnitt.getByRole("button", { name: "erledigt", exact: true }).first(), ).toHaveAttribute("aria-pressed", "true"); await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeEnabled(); await page.getByRole("button", { name: "Lauf abschließen" }).click(); await expect(page.getByRole("button", { name: "archivieren" })).toBeVisible(); // Archivieren ist die zweite, ausdrückliche Handlung – mit Rückfrage. await page.getByRole("button", { name: "archivieren" }).click(); await page.getByRole("button", { name: "Ja, archivieren" }).click(); await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible(); // Und danach ändert sich nichts mehr: keine Knöpfe, kein Kommentarfeld. await page.goto(pfad); await expect(page.getByRole("button", { name: "erledigt", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Kommentar hinzufügen" })).toHaveCount(0); }); test("verlangt für „nicht zutreffend“ eine Begründung", async ({ page }) => { test.slow(); await anmelden(page); await laufStarten(page, `${TITEL} B`); await page.getByRole("button", { name: "Lauf beginnen" }).click(); // Der Klick allein speichert nicht – erst erscheint das Begründungsfeld. await page.getByRole("button", { name: "nicht zutreffend" }).first().click(); const speichern = page.getByRole("button", { name: "Mit Begründung speichern" }); await expect(speichern).toBeVisible(); await expect(speichern).toBeDisabled(); await page.getByLabel("Begründung").fill("Dieser Kunde hat kein Active Directory."); await expect(speichern).toBeEnabled(); await speichern.click(); /* * Erst auf den gespeicherten Stand warten, dann neu laden: die Aktion * frischt die Seite selbst auf, und ein `reload` mitten hinein bricht sie * ab. Am Handy war dieses Fenster groß genug, dass der Test daran * gescheitert ist. */ const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" }); await expect(schritt.getByText("nicht zutreffend", { exact: true }).last()).toBeVisible(); await page.reload(); // Die Begründung steht im Feld, wo sie sich auch ändern lässt – und nur dort. await expect(schritt.getByLabel("Begründung")).toHaveValue("Dieser Kunde hat kein Active Directory."); }); test("nimmt „nicht zutreffend“ auf Klick wieder zurück", async ({ page }) => { /* * Der Blocker aus dem Review zu #013: ein Klick auf die schon gewählte Pille * setzte den Zustand nur im Browser zurück. Auf dem Bildschirm war danach * nichts mehr gewählt, in der Datenbank stand weiter „nicht zutreffend“ — * und es gab keinen Weg mehr, das zu ändern. */ test.slow(); await anmelden(page); await laufStarten(page, `${TITEL} F`); await page.getByRole("button", { name: "Lauf beginnen" }).click(); const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" }); const pille = schritt.getByRole("button", { name: "nicht zutreffend" }); await pille.click(); await schritt.getByLabel("Begründung").fill("Gerät ist neu ausgepackt."); await schritt.getByRole("button", { name: "Mit Begründung speichern" }).click(); await expect(pille).toHaveAttribute("aria-pressed", "true"); // Zurücknehmen braucht keine Begründung – es nimmt ja gerade eine zurück. await pille.click(); // Erst das Speichern abwarten: ein `reload` mitten in der Aktion bricht sie ab. await page.waitForLoadState("networkidle"); await page.reload(); await expect(pille).toHaveAttribute("aria-pressed", "false"); await expect(schritt.getByLabel("Begründung")).toHaveCount(0); }); test("zeigt einen Befehl mit eingesetzten Werten und einem Kopier-Knopf", async ({ page }) => { test.slow(); await anmelden(page); 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. */ const befehl = page.locator("li 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(); }); test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { test.skip(!isMobile, "Nur im Projekt „handy“."); test.slow(); await anmelden(page); await laufStarten(page, `${TITEL} D`); const masse = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, })); expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); }); }); test.describe("Der Zustands-Endpunkt", () => { /* * Geprueft wird hier der **Route Handler**, nicht die Abfrage darunter: dass * er sein Recht selbst prueft, einen fremden oder unbekannten Lauf als „nicht * gefunden“ beantwortet und nichts zwischenspeichern laesst. Die Huelle unter * `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab — das ist genau der * Grund, warum es diesen Test gibt (Reviewbefund zu #013). */ test("antwortet ohne Anmeldung mit 401", async ({ request }) => { const antwort = await request.get("/api/laeufe/egal/zustand"); expect(antwort.status()).toBe(401); }); test("liefert angemeldet den Zustand ohne Befehle und ohne Zwischenspeicher", async ({ page }) => { test.slow(); await anmelden(page); const pfad = await laufStarten(page, `${TITEL} E`); const laufId = pfad.split("/").pop(); const antwort = await page.request.get(`/api/laeufe/${laufId}/zustand`); expect(antwort.status()).toBe(200); expect(antwort.headers()["cache-control"]).toBe("no-store"); const zustand: unknown = await antwort.json(); expect(Object.keys(zustand as object).sort()).toEqual([ "anzahlKommentare", "anzahlPflichtOffen", "geaendertAm", "schritte", "status", ]); expect(JSON.stringify(zustand)).not.toContain("Rename-Computer"); }); test("gibt einen Lauf, den es nicht gibt, als 404 zurück", async ({ page }) => { await anmelden(page); const antwort = await page.request.get("/api/laeufe/gibtsnicht/zustand"); expect(antwort.status()).toBe(404); }); }); test.describe("Namensvorschläge im Start-Dialog", () => { /* * Der Onboarding-Fall: die Person steht noch nicht im Verzeichnis, ihre * Anmeldenamen entstehen erst hier. Die Regeln liegen im Dienst (#012) — dass * der Dialog sie auch wirklich erreicht, sieht man nur so. */ const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`; test.beforeAll(async () => { await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: { upn: "{vorname}.{nachname}@firma.de", sam: "{v}{nachname}", mail: "{vorname}.{nachname}@firma.de", }, }, }); const vorlage = await prisma.vorlage.create({ data: { name: VORLAGE, kategorie: "ZZ Test" } }); const version = await prisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" }, }); for (const [position, schluessel] of ["upn", "sam", "mail"].entries()) { await prisma.vorlagenVariable.create({ data: { versionId: version.id, schluessel, beschriftung: schluessel, position }, }); } const abschnitt = await prisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Konto" }, }); await prisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto angelegt" }, }); await prisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, }); }); /** 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 page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid"); await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); /* * Genau der Fall aus dem Auftrag: Umlaute gefaltet, `sam` auf 20 Zeichen * gekuerzt. Die Zahl am Ende ist kein Zufall — traegt die Datenbank den * Namen schon (die Beispieldaten tun es), haengt die Regel einen Zaehler an. * Ob mit oder ohne, entscheidet der Bestand, deshalb steht hier ein Muster. */ await expect(page.getByLabel("upn")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u); await expect(page.getByLabel("sam")).toHaveValue(/^jmuellerluedensch\d*$/u); await expect(page.getByLabel("mail")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u); }); test("überschreibt nichts, was schon dasteht", async ({ page }) => { test.slow(); await anmelden(page); await page.goto("/laeufe/starten"); await vorlageWaehlen(page); await page.getByLabel("… oder Name als Freitext").fill("Maria Huber"); await page.getByLabel("upn").fill("chefin@firma.de"); await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); await expect(page.getByLabel("sam")).toHaveValue(/^mhuber\d*$/u); await expect(page.getByLabel("upn")).toHaveValue("chefin@firma.de"); }); test.afterAll(async () => { const vorlage = await prisma.vorlage.findFirst({ where: { name: VORLAGE }, select: { id: true }, }); if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } }); await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } }); }); }); test.afterAll(async () => { /* * Aufgeraeumt wird ueber die Datenbank: die Anwendung kennt fuer einen Lauf * keinen Loeschweg, und das ist richtig so. Ohne diesen Nachlauf saehe jede * Datenbank, gegen die diese Tests laufen, nach ein paar Durchlaeufen aus wie * ein Messie-Keller. */ const laeufe = await prisma.lauf.findMany({ where: { titel: { startsWith: "ZZ E2E Lauf" } }, 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 } } }); // `Protokoll.laufId` steht auf `Restrict` – ohne diese Zeile bleibt der Lauf // stehen, und der Fremdschluessel meldet sich. await prisma.protokoll.deleteMany({ where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] }, }); await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } }); } await prisma.$disconnect(); });