import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, _electron as electron, type ConsoleMessage, type ElectronApplication, type Page, } from '@playwright/test'; import { THEME_BESCHRIFTUNGEN, type ThemeAufgeloest } from '../src/shared/theme'; import { fensterUeberwachen, konsoleErwartet, konsolenwacheAnhaengen } from './konsolenwache'; /** Projektwurzel (app/), unabhängig vom Arbeitsverzeichnis. */ export const projektWurzel = join(__dirname, '..'); /** Einstiegspunkt des gebauten Main-Prozesses. */ export const hauptEinstieg = join(projektWurzel, 'out', 'main', 'index.js'); /** Quelltextverzeichnis, gegen dessen Alter der Bau geprüft wird. */ const quellWurzel = join(projektWurzel, 'src'); /** Jüngste Änderungszeit unterhalb eines Verzeichnisses, in Millisekunden. */ function juengsteAenderung(verzeichnis: string): number { let juengste = 0; for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { const pfad = join(verzeichnis, eintrag.name); const zeit = eintrag.isDirectory() ? juengsteAenderung(pfad) : statSync(pfad).mtimeMs; if (zeit > juengste) { juengste = zeit; } } return juengste; } /** * Stellt sicher, dass ein **aktueller** Bau vorliegt – sonst Abbruch. * * ## Warum das wirft und nicht überspringt * * Vorher stand hier `buildVorhanden()`, und jede Suite rief damit * `test.skip()`. Wer `npx playwright test` ohne vorherigen Bau startete, * bekam einen grünen Lauf gemeldet, in dem 59 von 69 Tests übersprungen * wurden – darunter sämtliche Barrierefreiheitsprüfungen im echten Fenster. * Ein Gate, das darauf aufsetzt, prüft nichts und behauptet das Gegenteil. * Ein fehlender Bau ist kein Grund zum Schweigen, sondern ein Fehler. * * ## Warum zusätzlich das Alter zählt * * `existsSync` beantwortet nur, ob *irgendein* Bau daliegt. Ein Bau von * gestern gegen den Quelltext von heute ist schlimmer als gar keiner: Der * Lauf ist grün und misst die falsche Anwendung. Deshalb wird die jüngste * Änderung unter `src/` gegen die des gebauten Einstiegspunkts gehalten. * * Die Toleranz von zwei Sekunden fängt die Dateisystemauflösung und den * Umstand ab, dass electron-vite die Ausgaben nicht in derselben * Millisekunde schreibt, in der es sie liest. */ /** * Woran sich der Startbildschirm zweifelsfrei erkennen lässt. * * **Nicht am Knopf „Weiterlernen".** Den gibt es seit 0.22.0 auch auf der * Sitzungsauswertung – dort ist er der kürzere Weg zur nächsten Sitzung. Wer * ihn als Merkmal des Startbildschirms nimmt, hält die Auswertung für den * Start und sucht dort vergeblich nach der Datensicherung. Genau das ist * passiert: zehn E2E-Prüfungen fielen aus, nachdem der Knopf dazugekommen war. * * Die Überschrift der Ansicht gibt es dagegen genau einmal. */ export const STARTTITEL = /^Waffensachkunde/u; /** Steht der Startbildschirm auf dem Schirm? */ export async function amStart(seite: Page): Promise { return (await seite.getByRole('heading', { level: 1, name: STARTTITEL }).count()) > 0; } export function bauPruefen(): void { if (!existsSync(hauptEinstieg)) { throw new Error( 'Kein Bau in out/ gefunden. Diese Tests prüfen die gebaute Anwendung – ' + 'zuerst `npm run build` ausführen (oder `npm run test:e2e`, das baut selbst).', ); } const gebautAm = statSync(hauptEinstieg).mtimeMs; const quelltextVon = juengsteAenderung(quellWurzel); const TOLERANZ_MS = 2000; if (quelltextVon > gebautAm + TOLERANZ_MS) { const alterSekunden = Math.round((quelltextVon - gebautAm) / 1000); throw new Error( `Der Bau in out/ ist älter als der Quelltext (um ${String(alterSekunden)} s). ` + 'Diese Tests würden die vorige Fassung prüfen und grün melden. ' + 'Zuerst `npm run build` ausführen (oder `npm run test:e2e`, das baut selbst).', ); } } /** `process.env` enthält optionale Werte – Playwright erwartet reine Strings. */ function umgebung(): Record { const bereinigt: Record = {}; for (const [schluessel, wert] of Object.entries(process.env)) { if (typeof wert === 'string') { bereinigt[schluessel] = wert; } } bereinigt['NODE_ENV'] = 'production'; return bereinigt; } export interface GestarteteApp { readonly app: ElectronApplication; readonly fenster: Page; } /** * Startet die gebaute Anwendung mit einem frischen Nutzerprofil. * * Das eigene `--user-data-dir` ist wichtig: die App speichert die * Theme-Auswahl in `userData`. Ohne Isolation würde ein Lauf den nächsten * beeinflussen und die Tests wären reihenfolgeabhängig. * * `ELECTRON_DISABLE_SECURITY_WARNINGS` bleibt bewusst AUS: erscheinen * Sicherheitswarnungen in der Konsole, ist das ein echter Befund. Gehört wird * die Konsole von `e2e/konsolenwache.ts` – jahrelang stand hier nur der Satz, * und niemand hörte zu. */ export async function appStarten(): Promise { const gestartet = await appStartenOhneZuschnitt(); await weiterAmZuschnitt(gestartet.fenster); return gestartet; } /** * Wie {@link appStarten}, aber ohne die Erststart-Frage zu beantworten. * * Für die beiden Suiten, in denen die Frage selbst der Gegenstand ist: * `e2e/erststart.spec.ts` prüft ihr Verhalten, `barrierefreiheit-ansichten.spec.ts` * misst sie mit axe. Beide brauchen sie stehend – und beide bekommen dafür ein * eigenes, frisches Profilverzeichnis. */ export async function appStartenOhneZuschnitt(): Promise { const profil = mkdtempSync(join(tmpdir(), 'wsk-e2e-')); const app = await electron.launch({ args: [hauptEinstieg, `--user-data-dir=${profil}`], env: umgebung(), }); /* Unmittelbar nach dem Start und vor `firstWindow()`: Der erste Ladevorgang ist die lauteste Phase – dort schrieb Chromium den CSP-Fehler –, und wer erst danach zuhört, hat ihn verpasst. */ konsolenwacheAnhaengen(app); app.on('close', () => { try { rmSync(profil, { recursive: true, force: true }); } catch { // Aufräumen ist Kür – ein verwaistes Temp-Verzeichnis darf den // Testlauf nicht zum Scheitern bringen. } }); const fenster = await app.firstWindow(); /* Zweiter Weg zum selben Fenster – `app.on('window')` feuert je nach Zeitverlauf schon vorher. Doppelt angemeldet wird nichts, das verhindert `fensterUeberwachen` selbst. */ fensterUeberwachen(fenster); await fenster.waitForLoadState('domcontentloaded'); // Warten, bis React gerendert hat. await fenster.waitForSelector('h1', { state: 'visible' }); return { app, fenster }; } /** * Beantwortet die Erststart-Frage, falls sie steht. * * Jeder Lauf beginnt mit einem frischen Profilverzeichnis – die Frage steht * also vor jedem Test. Beantwortet wird sie mit „mitlernen“, dem * vollständigen Katalog: Genau davon gehen alle übrigen Prüfungen aus, und * eine Antwort hier ist ehrlicher, als die Frage im Programm zu * unterdrücken. */ export async function weiterAmZuschnitt(fenster: Page): Promise { const weiter = fenster.getByRole('button', { name: 'Weiter', exact: true }); if ((await weiter.count()) === 0 || !(await weiter.first().isVisible())) { return; } await weiter.first().click(); await fenster.waitForSelector('h1', { state: 'visible' }); /* Neu laden, statt nur den Fokus zurückzunehmen. Die Anwendung setzt ihn nach dem Beantworten auf die Einstiegsüberschrift des Startbildschirms – richtig so. Für die übrigen Prüfungen ist das aber ein Zustand, den ein Start ohne Frage nicht hätte: Der Sprunglink-Test erwartet den ersten Tabstopp eines frisch geladenen Fensters. `blur()` genügt dafür nicht – nachgemessen landet der nächste Tabulator danach auf „Weiterlernen“ und nicht auf dem Sprunglink: Chromium merkt sich die Stelle, an der die Tabulatorreihenfolge weitergeht, und ein blosses Abmelden des Fokus setzt sie nicht zurück. Das Neuladen gibt ein wirklich frisches Dokument – und ist zugleich der Zustand, den jeder zweite Programmstart hat: Die Antwort steht in den Einstellungen, die Frage kommt nicht wieder. Wie der Fokus unmittelbar nach der Frage läuft, prüft `e2e/erststart.spec.ts`. */ await fenster.reload(); await fenster.waitForSelector('h1', { state: 'visible' }); } /** Der Wortlaut, der im ``-Element des geladenen Dokuments steht. */ export async function metaRichtlinie(seite: Page): Promise { const inhalt = await seite .locator('meta[http-equiv="Content-Security-Policy"]') .getAttribute('content'); expect(inhalt, 'Das Dokument trägt kein CSP--Element.').toBeTruthy(); return inhalt ?? ''; } /** * Liest die Richtlinien aus, die der Renderer **tatsächlich anwendet**. * * Steht hier und nicht in einer einzelnen Suite, weil zwei Suiten dieselbe * Messung brauchen: `anwendung.spec.ts` gegen den Bau in `out/`, * `gepackt.spec.ts` gegen das ausgelieferte Paket. Erst die zweite beantwortet * die Frage, um die es geht – im Paket ist `app.isPackaged` wahr, und erst * dann liefert der Hauptprozess die strenge Fassung in den HTTP-Kopf. * * ## Wie gemessen wird * * Gelten mehrere Richtlinien nebeneinander – hier die aus dem ``-Element * und die aus dem HTTP-Kopf –, meldet ein einziger Verstoß je Richtlinie ein * `securitypolicyviolation`-Ereignis, und jedes trägt in `originalPolicy` den * Wortlaut genau der Richtlinie, an der es gescheitert ist. Das ist der * einzige Weg, aus dem Dokument heraus zu erfahren, was wirklich gilt, statt * was irgendwo geschrieben steht. * * Ausgelöst wird der Verstoß mit einem Bild von einer Adresse, die * `img-src 'self' data:` nicht deckt. Netzverkehr entsteht dabei keiner: Die * CSP bricht den Ladeversuch ab, bevor er beginnt – und `.invalid` ist laut * RFC 2606 ohnehin dauerhaft unauflösbar. */ export async function angewandteRichtlinien(seite: Page): Promise { /* Der Verstoß wird absichtlich ausgelöst – und Chromium schreibt für jede geltende Richtlinie einen Konsolenfehler darüber. Ohne diese Anmeldung fiele die Konsolenwache über die eigene Messung her. Angemeldet wird das genaue Bild, nicht „irgendein CSP-Verstoß": Ein weiter gefasstes Muster machte die Wache blind für echte Verstöße – also für die Fehlerklasse, derentwegen es sie gibt. */ konsoleErwartet( /Loading the image 'https:\/\/gibt-es-nicht\.invalid\/pixel\.png' violates/u, 'Der Bildaufruf ist die Messung selbst: Nur ein echter Verstoß verrät, welche ' + 'Richtlinien gelten. Je geltender Richtlinie meldet Chromium ihn einmal.', ); return seite.evaluate(async () => { const gesammelt: string[] = []; const zuhoerer = (ereignis: SecurityPolicyViolationEvent): void => { gesammelt.push(ereignis.originalPolicy); }; document.addEventListener('securitypolicyviolation', zuhoerer); const bild = new Image(); bild.src = 'https://gibt-es-nicht.invalid/pixel.png'; // Die Meldung kommt asynchron; eine Sekunde ist reichlich bemessen. await new Promise((fertig) => setTimeout(fertig, 1000)); document.removeEventListener('securitypolicyviolation', zuhoerer); return gesammelt; }); } /** * Sammelt die Konsolenausgabe eines Ladevorgangs. * * Der erste Ladevorgang ist vorbei, bevor ein Zuhörer hängen kann; gemessen * wird deshalb beim Neuladen. Das ist derselbe Vorgang wie ein Programmstart * und hinterlässt ein frisches Dokument. */ export async function konsoleBeimLaden(seite: Page): Promise { const meldungen: string[] = []; const zuhoerer = (m: ConsoleMessage): void => { meldungen.push(`${m.type()}: ${m.text()}`); }; seite.on('console', zuhoerer); try { await seite.reload(); await seite.waitForSelector('h1', { state: 'visible' }); } finally { seite.off('console', zuhoerer); } return meldungen; } /** * Wartet, bis alle laufenden CSS-Übergänge und Animationen abgeschlossen sind. * * Ohne das misst axe-core die Farben mitten im Theme-Übergang (die * Bedienelemente blenden über 140 ms um) und meldet sporadisch * Kontrastfehler, die es im Ruhezustand gar nicht gibt. */ export async function animationenAbwarten(seite: Page): Promise { await seite.evaluate(async () => { const laufende = document.getAnimations(); await Promise.all(laufende.map(async (a) => a.finished.catch(() => undefined))); }); } /** * Farbschema über den Umschalter auf dem Startbildschirm setzen. * * Steht hier und nicht in einer einzelnen Suite, weil zwei Suiten je * Farbschema messen und beide dieselbe Bewegung brauchen: klicken, auf das * Attribut am Wurzelelement warten, die Übergänge auslaufen lassen. Das letzte * Warten ist Pflicht – ohne es misst axe die Farben mitten im Umblenden * (140 ms, `--uebergang-dauer`) und meldet Kontrastfehler, die es im * Ruhezustand nicht gibt. * * Der Umschalter steht ausschließlich auf dem Startbildschirm; wer aus einer * anderen Ansicht kommt, muss vorher dorthin zurück. */ export async function themaSetzen(seite: Page, thema: ThemeAufgeloest): Promise { await seite.getByRole('radio', { name: THEME_BESCHRIFTUNGEN[thema], exact: true }).click(); await expect(seite.locator('html')).toHaveAttribute('data-thema', thema); await animationenAbwarten(seite); }