/** * Die Konsolenwache: hört mit, was die laufende Anwendung meldet. * * ## Warum es diese Datei gibt * * Im Kopf von `e2e/electron-hilfe.ts` stand seit jeher der Satz * „`ELECTRON_DISABLE_SECURITY_WARNINGS` bleibt bewusst AUS: erscheinen * Sicherheitswarnungen in der Konsole, ist das ein echter Befund." Die Absicht * war formuliert – die Vorrichtung dazu fehlte. In keiner der Suiten stand ein * `page.on('console')`, niemand hörte also zu. Was das kostet, hat der * `frame-ancestors`-Fall gezeigt: Chromium schrieb bei **jedem** Programmstart * einen Konsolenfehler über eine verworfene CSP-Direktive, und der Testlauf * meldete währenddessen grün. Gefunden wurde der Fehler erst, als ein Mensch * zufällig hinsah. * * ## Was gesammelt wird * * - Konsolenfehler und -warnungen des **Renderers** (`page.on('console')`), * einschließlich der Meldungen, die Chromium selbst schreibt – ignorierte * CSP-Direktiven, blockierte Ressourcen, Electrons Sicherheitswarnungen. * - Ausnahmen im Renderer (`page.on('pageerror')`) – und darüber **auch * unbehandelte Zusagen**. Das war die eine Stelle, an der nicht klar war, ob * es einen zweiten Zuhörer braucht; nachgemessen mit einer Wegwerf-Probe: * Ein `throw` im Renderer und ein `Promise.reject(…)` ohne `catch` liefen * beide über `pageerror` auf, keines über `console`. Ein eigener * `unhandledrejection`-Zuhörer im Dokument ist deshalb nicht nötig – er * hätte die Anwendung dafür verändern müssen. * - Konsolenfehler und -warnungen des **Hauptprozesses** * (`electronApplication.on('console')`). Der Hauptprozess ist der einzige * Ort, an dem Datenbank-, Datei- und Sicherheitsfehler auffallen; * `src/main/sicherheit.ts` schreibt dort etwa jede blockierte Navigation hin. * - Weitere Fenster: `app.on('window')` hängt die Wache an jedes Fenster, das * die Anwendung später öffnet. Nachgemessen an `e2e/druck.spec.ts`: Auch das * **verborgene** Druckfenster aus `src/main/druck.ts` meldet sich hier * (`…/wsk-druck-…/dokument.html`) – gerade dort sieht sonst niemand hin, das * Fenster hat ja keine Anzeige. * * Gesammelt werden nur `error`, `warning` und `assert`. `log`, `info` und * `debug` sind Mitteilungen, keine Befunde; die Anwendung schreibt dort im * Regelbetrieb ihren Datenbankstand hin (`console.info` in `src/main/index.ts`). * * ## Was durchgelassen wird * * Nichts – außer, ein Test meldet ausdrücklich an, dass er eine Meldung * selbst auslöst: {@link konsoleErwartet}. Warum die Ausnahmen dort stehen und * nicht in einer globalen Liste, steht bei dieser Funktion, samt dem Ergebnis * des ersten Messlaufs. * * ## Warum ein Fixture und kein `test.afterEach` in dieser Datei * * Naheliegend wäre, hier schlicht `test.afterEach(...)` aufzurufen – die * Suiten importieren diese Datei ohnehin. Das wäre eine Falle: Playwright * ordnet einen Hook der Datei zu, die gerade geladen wird, und Node führt ein * Modul nur **einmal** aus. Der Hook hinge damit allein an der ersten Suite, * die diese Datei importiert; alle übrigen liefen ungeprüft – und niemand * sähe es. Deshalb der ausdrückliche Weg über ein automatisches Fixture: Jede * Suite, die `test` von hier bezieht, bekommt die Prüfung, ohne sie * anzumelden. Dass eine Suite `test` versehentlich wieder direkt von * `@playwright/test` bezieht, verhindert eine ESLint-Regel für die * Prüfdateien unter `e2e/` (siehe `eslint.config.mjs`). */ import { basename } from 'node:path'; import { test as basisTest, type ConsoleMessage, type ElectronApplication, type Page, } from '@playwright/test'; export { expect } from '@playwright/test'; /** Eine aufgelaufene Meldung samt der Stelle, an der sie auflief. */ interface Befund { /** Menschenlesbare Art: „Konsolenfehler", „Ausnahme im Renderer", … */ readonly art: string; /** Renderer oder Hauptprozess – die Suche beginnt an verschiedenen Orten. */ readonly herkunft: string; /** Der Wortlaut der Meldung. */ readonly text: string; /** Datei, Zeile und Spalte, soweit die Meldung sie mitbringt. */ readonly ort: string; /** Welcher Test lief, als die Meldung kam – die „Ansicht" des Befunds. */ readonly abschnitt: string; } /** Eine angemeldete Ausnahme: Diese Meldung ist in diesem Test gewollt. */ interface Erwartung { readonly muster: RegExp; readonly grund: string; } /** Was aufgelaufen ist. Lebt im Arbeitsprozess, nicht in einer einzelnen Suite. */ const aufgelaufen: Befund[] = []; /** * Gilt vom Aufruf bis zum Ende des laufenden Tests; das Fixture räumt danach. * Eine Anmeldung in `beforeAll`/`beforeEach` gilt also für den Test mit, der * darauf folgt. */ let erwartungen: Erwartung[] = []; /** * Meldet eine Meldung an, die **dieser Test absichtlich auslöst**. * * ## Warum das die Ausnahmeliste ist – und keine globale * * Gemessen im ersten vollen Lauf – 178 Prüfungen, ohne jede Ausnahme. Die * Wache fing genau zwei Sorten Meldungen auf, und **beide löst die Prüfung * selbst aus**; keine einzige stammte aus dem Regelbetrieb der Anwendung. * * 1. „Loading the image 'https://gibt-es-nicht.invalid/pixel.png' violates … * img-src" – der absichtliche CSP-Verstoß, mit dem * `angewandteRichtlinien()` die geltenden Richtlinien sichtbar macht * (`e2e/electron-hilfe.ts`). Je Test zweimal, weil zwei Richtlinien gelten; * aufgelaufen in `anwendung.spec.ts` und in `gepackt.spec.ts`. * 2. „[sicherheit] Navigation blockiert: file:///C:/Windows/win.ini" aus dem * Hauptprozess – der Beweis, dass die Sperre in * `e2e/navigationssperre.spec.ts` greift. * * Eine **globale** Liste wäre hier der falsche Ort gewesen, und zwar aus einem * Grund, der die ganze Wache betrifft: Ein globales Muster auf „violates the * following Content Security Policy directive" hätte die Wache genau für die * Fehlerklasse blind gemacht, für die es sie gibt. Dasselbe gilt für * „[sicherheit] …": Blockierte Navigationen sind anderswo ein Alarm, nicht * Rauschen. Eine Ausnahme gehört deshalb dorthin, wo die Meldung erzeugt * wird, und sie endet mit dem Test, der sie erzeugt. * * Aus demselben Grund verlangt diese Funktion `grund` als Pflichtargument: * Steht der Satz nicht da, gibt es die Ausnahme nicht. Bei einem Befund nennt * der Bericht alle angemeldeten Ausnahmen samt Begründung – wer ihn liest, * sieht, wofür die Wache in diesem Test taub war. * * Für Rauschen, das **jeder** Test hätte und das die Anwendung nachweislich * nicht verursacht, gibt es hier bewusst nichts: Der Messlauf hat kein * einziges solches Beispiel geliefert. Wer eines findet, entscheidet dann mit * dem Fall in der Hand, ob eine globale Liste nötig ist – statt sie auf * Vorrat anzulegen, wo sie nur einlädt, Befunde hineinzuschieben. */ export function konsoleErwartet(muster: RegExp, grund: string): void { if (erwartungen.some((e) => e.muster.source === muster.source)) { return; } erwartungen.push({ muster, grund }); } /** Der Wortlaut, mit dem Meldungen aus der Startphase beschriftet werden. */ const STARTPHASE = 'Start der Anwendung (beforeAll), noch vor dem ersten Test der Datei'; /** * Die Stelle, der eine ankommende Meldung zugeschrieben wird. * * Meldungen kommen asynchron; welcher Test gerade lief, weiß nur der Lauf * selbst. Der Marker wird an drei Stellen gestellt: vom Fixture vor jedem * Test, vom Fixture nach jedem Test (Aufräumphase) und beim Start einer * Anwendung. Der dritte Punkt ist kein Beiwerk – ohne ihn trüge ausgerechnet * die Startphase, in der der CSP-Fehler auflief, noch die Beschriftung des * letzten Tests der **vorigen** Datei. */ let abschnitt = STARTPHASE; /** Fenster, an denen die Wache schon hängt – gegen doppelte Anmeldung. */ const beobachtet = new WeakSet(); /** Lange Meldungen werden gekürzt; der Bericht soll lesbar bleiben. */ function gekuerzt(text: string, grenze = 600): string { const eine = text.replace(/\s+/gu, ' ').trim(); return eine.length > grenze ? `${eine.slice(0, grenze)} … (gekürzt)` : eine; } /* Aufgefangen wird zunächst alles; aussortiert wird erst beim Abräumen. Der Reihenfolge wegen: Eine Konsolenmeldung kommt über die Leitung, während der Test schon weiterläuft. Wer beim Eintreffen filtern wollte, müsste verlangen, dass `konsoleErwartet` vor der Meldung angemeldet ist – und hätte eine Wache, die je nach Zeitverlauf mal rot und mal grün ist. Am Testende steht dagegen beides fest. */ function vermerken(befund: Befund): void { aufgelaufen.push(befund); } /** Deutsche Benennung der Playwright-Meldungsarten. */ function artName(typ: string): string | undefined { if (typ === 'error') { return 'Konsolenfehler'; } if (typ === 'warning') { return 'Konsolenwarnung'; } if (typ === 'assert') { return 'fehlgeschlagene console.assert'; } return undefined; } function konsolenmeldung(meldung: ConsoleMessage, herkunft: string): void { const art = artName(meldung.type()); if (art === undefined) { return; } const stelle = meldung.location(); const ort = stelle.url === '' ? '(ohne Ortsangabe)' : `${stelle.url}:${String(stelle.lineNumber)}:${String(stelle.columnNumber)}`; vermerken({ art, herkunft, text: gekuerzt(meldung.text()), ort, abschnitt }); } /** * Hängt die Wache an ein Fenster. * * Getrennt von {@link konsolenwacheAnhaengen}, weil `app.on('window')` und * `app.firstWindow()` dasselbe Fenster liefern können – die Wache darf dann * nicht doppelt zählen. */ export function fensterUeberwachen(seite: Page): void { if (beobachtet.has(seite)) { return; } beobachtet.add(seite); seite.on('console', (meldung) => { konsolenmeldung(meldung, 'Renderer'); }); seite.on('pageerror', (fehler) => { /* Die erste Zeile des Stapels sagt, wo es geknallt hat – ohne sie ist eine Ausnahme im gebündelten Renderer-Code kaum zu finden. */ const stapel = (fehler.stack ?? '').split('\n').slice(1, 2).join('').trim(); vermerken({ art: 'Ausnahme im Renderer', herkunft: 'Renderer', text: gekuerzt(`${fehler.name}: ${fehler.message}`), ort: stapel === '' ? '(ohne Stapelangabe)' : stapel, abschnitt, }); }); } /** * Hängt die Wache an eine gestartete Anwendung. * * Wird von `appStartenOhneZuschnitt` aufgerufen und deckt damit alle Suiten * ab. `e2e/gepackt.spec.ts` startet das Paket selbst und ruft beides einzeln * auf – dieselbe Wache, anderer Startweg. */ export function konsolenwacheAnhaengen(app: ElectronApplication): void { /* Jede Suite startet ihre Anwendung im `beforeAll`, also zwischen zwei Tests. Ohne diese Zeile bekämen die Meldungen des Starts die Beschriftung des letzten Tests der vorigen Datei – und die Suche begänne am falschen Ende. Welche Datei es ist, sagt ohnehin der fallende Test. */ abschnitt = STARTPHASE; app.on('console', (meldung) => { konsolenmeldung(meldung, 'Hauptprozess'); }); app.on('window', (seite) => { fensterUeberwachen(seite); }); } /** * Nimmt die Befunde heraus, für die niemand geradesteht – und leert den Speicher. * * Geleert wird **vollständig**, einschließlich der angemeldeten Meldungen: * Sonst schleppte der nächste Test sie mit und bekäme einen Befund für etwas, * das der vorige ausgelöst hat. */ function entnehmen(): Befund[] { const alle = aufgelaufen.splice(0, aufgelaufen.length); return alle.filter((b) => !erwartungen.some((e) => e.muster.test(b.text))); } /** * Der Bericht. * * Sagt, **was** auflief und **wo** – eine Wache, die nur „es gab Meldungen" * meldet, verschiebt die Arbeit bloß auf den nächsten Lauf. Und sie sagt, * wofür sie in diesem Test taub war: Wer einen Befund vor sich hat, soll * nicht raten müssen, ob nebenan noch etwas durchgelassen wurde. */ function bericht(befunde: readonly Befund[]): string { const zeilen = befunde.map( (b, i) => `${String(i + 1)}. ${b.art} (${b.herkunft}) in „${b.abschnitt}"\n` + ` Meldung: ${b.text}\n` + ` Ort: ${b.ort}`, ); const angemeldet = erwartungen.length === 0 ? '' : '\n\nAbsichtlich durchgelassen wurde außerdem:\n' + erwartungen.map((e) => ` ${String(e.muster)} – ${e.grund}`).join('\n'); return ( `Die Konsolenwache hat ${String(befunde.length)} Meldung(en) aufgefangen. ` + 'Die laufende Anwendung darf beim Testlauf weder Fehler noch Warnungen schreiben.\n\n' + zeilen.join('\n\n') + angemeldet + '\n\nEntweder die Ursache beheben – oder, wenn die Prüfung die Meldung absichtlich ' + 'auslöst, sie mit `konsoleErwartet(muster, grund)` aus e2e/konsolenwache.ts anmelden.' ); } /** * `test` mit angehängter Konsolenwache – die Einstiegsstelle für alle Suiten. * * Zwei automatische Fixtures, weil Meldungen in zwei Phasen kommen: * * - `konsolenwache` (je Test) schreibt vor dem Test den Marker und prüft nach * ihm. Was während `beforeAll` auflief – Anwendungsstart, erster * Ladevorgang, genau die Phase des CSP-Fehlers –, meldet damit der erste * Test der Datei. * - `konsolenwacheAbschluss` (je Arbeitsprozess) fegt am Ende nach. Ohne das * fiele unter den Tisch, was während `afterAll` auflief, also beim * Schließen der Anwendung. * * Beide Fixtures liefern `undefined` und nicht das sonst übliche `void`: * Sie stellen keinen Wert bereit, sondern hängen nur eine Prüfung an – und * `void` als Typargument verbietet `@typescript-eslint/no-invalid-void-type` * zu Recht. */ export const test = basisTest.extend< { konsolenwache: undefined }, { konsolenwacheAbschluss: undefined } >({ konsolenwache: [ // eslint-disable-next-line no-empty-pattern -- Playwright liest die Abhängigkeiten eines Fixtures aus dem destrukturierten ersten Argument und bricht ohne es ab; diese Wache braucht kein anderes Fixture. async ({}, benutzen, info) => { const teile = info.titlePath.filter((t) => t.length > 0); const datei = basename(info.file); if (!teile.some((t) => t.includes(datei))) { teile.unshift(datei); } abschnitt = teile.join(' › '); await benutzen(undefined); /* Erst den Marker weiterstellen, dann prüfen: Was nach diesem Punkt noch kommt, gehört zur Aufräumphase und nicht mehr zum Test. */ const gerade = abschnitt; abschnitt = `Aufräumphase nach „${gerade}"`; try { const befunde = entnehmen(); if (befunde.length > 0) { throw new Error(bericht(befunde)); } } finally { /* Die Anmeldungen werden am Ende geräumt, nicht am Anfang. Am Anfang zu räumen wäre die naheliegendere Zeile und eine Falle: Ein `konsoleErwartet` in einem `beforeAll` wäre gelöscht, bevor der erste Test überhaupt beginnt – die angemeldete Meldung liefe dann doch auf, und niemand verstünde warum. So gilt eine Anmeldung vom Aufruf bis zum Ende des laufenden Tests, gleich wo sie steht. Im `finally`, weil die Prüfung darüber wirft: Bliebe die Anmeldung stehen, wäre die Wache im nächsten Test taub. */ erwartungen = []; } }, { auto: true }, ], konsolenwacheAbschluss: [ // eslint-disable-next-line no-empty-pattern -- siehe oben: Playwright verlangt die Destrukturierung als erstes Argument. async ({}, benutzen) => { await benutzen(undefined); const befunde = entnehmen(); if (befunde.length > 0) { throw new Error(bericht(befunde)); } }, { scope: 'worker', auto: true }, ], });