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 { 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(); });