waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs'; |
| 2 | import { tmpdir } from 'node:os'; |
| 3 | import { join } from 'node:path'; |
| 4 | |
| 5 | import { |
| 6 | expect, |
| 7 | _electron as electron, |
| 8 | type ConsoleMessage, |
| 9 | type ElectronApplication, |
| 10 | type Page, |
| 11 | } from '@playwright/test'; |
| 12 | |
| 13 | import { THEME_BESCHRIFTUNGEN, type ThemeAufgeloest } from '../src/shared/theme'; |
| 14 | import { fensterUeberwachen, konsoleErwartet, konsolenwacheAnhaengen } from './konsolenwache'; |
| 15 | |
| 16 | /** Projektwurzel (app/), unabhängig vom Arbeitsverzeichnis. */ |
| 17 | export const projektWurzel = join(__dirname, '..'); |
| 18 | |
| 19 | /** Einstiegspunkt des gebauten Main-Prozesses. */ |
| 20 | export const hauptEinstieg = join(projektWurzel, 'out', 'main', 'index.js'); |
| 21 | |
| 22 | /** Quelltextverzeichnis, gegen dessen Alter der Bau geprüft wird. */ |
| 23 | const quellWurzel = join(projektWurzel, 'src'); |
| 24 | |
| 25 | /** Jüngste Änderungszeit unterhalb eines Verzeichnisses, in Millisekunden. */ |
| 26 | function juengsteAenderung(verzeichnis: string): number { |
| 27 | let juengste = 0; |
| 28 | for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { |
| 29 | const pfad = join(verzeichnis, eintrag.name); |
| 30 | const zeit = eintrag.isDirectory() ? juengsteAenderung(pfad) : statSync(pfad).mtimeMs; |
| 31 | if (zeit > juengste) { |
| 32 | juengste = zeit; |
| 33 | } |
| 34 | } |
| 35 | return juengste; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Stellt sicher, dass ein **aktueller** Bau vorliegt – sonst Abbruch. |
| 40 | * |
| 41 | * ## Warum das wirft und nicht überspringt |
| 42 | * |
| 43 | * Vorher stand hier `buildVorhanden()`, und jede Suite rief damit |
| 44 | * `test.skip()`. Wer `npx playwright test` ohne vorherigen Bau startete, |
| 45 | * bekam einen grünen Lauf gemeldet, in dem 59 von 69 Tests übersprungen |
| 46 | * wurden – darunter sämtliche Barrierefreiheitsprüfungen im echten Fenster. |
| 47 | * Ein Gate, das darauf aufsetzt, prüft nichts und behauptet das Gegenteil. |
| 48 | * Ein fehlender Bau ist kein Grund zum Schweigen, sondern ein Fehler. |
| 49 | * |
| 50 | * ## Warum zusätzlich das Alter zählt |
| 51 | * |
| 52 | * `existsSync` beantwortet nur, ob *irgendein* Bau daliegt. Ein Bau von |
| 53 | * gestern gegen den Quelltext von heute ist schlimmer als gar keiner: Der |
| 54 | * Lauf ist grün und misst die falsche Anwendung. Deshalb wird die jüngste |
| 55 | * Änderung unter `src/` gegen die des gebauten Einstiegspunkts gehalten. |
| 56 | * |
| 57 | * Die Toleranz von zwei Sekunden fängt die Dateisystemauflösung und den |
| 58 | * Umstand ab, dass electron-vite die Ausgaben nicht in derselben |
| 59 | * Millisekunde schreibt, in der es sie liest. |
| 60 | */ |
| 61 | /** |
| 62 | * Woran sich der Startbildschirm zweifelsfrei erkennen lässt. |
| 63 | * |
| 64 | * **Nicht am Knopf „Weiterlernen".** Den gibt es seit 0.22.0 auch auf der |
| 65 | * Sitzungsauswertung – dort ist er der kürzere Weg zur nächsten Sitzung. Wer |
| 66 | * ihn als Merkmal des Startbildschirms nimmt, hält die Auswertung für den |
| 67 | * Start und sucht dort vergeblich nach der Datensicherung. Genau das ist |
| 68 | * passiert: zehn E2E-Prüfungen fielen aus, nachdem der Knopf dazugekommen war. |
| 69 | * |
| 70 | * Die Überschrift der Ansicht gibt es dagegen genau einmal. |
| 71 | */ |
| 72 | export const STARTTITEL = /^Waffensachkunde/u; |
| 73 | |
| 74 | /** Steht der Startbildschirm auf dem Schirm? */ |
| 75 | export async function amStart(seite: Page): Promise<boolean> { |
| 76 | return (await seite.getByRole('heading', { level: 1, name: STARTTITEL }).count()) > 0; |
| 77 | } |
| 78 | |
| 79 | export function bauPruefen(): void { |
| 80 | if (!existsSync(hauptEinstieg)) { |
| 81 | throw new Error( |
| 82 | 'Kein Bau in out/ gefunden. Diese Tests prüfen die gebaute Anwendung – ' + |
| 83 | 'zuerst `npm run build` ausführen (oder `npm run test:e2e`, das baut selbst).', |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | const gebautAm = statSync(hauptEinstieg).mtimeMs; |
| 88 | const quelltextVon = juengsteAenderung(quellWurzel); |
| 89 | const TOLERANZ_MS = 2000; |
| 90 | |
| 91 | if (quelltextVon > gebautAm + TOLERANZ_MS) { |
| 92 | const alterSekunden = Math.round((quelltextVon - gebautAm) / 1000); |
| 93 | throw new Error( |
| 94 | `Der Bau in out/ ist älter als der Quelltext (um ${String(alterSekunden)} s). ` + |
| 95 | 'Diese Tests würden die vorige Fassung prüfen und grün melden. ' + |
| 96 | 'Zuerst `npm run build` ausführen (oder `npm run test:e2e`, das baut selbst).', |
| 97 | ); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | /** `process.env` enthält optionale Werte – Playwright erwartet reine Strings. */ |
| 102 | function umgebung(): Record<string, string> { |
| 103 | const bereinigt: Record<string, string> = {}; |
| 104 | for (const [schluessel, wert] of Object.entries(process.env)) { |
| 105 | if (typeof wert === 'string') { |
| 106 | bereinigt[schluessel] = wert; |
| 107 | } |
| 108 | } |
| 109 | bereinigt['NODE_ENV'] = 'production'; |
| 110 | return bereinigt; |
| 111 | } |
| 112 | |
| 113 | export interface GestarteteApp { |
| 114 | readonly app: ElectronApplication; |
| 115 | readonly fenster: Page; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Startet die gebaute Anwendung mit einem frischen Nutzerprofil. |
| 120 | * |
| 121 | * Das eigene `--user-data-dir` ist wichtig: die App speichert die |
| 122 | * Theme-Auswahl in `userData`. Ohne Isolation würde ein Lauf den nächsten |
| 123 | * beeinflussen und die Tests wären reihenfolgeabhängig. |
| 124 | * |
| 125 | * `ELECTRON_DISABLE_SECURITY_WARNINGS` bleibt bewusst AUS: erscheinen |
| 126 | * Sicherheitswarnungen in der Konsole, ist das ein echter Befund. Gehört wird |
| 127 | * die Konsole von `e2e/konsolenwache.ts` – jahrelang stand hier nur der Satz, |
| 128 | * und niemand hörte zu. |
| 129 | */ |
| 130 | export async function appStarten(): Promise<GestarteteApp> { |
| 131 | const gestartet = await appStartenOhneZuschnitt(); |
| 132 | await weiterAmZuschnitt(gestartet.fenster); |
| 133 | return gestartet; |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Wie {@link appStarten}, aber ohne die Erststart-Frage zu beantworten. |
| 138 | * |
| 139 | * Für die beiden Suiten, in denen die Frage selbst der Gegenstand ist: |
| 140 | * `e2e/erststart.spec.ts` prüft ihr Verhalten, `barrierefreiheit-ansichten.spec.ts` |
| 141 | * misst sie mit axe. Beide brauchen sie stehend – und beide bekommen dafür ein |
| 142 | * eigenes, frisches Profilverzeichnis. |
| 143 | */ |
| 144 | export async function appStartenOhneZuschnitt(): Promise<GestarteteApp> { |
| 145 | const profil = mkdtempSync(join(tmpdir(), 'wsk-e2e-')); |
| 146 | |
| 147 | const app = await electron.launch({ |
| 148 | args: [hauptEinstieg, `--user-data-dir=${profil}`], |
| 149 | env: umgebung(), |
| 150 | }); |
| 151 | |
| 152 | /* Unmittelbar nach dem Start und vor `firstWindow()`: Der erste Ladevorgang |
| 153 | ist die lauteste Phase – dort schrieb Chromium den CSP-Fehler –, und wer |
| 154 | erst danach zuhört, hat ihn verpasst. */ |
| 155 | konsolenwacheAnhaengen(app); |
| 156 | |
| 157 | app.on('close', () => { |
| 158 | try { |
| 159 | rmSync(profil, { recursive: true, force: true }); |
| 160 | } catch { |
| 161 | // Aufräumen ist Kür – ein verwaistes Temp-Verzeichnis darf den |
| 162 | // Testlauf nicht zum Scheitern bringen. |
| 163 | } |
| 164 | }); |
| 165 | |
| 166 | const fenster = await app.firstWindow(); |
| 167 | /* Zweiter Weg zum selben Fenster – `app.on('window')` feuert je nach |
| 168 | Zeitverlauf schon vorher. Doppelt angemeldet wird nichts, das verhindert |
| 169 | `fensterUeberwachen` selbst. */ |
| 170 | fensterUeberwachen(fenster); |
| 171 | await fenster.waitForLoadState('domcontentloaded'); |
| 172 | |
| 173 | // Warten, bis React gerendert hat. |
| 174 | await fenster.waitForSelector('h1', { state: 'visible' }); |
| 175 | |
| 176 | return { app, fenster }; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Beantwortet die Erststart-Frage, falls sie steht. |
| 181 | * |
| 182 | * Jeder Lauf beginnt mit einem frischen Profilverzeichnis – die Frage steht |
| 183 | * also vor jedem Test. Beantwortet wird sie mit „mitlernen“, dem |
| 184 | * vollständigen Katalog: Genau davon gehen alle übrigen Prüfungen aus, und |
| 185 | * eine Antwort hier ist ehrlicher, als die Frage im Programm zu |
| 186 | * unterdrücken. |
| 187 | */ |
| 188 | export async function weiterAmZuschnitt(fenster: Page): Promise<void> { |
| 189 | const weiter = fenster.getByRole('button', { name: 'Weiter', exact: true }); |
| 190 | if ((await weiter.count()) === 0 || !(await weiter.first().isVisible())) { |
| 191 | return; |
| 192 | } |
| 193 | |
| 194 | await weiter.first().click(); |
| 195 | await fenster.waitForSelector('h1', { state: 'visible' }); |
| 196 | |
| 197 | /* |
| 198 | Neu laden, statt nur den Fokus zurückzunehmen. |
| 199 | |
| 200 | Die Anwendung setzt ihn nach dem Beantworten auf die Einstiegsüberschrift |
| 201 | des Startbildschirms – richtig so. Für die übrigen Prüfungen ist das aber |
| 202 | ein Zustand, den ein Start ohne Frage nicht hätte: Der Sprunglink-Test |
| 203 | erwartet den ersten Tabstopp eines frisch geladenen Fensters. |
| 204 | |
| 205 | `blur()` genügt dafür nicht – nachgemessen landet der nächste Tabulator |
| 206 | danach auf „Weiterlernen“ und nicht auf dem Sprunglink: Chromium merkt |
| 207 | sich die Stelle, an der die Tabulatorreihenfolge weitergeht, und ein |
| 208 | blosses Abmelden des Fokus setzt sie nicht zurück. Das Neuladen gibt ein |
| 209 | wirklich frisches Dokument – und ist zugleich der Zustand, den jeder |
| 210 | zweite Programmstart hat: Die Antwort steht in den Einstellungen, die |
| 211 | Frage kommt nicht wieder. |
| 212 | |
| 213 | Wie der Fokus unmittelbar nach der Frage läuft, prüft |
| 214 | `e2e/erststart.spec.ts`. |
| 215 | */ |
| 216 | await fenster.reload(); |
| 217 | await fenster.waitForSelector('h1', { state: 'visible' }); |
| 218 | } |
| 219 | |
| 220 | /** Der Wortlaut, der im `<meta>`-Element des geladenen Dokuments steht. */ |
| 221 | export async function metaRichtlinie(seite: Page): Promise<string> { |
| 222 | const inhalt = await seite |
| 223 | .locator('meta[http-equiv="Content-Security-Policy"]') |
| 224 | .getAttribute('content'); |
| 225 | expect(inhalt, 'Das Dokument trägt kein CSP-<meta>-Element.').toBeTruthy(); |
| 226 | return inhalt ?? ''; |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * Liest die Richtlinien aus, die der Renderer **tatsächlich anwendet**. |
| 231 | * |
| 232 | * Steht hier und nicht in einer einzelnen Suite, weil zwei Suiten dieselbe |
| 233 | * Messung brauchen: `anwendung.spec.ts` gegen den Bau in `out/`, |
| 234 | * `gepackt.spec.ts` gegen das ausgelieferte Paket. Erst die zweite beantwortet |
| 235 | * die Frage, um die es geht – im Paket ist `app.isPackaged` wahr, und erst |
| 236 | * dann liefert der Hauptprozess die strenge Fassung in den HTTP-Kopf. |
| 237 | * |
| 238 | * ## Wie gemessen wird |
| 239 | * |
| 240 | * Gelten mehrere Richtlinien nebeneinander – hier die aus dem `<meta>`-Element |
| 241 | * und die aus dem HTTP-Kopf –, meldet ein einziger Verstoß je Richtlinie ein |
| 242 | * `securitypolicyviolation`-Ereignis, und jedes trägt in `originalPolicy` den |
| 243 | * Wortlaut genau der Richtlinie, an der es gescheitert ist. Das ist der |
| 244 | * einzige Weg, aus dem Dokument heraus zu erfahren, was wirklich gilt, statt |
| 245 | * was irgendwo geschrieben steht. |
| 246 | * |
| 247 | * Ausgelöst wird der Verstoß mit einem Bild von einer Adresse, die |
| 248 | * `img-src 'self' data:` nicht deckt. Netzverkehr entsteht dabei keiner: Die |
| 249 | * CSP bricht den Ladeversuch ab, bevor er beginnt – und `.invalid` ist laut |
| 250 | * RFC 2606 ohnehin dauerhaft unauflösbar. |
| 251 | */ |
| 252 | export async function angewandteRichtlinien(seite: Page): Promise<string[]> { |
| 253 | /* |
| 254 | Der Verstoß wird absichtlich ausgelöst – und Chromium schreibt für jede |
| 255 | geltende Richtlinie einen Konsolenfehler darüber. Ohne diese Anmeldung |
| 256 | fiele die Konsolenwache über die eigene Messung her. |
| 257 | |
| 258 | Angemeldet wird das genaue Bild, nicht „irgendein CSP-Verstoß": Ein |
| 259 | weiter gefasstes Muster machte die Wache blind für echte Verstöße – also |
| 260 | für die Fehlerklasse, derentwegen es sie gibt. |
| 261 | */ |
| 262 | konsoleErwartet( |
| 263 | /Loading the image 'https:\/\/gibt-es-nicht\.invalid\/pixel\.png' violates/u, |
| 264 | 'Der Bildaufruf ist die Messung selbst: Nur ein echter Verstoß verrät, welche ' + |
| 265 | 'Richtlinien gelten. Je geltender Richtlinie meldet Chromium ihn einmal.', |
| 266 | ); |
| 267 | |
| 268 | return seite.evaluate(async () => { |
| 269 | const gesammelt: string[] = []; |
| 270 | const zuhoerer = (ereignis: SecurityPolicyViolationEvent): void => { |
| 271 | gesammelt.push(ereignis.originalPolicy); |
| 272 | }; |
| 273 | document.addEventListener('securitypolicyviolation', zuhoerer); |
| 274 | |
| 275 | const bild = new Image(); |
| 276 | bild.src = 'https://gibt-es-nicht.invalid/pixel.png'; |
| 277 | |
| 278 | // Die Meldung kommt asynchron; eine Sekunde ist reichlich bemessen. |
| 279 | await new Promise((fertig) => setTimeout(fertig, 1000)); |
| 280 | document.removeEventListener('securitypolicyviolation', zuhoerer); |
| 281 | return gesammelt; |
| 282 | }); |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Sammelt die Konsolenausgabe eines Ladevorgangs. |
| 287 | * |
| 288 | * Der erste Ladevorgang ist vorbei, bevor ein Zuhörer hängen kann; gemessen |
| 289 | * wird deshalb beim Neuladen. Das ist derselbe Vorgang wie ein Programmstart |
| 290 | * und hinterlässt ein frisches Dokument. |
| 291 | */ |
| 292 | export async function konsoleBeimLaden(seite: Page): Promise<string[]> { |
| 293 | const meldungen: string[] = []; |
| 294 | const zuhoerer = (m: ConsoleMessage): void => { |
| 295 | meldungen.push(`${m.type()}: ${m.text()}`); |
| 296 | }; |
| 297 | seite.on('console', zuhoerer); |
| 298 | |
| 299 | try { |
| 300 | await seite.reload(); |
| 301 | await seite.waitForSelector('h1', { state: 'visible' }); |
| 302 | } finally { |
| 303 | seite.off('console', zuhoerer); |
| 304 | } |
| 305 | |
| 306 | return meldungen; |
| 307 | } |
| 308 | |
| 309 | /** |
| 310 | * Wartet, bis alle laufenden CSS-Übergänge und Animationen abgeschlossen sind. |
| 311 | * |
| 312 | * Ohne das misst axe-core die Farben mitten im Theme-Übergang (die |
| 313 | * Bedienelemente blenden über 140 ms um) und meldet sporadisch |
| 314 | * Kontrastfehler, die es im Ruhezustand gar nicht gibt. |
| 315 | */ |
| 316 | export async function animationenAbwarten(seite: Page): Promise<void> { |
| 317 | await seite.evaluate(async () => { |
| 318 | const laufende = document.getAnimations(); |
| 319 | await Promise.all(laufende.map(async (a) => a.finished.catch(() => undefined))); |
| 320 | }); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Farbschema über den Umschalter auf dem Startbildschirm setzen. |
| 325 | * |
| 326 | * Steht hier und nicht in einer einzelnen Suite, weil zwei Suiten je |
| 327 | * Farbschema messen und beide dieselbe Bewegung brauchen: klicken, auf das |
| 328 | * Attribut am Wurzelelement warten, die Übergänge auslaufen lassen. Das letzte |
| 329 | * Warten ist Pflicht – ohne es misst axe die Farben mitten im Umblenden |
| 330 | * (140 ms, `--uebergang-dauer`) und meldet Kontrastfehler, die es im |
| 331 | * Ruhezustand nicht gibt. |
| 332 | * |
| 333 | * Der Umschalter steht ausschließlich auf dem Startbildschirm; wer aus einer |
| 334 | * anderen Ansicht kommt, muss vorher dorthin zurück. |
| 335 | */ |
| 336 | export async function themaSetzen(seite: Page, thema: ThemeAufgeloest): Promise<void> { |
| 337 | await seite.getByRole('radio', { name: THEME_BESCHRIFTUNGEN[thema], exact: true }).click(); |
| 338 | await expect(seite.locator('html')).toHaveAttribute('data-thema', thema); |
| 339 | await animationenAbwarten(seite); |
| 340 | } |