waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Die Konsolenwache: hört mit, was die laufende Anwendung meldet. |
| 3 | * |
| 4 | * ## Warum es diese Datei gibt |
| 5 | * |
| 6 | * Im Kopf von `e2e/electron-hilfe.ts` stand seit jeher der Satz |
| 7 | * „`ELECTRON_DISABLE_SECURITY_WARNINGS` bleibt bewusst AUS: erscheinen |
| 8 | * Sicherheitswarnungen in der Konsole, ist das ein echter Befund." Die Absicht |
| 9 | * war formuliert – die Vorrichtung dazu fehlte. In keiner der Suiten stand ein |
| 10 | * `page.on('console')`, niemand hörte also zu. Was das kostet, hat der |
| 11 | * `frame-ancestors`-Fall gezeigt: Chromium schrieb bei **jedem** Programmstart |
| 12 | * einen Konsolenfehler über eine verworfene CSP-Direktive, und der Testlauf |
| 13 | * meldete währenddessen grün. Gefunden wurde der Fehler erst, als ein Mensch |
| 14 | * zufällig hinsah. |
| 15 | * |
| 16 | * ## Was gesammelt wird |
| 17 | * |
| 18 | * - Konsolenfehler und -warnungen des **Renderers** (`page.on('console')`), |
| 19 | * einschließlich der Meldungen, die Chromium selbst schreibt – ignorierte |
| 20 | * CSP-Direktiven, blockierte Ressourcen, Electrons Sicherheitswarnungen. |
| 21 | * - Ausnahmen im Renderer (`page.on('pageerror')`) – und darüber **auch |
| 22 | * unbehandelte Zusagen**. Das war die eine Stelle, an der nicht klar war, ob |
| 23 | * es einen zweiten Zuhörer braucht; nachgemessen mit einer Wegwerf-Probe: |
| 24 | * Ein `throw` im Renderer und ein `Promise.reject(…)` ohne `catch` liefen |
| 25 | * beide über `pageerror` auf, keines über `console`. Ein eigener |
| 26 | * `unhandledrejection`-Zuhörer im Dokument ist deshalb nicht nötig – er |
| 27 | * hätte die Anwendung dafür verändern müssen. |
| 28 | * - Konsolenfehler und -warnungen des **Hauptprozesses** |
| 29 | * (`electronApplication.on('console')`). Der Hauptprozess ist der einzige |
| 30 | * Ort, an dem Datenbank-, Datei- und Sicherheitsfehler auffallen; |
| 31 | * `src/main/sicherheit.ts` schreibt dort etwa jede blockierte Navigation hin. |
| 32 | * - Weitere Fenster: `app.on('window')` hängt die Wache an jedes Fenster, das |
| 33 | * die Anwendung später öffnet. Nachgemessen an `e2e/druck.spec.ts`: Auch das |
| 34 | * **verborgene** Druckfenster aus `src/main/druck.ts` meldet sich hier |
| 35 | * (`…/wsk-druck-…/dokument.html`) – gerade dort sieht sonst niemand hin, das |
| 36 | * Fenster hat ja keine Anzeige. |
| 37 | * |
| 38 | * Gesammelt werden nur `error`, `warning` und `assert`. `log`, `info` und |
| 39 | * `debug` sind Mitteilungen, keine Befunde; die Anwendung schreibt dort im |
| 40 | * Regelbetrieb ihren Datenbankstand hin (`console.info` in `src/main/index.ts`). |
| 41 | * |
| 42 | * ## Was durchgelassen wird |
| 43 | * |
| 44 | * Nichts – außer, ein Test meldet ausdrücklich an, dass er eine Meldung |
| 45 | * selbst auslöst: {@link konsoleErwartet}. Warum die Ausnahmen dort stehen und |
| 46 | * nicht in einer globalen Liste, steht bei dieser Funktion, samt dem Ergebnis |
| 47 | * des ersten Messlaufs. |
| 48 | * |
| 49 | * ## Warum ein Fixture und kein `test.afterEach` in dieser Datei |
| 50 | * |
| 51 | * Naheliegend wäre, hier schlicht `test.afterEach(...)` aufzurufen – die |
| 52 | * Suiten importieren diese Datei ohnehin. Das wäre eine Falle: Playwright |
| 53 | * ordnet einen Hook der Datei zu, die gerade geladen wird, und Node führt ein |
| 54 | * Modul nur **einmal** aus. Der Hook hinge damit allein an der ersten Suite, |
| 55 | * die diese Datei importiert; alle übrigen liefen ungeprüft – und niemand |
| 56 | * sähe es. Deshalb der ausdrückliche Weg über ein automatisches Fixture: Jede |
| 57 | * Suite, die `test` von hier bezieht, bekommt die Prüfung, ohne sie |
| 58 | * anzumelden. Dass eine Suite `test` versehentlich wieder direkt von |
| 59 | * `@playwright/test` bezieht, verhindert eine ESLint-Regel für die |
| 60 | * Prüfdateien unter `e2e/` (siehe `eslint.config.mjs`). |
| 61 | */ |
| 62 | import { basename } from 'node:path'; |
| 63 | |
| 64 | import { |
| 65 | test as basisTest, |
| 66 | type ConsoleMessage, |
| 67 | type ElectronApplication, |
| 68 | type Page, |
| 69 | } from '@playwright/test'; |
| 70 | |
| 71 | export { expect } from '@playwright/test'; |
| 72 | |
| 73 | /** Eine aufgelaufene Meldung samt der Stelle, an der sie auflief. */ |
| 74 | interface Befund { |
| 75 | /** Menschenlesbare Art: „Konsolenfehler", „Ausnahme im Renderer", … */ |
| 76 | readonly art: string; |
| 77 | /** Renderer oder Hauptprozess – die Suche beginnt an verschiedenen Orten. */ |
| 78 | readonly herkunft: string; |
| 79 | /** Der Wortlaut der Meldung. */ |
| 80 | readonly text: string; |
| 81 | /** Datei, Zeile und Spalte, soweit die Meldung sie mitbringt. */ |
| 82 | readonly ort: string; |
| 83 | /** Welcher Test lief, als die Meldung kam – die „Ansicht" des Befunds. */ |
| 84 | readonly abschnitt: string; |
| 85 | } |
| 86 | |
| 87 | /** Eine angemeldete Ausnahme: Diese Meldung ist in diesem Test gewollt. */ |
| 88 | interface Erwartung { |
| 89 | readonly muster: RegExp; |
| 90 | readonly grund: string; |
| 91 | } |
| 92 | |
| 93 | /** Was aufgelaufen ist. Lebt im Arbeitsprozess, nicht in einer einzelnen Suite. */ |
| 94 | const aufgelaufen: Befund[] = []; |
| 95 | |
| 96 | /** |
| 97 | * Gilt vom Aufruf bis zum Ende des laufenden Tests; das Fixture räumt danach. |
| 98 | * Eine Anmeldung in `beforeAll`/`beforeEach` gilt also für den Test mit, der |
| 99 | * darauf folgt. |
| 100 | */ |
| 101 | let erwartungen: Erwartung[] = []; |
| 102 | |
| 103 | /** |
| 104 | * Meldet eine Meldung an, die **dieser Test absichtlich auslöst**. |
| 105 | * |
| 106 | * ## Warum das die Ausnahmeliste ist – und keine globale |
| 107 | * |
| 108 | * Gemessen im ersten vollen Lauf – 178 Prüfungen, ohne jede Ausnahme. Die |
| 109 | * Wache fing genau zwei Sorten Meldungen auf, und **beide löst die Prüfung |
| 110 | * selbst aus**; keine einzige stammte aus dem Regelbetrieb der Anwendung. |
| 111 | * |
| 112 | * 1. „Loading the image 'https://gibt-es-nicht.invalid/pixel.png' violates … |
| 113 | * img-src" – der absichtliche CSP-Verstoß, mit dem |
| 114 | * `angewandteRichtlinien()` die geltenden Richtlinien sichtbar macht |
| 115 | * (`e2e/electron-hilfe.ts`). Je Test zweimal, weil zwei Richtlinien gelten; |
| 116 | * aufgelaufen in `anwendung.spec.ts` und in `gepackt.spec.ts`. |
| 117 | * 2. „[sicherheit] Navigation blockiert: file:///C:/Windows/win.ini" aus dem |
| 118 | * Hauptprozess – der Beweis, dass die Sperre in |
| 119 | * `e2e/navigationssperre.spec.ts` greift. |
| 120 | * |
| 121 | * Eine **globale** Liste wäre hier der falsche Ort gewesen, und zwar aus einem |
| 122 | * Grund, der die ganze Wache betrifft: Ein globales Muster auf „violates the |
| 123 | * following Content Security Policy directive" hätte die Wache genau für die |
| 124 | * Fehlerklasse blind gemacht, für die es sie gibt. Dasselbe gilt für |
| 125 | * „[sicherheit] …": Blockierte Navigationen sind anderswo ein Alarm, nicht |
| 126 | * Rauschen. Eine Ausnahme gehört deshalb dorthin, wo die Meldung erzeugt |
| 127 | * wird, und sie endet mit dem Test, der sie erzeugt. |
| 128 | * |
| 129 | * Aus demselben Grund verlangt diese Funktion `grund` als Pflichtargument: |
| 130 | * Steht der Satz nicht da, gibt es die Ausnahme nicht. Bei einem Befund nennt |
| 131 | * der Bericht alle angemeldeten Ausnahmen samt Begründung – wer ihn liest, |
| 132 | * sieht, wofür die Wache in diesem Test taub war. |
| 133 | * |
| 134 | * Für Rauschen, das **jeder** Test hätte und das die Anwendung nachweislich |
| 135 | * nicht verursacht, gibt es hier bewusst nichts: Der Messlauf hat kein |
| 136 | * einziges solches Beispiel geliefert. Wer eines findet, entscheidet dann mit |
| 137 | * dem Fall in der Hand, ob eine globale Liste nötig ist – statt sie auf |
| 138 | * Vorrat anzulegen, wo sie nur einlädt, Befunde hineinzuschieben. |
| 139 | */ |
| 140 | export function konsoleErwartet(muster: RegExp, grund: string): void { |
| 141 | if (erwartungen.some((e) => e.muster.source === muster.source)) { |
| 142 | return; |
| 143 | } |
| 144 | erwartungen.push({ muster, grund }); |
| 145 | } |
| 146 | |
| 147 | /** Der Wortlaut, mit dem Meldungen aus der Startphase beschriftet werden. */ |
| 148 | const STARTPHASE = 'Start der Anwendung (beforeAll), noch vor dem ersten Test der Datei'; |
| 149 | |
| 150 | /** |
| 151 | * Die Stelle, der eine ankommende Meldung zugeschrieben wird. |
| 152 | * |
| 153 | * Meldungen kommen asynchron; welcher Test gerade lief, weiß nur der Lauf |
| 154 | * selbst. Der Marker wird an drei Stellen gestellt: vom Fixture vor jedem |
| 155 | * Test, vom Fixture nach jedem Test (Aufräumphase) und beim Start einer |
| 156 | * Anwendung. Der dritte Punkt ist kein Beiwerk – ohne ihn trüge ausgerechnet |
| 157 | * die Startphase, in der der CSP-Fehler auflief, noch die Beschriftung des |
| 158 | * letzten Tests der **vorigen** Datei. |
| 159 | */ |
| 160 | let abschnitt = STARTPHASE; |
| 161 | |
| 162 | /** Fenster, an denen die Wache schon hängt – gegen doppelte Anmeldung. */ |
| 163 | const beobachtet = new WeakSet<Page>(); |
| 164 | |
| 165 | /** Lange Meldungen werden gekürzt; der Bericht soll lesbar bleiben. */ |
| 166 | function gekuerzt(text: string, grenze = 600): string { |
| 167 | const eine = text.replace(/\s+/gu, ' ').trim(); |
| 168 | return eine.length > grenze ? `${eine.slice(0, grenze)} … (gekürzt)` : eine; |
| 169 | } |
| 170 | |
| 171 | /* |
| 172 | Aufgefangen wird zunächst alles; aussortiert wird erst beim Abräumen. |
| 173 | |
| 174 | Der Reihenfolge wegen: Eine Konsolenmeldung kommt über die Leitung, während |
| 175 | der Test schon weiterläuft. Wer beim Eintreffen filtern wollte, müsste |
| 176 | verlangen, dass `konsoleErwartet` vor der Meldung angemeldet ist – und hätte |
| 177 | eine Wache, die je nach Zeitverlauf mal rot und mal grün ist. Am Testende |
| 178 | steht dagegen beides fest. |
| 179 | */ |
| 180 | function vermerken(befund: Befund): void { |
| 181 | aufgelaufen.push(befund); |
| 182 | } |
| 183 | |
| 184 | /** Deutsche Benennung der Playwright-Meldungsarten. */ |
| 185 | function artName(typ: string): string | undefined { |
| 186 | if (typ === 'error') { |
| 187 | return 'Konsolenfehler'; |
| 188 | } |
| 189 | if (typ === 'warning') { |
| 190 | return 'Konsolenwarnung'; |
| 191 | } |
| 192 | if (typ === 'assert') { |
| 193 | return 'fehlgeschlagene console.assert'; |
| 194 | } |
| 195 | return undefined; |
| 196 | } |
| 197 | |
| 198 | function konsolenmeldung(meldung: ConsoleMessage, herkunft: string): void { |
| 199 | const art = artName(meldung.type()); |
| 200 | if (art === undefined) { |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | const stelle = meldung.location(); |
| 205 | const ort = |
| 206 | stelle.url === '' |
| 207 | ? '(ohne Ortsangabe)' |
| 208 | : `${stelle.url}:${String(stelle.lineNumber)}:${String(stelle.columnNumber)}`; |
| 209 | |
| 210 | vermerken({ art, herkunft, text: gekuerzt(meldung.text()), ort, abschnitt }); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Hängt die Wache an ein Fenster. |
| 215 | * |
| 216 | * Getrennt von {@link konsolenwacheAnhaengen}, weil `app.on('window')` und |
| 217 | * `app.firstWindow()` dasselbe Fenster liefern können – die Wache darf dann |
| 218 | * nicht doppelt zählen. |
| 219 | */ |
| 220 | export function fensterUeberwachen(seite: Page): void { |
| 221 | if (beobachtet.has(seite)) { |
| 222 | return; |
| 223 | } |
| 224 | beobachtet.add(seite); |
| 225 | |
| 226 | seite.on('console', (meldung) => { |
| 227 | konsolenmeldung(meldung, 'Renderer'); |
| 228 | }); |
| 229 | |
| 230 | seite.on('pageerror', (fehler) => { |
| 231 | /* Die erste Zeile des Stapels sagt, wo es geknallt hat – ohne sie ist eine |
| 232 | Ausnahme im gebündelten Renderer-Code kaum zu finden. */ |
| 233 | const stapel = (fehler.stack ?? '').split('\n').slice(1, 2).join('').trim(); |
| 234 | vermerken({ |
| 235 | art: 'Ausnahme im Renderer', |
| 236 | herkunft: 'Renderer', |
| 237 | text: gekuerzt(`${fehler.name}: ${fehler.message}`), |
| 238 | ort: stapel === '' ? '(ohne Stapelangabe)' : stapel, |
| 239 | abschnitt, |
| 240 | }); |
| 241 | }); |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Hängt die Wache an eine gestartete Anwendung. |
| 246 | * |
| 247 | * Wird von `appStartenOhneZuschnitt` aufgerufen und deckt damit alle Suiten |
| 248 | * ab. `e2e/gepackt.spec.ts` startet das Paket selbst und ruft beides einzeln |
| 249 | * auf – dieselbe Wache, anderer Startweg. |
| 250 | */ |
| 251 | export function konsolenwacheAnhaengen(app: ElectronApplication): void { |
| 252 | /* Jede Suite startet ihre Anwendung im `beforeAll`, also zwischen zwei |
| 253 | Tests. Ohne diese Zeile bekämen die Meldungen des Starts die Beschriftung |
| 254 | des letzten Tests der vorigen Datei – und die Suche begänne am falschen |
| 255 | Ende. Welche Datei es ist, sagt ohnehin der fallende Test. */ |
| 256 | abschnitt = STARTPHASE; |
| 257 | |
| 258 | app.on('console', (meldung) => { |
| 259 | konsolenmeldung(meldung, 'Hauptprozess'); |
| 260 | }); |
| 261 | |
| 262 | app.on('window', (seite) => { |
| 263 | fensterUeberwachen(seite); |
| 264 | }); |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Nimmt die Befunde heraus, für die niemand geradesteht – und leert den Speicher. |
| 269 | * |
| 270 | * Geleert wird **vollständig**, einschließlich der angemeldeten Meldungen: |
| 271 | * Sonst schleppte der nächste Test sie mit und bekäme einen Befund für etwas, |
| 272 | * das der vorige ausgelöst hat. |
| 273 | */ |
| 274 | function entnehmen(): Befund[] { |
| 275 | const alle = aufgelaufen.splice(0, aufgelaufen.length); |
| 276 | return alle.filter((b) => !erwartungen.some((e) => e.muster.test(b.text))); |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * Der Bericht. |
| 281 | * |
| 282 | * Sagt, **was** auflief und **wo** – eine Wache, die nur „es gab Meldungen" |
| 283 | * meldet, verschiebt die Arbeit bloß auf den nächsten Lauf. Und sie sagt, |
| 284 | * wofür sie in diesem Test taub war: Wer einen Befund vor sich hat, soll |
| 285 | * nicht raten müssen, ob nebenan noch etwas durchgelassen wurde. |
| 286 | */ |
| 287 | function bericht(befunde: readonly Befund[]): string { |
| 288 | const zeilen = befunde.map( |
| 289 | (b, i) => |
| 290 | `${String(i + 1)}. ${b.art} (${b.herkunft}) in „${b.abschnitt}"\n` + |
| 291 | ` Meldung: ${b.text}\n` + |
| 292 | ` Ort: ${b.ort}`, |
| 293 | ); |
| 294 | |
| 295 | const angemeldet = |
| 296 | erwartungen.length === 0 |
| 297 | ? '' |
| 298 | : '\n\nAbsichtlich durchgelassen wurde außerdem:\n' + |
| 299 | erwartungen.map((e) => ` ${String(e.muster)} – ${e.grund}`).join('\n'); |
| 300 | |
| 301 | return ( |
| 302 | `Die Konsolenwache hat ${String(befunde.length)} Meldung(en) aufgefangen. ` + |
| 303 | 'Die laufende Anwendung darf beim Testlauf weder Fehler noch Warnungen schreiben.\n\n' + |
| 304 | zeilen.join('\n\n') + |
| 305 | angemeldet + |
| 306 | '\n\nEntweder die Ursache beheben – oder, wenn die Prüfung die Meldung absichtlich ' + |
| 307 | 'auslöst, sie mit `konsoleErwartet(muster, grund)` aus e2e/konsolenwache.ts anmelden.' |
| 308 | ); |
| 309 | } |
| 310 | |
| 311 | /** |
| 312 | * `test` mit angehängter Konsolenwache – die Einstiegsstelle für alle Suiten. |
| 313 | * |
| 314 | * Zwei automatische Fixtures, weil Meldungen in zwei Phasen kommen: |
| 315 | * |
| 316 | * - `konsolenwache` (je Test) schreibt vor dem Test den Marker und prüft nach |
| 317 | * ihm. Was während `beforeAll` auflief – Anwendungsstart, erster |
| 318 | * Ladevorgang, genau die Phase des CSP-Fehlers –, meldet damit der erste |
| 319 | * Test der Datei. |
| 320 | * - `konsolenwacheAbschluss` (je Arbeitsprozess) fegt am Ende nach. Ohne das |
| 321 | * fiele unter den Tisch, was während `afterAll` auflief, also beim |
| 322 | * Schließen der Anwendung. |
| 323 | * |
| 324 | * Beide Fixtures liefern `undefined` und nicht das sonst übliche `void`: |
| 325 | * Sie stellen keinen Wert bereit, sondern hängen nur eine Prüfung an – und |
| 326 | * `void` als Typargument verbietet `@typescript-eslint/no-invalid-void-type` |
| 327 | * zu Recht. |
| 328 | */ |
| 329 | export const test = basisTest.extend< |
| 330 | { konsolenwache: undefined }, |
| 331 | { konsolenwacheAbschluss: undefined } |
| 332 | >({ |
| 333 | konsolenwache: [ |
| 334 | // eslint-disable-next-line no-empty-pattern -- Playwright liest die Abhängigkeiten eines Fixtures aus dem destrukturierten ersten Argument und bricht ohne es ab; diese Wache braucht kein anderes Fixture. |
| 335 | async ({}, benutzen, info) => { |
| 336 | const teile = info.titlePath.filter((t) => t.length > 0); |
| 337 | const datei = basename(info.file); |
| 338 | if (!teile.some((t) => t.includes(datei))) { |
| 339 | teile.unshift(datei); |
| 340 | } |
| 341 | abschnitt = teile.join(' › '); |
| 342 | |
| 343 | await benutzen(undefined); |
| 344 | |
| 345 | /* Erst den Marker weiterstellen, dann prüfen: Was nach diesem Punkt noch |
| 346 | kommt, gehört zur Aufräumphase und nicht mehr zum Test. */ |
| 347 | const gerade = abschnitt; |
| 348 | abschnitt = `Aufräumphase nach „${gerade}"`; |
| 349 | |
| 350 | try { |
| 351 | const befunde = entnehmen(); |
| 352 | if (befunde.length > 0) { |
| 353 | throw new Error(bericht(befunde)); |
| 354 | } |
| 355 | } finally { |
| 356 | /* |
| 357 | Die Anmeldungen werden am Ende geräumt, nicht am Anfang. |
| 358 | |
| 359 | Am Anfang zu räumen wäre die naheliegendere Zeile und eine Falle: |
| 360 | Ein `konsoleErwartet` in einem `beforeAll` wäre gelöscht, bevor der |
| 361 | erste Test überhaupt beginnt – die angemeldete Meldung liefe dann |
| 362 | doch auf, und niemand verstünde warum. So gilt eine Anmeldung vom |
| 363 | Aufruf bis zum Ende des laufenden Tests, gleich wo sie steht. |
| 364 | |
| 365 | Im `finally`, weil die Prüfung darüber wirft: Bliebe die Anmeldung |
| 366 | stehen, wäre die Wache im nächsten Test taub. |
| 367 | */ |
| 368 | erwartungen = []; |
| 369 | } |
| 370 | }, |
| 371 | { auto: true }, |
| 372 | ], |
| 373 | |
| 374 | konsolenwacheAbschluss: [ |
| 375 | // eslint-disable-next-line no-empty-pattern -- siehe oben: Playwright verlangt die Destrukturierung als erstes Argument. |
| 376 | async ({}, benutzen) => { |
| 377 | await benutzen(undefined); |
| 378 | |
| 379 | const befunde = entnehmen(); |
| 380 | if (befunde.length > 0) { |
| 381 | throw new Error(bericht(befunde)); |
| 382 | } |
| 383 | }, |
| 384 | { scope: 'worker', auto: true }, |
| 385 | ], |
| 386 | }); |