Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.
Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.
Schwer:
- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
(/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.
Mittel:
- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
Graph-Leser sich im Kommentar darauf berief.
Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.
Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.
1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
226 lines
10 KiB
TypeScript
226 lines
10 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"]);
|
||
|
||
/*
|
||
* **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();
|
||
});
|