waffensachkunde

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

/ app e2e electron-hilfe.ts

13,3 KB Rohdatei
app/e2e/electron-hilfe.ts — 340 Zeilen
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 }