waffensachkunde

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

/ app e2e gepackt.spec.ts

13,7 KB Rohdatei
app/e2e/gepackt.spec.ts — 314 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 test('startet und zeigt den Startbildschirm', async () => {
84 await expect(fenster).toHaveTitle('Waffensachkunde – Lernsoftware');
85 await expect(fenster.getByRole('heading', { level: 1 })).toHaveText(
86 'Waffensachkunde – Lernsoftware',
87 );
88 });
89
90 test('findet den Fragenkatalog unter resources/', async () => {
91 /*
92 Der schärfste Test des gepackten Zustands: Steht die Gesamtzahl da, wurde
93 katalog.json tatsächlich gefunden und geladen.
94
95 Aus `aria-valuemax` statt aus dem Text. Der Satz der Ampel wechselt seine
96 Form je nach Stand – bei leerem Stand nennt er die Belegregel statt einer
97 Zahl –, die Obergrenze des Balkens ist dagegen immer der Umfang des
98 Katalogs. Die vorige Fassung hing am Wortlaut „0 von 575 Fragen sicher“
99 und wurde beim Umbau der Kennzahl übersehen: Diese Datei läuft nur, wenn
100 `release/win-unpacked/` existiert, und war deshalb bei jedem Lauf ohne
101 gebautes Paket schlicht übersprungen.
102 */
103 await expect(
104 fenster.getByRole('progressbar', { name: 'Prüfungsreife insgesamt' }),
105 ).toHaveAttribute('aria-valuemax', '575', { timeout: 15_000 });
106 });
107
108 test('findet die Erklaerungen unter resources/', async () => {
109 /* Die Erklaerungen liegen als eigene Datei neben dem Katalog. Ihr Pfad
110 wird im gepackten Zustand anders aufgeloest als im Quellbaum – genau
111 diese Unterscheidung hatte beim Katalog schon einmal versagt. */
112 const anzahl = await fenster.evaluate(async () => {
113 const api = (globalThis as Record<string, unknown>)['lernApp'] as
114 { erklaerungenLaden?: () => Promise<{ zuFrage: Record<string, unknown> }> } | undefined;
115 if (!api?.erklaerungenLaden) {
116 return -1;
117 }
118 const daten = await api.erklaerungenLaden();
119 return Object.keys(daten.zuFrage).length;
120 });
121
122 /* Nicht nur „mehr als null": Der Lader faellt bei jedem Formfehler still
123 auf einen leeren Bestand zurueck, und genau das ist einmal passiert –
124 450 Angaben standen als Zahl statt als Zeichenkette in der Datei, das
125 Paket haette ohne eine einzige Erklaerung ausgeliefert. Geprueft wird
126 deshalb gegen die Zahl der Fragen im Katalog. */
127 expect(anzahl).toBe(575);
128 });
129
130 test('findet das Glossar unter resources/', async () => {
131 /* Dieselbe Falle wie bei den Erklaerungen: Der Lader faellt bei jedem
132 Formfehler still auf einen leeren Bestand zurueck. Ein Glossar, das
133 ausgeliefert wird und leer ankommt, faellt sonst niemandem auf. */
134 const anzahl = await fenster.evaluate(async () => {
135 const api = (globalThis as Record<string, unknown>)['lernApp'] as
136 { glossarLaden?: () => Promise<{ eintraege: unknown[] }> } | undefined;
137 if (!api?.glossarLaden) {
138 return -1;
139 }
140 const daten = await api.glossarLaden();
141 return daten.eintraege.length;
142 });
143
144 // Der Bestand waechst; geprueft wird, dass er ueberhaupt ankommt.
145 expect(anzahl).toBeGreaterThan(50);
146 });
147
148 test('findet die Normtexte unter resources/', async () => {
149 /* Seit 0.23.0 liegt der Wortlaut der zitierten Normen bei. Dieselbe Falle
150 wie bei Katalog, Erklaerungen und Glossar: Der Lader faellt bei jedem
151 Formfehler still auf einen leeren Bestand zurueck – und die Fundstellen
152 saehen dann wieder aus wie vor 0.23.0, naemlich wie reine Zitate ohne
153 Sprungziel. Das faellt in einem gepackten Paket niemandem auf, weil die
154 Anwendung weiterlaeuft. */
155 const gesetze = await fenster.evaluate(async () => {
156 const api = (globalThis as Record<string, unknown>)['lernApp'] as
157 { normtexteLaden?: () => Promise<{ gesetze: Record<string, unknown> }> } | undefined;
158 if (!api?.normtexteLaden) {
159 return -1;
160 }
161 const daten = await api.normtexteLaden();
162 return Object.keys(daten.gesetze).length;
163 });
164
165 // Sieben Gesetze werden zitiert; weniger hiesse, dass etwas fehlt.
166 expect(gesetze).toBe(7);
167 });
168
169 test('findet die Themengliederung unter resources/', async () => {
170 /* Ohne sie stuende in den Kapiteln II bis IV nur der Knopf fuer das ganze
171 Kapitel – also der Zustand vor 0.23.0. Auch das laeuft still weiter. */
172 const fragen = await fenster.evaluate(async () => {
173 const api = (globalThis as Record<string, unknown>)['lernApp'] as
174 { themenLaden?: () => Promise<{ gruppen: { fragen: string[] }[] }> } | undefined;
175 if (!api?.themenLaden) {
176 return -1;
177 }
178 const daten = await api.themenLaden();
179 return daten.gruppen.reduce((summe, gruppe) => summe + gruppe.fragen.length, 0);
180 });
181
182 /* Gegen die Zahl der Fragen ohne amtlichen Abschnitt und nicht gegen „mehr
183 als null": Die Gliederung ist nur brauchbar, wenn sie vollstaendig ist. */
184 expect(fragen).toBe(230);
185 });
186
187 test('meldet eine einsatzbereite Datenbank', async () => {
188 await expect(fenster.getByText(/SQLite \d+\.\d+/u)).toBeVisible();
189 });
190
191 test('wendet im Paket beide Richtlinien an – die strenge auch im HTTP-Kopf', async () => {
192 /*
193 Erst hier ist die Frage wirklich beantwortet.
194
195 `app.isPackaged` ist nur im Paket wahr, und erst dann liefert der
196 Hauptprozess die Produktionsfassung in den Kopf; der Lauf gegen `out/`
197 misst dort die Dev-Fassung. Und erst hier steht fest, dass der Kopf-Weg
198 beim Laden über `file://` aus dem Paket heraus überhaupt ankommt – genau
199 das hatte `src/shared/csp.ts` jahrelang anders behauptet.
200
201 Geprüft wird deshalb der Wortlaut BEIDER tatsächlich angewandter
202 Richtlinien gegen die eine Quelle der Wahrheit.
203 */
204 const meta = await metaRichtlinie(fenster);
205 expect(meta).toBe(cspRichtlinie('production', 'meta'));
206
207 const angewandt = await angewandteRichtlinien(fenster);
208 expect(angewandt.slice().sort()).toEqual(
209 [cspRichtlinie('production', 'meta'), cspRichtlinie('production', 'kopf')].sort(),
210 );
211
212 /* Der Punkt der ganzen Übung: Die Direktive kommt über den Kopf an, wo sie
213 gilt – und nicht mehr über das `<meta>`-Element, wo Chromium sie verwirft. */
214 const ausDemKopf = angewandt.filter((r) => r !== meta);
215 expect(ausDemKopf[0]).toContain("frame-ancestors 'none'");
216 expect(meta).not.toContain('frame-ancestors');
217 });
218
219 test('startet ohne Konsolenfehler über die Richtlinie', async () => {
220 /* Der ignorierte `frame-ancestors`-Eintrag im `<meta>` schrieb bei jedem
221 Start einen Konsolenfehler. Im ausgelieferten Programm sieht den niemand –
222 grund genug, ihn hier festzuhalten, statt ihn im Paket mitzuliefern. */
223 const meldungen = await konsoleBeimLaden(fenster);
224
225 const zurRichtlinie = meldungen.filter((m) => /content security policy/iu.test(m));
226 expect(
227 zurRichtlinie,
228 `Konsolenausgabe beim Laden:\n${meldungen.join('\n') || '(keine)'}`,
229 ).toEqual([]);
230 });
231
232 test('zeigt die Anwendungsversion, nicht die von Electron', async () => {
233 /* Die Nummer kommt aus der package.json, nicht aus dem Test: Eine fest
234 eingetragene Version bräche diesen Test bei jedem Versionswechsel – und
235 zwar mit einer Meldung, die den eigentlichen Punkt verfehlt. */
236 const paket = JSON.parse(readFileSync(join(projektWurzel, 'package.json'), 'utf8')) as {
237 version: string;
238 };
239 /* Gezielt der Wert NEBEN „Programmversion“: Die Liste nennt die
240 Electron-Version an anderer Stelle völlig zu Recht, ein Blick auf die
241 ganze Liste ginge also am Punkt vorbei. */
242 const wert = fenster.locator('.statusliste dt:has-text("Programmversion") + dd');
243
244 await expect(wert).toHaveText(paket.version);
245 });
246
247 test('nennt den Baustand mit Commit-Kürzel', async () => {
248 /* Die Versionsnummer allein benennt keinen Stand: Zwischen zwei
249 Veröffentlichungen entstehen viele Bauten mit derselben Nummer. */
250 const liste = fenster.locator('.statusliste');
251
252 await expect(liste).toContainText('Baustand');
253 /* Eigene Klasse statt "das code-Element in der Liste": Seit dort auch der
254 Quellort des Fragenkatalogs steht, gibt es zwei. Die Mehrdeutigkeit fiel
255 erst beim naechsten Bau auf, weil diese Datei nur gegen ein gebautes
256 Paket laeuft - genau der Fall, vor dem docs/veroeffentlichen.md warnt. */
257 await expect(fenster.locator('.statusliste__baukennung')).toHaveText(/^[0-9a-f]{7,}\+?$/u);
258 });
259
260 test('lädt die Lizenztexte aus resources/', async () => {
261 await fenster
262 .getByRole('button', { name: /Lizenzen und Herkunft anzeigen/i })
263 .first()
264 .click();
265
266 await expect(fenster.getByRole('heading', { name: /Über diese Software/i })).toBeVisible();
267 // EUPL-Volltext und Fremdkomponenten kommen aus extraResources.
268 await expect(fenster.getByText(/European Union Public Licence/i).first()).toBeVisible();
269 await expect(fenster.getByText(/better-sqlite3/i).first()).toBeVisible();
270 });
271
272 /*
273 Die Datenschutzerklärung war bis Fassung 0.24.2 in der Anwendung nirgends
274 erreichbar: `docs/` kommt nicht ins Paket, und der einzige Weg nach außen
275 führt zur Unterstützungsseite. Seit 0.25.0 liegt `content/datenschutz.json`
276 über `extraResources` unter `resources/` — und ausschließlich diese Prüfung
277 belegt, dass sie dort auch ankommt. Ein Lader, der die Datei nicht findet,
278 zeigt eine Begründung statt der Erklärung; im Quellbaum fiele das nie auf,
279 weil er sie dort aufwärts sucht und immer findet.
280 */
281 test('lädt die Datenschutzerklärung aus resources/', async () => {
282 /* Kein Klick auf „Lizenzen und Herkunft anzeigen“: Die Prüfungen dieser
283 Datei teilen sich eine laufende Anwendung, und die vorige lässt „Über
284 diese Software“ offen. Der Knopf steht dort nicht mehr. */
285 await expect(fenster.getByRole('heading', { name: 'Datenschutz', level: 2 })).toBeVisible();
286
287 const schalter = fenster.getByRole('button', { name: /Datenschutzerklärung lesen/i });
288 await expect(schalter).toBeVisible();
289 await schalter.click();
290
291 /* Ein Satz aus Abschnitt 3, der die Aussage der Erklärung trägt – und
292 zugleich die Stelle, an der die Oberfläche bis 0.24.2 das Gegenteil
293 behauptete („sie sammelt keine Daten“). */
294 await expect(fenster.getByText(/entstehen Daten/i).first()).toBeVisible();
295 });
296
297 test('startet eine Lernsitzung mit echten Katalogfragen', async () => {
298 await fenster
299 /* „Zum Start“ steht seit der Zurueckleiste zweimal in dieser Ansicht:
300 oben zur Orientierung, unten hinter dem langen Inhalt. Beide fuehren
301 an dieselbe Stelle. */
302 .getByRole('button', { name: /Zum Start/i })
303 .first()
304 .click();
305 await fenster
306 .getByRole('button', { name: /Weiterlernen/i })
307 .first()
308 .click();
309
310 // Eine amtliche Fragennummer belegt, dass echte Daten geladen wurden.
311 await expect(fenster.getByRole('heading', { name: /Frage \d/u })).toBeVisible({
312 timeout: 15_000,
313 });
314 });