import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { IPC, type OpenFileResult, type SaveFileResult } from '../../shared/ipc'; /** * Befunde 70 und 73 - beide an den Dateiwegen des Hauptprozesses. * * BEFUND 70: `readFile(..., 'utf8')` erzeugt eine einzige Zeichenkette. V8 * laesst hoechstens rund 512 MiB Zeichen zu; darueber wirft der Aufruf einen * `RangeError` OHNE `code`. `describe` deckt nur EACCES/EPERM, ENOSPC, ENOENT * und EBUSY ab und gab sonst `error.message` unveraendert zurueck - der Anwender * las "Invalid string length" und erfuhr nicht, dass die Datei schlicht zu gross * ist. Eine vorgelagerte Groessenpruefung gab es nicht. Der Dateidialog laesst * neben `.lsap` auch `.json` zu; ein Fehlgriff auf eine Datenausleitung von * mehreren hundert Megabyte genuegt. * * BEFUND 73: Der Name der Nachbardatei `.teil` ist fuenf Zeichen laenger * als das Ziel, NTFS laesst je Namensbestandteil aber 255 Zeichen zu. Bei einem * Zielnamen von 251 bis 255 Zeichen scheiterte das Oeffnen der Nachbardatei mit * ENOENT, und `describe` machte daraus "Die Datei oder der Ordner wurde nicht * gefunden." - eine Aussage, die hier falsch ist: Der Ordner besteht, und den * Namen hat der Dialog eben noch angenommen. * * Geprueft wird gegen ein echtes Verzeichnis. Ersetzt ist nur die Groessenangabe * des Dateisystems: Eine Datei von mehreren hundert Megabyte anzulegen, um eine * Schranke zu pruefen, waere die falsche Art von Genauigkeit. */ 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); } } 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; } } const zustand = { fenster: null as Fenster | null, benutzerdaten: '.', /** Was der Speichern-unter-Dialog zurueckgibt. */ speichernZiel: null as string | null, /** Was der Oeffnen-Dialog zurueckgibt. */ oeffnenZiel: null as string | null, /** Vorgetaeuschte Dateigroesse in Byte; `null` = die echte Angabe gilt. */ vorgetaeuschteGroesse: null as number | null, /** Scharfgestellter Wurf beim Lesen. */ lesefehler: null as Error | null, /** Wie oft wirklich gelesen wurde. */ lesevorgaenge: 0, }; class Fenster extends Ereignisquelle { readonly webContents = new Anzeigeinhalt(); constructor() { super(); zustand.fenster = this; } loadFile(): Promise { return Promise.resolve(); } 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: (): string => zustand.benutzerdaten, getVersion: (): string => '5.10.0', quit: (): void => undefined, }, BrowserWindow: Fenster, Menu: { buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }), setApplicationMenu: (): void => undefined, }, dialog: { showMessageBoxSync: (): number => 0, showErrorBox: (): void => undefined, showSaveDialog: (): Promise<{ canceled: boolean; filePath: string | undefined }> => Promise.resolve({ canceled: zustand.speichernZiel === null, filePath: zustand.speichernZiel ?? undefined, }), showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> => Promise.resolve({ canceled: zustand.oeffnenZiel === null, filePaths: zustand.oeffnenZiel === null ? [] : [zustand.oeffnenZiel], }), }, 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.speichernZiel = null; zustand.oeffnenZiel = null; zustand.vorgetaeuschteGroesse = null; zustand.lesefehler = null; zustand.lesevorgaenge = 0; behandler.clear(); }, }; }); vi.mock('electron', () => welt.electron); /** * Ersatz fuer `node:fs/promises`. * * Nur zwei Stellen sind ersetzt: die gemeldete Dateigroesse und der Wurf beim * Lesen. Alles uebrige - Oeffnen, Schreiben, Umbenennen, Entfernen - geht an das * echte Dateisystem, damit die Namenslaenge an NTFS gemessen wird und nicht an * einer Nachbildung. */ vi.mock('node:fs/promises', async (echtesModul) => { const echt = await echtesModul(); return { ...echt, default: echt, async stat(ziel: unknown): Promise { const angabe = await echt.stat(ziel as string); if (welt.zustand.vorgetaeuschteGroesse === null) return angabe; return { ...angabe, size: welt.zustand.vorgetaeuschteGroesse }; }, async readFile(ziel: unknown, kennzeichen?: unknown): Promise { welt.zustand.lesevorgaenge += 1; if (welt.zustand.lesefehler !== null) throw welt.zustand.lesefehler; return echt.readFile(ziel as string, kennzeichen as never); }, }; }); process.setMaxListeners(64); const STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße"}}'; let ablage = ''; async function starteHauptprozess(): Promise { vi.resetModules(); welt.zuruecksetzen(); await import('../../electron/main'); for (let i = 0; i < 5; i += 1) await Promise.resolve(); } /** "Datei > Öffnen" mit dem Dialog davor. */ async function oeffne(ziel: string): Promise { welt.zustand.oeffnenZiel = ziel; return (await welt.behandler.get(IPC.openFile)?.(null, [])) as OpenFileResult; } /** Der Kanal `IPC.readFile` auf einen bereits bestaetigten Pfad. */ async function lieseErneut(ziel: string): Promise { return (await welt.behandler.get(IPC.readFile)?.(null, ziel)) as OpenFileResult; } /** "Speichern unter" mit dem Dialog davor. */ async function speichereUnter(ziel: string, daten: string): Promise { welt.zustand.speichernZiel = ziel; return (await welt.behandler.get(IPC.saveFile)?.(null, { defaultName: path.basename(ziel), filters: [], data: daten, })) as SaveFileResult; } /** Strg+S auf einen bereits bestaetigten Pfad. */ async function speichere(ziel: string, daten: string): Promise { return (await welt.behandler.get(IPC.writeFile)?.(null, { filePath: ziel, data: daten, })) as SaveFileResult; } /** * Dieselben beiden Wege mit einer Nutzlast, die der Typ gar nicht zulaesst. * * Ueber die Bruecke kommt an, was der Anzeigeprozess sendet; der Typ * `string | Uint8Array` ist eine Zusage des Uebersetzers, keine Wache zur * Laufzeit. `toBuffer` faengt das ab. */ async function speichereUnterMitNutzlast(ziel: string, daten: unknown): Promise { welt.zustand.speichernZiel = ziel; return (await welt.behandler.get(IPC.saveFile)?.(null, { defaultName: path.basename(ziel), filters: [], data: daten, })) as SaveFileResult; } async function speichereMitNutzlast(ziel: string, daten: unknown): Promise { return (await welt.behandler.get(IPC.writeFile)?.(null, { filePath: ziel, data: daten, })) as SaveFileResult; } beforeEach(async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-hauptprozess-')); welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-hauptprozess-benutzer-')); await starteHauptprozess(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Öffnen: eine übergroße Datei wird auf Deutsch abgewiesen', () => { it('nennt beim Öffnen die tatsächliche und die zulässige Größe', async () => { const ziel = path.join(ablage, 'ausleitung.json'); writeFileSync(ziel, STAND, 'utf8'); welt.zustand.vorgetaeuschteGroesse = 600 * 1024 * 1024; const ergebnis = await oeffne(ziel); expect(ergebnis.ok).toBe(false); expect(ergebnis.error, 'die englische V8-Meldung erreicht den Anwender').not.toContain( 'Invalid string length', ); expect(ergebnis.error).toContain('600,0 MB'); expect(ergebnis.error).toContain('Grenze von 256 MB'); }); it('liest die Datei gar nicht erst ein', async () => { const ziel = path.join(ablage, 'ausleitung.json'); writeFileSync(ziel, STAND, 'utf8'); welt.zustand.vorgetaeuschteGroesse = 600 * 1024 * 1024; await oeffne(ziel); expect(welt.zustand.lesevorgaenge, 'die Datei wurde trotzdem gelesen').toBe(0); }); it('weist auch den Kanal für das erneute Lesen ab', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, STAND, 'utf8'); await oeffne(ziel); welt.zustand.vorgetaeuschteGroesse = 600 * 1024 * 1024; const ergebnis = await lieseErneut(path.resolve(ziel)); expect(ergebnis.ok).toBe(false); expect(ergebnis.error).toContain('Grenze von 256 MB'); }); it('lässt eine Projektdatei üblicher Größe unverändert durch', async () => { // Gegenprobe: Die Schranke liegt um ein Vielfaches ueber allem, was dieses // Programm schreiben kann - das groesste eingebettete Bild belegt 32 MiB. const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, STAND, 'utf8'); const ergebnis = await oeffne(ziel); expect(ergebnis.ok).toBe(true); expect(ergebnis.content).toBe(STAND); }); it('setzt einen deutschen Satz um eine Meldung, die nur die Laufzeitumgebung kennt', async () => { // Der Auffangzweig von `describe`: EIO, ELOOP, EMFILE und alles ohne `code` // kamen bis hierher unuebersetzt an der Oberflaeche an. const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, STAND, 'utf8'); welt.zustand.lesefehler = new RangeError('Invalid string length'); const ergebnis = await oeffne(ziel); expect(ergebnis.ok).toBe(false); expect(ergebnis.error, 'kein deutscher Satz vor der fremden Meldung').toMatch( /^Der Vorgang ist fehlgeschlagen\. Das System meldet: /, ); expect(ergebnis.error, 'die Ursache selbst darf nicht verschwinden').toContain( 'Invalid string length', ); }); }); /** Ein Name aus `laenge` Zeichen, wie ihn ein Anwender einfuegen kann. */ function langerName(laenge: number): string { const stamm = 'Knotenpunkt-Bahnhofstrasse-Suedring-Bauabschnitt-'; return stamm.repeat(Math.ceil(laenge / stamm.length)).slice(0, laenge); } describe('Speichern: ein zu langer Dateiname wird beim Namen genannt', () => { it('nennt den wirklichen Grund statt "wurde nicht gefunden"', async () => { // 251 Zeichen: Der Zielname geht, die Nachbardatei ".teil" kaeme auf // 256 und damit ueber die Grenze von NTFS. const ziel = path.join(ablage, langerName(251)); const ergebnis = await speichereUnter(ziel, STAND); expect(ergebnis.ok).toBe(false); expect(ergebnis.error, 'die irreführende Begründung steht noch').not.toBe( 'Die Datei oder der Ordner wurde nicht gefunden.', ); expect(ergebnis.error).toContain('Der Dateiname ist zu lang'); expect(ergebnis.error, 'die zulässige Länge wird nicht genannt').toContain('250 Zeichen'); }); it('lässt beim Abweisen nichts im Ordner zurück', async () => { const ziel = path.join(ablage, langerName(251)); await speichereUnter(ziel, STAND); expect(readdirSync(ablage)).toEqual([]); }); it('nennt denselben Grund beim Strg+S auf einen bestätigten Pfad', async () => { const ziel = path.join(ablage, langerName(255)); // Der Dialog bestaetigt den Pfad, bevor geschrieben wird. await speichereUnter(ziel, STAND); const ergebnis = await speichere(path.resolve(ziel), STAND); expect(ergebnis.ok).toBe(false); expect(ergebnis.error).toContain('Der Dateiname ist zu lang'); }); it('speichert bei 250 Zeichen weiterhin', async () => { // Gegenprobe: Genau bis hierher reicht es, und die Grenze wird nicht // strenger gezogen als das Dateisystem sie zieht. const ziel = path.join(ablage, langerName(250)); const ergebnis = await speichereUnter(ziel, STAND); expect(ergebnis.ok).toBe(true); expect(readFileSync(ziel, 'utf8')).toBe(STAND); expect(readdirSync(ablage)).toEqual([langerName(250)]); }); }); /** * Der Auffangzweig von `describe` legt um jede fremde Meldung den Satz "Der * Vorgang ist fehlgeschlagen. Das System meldet: ...". Das ist richtig fuer * Meldungen der Laufzeitumgebung - und falsch fuer die eine Stelle im * Hauptprozess, die selbst einen bereits fuer den Anwender geschriebenen * deutschen Satz wirft: `toBuffer`. Sonst liest der Anwender einen deutschen * Satz des eigenen Programms als Meldung des Systems. */ const NUTZLASTMELDUNG = 'Ungültige Daten: erwartet wird Text oder ein Bytefeld.'; describe('Speichern: eine Nutzlast, die weder Text noch Bytefeld ist', () => { it('gibt den eigenen deutschen Satz ohne einen zweiten davor aus', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); const ergebnis = await speichereUnterMitNutzlast(ziel, 42); expect(ergebnis.ok).toBe(false); expect(ergebnis.error, 'ein eigener Satz als Meldung des Systems ausgegeben').toBe( NUTZLASTMELDUNG, ); }); it('nennt denselben Satz beim Strg+S auf einen bestätigten Pfad', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); await speichereUnter(ziel, STAND); const ergebnis = await speichereMitNutzlast(path.resolve(ziel), { daten: 'nichts davon' }); expect(ergebnis.ok).toBe(false); expect(ergebnis.error).toBe(NUTZLASTMELDUNG); }); it('lässt die zuvor gesicherte Fassung unberührt', async () => { // Gegenprobe, auch am Altstand gruen: Der Wurf faellt vor dem ersten Byte, // weil `toBuffer` ausgewertet wird, bevor `schreibeUnteilbar` laeuft. Was // sich geaendert hat, ist allein die Meldung - nicht, was auf dem // Datentraeger steht. const ziel = path.join(ablage, 'knotenpunkt.lsap'); await speichereUnter(ziel, STAND); await speichereMitNutzlast(path.resolve(ziel), null); expect(readFileSync(ziel, 'utf8')).toBe(STAND); expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']); }); });