waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app e2e konsolenwache.ts

15,4 KB Rohdatei
app/e2e/konsolenwache.ts — 386 Zeilen
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 });