/** * Rauchtest gegen die **gepackte** Anwendung. * * Im gepackten Zustand gilt `app.isPackaged === true`, und damit greift eine * andere Pfadauflösung: Katalog und Lizenztexte liegen dann unter * `resources/`, nicht im Projektbaum. Genau dieser Unterschied hat schon * einmal dazu geführt, dass der Fragenkatalog nicht gefunden wurde – ein * Fehler, den kein Test gegen den Quellbaum bemerkt hätte. * * Läuft nur, wenn zuvor `npm run dist:win` gelaufen ist. */ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test'; import { cspRichtlinie } from '../src/shared/csp'; import { paketstand } from '../tools/paketstand.mjs'; import { angewandteRichtlinien, konsoleBeimLaden, metaRichtlinie, projektWurzel, weiterAmZuschnitt, } from './electron-hilfe'; import { expect, fensterUeberwachen, konsolenwacheAnhaengen, test } from './konsolenwache'; const EXE = join(projektWurzel, 'release', 'win-unpacked', 'Waffensachkunde Lernsoftware.exe'); // `undefined`, solange der beforeAll-Hook übersprungen wurde. let app: ElectronApplication | undefined; let fenster: Page; let profil: string; test.beforeAll(async () => { /* Nicht nur, OB ein Paket dasteht, sondern ob es zum Quelltext passt. Ein Paket von gestern gegen den Quelltext von heute ist schlimmer als gar keines: Der Lauf ist grün und misst die vorige Fassung. Genau das ist hier zweimal passiert – der Wortlaut „0 von 575 Fragen sicher“ nach dem Umbau der Reifekennzahl und der mehrdeutige Locator nach der neuen Katalogzeile. Beide Male meldete das Gate grün, beide Male fiel es erst beim nächsten Paketbau auf. `bauPruefen()` führt dieses Argument für `out/` seit jeher; auf das Paket wurde es nie angewandt. Übersprungen statt abgebrochen: `npm run gate` baut das Paket nicht mit, und ein harter Abbruch zwänge zu einem mehrminütigen `dist:win` vor jedem Lauf. Dass dabei etwas ungeprüft bleibt, sagt der Gate-Bericht am Ende – aus derselben Quelle, damit Bericht und Lauf nicht auseinanderlaufen. */ const paket = paketstand(projektWurzel); test.skip(!paket.vorhanden || paket.veraltet, paket.grund); profil = mkdtempSync(join(tmpdir(), 'wsk-gepackt-')); app = await electron.launch({ executablePath: EXE, args: [`--user-data-dir=${profil}`] }); /* Diese Datei startet selbst und geht nicht durch `appStarten` – die Konsolenwache muss deshalb hier von Hand angehängt werden. Gerade hier lohnt sie: Nur im Paket gelten die ausgelieferten Pfade und die strenge Richtlinie, ein Fehler daraus meldet sich sonst erst beim Anwender. */ konsolenwacheAnhaengen(app); fenster = await app.firstWindow(); fensterUeberwachen(fenster); await fenster.waitForLoadState('domcontentloaded'); await fenster.waitForSelector('h1', { state: 'visible' }); /* Diese Datei startet das Paket selbst und geht nicht durch `appStarten` – die Erststart-Frage nach dem abwählbaren Kapitel steht deshalb auch hier. Beantwortet wird sie wie überall mit „mitlernen“. */ await weiterAmZuschnitt(fenster); }); test.afterAll(async () => { await app?.close(); try { rmSync(profil, { recursive: true, force: true }); } catch { /* Aufräumen ist Kür. */ } }); test('startet und zeigt den Startbildschirm', async () => { await expect(fenster).toHaveTitle('Waffensachkunde – Lernsoftware'); await expect(fenster.getByRole('heading', { level: 1 })).toHaveText( 'Waffensachkunde – Lernsoftware', ); }); test('findet den Fragenkatalog unter resources/', async () => { /* Der schärfste Test des gepackten Zustands: Steht die Gesamtzahl da, wurde katalog.json tatsächlich gefunden und geladen. Aus `aria-valuemax` statt aus dem Text. Der Satz der Ampel wechselt seine Form je nach Stand – bei leerem Stand nennt er die Belegregel statt einer Zahl –, die Obergrenze des Balkens ist dagegen immer der Umfang des Katalogs. Die vorige Fassung hing am Wortlaut „0 von 575 Fragen sicher“ und wurde beim Umbau der Kennzahl übersehen: Diese Datei läuft nur, wenn `release/win-unpacked/` existiert, und war deshalb bei jedem Lauf ohne gebautes Paket schlicht übersprungen. */ await expect( fenster.getByRole('progressbar', { name: 'Prüfungsreife insgesamt' }), ).toHaveAttribute('aria-valuemax', '575', { timeout: 15_000 }); }); test('findet die Erklaerungen unter resources/', async () => { /* Die Erklaerungen liegen als eigene Datei neben dem Katalog. Ihr Pfad wird im gepackten Zustand anders aufgeloest als im Quellbaum – genau diese Unterscheidung hatte beim Katalog schon einmal versagt. */ const anzahl = await fenster.evaluate(async () => { const api = (globalThis as Record)['lernApp'] as { erklaerungenLaden?: () => Promise<{ zuFrage: Record }> } | undefined; if (!api?.erklaerungenLaden) { return -1; } const daten = await api.erklaerungenLaden(); return Object.keys(daten.zuFrage).length; }); /* Nicht nur „mehr als null": Der Lader faellt bei jedem Formfehler still auf einen leeren Bestand zurueck, und genau das ist einmal passiert – 450 Angaben standen als Zahl statt als Zeichenkette in der Datei, das Paket haette ohne eine einzige Erklaerung ausgeliefert. Geprueft wird deshalb gegen die Zahl der Fragen im Katalog. */ expect(anzahl).toBe(575); }); test('findet das Glossar unter resources/', async () => { /* Dieselbe Falle wie bei den Erklaerungen: Der Lader faellt bei jedem Formfehler still auf einen leeren Bestand zurueck. Ein Glossar, das ausgeliefert wird und leer ankommt, faellt sonst niemandem auf. */ const anzahl = await fenster.evaluate(async () => { const api = (globalThis as Record)['lernApp'] as { glossarLaden?: () => Promise<{ eintraege: unknown[] }> } | undefined; if (!api?.glossarLaden) { return -1; } const daten = await api.glossarLaden(); return daten.eintraege.length; }); // Der Bestand waechst; geprueft wird, dass er ueberhaupt ankommt. expect(anzahl).toBeGreaterThan(50); }); test('findet die Normtexte unter resources/', async () => { /* Seit 0.23.0 liegt der Wortlaut der zitierten Normen bei. Dieselbe Falle wie bei Katalog, Erklaerungen und Glossar: Der Lader faellt bei jedem Formfehler still auf einen leeren Bestand zurueck – und die Fundstellen saehen dann wieder aus wie vor 0.23.0, naemlich wie reine Zitate ohne Sprungziel. Das faellt in einem gepackten Paket niemandem auf, weil die Anwendung weiterlaeuft. */ const gesetze = await fenster.evaluate(async () => { const api = (globalThis as Record)['lernApp'] as { normtexteLaden?: () => Promise<{ gesetze: Record }> } | undefined; if (!api?.normtexteLaden) { return -1; } const daten = await api.normtexteLaden(); return Object.keys(daten.gesetze).length; }); // Sieben Gesetze werden zitiert; weniger hiesse, dass etwas fehlt. expect(gesetze).toBe(7); }); test('findet die Themengliederung unter resources/', async () => { /* Ohne sie stuende in den Kapiteln II bis IV nur der Knopf fuer das ganze Kapitel – also der Zustand vor 0.23.0. Auch das laeuft still weiter. */ const fragen = await fenster.evaluate(async () => { const api = (globalThis as Record)['lernApp'] as { themenLaden?: () => Promise<{ gruppen: { fragen: string[] }[] }> } | undefined; if (!api?.themenLaden) { return -1; } const daten = await api.themenLaden(); return daten.gruppen.reduce((summe, gruppe) => summe + gruppe.fragen.length, 0); }); /* Gegen die Zahl der Fragen ohne amtlichen Abschnitt und nicht gegen „mehr als null": Die Gliederung ist nur brauchbar, wenn sie vollstaendig ist. */ expect(fragen).toBe(230); }); test('meldet eine einsatzbereite Datenbank', async () => { await expect(fenster.getByText(/SQLite \d+\.\d+/u)).toBeVisible(); }); test('wendet im Paket beide Richtlinien an – die strenge auch im HTTP-Kopf', async () => { /* Erst hier ist die Frage wirklich beantwortet. `app.isPackaged` ist nur im Paket wahr, und erst dann liefert der Hauptprozess die Produktionsfassung in den Kopf; der Lauf gegen `out/` misst dort die Dev-Fassung. Und erst hier steht fest, dass der Kopf-Weg beim Laden über `file://` aus dem Paket heraus überhaupt ankommt – genau das hatte `src/shared/csp.ts` jahrelang anders behauptet. Geprüft wird deshalb der Wortlaut BEIDER tatsächlich angewandter Richtlinien gegen die eine Quelle der Wahrheit. */ const meta = await metaRichtlinie(fenster); expect(meta).toBe(cspRichtlinie('production', 'meta')); const angewandt = await angewandteRichtlinien(fenster); expect(angewandt.slice().sort()).toEqual( [cspRichtlinie('production', 'meta'), cspRichtlinie('production', 'kopf')].sort(), ); /* Der Punkt der ganzen Übung: Die Direktive kommt über den Kopf an, wo sie gilt – und nicht mehr über das ``-Element, wo Chromium sie verwirft. */ const ausDemKopf = angewandt.filter((r) => r !== meta); expect(ausDemKopf[0]).toContain("frame-ancestors 'none'"); expect(meta).not.toContain('frame-ancestors'); }); test('startet ohne Konsolenfehler über die Richtlinie', async () => { /* Der ignorierte `frame-ancestors`-Eintrag im `` schrieb bei jedem Start einen Konsolenfehler. Im ausgelieferten Programm sieht den niemand – grund genug, ihn hier festzuhalten, statt ihn im Paket mitzuliefern. */ const meldungen = await konsoleBeimLaden(fenster); const zurRichtlinie = meldungen.filter((m) => /content security policy/iu.test(m)); expect( zurRichtlinie, `Konsolenausgabe beim Laden:\n${meldungen.join('\n') || '(keine)'}`, ).toEqual([]); }); test('zeigt die Anwendungsversion, nicht die von Electron', async () => { /* Die Nummer kommt aus der package.json, nicht aus dem Test: Eine fest eingetragene Version bräche diesen Test bei jedem Versionswechsel – und zwar mit einer Meldung, die den eigentlichen Punkt verfehlt. */ const paket = JSON.parse(readFileSync(join(projektWurzel, 'package.json'), 'utf8')) as { version: string; }; /* Gezielt der Wert NEBEN „Programmversion“: Die Liste nennt die Electron-Version an anderer Stelle völlig zu Recht, ein Blick auf die ganze Liste ginge also am Punkt vorbei. */ const wert = fenster.locator('.statusliste dt:has-text("Programmversion") + dd'); await expect(wert).toHaveText(paket.version); }); test('nennt den Baustand mit Commit-Kürzel', async () => { /* Die Versionsnummer allein benennt keinen Stand: Zwischen zwei Veröffentlichungen entstehen viele Bauten mit derselben Nummer. */ const liste = fenster.locator('.statusliste'); await expect(liste).toContainText('Baustand'); /* Eigene Klasse statt "das code-Element in der Liste": Seit dort auch der Quellort des Fragenkatalogs steht, gibt es zwei. Die Mehrdeutigkeit fiel erst beim naechsten Bau auf, weil diese Datei nur gegen ein gebautes Paket laeuft - genau der Fall, vor dem docs/veroeffentlichen.md warnt. */ await expect(fenster.locator('.statusliste__baukennung')).toHaveText(/^[0-9a-f]{7,}\+?$/u); }); test('lädt die Lizenztexte aus resources/', async () => { await fenster .getByRole('button', { name: /Lizenzen und Herkunft anzeigen/i }) .first() .click(); await expect(fenster.getByRole('heading', { name: /Über diese Software/i })).toBeVisible(); // EUPL-Volltext und Fremdkomponenten kommen aus extraResources. await expect(fenster.getByText(/European Union Public Licence/i).first()).toBeVisible(); await expect(fenster.getByText(/better-sqlite3/i).first()).toBeVisible(); }); /* Die Datenschutzerklärung war bis Fassung 0.24.2 in der Anwendung nirgends erreichbar: `docs/` kommt nicht ins Paket, und der einzige Weg nach außen führt zur Unterstützungsseite. Seit 0.25.0 liegt `content/datenschutz.json` über `extraResources` unter `resources/` — und ausschließlich diese Prüfung belegt, dass sie dort auch ankommt. Ein Lader, der die Datei nicht findet, zeigt eine Begründung statt der Erklärung; im Quellbaum fiele das nie auf, weil er sie dort aufwärts sucht und immer findet. */ test('lädt die Datenschutzerklärung aus resources/', async () => { /* Kein Klick auf „Lizenzen und Herkunft anzeigen“: Die Prüfungen dieser Datei teilen sich eine laufende Anwendung, und die vorige lässt „Über diese Software“ offen. Der Knopf steht dort nicht mehr. */ await expect(fenster.getByRole('heading', { name: 'Datenschutz', level: 2 })).toBeVisible(); const schalter = fenster.getByRole('button', { name: /Datenschutzerklärung lesen/i }); await expect(schalter).toBeVisible(); await schalter.click(); /* Ein Satz aus Abschnitt 3, der die Aussage der Erklärung trägt – und zugleich die Stelle, an der die Oberfläche bis 0.24.2 das Gegenteil behauptete („sie sammelt keine Daten“). */ await expect(fenster.getByText(/entstehen Daten/i).first()).toBeVisible(); }); test('startet eine Lernsitzung mit echten Katalogfragen', async () => { await fenster /* „Zum Start“ steht seit der Zurueckleiste zweimal in dieser Ansicht: oben zur Orientierung, unten hinter dem langen Inhalt. Beide fuehren an dieselbe Stelle. */ .getByRole('button', { name: /Zum Start/i }) .first() .click(); await fenster .getByRole('button', { name: /Weiterlernen/i }) .first() .click(); // Eine amtliche Fragennummer belegt, dass echte Daten geladen wurden. await expect(fenster.getByRole('heading', { name: /Frage \d/u })).toBeVisible({ timeout: 15_000, }); });