Files
Laufzettel/tests/e2e/mobil.spec.ts
T
Florian WildeandClaude Opus 5 75c335df23
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
test: Zusagen pruefbar machen, die nur von Hand hielten (#038, #037)
#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>
2026-10-11 06:05:29 +02:00

368 lines
14 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";
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();
});