import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { IPC } from '../../shared/ipc'; /** * Befund 15 zur Fassung 5.8.0. * * Der Hauptprozess fragte den Anzeigeprozess vor dem Schliessen, ob * ungespeicherte Aenderungen verworfen werden duerfen - und setzte auf die * ANTWORT eine Frist von fuenf Sekunden. Beantwortet wird die Rueckfrage aber * von einem Menschen: Der Anzeigeprozess sendet erst, wenn eine der drei * Schaltflaechen gewaehlt ist, beim Weg "Speichern und schliessen" sogar erst * nach dem Sichern. Wer laenger als fuenf Sekunden las, bekam eine zweite, * modale Meldung "Die Anwendung antwortet nicht." ueber den offenen Dialog * gelegt, mit dem Angebot "Trotzdem schließen". * * Zwei weitere Folgen traten ohne jedes Zutun ein: Nach Ablauf der Frist war * der Wartezustand geloescht, sodass die spaeter eintreffende echte Antwort * verpuffte und sich das Fenster trotz beantworteter Rueckfrage nicht mehr * schliessen liess; und jeder weitere Klick auf das Schliesskreuz legte einen * weiteren Zeitgeber an, der die Meldung spaeter erneut zeigte. * * Geprueft wird am Hauptprozess selbst: Electron ist ersetzt, das Fenster und * die Kanaele sind nachgebildet. */ 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 { const einmal: Horcher = (...werte: unknown[]) => { this.removeListener(name, einmal); fn(...werte); }; return this.on(name, einmal); } removeListener(name: string, fn: Horcher): this { const liste = (this.horcher.get(name) ?? []).filter((h) => h !== fn); this.horcher.set(name, liste); return this; } off(name: string, fn: Horcher): this { return this.removeListener(name, fn); } /** Loest ein Ereignis aus, wie Electron es taete. */ loese(name: string, ...werte: unknown[]): void { for (const fn of [...(this.horcher.get(name) ?? [])]) fn(...werte); } /** Wie viele Empfaenger haengen an diesem Ereignis? */ anzahl(name: string): number { return (this.horcher.get(name) ?? []).length; } } class Anzeigeinhalt extends Ereignisquelle { abgestuerzt = false; zerstoert = false; readonly gesendet: string[] = []; send(kanal: string): void { this.gesendet.push(kanal); } setZoomFactor(): void { /* Im Test ohne Wirkung. */ } setWindowOpenHandler(): void { /* Im Test ohne Wirkung. */ } isCrashed(): boolean { return this.abgestuerzt; } isDestroyed(): boolean { return this.zerstoert; } } const zustand = { fenster: null as Fenster | null, /** Alle Meldungskaesten, die der Hauptprozess gezeigt hat. */ meldungen: [] as Record[], /** Welche Schaltflaeche der Anwender im Meldungskasten waehlt. */ antwort: 0, benutzerdaten: '.', }; class Fenster extends Ereignisquelle { readonly webContents = new Anzeigeinhalt(); geschlossen = 0; 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; } /** * Wie in Electron: `close()` loest erst `close` aus und - wenn niemand * widerspricht - danach `closed`. Der Hauptprozess setzt sein Fenster * daraufhin auf null, und ein zweiter Schliessversuch laeuft ins Leere. */ close(): void { this.geschlossen += 1; let verhindert = false; this.loese('close', { preventDefault: (): void => { verhindert = true; }, }); if (!verhindert) this.loese('closed'); } static getAllWindows(): Fenster[] { return zustand.fenster === null ? [] : [zustand.fenster]; } } const behandler = new Map unknown>(); const empfaenger = new Map void>(); const electron = { app: { isPackaged: false, requestSingleInstanceLock: (): boolean => true, on: (): void => undefined, whenReady: (): Promise => Promise.resolve(), getPath: (): string => zustand.benutzerdaten, getVersion: (): string => '5.8.0', quit: (): void => undefined, }, BrowserWindow: Fenster, Menu: { buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }), setApplicationMenu: (): void => undefined, }, dialog: { showMessageBoxSync: (_fenster: unknown, optionen: Record): number => { zustand.meldungen.push(optionen); return zustand.antwort; }, showErrorBox: (): void => undefined, }, ipcMain: { handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => { behandler.set(kanal, fn); }, on: (kanal: string, fn: (...werte: unknown[]) => void): void => { empfaenger.set(kanal, fn); }, }, nativeTheme: { shouldUseDarkColors: false, on: (): void => undefined }, session: { defaultSession: { webRequest: { onHeadersReceived: (): void => undefined }, setPermissionRequestHandler: (): void => undefined, setPermissionCheckHandler: (): void => undefined, }, }, shell: { openExternal: (): Promise => Promise.resolve() }, }; return { electron, zustand, empfaenger, zuruecksetzen: (): void => { zustand.fenster = null; zustand.meldungen = []; zustand.antwort = 0; behandler.clear(); empfaenger.clear(); }, }; }); vi.mock('electron', () => welt.electron); // Der Hauptprozess meldet den Start ins Fehlerprotokoll; jeder Import legte // sonst weitere Empfaenger an, und die Konsole liefe voll. process.setMaxListeners(64); /** Laesst schwebende Zusagen durchlaufen, ohne einen Zeitgeber zu bewegen. */ async function takt(): Promise { for (let i = 0; i < 5; i += 1) await Promise.resolve(); } async function starteHauptprozess(): Promise { vi.resetModules(); welt.zuruecksetzen(); await import('../../electron/main'); await takt(); } function fenster(): { webContents: { gesendet: string[]; abgestuerzt: boolean; loese: (name: string) => void }; geschlossen: number; loese: (name: string, ...werte: unknown[]) => void; } { const gefunden = welt.zustand.fenster; expect(gefunden, 'Der Hauptprozess hat kein Fenster gebaut').not.toBeNull(); return gefunden as never; } /** Der Anzeigeprozess meldet ungespeicherte Aenderungen. */ function meldeUngespeichert(): void { welt.empfaenger.get(IPC.setDirty)?.(null, true); } /** Klick auf das Schliesskreuz. */ function klickeSchliesskreuz(): void { fenster().loese('close', { preventDefault: (): void => undefined }); } /** Der Anzeigeprozess antwortet - nachdem der Anwender gewaehlt hat. */ async function anzeigeAntwortet(erlaubt: boolean): Promise { welt.empfaenger.get(IPC.closeResponse)?.(null, erlaubt); await takt(); } function rueckfragen(): number { return fenster().webContents.gesendet.filter((k) => k === IPC.requestClose).length; } beforeEach(async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-schliessen-')); vi.useFakeTimers(); await starteHauptprozess(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe('Schliessabfrage: die Frist misst nicht die Bedenkzeit des Anwenders', () => { it('behauptet nicht nach fünf Sekunden, die Anwendung antworte nicht', async () => { // Der Anwender liest die drei Schaltflaechen - eine halbe Minute lang. meldeUngespeichert(); klickeSchliesskreuz(); expect(rueckfragen()).toBe(1); await vi.advanceTimersByTimeAsync(30_000); expect( welt.zustand.meldungen.map((m) => m['message']), 'Der Hauptprozess hat den antwortenden Anzeigeprozess für hängend erklärt', ).toEqual([]); expect(fenster().geschlossen).toBe(0); }); it('nimmt die Antwort auch dann noch an, wenn der Anwender lange überlegt hat', async () => { meldeUngespeichert(); klickeSchliesskreuz(); await vi.advanceTimersByTimeAsync(30_000); // Jetzt erst waehlt der Anwender "Ohne Speichern schließen". await anzeigeAntwortet(true); expect( fenster().geschlossen, 'Das Fenster ließ sich trotz beantworteter Rückfrage nicht schließen', ).toBe(1); }); it('stellt beim zweiten Klick auf das Schließkreuz keine zweite Rückfrage', async () => { meldeUngespeichert(); klickeSchliesskreuz(); klickeSchliesskreuz(); expect(rueckfragen(), 'Der Anzeigeprozess bekam die Rückfrage doppelt').toBe(1); await vi.advanceTimersByTimeAsync(30_000); expect(welt.zustand.meldungen).toEqual([]); // Und die eine Antwort schliesst das Fenster. await anzeigeAntwortet(true); expect(fenster().geschlossen).toBe(1); }); }); describe('Schliessabfrage: der Ausweg bleibt, haengt aber an einem Lebenszeichen', () => { it('fragt im Hauptprozess nach, sobald die Anzeige wirklich nicht mehr antwortet', async () => { welt.zustand.antwort = 1; // "Trotzdem schließen" meldeUngespeichert(); klickeSchliesskreuz(); // Chromiums Wache meldet die haengende Anzeige. fenster().webContents.loese('unresponsive'); await takt(); expect(welt.zustand.meldungen.map((m) => m['message'])).toEqual([ 'Die Anwendung antwortet nicht.', ]); expect(fenster().geschlossen).toBe(1); }); it('fragt eine abgestürzte Anzeige gar nicht erst', async () => { welt.zustand.antwort = 1; meldeUngespeichert(); fenster().webContents.abgestuerzt = true; klickeSchliesskreuz(); await takt(); expect(rueckfragen(), 'Eine abgestürzte Anzeige wurde noch befragt').toBe(0); expect(welt.zustand.meldungen.map((m) => m['message'])).toEqual([ 'Die Anwendung antwortet nicht.', ]); expect(fenster().geschlossen).toBe(1); }); it('zeigt die Warnung beim zweiten Klick erneut, wenn der Anwender die erste abgebrochen hat', async () => { meldeUngespeichert(); fenster().webContents.abgestuerzt = true; // Erster Versuch: der Anwender waehlt "Abbrechen" - das Fenster bleibt // offen, und genau dafuer ist der Ausweg da. welt.zustand.antwort = 0; klickeSchliesskreuz(); await takt(); expect(fenster().geschlossen).toBe(0); // Zweiter Versuch: jetzt waehlt er "Trotzdem schließen". welt.zustand.antwort = 1; klickeSchliesskreuz(); await takt(); expect( welt.zustand.meldungen.map((m) => m['message']), 'Der Ausweg war nach einmaligem Abbrechen versperrt: keine zweite Warnung', ).toEqual(['Die Anwendung antwortet nicht.', 'Die Anwendung antwortet nicht.']); expect( fenster().geschlossen, 'Das abgestürzte Fenster liess sich nur noch über den Task-Manager beenden', ).toBe(1); }); });