lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests electron protokollWeitergabe.test.ts
| 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 | }); |