feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme
Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35 Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue Abhaengigkeit. Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf, Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf achtzehn Seiten. Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie, warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen Quelle um, in einer echten Installation also der produktiven. 101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9bd198a531
commit
10e272654f
+97
-66
@@ -1,6 +1,16 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
SEED_GERAET,
|
||||
SEED_TAG,
|
||||
SEED_VORLAGE,
|
||||
anmelden,
|
||||
kundeWaehlen,
|
||||
variablenFuellen,
|
||||
vorlageWaehlen,
|
||||
} from "./helfer";
|
||||
|
||||
/**
|
||||
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
|
||||
@@ -10,68 +20,57 @@ import { prisma } from "@/lib/db";
|
||||
* 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.
|
||||
* Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
|
||||
* **gebauter** Stand antwortet, `SEED_VORLAGE` ist veroeffentlicht, das
|
||||
* Beispielgeraet steht, und der Server liest dieselbe Datenbank wie dieser Lauf.
|
||||
* Gearbeitet wird mit dem Bestand aus `pnpm db:seed` — die Vorlage aus
|
||||
* `pnpm beispiel:vorlage` braucht hier niemand mehr (#018).
|
||||
*/
|
||||
|
||||
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"]')]);
|
||||
/**
|
||||
* Das Beispielgeraet, geholt ueber den **Tag-Code**.
|
||||
*
|
||||
* Nicht ueber `{ index: 1 }` und nicht ueber den Hostnamen: die Position in der
|
||||
* Liste haengt an der Sortierung, und „PC-001“ gibt es in einer benutzten
|
||||
* Datenbank mehr als einmal. Der Tag-Code gehoert genau einem Geraet — und dass
|
||||
* es dieses gibt, prueft der Global-Setup.
|
||||
*/
|
||||
async function seedGeraetId(): Promise<string> {
|
||||
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||||
where: { tagCode: SEED_TAG },
|
||||
select: { id: true },
|
||||
});
|
||||
return geraet.id;
|
||||
}
|
||||
|
||||
/** 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<string> {
|
||||
await seite.goto("/laeufe/starten");
|
||||
|
||||
await vorlageWaehlen(seite, SEED_VORLAGE);
|
||||
/*
|
||||
* 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.
|
||||
* Erst der Kunde, dann das Geraet — und zwar **der Kunde des Geraets**. Im
|
||||
* Modus DIENSTLEISTUNG fragt der Dialog danach, und ein Lauf kann strukturell
|
||||
* kein Geraet eines fremden Kunden waehlen: der zusammengesetzte
|
||||
* Fremdschluessel `Lauf_geraet_gleicher_kunde` weist das ab. „Irgendein Kunde“
|
||||
* waere hier also nicht ungenau, sondern rot. Im Modus INTERN gibt es das Feld
|
||||
* nicht.
|
||||
*/
|
||||
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 });
|
||||
const geraetId = await seedGeraetId();
|
||||
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||||
where: { id: geraetId },
|
||||
select: { kundeId: true },
|
||||
});
|
||||
await kundeWaehlen(seite, geraet.kundeId);
|
||||
// Das Geraet setzt `{{hostname}}` im Befehlsschritt – ohne es bliebe der
|
||||
// Platzhalter stehen, und der Test darauf pruefte nichts.
|
||||
await seite.selectOption('select[name="geraetId"]', geraetId);
|
||||
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 variablenFuellen(seite);
|
||||
|
||||
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
|
||||
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
|
||||
@@ -153,8 +152,11 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
|
||||
await laufStarten(page, `${TITEL} B`);
|
||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||
|
||||
// Am Schritt und nicht ueber `.first()`: welcher Schritt zuerst kommt, ist
|
||||
// eine Annahme ueber die Vorlage (#018).
|
||||
const schrittZuvor = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
|
||||
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
|
||||
await page.getByRole("button", { name: "nicht zutreffend" }).first().click();
|
||||
await schrittZuvor.getByRole("button", { name: "nicht zutreffend" }).click();
|
||||
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
|
||||
await expect(speichern).toBeVisible();
|
||||
await expect(speichern).toBeDisabled();
|
||||
@@ -211,15 +213,26 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
|
||||
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.
|
||||
* Am **Schritt**, der den Platzhalter traegt, und nicht am ersten `pre.mono`
|
||||
* der Seite: nur dort beweist „enthaelt `{{hostname}}` nicht“ etwas. Ein
|
||||
* Befehl ohne Platzhalter waere auch gruen, wenn nie etwas eingesetzt wuerde.
|
||||
*/
|
||||
const befehl = page.locator("li pre.mono").first();
|
||||
const schritt = page.getByRole("listitem").filter({ hasText: "Hostname gesetzt" });
|
||||
const befehl = schritt.locator("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();
|
||||
// Und was stattdessen dasteht: der Name des gewaehlten Geraets.
|
||||
await expect(befehl).toContainText(SEED_GERAET);
|
||||
await expect(schritt.getByRole("button", { name: "Befehl kopieren" })).toBeVisible();
|
||||
|
||||
/*
|
||||
* Die Skript-Kopie steckt in einem zugeklappten `details` und steht **je
|
||||
* Abschnitt**, der Befehle hat — bei dieser Vorlage in dreien. Geprueft wird
|
||||
* deshalb der Abschnitt dieses Schritts, nicht „irgendwo auf der Seite“:
|
||||
* ohne die Eingrenzung fand der Test drei Treffer und brach ab (#018).
|
||||
*/
|
||||
const abschnitt = page.locator("section").filter({ has: schritt });
|
||||
await expect(abschnitt.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
@@ -285,7 +298,23 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
*/
|
||||
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
|
||||
|
||||
/*
|
||||
* Die Namensregeln des Hauses, wie sie vorher dastanden.
|
||||
*
|
||||
* Sie werden fuer diese beiden Ablaeufe gesetzt und hinterher **zurueckgestellt**
|
||||
* — nicht auf `{}` geleert. Genau das tat der Nachlauf vorher, und damit nahm
|
||||
* er der Datenbank die Regeln, die `pnpm db:seed` eingerichtet hatte: in der
|
||||
* Entwicklungsdatenbank stand danach `namensregeln = {}`, und der Schnellstart
|
||||
* eines Onboardings schlug ohne erkennbaren Grund fehl (#018).
|
||||
*/
|
||||
let namensregelnVorher: Prisma.JsonValue = {};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const vorher = await prisma.einstellung.findUniqueOrThrow({
|
||||
where: { id: "global" },
|
||||
select: { namensregeln: true },
|
||||
});
|
||||
namensregelnVorher = vorher.namensregeln;
|
||||
await prisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: {
|
||||
@@ -318,21 +347,19 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 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 vorlageWaehlen(page, VORLAGE);
|
||||
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
|
||||
/*
|
||||
* Im Modus DIENSTLEISTUNG braucht der Vorschlag einen Kunden: die Regeln
|
||||
* koennen je Kunde eigene sein, und `istVergeben` fragt innerhalb seiner
|
||||
* Grenze. Ohne Auswahl antwortet die Aktion mit „Im Modus Dienstleistung
|
||||
* muss ein Kunde gewählt werden“ und die Felder bleiben leer.
|
||||
*/
|
||||
await kundeWaehlen(page);
|
||||
|
||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||
|
||||
@@ -351,8 +378,9 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await page.goto("/laeufe/starten");
|
||||
await vorlageWaehlen(page);
|
||||
await vorlageWaehlen(page, VORLAGE);
|
||||
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
|
||||
await kundeWaehlen(page);
|
||||
await page.getByLabel("upn").fill("chefin@firma.de");
|
||||
|
||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||
@@ -367,7 +395,10 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
select: { id: true },
|
||||
});
|
||||
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
|
||||
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } });
|
||||
await prisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: { namensregeln: namensregelnVorher as Prisma.InputJsonValue },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user