// @vitest-environment node /** * Das Unterstützungsangebot: die Adresse, der Weg nach außen, sein Ort. * * Drei Dinge sind hier zu sichern, und sie hängen nicht zusammen: * * 1. **Die Form der Adresse.** Sie ist heute leer und wird später von Hand * eingetragen. Die Prüfungen dürfen deshalb nicht vom heutigen Wert * abhängen – ein Test, der auf „leer“ besteht, machte aus der zugesagten * Einzeiler-Änderung einen roten Lauf. * 2. **Der Weg nach außen.** `shell.openExternal` reicht an das * Betriebssystem weiter; was dort ankommt, ist nicht mehr einzufangen. * Geöffnet werden darf deshalb ausschließlich die eingetragene Adresse, * und nichts sonst. * 3. **Der Ort des Angebots.** Es steht in „Über diese Software“ und * ausdrücklich nirgends sonst. Ein Einblenden mitten in der Lernsitzung * wäre genau das Muster, das dieses Projekt bei Streaks und Tagesziel-Ring * abgelehnt hat (`docs/entscheidung-motivation.md`). Ein Verhaltenstest * könnte das nicht beweisen – er könnte nur an einer Stelle nachsehen, an * der gerade niemand etwas eingebaut hat. Geprüft wird deshalb der * Quelltext. */ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UNTERSTUETZUNG_KNOPF, UNTERSTUETZUNG_URL, unterstuetzungsziel, } from '../src/shared/unterstuetzung'; /* Nur für die Typangabe von `importOriginal` weiter unten – als benannter Namensraum, weil `import()`-Typen projektweit untersagt sind. */ import type * as Unterstuetzungsmodul from '../src/shared/unterstuetzung'; const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); /** Eine erfundene, aber formgerechte Adresse – die echte steht noch nicht fest. */ const PROBE = 'https://beispiel.example/unterstuetzen'; // ─── 1. Die Form der Adresse ──────────────────────────────────────────── describe('Adresse der Unterstützungsseite', () => { it('lässt eine https-Adresse gelten und gibt sie unverändert zurück', () => { /* Unverändert und nicht als `URL.href` neu zusammengesetzt: Angezeigt und geöffnet wird so dieselbe Zeichenkette, und der Vergleich im Hauptprozess trifft genau das, was auf dem Bildschirm stand. `href` hängte hier ein „/“ an und die Anzeige liefe von der Eingabe weg. */ expect(unterstuetzungsziel('https://ko-fi.example/name')).toBe('https://ko-fi.example/name'); expect(unterstuetzungsziel(' https://ko-fi.example/name ')).toBe( 'https://ko-fi.example/name', ); }); it.each([ ['leer', ''], ['nur Leerzeichen', ' '], ['ohne Schema', 'ko-fi.example/name'], ['unverschlüsselt', 'http://ko-fi.example/name'], ['Skript-Schema', 'javascript:alert(1)'], ['Postfach-Schema', 'mailto:name@beispiel.example'], ['Datei-Schema', 'file:///C:/Windows/win.ini'], ['unvollständig', 'https://'], ['mit Zugangsdaten', 'https://name:wort@ko-fi.example/'], ['Unsinn', 'nichts'], ])('behandelt eine Adresse %s wie „nicht gesetzt“', (_fall, eingabe) => { /* „Nicht gesetzt“ und nicht „Fehler“: Ein Vertipper soll dieselbe Folge haben wie ein leeres Feld – das Angebot entfällt. Ein toter Knopf oder eine Fehlermeldung an dieser Stelle wäre für den Lernenden ein Mangel der Anwendung, obwohl es einer des Eintrags ist. */ expect(unterstuetzungsziel(eingabe)).toBeNull(); }); it('hält die eingetragene Adresse an die eigene Form', () => { /* Bewusst KEINE Prüfung auf „heute leer“. Der Eintrag ist eine Einzeiler-Änderung und darf keinen roten Lauf auslösen. Geprüft wird deshalb die Zusage, die in beiden Zuständen gilt: Was durchkommt, ist entweder nichts oder eine https-Adresse – nie etwas dazwischen. */ const ziel = unterstuetzungsziel(UNTERSTUETZUNG_URL); if (ziel !== null) { expect(ziel).toMatch(/^https:\/\//u); // Und die Prüfung ist stabil: eine bereits geprüfte Adresse bleibt gültig. expect(unterstuetzungsziel(ziel)).toBe(ziel); } }); }); // ─── 2. Der Weg nach außen ────────────────────────────────────────────── describe('Der Weg nach außen', () => { /** Steht für `shell.openExternal` und meldet, ob und womit es gerufen wurde. */ const browserOeffnen = vi.fn<(url: string) => Promise>(); /** * Was der Hauptprozess ins Protokoll geschrieben hat. * * Aufgefangen statt ausgegeben: Das Protokoll gehört nicht in die * Testausgabe, und eine Abweisung, die stillschweigend geschieht, wäre bei * der Fehlersuche später nicht auffindbar – deshalb wird sie auch geprüft. * Der Wächter entsteht in `beforeEach`, weil `restoreMocks` in der * Vitest-Einstellung jeden Spion nach jedem Test zurücknimmt. */ const warnungen: string[] = []; beforeEach(() => { warnungen.length = 0; vi.spyOn(console, 'warn').mockImplementation((...teile: unknown[]) => { warnungen.push(teile.map(String).join(' ')); }); }); /** * Lädt `main/sicherheit.ts` mit einer gesetzten Adresse neu. * * Über `doMock` und einen dynamischen Import, weil beide Zustände zu prüfen * sind – der von heute (keine Adresse) und der von morgen. Ein Testlauf, der * nur den heutigen sähe, ließe genau die Änderung ungeprüft, für die dieses * Modul gebaut ist. */ async function aussenweg(adresse: string): Promise<(gewuenscht: unknown) => Promise> { vi.resetModules(); browserOeffnen.mockReset(); browserOeffnen.mockResolvedValue(undefined); vi.doMock('electron', () => ({ /* Nur, was `sicherheit.ts` importiert. Aufgerufen wird an dieser Stelle allein `shell.openExternal`; die übrigen sind Platzhalter, damit der Import überhaupt gelingt. */ app: { on: vi.fn() }, session: { defaultSession: {} }, shell: { openExternal: browserOeffnen }, })); vi.doMock('../src/shared/unterstuetzung', async (echt) => ({ ...(await echt()), UNTERSTUETZUNG_URL: adresse, })); const { unterstuetzungOeffnen } = await import('../src/main/sicherheit'); return unterstuetzungOeffnen; } afterEach(() => { vi.doUnmock('electron'); vi.doUnmock('../src/shared/unterstuetzung'); vi.resetModules(); }); it('öffnet die eingetragene Adresse im Standardbrowser', async () => { const oeffnen = await aussenweg(PROBE); await expect(oeffnen(PROBE)).resolves.toBe(true); expect(browserOeffnen.mock.calls).toEqual([[PROBE]]); }); it('öffnet keine andere Adresse, auch keine harmlos aussehende', async () => { /* Der Kern der Sache. Die Adresse wandert über die Brücke, damit prüfbar bleibt, dass die Oberfläche genau die anfordert, die sie anzeigt – maßgeblich ist sie damit nicht. Wer eine Adresse mitbringen darf, darf sonst auch eine andere mitbringen. */ const oeffnen = await aussenweg(PROBE); for (const fremd of [ 'https://beispiel.example/etwas-anderes', 'https://boeswillig.example/', `${PROBE}/`, PROBE.toUpperCase(), 'file:///C:/Windows/System32/cmd.exe', ]) { await expect(oeffnen(fremd)).resolves.toBe(false); } expect(browserOeffnen).not.toHaveBeenCalled(); expect(warnungen.length).toBeGreaterThan(0); }); it('öffnet nichts, was keine Zeichenkette ist', async () => { /* Was über die Brücke kommt, ist an dieser Grenze unbekannt – die Typisierung beschreibt, was der Renderer schicken SOLL. */ const oeffnen = await aussenweg(PROBE); for (const unsinn of [undefined, null, 42, { url: PROBE }, [PROBE]]) { await expect(oeffnen(unsinn)).resolves.toBe(false); } expect(browserOeffnen).not.toHaveBeenCalled(); }); it('öffnet nichts, solange keine Adresse eingetragen ist', async () => { /* Der heutige Zustand. Selbst wenn die Oberfläche fragte – sie zeigt das Angebot gar nicht erst –, geschähe nichts. */ const oeffnen = await aussenweg(''); await expect(oeffnen(PROBE)).resolves.toBe(false); await expect(oeffnen('')).resolves.toBe(false); expect(browserOeffnen).not.toHaveBeenCalled(); }); it('öffnet nichts, wenn die eingetragene Adresse die Form verfehlt', async () => { /* Ein `http://` im Eintrag ist kein halber Treffer, sondern kein Ziel. */ const oeffnen = await aussenweg('http://beispiel.example/unterstuetzen'); await expect(oeffnen('http://beispiel.example/unterstuetzen')).resolves.toBe(false); expect(browserOeffnen).not.toHaveBeenCalled(); }); it('meldet einen gescheiterten Aufruf, statt ihn durchschlagen zu lassen', async () => { /* Scheitert der Systemaufruf, bekommt die Oberfläche `false` und zeigt die Adresse zum Abschreiben. Eine geworfene Ausnahme über die Brücke wäre für den Lernenden dieselbe Lage mit schlechterem Text. */ const oeffnen = await aussenweg(PROBE); browserOeffnen.mockRejectedValueOnce(new Error('Kein Standardbrowser eingerichtet')); await expect(oeffnen(PROBE)).resolves.toBe(false); expect(warnungen.join('\n')).toMatch(/Standardbrowser/u); }); }); // ─── 3. Der Ort des Angebots ──────────────────────────────────────────── describe('Wo das Angebot stehen darf', () => { /** Der ausgelieferte Quelltext, oder `null` ohne Git. */ function quelldateien(): readonly { readonly pfad: string; readonly inhalt: string }[] | null { let dateien: string[]; try { dateien = execFileSync('git', ['ls-files', 'app/src'], { cwd: wurzel, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }) .split('\n') .map((zeile) => zeile.trim()) .filter((zeile) => zeile.endsWith('.tsx')); } catch { return null; } return dateien.map((pfad) => ({ pfad, inhalt: readFileSync(join(wurzel, pfad), 'utf8') })); } it('steht in genau einer Ansicht – und das ist „Über diese Software“', () => { const dateien = quelldateien(); if (dateien === null) { /* Kein Git – dann ist nicht feststellbar, was ausgeliefert würde. */ return; } expect(dateien.length).toBeGreaterThan(20); const verwender = dateien .filter( (datei) => datei.inhalt.includes(' datei.pfad); expect( verwender, 'Das Unterstützungsangebot gehört an genau eine ruhige Stelle. Ein zweiter ' + 'Einbau – im Startbildschirm, in der Lernsitzung, in der Auswertung – ' + 'wäre das Muster, das docs/entscheidung-motivation.md ablehnt.', ).toEqual(['app/src/renderer/src/components/ueber/UeberSoftware.tsx']); }); }); // ─── Was der Knopf verspricht ─────────────────────────────────────────── describe('Beschriftung des Knopfes', () => { it('kündigt an, dass das Fenster verlassen wird', () => { /* WCAG 3.2: Ein Verweis, der unangekündigt den Browser startet, ist eine Überraschung. Die Beschriftung sagt es – und sie ist zugleich der ganze zugängliche Name, es gibt kein `aria-label` daneben (WCAG 2.5.3). */ expect(UNTERSTUETZUNG_KNOPF).toMatch(/Browser/u); expect(UNTERSTUETZUNG_KNOPF).toMatch(/öffnen/u); }); });