// @vitest-environment node // Reiner Dateisystem-Test – jsdom wird hier nicht gebraucht und würde // `import.meta.url` auf eine http-URL setzen. /** * Der Altersvergleich für das gepackte Paket. * * Er entscheidet, ob die Prüfungen aus `e2e/gepackt.spec.ts` laufen. * Sagt er fälschlich „aktuell“, prüfen sie die vorige Fassung und melden * grün – genau das ist in diesem Projekt zweimal geschehen. * * Ein drittes Mal wäre um ein Haar dazugekommen: Der Vergleich sah nur * `app/src` an, die ausgelieferten **Inhalte** liegen aber unter `content/` * und kommen über `extraResources` ins Paket. Eine geänderte Erklärung ließ * das Paket „so jung wie der Quelltext“ aussehen. Die Fälle unten halten * beide Hälften fest. */ import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ausgelieferteQuellen, bauressourcenAusBauplan, musterAusBauplan, plattformDateilisten, paketstand, } from '../tools/paketstand.mjs'; /** Das echte `app/`-Verzeichnis dieses Projekts. */ const echteAppWurzel = fileURLToPath(new URL('..', import.meta.url)); /* Ein Prüfbaum, der dem echten in der Form gleicht: `app/` neben `content/`, weil `extraResources` mit `../content/...` genau über diese Grenze greift. Läge `content/` unterhalb von `app/`, prüfte der Test eine Anordnung, die es im Projekt nicht gibt. */ let wurzel: string; let appWurzel: string; /** Bauplan in der Form der echten Datei – der Ausschnitt, der etwas liefert. */ const BAUPLAN = `appId: de.willerding.waffensachkunde directories: output: release buildResources: build-resources # Nur die gebauten Bundles paketieren. files: - out/**/* - package.json - '!**/*.map' - '!**/{test,tests,__tests__,e2e}/**' extraResources: - from: ../content/katalog to: katalog filter: - '**/*' - from: ../content/erklaerungen.json to: erklaerungen.json asar: true `; /** Setzt das Änderungsdatum einer Datei auf jetzt plus Versatz. */ function datieren(pfad: string, sekundenVersatz: number): void { const zeit = Date.now() / 1000 + sekundenVersatz; utimesSync(pfad, zeit, zeit); } /** Schreibt eine Datei samt Elternverzeichnissen und datiert sie. */ function schreiben(pfad: string, inhalt: string, sekundenVersatz: number): void { mkdirSync(join(pfad, '..'), { recursive: true }); writeFileSync(pfad, inhalt, 'utf8'); datieren(pfad, sekundenVersatz); } /** Legt eine Quelldatei unter `app/src/` an. */ function quelle(sekundenVersatz: number): void { schreiben(join(appWurzel, 'src', 'datei.ts'), 'const a = 1;\n', sekundenVersatz); } /** Legt einen ausgelieferten Inhalt unter `content/` an. */ function inhalt(sekundenVersatz: number): void { schreiben(join(wurzel, 'content', 'katalog', 'katalog.json'), '{}\n', sekundenVersatz); schreiben(join(wurzel, 'content', 'erklaerungen.json'), '{}\n', sekundenVersatz); } /** Legt ein gepacktes Paket an und setzt sein Änderungsdatum. */ function paket(sekundenVersatz: number): void { const verzeichnis = join(appWurzel, 'release', 'win-unpacked'); mkdirSync(verzeichnis, { recursive: true }); const pfad = join(verzeichnis, 'Waffensachkunde Lernsoftware.exe'); writeFileSync(pfad, 'MZ', 'utf8'); datieren(pfad, sekundenVersatz); } beforeEach(() => { wurzel = mkdtempSync(join(tmpdir(), 'wsk-paketstand-')); appWurzel = join(wurzel, 'app'); mkdirSync(join(appWurzel, 'src'), { recursive: true }); // Alles, was der Bauplan nennt, muss dastehen – sonst wirft die Auflösung. schreiben(join(appWurzel, 'electron-builder.yml'), BAUPLAN, -7200); schreiben(join(appWurzel, 'package.json'), '{"version":"0.0.0"}\n', -7200); // Das Verzeichnis mit dem Programmsymbol. Es zählt zum Programm, weil das // Symbol in die gebaute exe eingebaut wird. schreiben(join(appWurzel, 'build-resources', 'icon.ico'), 'ICO', -7200); quelle(-7200); inhalt(-7200); }); afterEach(() => { rmSync(wurzel, { recursive: true, force: true }); }); describe('paketstand', () => { it('meldet ein fehlendes Paket als nicht vorhanden', () => { const stand = paketstand(appWurzel); expect(stand.vorhanden).toBe(false); expect(stand.veraltet).toBe(false); expect(stand.grund).toContain('dist:win'); }); it('meldet ein Paket als veraltet, das älter ist als der Quelltext', () => { /* Der Fall, der zweimal einen Fehler verdeckt hat: Das Paket liegt da, ist aber von vor der Änderung. */ paket(-3600); quelle(0); const stand = paketstand(appWurzel); expect(stand.vorhanden).toBe(true); expect(stand.veraltet).toBe(true); expect(stand.alterSekunden).toBeGreaterThan(3000); expect(stand.grund).toContain('älter als das Programm'); }); it('meldet ein Paket als veraltet, dessen Inhalte weitergewandert sind', () => { /* Die Lücke, um die es hier geht. Der Quelltext bleibt alt, geändert wird allein `content/` – also das, was über `extraResources` ins Paket geht. Die vorige Fassung sah nur `app/src` an und hätte hier „so jung wie der Quelltext“ gemeldet; die Prüfungen aus `e2e/gepackt.spec.ts` hätten die vorige Fassung des Katalogs gemessen und grün gemeldet. */ paket(-3600); inhalt(0); const stand = paketstand(appWurzel); expect(stand.veraltet).toBe(true); expect(stand.ursache).toBe('inhalte'); expect(stand.grund).toContain('älter als die mitgelieferten Inhalte'); }); it('benennt die Datei, die den Ausschlag gibt', () => { /* „Irgendetwas ist neuer“ zwingt zum Suchen. Der Name der Datei macht aus der Meldung eine nachprüfbare Aussage – und sagt zugleich, ob neu zu bauen oder nur neu zu packen ist. */ paket(-3600); schreiben(join(wurzel, 'content', 'erklaerungen.json'), '{"a":1}\n', 0); const stand = paketstand(appWurzel); expect(stand.juengsteQuelle).toBe('content/erklaerungen.json'); expect(stand.grund).toContain('content/erklaerungen.json'); }); it('nennt beides, wenn Quelltext und Inhalte weitergewandert sind', () => { paket(-3600); quelle(-60); inhalt(0); const stand = paketstand(appWurzel); expect(stand.ursache).toBe('beides'); expect(stand.grund).toContain('Programm und mitgelieferte Inhalte'); }); it('lässt ein Paket gelten, das jünger ist als Quelltext und Inhalte', () => { quelle(-3600); inhalt(-3600); paket(0); const stand = paketstand(appWurzel); expect(stand.veraltet).toBe(false); expect(stand.alterSekunden).toBe(0); expect(stand.ursache).toBeNull(); expect(stand.grund).toContain('Inhalte'); }); it('verzeiht eine Sekunde Unterschied', () => { /* Ein Paketbau schreibt seine Ausgaben nicht in derselben Millisekunde, in der er die Quellen liest. Ohne Toleranz schlüge die Prüfung unmittelbar nach einem erfolgreichen Bau an. */ paket(-1); quelle(0); inhalt(0); expect(paketstand(appWurzel).veraltet).toBe(false); }); it('sieht auch in Unterverzeichnisse des Quelltextes', () => { /* Die meisten Änderungen liegen nicht direkt unter src/, sondern tief darunter. Ein Vergleich nur der obersten Ebene ginge fast immer gut aus – und wäre damit wertlos. */ paket(-3600); schreiben(join(appWurzel, 'src', 'renderer', 'components', 'Tief.tsx'), 'export {};\n', 0); expect(paketstand(appWurzel).veraltet).toBe(true); }); it('sieht auch in Unterverzeichnisse der Inhalte', () => { /* Dasselbe Argument für die andere Hälfte: Die Prüfzeichen liegen unter `content/katalog/assets/`, nicht daneben. */ paket(-3600); schreiben(join(wurzel, 'content', 'katalog', 'assets', 'zeichen.png'), 'PNG', 0); expect(paketstand(appWurzel).veraltet).toBe(true); }); it('lässt `out/` selbst außer Betracht', () => { /* `files:` nennt `out/**\/*`, das ist aber das Erzeugnis von `electron-vite build` aus `src/`. `npm run gate` baut es in jedem Lauf neu. Zählte sein Alter mit, sähe das Paket unmittelbar nach jedem Gate veraltet aus – und die Prüfungen übersprängen sich für immer. */ paket(-3600); schreiben(join(appWurzel, 'out', 'main', 'index.js'), 'console.log(1);\n', 0); expect(paketstand(appWurzel).veraltet).toBe(false); }); it('wirft, wenn der Bauplan auf etwas verweist, das es nicht gibt', () => { /* Der stille Fehlschlag wäre der schlimmere: Ein Bauplan, den der Vergleich nicht auflösen kann, hieße wieder „geprüft“ ohne Prüfung. */ paket(0); schreiben( join(appWurzel, 'electron-builder.yml'), `${BAUPLAN}\nextraResources:\n - from: ../content/gibtesnicht.json\n to: x.json\n`, -7200, ); expect(() => paketstand(appWurzel)).toThrow(/gibtesnicht\.json/u); }); it('wirft ohne Bauplan', () => { paket(0); rmSync(join(appWurzel, 'electron-builder.yml')); expect(() => paketstand(appWurzel)).toThrow(/Bauplan/u); }); }); describe('bauressourcenAusBauplan', () => { /* Das Verzeichnis mit dem Programmsymbol steht in keiner der beiden Listen des Bauplans, sondern unter `directories`. Es zählt trotzdem: Das Symbol wird in die exe eingebaut. Wer es ändert, ohne neu zu bauen, trägt das alte Bild weiter – und ohne diese Auflösung meldete der Vergleich dazu „so jung wie der Quelltext“. */ it('findet das Verzeichnis unter directories', () => { expect(bauressourcenAusBauplan(BAUPLAN)).toBe('build-resources'); }); it('meldet null, wenn keines eingetragen ist', () => { expect(bauressourcenAusBauplan('appId: x\nfiles:\n - out/**/*\n')).toBeNull(); }); it('greift nicht auf einen gleichnamigen Schlüssel außerhalb des Blocks', () => { /* Ein blankes Suchen nach „buildResources“ fände auch einen Eintrag, der woanders steht – und bewachte dann ein Verzeichnis, aus dem gar nichts gebaut wird. */ const fremd = 'appx:\n buildResources: irgendwo\ndirectories:\n output: release\n'; expect(bauressourcenAusBauplan(fremd)).toBeNull(); }); it('überliest Kommentare', () => { const mitKommentar = 'directories:\n # buildResources: alt-und-falsch\n buildResources: build-resources\n'; expect(bauressourcenAusBauplan(mitKommentar)).toBe('build-resources'); }); }); describe('musterAusBauplan', () => { it('trennt ausgeliefertes Programm von ausgelieferten Inhalten', () => { const muster = musterAusBauplan(BAUPLAN); expect(muster.programm).toEqual(['out/**/*', 'package.json']); expect(muster.inhalte).toEqual(['../content/katalog', '../content/erklaerungen.json']); }); it('übergeht Ausschlussmuster', () => { // Ein Muster mit führendem Ausrufezeichen liefert nichts aus; sein Alter // zu messen wäre sinnlos, und der Pfad ließe sich gar nicht auflösen. expect(musterAusBauplan(BAUPLAN).programm).not.toContain('!**/*.map'); }); it('wirft bei einer Schreibweise, die es nicht versteht', () => { /* Der Kern dieser Wache: Käme im Bauplan eine Form dazu, die der Ausschnitt still überliest, prüfte der Altersvergleich wieder weniger, als er behauptet – und niemand merkte es. Lieber laut scheitern. */ const fremd = 'extraResources:\n ? seltsam\n'; expect(() => musterAusBauplan(fremd)).toThrow(/unverstandene Zeile/u); }); it('liest den echten Bauplan dieses Projekts vollständig', () => { /* Die eigentliche Absicherung gegen Auseinanderlaufen: nicht ein Abbild des Bauplans, sondern der Bauplan selbst. Kommt eine Ressource dazu und versteht der Ausschnitt sie nicht, fällt dieser Fall um – nicht erst der nächste Paketbau. Die erwarteten Werte stehen hier ausgeschrieben, damit ein stilles Verschwinden auffällt: Ein Vergleich gegen „irgendetwas Nichtleeres“ wäre auch dann grün, wenn der Katalog aus dem Paket fiele. */ const muster = musterAusBauplan( readFileSync(join(echteAppWurzel, 'electron-builder.yml'), 'utf8'), ); expect(muster.programm).toEqual(['out/**/*', 'package.json']); expect(muster.inhalte).toEqual([ '../content/katalog', '../content/erklaerungen.json', '../content/glossar.json', '../content/normtexte.json', '../content/themen.json', '../LICENSE.de.txt', '../content/drittlizenzen.json', /* Seit 0.25.0: Die Datenschutzerklärung liegt in der Anwendung. Wer sie ändert, entwertet damit den Beleg der Paketprüfungen – genau das soll diese Liste sicherstellen. */ '../content/datenschutz.json', ]); }); }); describe('Die Plattformblöcke des Bauplans', () => { /* Die Wache, die gefehlt hat – und der teuerste Befund dieser Prüfrunde. electron-builder ersetzt die oberste `files:`-Liste durch die plattformeigene, statt sie zu ergänzen. Besteht die plattformeigene nur aus Ausschlussmustern, stellt die Bibliothek ihr `**\/*` voran (`containsOnlyIgnore` in app-builder-lib/out/fileMatcher.js) – und dann liegt alles im Paket, was die oberste Liste heraushalten sollte. Gemessen am ausgelieferten `app.asar` der Fassung 0.27.2, derselben Fassung, die die Store-Prüfung bestanden hat: 592 Dateien, davon in `out/` (die gebauten Bundles) fünf. Dazu `coverage/` mit 217 Dateien und 8.967.480 Byte – der HTML-Abdeckungsbericht des letzten Gate-Laufs, der nur mitreist, weil er auf dem Entwicklerrechner gerade dalag; `src/` mit 200, `tests/` mit 81, `e2e/` mit 26 Dateien, dazu `tools/`, sechs tsconfig-Dateien, die Konfigurationen von ESLint, Prettier, Playwright und Vitest, `README.md` und ein liegengebliebenes `v2-frage.png`. Zwei Zeilen über der obersten Liste steht: „Nur die gebauten Bundles paketieren – die Quellen bleiben draußen.“ Geprüft wird die Bedingung selbst, nicht ihre Folge: Eine plattformeigene Liste darf nie nur aus Ausschlüssen bestehen, und sie muss jeden einschließenden Eintrag der obersten Liste mitführen – sonst fällt beim Ersetzen etwas weg. Was am Ende wirklich im Archiv liegt, prüft `e2e/gepackt.spec.ts` am gebauten Paket. */ const bauplan = readFileSync(join(echteAppWurzel, 'electron-builder.yml'), 'utf8'); const listen = plattformDateilisten(bauplan); const oberste = musterAusBauplan(bauplan).programm; it('gibt es überhaupt – sonst bewacht dieser Block nichts', () => { expect(Object.keys(listen).sort()).toEqual(['mac', 'win']); expect(oberste.length).toBeGreaterThan(0); }); for (const plattform of ['mac', 'win']) { it(`besteht bei ${plattform} nicht nur aus Ausschlüssen`, () => { const liste = listen[plattform] ?? []; const einschliessend = liste.filter((muster) => !muster.startsWith('!')); expect( einschliessend, `${plattform}.files enthält nur Ausschlussmuster – electron-builder stellt dann ` + '"**/*" voran und packt den ganzen Projektbaum ein', ).not.toEqual([]); }); it(`führt bei ${plattform} jeden einschließenden Eintrag der obersten Liste mit`, () => { /* Die plattformeigene Liste ERSETZT die oberste. Was dort fehlt, wird nicht etwa geerbt – es fehlt. */ const liste = listen[plattform] ?? []; const fehlend = oberste.filter((muster) => !liste.includes(muster)); expect(fehlend, `${plattform}.files fehlen Einträge der obersten files:-Liste`).toEqual([]); }); } }); describe('ausgelieferteQuellen gegen den echten Projektbaum', () => { it('löst jeden Pfad des echten Bauplans auf einen vorhandenen Ort auf', () => { /* Ohne diesen Fall bliebe die Herleitung Theorie. Er darf nicht werfen – ein Wurf hieße, dass Bauplan und Auflösung auseinanderliegen. Bewusst über `ausgelieferteQuellen()` und nicht über `paketstand()`: Letzteres kehrt ohne gebautes Paket sofort um und käme an der Auflösung vorbei. Der Test prüfte dann an jedem Arbeitsplatz ohne `release/` nichts und meldete trotzdem grün – dieselbe Unwahrheit, gegen die diese ganze Datei angelegt ist. */ const quellen = ausgelieferteQuellen(echteAppWurzel); // `out/**/*` zählt als `src/`: das Erzeugnis wird über seine Quelle datiert. expect(quellen.quelltext).toEqual([ resolve(echteAppWurzel, 'src'), resolve(echteAppWurzel, 'package.json'), // Der Bauplan selbst: Er bestimmt nicht nur, was ausgeliefert wird, // sondern auch wie – Ziele, Dateilisten, ASAR-Entpackung, die Angaben // des Store-Pakets. Bis 0.24.1 sah die Wache jeden Pfad an, den er // nennt, nur nicht ihn. resolve(echteAppWurzel, 'electron-builder.yml'), // Das Programmsymbol steckt in der gebauten exe, steht aber in keiner // der beiden Listen des Bauplans - es kommt aus directories.buildResources. resolve(echteAppWurzel, 'build-resources'), ]); // Der Inhalt, den `e2e/gepackt.spec.ts` im Paket misst. expect(quellen.inhalte).toEqual([ resolve(echteAppWurzel, '..', 'content', 'katalog'), resolve(echteAppWurzel, '..', 'content', 'erklaerungen.json'), resolve(echteAppWurzel, '..', 'content', 'glossar.json'), resolve(echteAppWurzel, '..', 'content', 'normtexte.json'), resolve(echteAppWurzel, '..', 'content', 'themen.json'), resolve(echteAppWurzel, '..', 'LICENSE.de.txt'), resolve(echteAppWurzel, '..', 'content', 'drittlizenzen.json'), resolve(echteAppWurzel, '..', 'content', 'datenschutz.json'), ]); }); });