import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; import { SEED_TAG, SEED_VORLAGE, anmelden, 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. * * Nicht dabei: `/meine-geraete` (#035) — es verlangt das Recht * `selbstauskunft`, und das hat nur `MITARBEITER`. Dieser Ablauf meldet sich * als Admin an, der dort „kein Zugriff“ sähe; gemessen würde die * Verweigerungsseite. Dafür braucht es ein eigenes Konto, siehe #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); }); } }); 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(); });