Files
betreuungs-cockpit/e2e/hilfen.ts
T
Florian Wilde 3772e360e1 Doppelt gerenderte Listen dokumentieren
Die Dokumentenliste rendert jeden Eintrag zweimal: als Karte fuers Handy,
als Tabellenzeile ab Tablet. Eine Fassung ist per Breakpoint versteckt,
steht aber im DOM. Beim Testen trifft getByText(...).first() deshalb die
unsichtbare Kopie, und die Pruefung scheitert mit "element is not
visible" an einer Stelle, die augenscheinlich in Ordnung ist. Das hat
mehrfach Zeit gekostet, weil man den Grund nicht sieht.

Nachgemessen an /dokumente mit acht Dokumenten: ein Titel liefert 16
Treffer, davon 8 sichtbar - und first() ist unsichtbar.

- Kommentar an der Komponente, mit dem Verweis auf die Hilfsdatei.
- In e2e/hilfen.ts das Muster beschrieben und drei Wege genannt, die
  tragen: innerText fuer "steht das da", visible=true zum Klicken, die
  Zusammenfassungszeile fuer Anzahlen. Alle drei nachgeprueft, bevor sie
  aufgeschrieben wurden - eine Anleitung mit kaputtem Rezept waere
  schlimmer als keine.

Zur Frage aus dem Issue, ob sich die Verdopplung vermeiden laesst: nein,
nicht ohne Verlust. Eine Tabelle, die zu Karten umbricht, verlangt
display:block auf Zeilen und Zellen - damit ist es keine Tabelle mehr,
Vorlesesoftware verliert den Zusammenhang von Spaltenkopf und Wert, und
genau der macht eine Liste mit acht Spalten erst benutzbar. Die
Begruendung steht jetzt am Code, statt die Frage offen zu lassen.

Betroffen ist uebrigens nur diese eine Stelle, nicht mehrere:
kalender-monat.tsx und das Stammdatenformular nutzen Breakpoints, ohne
Inhalt doppelt zu rendern.

Refs #28
2026-08-23 23:28:22 +02:00

163 lines
5.4 KiB
TypeScript

import type { Page } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { TOTP, Secret } from 'otpauth'
import { readFile } from 'node:fs/promises'
/**
* Gemeinsame Helfer fuer die Playwright-Tests.
*
* Die Anmeldung braucht einen echten TOTP-Code. Deshalb setzt der Seed im
* Entwicklungsmodus ein bekanntes Geheimnis (SEED_DEV_TOTP_SECRET), aus
* dem sich hier der aktuelle Code errechnen laesst. In der Produktion
* ignoriert der Seed diese Variable.
*/
let gemerkt: Record<string, string> | null = null
export async function umgebung(): Promise<Record<string, string>> {
if (gemerkt) return gemerkt
const roh = await readFile('.env', 'utf8')
const werte: Record<string, string> = {}
for (const zeile of roh.split(/\r?\n/)) {
const treffer = zeile.match(/^([A-Z0-9_]+)=(.*)$/)
if (treffer) werte[treffer[1]!] = treffer[2]!.replace(/^"|"$/g, '')
}
gemerkt = werte
return werte
}
export function totpCode(secret: string, kennung: string): string {
return new TOTP({
issuer: 'Betreuungs-Cockpit',
label: kennung,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: Secret.fromBase32(secret),
}).generate()
}
/**
* Loescht die Fehlversuche des Testzugangs.
*
* Noetig, weil ein Test die Login-Sperre absichtlich ausloest. Ohne diesen
* Reset waeren alle folgenden Tests fuer eine Viertelstunde ausgesperrt -
* und man saehe nicht mehr, ob sie wirklich fehlschlagen oder nur warten.
*/
export async function fehlversucheLoeschen(): Promise<void> {
const db = new PrismaClient()
try {
await db.loginVersuch.deleteMany({ where: { erfolg: false } })
} finally {
await db.$disconnect()
}
}
/** Meldet sich an und wartet, bis die Anwendung erreicht ist. */
export async function anmelden(seite: Page): Promise<void> {
const env = await umgebung()
// Vorherige Tests koennen die Sperre ausgeloest haben.
await fehlversucheLoeschen()
await seite.goto('/anmelden')
await seite.fill('input[name="email"]', env.SEED_ADMIN_EMAIL!)
await seite.fill('input[name="passwort"]', env.SEED_ADMIN_PASSWORT!)
await seite.fill('input[name="code"]', totpCode(env.SEED_DEV_TOTP_SECRET!, env.SEED_ADMIN_EMAIL!))
await Promise.all([
seite.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }),
seite.click('button[type="submit"]'),
])
}
/**
* Ein winziges, gueltiges PNG als Buffer - dient als hochgeladenes
* "Foto eines Behoerdenschreibens". Bewusst inline erzeugt, damit im
* Repository keine Binaerdatei liegt.
*/
export function beispielBild(): Buffer {
return Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
}
/**
* Eine minimale, gueltige PDF-Datei. Ebenfalls inline erzeugt.
* Enthaelt den uebergebenen Text als sichtbaren Seiteninhalt.
*/
export function beispielPdf(text = 'Bescheid'): Buffer {
const inhalt = `BT /F1 24 Tf 72 700 Td (${text}) Tj ET`
const objekte = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] ' +
'/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
`<< /Length ${inhalt.length} >>\nstream\n${inhalt}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
]
let pdf = '%PDF-1.4\n'
const versaetze: number[] = []
objekte.forEach((objekt, i) => {
versaetze.push(pdf.length)
pdf += `${i + 1} 0 obj\n${objekt}\nendobj\n`
})
const xref = pdf.length
pdf += `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n`
for (const versatz of versaetze) {
pdf += `${String(versatz).padStart(10, '0')} 00000 n \n`
}
pdf += `trailer\n<< /Size ${objekte.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
return Buffer.from(pdf, 'latin1')
}
/**
* Doppelt gerenderte Listen — was beim Suchen schiefgeht.
*
* Die Dokumentenliste (und moeglicherweise kuenftig weitere) rendert
* denselben Inhalt ZWEIMAL: als Karten fuers Handy, als Tabelle ab
* Tablet. Eine der beiden Fassungen ist per Breakpoint versteckt, steht
* aber im DOM.
*
* Die Folge im Test:
*
* await expect(page.getByText('Bescheid').first()).toBeVisible()
* // -> "element is not visible", obwohl die Seite richtig aussieht
*
* `.first()` nimmt, was zuerst im Baum steht - und das ist die Karte,
* also am Schreibtisch die unsichtbare Fassung. Es liegt NICHT an der
* Anwendung.
*
* Nachgemessen an /dokumente mit acht Dokumenten: Ein Titel liefert 16
* Treffer, davon 8 sichtbar - und `.first()` ist unsichtbar.
*
* Drei Wege, die tragen:
*
* 1. Ueber den Seitentext pruefen, wenn es nur um "steht das da?" geht:
*
* const inhalt = await page.locator('body').innerText()
* expect(inhalt).toContain('Bescheid')
*
* innerText liefert nur sichtbaren Text - die versteckte Fassung
* faellt von selbst weg. (Fuer Inhalt in einem zugeklappten
* <details> braucht es dagegen textContent.)
*
* 2. Auf die sichtbare Fassung einschraenken, wenn geklickt wird:
*
* page.getByRole('link', { name: 'Bescheid' }).locator('visible=true')
*
* 3. Die Zusammenfassungszeile pruefen ("12 Dokumente"), wenn es um
* Anzahlen geht - die steht nur einmal.
*
* Was nicht traegt: eine feste Anzahl erwarten. `getByText(...)` findet
* jeden Eintrag zweimal, und wer das mit toHaveCount(1) prueft, schreibt
* einen Test, der bei jeder Layoutaenderung umfaellt.
*/