import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; import { anmelden } from "./helfer"; /** * Der Vorlagen-Editor durch die Oberflaeche (#010). * * Diese Datei gibt es, weil ein Screenshot ueber Bedienbarkeit **nichts** * aussagt: die Seiten werden serverseitig gerendert und sehen auch dann richtig * aus, wenn React sich nie anhaengt. Geprueft wird deshalb, was nur mit * angehaengtem React gelingt — anlegen, tippen, sortieren, speichern — und * anschliessend, ob es wirklich in der Datenbank steht: durch Neuladen. * * Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein * **gebauter** Stand antwortet, der Seed-Bestand steht, und der Server liest * dieselbe Datenbank wie dieser Lauf. Gegen `pnpm dev` schlaegt das hier fehl, * und das ist richtig so — siehe CLAUDE.md, „Oberflaeche pruefen“. * * Der Ablauf legt seine eigene Vorlage an, **archiviert** sie ueber die * Oberflaeche — das ist der Weg, den die Anwendung kennt, geloescht wird dort * nichts — und raeumt sie hinterher ueber die Datenbank weg. Ohne diesen * Nachlauf blieb nach jedem Durchgang eine archivierte „ZZ E2E Editor“-Vorlage * stehen; in der Entwicklungsdatenbank lagen davon zuletzt ueber hundert (#018). */ const NAME = `ZZ E2E Editor ${Date.now()}`; /** Legt die Vorlage an und fuehrt in ihren Entwurf. Gibt die Adresse des Editors zurueck. */ async function entwurfAnlegen(seite: Page, name: string): Promise { await seite.goto("/vorlagen"); await seite.getByLabel("Name").fill(name); await seite.getByRole("button", { name: "Vorlage anlegen" }).click(); await expect(seite.getByText(`„${name}“ wurde angelegt.`)).toBeVisible(); await seite.goto("/vorlagen"); await seite.getByRole("link", { name }).click(); await seite.getByRole("button", { name: /bearbeiten/i }).click(); await seite.waitForURL(/\/vorlagen\/version\//); return seite.url(); } async function aufraeumen(seite: Page, name: string) { await seite.goto("/vorlagen"); const verweis = seite.getByRole("link", { name }); if ((await verweis.count()) === 0) return; await verweis.click(); await seite.getByRole("button", { name: "archivieren" }).click(); await expect(seite.getByRole("button", { name: "wieder freigeben" })).toBeVisible(); } test.describe("Vorlagen-Editor", () => { test("legt an, baut auf, speichert und veröffentlicht", async ({ page }) => { test.slow(); const name = `${NAME} A`; await anmelden(page); await entwurfAnlegen(page, name); // Ein Abschnitt mit einem Befehlsschritt. await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click(); await page.getByLabel("Titel des Abschnitts").fill("Vorbereitung"); const leiste = page.locator("form.sticky"); await expect(leiste).toContainText("Nicht gespeicherte Änderungen"); await page.getByRole("button", { name: "Schritt hinzufügen" }).click(); await page.getByLabel("Titel", { exact: true }).last().fill("Rechner umbenennen"); await leiste.getByRole("button", { name: "Speichern" }).click(); await expect(leiste).toContainText("Entwurf gespeichert."); await expect(leiste).toContainText("Alles gespeichert"); // Der Beweis, dass es nicht nur im Browser steht: neu laden. await page.reload(); await expect(page.getByLabel("Titel des Abschnitts")).toHaveValue("Vorbereitung"); await expect(page.locator("form.sticky")).toContainText("Alles gespeichert"); // Veröffentlichen macht die Version unveränderlich. await page.getByLabel("Änderungsnotiz (optional)").fill("Erste Fassung"); await page.getByRole("button", { name: "Veröffentlichen", exact: true }).click(); /* * Die Erfolgsmeldung steht im Veroeffentlichen-Formular – und genau das ist * nach dem `router.refresh()` verschwunden. Geprueft wird deshalb der * Zustand danach, nicht die Meldung: das ist ohnehin die haerteren Zusage. */ await expect(page.locator("form.sticky")).toHaveCount(0); await expect(page.getByRole("button", { name: "Verschieben" })).toHaveCount(0); await expect(page.getByText(/ist veröffentlicht und wird nicht mehr verändert/)).toBeVisible(); await aufraeumen(page, name); }); test("warnt vor einem unbekannten Platzhalter, ohne zu blockieren", async ({ page }) => { test.slow(); const name = `${NAME} B`; await anmelden(page); await entwurfAnlegen(page, name); await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click(); await page.getByLabel("Titel des Abschnitts").fill("Installation"); await page.getByRole("button", { name: "Schritt hinzufügen" }).click(); await page.getByLabel("Titel", { exact: true }).last().fill("{{hostnmae}} setzen"); await expect(page.getByText(/gehört zu keiner Variablen/)).toBeVisible(); await expect(page.getByText("{{hostnmae}}", { exact: true })).toBeVisible(); // Die Warnung ist eine Warnung: Speichern bleibt möglich. const leiste = page.locator("form.sticky"); await leiste.getByRole("button", { name: "Speichern" }).click(); await expect(leiste).toContainText("Entwurf gespeichert."); // Ein eingebauter Platzhalter loest keine Warnung aus. await page.getByLabel("Titel", { exact: true }).last().fill("{{hostname}} setzen"); await expect(page.getByText(/gehört zu keiner Variablen/)).toHaveCount(0); await aufraeumen(page, name); }); test("sortiert am Handy mit dem Finger", async ({ page, isMobile, viewport }) => { test.skip(!isMobile, "Nur im Projekt „handy“ – am Schreibtisch gibt es keinen Finger."); test.slow(); const name = `${NAME} C`; await anmelden(page); await entwurfAnlegen(page, name); for (const titel of ["Erster", "Zweiter"]) { await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click(); await page.getByLabel("Titel des Abschnitts").last().fill(titel); } const werte = async () => page .getByLabel("Titel des Abschnitts") .evaluateAll((felder) => felder.map((feld) => (feld as HTMLInputElement).value)); expect(await werte()).toEqual(["Erster", "Zweiter"]); /* * **Erst in den Blick scrollen.** Am Handy stand der erste Anfasser nach dem * Anlegen zweier Abschnitte bei `y = -115`, also oberhalb des Sichtfensters; * der Touch-Start landete damit außerhalb und bewegte nichts. Das fiel * nicht auf, weil `pnpm test:e2e` nicht im Tor vor dem Commit steht * (Reviewbefund zu #029). */ const anfasser = page.getByRole("button", { name: "Verschieben" }).first(); await anfasser.scrollIntoViewIfNeeded(); const kasten = await anfasser.boundingBox(); expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44); expect(kasten?.width ?? 0).toBeGreaterThanOrEqual(44); // Halten, ziehen, loslassen. Der TouchSensor wartet bewusst 200 ms, damit // eine Wischgeste ein Bildlauf bleibt und kein Verschieben. const x = kasten!.x + kasten!.width / 2; const y = kasten!.y + kasten!.height / 2; /* * **Die Zugweite wird gemessen, nicht gesetzt.** Vorher waren es fest * 12 × 25 px = 300; der Abstand zwischen den beiden Anfassern ist aber * 532 px, seit mit #030 und #031 Bausteinverweis und Abteilungsfilter in * jeden Abschnitt gekommen sind. Der Finger kam damit nicht mehr über den * zweiten Abschnitt, und die Reihenfolge blieb stehen. * * Eine feste Weite in einem Sortiertest ist eine Zusage über das Layout, * die niemand pflegt. Gemessen wird sie bei jedem Lauf richtig — und wenn * der Abstand sich wieder ändert, bleibt der Test grün, statt eine * Pixelzahl nachzuziehen. */ const zweiterAnfasser = await page.getByRole("button", { name: "Verschieben" }).nth(1).boundingBox(); // Ein Stück über die Mitte des zweiten hinaus, sonst entscheidet dnd-kit // sich fuer „bleibt“. const weite = (zweiterAnfasser?.y ?? y + 300) - kasten!.y + kasten!.height; const schritte = 20; const sitzung = await page.context().newCDPSession(page); await sitzung.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); await page.waitForTimeout(350); for (let schritt = 1; schritt <= schritte; schritt += 1) { await sitzung.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + (weite * schritt) / schritte }], }); await page.waitForTimeout(40); } await sitzung.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await expect.poll(werte).toEqual(["Zweiter", "Erster"]); // Und dabei wird nicht horizontal gescrollt. const masse = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, })); expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); expect(viewport?.width).toBeLessThanOrEqual(430); await aufraeumen(page, name); }); }); test.afterAll(async () => { /* * Aufgeraeumt wird ueber die Datenbank, weil die Anwendung fuer eine Vorlage * bewusst keinen Loeschweg kennt — archivieren ist das Gegenteil von * Aufraeumen. Die veroeffentlichte Version steht dem nicht im Weg: der Trigger * aus #009 schuetzt Abschnitte und Schritte einer veroeffentlichten Version, * und beim Kaskadenloeschen ist die Version bereits fort, wenn sie fallen. * * An diesen Vorlagen haengt kein Lauf — dieser Ablauf startet keinen. Haengte * einer, verweigerte der Fremdschluessel das Loeschen, und das waere richtig. */ const vorlagen = await prisma.vorlage.findMany({ where: { name: { startsWith: "ZZ E2E Editor" } }, select: { id: true, versionen: { select: { id: true } } }, }); if (vorlagen.length === 0) { await prisma.$disconnect(); return; } const kennungen = vorlagen.flatMap((vorlage) => [ vorlage.id, ...vorlage.versionen.map((version) => version.id), ]); // Das Protokoll haelt Anlegen und Veroeffentlichen fest; `entitaetId` ist kein // Fremdschluessel, die Zeilen bleiben also sonst als Waisen stehen. await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } }); await prisma.vorlage.deleteMany({ where: { id: { in: vorlagen.map((zeile) => zeile.id) } } }); await prisma.$disconnect(); });