waffensachkunde

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

/ app e2e gepackt.spec.ts

15,9 KB Rohdatei
app/e2e/gepackt.spec.ts — 365 Zeilen
1 /**
2 * Rauchtest gegen die **gepackte** Anwendung.
3 *
4 * Im gepackten Zustand gilt `app.isPackaged === true`, und damit greift eine
5 * andere Pfadauflösung: Katalog und Lizenztexte liegen dann unter
6 * `resources/`, nicht im Projektbaum. Genau dieser Unterschied hat schon
7 * einmal dazu geführt, dass der Fragenkatalog nicht gefunden wurde – ein
8 * Fehler, den kein Test gegen den Quellbaum bemerkt hätte.
9 *
10 * Läuft nur, wenn zuvor `npm run dist:win` gelaufen ist.
11 */
12 import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
13 import { tmpdir } from 'node:os';
14 import { join } from 'node:path';
15
16 import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
17
18 import { cspRichtlinie } from '../src/shared/csp';
19 import { paketstand } from '../tools/paketstand.mjs';
20 import {
21 angewandteRichtlinien,
22 konsoleBeimLaden,
23 metaRichtlinie,
24 projektWurzel,
25 weiterAmZuschnitt,
26 } from './electron-hilfe';
27 import { expect, fensterUeberwachen, konsolenwacheAnhaengen, test } from './konsolenwache';
28
29 const EXE = join(projektWurzel, 'release', 'win-unpacked', 'Waffensachkunde Lernsoftware.exe');
30
31 // `undefined`, solange der beforeAll-Hook übersprungen wurde.
32 let app: ElectronApplication | undefined;
33 let fenster: Page;
34 let profil: string;
35
36 test.beforeAll(async () => {
37 /*
38 Nicht nur, OB ein Paket dasteht, sondern ob es zum Quelltext passt.
39
40 Ein Paket von gestern gegen den Quelltext von heute ist schlimmer als gar
41 keines: Der Lauf ist grün und misst die vorige Fassung. Genau das ist hier
42 zweimal passiert – der Wortlaut „0 von 575 Fragen sicher“ nach dem Umbau
43 der Reifekennzahl und der mehrdeutige Locator nach der neuen Katalogzeile.
44 Beide Male meldete das Gate grün, beide Male fiel es erst beim nächsten
45 Paketbau auf. `bauPruefen()` führt dieses Argument für `out/` seit jeher;
46 auf das Paket wurde es nie angewandt.
47
48 Übersprungen statt abgebrochen: `npm run gate` baut das Paket nicht mit,
49 und ein harter Abbruch zwänge zu einem mehrminütigen `dist:win` vor jedem
50 Lauf. Dass dabei etwas ungeprüft bleibt, sagt der Gate-Bericht am Ende –
51 aus derselben Quelle, damit Bericht und Lauf nicht auseinanderlaufen.
52 */
53 const paket = paketstand(projektWurzel);
54 test.skip(!paket.vorhanden || paket.veraltet, paket.grund);
55
56 profil = mkdtempSync(join(tmpdir(), 'wsk-gepackt-'));
57 app = await electron.launch({ executablePath: EXE, args: [`--user-data-dir=${profil}`] });
58 /* Diese Datei startet selbst und geht nicht durch `appStarten` – die
59 Konsolenwache muss deshalb hier von Hand angehängt werden. Gerade hier
60 lohnt sie: Nur im Paket gelten die ausgelieferten Pfade und die strenge
61 Richtlinie, ein Fehler daraus meldet sich sonst erst beim Anwender. */
62 konsolenwacheAnhaengen(app);
63 fenster = await app.firstWindow();
64 fensterUeberwachen(fenster);
65 await fenster.waitForLoadState('domcontentloaded');
66 await fenster.waitForSelector('h1', { state: 'visible' });
67
68 /* Diese Datei startet das Paket selbst und geht nicht durch `appStarten` –
69 die Erststart-Frage nach dem abwählbaren Kapitel steht deshalb auch hier.
70 Beantwortet wird sie wie überall mit „mitlernen“. */
71 await weiterAmZuschnitt(fenster);
72 });
73
74 test.afterAll(async () => {
75 await app?.close();
76 try {
77 rmSync(profil, { recursive: true, force: true });
78 } catch {
79 /* Aufräumen ist Kür. */
80 }
81 });
82
83 /**
84 * Liest das Inhaltsverzeichnis eines asar-Archivs.
85 *
86 * Von Hand statt über `@electron/asar`: Das Modul liegt zwar unter
87 * `node_modules`, aber nur als Beipack von electron-builder – ein Import
88 * darauf wäre eine nicht erklärte Abhängigkeit. Dieselbe Überlegung wie bei
89 * dem YAML-Ausschnitt in `tools/paketstand.mjs`.
90 *
91 * Der Kopf ist ein Chromium-Pickle: vier Byte Gesamtlänge, vier Byte Länge
92 * des Kopf-Pickles, vier Byte Nutzlänge, vier Byte Länge der Zeichenkette,
93 * dann das JSON.
94 */
95 function asarVerzeichnis(pfad: string): Record<string, unknown> {
96 const roh = readFileSync(pfad);
97 const kopfLaenge = roh.readUInt32LE(12);
98 const kopf = JSON.parse(roh.subarray(16, 16 + kopfLaenge).toString('utf8')) as {
99 files: Record<string, unknown>;
100 };
101 return kopf.files;
102 }
103
104 test('liefert im Archiv nur die gebauten Bundles aus', () => {
105 /*
106 Die Wache, die gefehlt hat.
107
108 Das ausgelieferte `app.asar` der Fassung 0.27.2 – derselben Fassung, die
109 die Store-Prüfung bestanden hat – enthielt 592 Dateien: neben den fünf
110 aus `out/` den vollständigen TypeScript-Quelltext (`src/`, 200 Dateien),
111 alle Unit- und E2E-Tests (81 und 26), `tools/`, sechs tsconfig-Dateien,
112 die Konfigurationen von ESLint, Prettier, Playwright und Vitest,
113 `README.md`, ein liegengebliebenes `v2-frage.png` – und `coverage/` mit
114 217 Dateien und 8.967.480 Byte, den HTML-Abdeckungsbericht des letzten
115 Gate-Laufs. Der Inhalt des Pakets hing damit vom Zustand des
116 Entwicklerrechners ab: Wer vorher keinen Abdeckungslauf gefahren hatte,
117 lieferte ein anderes Paket aus.
118
119 Ursache war eine Falle von electron-builder, die
120 `tests/paketstand.test.ts` inzwischen an der Konfiguration abfängt. Diese
121 Prüfung sieht am Erzeugnis nach – denn eine Konfiguration, die richtig
122 aussieht, ist noch kein Paket, das richtig ist.
123
124 Geprüft wird die oberste Ebene und nicht jede Datei: `out/`,
125 `node_modules/` und `package.json` sind alles, was hineingehört.
126 */
127 const verzeichnis = asarVerzeichnis(
128 join(projektWurzel, 'release', 'win-unpacked', 'resources', 'app.asar'),
129 );
130
131 expect(Object.keys(verzeichnis).sort()).toEqual(['node_modules', 'out', 'package.json']);
132 });
133
134 test('startet und zeigt den Startbildschirm', async () => {
135 await expect(fenster).toHaveTitle('Waffensachkunde – Lernsoftware');
136 await expect(fenster.getByRole('heading', { level: 1 })).toHaveText(
137 'Waffensachkunde – Lernsoftware',
138 );
139 });
140
141 test('findet den Fragenkatalog unter resources/', async () => {
142 /*
143 Der schärfste Test des gepackten Zustands: Steht die Gesamtzahl da, wurde
144 katalog.json tatsächlich gefunden und geladen.
145
146 Aus `aria-valuemax` statt aus dem Text. Der Satz der Ampel wechselt seine
147 Form je nach Stand – bei leerem Stand nennt er die Belegregel statt einer
148 Zahl –, die Obergrenze des Balkens ist dagegen immer der Umfang des
149 Katalogs. Die vorige Fassung hing am Wortlaut „0 von 575 Fragen sicher“
150 und wurde beim Umbau der Kennzahl übersehen: Diese Datei läuft nur, wenn
151 `release/win-unpacked/` existiert, und war deshalb bei jedem Lauf ohne
152 gebautes Paket schlicht übersprungen.
153 */
154 await expect(
155 fenster.getByRole('progressbar', { name: 'Prüfungsreife insgesamt' }),
156 ).toHaveAttribute('aria-valuemax', '575', { timeout: 15_000 });
157 });
158
159 test('findet die Erklaerungen unter resources/', async () => {
160 /* Die Erklaerungen liegen als eigene Datei neben dem Katalog. Ihr Pfad
161 wird im gepackten Zustand anders aufgeloest als im Quellbaum – genau
162 diese Unterscheidung hatte beim Katalog schon einmal versagt. */
163 const anzahl = await fenster.evaluate(async () => {
164 const api = (globalThis as Record<string, unknown>)['lernApp'] as
165 { erklaerungenLaden?: () => Promise<{ zuFrage: Record<string, unknown> }> } | undefined;
166 if (!api?.erklaerungenLaden) {
167 return -1;
168 }
169 const daten = await api.erklaerungenLaden();
170 return Object.keys(daten.zuFrage).length;
171 });
172
173 /* Nicht nur „mehr als null": Der Lader faellt bei jedem Formfehler still
174 auf einen leeren Bestand zurueck, und genau das ist einmal passiert –
175 450 Angaben standen als Zahl statt als Zeichenkette in der Datei, das
176 Paket haette ohne eine einzige Erklaerung ausgeliefert. Geprueft wird
177 deshalb gegen die Zahl der Fragen im Katalog. */
178 expect(anzahl).toBe(575);
179 });
180
181 test('findet das Glossar unter resources/', async () => {
182 /* Dieselbe Falle wie bei den Erklaerungen: Der Lader faellt bei jedem
183 Formfehler still auf einen leeren Bestand zurueck. Ein Glossar, das
184 ausgeliefert wird und leer ankommt, faellt sonst niemandem auf. */
185 const anzahl = await fenster.evaluate(async () => {
186 const api = (globalThis as Record<string, unknown>)['lernApp'] as
187 { glossarLaden?: () => Promise<{ eintraege: unknown[] }> } | undefined;
188 if (!api?.glossarLaden) {
189 return -1;
190 }
191 const daten = await api.glossarLaden();
192 return daten.eintraege.length;
193 });
194
195 // Der Bestand waechst; geprueft wird, dass er ueberhaupt ankommt.
196 expect(anzahl).toBeGreaterThan(50);
197 });
198
199 test('findet die Normtexte unter resources/', async () => {
200 /* Seit 0.23.0 liegt der Wortlaut der zitierten Normen bei. Dieselbe Falle
201 wie bei Katalog, Erklaerungen und Glossar: Der Lader faellt bei jedem
202 Formfehler still auf einen leeren Bestand zurueck – und die Fundstellen
203 saehen dann wieder aus wie vor 0.23.0, naemlich wie reine Zitate ohne
204 Sprungziel. Das faellt in einem gepackten Paket niemandem auf, weil die
205 Anwendung weiterlaeuft. */
206 const gesetze = await fenster.evaluate(async () => {
207 const api = (globalThis as Record<string, unknown>)['lernApp'] as
208 { normtexteLaden?: () => Promise<{ gesetze: Record<string, unknown> }> } | undefined;
209 if (!api?.normtexteLaden) {
210 return -1;
211 }
212 const daten = await api.normtexteLaden();
213 return Object.keys(daten.gesetze).length;
214 });
215
216 // Sieben Gesetze werden zitiert; weniger hiesse, dass etwas fehlt.
217 expect(gesetze).toBe(7);
218 });
219
220 test('findet die Themengliederung unter resources/', async () => {
221 /* Ohne sie stuende in den Kapiteln II bis IV nur der Knopf fuer das ganze
222 Kapitel – also der Zustand vor 0.23.0. Auch das laeuft still weiter. */
223 const fragen = await fenster.evaluate(async () => {
224 const api = (globalThis as Record<string, unknown>)['lernApp'] as
225 { themenLaden?: () => Promise<{ gruppen: { fragen: string[] }[] }> } | undefined;
226 if (!api?.themenLaden) {
227 return -1;
228 }
229 const daten = await api.themenLaden();
230 return daten.gruppen.reduce((summe, gruppe) => summe + gruppe.fragen.length, 0);
231 });
232
233 /* Gegen die Zahl der Fragen ohne amtlichen Abschnitt und nicht gegen „mehr
234 als null": Die Gliederung ist nur brauchbar, wenn sie vollstaendig ist. */
235 expect(fragen).toBe(230);
236 });
237
238 test('meldet eine einsatzbereite Datenbank', async () => {
239 await expect(fenster.getByText(/SQLite \d+\.\d+/u)).toBeVisible();
240 });
241
242 test('wendet im Paket beide Richtlinien an – die strenge auch im HTTP-Kopf', async () => {
243 /*
244 Erst hier ist die Frage wirklich beantwortet.
245
246 `app.isPackaged` ist nur im Paket wahr, und erst dann liefert der
247 Hauptprozess die Produktionsfassung in den Kopf; der Lauf gegen `out/`
248 misst dort die Dev-Fassung. Und erst hier steht fest, dass der Kopf-Weg
249 beim Laden über `file://` aus dem Paket heraus überhaupt ankommt – genau
250 das hatte `src/shared/csp.ts` jahrelang anders behauptet.
251
252 Geprüft wird deshalb der Wortlaut BEIDER tatsächlich angewandter
253 Richtlinien gegen die eine Quelle der Wahrheit.
254 */
255 const meta = await metaRichtlinie(fenster);
256 expect(meta).toBe(cspRichtlinie('production', 'meta'));
257
258 const angewandt = await angewandteRichtlinien(fenster);
259 expect(angewandt.slice().sort()).toEqual(
260 [cspRichtlinie('production', 'meta'), cspRichtlinie('production', 'kopf')].sort(),
261 );
262
263 /* Der Punkt der ganzen Übung: Die Direktive kommt über den Kopf an, wo sie
264 gilt – und nicht mehr über das `<meta>`-Element, wo Chromium sie verwirft. */
265 const ausDemKopf = angewandt.filter((r) => r !== meta);
266 expect(ausDemKopf[0]).toContain("frame-ancestors 'none'");
267 expect(meta).not.toContain('frame-ancestors');
268 });
269
270 test('startet ohne Konsolenfehler über die Richtlinie', async () => {
271 /* Der ignorierte `frame-ancestors`-Eintrag im `<meta>` schrieb bei jedem
272 Start einen Konsolenfehler. Im ausgelieferten Programm sieht den niemand –
273 grund genug, ihn hier festzuhalten, statt ihn im Paket mitzuliefern. */
274 const meldungen = await konsoleBeimLaden(fenster);
275
276 const zurRichtlinie = meldungen.filter((m) => /content security policy/iu.test(m));
277 expect(
278 zurRichtlinie,
279 `Konsolenausgabe beim Laden:\n${meldungen.join('\n') || '(keine)'}`,
280 ).toEqual([]);
281 });
282
283 test('zeigt die Anwendungsversion, nicht die von Electron', async () => {
284 /* Die Nummer kommt aus der package.json, nicht aus dem Test: Eine fest
285 eingetragene Version bräche diesen Test bei jedem Versionswechsel – und
286 zwar mit einer Meldung, die den eigentlichen Punkt verfehlt. */
287 const paket = JSON.parse(readFileSync(join(projektWurzel, 'package.json'), 'utf8')) as {
288 version: string;
289 };
290 /* Gezielt der Wert NEBEN „Programmversion“: Die Liste nennt die
291 Electron-Version an anderer Stelle völlig zu Recht, ein Blick auf die
292 ganze Liste ginge also am Punkt vorbei. */
293 const wert = fenster.locator('.statusliste dt:has-text("Programmversion") + dd');
294
295 await expect(wert).toHaveText(paket.version);
296 });
297
298 test('nennt den Baustand mit Commit-Kürzel', async () => {
299 /* Die Versionsnummer allein benennt keinen Stand: Zwischen zwei
300 Veröffentlichungen entstehen viele Bauten mit derselben Nummer. */
301 const liste = fenster.locator('.statusliste');
302
303 await expect(liste).toContainText('Baustand');
304 /* Eigene Klasse statt "das code-Element in der Liste": Seit dort auch der
305 Quellort des Fragenkatalogs steht, gibt es zwei. Die Mehrdeutigkeit fiel
306 erst beim naechsten Bau auf, weil diese Datei nur gegen ein gebautes
307 Paket laeuft - genau der Fall, vor dem docs/veroeffentlichen.md warnt. */
308 await expect(fenster.locator('.statusliste__baukennung')).toHaveText(/^[0-9a-f]{7,}\+?$/u);
309 });
310
311 test('lädt die Lizenztexte aus resources/', async () => {
312 await fenster
313 .getByRole('button', { name: /Lizenzen und Herkunft anzeigen/i })
314 .first()
315 .click();
316
317 await expect(fenster.getByRole('heading', { name: /Über diese Software/i })).toBeVisible();
318 // EUPL-Volltext und Fremdkomponenten kommen aus extraResources.
319 await expect(fenster.getByText(/European Union Public Licence/i).first()).toBeVisible();
320 await expect(fenster.getByText(/better-sqlite3/i).first()).toBeVisible();
321 });
322
323 /*
324 Die Datenschutzerklärung war bis Fassung 0.24.2 in der Anwendung nirgends
325 erreichbar: `docs/` kommt nicht ins Paket, und der einzige Weg nach außen
326 führt zur Unterstützungsseite. Seit 0.25.0 liegt `content/datenschutz.json`
327 über `extraResources` unter `resources/` — und ausschließlich diese Prüfung
328 belegt, dass sie dort auch ankommt. Ein Lader, der die Datei nicht findet,
329 zeigt eine Begründung statt der Erklärung; im Quellbaum fiele das nie auf,
330 weil er sie dort aufwärts sucht und immer findet.
331 */
332 test('lädt die Datenschutzerklärung aus resources/', async () => {
333 /* Kein Klick auf „Lizenzen und Herkunft anzeigen“: Die Prüfungen dieser
334 Datei teilen sich eine laufende Anwendung, und die vorige lässt „Über
335 diese Software“ offen. Der Knopf steht dort nicht mehr. */
336 await expect(fenster.getByRole('heading', { name: 'Datenschutz', level: 2 })).toBeVisible();
337
338 const schalter = fenster.getByRole('button', { name: /Datenschutzerklärung lesen/i });
339 await expect(schalter).toBeVisible();
340 await schalter.click();
341
342 /* Ein Satz aus Abschnitt 3, der die Aussage der Erklärung trägt – und
343 zugleich die Stelle, an der die Oberfläche bis 0.24.2 das Gegenteil
344 behauptete („sie sammelt keine Daten“). */
345 await expect(fenster.getByText(/entstehen Daten/i).first()).toBeVisible();
346 });
347
348 test('startet eine Lernsitzung mit echten Katalogfragen', async () => {
349 await fenster
350 /* „Zum Start“ steht seit der Zurueckleiste zweimal in dieser Ansicht:
351 oben zur Orientierung, unten hinter dem langen Inhalt. Beide fuehren
352 an dieselbe Stelle. */
353 .getByRole('button', { name: /Zum Start/i })
354 .first()
355 .click();
356 await fenster
357 .getByRole('button', { name: /Weiterlernen/i })
358 .first()
359 .click();
360
361 // Eine amtliche Fragennummer belegt, dass echte Daten geladen wurden.
362 await expect(fenster.getByRole('heading', { name: /Frage \d/u })).toBeVisible({
363 timeout: 15_000,
364 });
365 });