import { type ElectronApplication, type Page } from '@playwright/test'; import { cspRichtlinie } from '../src/shared/csp'; import { angewandteRichtlinien, appStarten, bauPruefen, konsoleBeimLaden, metaRichtlinie, } from './electron-hilfe'; import { expect, test } from './konsolenwache'; // `undefined`, solange der beforeAll-Hook übersprungen wurde. let app: ElectronApplication | undefined; let fenster: Page; test.beforeAll(async () => { // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. bauPruefen(); ({ app, fenster } = await appStarten()); }); test.afterAll(async () => { await app?.close(); }); test.describe('Startbildschirm', () => { test('zeigt Titel und Überschriftenhierarchie auf Deutsch', async () => { await expect(fenster).toHaveTitle('Waffensachkunde – Lernsoftware'); await expect(fenster.getByRole('heading', { level: 1 })).toHaveText( 'Waffensachkunde – Lernsoftware', ); // Genau eine h1, darunter h2-Abschnitte, darunter h3 – keine // übersprungenen Ebenen. Die Anzahl der Abschnitte wächst mit dem // Funktionsumfang; geprüft wird deshalb die Hierarchie, nicht die Menge. await expect(fenster.getByRole('heading', { level: 1 })).toHaveCount(1); expect(await fenster.getByRole('heading', { level: 2 }).count()).toBeGreaterThan(0); const ebenen = await fenster.evaluate(() => [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((h) => Number(h.tagName.substring(1)), ), ); expect(ebenen.length).toBeGreaterThan(0); expect(ebenen[0]).toBe(1); // Eine Überschrift darf höchstens eine Ebene tiefer sein als die vorige. let vorige = 1; for (const ebene of ebenen) { expect(ebene).toBeLessThanOrEqual(vorige + 1); vorige = ebene; } }); test('setzt lang="de" auf dem Wurzelelement', async () => { await expect(fenster.locator('html')).toHaveAttribute('lang', 'de'); }); test('meldet einen einsatzbereiten SQLite-Rauchtest', async () => { const abzeichen = fenster.locator('.statusabzeichen--ok'); await expect(abzeichen).toBeVisible(); await expect(fenster.getByText(/SQLite \d+\.\d+/u)).toBeVisible(); }); }); test.describe('Sicherheitsgrundlagen', () => { /* Anmerkung: Electron bietet ab Version 43 keine öffentliche API mehr, um die angewandten `webPreferences` auszulesen. Deshalb wird hier das beobachtbare Verhalten geprüft (das ist ohnehin die härtere Zusage); die Konfigurationswerte selbst sichert `tests/sicherheit.test.ts` ab. */ test('stellt dem Renderer keine Node-Globals bereit', async () => { const globals = await fenster.evaluate(() => ['require', 'process', 'module', 'exports', 'Buffer', 'global'].map( (name) => `${name}=${typeof (globalThis as Record)[name]}`, ), ); expect(globals).toEqual([ 'require=undefined', 'process=undefined', 'module=undefined', 'exports=undefined', 'Buffer=undefined', 'global=undefined', ]); }); test('isoliert den Kontext: kein Zugriff auf Electron-Interna', async () => { const interna = await fenster.evaluate(() => ['electron', 'ipcRenderer', 'contextBridge', '__electronApi'].map( (name) => typeof (globalThis as Record)[name], ), ); expect(interna).toEqual(['undefined', 'undefined', 'undefined', 'undefined']); }); test('legt genau die vereinbarte Bridge frei – und nichts weiter', async () => { const bruecke = await fenster.evaluate(() => { const api = (globalThis as Record)['lernApp']; if (typeof api !== 'object' || api === null) { return null; } return Object.keys(api).sort(); }); // Bewusst als vollständige Liste gepflegt: Der Test soll auffallen, sobald // die Bridge etwas freilegt, das hier nicht bewusst eingetragen wurde. expect(bruecke).toEqual([ 'anwendungsInfoLesen', 'anzeigegroesseBeobachten', 'anzeigegroesseSetzen', 'dateiZeigen', 'datenschutzLesen', 'einstellungenLesen', 'einstellungenSchreiben', 'energiesparenBeobachten', 'erklaerungenLaden', 'fehlerprotokollDrucken', 'fragenlisteDrucken', 'glossarLaden', 'hilfsmittelAktiv', 'hilfsmittelBeobachten', 'katalogBild', 'katalogLaden', 'lernAntworten', 'lernFragestand', 'lernHartnaeckige', 'lernMerken', 'lernPlan', 'lernSitzung', 'lernUebersicht', 'lernVerlauf', 'lernZuruecksetzen', 'lernberichtDrucken', 'lizenzenLesen', 'menuebefehlBeobachten', 'normtexteLaden', 'profilAktualisieren', 'profilAnlegen', 'profilListe', 'profilLoeschen', 'pruefungAuswerten', 'pruefungLaeuft', 'pruefungOffen', 'pruefungSichern', 'pruefungStarten', 'pruefungVerlauf', 'pruefungVerwerfen', 'sicherungAnlegen', 'sicherungEinspielen', 'sicherungPruefen', 'sicherungUebernehmen', 'sicherungsordnerZeigen', 'themenLaden', 'unterstuetzungOeffnen', 'zwischenablageSchreiben', ]); }); test('erzwingt eine strenge Content-Security-Policy', async () => { const richtlinie = await metaRichtlinie(fenster); // Der Bau setzt die ``-Fassung ein; abweichen darf davon nichts. expect(richtlinie).toBe(cspRichtlinie('production', 'meta')); expect(richtlinie).toContain("default-src 'none'"); expect(richtlinie).toContain("object-src 'none'"); // Im Produktionsbuild darf es kein 'unsafe-inline' und kein 'unsafe-eval' geben. expect(richtlinie).not.toContain('unsafe-inline'); expect(richtlinie).not.toContain('unsafe-eval'); }); test('lässt frame-ancestors über den HTTP-Kopf gelten, nicht über das -Element', async () => { /* Der Kern dieser Prüfung – und der Grund, warum sie nicht mehr am Wortlaut des Attributs hängt. Vorher stand hier `expect(richtlinie).toContain("frame-ancestors 'none'")`. Das war grün, während die Direktive gar nicht galt: Chromium verwirft `frame-ancestors` im ``-Element und schrieb bei jedem Start einen Konsolenfehler darüber. Gemessen wird jetzt, welche Richtlinien der Renderer tatsächlich anwendet. */ const meta = await metaRichtlinie(fenster); expect(meta).not.toContain('frame-ancestors'); const angewandt = await angewandteRichtlinien(fenster); // Zwei Wege, zwei Richtlinien: das ``-Element und der HTTP-Kopf. expect(angewandt).toContain(meta); const ausDemKopf = angewandt.filter((r) => r !== meta); expect(ausDemKopf, `Angewandte Richtlinien:\n${angewandt.join('\n')}`).toHaveLength(1); /* Nicht gegen einen festen Wortlaut geprüft: Dieser Lauf startet den Bau aus `out/` unmittelbar, also ist `app.isPackaged` falsch und der Hauptprozess liefert die Dev-Fassung in den Kopf (nachgemessen). Für die Frage, um die es hier geht, ist das gleichgültig – entscheidend ist, dass die Direktive über einen Weg ankommt, auf dem sie gilt. */ expect(ausDemKopf[0]).toContain("frame-ancestors 'none'"); /* Und die Schnittmenge bleibt streng: Chromium wertet beide Richtlinien einzeln aus, erlaubt ist nur, was jede erlaubt. Dass die Kopf-Fassung im ungepackten Lauf `'unsafe-inline'` enthält, lockert deshalb nichts – die ``-Fassung verbietet es weiterhin (oben geprüft). */ for (const einzelne of angewandt) { expect(einzelne).toContain("default-src 'none'"); } }); test('schreibt beim Laden keinen Konsolenfehler über die Richtlinie', async () => { /* Die Gegenprobe zur Direktive, die nicht gilt: Chromium meldet jede ignorierte ``-Direktive als Konsolenfehler, bei jedem einzelnen Start. Der Fehler ist hier die Zusage, nicht nur ein Schönheitsfehler – eine Wache, die auf Konsolenfehler anspringt, hätte sonst dauerhaft eine Ausnahme für diese eine Meldung tragen müssen. Gemessen wird beim Neuladen – warum, steht bei `konsoleBeimLaden`. */ 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.describe('Tastaturbedienung', () => { test('erreicht den Skip-Link als ersten Tabstopp und springt zum Inhalt', async () => { await fenster.locator('body').press('Tab'); const fokussiert = fenster.locator(':focus'); await expect(fokussiert).toHaveText('Direkt zum Hauptinhalt springen'); await fokussiert.press('Enter'); await expect(fenster.locator('#inhalt')).toBeFocused(); }); test('schaltet das Theme allein per Tastatur um', async () => { const gruppe = fenster.getByRole('radiogroup', { name: 'Farbschema' }); await gruppe.getByRole('radio', { name: 'Systemvorgabe', exact: true }).focus(); await fenster.keyboard.press('End'); await expect(gruppe.getByRole('radio', { name: 'Hoher Kontrast', exact: true })).toBeChecked(); await expect(fenster.locator('html')).toHaveAttribute('data-thema', 'hochkontrast'); }); test('sagt die Umschaltung über aria-live an', async () => { /* Gezielt die Ansage der Anwendung, nicht irgendeine Live-Region: Der Startbildschirm hat inzwischen eine zweite für den Lernbericht. Zwei höfliche Regionen nebeneinander sind zulässig – jede meldet nur, was in ihr steht –, aber ein Test muss sagen, welche er meint. */ const meldung = fenster.locator('.nur-screenreader[role="status"][aria-live="polite"]'); await expect(meldung).toBeAttached(); // Der Auswahlglyph ist `aria-hidden`, der zugängliche Name ist deshalb // exakt die Beschriftung. await fenster.getByRole('radio', { name: 'Dunkel', exact: true }).click(); await expect(meldung).toHaveText('Darstellung umgestellt auf Dunkel.'); }); });