import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { MenuItemConstructorOptions } from 'electron'; import { IPC, type MenuCommand, type OpenFileResult, type SaveFileResult } from '../../shared/ipc'; /** * Das Fehlerprotokoll muss sich weitergeben lassen, auch dort, wo Windows die * Dateien des Benutzerprofils umleitet. * * Gemessen am 17.09.2026 unter Windows 11: Je nach Installationsart leitet * Windows neue Dateien unter %APPDATA% in einen eigenen Bereich um, waehrend * `app.getPath('userData')` den UNumgeleiteten Pfad nennt. Bestand * %APPDATA%\lsa-planer-professional schon vorher - vom Setup oder vom * tragbaren Programm -, landen auch neue Dateien dort, und der Pfad stimmt. * Bis 5.42.1 nannte die Kurzhilfe allein diesen Pfad und bat, die Datei einer Fehlermeldung * beizulegen; die beiden Fehlerdialoge des Hauptprozesses nennen ihn als Ort * der Einzelheiten - im ersten Fall ist die Datei im Explorer dort nicht zu * finden. Der Ausweg ist der Eintrag Hilfe -> "Fehlerprotokoll speichern …", * der eine Kopie an einen Ort eigener Wahl schreibt; das Programm selbst liest * die Datei unter dem Pfad, den es kennt. Die Startzeile des Protokolls nennt * Fassung, Laufzeit und Plattform und sonst nichts - wie das Programm * eingerichtet ist, sagt sie seit 5.43.2 nicht mehr. * * Zwei Teile: * 1. ohne Fenster - der Menueeintrag in der Vorlage, die Zusammensetzung der * Kopie, der vorgeschlagene Dateiname und die Startzeile; * 2. am echten electron/main.ts gegen ein echtes Verzeichnis - ersetzt sind * nur Electron, fuer den Fehlschlag das Schreiben der Bytes und, auf * Wunsch eines Falls, die Dateinummer aus `statSync`. Dort wird * bewacht: Die Kopie landet, wo der Dialog hinzeigt; sie enthaelt die * Datei, die die Rotation abgeloest hat; das Ziel kommt NICHT in die * Freigabeliste; geschrieben wird unteilbar; keine der beiden * Protokolldateien ist als Ziel zugelassen; kein Fehlschlag wird * verschluckt; beide Fehlerdialoge nennen den Menueweg. * * Jeder Block raeumt die Ordner ab, die er unter os.tmpdir() anlegt - die * Datei laeuft bei jedem Commit mit (Fassung 5.43.0, Befund 28). */ const welt = vi.hoisted(() => { type Horcher = (...werte: unknown[]) => void; class Ereignisquelle { private readonly horcher = new Map(); on(name: string, fn: Horcher): this { const liste = this.horcher.get(name) ?? []; liste.push(fn); this.horcher.set(name, liste); return this; } once(name: string, fn: Horcher): this { return this.on(name, fn); } removeListener(name: string, fn: Horcher): this { this.horcher.set( name, (this.horcher.get(name) ?? []).filter((h) => h !== fn), ); return this; } off(name: string, fn: Horcher): this { return this.removeListener(name, fn); } ausloesen(name: string, ...werte: unknown[]): void { for (const fn of this.horcher.get(name) ?? []) fn(...werte); } } class Anzeigeinhalt extends Ereignisquelle { send(): void { /* Im Test ohne Wirkung. */ } setZoomFactor(): void { /* Im Test ohne Wirkung. */ } setWindowOpenHandler(): void { /* Im Test ohne Wirkung. */ } isCrashed(): boolean { return false; } isDestroyed(): boolean { return false; } } interface Dialogaufruf { fenster: unknown; optionen: { title?: string; defaultPath?: string; filters?: unknown; properties?: string[] }; } const zustand = { fenster: null as Fenster | null, benutzerdaten: '.', dokumente: '.', /** Wirft `getPath('documents')`? Electron tut das, wenn der Ordner nicht zu ermitteln ist. */ dokumenteFehlen: false, /** Was der Speichern-Dialog zurueckgibt; `null` heisst Abbrechen. */ speichernZiel: null as string | null, /** * Ergebnis des Speichern-Dialogs unmittelbar, an `speichernZiel` vorbei - * fuer eine Rueckgabe, die der Typbeschreibung von Electron widerspricht. */ rohergebnis: null as { canceled: boolean; filePath: string | undefined } | null, dialoge: [] as Dialogaufruf[], fehlerkaesten: [] as { titel: string; text: string }[], vorlage: [] as MenuItemConstructorOptions[], /** Scharfgestellter Fehler beim Schreiben der Bytes (nicht beim Oeffnen). */ schreibfehler: null as NodeJS.ErrnoException | null, /** Meldet `statSync` fuer jede Datei die Dateinummer 0 - wie ein Datentraeger ohne Dateinummern? */ dateinummerNull: false, }; class Fenster extends Ereignisquelle { readonly webContents = new Anzeigeinhalt(); constructor() { super(); zustand.fenster = this; } loadFile(): Promise { return Promise.resolve(); } reload(): void { /* Im Test ohne Wirkung. */ } show(): void { /* Im Test ohne Wirkung. */ } setBackgroundColor(): void { /* Im Test ohne Wirkung. */ } setDocumentEdited(): void { /* Im Test ohne Wirkung. */ } isDestroyed(): boolean { return false; } close(): void { /* Im Test ohne Wirkung. */ } static getAllWindows(): Fenster[] { return zustand.fenster === null ? [] : [zustand.fenster]; } } const behandler = new Map unknown>(); const electron = { app: { isPackaged: false, requestSingleInstanceLock: (): boolean => true, on: (): void => undefined, whenReady: (): Promise => Promise.resolve(), getPath: (name: string): string => { if (name !== 'documents') return zustand.benutzerdaten; if (zustand.dokumenteFehlen) throw new Error("Failed to get 'documents' path"); return zustand.dokumente; }, getVersion: (): string => '5.43.0', quit: (): void => undefined, }, BrowserWindow: Fenster, Menu: { buildFromTemplate: ( vorlage: MenuItemConstructorOptions[], ): { getMenuItemById: () => null } => { zustand.vorlage = vorlage; return { getMenuItemById: () => null }; }, setApplicationMenu: (): void => undefined, }, dialog: { showMessageBoxSync: (): number => 0, showErrorBox: (titel: string, text: string): void => { zustand.fehlerkaesten.push({ titel, text }); }, showSaveDialog: ( ...werte: unknown[] ): Promise<{ canceled: boolean; filePath: string | undefined }> => { const [fenster, optionen] = werte.length > 1 ? werte : [undefined, werte[0]]; zustand.dialoge.push({ fenster, optionen: optionen as Dialogaufruf['optionen'] }); if (zustand.rohergebnis !== null) return Promise.resolve(zustand.rohergebnis); return Promise.resolve({ canceled: zustand.speichernZiel === null, filePath: zustand.speichernZiel ?? '', }); }, showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> => Promise.resolve({ canceled: true, filePaths: [] }), }, ipcMain: { handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => { behandler.set(kanal, fn); }, on: (): void => undefined, }, nativeTheme: { shouldUseDarkColors: false, shouldUseHighContrastColors: false, on: (): void => undefined, }, systemPreferences: { getColor: (): string => '#000000' }, session: { defaultSession: { webRequest: { onHeadersReceived: (): void => undefined }, setPermissionRequestHandler: (): void => undefined, setPermissionCheckHandler: (): void => undefined, }, }, shell: { openExternal: (): Promise => Promise.resolve() }, }; return { electron, zustand, behandler, zuruecksetzen: (): void => { zustand.fenster = null; zustand.dokumenteFehlen = false; zustand.speichernZiel = null; zustand.rohergebnis = null; zustand.dialoge = []; zustand.fehlerkaesten = []; zustand.vorlage = []; zustand.schreibfehler = null; zustand.dateinummerNull = false; behandler.clear(); }, }; }); vi.mock('electron', () => welt.electron); /** * Ersatz fuer `node:fs/promises`: Oeffnen gelingt, erst das Schreiben der * Bytes scheitert - wie bei einem vollen Datentraeger. Alles uebrige geht an * das echte Dateisystem. */ /** * Ersatz fuer `node:fs`: alles echt; nur `statSync` meldet auf Wunsch die * Dateinummer 0, wie ein Datentraeger, der keine Dateinummern fuehrt. */ vi.mock('node:fs', async (echtesModul) => { const echt = await echtesModul(); const statSync = (...werte: unknown[]): unknown => { const angaben = (echt.statSync as (...w: unknown[]) => unknown)(...werte); if (!welt.zustand.dateinummerNull || typeof angaben !== 'object' || angaben === null) { return angaben; } const ohneNummer: unknown = Object.assign( Object.create(Object.getPrototypeOf(angaben) as object | null) as object, angaben, ); const { ino } = angaben as { ino: unknown }; return Object.assign(ohneNummer as object, { ino: typeof ino === 'bigint' ? 0n : 0 }); }; return { ...echt, default: { ...echt, statSync }, statSync }; }); vi.mock('node:fs/promises', async (echtesModul) => { const echt = await echtesModul(); return { ...echt, default: echt, async open(ziel: unknown, kennzeichen?: unknown): Promise { const griff = await echt.open(ziel as string, (kennzeichen as string | undefined) ?? 'r'); const fehler = welt.zustand.schreibfehler; if (fehler === null) return griff; return { writeFile: (): Promise => Promise.reject(fehler), sync: (): Promise => griff.sync(), close: (): Promise => griff.close(), }; }, }; }); process.setMaxListeners(64); const { menuevorlage } = await import('../../electron/menu'); const { KEIN_PROTOKOLL, abgeloestesProtokoll, liesProtokolldatei, protokollDateiname, protokollkopie, protokolliereStart, startzeile, } = await import('../../electron/protokoll'); /** Die Beschriftung, woertlich - mit Auslassungszeichen U+2026, nicht drei Punkten. */ const BESCHRIFTUNG = `Fehlerprotokoll speichern ${String.fromCodePoint(0x2026)}`; /** Der Menueweg, wie ihn die Fehlerdialoge nennen - mit deutschen Anfuehrungszeichen. */ const MENUEWEG = '„Hilfe → Fehlerprotokoll speichern …“'; /** * Groesse, ab der `protokolliere` die Datei abloest - aus dem Quelltext * gelesen, weil sie nicht ausgefuehrt ist. Eine zweite Zahl hier liefe * auseinander. */ const MAX_BYTES = ((): number => { const treffer = /^const MAX_BYTES = ([\d_]+);$/m.exec( readFileSync('electron/protokoll.ts', 'utf8'), ); expect(treffer, 'MAX_BYTES in electron/protokoll.ts nicht gefunden').not.toBeNull(); return Number(treffer![1]!.replace(/_/g, '')); })(); // === 1. Ohne Fenster ======================================================== interface Aufzeichnung { befehle: MenuCommand[]; seiten: string[]; kopien: number; } function hilfemenue(): { eintraege: MenuItemConstructorOptions[]; auf: Aufzeichnung } { const auf: Aufzeichnung = { befehle: [], seiten: [], kopien: 0 }; const menue = menuevorlage( (befehl) => auf.befehle.push(befehl), false, { aktuell: () => 100, setze: () => undefined, schritt: () => undefined }, '5.43.0', (url) => auf.seiten.push(url), () => { auf.kopien += 1; }, ); const hilfe = menue.find((e) => e.label === '&Hilfe')?.submenu; expect(Array.isArray(hilfe), 'Das Hilfemenü fehlt').toBe(true); return { eintraege: hilfe as MenuItemConstructorOptions[], auf }; } function klicke(eintrag: MenuItemConstructorOptions | undefined): void { expect(eintrag, 'Menüeintrag fehlt').toBeDefined(); eintrag?.click?.(undefined as never, undefined, undefined as never); } describe('Hilfemenü: Fehlerprotokoll speichern …', () => { it('steht unmittelbar nach "Kurzhilfe" und vor dem Trennstrich', () => { const { eintraege } = hilfemenue(); const kurzhilfe = eintraege.findIndex((e) => e.label === 'Kurzhilfe'); expect(kurzhilfe, 'Kurzhilfe fehlt').toBeGreaterThanOrEqual(0); expect(eintraege[kurzhilfe + 1]?.label).toBe(BESCHRIFTUNG); expect(eintraege[kurzhilfe + 2]?.type).toBe('separator'); }); it('traegt das Auslassungszeichen und keine drei Punkte', () => { const { eintraege } = hilfemenue(); const beschriftungen = eintraege.map((e) => e.label); expect(beschriftungen).toContain(BESCHRIFTUNG); expect(beschriftungen).not.toContain('Fehlerprotokoll speichern ...'); expect(beschriftungen).not.toContain('Fehlerprotokoll speichern...'); }); it('ruft beim Anklicken genau einmal die Rueckruffunktion des Hauptprozesses', () => { const { eintraege, auf } = hilfemenue(); klicke(eintraege.find((e) => e.label === BESCHRIFTUNG)); expect(auf.kopien).toBe(1); }); it('schickt dabei keinen Befehl an den Renderer und oeffnet keine Seite', () => { // Kein IPC-Kanal und kein Rendereranteil: Quelle und Ziel entscheidet // allein der Hauptprozess. const { eintraege, auf } = hilfemenue(); klicke(eintraege.find((e) => e.label === BESCHRIFTUNG)); expect(auf.befehle).toEqual([]); expect(auf.seiten).toEqual([]); }); it('laesst die Kurzhilfe bei ihrem Befehl', () => { // Grenzwache: Der neue Eintrag hat die Kurzhilfe nicht verdraengt. const { eintraege, auf } = hilfemenue(); klicke(eintraege.find((e) => e.label === 'Kurzhilfe')); expect(auf.befehle).toEqual(['hilfe']); expect(auf.kopien).toBe(0); }); }); /** Lesefunktion ueber eine Tafel - `null` heisst: Datei gibt es nicht. */ function tafel(inhalte: Record): (pfad: string) => string | null { return (pfad) => inhalte[pfad] ?? null; } const DATEI = path.join('C:', 'Benutzer', 'fehlerprotokoll.log'); describe('Kopie des Fehlerprotokolls: Zusammensetzung', () => { it('stellt die abgeloeste Datei vor die laufende - in zeitlicher Reihenfolge', () => { const lies = tafel({ [abgeloestesProtokoll(DATEI)]: '[2026-09-01T08:00:00.000Z] Start: alt\n', [DATEI]: '[2026-09-17T08:00:00.000Z] Start: neu\n', }); expect(protokollkopie(DATEI, lies)).toBe( '[2026-09-01T08:00:00.000Z] Start: alt\n[2026-09-17T08:00:00.000Z] Start: neu\n', ); }); it('nennt die abgeloeste Datei .alt', () => { // Dass die Rotation sie unter genau diesem Namen ablegt, bewacht der Fall // "liest die abgeloeste Datei unter dem Namen, unter dem die Rotation sie // ablegt" am echten Hauptprozess. expect(abgeloestesProtokoll(DATEI)).toBe(`${DATEI}.alt`); }); it('gibt ohne abgeloeste Datei die laufende unveraendert wieder', () => { const inhalt = '[2026-09-17T08:00:00.000Z] Start: Version 5.43.0\n stapelzeile\n'; expect(protokollkopie(DATEI, tafel({ [DATEI]: inhalt }))).toBe(inhalt); }); it('gibt die abgeloeste Datei wieder, wenn nur sie besteht', () => { const inhalt = '[2026-09-01T08:00:00.000Z] Start: alt\n'; expect(protokollkopie(DATEI, tafel({ [abgeloestesProtokoll(DATEI)]: inhalt }))).toBe(inhalt); }); it('trennt beide Teile mit einem Zeilenumbruch, auch wenn der erste ohne endet', () => { // Sonst klebte die erste Zeile der laufenden Datei an der letzten der // abgeloesten, und die Zeitmarke stuende mitten in der Zeile. const lies = tafel({ [abgeloestesProtokoll(DATEI)]: '[2026-09-01T08:00:00.000Z] Start: alt', [DATEI]: '[2026-09-17T08:00:00.000Z] Start: neu\n', }); expect(protokollkopie(DATEI, lies).split('\n')).toEqual([ '[2026-09-01T08:00:00.000Z] Start: alt', '[2026-09-17T08:00:00.000Z] Start: neu', '', ]); }); it('schreibt ohne Protokoll eine Zeile, dass nichts protokolliert wurde', () => { expect(protokollkopie(DATEI, tafel({}))).toBe(`${KEIN_PROTOKOLL}\n`); expect(KEIN_PROTOKOLL).toMatch(/nichts protokolliert/); }); it('behandelt leere Dateien wie fehlende', () => { const lies = tafel({ [abgeloestesProtokoll(DATEI)]: '', [DATEI]: '' }); expect(protokollkopie(DATEI, lies)).toBe(`${KEIN_PROTOKOLL}\n`); }); it('fuegt einem vorhandenen Protokoll nichts hinzu', () => { // Die Kopie ist eine Kopie: Wer sie beilegt, legt das Protokoll bei. const inhalt = '[2026-09-17T08:00:00.000Z] Start: neu\n'; expect(protokollkopie(DATEI, tafel({ [DATEI]: inhalt }))).not.toContain(KEIN_PROTOKOLL); }); it('liest nur die beiden festen Dateien', () => { const gelesen: string[] = []; protokollkopie(DATEI, (pfad) => { gelesen.push(pfad); return null; }); expect([...gelesen].sort()).toEqual([DATEI, abgeloestesProtokoll(DATEI)].sort()); }); it('laesst einen Lesefehler durch, statt "nichts protokolliert" zu behaupten', () => { const gesperrt = Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' }); expect(() => protokollkopie(DATEI, () => { throw gesperrt; }), ).toThrow(gesperrt); }); }); describe('Kopie des Fehlerprotokolls: Lesen von der Platte', () => { let ordner = ''; beforeEach(() => { ordner = mkdtempSync(path.join(os.tmpdir(), 'lsa-protokollkopie-')); }); afterEach(() => { rmSync(ordner, { recursive: true, force: true }); }); it('liefert den Inhalt einer vorhandenen Datei', () => { const datei = path.join(ordner, 'fehlerprotokoll.log'); writeFileSync(datei, 'Zeile mit Umlaut: Größe\n', 'utf8'); expect(liesProtokolldatei(datei)).toBe('Zeile mit Umlaut: Größe\n'); }); it('liefert null, wenn die Datei fehlt', () => { expect(liesProtokolldatei(path.join(ordner, 'gibt-es-nicht.log'))).toBeNull(); }); it('wirft bei jedem anderen Fehlschlag', () => { // Ein Ordner an der Stelle der Datei: Lesen scheitert, aber nicht mit // ENOENT. "Nichts protokolliert" waere hier eine falsche Auskunft. const verzeichnis = path.join(ordner, 'fehlerprotokoll.log'); mkdirSync(verzeichnis); let geworfen: unknown = null; try { liesProtokolldatei(verzeichnis); } catch (fehler) { geworfen = fehler; } expect((geworfen as NodeJS.ErrnoException | null)?.code).toBe('EISDIR'); }); }); describe('Kopie des Fehlerprotokolls: vorgeschlagener Dateiname', () => { it('traegt das oertliche Datum', () => { expect(protokollDateiname(new Date(2026, 8, 17, 12, 0))).toBe( 'LSA-Planer-Fehlerprotokoll-2026-09-17.txt', ); }); it('nimmt kurz nach Mitternacht den oertlichen Tag, nicht den nach Weltzeit', () => { // Oestlich von Greenwich - also auch in Deutschland - liegt 00:30 Uhr // oertlich noch am Vortag nach Weltzeit. `toISOString` nannte den Vortag. expect(protokollDateiname(new Date(2026, 0, 1, 0, 30))).toBe( 'LSA-Planer-Fehlerprotokoll-2026-01-01.txt', ); }); it('nimmt kurz vor Mitternacht den oertlichen Tag, nicht den nach Weltzeit', () => { // Westlich von Greenwich das Gegenstueck; einer der beiden Faelle greift // in jeder Zeitzone ausser der Weltzeit selbst. expect(protokollDateiname(new Date(2025, 11, 31, 23, 30))).toBe( 'LSA-Planer-Fehlerprotokoll-2025-12-31.txt', ); }); }); describe('Startzeile des Protokolls', () => { const UMGEBUNG = { version: '5.43.0', electron: '44.3.0', chromium: '140.0.0.0', plattform: 'win32', architektur: 'x64', }; it('nennt Fassung, Laufzeit und Plattform - und sonst nichts', () => { expect(startzeile(UMGEBUNG)).toBe( 'Version 5.43.0 · Electron 44.3.0 · Chromium 140.0.0.0 · win32 x64', ); }); it('haengt kein weiteres Glied an', () => { /* * Vier Glieder: Fassung, Electron, Chromium, Plattform mit Architektur. * Bis 5.43.1 folgte ein fuenftes, das sagte, wie das Programm * eingerichtet ist. Seit 5.43.2 nennt die Zeile das nicht mehr; wo * Windows die Dateien des Benutzerprofils umleitet, fuehrt der * Menueeintrag "Fehlerprotokoll speichern …" trotzdem zur Datei. */ expect(startzeile(UMGEBUNG).split(' · ')).toEqual([ 'Version 5.43.0', 'Electron 44.3.0', 'Chromium 140.0.0.0', 'win32 x64', ]); }); describe('protokolliereStart', () => { let konsole: string[] = []; beforeEach(() => { welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-startzeile-')); konsole = []; vi.spyOn(console, 'error').mockImplementation((text: unknown) => { konsole.push(String(text)); }); }); afterEach(() => { vi.restoreAllMocks(); rmSync(welt.zustand.benutzerdaten, { recursive: true, force: true }); }); it('schreibt die Angaben des laufenden Prozesses', () => { protokolliereStart(); const zeile = konsole.find((text) => text.startsWith('Start: ')); expect(zeile, 'die Startzeile auf der Konsole').toBeDefined(); expect(zeile).toContain('Version 5.43.0'); expect(zeile).toContain(`Electron ${process.versions.electron}`); expect(zeile).toContain(`Chromium ${process.versions.chrome}`); expect( String(zeile).endsWith(` · ${process.platform} ${process.arch}`), `das letzte Glied von "${String(zeile)}"`, ).toBe(true); }); }); }); // === 2. Am echten Hauptprozess ============================================== describe('Hauptprozess: Fehlerprotokoll speichern …', () => { let ablage = ''; let ausnahmehorcher: ((...werte: unknown[]) => void) | undefined; async function starteHauptprozess(): Promise { vi.resetModules(); welt.zuruecksetzen(); const vorher = process.listeners('uncaughtException'); await import('../../electron/main'); for (let i = 0; i < 5; i += 1) await Promise.resolve(); ausnahmehorcher = process .listeners('uncaughtException') .find((h) => !vorher.includes(h)) as typeof ausnahmehorcher; } function protokolldatei(): string { return path.join(welt.zustand.benutzerdaten, 'fehlerprotokoll.log'); } function hilfeAusVorlage(): MenuItemConstructorOptions[] { return (welt.zustand.vorlage.find((e) => e.label === '&Hilfe')?.submenu ?? []) as MenuItemConstructorOptions[]; } /** Hilfe -> "Fehlerprotokoll speichern …" anklicken. */ function klickeMenueeintrag(): void { const eintrag = hilfeAusVorlage().find((e) => e.label === BESCHRIFTUNG); expect(eintrag, 'Der Hauptprozess baut den Menüeintrag nicht ein').toBeDefined(); eintrag?.click?.(undefined as never, undefined, undefined as never); } /** Wartet, bis der Vorgang hinter dem Menueeintrag sichtbar abgeschlossen ist. */ async function warteAuf(bedingung: () => boolean): Promise { await vi.waitFor(() => { expect(bedingung()).toBe(true); }); } /** Laesst Zusagen und Ein-/Ausgabe auslaufen, wenn nichts geschehen soll. */ async function lassAuslaufen(): Promise { for (let i = 0; i < 5; i += 1) { await new Promise((fertig) => setImmediate(fertig)); } } beforeEach(async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-protokollziel-')); welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-protokollquelle-')); welt.zustand.dokumente = mkdtempSync(path.join(os.tmpdir(), 'lsa-dokumente-')); await starteHauptprozess(); }); afterEach(() => { vi.restoreAllMocks(); for (const ordner of [ablage, welt.zustand.benutzerdaten, welt.zustand.dokumente]) { rmSync(ordner, { recursive: true, force: true }); } }); describe('der Dialog', () => { it('oeffnet den Speichern-Dialog am Hauptfenster', async () => { klickeMenueeintrag(); await warteAuf(() => welt.zustand.dialoge.length === 1); expect(welt.zustand.dialoge[0]?.fenster).toBe(welt.zustand.fenster); }); /* * Zum Datum in den beiden folgenden Faellen: Das Programm liest die Uhr * selbst, der Fall ein zweites Mal. Zwischen beiden Lesungen liegen nur * wenige Mikroaufgaben - der Klick laeuft bis zum Dialog in einem Zug, und * `vi.waitFor` prueft zuerst ohne Pause -, also weniger als eine * Millisekunde. Faellt Mitternacht dazwischen, naennten beide Lesungen * verschiedene Tage, obwohl das Programm richtig rechnet. Beobachtet ist * das nicht; verglichen wird trotzdem gegen die Uhr vor UND nach dem Klick, * wie in tests/tools/pruefsummendatum.test.ts (Fassung 5.43.0, * Befund 27). */ it('schlaegt eine Textdatei mit oertlichem Datum im Dokumente-Ordner vor', async () => { const vorher = protokollDateiname(new Date()); klickeMenueeintrag(); await warteAuf(() => welt.zustand.dialoge.length === 1); const nachher = protokollDateiname(new Date()); const optionen = welt.zustand.dialoge[0]?.optionen; // Zwei Werte, falls der Lauf ueber Mitternacht faellt. expect([ path.join(welt.zustand.dokumente, vorher), path.join(welt.zustand.dokumente, nachher), ]).toContain(optionen?.defaultPath); expect(optionen?.filters).toEqual([{ name: 'Textdatei', extensions: ['txt'] }]); expect(optionen?.properties).toContain('showOverwriteConfirmation'); }); it('schlaegt nur den Dateinamen vor, wenn der Dokumente-Ordner nicht zu ermitteln ist', async () => { welt.zustand.dokumenteFehlen = true; const vorher = protokollDateiname(new Date()); klickeMenueeintrag(); await warteAuf(() => welt.zustand.dialoge.length === 1); const nachher = protokollDateiname(new Date()); // Zwei Werte, falls der Lauf ueber Mitternacht faellt. expect([vorher, nachher]).toContain(welt.zustand.dialoge[0]?.optionen.defaultPath); expect(welt.zustand.fehlerkaesten).toEqual([]); }); it('schreibt nichts, wenn der Anwender abbricht', async () => { welt.zustand.speichernZiel = null; klickeMenueeintrag(); await warteAuf(() => welt.zustand.dialoge.length === 1); await lassAuslaufen(); expect(readdirSync(ablage)).toEqual([]); expect(readdirSync(welt.zustand.dokumente)).toEqual([]); expect(welt.zustand.fehlerkaesten).toEqual([]); }); it('bricht still ab, wenn der Dialog ohne Abbruch kein Ziel liefert', async () => { // Die Typbeschreibung verspricht eine Zeichenkette. Kommt trotzdem // `undefined`, wirft `path.resolve` in `nacheinanderJeZiel`, und ohne die // Pruefung auf ein leeres Ziel stuende ein Fehlerkasten mit der // englischen Meldung des Laufzeitsystems da - obwohl nichts gewaehlt ist. welt.zustand.rohergebnis = { canceled: false, filePath: undefined }; klickeMenueeintrag(); await warteAuf(() => welt.zustand.dialoge.length === 1); await lassAuslaufen(); expect(welt.zustand.fehlerkaesten).toEqual([]); expect(readdirSync(ablage)).toEqual([]); }); }); describe('die Kopie', () => { it('schreibt das Protokoll an den gewaehlten Ort', async () => { const ziel = path.join(ablage, 'protokoll.txt'); welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => existsSync(ziel)); // Die Startzeile hat protokolliereStart beim Hochfahren geschrieben. expect(readFileSync(ziel, 'utf8')).toBe(readFileSync(protokolldatei(), 'utf8')); expect(readFileSync(ziel, 'utf8')).toContain('Start: Version 5.43.0'); expect(readdirSync(ablage), 'eine Nachbardatei ist liegen geblieben').toEqual([ 'protokoll.txt', ]); }); it('stellt die abgeloeste Datei davor', async () => { writeFileSync(`${protokolldatei()}.alt`, '[2026-09-01T08:00:00.000Z] Start: alt\n', 'utf8'); const ziel = path.join(ablage, 'protokoll.txt'); welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => existsSync(ziel)); const kopie = readFileSync(ziel, 'utf8'); expect(kopie.startsWith('[2026-09-01T08:00:00.000Z] Start: alt\n')).toBe(true); expect(kopie).toContain('Start: Version 5.43.0'); }); it('liest die abgeloeste Datei unter dem Namen, unter dem die Rotation sie ablegt', async () => { /* * Fassung 5.43.0, Befund 21: Der Fall darueber schreibt `.alt` * selbst, bindet also nur die Leseseite an die Endung. Den Zweig, in dem * `protokolliere` die volle Datei umbenennt, nahm kein Fall - benannte die * Rotation anders, als die Kopie liest, blieb alles gruen, und der aeltere * Teil fehlte in der Kopie. Hier rotiert das Programm selbst. */ const ALTE_ZEILE = '[2026-09-01T08:00:00.000Z] Start: vor der Abloesung\n'; const fuellzeile = `${'x'.repeat(99)}\n`; const fuellung = fuellzeile.repeat(Math.ceil(MAX_BYTES / fuellzeile.length)); writeFileSync(protokolldatei(), `${ALTE_ZEILE}${fuellung}`, 'utf8'); expect(ausnahmehorcher, 'kein Horcher auf uncaughtException').toBeDefined(); ausnahmehorcher?.(new Error('Pruefstand'), 'uncaughtException'); // Die Rotation hat stattgefunden: Die laufende Datei ist neu angelegt. expect(readFileSync(protokolldatei(), 'utf8')).not.toContain('vor der Abloesung'); const ziel = path.join(ablage, 'protokoll.txt'); welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => existsSync(ziel)); const kopie = readFileSync(ziel, 'utf8'); expect(kopie.startsWith(ALTE_ZEILE), 'der abgeloeste Teil fehlt in der Kopie').toBe(true); expect(kopie.indexOf('Pruefstand')).toBeGreaterThan(fuellung.length); }); it('schreibt ohne Protokoll eine Kopie mit dem Vermerk', async () => { rmSync(protokolldatei(), { force: true }); const ziel = path.join(ablage, 'protokoll.txt'); welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => existsSync(ziel)); expect(readFileSync(ziel, 'utf8')).toBe(`${KEIN_PROTOKOLL}\n`); expect(welt.zustand.fehlerkaesten).toEqual([]); }); it('nimmt das Ziel nicht in die Freigabeliste auf', async () => { const ziel = path.join(ablage, 'protokoll.txt'); welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => existsSync(ziel)); const geschrieben = (await welt.behandler.get(IPC.writeFile)?.(null, { filePath: ziel, data: 'vom Renderer', })) as SaveFileResult; const gelesen = (await welt.behandler.get(IPC.readFile)?.(null, ziel)) as OpenFileResult; expect(geschrieben.ok, 'der Renderer darf die Kopie beschreiben').toBe(false); expect(gelesen.ok, 'der Renderer darf die Kopie lesen').toBe(false); expect(readFileSync(ziel, 'utf8')).not.toContain('vom Renderer'); }); }); describe('keine der beiden Protokolldateien als Ziel', () => { /* * Fassung 5.43.0, Befund 11: Die Kurzhilfe zeigt den Pfad des * Protokolls als auswaehlbaren Text, gleich neben der Bitte um eine Kopie. * Wer ihn in den Dialog einfuegt und das Ueberschreiben bestaetigt, liess * `schreibeUnteilbar` das laufende Protokoll durch "alt + laufend" * ersetzen; die naechste Kopie enthielt den alten Teil doppelt und nicht * mehr in zeitlicher Folge. Mit `.alt` als Ziel wurde der laufende Teil * doppelt. */ const ALT = '[2026-09-01T08:00:00.000Z] Start: alt\n'; /** Inhalt beider Protokolldateien und alles, was im Ordner liegt. */ function bestand(): { laufend: string; alt: string | null; dateien: string[] } { const alt = `${protokolldatei()}.alt`; return { laufend: readFileSync(protokolldatei(), 'utf8'), alt: existsSync(alt) ? readFileSync(alt, 'utf8') : null, dateien: readdirSync(welt.zustand.benutzerdaten).sort(), }; } async function lehntAb(ziel: string): Promise { const vorher = bestand(); welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => welt.zustand.fehlerkaesten.length > 0); await lassAuslaufen(); expect(welt.zustand.fehlerkaesten).toEqual([ { titel: 'Fehlerprotokoll nicht gespeichert', text: 'Die Kopie kann nicht an die Stelle des Protokolls selbst geschrieben werden. ' + 'Bitte wählen Sie einen anderen Ort oder Dateinamen.', }, ]); // Nichts geschrieben, auch keine liegengebliebene Nachbardatei `.teil`. expect(bestand()).toEqual(vorher); } it('lehnt die laufende Datei ab', async () => { writeFileSync(`${protokolldatei()}.alt`, ALT, 'utf8'); await lehntAb(protokolldatei()); }); it('lehnt die abgeloeste Datei ab', async () => { writeFileSync(`${protokolldatei()}.alt`, ALT, 'utf8'); await lehntAb(`${protokolldatei()}.alt`); }); it('lehnt die abgeloeste Datei auch ab, solange es sie noch nicht gibt', async () => { // Hier traegt allein der Pfad: Eine Datei, die es nicht gibt, hat keine // Dateikennung. await lehntAb(`${protokolldatei()}.alt`); }); it.runIf(process.platform === 'win32')( 'lehnt sie unter Windows auch in anderer Schreibweise ab', async () => { // Unter Windows ist FEHLERPROTOKOLL.LOG.ALT dieselbe Datei. Solange es // sie nicht gibt, traegt wieder allein der Pfad. await lehntAb(path.join(welt.zustand.benutzerdaten, 'FEHLERPROTOKOLL.LOG.ALT')); }, ); it('erkennt die laufende Datei auch unter einem anderen Pfad', async () => { // Derselbe Ordner ueber eine Verzeichnisverknuepfung: Der Pfad ist ein // anderer, die Datei dieselbe - wie unter einem 8.3-Kurznamen. Hier // traegt allein die Dateikennung. const verknuepfung = path.join(ablage, 'verknuepfung'); symlinkSync(welt.zustand.benutzerdaten, verknuepfung, 'junction'); await lehntAb(path.join(verknuepfung, 'fehlerprotokoll.log')); }); it('haelt zwei Dateien ohne Dateinummer nicht fuer dieselbe', async () => { // Meldet ein Datentraeger fuer jede Datei die Dateinummer 0, waeren auf // ihm alle Dateien "dieselbe" - und jede Kopie neben dem Protokoll // scheiterte mit einer falschen Begruendung. const ziel = path.join(ablage, 'protokoll.txt'); writeFileSync(ziel, 'ALTER INHALT', 'utf8'); expect( statSync(ziel, { bigint: true }).dev, 'Ziel und Protokoll liegen nicht auf demselben Datentraeger - der Fall prueft dann nichts', ).toBe(statSync(protokolldatei(), { bigint: true }).dev); welt.zustand.dateinummerNull = true; welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => readFileSync(ziel, 'utf8') !== 'ALTER INHALT'); expect(welt.zustand.fehlerkaesten).toEqual([]); expect(readFileSync(ziel, 'utf8')).toBe(readFileSync(protokolldatei(), 'utf8')); }); }); describe('Fehlschlaege', () => { it('behaelt eine vorhandene Datei am Ziel und meldet den Grund, wenn das Schreiben scheitert', async () => { const ziel = path.join(ablage, 'protokoll.txt'); writeFileSync(ziel, 'ALTER INHALT', 'utf8'); welt.zustand.speichernZiel = ziel; welt.zustand.schreibfehler = Object.assign(new Error('no space left on device'), { code: 'ENOSPC', }); klickeMenueeintrag(); await warteAuf(() => welt.zustand.fehlerkaesten.length === 1); expect(readFileSync(ziel, 'utf8'), 'unteilbar geschrieben wurde nicht').toBe('ALTER INHALT'); expect(readdirSync(ablage)).toEqual(['protokoll.txt']); const [kasten] = welt.zustand.fehlerkaesten; expect(kasten?.titel).toBe('Fehlerprotokoll nicht gespeichert'); expect(kasten?.text).toContain('Auf dem Datenträger ist kein Platz mehr frei.'); }); it('meldet, wenn sich das Protokoll nicht lesen laesst, und schreibt keine Kopie', async () => { // Ein Ordner an der Stelle der Datei: Lesen scheitert, aber nicht mit // ENOENT. Eine Kopie "nichts protokolliert" waere eine falsche Auskunft. rmSync(protokolldatei(), { force: true }); mkdirSync(protokolldatei()); const ziel = path.join(ablage, 'protokoll.txt'); welt.zustand.speichernZiel = ziel; klickeMenueeintrag(); await warteAuf(() => welt.zustand.fehlerkaesten.length === 1); await lassAuslaufen(); expect(existsSync(ziel)).toBe(false); const [kasten] = welt.zustand.fehlerkaesten; expect(kasten?.titel).toBe('Fehlerprotokoll nicht gespeichert'); expect(kasten?.text).toContain('ließ sich nicht lesen'); // Nicht die Schreibmeldung: "Kein Schreibrecht ... anderen Ordner" waere // hier ein falscher Rat. expect(kasten?.text).not.toContain('Wählen Sie einen anderen Ordner'); }); }); describe('Fehlerdialoge nennen den Menüweg', () => { /** * Der Menueweg, wie der Hauptprozess ihn wirklich eingebaut hat - nicht * eine zweite Abschrift der Beschriftung. Zwei Schreibweisen desselben * Eintrags liefen auseinander, und der Anwender suchte einen, den es so * nicht gibt. */ function menuewegAusVorlage(): string { const hilfe = hilfeAusVorlage(); const kurzhilfe = hilfe.findIndex((e) => e.label === 'Kurzhilfe'); return `„Hilfe → ${String(hilfe[kurzhilfe + 1]?.label)}“`; } it('beim unerwarteten Ende der Anzeige', () => { welt.zustand.fenster?.webContents.ausloesen( 'render-process-gone', {}, { reason: 'crashed', exitCode: 1 }, ); const [kasten] = welt.zustand.fehlerkaesten; expect(kasten?.titel).toBe('Die Anzeige wurde unerwartet beendet'); expect(kasten?.text).toContain(MENUEWEG); expect(kasten?.text).toContain(menuewegAusVorlage()); // Der Pfad bleibt stehen - meist stimmt er. expect(kasten?.text).toContain(protokolldatei()); }); it('beim unerwarteten Fehler im Hauptprozess', () => { expect(ausnahmehorcher, 'kein Horcher auf uncaughtException').toBeDefined(); ausnahmehorcher?.(new Error('Pruefstand'), 'uncaughtException'); const [kasten] = welt.zustand.fehlerkaesten; expect(kasten?.titel).toBe('Unerwarteter Fehler'); expect(kasten?.text).toContain(MENUEWEG); expect(kasten?.text).toContain(menuewegAusVorlage()); expect(kasten?.text).toContain(protokolldatei()); }); }); });