200 lines
8.9 KiB
TypeScript
200 lines
8.9 KiB
TypeScript
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<string> {
|
||
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"]);
|
||
|
||
// Der Anfasser ist mindestens 44 px hoch – sonst trifft ihn niemand.
|
||
const anfasser = page.getByRole("button", { name: "Verschieben" }).first();
|
||
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;
|
||
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 <= 12; schritt += 1) {
|
||
await sitzung.send("Input.dispatchTouchEvent", {
|
||
type: "touchMove",
|
||
touchPoints: [{ x, y: y + schritt * 25 }],
|
||
});
|
||
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();
|
||
});
|