lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests electron protokollWeitergabe.test.ts

38,0 KB Rohdatei
tests/electron/protokollWeitergabe.test.ts — 1010 Zeilen
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2 import {
3 existsSync,
4 mkdirSync,
5 mkdtempSync,
6 readFileSync,
7 readdirSync,
8 rmSync,
9 statSync,
10 symlinkSync,
11 writeFileSync,
12 } from 'node:fs';
13 import os from 'node:os';
14 import path from 'node:path';
15 import type { MenuItemConstructorOptions } from 'electron';
16 import { IPC, type MenuCommand, type OpenFileResult, type SaveFileResult } from '../../shared/ipc';
17
18 /**
19 * Das Fehlerprotokoll muss sich weitergeben lassen, auch dort, wo Windows die
20 * Dateien des Benutzerprofils umleitet.
21 *
22 * Gemessen am 17.09.2026 unter Windows 11: Je nach Installationsart leitet
23 * Windows neue Dateien unter %APPDATA% in einen eigenen Bereich um, waehrend
24 * `app.getPath('userData')` den UNumgeleiteten Pfad nennt. Bestand
25 * %APPDATA%\lsa-planer-professional schon vorher - vom Setup oder vom
26 * tragbaren Programm -, landen auch neue Dateien dort, und der Pfad stimmt.
27 * Bis 5.42.1 nannte die Kurzhilfe allein diesen Pfad und bat, die Datei einer Fehlermeldung
28 * beizulegen; die beiden Fehlerdialoge des Hauptprozesses nennen ihn als Ort
29 * der Einzelheiten - im ersten Fall ist die Datei im Explorer dort nicht zu
30 * finden. Der Ausweg ist der Eintrag Hilfe -> "Fehlerprotokoll speichern …",
31 * der eine Kopie an einen Ort eigener Wahl schreibt; das Programm selbst liest
32 * die Datei unter dem Pfad, den es kennt. Die Startzeile des Protokolls nennt
33 * Fassung, Laufzeit und Plattform und sonst nichts - wie das Programm
34 * eingerichtet ist, sagt sie seit 5.43.2 nicht mehr.
35 *
36 * Zwei Teile:
37 * 1. ohne Fenster - der Menueeintrag in der Vorlage, die Zusammensetzung der
38 * Kopie, der vorgeschlagene Dateiname und die Startzeile;
39 * 2. am echten electron/main.ts gegen ein echtes Verzeichnis - ersetzt sind
40 * nur Electron, fuer den Fehlschlag das Schreiben der Bytes und, auf
41 * Wunsch eines Falls, die Dateinummer aus `statSync`. Dort wird
42 * bewacht: Die Kopie landet, wo der Dialog hinzeigt; sie enthaelt die
43 * Datei, die die Rotation abgeloest hat; das Ziel kommt NICHT in die
44 * Freigabeliste; geschrieben wird unteilbar; keine der beiden
45 * Protokolldateien ist als Ziel zugelassen; kein Fehlschlag wird
46 * verschluckt; beide Fehlerdialoge nennen den Menueweg.
47 *
48 * Jeder Block raeumt die Ordner ab, die er unter os.tmpdir() anlegt - die
49 * Datei laeuft bei jedem Commit mit (Fassung 5.43.0, Befund 28).
50 */
51
52 const welt = vi.hoisted(() => {
53 type Horcher = (...werte: unknown[]) => void;
54
55 class Ereignisquelle {
56 private readonly horcher = new Map<string, Horcher[]>();
57
58 on(name: string, fn: Horcher): this {
59 const liste = this.horcher.get(name) ?? [];
60 liste.push(fn);
61 this.horcher.set(name, liste);
62 return this;
63 }
64
65 once(name: string, fn: Horcher): this {
66 return this.on(name, fn);
67 }
68
69 removeListener(name: string, fn: Horcher): this {
70 this.horcher.set(
71 name,
72 (this.horcher.get(name) ?? []).filter((h) => h !== fn),
73 );
74 return this;
75 }
76
77 off(name: string, fn: Horcher): this {
78 return this.removeListener(name, fn);
79 }
80
81 ausloesen(name: string, ...werte: unknown[]): void {
82 for (const fn of this.horcher.get(name) ?? []) fn(...werte);
83 }
84 }
85
86 class Anzeigeinhalt extends Ereignisquelle {
87 send(): void {
88 /* Im Test ohne Wirkung. */
89 }
90 setZoomFactor(): void {
91 /* Im Test ohne Wirkung. */
92 }
93 setWindowOpenHandler(): void {
94 /* Im Test ohne Wirkung. */
95 }
96 isCrashed(): boolean {
97 return false;
98 }
99 isDestroyed(): boolean {
100 return false;
101 }
102 }
103
104 interface Dialogaufruf {
105 fenster: unknown;
106 optionen: { title?: string; defaultPath?: string; filters?: unknown; properties?: string[] };
107 }
108
109 const zustand = {
110 fenster: null as Fenster | null,
111 benutzerdaten: '.',
112 dokumente: '.',
113 /** Wirft `getPath('documents')`? Electron tut das, wenn der Ordner nicht zu ermitteln ist. */
114 dokumenteFehlen: false,
115 /** Was der Speichern-Dialog zurueckgibt; `null` heisst Abbrechen. */
116 speichernZiel: null as string | null,
117 /**
118 * Ergebnis des Speichern-Dialogs unmittelbar, an `speichernZiel` vorbei -
119 * fuer eine Rueckgabe, die der Typbeschreibung von Electron widerspricht.
120 */
121 rohergebnis: null as { canceled: boolean; filePath: string | undefined } | null,
122 dialoge: [] as Dialogaufruf[],
123 fehlerkaesten: [] as { titel: string; text: string }[],
124 vorlage: [] as MenuItemConstructorOptions[],
125 /** Scharfgestellter Fehler beim Schreiben der Bytes (nicht beim Oeffnen). */
126 schreibfehler: null as NodeJS.ErrnoException | null,
127 /** Meldet `statSync` fuer jede Datei die Dateinummer 0 - wie ein Datentraeger ohne Dateinummern? */
128 dateinummerNull: false,
129 };
130
131 class Fenster extends Ereignisquelle {
132 readonly webContents = new Anzeigeinhalt();
133
134 constructor() {
135 super();
136 zustand.fenster = this;
137 }
138
139 loadFile(): Promise<void> {
140 return Promise.resolve();
141 }
142 reload(): void {
143 /* Im Test ohne Wirkung. */
144 }
145 show(): void {
146 /* Im Test ohne Wirkung. */
147 }
148 setBackgroundColor(): void {
149 /* Im Test ohne Wirkung. */
150 }
151 setDocumentEdited(): void {
152 /* Im Test ohne Wirkung. */
153 }
154 isDestroyed(): boolean {
155 return false;
156 }
157 close(): void {
158 /* Im Test ohne Wirkung. */
159 }
160
161 static getAllWindows(): Fenster[] {
162 return zustand.fenster === null ? [] : [zustand.fenster];
163 }
164 }
165
166 const behandler = new Map<string, (...werte: unknown[]) => unknown>();
167
168 const electron = {
169 app: {
170 isPackaged: false,
171 requestSingleInstanceLock: (): boolean => true,
172 on: (): void => undefined,
173 whenReady: (): Promise<void> => Promise.resolve(),
174 getPath: (name: string): string => {
175 if (name !== 'documents') return zustand.benutzerdaten;
176 if (zustand.dokumenteFehlen) throw new Error("Failed to get 'documents' path");
177 return zustand.dokumente;
178 },
179 getVersion: (): string => '5.43.0',
180 quit: (): void => undefined,
181 },
182 BrowserWindow: Fenster,
183 Menu: {
184 buildFromTemplate: (
185 vorlage: MenuItemConstructorOptions[],
186 ): { getMenuItemById: () => null } => {
187 zustand.vorlage = vorlage;
188 return { getMenuItemById: () => null };
189 },
190 setApplicationMenu: (): void => undefined,
191 },
192 dialog: {
193 showMessageBoxSync: (): number => 0,
194 showErrorBox: (titel: string, text: string): void => {
195 zustand.fehlerkaesten.push({ titel, text });
196 },
197 showSaveDialog: (
198 ...werte: unknown[]
199 ): Promise<{ canceled: boolean; filePath: string | undefined }> => {
200 const [fenster, optionen] = werte.length > 1 ? werte : [undefined, werte[0]];
201 zustand.dialoge.push({ fenster, optionen: optionen as Dialogaufruf['optionen'] });
202 if (zustand.rohergebnis !== null) return Promise.resolve(zustand.rohergebnis);
203 return Promise.resolve({
204 canceled: zustand.speichernZiel === null,
205 filePath: zustand.speichernZiel ?? '',
206 });
207 },
208 showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> =>
209 Promise.resolve({ canceled: true, filePaths: [] }),
210 },
211 ipcMain: {
212 handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => {
213 behandler.set(kanal, fn);
214 },
215 on: (): void => undefined,
216 },
217 nativeTheme: {
218 shouldUseDarkColors: false,
219 shouldUseHighContrastColors: false,
220 on: (): void => undefined,
221 },
222 systemPreferences: { getColor: (): string => '#000000' },
223 session: {
224 defaultSession: {
225 webRequest: { onHeadersReceived: (): void => undefined },
226 setPermissionRequestHandler: (): void => undefined,
227 setPermissionCheckHandler: (): void => undefined,
228 },
229 },
230 shell: { openExternal: (): Promise<void> => Promise.resolve() },
231 };
232
233 return {
234 electron,
235 zustand,
236 behandler,
237 zuruecksetzen: (): void => {
238 zustand.fenster = null;
239 zustand.dokumenteFehlen = false;
240 zustand.speichernZiel = null;
241 zustand.rohergebnis = null;
242 zustand.dialoge = [];
243 zustand.fehlerkaesten = [];
244 zustand.vorlage = [];
245 zustand.schreibfehler = null;
246 zustand.dateinummerNull = false;
247 behandler.clear();
248 },
249 };
250 });
251
252 vi.mock('electron', () => welt.electron);
253
254 /**
255 * Ersatz fuer `node:fs/promises`: Oeffnen gelingt, erst das Schreiben der
256 * Bytes scheitert - wie bei einem vollen Datentraeger. Alles uebrige geht an
257 * das echte Dateisystem.
258 */
259 /**
260 * Ersatz fuer `node:fs`: alles echt; nur `statSync` meldet auf Wunsch die
261 * Dateinummer 0, wie ein Datentraeger, der keine Dateinummern fuehrt.
262 */
263 vi.mock('node:fs', async (echtesModul) => {
264 const echt = await echtesModul<typeof import('node:fs')>();
265 const statSync = (...werte: unknown[]): unknown => {
266 const angaben = (echt.statSync as (...w: unknown[]) => unknown)(...werte);
267 if (!welt.zustand.dateinummerNull || typeof angaben !== 'object' || angaben === null) {
268 return angaben;
269 }
270 const ohneNummer: unknown = Object.assign(
271 Object.create(Object.getPrototypeOf(angaben) as object | null) as object,
272 angaben,
273 );
274 const { ino } = angaben as { ino: unknown };
275 return Object.assign(ohneNummer as object, { ino: typeof ino === 'bigint' ? 0n : 0 });
276 };
277 return { ...echt, default: { ...echt, statSync }, statSync };
278 });
279
280 vi.mock('node:fs/promises', async (echtesModul) => {
281 const echt = await echtesModul<typeof import('node:fs/promises')>();
282 return {
283 ...echt,
284 default: echt,
285 async open(ziel: unknown, kennzeichen?: unknown): Promise<unknown> {
286 const griff = await echt.open(ziel as string, (kennzeichen as string | undefined) ?? 'r');
287 const fehler = welt.zustand.schreibfehler;
288 if (fehler === null) return griff;
289 return {
290 writeFile: (): Promise<never> => Promise.reject(fehler),
291 sync: (): Promise<void> => griff.sync(),
292 close: (): Promise<void> => griff.close(),
293 };
294 },
295 };
296 });
297
298 process.setMaxListeners(64);
299
300 const { menuevorlage } = await import('../../electron/menu');
301 const {
302 KEIN_PROTOKOLL,
303 abgeloestesProtokoll,
304 liesProtokolldatei,
305 protokollDateiname,
306 protokollkopie,
307 protokolliereStart,
308 startzeile,
309 } = await import('../../electron/protokoll');
310
311 /** Die Beschriftung, woertlich - mit Auslassungszeichen U+2026, nicht drei Punkten. */
312 const BESCHRIFTUNG = `Fehlerprotokoll speichern ${String.fromCodePoint(0x2026)}`;
313
314 /** Der Menueweg, wie ihn die Fehlerdialoge nennen - mit deutschen Anfuehrungszeichen. */
315 const MENUEWEG = '„Hilfe → Fehlerprotokoll speichern …“';
316
317 /**
318 * Groesse, ab der `protokolliere` die Datei abloest - aus dem Quelltext
319 * gelesen, weil sie nicht ausgefuehrt ist. Eine zweite Zahl hier liefe
320 * auseinander.
321 */
322 const MAX_BYTES = ((): number => {
323 const treffer = /^const MAX_BYTES = ([\d_]+);$/m.exec(
324 readFileSync('electron/protokoll.ts', 'utf8'),
325 );
326 expect(treffer, 'MAX_BYTES in electron/protokoll.ts nicht gefunden').not.toBeNull();
327 return Number(treffer![1]!.replace(/_/g, ''));
328 })();
329
330 // === 1. Ohne Fenster ========================================================
331
332 interface Aufzeichnung {
333 befehle: MenuCommand[];
334 seiten: string[];
335 kopien: number;
336 }
337
338 function hilfemenue(): { eintraege: MenuItemConstructorOptions[]; auf: Aufzeichnung } {
339 const auf: Aufzeichnung = { befehle: [], seiten: [], kopien: 0 };
340 const menue = menuevorlage(
341 (befehl) => auf.befehle.push(befehl),
342 false,
343 { aktuell: () => 100, setze: () => undefined, schritt: () => undefined },
344 '5.43.0',
345 (url) => auf.seiten.push(url),
346 () => {
347 auf.kopien += 1;
348 },
349 );
350 const hilfe = menue.find((e) => e.label === '&Hilfe')?.submenu;
351 expect(Array.isArray(hilfe), 'Das Hilfemenü fehlt').toBe(true);
352 return { eintraege: hilfe as MenuItemConstructorOptions[], auf };
353 }
354
355 function klicke(eintrag: MenuItemConstructorOptions | undefined): void {
356 expect(eintrag, 'Menüeintrag fehlt').toBeDefined();
357 eintrag?.click?.(undefined as never, undefined, undefined as never);
358 }
359
360 describe('Hilfemenü: Fehlerprotokoll speichern …', () => {
361 it('steht unmittelbar nach "Kurzhilfe" und vor dem Trennstrich', () => {
362 const { eintraege } = hilfemenue();
363 const kurzhilfe = eintraege.findIndex((e) => e.label === 'Kurzhilfe');
364 expect(kurzhilfe, 'Kurzhilfe fehlt').toBeGreaterThanOrEqual(0);
365 expect(eintraege[kurzhilfe + 1]?.label).toBe(BESCHRIFTUNG);
366 expect(eintraege[kurzhilfe + 2]?.type).toBe('separator');
367 });
368
369 it('traegt das Auslassungszeichen und keine drei Punkte', () => {
370 const { eintraege } = hilfemenue();
371 const beschriftungen = eintraege.map((e) => e.label);
372 expect(beschriftungen).toContain(BESCHRIFTUNG);
373 expect(beschriftungen).not.toContain('Fehlerprotokoll speichern ...');
374 expect(beschriftungen).not.toContain('Fehlerprotokoll speichern...');
375 });
376
377 it('ruft beim Anklicken genau einmal die Rueckruffunktion des Hauptprozesses', () => {
378 const { eintraege, auf } = hilfemenue();
379 klicke(eintraege.find((e) => e.label === BESCHRIFTUNG));
380 expect(auf.kopien).toBe(1);
381 });
382
383 it('schickt dabei keinen Befehl an den Renderer und oeffnet keine Seite', () => {
384 // Kein IPC-Kanal und kein Rendereranteil: Quelle und Ziel entscheidet
385 // allein der Hauptprozess.
386 const { eintraege, auf } = hilfemenue();
387 klicke(eintraege.find((e) => e.label === BESCHRIFTUNG));
388 expect(auf.befehle).toEqual([]);
389 expect(auf.seiten).toEqual([]);
390 });
391
392 it('laesst die Kurzhilfe bei ihrem Befehl', () => {
393 // Grenzwache: Der neue Eintrag hat die Kurzhilfe nicht verdraengt.
394 const { eintraege, auf } = hilfemenue();
395 klicke(eintraege.find((e) => e.label === 'Kurzhilfe'));
396 expect(auf.befehle).toEqual(['hilfe']);
397 expect(auf.kopien).toBe(0);
398 });
399 });
400
401 /** Lesefunktion ueber eine Tafel - `null` heisst: Datei gibt es nicht. */
402 function tafel(inhalte: Record<string, string>): (pfad: string) => string | null {
403 return (pfad) => inhalte[pfad] ?? null;
404 }
405
406 const DATEI = path.join('C:', 'Benutzer', 'fehlerprotokoll.log');
407
408 describe('Kopie des Fehlerprotokolls: Zusammensetzung', () => {
409 it('stellt die abgeloeste Datei vor die laufende - in zeitlicher Reihenfolge', () => {
410 const lies = tafel({
411 [abgeloestesProtokoll(DATEI)]: '[2026-09-01T08:00:00.000Z] Start: alt\n',
412 [DATEI]: '[2026-09-17T08:00:00.000Z] Start: neu\n',
413 });
414 expect(protokollkopie(DATEI, lies)).toBe(
415 '[2026-09-01T08:00:00.000Z] Start: alt\n[2026-09-17T08:00:00.000Z] Start: neu\n',
416 );
417 });
418
419 it('nennt die abgeloeste Datei <Protokoll>.alt', () => {
420 // Dass die Rotation sie unter genau diesem Namen ablegt, bewacht der Fall
421 // "liest die abgeloeste Datei unter dem Namen, unter dem die Rotation sie
422 // ablegt" am echten Hauptprozess.
423 expect(abgeloestesProtokoll(DATEI)).toBe(`${DATEI}.alt`);
424 });
425
426 it('gibt ohne abgeloeste Datei die laufende unveraendert wieder', () => {
427 const inhalt = '[2026-09-17T08:00:00.000Z] Start: Version 5.43.0\n stapelzeile\n';
428 expect(protokollkopie(DATEI, tafel({ [DATEI]: inhalt }))).toBe(inhalt);
429 });
430
431 it('gibt die abgeloeste Datei wieder, wenn nur sie besteht', () => {
432 const inhalt = '[2026-09-01T08:00:00.000Z] Start: alt\n';
433 expect(protokollkopie(DATEI, tafel({ [abgeloestesProtokoll(DATEI)]: inhalt }))).toBe(inhalt);
434 });
435
436 it('trennt beide Teile mit einem Zeilenumbruch, auch wenn der erste ohne endet', () => {
437 // Sonst klebte die erste Zeile der laufenden Datei an der letzten der
438 // abgeloesten, und die Zeitmarke stuende mitten in der Zeile.
439 const lies = tafel({
440 [abgeloestesProtokoll(DATEI)]: '[2026-09-01T08:00:00.000Z] Start: alt',
441 [DATEI]: '[2026-09-17T08:00:00.000Z] Start: neu\n',
442 });
443 expect(protokollkopie(DATEI, lies).split('\n')).toEqual([
444 '[2026-09-01T08:00:00.000Z] Start: alt',
445 '[2026-09-17T08:00:00.000Z] Start: neu',
446 '',
447 ]);
448 });
449
450 it('schreibt ohne Protokoll eine Zeile, dass nichts protokolliert wurde', () => {
451 expect(protokollkopie(DATEI, tafel({}))).toBe(`${KEIN_PROTOKOLL}\n`);
452 expect(KEIN_PROTOKOLL).toMatch(/nichts protokolliert/);
453 });
454
455 it('behandelt leere Dateien wie fehlende', () => {
456 const lies = tafel({ [abgeloestesProtokoll(DATEI)]: '', [DATEI]: '' });
457 expect(protokollkopie(DATEI, lies)).toBe(`${KEIN_PROTOKOLL}\n`);
458 });
459
460 it('fuegt einem vorhandenen Protokoll nichts hinzu', () => {
461 // Die Kopie ist eine Kopie: Wer sie beilegt, legt das Protokoll bei.
462 const inhalt = '[2026-09-17T08:00:00.000Z] Start: neu\n';
463 expect(protokollkopie(DATEI, tafel({ [DATEI]: inhalt }))).not.toContain(KEIN_PROTOKOLL);
464 });
465
466 it('liest nur die beiden festen Dateien', () => {
467 const gelesen: string[] = [];
468 protokollkopie(DATEI, (pfad) => {
469 gelesen.push(pfad);
470 return null;
471 });
472 expect([...gelesen].sort()).toEqual([DATEI, abgeloestesProtokoll(DATEI)].sort());
473 });
474
475 it('laesst einen Lesefehler durch, statt "nichts protokolliert" zu behaupten', () => {
476 const gesperrt = Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' });
477 expect(() =>
478 protokollkopie(DATEI, () => {
479 throw gesperrt;
480 }),
481 ).toThrow(gesperrt);
482 });
483 });
484
485 describe('Kopie des Fehlerprotokolls: Lesen von der Platte', () => {
486 let ordner = '';
487
488 beforeEach(() => {
489 ordner = mkdtempSync(path.join(os.tmpdir(), 'lsa-protokollkopie-'));
490 });
491
492 afterEach(() => {
493 rmSync(ordner, { recursive: true, force: true });
494 });
495
496 it('liefert den Inhalt einer vorhandenen Datei', () => {
497 const datei = path.join(ordner, 'fehlerprotokoll.log');
498 writeFileSync(datei, 'Zeile mit Umlaut: Größe\n', 'utf8');
499 expect(liesProtokolldatei(datei)).toBe('Zeile mit Umlaut: Größe\n');
500 });
501
502 it('liefert null, wenn die Datei fehlt', () => {
503 expect(liesProtokolldatei(path.join(ordner, 'gibt-es-nicht.log'))).toBeNull();
504 });
505
506 it('wirft bei jedem anderen Fehlschlag', () => {
507 // Ein Ordner an der Stelle der Datei: Lesen scheitert, aber nicht mit
508 // ENOENT. "Nichts protokolliert" waere hier eine falsche Auskunft.
509 const verzeichnis = path.join(ordner, 'fehlerprotokoll.log');
510 mkdirSync(verzeichnis);
511 let geworfen: unknown = null;
512 try {
513 liesProtokolldatei(verzeichnis);
514 } catch (fehler) {
515 geworfen = fehler;
516 }
517 expect((geworfen as NodeJS.ErrnoException | null)?.code).toBe('EISDIR');
518 });
519 });
520
521 describe('Kopie des Fehlerprotokolls: vorgeschlagener Dateiname', () => {
522 it('traegt das oertliche Datum', () => {
523 expect(protokollDateiname(new Date(2026, 8, 17, 12, 0))).toBe(
524 'LSA-Planer-Fehlerprotokoll-2026-09-17.txt',
525 );
526 });
527
528 it('nimmt kurz nach Mitternacht den oertlichen Tag, nicht den nach Weltzeit', () => {
529 // Oestlich von Greenwich - also auch in Deutschland - liegt 00:30 Uhr
530 // oertlich noch am Vortag nach Weltzeit. `toISOString` nannte den Vortag.
531 expect(protokollDateiname(new Date(2026, 0, 1, 0, 30))).toBe(
532 'LSA-Planer-Fehlerprotokoll-2026-01-01.txt',
533 );
534 });
535
536 it('nimmt kurz vor Mitternacht den oertlichen Tag, nicht den nach Weltzeit', () => {
537 // Westlich von Greenwich das Gegenstueck; einer der beiden Faelle greift
538 // in jeder Zeitzone ausser der Weltzeit selbst.
539 expect(protokollDateiname(new Date(2025, 11, 31, 23, 30))).toBe(
540 'LSA-Planer-Fehlerprotokoll-2025-12-31.txt',
541 );
542 });
543 });
544
545 describe('Startzeile des Protokolls', () => {
546 const UMGEBUNG = {
547 version: '5.43.0',
548 electron: '44.3.0',
549 chromium: '140.0.0.0',
550 plattform: 'win32',
551 architektur: 'x64',
552 };
553
554 it('nennt Fassung, Laufzeit und Plattform - und sonst nichts', () => {
555 expect(startzeile(UMGEBUNG)).toBe(
556 'Version 5.43.0 · Electron 44.3.0 · Chromium 140.0.0.0 · win32 x64',
557 );
558 });
559
560 it('haengt kein weiteres Glied an', () => {
561 /*
562 * Vier Glieder: Fassung, Electron, Chromium, Plattform mit Architektur.
563 * Bis 5.43.1 folgte ein fuenftes, das sagte, wie das Programm
564 * eingerichtet ist. Seit 5.43.2 nennt die Zeile das nicht mehr; wo
565 * Windows die Dateien des Benutzerprofils umleitet, fuehrt der
566 * Menueeintrag "Fehlerprotokoll speichern …" trotzdem zur Datei.
567 */
568 expect(startzeile(UMGEBUNG).split(' · ')).toEqual([
569 'Version 5.43.0',
570 'Electron 44.3.0',
571 'Chromium 140.0.0.0',
572 'win32 x64',
573 ]);
574 });
575
576 describe('protokolliereStart', () => {
577 let konsole: string[] = [];
578
579 beforeEach(() => {
580 welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-startzeile-'));
581 konsole = [];
582 vi.spyOn(console, 'error').mockImplementation((text: unknown) => {
583 konsole.push(String(text));
584 });
585 });
586
587 afterEach(() => {
588 vi.restoreAllMocks();
589 rmSync(welt.zustand.benutzerdaten, { recursive: true, force: true });
590 });
591
592 it('schreibt die Angaben des laufenden Prozesses', () => {
593 protokolliereStart();
594 const zeile = konsole.find((text) => text.startsWith('Start: '));
595 expect(zeile, 'die Startzeile auf der Konsole').toBeDefined();
596 expect(zeile).toContain('Version 5.43.0');
597 expect(zeile).toContain(`Electron ${process.versions.electron}`);
598 expect(zeile).toContain(`Chromium ${process.versions.chrome}`);
599 expect(
600 String(zeile).endsWith(` · ${process.platform} ${process.arch}`),
601 `das letzte Glied von "${String(zeile)}"`,
602 ).toBe(true);
603 });
604 });
605 });
606
607 // === 2. Am echten Hauptprozess ==============================================
608
609 describe('Hauptprozess: Fehlerprotokoll speichern …', () => {
610 let ablage = '';
611 let ausnahmehorcher: ((...werte: unknown[]) => void) | undefined;
612
613 async function starteHauptprozess(): Promise<void> {
614 vi.resetModules();
615 welt.zuruecksetzen();
616 const vorher = process.listeners('uncaughtException');
617 await import('../../electron/main');
618 for (let i = 0; i < 5; i += 1) await Promise.resolve();
619 ausnahmehorcher = process
620 .listeners('uncaughtException')
621 .find((h) => !vorher.includes(h)) as typeof ausnahmehorcher;
622 }
623
624 function protokolldatei(): string {
625 return path.join(welt.zustand.benutzerdaten, 'fehlerprotokoll.log');
626 }
627
628 function hilfeAusVorlage(): MenuItemConstructorOptions[] {
629 return (welt.zustand.vorlage.find((e) => e.label === '&Hilfe')?.submenu ??
630 []) as MenuItemConstructorOptions[];
631 }
632
633 /** Hilfe -> "Fehlerprotokoll speichern …" anklicken. */
634 function klickeMenueeintrag(): void {
635 const eintrag = hilfeAusVorlage().find((e) => e.label === BESCHRIFTUNG);
636 expect(eintrag, 'Der Hauptprozess baut den Menüeintrag nicht ein').toBeDefined();
637 eintrag?.click?.(undefined as never, undefined, undefined as never);
638 }
639
640 /** Wartet, bis der Vorgang hinter dem Menueeintrag sichtbar abgeschlossen ist. */
641 async function warteAuf(bedingung: () => boolean): Promise<void> {
642 await vi.waitFor(() => {
643 expect(bedingung()).toBe(true);
644 });
645 }
646
647 /** Laesst Zusagen und Ein-/Ausgabe auslaufen, wenn nichts geschehen soll. */
648 async function lassAuslaufen(): Promise<void> {
649 for (let i = 0; i < 5; i += 1) {
650 await new Promise((fertig) => setImmediate(fertig));
651 }
652 }
653
654 beforeEach(async () => {
655 vi.spyOn(console, 'error').mockImplementation(() => undefined);
656 ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-protokollziel-'));
657 welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-protokollquelle-'));
658 welt.zustand.dokumente = mkdtempSync(path.join(os.tmpdir(), 'lsa-dokumente-'));
659 await starteHauptprozess();
660 });
661
662 afterEach(() => {
663 vi.restoreAllMocks();
664 for (const ordner of [ablage, welt.zustand.benutzerdaten, welt.zustand.dokumente]) {
665 rmSync(ordner, { recursive: true, force: true });
666 }
667 });
668
669 describe('der Dialog', () => {
670 it('oeffnet den Speichern-Dialog am Hauptfenster', async () => {
671 klickeMenueeintrag();
672 await warteAuf(() => welt.zustand.dialoge.length === 1);
673
674 expect(welt.zustand.dialoge[0]?.fenster).toBe(welt.zustand.fenster);
675 });
676
677 /*
678 * Zum Datum in den beiden folgenden Faellen: Das Programm liest die Uhr
679 * selbst, der Fall ein zweites Mal. Zwischen beiden Lesungen liegen nur
680 * wenige Mikroaufgaben - der Klick laeuft bis zum Dialog in einem Zug, und
681 * `vi.waitFor` prueft zuerst ohne Pause -, also weniger als eine
682 * Millisekunde. Faellt Mitternacht dazwischen, naennten beide Lesungen
683 * verschiedene Tage, obwohl das Programm richtig rechnet. Beobachtet ist
684 * das nicht; verglichen wird trotzdem gegen die Uhr vor UND nach dem Klick,
685 * wie in tests/tools/pruefsummendatum.test.ts (Fassung 5.43.0,
686 * Befund 27).
687 */
688 it('schlaegt eine Textdatei mit oertlichem Datum im Dokumente-Ordner vor', async () => {
689 const vorher = protokollDateiname(new Date());
690 klickeMenueeintrag();
691 await warteAuf(() => welt.zustand.dialoge.length === 1);
692 const nachher = protokollDateiname(new Date());
693
694 const optionen = welt.zustand.dialoge[0]?.optionen;
695 // Zwei Werte, falls der Lauf ueber Mitternacht faellt.
696 expect([
697 path.join(welt.zustand.dokumente, vorher),
698 path.join(welt.zustand.dokumente, nachher),
699 ]).toContain(optionen?.defaultPath);
700 expect(optionen?.filters).toEqual([{ name: 'Textdatei', extensions: ['txt'] }]);
701 expect(optionen?.properties).toContain('showOverwriteConfirmation');
702 });
703
704 it('schlaegt nur den Dateinamen vor, wenn der Dokumente-Ordner nicht zu ermitteln ist', async () => {
705 welt.zustand.dokumenteFehlen = true;
706
707 const vorher = protokollDateiname(new Date());
708 klickeMenueeintrag();
709 await warteAuf(() => welt.zustand.dialoge.length === 1);
710 const nachher = protokollDateiname(new Date());
711
712 // Zwei Werte, falls der Lauf ueber Mitternacht faellt.
713 expect([vorher, nachher]).toContain(welt.zustand.dialoge[0]?.optionen.defaultPath);
714 expect(welt.zustand.fehlerkaesten).toEqual([]);
715 });
716
717 it('schreibt nichts, wenn der Anwender abbricht', async () => {
718 welt.zustand.speichernZiel = null;
719
720 klickeMenueeintrag();
721 await warteAuf(() => welt.zustand.dialoge.length === 1);
722 await lassAuslaufen();
723
724 expect(readdirSync(ablage)).toEqual([]);
725 expect(readdirSync(welt.zustand.dokumente)).toEqual([]);
726 expect(welt.zustand.fehlerkaesten).toEqual([]);
727 });
728
729 it('bricht still ab, wenn der Dialog ohne Abbruch kein Ziel liefert', async () => {
730 // Die Typbeschreibung verspricht eine Zeichenkette. Kommt trotzdem
731 // `undefined`, wirft `path.resolve` in `nacheinanderJeZiel`, und ohne die
732 // Pruefung auf ein leeres Ziel stuende ein Fehlerkasten mit der
733 // englischen Meldung des Laufzeitsystems da - obwohl nichts gewaehlt ist.
734 welt.zustand.rohergebnis = { canceled: false, filePath: undefined };
735
736 klickeMenueeintrag();
737 await warteAuf(() => welt.zustand.dialoge.length === 1);
738 await lassAuslaufen();
739
740 expect(welt.zustand.fehlerkaesten).toEqual([]);
741 expect(readdirSync(ablage)).toEqual([]);
742 });
743 });
744
745 describe('die Kopie', () => {
746 it('schreibt das Protokoll an den gewaehlten Ort', async () => {
747 const ziel = path.join(ablage, 'protokoll.txt');
748 welt.zustand.speichernZiel = ziel;
749
750 klickeMenueeintrag();
751 await warteAuf(() => existsSync(ziel));
752
753 // Die Startzeile hat protokolliereStart beim Hochfahren geschrieben.
754 expect(readFileSync(ziel, 'utf8')).toBe(readFileSync(protokolldatei(), 'utf8'));
755 expect(readFileSync(ziel, 'utf8')).toContain('Start: Version 5.43.0');
756 expect(readdirSync(ablage), 'eine Nachbardatei ist liegen geblieben').toEqual([
757 'protokoll.txt',
758 ]);
759 });
760
761 it('stellt die abgeloeste Datei davor', async () => {
762 writeFileSync(`${protokolldatei()}.alt`, '[2026-09-01T08:00:00.000Z] Start: alt\n', 'utf8');
763 const ziel = path.join(ablage, 'protokoll.txt');
764 welt.zustand.speichernZiel = ziel;
765
766 klickeMenueeintrag();
767 await warteAuf(() => existsSync(ziel));
768
769 const kopie = readFileSync(ziel, 'utf8');
770 expect(kopie.startsWith('[2026-09-01T08:00:00.000Z] Start: alt\n')).toBe(true);
771 expect(kopie).toContain('Start: Version 5.43.0');
772 });
773
774 it('liest die abgeloeste Datei unter dem Namen, unter dem die Rotation sie ablegt', async () => {
775 /*
776 * Fassung 5.43.0, Befund 21: Der Fall darueber schreibt `.alt`
777 * selbst, bindet also nur die Leseseite an die Endung. Den Zweig, in dem
778 * `protokolliere` die volle Datei umbenennt, nahm kein Fall - benannte die
779 * Rotation anders, als die Kopie liest, blieb alles gruen, und der aeltere
780 * Teil fehlte in der Kopie. Hier rotiert das Programm selbst.
781 */
782 const ALTE_ZEILE = '[2026-09-01T08:00:00.000Z] Start: vor der Abloesung\n';
783 const fuellzeile = `${'x'.repeat(99)}\n`;
784 const fuellung = fuellzeile.repeat(Math.ceil(MAX_BYTES / fuellzeile.length));
785 writeFileSync(protokolldatei(), `${ALTE_ZEILE}${fuellung}`, 'utf8');
786
787 expect(ausnahmehorcher, 'kein Horcher auf uncaughtException').toBeDefined();
788 ausnahmehorcher?.(new Error('Pruefstand'), 'uncaughtException');
789 // Die Rotation hat stattgefunden: Die laufende Datei ist neu angelegt.
790 expect(readFileSync(protokolldatei(), 'utf8')).not.toContain('vor der Abloesung');
791
792 const ziel = path.join(ablage, 'protokoll.txt');
793 welt.zustand.speichernZiel = ziel;
794 klickeMenueeintrag();
795 await warteAuf(() => existsSync(ziel));
796
797 const kopie = readFileSync(ziel, 'utf8');
798 expect(kopie.startsWith(ALTE_ZEILE), 'der abgeloeste Teil fehlt in der Kopie').toBe(true);
799 expect(kopie.indexOf('Pruefstand')).toBeGreaterThan(fuellung.length);
800 });
801
802 it('schreibt ohne Protokoll eine Kopie mit dem Vermerk', async () => {
803 rmSync(protokolldatei(), { force: true });
804 const ziel = path.join(ablage, 'protokoll.txt');
805 welt.zustand.speichernZiel = ziel;
806
807 klickeMenueeintrag();
808 await warteAuf(() => existsSync(ziel));
809
810 expect(readFileSync(ziel, 'utf8')).toBe(`${KEIN_PROTOKOLL}\n`);
811 expect(welt.zustand.fehlerkaesten).toEqual([]);
812 });
813
814 it('nimmt das Ziel nicht in die Freigabeliste auf', async () => {
815 const ziel = path.join(ablage, 'protokoll.txt');
816 welt.zustand.speichernZiel = ziel;
817
818 klickeMenueeintrag();
819 await warteAuf(() => existsSync(ziel));
820
821 const geschrieben = (await welt.behandler.get(IPC.writeFile)?.(null, {
822 filePath: ziel,
823 data: 'vom Renderer',
824 })) as SaveFileResult;
825 const gelesen = (await welt.behandler.get(IPC.readFile)?.(null, ziel)) as OpenFileResult;
826
827 expect(geschrieben.ok, 'der Renderer darf die Kopie beschreiben').toBe(false);
828 expect(gelesen.ok, 'der Renderer darf die Kopie lesen').toBe(false);
829 expect(readFileSync(ziel, 'utf8')).not.toContain('vom Renderer');
830 });
831 });
832
833 describe('keine der beiden Protokolldateien als Ziel', () => {
834 /*
835 * Fassung 5.43.0, Befund 11: Die Kurzhilfe zeigt den Pfad des
836 * Protokolls als auswaehlbaren Text, gleich neben der Bitte um eine Kopie.
837 * Wer ihn in den Dialog einfuegt und das Ueberschreiben bestaetigt, liess
838 * `schreibeUnteilbar` das laufende Protokoll durch "alt + laufend"
839 * ersetzen; die naechste Kopie enthielt den alten Teil doppelt und nicht
840 * mehr in zeitlicher Folge. Mit `.alt` als Ziel wurde der laufende Teil
841 * doppelt.
842 */
843 const ALT = '[2026-09-01T08:00:00.000Z] Start: alt\n';
844
845 /** Inhalt beider Protokolldateien und alles, was im Ordner liegt. */
846 function bestand(): { laufend: string; alt: string | null; dateien: string[] } {
847 const alt = `${protokolldatei()}.alt`;
848 return {
849 laufend: readFileSync(protokolldatei(), 'utf8'),
850 alt: existsSync(alt) ? readFileSync(alt, 'utf8') : null,
851 dateien: readdirSync(welt.zustand.benutzerdaten).sort(),
852 };
853 }
854
855 async function lehntAb(ziel: string): Promise<void> {
856 const vorher = bestand();
857 welt.zustand.speichernZiel = ziel;
858
859 klickeMenueeintrag();
860 await warteAuf(() => welt.zustand.fehlerkaesten.length > 0);
861 await lassAuslaufen();
862
863 expect(welt.zustand.fehlerkaesten).toEqual([
864 {
865 titel: 'Fehlerprotokoll nicht gespeichert',
866 text:
867 'Die Kopie kann nicht an die Stelle des Protokolls selbst geschrieben werden. ' +
868 'Bitte wählen Sie einen anderen Ort oder Dateinamen.',
869 },
870 ]);
871 // Nichts geschrieben, auch keine liegengebliebene Nachbardatei `.teil`.
872 expect(bestand()).toEqual(vorher);
873 }
874
875 it('lehnt die laufende Datei ab', async () => {
876 writeFileSync(`${protokolldatei()}.alt`, ALT, 'utf8');
877 await lehntAb(protokolldatei());
878 });
879
880 it('lehnt die abgeloeste Datei ab', async () => {
881 writeFileSync(`${protokolldatei()}.alt`, ALT, 'utf8');
882 await lehntAb(`${protokolldatei()}.alt`);
883 });
884
885 it('lehnt die abgeloeste Datei auch ab, solange es sie noch nicht gibt', async () => {
886 // Hier traegt allein der Pfad: Eine Datei, die es nicht gibt, hat keine
887 // Dateikennung.
888 await lehntAb(`${protokolldatei()}.alt`);
889 });
890
891 it.runIf(process.platform === 'win32')(
892 'lehnt sie unter Windows auch in anderer Schreibweise ab',
893 async () => {
894 // Unter Windows ist FEHLERPROTOKOLL.LOG.ALT dieselbe Datei. Solange es
895 // sie nicht gibt, traegt wieder allein der Pfad.
896 await lehntAb(path.join(welt.zustand.benutzerdaten, 'FEHLERPROTOKOLL.LOG.ALT'));
897 },
898 );
899
900 it('erkennt die laufende Datei auch unter einem anderen Pfad', async () => {
901 // Derselbe Ordner ueber eine Verzeichnisverknuepfung: Der Pfad ist ein
902 // anderer, die Datei dieselbe - wie unter einem 8.3-Kurznamen. Hier
903 // traegt allein die Dateikennung.
904 const verknuepfung = path.join(ablage, 'verknuepfung');
905 symlinkSync(welt.zustand.benutzerdaten, verknuepfung, 'junction');
906 await lehntAb(path.join(verknuepfung, 'fehlerprotokoll.log'));
907 });
908
909 it('haelt zwei Dateien ohne Dateinummer nicht fuer dieselbe', async () => {
910 // Meldet ein Datentraeger fuer jede Datei die Dateinummer 0, waeren auf
911 // ihm alle Dateien "dieselbe" - und jede Kopie neben dem Protokoll
912 // scheiterte mit einer falschen Begruendung.
913 const ziel = path.join(ablage, 'protokoll.txt');
914 writeFileSync(ziel, 'ALTER INHALT', 'utf8');
915 expect(
916 statSync(ziel, { bigint: true }).dev,
917 'Ziel und Protokoll liegen nicht auf demselben Datentraeger - der Fall prueft dann nichts',
918 ).toBe(statSync(protokolldatei(), { bigint: true }).dev);
919 welt.zustand.dateinummerNull = true;
920 welt.zustand.speichernZiel = ziel;
921
922 klickeMenueeintrag();
923 await warteAuf(() => readFileSync(ziel, 'utf8') !== 'ALTER INHALT');
924
925 expect(welt.zustand.fehlerkaesten).toEqual([]);
926 expect(readFileSync(ziel, 'utf8')).toBe(readFileSync(protokolldatei(), 'utf8'));
927 });
928 });
929
930 describe('Fehlschlaege', () => {
931 it('behaelt eine vorhandene Datei am Ziel und meldet den Grund, wenn das Schreiben scheitert', async () => {
932 const ziel = path.join(ablage, 'protokoll.txt');
933 writeFileSync(ziel, 'ALTER INHALT', 'utf8');
934 welt.zustand.speichernZiel = ziel;
935 welt.zustand.schreibfehler = Object.assign(new Error('no space left on device'), {
936 code: 'ENOSPC',
937 });
938
939 klickeMenueeintrag();
940 await warteAuf(() => welt.zustand.fehlerkaesten.length === 1);
941
942 expect(readFileSync(ziel, 'utf8'), 'unteilbar geschrieben wurde nicht').toBe('ALTER INHALT');
943 expect(readdirSync(ablage)).toEqual(['protokoll.txt']);
944 const [kasten] = welt.zustand.fehlerkaesten;
945 expect(kasten?.titel).toBe('Fehlerprotokoll nicht gespeichert');
946 expect(kasten?.text).toContain('Auf dem Datenträger ist kein Platz mehr frei.');
947 });
948
949 it('meldet, wenn sich das Protokoll nicht lesen laesst, und schreibt keine Kopie', async () => {
950 // Ein Ordner an der Stelle der Datei: Lesen scheitert, aber nicht mit
951 // ENOENT. Eine Kopie "nichts protokolliert" waere eine falsche Auskunft.
952 rmSync(protokolldatei(), { force: true });
953 mkdirSync(protokolldatei());
954 const ziel = path.join(ablage, 'protokoll.txt');
955 welt.zustand.speichernZiel = ziel;
956
957 klickeMenueeintrag();
958 await warteAuf(() => welt.zustand.fehlerkaesten.length === 1);
959 await lassAuslaufen();
960
961 expect(existsSync(ziel)).toBe(false);
962 const [kasten] = welt.zustand.fehlerkaesten;
963 expect(kasten?.titel).toBe('Fehlerprotokoll nicht gespeichert');
964 expect(kasten?.text).toContain('ließ sich nicht lesen');
965 // Nicht die Schreibmeldung: "Kein Schreibrecht ... anderen Ordner" waere
966 // hier ein falscher Rat.
967 expect(kasten?.text).not.toContain('Wählen Sie einen anderen Ordner');
968 });
969 });
970
971 describe('Fehlerdialoge nennen den Menüweg', () => {
972 /**
973 * Der Menueweg, wie der Hauptprozess ihn wirklich eingebaut hat - nicht
974 * eine zweite Abschrift der Beschriftung. Zwei Schreibweisen desselben
975 * Eintrags liefen auseinander, und der Anwender suchte einen, den es so
976 * nicht gibt.
977 */
978 function menuewegAusVorlage(): string {
979 const hilfe = hilfeAusVorlage();
980 const kurzhilfe = hilfe.findIndex((e) => e.label === 'Kurzhilfe');
981 return `„Hilfe → ${String(hilfe[kurzhilfe + 1]?.label)}“`;
982 }
983
984 it('beim unerwarteten Ende der Anzeige', () => {
985 welt.zustand.fenster?.webContents.ausloesen(
986 'render-process-gone',
987 {},
988 { reason: 'crashed', exitCode: 1 },
989 );
990
991 const [kasten] = welt.zustand.fehlerkaesten;
992 expect(kasten?.titel).toBe('Die Anzeige wurde unerwartet beendet');
993 expect(kasten?.text).toContain(MENUEWEG);
994 expect(kasten?.text).toContain(menuewegAusVorlage());
995 // Der Pfad bleibt stehen - meist stimmt er.
996 expect(kasten?.text).toContain(protokolldatei());
997 });
998
999 it('beim unerwarteten Fehler im Hauptprozess', () => {
1000 expect(ausnahmehorcher, 'kein Horcher auf uncaughtException').toBeDefined();
1001 ausnahmehorcher?.(new Error('Pruefstand'), 'uncaughtException');
1002
1003 const [kasten] = welt.zustand.fehlerkaesten;
1004 expect(kasten?.titel).toBe('Unerwarteter Fehler');
1005 expect(kasten?.text).toContain(MENUEWEG);
1006 expect(kasten?.text).toContain(menuewegAusVorlage());
1007 expect(kasten?.text).toContain(protokolldatei());
1008 });
1009 });
1010 });