Files
Laufzettel/tests/e2e/mobil.spec.ts
T
Florian WildeandClaude Opus 5 80d97467da
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 01:32:09 +02:00

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