waffensachkunde

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

/ app tests paketstand.test.ts

17,5 KB Rohdatei
app/tests/paketstand.test.ts — 450 Zeilen
1 // @vitest-environment node
2 // Reiner Dateisystem-Test – jsdom wird hier nicht gebraucht und würde
3 // `import.meta.url` auf eine http-URL setzen.
4
5 /**
6 * Der Altersvergleich für das gepackte Paket.
7 *
8 * Er entscheidet, ob die Prüfungen aus `e2e/gepackt.spec.ts` laufen.
9 * Sagt er fälschlich „aktuell“, prüfen sie die vorige Fassung und melden
10 * grün – genau das ist in diesem Projekt zweimal geschehen.
11 *
12 * Ein drittes Mal wäre um ein Haar dazugekommen: Der Vergleich sah nur
13 * `app/src` an, die ausgelieferten **Inhalte** liegen aber unter `content/`
14 * und kommen über `extraResources` ins Paket. Eine geänderte Erklärung ließ
15 * das Paket „so jung wie der Quelltext“ aussehen. Die Fälle unten halten
16 * beide Hälften fest.
17 */
18
19 import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
20 import { tmpdir } from 'node:os';
21 import { join, resolve } from 'node:path';
22 import { fileURLToPath } from 'node:url';
23
24 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
25
26 import {
27 ausgelieferteQuellen,
28 bauressourcenAusBauplan,
29 musterAusBauplan,
30 plattformDateilisten,
31 paketstand,
32 } from '../tools/paketstand.mjs';
33
34 /** Das echte `app/`-Verzeichnis dieses Projekts. */
35 const echteAppWurzel = fileURLToPath(new URL('..', import.meta.url));
36
37 /*
38 Ein Prüfbaum, der dem echten in der Form gleicht: `app/` neben `content/`,
39 weil `extraResources` mit `../content/...` genau über diese Grenze greift.
40 Läge `content/` unterhalb von `app/`, prüfte der Test eine Anordnung, die
41 es im Projekt nicht gibt.
42 */
43 let wurzel: string;
44 let appWurzel: string;
45
46 /** Bauplan in der Form der echten Datei – der Ausschnitt, der etwas liefert. */
47 const BAUPLAN = `appId: de.willerding.waffensachkunde
48
49 directories:
50 output: release
51 buildResources: build-resources
52
53 # Nur die gebauten Bundles paketieren.
54 files:
55 - out/**/*
56 - package.json
57 - '!**/*.map'
58 - '!**/{test,tests,__tests__,e2e}/**'
59
60 extraResources:
61 - from: ../content/katalog
62 to: katalog
63 filter:
64 - '**/*'
65 - from: ../content/erklaerungen.json
66 to: erklaerungen.json
67
68 asar: true
69 `;
70
71 /** Setzt das Änderungsdatum einer Datei auf jetzt plus Versatz. */
72 function datieren(pfad: string, sekundenVersatz: number): void {
73 const zeit = Date.now() / 1000 + sekundenVersatz;
74 utimesSync(pfad, zeit, zeit);
75 }
76
77 /** Schreibt eine Datei samt Elternverzeichnissen und datiert sie. */
78 function schreiben(pfad: string, inhalt: string, sekundenVersatz: number): void {
79 mkdirSync(join(pfad, '..'), { recursive: true });
80 writeFileSync(pfad, inhalt, 'utf8');
81 datieren(pfad, sekundenVersatz);
82 }
83
84 /** Legt eine Quelldatei unter `app/src/` an. */
85 function quelle(sekundenVersatz: number): void {
86 schreiben(join(appWurzel, 'src', 'datei.ts'), 'const a = 1;\n', sekundenVersatz);
87 }
88
89 /** Legt einen ausgelieferten Inhalt unter `content/` an. */
90 function inhalt(sekundenVersatz: number): void {
91 schreiben(join(wurzel, 'content', 'katalog', 'katalog.json'), '{}\n', sekundenVersatz);
92 schreiben(join(wurzel, 'content', 'erklaerungen.json'), '{}\n', sekundenVersatz);
93 }
94
95 /** Legt ein gepacktes Paket an und setzt sein Änderungsdatum. */
96 function paket(sekundenVersatz: number): void {
97 const verzeichnis = join(appWurzel, 'release', 'win-unpacked');
98 mkdirSync(verzeichnis, { recursive: true });
99 const pfad = join(verzeichnis, 'Waffensachkunde Lernsoftware.exe');
100 writeFileSync(pfad, 'MZ', 'utf8');
101 datieren(pfad, sekundenVersatz);
102 }
103
104 beforeEach(() => {
105 wurzel = mkdtempSync(join(tmpdir(), 'wsk-paketstand-'));
106 appWurzel = join(wurzel, 'app');
107 mkdirSync(join(appWurzel, 'src'), { recursive: true });
108 // Alles, was der Bauplan nennt, muss dastehen – sonst wirft die Auflösung.
109 schreiben(join(appWurzel, 'electron-builder.yml'), BAUPLAN, -7200);
110 schreiben(join(appWurzel, 'package.json'), '{"version":"0.0.0"}\n', -7200);
111 // Das Verzeichnis mit dem Programmsymbol. Es zählt zum Programm, weil das
112 // Symbol in die gebaute exe eingebaut wird.
113 schreiben(join(appWurzel, 'build-resources', 'icon.ico'), 'ICO', -7200);
114 quelle(-7200);
115 inhalt(-7200);
116 });
117
118 afterEach(() => {
119 rmSync(wurzel, { recursive: true, force: true });
120 });
121
122 describe('paketstand', () => {
123 it('meldet ein fehlendes Paket als nicht vorhanden', () => {
124 const stand = paketstand(appWurzel);
125
126 expect(stand.vorhanden).toBe(false);
127 expect(stand.veraltet).toBe(false);
128 expect(stand.grund).toContain('dist:win');
129 });
130
131 it('meldet ein Paket als veraltet, das älter ist als der Quelltext', () => {
132 /* Der Fall, der zweimal einen Fehler verdeckt hat: Das Paket liegt da,
133 ist aber von vor der Änderung. */
134 paket(-3600);
135 quelle(0);
136
137 const stand = paketstand(appWurzel);
138
139 expect(stand.vorhanden).toBe(true);
140 expect(stand.veraltet).toBe(true);
141 expect(stand.alterSekunden).toBeGreaterThan(3000);
142 expect(stand.grund).toContain('älter als das Programm');
143 });
144
145 it('meldet ein Paket als veraltet, dessen Inhalte weitergewandert sind', () => {
146 /*
147 Die Lücke, um die es hier geht. Der Quelltext bleibt alt, geändert wird
148 allein `content/` – also das, was über `extraResources` ins Paket geht.
149 Die vorige Fassung sah nur `app/src` an und hätte hier „so jung wie der
150 Quelltext“ gemeldet; die Prüfungen aus `e2e/gepackt.spec.ts` hätten
151 die vorige Fassung des Katalogs gemessen und grün gemeldet.
152 */
153 paket(-3600);
154 inhalt(0);
155
156 const stand = paketstand(appWurzel);
157
158 expect(stand.veraltet).toBe(true);
159 expect(stand.ursache).toBe('inhalte');
160 expect(stand.grund).toContain('älter als die mitgelieferten Inhalte');
161 });
162
163 it('benennt die Datei, die den Ausschlag gibt', () => {
164 /* „Irgendetwas ist neuer“ zwingt zum Suchen. Der Name der Datei macht aus
165 der Meldung eine nachprüfbare Aussage – und sagt zugleich, ob neu zu
166 bauen oder nur neu zu packen ist. */
167 paket(-3600);
168 schreiben(join(wurzel, 'content', 'erklaerungen.json'), '{"a":1}\n', 0);
169
170 const stand = paketstand(appWurzel);
171
172 expect(stand.juengsteQuelle).toBe('content/erklaerungen.json');
173 expect(stand.grund).toContain('content/erklaerungen.json');
174 });
175
176 it('nennt beides, wenn Quelltext und Inhalte weitergewandert sind', () => {
177 paket(-3600);
178 quelle(-60);
179 inhalt(0);
180
181 const stand = paketstand(appWurzel);
182
183 expect(stand.ursache).toBe('beides');
184 expect(stand.grund).toContain('Programm und mitgelieferte Inhalte');
185 });
186
187 it('lässt ein Paket gelten, das jünger ist als Quelltext und Inhalte', () => {
188 quelle(-3600);
189 inhalt(-3600);
190 paket(0);
191
192 const stand = paketstand(appWurzel);
193
194 expect(stand.veraltet).toBe(false);
195 expect(stand.alterSekunden).toBe(0);
196 expect(stand.ursache).toBeNull();
197 expect(stand.grund).toContain('Inhalte');
198 });
199
200 it('verzeiht eine Sekunde Unterschied', () => {
201 /* Ein Paketbau schreibt seine Ausgaben nicht in derselben Millisekunde,
202 in der er die Quellen liest. Ohne Toleranz schlüge die Prüfung
203 unmittelbar nach einem erfolgreichen Bau an. */
204 paket(-1);
205 quelle(0);
206 inhalt(0);
207
208 expect(paketstand(appWurzel).veraltet).toBe(false);
209 });
210
211 it('sieht auch in Unterverzeichnisse des Quelltextes', () => {
212 /* Die meisten Änderungen liegen nicht direkt unter src/, sondern tief
213 darunter. Ein Vergleich nur der obersten Ebene ginge fast immer gut
214 aus – und wäre damit wertlos. */
215 paket(-3600);
216 schreiben(join(appWurzel, 'src', 'renderer', 'components', 'Tief.tsx'), 'export {};\n', 0);
217
218 expect(paketstand(appWurzel).veraltet).toBe(true);
219 });
220
221 it('sieht auch in Unterverzeichnisse der Inhalte', () => {
222 /* Dasselbe Argument für die andere Hälfte: Die Prüfzeichen liegen unter
223 `content/katalog/assets/`, nicht daneben. */
224 paket(-3600);
225 schreiben(join(wurzel, 'content', 'katalog', 'assets', 'zeichen.png'), 'PNG', 0);
226
227 expect(paketstand(appWurzel).veraltet).toBe(true);
228 });
229
230 it('lässt `out/` selbst außer Betracht', () => {
231 /*
232 `files:` nennt `out/**\/*`, das ist aber das Erzeugnis von
233 `electron-vite build` aus `src/`. `npm run gate` baut es in jedem Lauf
234 neu. Zählte sein Alter mit, sähe das Paket unmittelbar nach jedem Gate
235 veraltet aus – und die Prüfungen übersprängen sich für immer.
236 */
237 paket(-3600);
238 schreiben(join(appWurzel, 'out', 'main', 'index.js'), 'console.log(1);\n', 0);
239
240 expect(paketstand(appWurzel).veraltet).toBe(false);
241 });
242
243 it('wirft, wenn der Bauplan auf etwas verweist, das es nicht gibt', () => {
244 /* Der stille Fehlschlag wäre der schlimmere: Ein Bauplan, den der
245 Vergleich nicht auflösen kann, hieße wieder „geprüft“ ohne Prüfung. */
246 paket(0);
247 schreiben(
248 join(appWurzel, 'electron-builder.yml'),
249 `${BAUPLAN}\nextraResources:\n - from: ../content/gibtesnicht.json\n to: x.json\n`,
250 -7200,
251 );
252
253 expect(() => paketstand(appWurzel)).toThrow(/gibtesnicht\.json/u);
254 });
255
256 it('wirft ohne Bauplan', () => {
257 paket(0);
258 rmSync(join(appWurzel, 'electron-builder.yml'));
259
260 expect(() => paketstand(appWurzel)).toThrow(/Bauplan/u);
261 });
262 });
263
264 describe('bauressourcenAusBauplan', () => {
265 /*
266 Das Verzeichnis mit dem Programmsymbol steht in keiner der beiden Listen
267 des Bauplans, sondern unter `directories`. Es zählt trotzdem: Das Symbol
268 wird in die exe eingebaut. Wer es ändert, ohne neu zu bauen, trägt das
269 alte Bild weiter – und ohne diese Auflösung meldete der Vergleich dazu
270 „so jung wie der Quelltext“.
271 */
272 it('findet das Verzeichnis unter directories', () => {
273 expect(bauressourcenAusBauplan(BAUPLAN)).toBe('build-resources');
274 });
275
276 it('meldet null, wenn keines eingetragen ist', () => {
277 expect(bauressourcenAusBauplan('appId: x\nfiles:\n - out/**/*\n')).toBeNull();
278 });
279
280 it('greift nicht auf einen gleichnamigen Schlüssel außerhalb des Blocks', () => {
281 /* Ein blankes Suchen nach „buildResources“ fände auch einen Eintrag, der
282 woanders steht – und bewachte dann ein Verzeichnis, aus dem gar nichts
283 gebaut wird. */
284 const fremd = 'appx:\n buildResources: irgendwo\ndirectories:\n output: release\n';
285 expect(bauressourcenAusBauplan(fremd)).toBeNull();
286 });
287
288 it('überliest Kommentare', () => {
289 const mitKommentar =
290 'directories:\n # buildResources: alt-und-falsch\n buildResources: build-resources\n';
291 expect(bauressourcenAusBauplan(mitKommentar)).toBe('build-resources');
292 });
293 });
294
295 describe('musterAusBauplan', () => {
296 it('trennt ausgeliefertes Programm von ausgelieferten Inhalten', () => {
297 const muster = musterAusBauplan(BAUPLAN);
298
299 expect(muster.programm).toEqual(['out/**/*', 'package.json']);
300 expect(muster.inhalte).toEqual(['../content/katalog', '../content/erklaerungen.json']);
301 });
302
303 it('übergeht Ausschlussmuster', () => {
304 // Ein Muster mit führendem Ausrufezeichen liefert nichts aus; sein Alter
305 // zu messen wäre sinnlos, und der Pfad ließe sich gar nicht auflösen.
306 expect(musterAusBauplan(BAUPLAN).programm).not.toContain('!**/*.map');
307 });
308
309 it('wirft bei einer Schreibweise, die es nicht versteht', () => {
310 /*
311 Der Kern dieser Wache: Käme im Bauplan eine Form dazu, die der
312 Ausschnitt still überliest, prüfte der Altersvergleich wieder weniger,
313 als er behauptet – und niemand merkte es. Lieber laut scheitern.
314 */
315 const fremd = 'extraResources:\n ? seltsam\n';
316
317 expect(() => musterAusBauplan(fremd)).toThrow(/unverstandene Zeile/u);
318 });
319
320 it('liest den echten Bauplan dieses Projekts vollständig', () => {
321 /*
322 Die eigentliche Absicherung gegen Auseinanderlaufen: nicht ein Abbild
323 des Bauplans, sondern der Bauplan selbst. Kommt eine Ressource dazu und
324 versteht der Ausschnitt sie nicht, fällt dieser Fall um – nicht erst der
325 nächste Paketbau.
326
327 Die erwarteten Werte stehen hier ausgeschrieben, damit ein stilles
328 Verschwinden auffällt: Ein Vergleich gegen „irgendetwas Nichtleeres“
329 wäre auch dann grün, wenn der Katalog aus dem Paket fiele.
330 */
331 const muster = musterAusBauplan(
332 readFileSync(join(echteAppWurzel, 'electron-builder.yml'), 'utf8'),
333 );
334
335 expect(muster.programm).toEqual(['out/**/*', 'package.json']);
336 expect(muster.inhalte).toEqual([
337 '../content/katalog',
338 '../content/erklaerungen.json',
339 '../content/glossar.json',
340 '../content/normtexte.json',
341 '../content/themen.json',
342 '../LICENSE.de.txt',
343 '../content/drittlizenzen.json',
344 /* Seit 0.25.0: Die Datenschutzerklärung liegt in der Anwendung. Wer
345 sie ändert, entwertet damit den Beleg der Paketprüfungen – genau
346 das soll diese Liste sicherstellen. */
347 '../content/datenschutz.json',
348 ]);
349 });
350 });
351
352 describe('Die Plattformblöcke des Bauplans', () => {
353 /*
354 Die Wache, die gefehlt hat – und der teuerste Befund dieser Prüfrunde.
355
356 electron-builder ersetzt die oberste `files:`-Liste durch die
357 plattformeigene, statt sie zu ergänzen. Besteht die plattformeigene nur
358 aus Ausschlussmustern, stellt die Bibliothek ihr `**\/*` voran
359 (`containsOnlyIgnore` in app-builder-lib/out/fileMatcher.js) – und dann
360 liegt alles im Paket, was die oberste Liste heraushalten sollte.
361
362 Gemessen am ausgelieferten `app.asar` der Fassung 0.27.2, derselben
363 Fassung, die die Store-Prüfung bestanden hat: 592 Dateien, davon in `out/`
364 (die gebauten Bundles) fünf. Dazu `coverage/` mit 217 Dateien und
365 8.967.480 Byte – der HTML-Abdeckungsbericht des letzten Gate-Laufs, der
366 nur mitreist, weil er auf dem Entwicklerrechner gerade dalag; `src/` mit
367 200, `tests/` mit 81, `e2e/` mit 26 Dateien, dazu `tools/`, sechs
368 tsconfig-Dateien, die Konfigurationen von ESLint, Prettier, Playwright und
369 Vitest, `README.md` und ein liegengebliebenes `v2-frage.png`. Zwei Zeilen
370 über der obersten Liste steht: „Nur die gebauten Bundles paketieren – die
371 Quellen bleiben draußen.“
372
373 Geprüft wird die Bedingung selbst, nicht ihre Folge: Eine
374 plattformeigene Liste darf nie nur aus Ausschlüssen bestehen, und sie muss
375 jeden einschließenden Eintrag der obersten Liste mitführen – sonst fällt
376 beim Ersetzen etwas weg. Was am Ende wirklich im Archiv liegt, prüft
377 `e2e/gepackt.spec.ts` am gebauten Paket.
378 */
379 const bauplan = readFileSync(join(echteAppWurzel, 'electron-builder.yml'), 'utf8');
380 const listen = plattformDateilisten(bauplan);
381 const oberste = musterAusBauplan(bauplan).programm;
382
383 it('gibt es überhaupt – sonst bewacht dieser Block nichts', () => {
384 expect(Object.keys(listen).sort()).toEqual(['mac', 'win']);
385 expect(oberste.length).toBeGreaterThan(0);
386 });
387
388 for (const plattform of ['mac', 'win']) {
389 it(`besteht bei ${plattform} nicht nur aus Ausschlüssen`, () => {
390 const liste = listen[plattform] ?? [];
391 const einschliessend = liste.filter((muster) => !muster.startsWith('!'));
392
393 expect(
394 einschliessend,
395 `${plattform}.files enthält nur Ausschlussmuster – electron-builder stellt dann ` +
396 '"**/*" voran und packt den ganzen Projektbaum ein',
397 ).not.toEqual([]);
398 });
399
400 it(`führt bei ${plattform} jeden einschließenden Eintrag der obersten Liste mit`, () => {
401 /* Die plattformeigene Liste ERSETZT die oberste. Was dort fehlt, wird
402 nicht etwa geerbt – es fehlt. */
403 const liste = listen[plattform] ?? [];
404 const fehlend = oberste.filter((muster) => !liste.includes(muster));
405
406 expect(fehlend, `${plattform}.files fehlen Einträge der obersten files:-Liste`).toEqual([]);
407 });
408 }
409 });
410
411 describe('ausgelieferteQuellen gegen den echten Projektbaum', () => {
412 it('löst jeden Pfad des echten Bauplans auf einen vorhandenen Ort auf', () => {
413 /*
414 Ohne diesen Fall bliebe die Herleitung Theorie. Er darf nicht werfen –
415 ein Wurf hieße, dass Bauplan und Auflösung auseinanderliegen.
416
417 Bewusst über `ausgelieferteQuellen()` und nicht über `paketstand()`:
418 Letzteres kehrt ohne gebautes Paket sofort um und käme an der Auflösung
419 vorbei. Der Test prüfte dann an jedem Arbeitsplatz ohne `release/`
420 nichts und meldete trotzdem grün – dieselbe Unwahrheit, gegen die diese
421 ganze Datei angelegt ist.
422 */
423 const quellen = ausgelieferteQuellen(echteAppWurzel);
424
425 // `out/**/*` zählt als `src/`: das Erzeugnis wird über seine Quelle datiert.
426 expect(quellen.quelltext).toEqual([
427 resolve(echteAppWurzel, 'src'),
428 resolve(echteAppWurzel, 'package.json'),
429 // Der Bauplan selbst: Er bestimmt nicht nur, was ausgeliefert wird,
430 // sondern auch wie – Ziele, Dateilisten, ASAR-Entpackung, die Angaben
431 // des Store-Pakets. Bis 0.24.1 sah die Wache jeden Pfad an, den er
432 // nennt, nur nicht ihn.
433 resolve(echteAppWurzel, 'electron-builder.yml'),
434 // Das Programmsymbol steckt in der gebauten exe, steht aber in keiner
435 // der beiden Listen des Bauplans - es kommt aus directories.buildResources.
436 resolve(echteAppWurzel, 'build-resources'),
437 ]);
438 // Der Inhalt, den `e2e/gepackt.spec.ts` im Paket misst.
439 expect(quellen.inhalte).toEqual([
440 resolve(echteAppWurzel, '..', 'content', 'katalog'),
441 resolve(echteAppWurzel, '..', 'content', 'erklaerungen.json'),
442 resolve(echteAppWurzel, '..', 'content', 'glossar.json'),
443 resolve(echteAppWurzel, '..', 'content', 'normtexte.json'),
444 resolve(echteAppWurzel, '..', 'content', 'themen.json'),
445 resolve(echteAppWurzel, '..', 'LICENSE.de.txt'),
446 resolve(echteAppWurzel, '..', 'content', 'drittlizenzen.json'),
447 resolve(echteAppWurzel, '..', 'content', 'datenschutz.json'),
448 ]);
449 });
450 });