Files
Laufzettel/tests/e2e/vorlagen-editor.spec.ts
T
florian.wildeandClaude Opus 5 c78a6ac1af
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der
Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung
an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt
aenderbar, archiviert nicht.

Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn
Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort
keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes
Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen
und Einfuegen aus.

Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene
Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht
zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche
behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten.

Closes #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 16:56:33 +00:00

179 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* 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.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
* „Oberflaeche pruefen“.
*
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
* nicht ueber einen Kaskadenloeschvorgang — der Trigger aus #009 verweigert das
* ausdruecklich, und genau das ist die Zusage, an der das ganze Archiv haengt.
* Wer diese Tests oft laufen laesst, braucht deshalb eine Wegwerf-Datenbank
* (kommt mit #018).
*/
const NAME = `ZZ E2E Editor ${Date.now()}`;
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"]')]);
}
/** 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);
});
});