#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen. Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in CLAUDE.md. Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt. tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen (Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch die Gegenrichtung. #037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf, dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird. Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen. Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen Durchgang haette ich zwei rote Ablaeufe gemergt. 1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur noch #024, und das verlangt einen echten Proxmox-Host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
||
import "dotenv/config";
|
||
import { prisma } from "@/lib/db";
|
||
import {
|
||
E2E_MITARBEITER_MAIL,
|
||
E2E_MITARBEITER_PASSWORT,
|
||
SEED_TAG,
|
||
SEED_VORLAGE,
|
||
anmelden,
|
||
anmeldenMit,
|
||
betriebsmodus,
|
||
} from "./helfer";
|
||
|
||
/**
|
||
* Am Handy scrollt nichts nach rechts (#018).
|
||
*
|
||
* Die Zusage steht in CLAUDE.md und im Auftrag: **bei 390 px kein horizontales
|
||
* Scrollen**, auf keiner Hauptseite. Sie ist keine Geschmacksfrage. Ein Techniker
|
||
* arbeitet einen Lauf einhändig neben einem aufgeschraubten Rechner ab; eine
|
||
* Seite, die seitwärts wegläuft, kostet ihn bei jedem Haken einen Griff.
|
||
*
|
||
* Geprüft wird das schmalste Maß, das es gibt: `scrollWidth <= clientWidth` am
|
||
* `html`-Element. Und geprüft wird es **auf allen** Hauptseiten, nicht auf einer
|
||
* Auswahl — die eine Seite, die jemand vergisst, ist die, auf der es bricht.
|
||
*
|
||
* Die Meldung nennt die Seite, die Zahlen und das breiteste Element. „expected
|
||
* true“ beschreibt zwar denselben Fehler, aber niemand kann damit etwas anfangen:
|
||
* bei einer Seite mit dreihundert Kästen ist die Frage nicht *ob*, sondern *wo*.
|
||
*
|
||
* Der Ablauf legt an, was er braucht — einen Lauf für die Abarbeitungsansicht und
|
||
* einen Entwurf für den Editor — und räumt beides weg. Ein Ablauf, der sich
|
||
* darauf verlässt, dass zufällig ein Lauf in der Datenbank steht, scheitert gegen
|
||
* eine frische Installation mit „No record was found“, und das sagt nichts über
|
||
* die Ursache (Befund aus #017).
|
||
*/
|
||
|
||
const STEMPEL = Date.now();
|
||
const LAUF_TITEL = `ZZ E2E Mobil ${STEMPEL}`;
|
||
const ENTWURF_NAME = `ZZ E2E Mobil Entwurf ${STEMPEL}`;
|
||
|
||
let laufId = "";
|
||
let geraetId = "";
|
||
let mitarbeiterId = "";
|
||
let vorlageId = "";
|
||
let entwurfId = "";
|
||
let entwurfVorlageId = "";
|
||
|
||
/*
|
||
* Nur im Projekt „handy“. Auf dem Desktop prüfte derselbe Ablauf 1440 px gegen
|
||
* 1440 px und wäre immer grün — grün ohne Aussage ist schlimmer als rot.
|
||
*/
|
||
test.skip(({ isMobile }) => !isMobile, "Nur im Projekt „handy“ — 390 px ist die Zusage.");
|
||
|
||
/**
|
||
* **390 × 844, ausdrücklich.**
|
||
*
|
||
* Das Projekt „handy“ benutzt `devices["Pixel 7"]`, und das sind 412 × 839. Die
|
||
* Zusage lautet aber auf 390 px (CLAUDE.md, „Gestaltung“; Abnahmekriterium in
|
||
* #018), und 22 px sind kein Rundungsfehler: eine Seite, die bei 390 px
|
||
* ausbricht, kann bei 412 px tadellos aussehen. Die Zahl, auf die es ankommt,
|
||
* steht deshalb hier und nicht in einem Gerätenamen, dessen Maße sich mit der
|
||
* nächsten Playwright-Fassung ändern dürfen.
|
||
*/
|
||
test.use({ viewport: { width: 390, height: 844 } });
|
||
|
||
test.beforeAll(async () => {
|
||
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||
where: { tagCode: SEED_TAG },
|
||
select: { id: true, kundeId: true },
|
||
});
|
||
geraetId = geraet.id;
|
||
|
||
const person = await prisma.mitarbeiter.findFirst({
|
||
where: { aktiv: true, kundeId: geraet.kundeId },
|
||
orderBy: { anzeigename: "asc" },
|
||
select: { id: true },
|
||
});
|
||
if (!person) throw new Error("Es gibt keine aktive Person — pnpm db:seed laufen lassen.");
|
||
mitarbeiterId = person.id;
|
||
|
||
const vorlage = await prisma.vorlage.findFirstOrThrow({
|
||
where: { name: SEED_VORLAGE },
|
||
select: {
|
||
id: true,
|
||
versionen: {
|
||
where: { status: "VEROEFFENTLICHT" },
|
||
orderBy: { version: "desc" },
|
||
take: 1,
|
||
select: { id: true },
|
||
},
|
||
},
|
||
});
|
||
vorlageId = vorlage.id;
|
||
const versionId = vorlage.versionen[0]?.id;
|
||
if (!versionId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`);
|
||
|
||
/*
|
||
* Ein Lauf in Arbeit: er trägt die langen Befehlszeilen und die Hilfetexte, und
|
||
* das sind die beiden Stellen, an denen eine Seite am Handy nach rechts
|
||
* ausbricht. Der Kunde kommt vom Gerät — ein zusammengesetzter Fremdschlüssel
|
||
* lässt kein Gerät eines fremden Kunden zu.
|
||
*/
|
||
const lauf = await prisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: versionId,
|
||
kundeId: geraet.kundeId,
|
||
geraetId: geraet.id,
|
||
mitarbeiterId,
|
||
titel: LAUF_TITEL,
|
||
status: "IN_ARBEIT",
|
||
gestartetAm: new Date(),
|
||
},
|
||
select: { id: true },
|
||
});
|
||
laufId = lauf.id;
|
||
|
||
/*
|
||
* Ein eigener Entwurf für den Editor. Nicht „irgendein Entwurf“ aus der
|
||
* Datenbank: eine frische Installation bringt bewusst keinen mit (so in #017
|
||
* entschieden), und die Seed-Vorlagen zum Bearbeiten zu öffnen hinterließe
|
||
* einen Entwurf, den niemand angelegt hat.
|
||
*/
|
||
const eigene = await prisma.vorlage.create({
|
||
data: { name: ENTWURF_NAME, kategorie: "ZZ Test" },
|
||
select: { id: true },
|
||
});
|
||
entwurfVorlageId = eigene.id;
|
||
const entwurf = await prisma.vorlagenVersion.create({
|
||
data: { vorlageId: eigene.id, version: 1, status: "ENTWURF" },
|
||
select: { id: true },
|
||
});
|
||
entwurfId = entwurf.id;
|
||
await prisma.vorlagenVariable.create({
|
||
data: {
|
||
versionId: entwurf.id,
|
||
schluessel: "hostname",
|
||
beschriftung: "Hostname des neuen Geräts",
|
||
position: 0,
|
||
pflicht: true,
|
||
},
|
||
});
|
||
const abschnitt = await prisma.vorlagenAbschnitt.create({
|
||
data: { versionId: entwurf.id, titel: "Vorbereitung", position: 0 },
|
||
select: { id: true },
|
||
});
|
||
await prisma.vorlagenSchritt.create({
|
||
data: {
|
||
abschnittId: abschnitt.id,
|
||
titel: "Rechner umbenennen",
|
||
position: 0,
|
||
typ: "BEFEHL",
|
||
pflicht: true,
|
||
// Absichtlich lang: ein kurzer Befehl bricht keine Seite auf.
|
||
konfig: {
|
||
shell: "powershell",
|
||
befehl: 'Rename-Computer -NewName "{{hostname}}" -DomainCredential (Get-Credential) -Restart -Force',
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
/** Eine Hauptseite. Die Adresse kommt als Funktion, weil die Kennungen erst in `beforeAll` entstehen. */
|
||
type Hauptseite = {
|
||
readonly name: string;
|
||
readonly pfad: () => string;
|
||
/** Nur in diesem Betriebsmodus vorhanden. Sonst gibt es die Seite nicht. */
|
||
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
|
||
};
|
||
|
||
const SEITEN: readonly Hauptseite[] = [
|
||
{ name: "Übersicht", pfad: () => "/" },
|
||
{ name: "Läufe", pfad: () => "/laeufe" },
|
||
{ name: "Lauf-Detail", pfad: () => `/laeufe/${laufId}` },
|
||
{ name: "Lauf starten", pfad: () => "/laeufe/starten" },
|
||
{ name: "Vorlagen", pfad: () => "/vorlagen" },
|
||
{ name: "Vorlagen-Detail", pfad: () => `/vorlagen/${vorlageId}` },
|
||
{ name: "Vorlagen-Editor", pfad: () => `/vorlagen/version/${entwurfId}` },
|
||
{ name: "Mitarbeiter", pfad: () => "/mitarbeiter" },
|
||
{ name: "Mitarbeiter-Detail", pfad: () => `/mitarbeiter/${mitarbeiterId}` },
|
||
{ name: "Geräte", pfad: () => "/geraete" },
|
||
{ name: "Geräteseite", pfad: () => `/geraete/${geraetId}` },
|
||
{ name: "Archiv", pfad: () => "/archiv" },
|
||
{ name: "Tags", pfad: () => "/tags" },
|
||
{ name: "Scannen", pfad: () => "/scannen" },
|
||
{ name: "Admin: Einstellungen", pfad: () => "/admin" },
|
||
{ name: "Admin: Benutzer", pfad: () => "/admin/benutzer" },
|
||
{ name: "Admin: Abteilungen", pfad: () => "/admin/abteilungen" },
|
||
/*
|
||
* Das Verzeichnis gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG
|
||
* leitet die Seite auf `/admin` um. Sie dort zu messen hiesse, die Umleitung
|
||
* zu messen — die scrollt garantiert nicht.
|
||
*/
|
||
{ name: "Admin: Verzeichnis", pfad: () => "/admin/verzeichnis", nurModus: "INTERN" },
|
||
{ name: "Kunden", pfad: () => "/kunden", nurModus: "DIENSTLEISTUNG" },
|
||
/*
|
||
* **Die Seiten aus Phase 2.** Sie fehlten hier, obwohl jedes der Issues eine
|
||
* Klickprobe bei 390 px von Hand berichtet — eine Probe von Hand ist kein
|
||
* Regressionstest (Reviewbefund zu #029). Gerade die Bausteinliste und das
|
||
* Zustellungs-Protokoll sind Tabellen, und Tabellen sind die Stellen, an denen
|
||
* horizontales Scrollen entsteht.
|
||
*
|
||
* `/meine-geraete` steht **nicht** in dieser Liste, sondern in einem eigenen
|
||
* Block darunter: die Seite verlangt `selbstauskunft`, und das hat nur
|
||
* `MITARBEITER`. Mit der Anmeldung dieses Ablaufs (Admin) würde die
|
||
* Verweigerungsseite gemessen — und die scrollt garantiert nicht (#037).
|
||
*/
|
||
{ name: "Vorlagen: Bausteine", pfad: () => "/vorlagen/bausteine" },
|
||
{ name: "Admin: Schnellstart", pfad: () => "/admin/schnellstart" },
|
||
{ name: "Admin: Protokoll", pfad: () => "/admin/protokoll" },
|
||
{ name: "Admin: Zustellungen", pfad: () => "/admin/zustellungen" },
|
||
];
|
||
|
||
type Messung = {
|
||
readonly scroll: number;
|
||
readonly sichtbar: number;
|
||
readonly uebeltaeter: readonly string[];
|
||
};
|
||
|
||
/**
|
||
* Misst die Seite und benennt, was zu weit rechts endet.
|
||
*
|
||
* Gemeldet wird nur das **äußerste** Element je Kette: ein Kind, dessen Elter
|
||
* schon zu breit ist, sagt nichts Neues und würde die Meldung mit dreißig
|
||
* Einträgen zuschütten, die alle dieselbe Ursache haben.
|
||
*/
|
||
async function messen(seite: Page): Promise<Messung> {
|
||
return seite.evaluate(() => {
|
||
const wurzel = document.documentElement;
|
||
const sichtbar = wurzel.clientWidth;
|
||
const gemeldet: Element[] = [];
|
||
|
||
for (const element of Array.from(document.body.querySelectorAll("*"))) {
|
||
const kasten = element.getBoundingClientRect();
|
||
if (kasten.width === 0 && kasten.height === 0) continue;
|
||
// Ein Pixel Spielraum: gebrochene Bildschirmbreiten runden.
|
||
if (kasten.right <= sichtbar + 1) continue;
|
||
if (gemeldet.some((frueher) => frueher.contains(element))) continue;
|
||
gemeldet.push(element);
|
||
}
|
||
|
||
const benennen = (element: Element): string => {
|
||
const klassen = (element.getAttribute("class") ?? "")
|
||
.split(/\s+/u)
|
||
.filter(Boolean)
|
||
.slice(0, 3)
|
||
.join(".");
|
||
const text = (element.textContent ?? "").trim().replace(/\s+/gu, " ").slice(0, 40);
|
||
const breite = Math.round(element.getBoundingClientRect().right);
|
||
return `<${element.tagName.toLowerCase()}${klassen ? `.${klassen}` : ""}> endet bei ${breite} px${
|
||
text ? ` („${text}“)` : ""
|
||
}`;
|
||
};
|
||
|
||
return {
|
||
scroll: wurzel.scrollWidth,
|
||
sichtbar,
|
||
uebeltaeter: gemeldet.slice(0, 3).map(benennen),
|
||
};
|
||
});
|
||
}
|
||
|
||
test.describe("Am Handy bei 390 px", () => {
|
||
for (const hauptseite of SEITEN) {
|
||
test(`scrollt „${hauptseite.name}“ nicht horizontal`, async ({ page }) => {
|
||
test.slow();
|
||
test.skip(
|
||
hauptseite.nurModus !== undefined && hauptseite.nurModus !== betriebsmodus(),
|
||
`„${hauptseite.name}“ gibt es nur im Modus ${hauptseite.nurModus}.`,
|
||
);
|
||
await anmelden(page);
|
||
|
||
const pfad = hauptseite.pfad();
|
||
await page.goto(pfad);
|
||
/*
|
||
* Erst prüfen, dass die Seite überhaupt die gemeinte ist: eine
|
||
* Weiterleitung auf `/anmelden` scrollt garantiert nicht, und der Ablauf
|
||
* hätte grün gemeldet, ohne die Seite gesehen zu haben.
|
||
*/
|
||
expect(new URL(page.url()).pathname, `${pfad} hat weitergeleitet.`).toBe(pfad);
|
||
await page.waitForLoadState("load");
|
||
await expect(page.locator("main, body").first()).toBeVisible();
|
||
|
||
const masse = await messen(page);
|
||
expect(
|
||
masse.scroll,
|
||
`„${hauptseite.name}“ (${pfad}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` +
|
||
`war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` +
|
||
(masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""),
|
||
).toBeLessThanOrEqual(masse.sichtbar);
|
||
});
|
||
}
|
||
});
|
||
|
||
/**
|
||
* `/meine-geraete` braucht eine andere Anmeldung (#037).
|
||
*
|
||
* Eigener Block statt eines Sonderfalls in der Schleife oben: die Seitenliste
|
||
* dort gilt für **eine** Rolle, und ein `if` darin hätte beim nächsten Eintrag
|
||
* geerbt werden müssen. Die Messung selbst ist dieselbe Funktion.
|
||
*/
|
||
test.describe("Am Handy bei 390 px: die Selbstauskunft", () => {
|
||
const PFAD = "/meine-geraete";
|
||
|
||
test(`scrollt „Meine Geräte“ nicht horizontal`, async ({ page }) => {
|
||
test.slow();
|
||
await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT);
|
||
|
||
await page.goto(PFAD);
|
||
/*
|
||
* Dieselbe Vorsicht wie oben, hier noch wichtiger: hätte das Konto die Rolle
|
||
* verloren, landete es auf `/kein-zugriff` — und der Ablauf hätte grün
|
||
* gemeldet, ohne die Seite gesehen zu haben.
|
||
*/
|
||
expect(new URL(page.url()).pathname, `${PFAD} hat weitergeleitet.`).toBe(PFAD);
|
||
await page.waitForLoadState("load");
|
||
|
||
// Und es ist wirklich die Selbstauskunft, nicht eine leere Hülle: der Name
|
||
// der verknüpften Person steht darauf.
|
||
const person = process.env["E2E_MITARBEITER_PERSON"];
|
||
if (person) {
|
||
await expect(page.getByText(person, { exact: false }).first()).toBeVisible();
|
||
}
|
||
|
||
const masse = await messen(page);
|
||
expect(
|
||
masse.scroll,
|
||
`„Meine Geräte“ (${PFAD}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` +
|
||
`war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` +
|
||
(masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""),
|
||
).toBeLessThanOrEqual(masse.sichtbar);
|
||
});
|
||
|
||
test("sieht dort keinen Weg in die Verwaltung", async ({ page }) => {
|
||
/*
|
||
* Die Gegenprobe zur Rolle, und sie gehört hierher: die Seitenleiste nennt
|
||
* seit #035 an jedem Eintrag ihr Recht, damit einem `MITARBEITER` keine
|
||
* sechs Links angeboten werden, die alle auf `/kein-zugriff` führen. Das
|
||
* ist in Vitest als Rollenmatrix geprüft — dass es im **gerenderten** DOM
|
||
* ankommt, nur hier.
|
||
*/
|
||
await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT);
|
||
await page.goto("/meine-geraete");
|
||
|
||
for (const ziel of ["/admin", "/vorlagen", "/laeufe", "/mitarbeiter", "/geraete", "/archiv"]) {
|
||
await expect(
|
||
page.locator(`a[href^="${ziel}"]`),
|
||
`Ein MITARBEITER bekommt einen Link auf ${ziel} angeboten.`,
|
||
).toHaveCount(0);
|
||
}
|
||
});
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
if (laufId) {
|
||
await prisma.kommentar.deleteMany({ where: { laufId } });
|
||
await prisma.schrittergebnis.deleteMany({ where: { laufId } });
|
||
// `Protokoll.laufId` steht auf `Restrict` – sonst bleibt der Lauf stehen.
|
||
await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } });
|
||
await prisma.lauf.deleteMany({ where: { id: laufId } });
|
||
}
|
||
if (entwurfVorlageId) {
|
||
// Ein Entwurf lässt sich löschen; eine veröffentlichte Version nie. Deshalb
|
||
// wird hier einer angelegt und keiner veröffentlicht.
|
||
await prisma.vorlage.deleteMany({ where: { id: entwurfVorlageId } });
|
||
}
|
||
await prisma.$disconnect();
|
||
});
|