lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import { mkdtempSync, readFileSync } from 'node:fs'; |
| 2 | import { tmpdir } from 'node:os'; |
| 3 | import { join } from 'node:path'; |
| 4 | |
| 5 | import { |
| 6 | _electron as electron, |
| 7 | expect, |
| 8 | test, |
| 9 | type ElectronApplication, |
| 10 | type Page, |
| 11 | } from '@playwright/test'; |
| 12 | |
| 13 | /** |
| 14 | * Rauchtest: Startet die gebaute Anwendung wirklich? |
| 15 | * |
| 16 | * Die Modultests pruefen Fachlogik, Ausdruck und Oberflaechenfunktionen - |
| 17 | * aber keiner von ihnen startet das Programm. Genau dort liegen die Fehler, die |
| 18 | * erst beim Anwender auffallen: ein Preload-Bundle, das nach dem Bauen nicht |
| 19 | * mehr passt; ein Pfad, der nur im Paket anders liegt; eine Sicherheits- |
| 20 | * richtlinie, die das Einsetzen beim Bauen nicht ueberlebt hat und die eigenen |
| 21 | * Skripte blockt. |
| 22 | * |
| 23 | * Dieser Test laeuft deshalb gegen `build/` - gegen das Erzeugnis, nicht gegen |
| 24 | * den Quelltext. Er braucht `npm run build` davor; `npm run e2e` erledigt das. |
| 25 | * |
| 26 | * Er prueft bewusst wenig, aber das Richtige: Startet sie, ist die Bruecke |
| 27 | * vollstaendig, haelt die Abschottung, und kam die Sicherheitsrichtlinie an? |
| 28 | */ |
| 29 | |
| 30 | const WURZEL = join(__dirname, '..'); |
| 31 | |
| 32 | /** Die Methoden, die `electron/preload.ts` ueber contextBridge freigibt. */ |
| 33 | const BRUECKE = [ |
| 34 | 'platform', |
| 35 | 'saveFile', |
| 36 | 'openFile', |
| 37 | 'writeFile', |
| 38 | 'readFile', |
| 39 | 'setDirty', |
| 40 | 'reportError', |
| 41 | 'logPath', |
| 42 | 'fetchMapImage', |
| 43 | 'onMenuCommand', |
| 44 | 'onBeforeClose', |
| 45 | ] as const; |
| 46 | |
| 47 | let anwendung: ElectronApplication; |
| 48 | let fenster: Page; |
| 49 | const fehlerBeimStart: string[] = []; |
| 50 | |
| 51 | test.beforeAll(async () => { |
| 52 | /* |
| 53 | * Eigenes, leeres Anwenderverzeichnis je Lauf. |
| 54 | * |
| 55 | * Ohne das laeuft der Rauchtest gegen den Sitzungsspeicher des Rechners, auf |
| 56 | * dem er startet - und beurteilt damit nicht das Erzeugnis, sondern was |
| 57 | * jemand zuletzt darin bearbeitet hat. Gemessen: Beim ersten Lauf gruen, beim |
| 58 | * zweiten rot, weil der erste beim Schliessen seinen Stand hinterlassen |
| 59 | * hatte. Ein Rauchtest, dessen Ergebnis vom vorigen Lauf abhaengt, taugt als |
| 60 | * Wache nichts. |
| 61 | * |
| 62 | * Geprueft wird damit der Start, den ein Anwender nach der Installation |
| 63 | * vorfindet. |
| 64 | */ |
| 65 | const anwenderverzeichnis = mkdtempSync(join(tmpdir(), 'lsa-planer-rauchtest-')); |
| 66 | |
| 67 | anwendung = await electron.launch({ |
| 68 | args: [WURZEL, `--user-data-dir=${anwenderverzeichnis}`], |
| 69 | cwd: WURZEL, |
| 70 | }); |
| 71 | |
| 72 | fenster = await anwendung.firstWindow(); |
| 73 | |
| 74 | // Alles einsammeln, was der Renderer beim Hochfahren an Fehlern wirft. |
| 75 | // Ein Programm, das startet und dabei Ausnahmen schluckt, ist nicht heil. |
| 76 | fenster.on('pageerror', (fehler) => fehlerBeimStart.push(String(fehler))); |
| 77 | fenster.on('console', (zeile) => { |
| 78 | if (zeile.type() === 'error') { |
| 79 | fehlerBeimStart.push(zeile.text()); |
| 80 | } |
| 81 | }); |
| 82 | |
| 83 | await fenster.waitForLoadState('domcontentloaded'); |
| 84 | }); |
| 85 | |
| 86 | test.afterAll(async () => { |
| 87 | // Das Fragezeichen sieht nach dem Typ ueberfluessig aus (der Linter meldet |
| 88 | // es auch), ist es aber nicht: `anwendung` wird erst in beforeAll gesetzt. |
| 89 | // Schlaegt der Start fehl, laeuft afterAll trotzdem - und ohne die Abfrage |
| 90 | // ueberdeckte hier ein TypeError den eigentlichen Startfehler, also genau |
| 91 | // die Meldung, wegen der es diesen Test gibt. |
| 92 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- anwendung ist vor beforeAll undefined; siehe darueber |
| 93 | await anwendung?.close(); |
| 94 | }); |
| 95 | |
| 96 | test('das Fenster oeffnet sich und der Renderer setzt den Titel', async () => { |
| 97 | // Nicht auf den festen Titel aus index.html pruefen: Den traegt das Fenster |
| 98 | // schon, bevor irgendetwas laeuft. Die Anwendung stellt ihm zur Laufzeit den |
| 99 | // Projektnamen voran - genau daran zeigt sich, dass der Renderer uebernommen |
| 100 | // hat und nicht bloss die HTML-Huelle dasteht. |
| 101 | await expect(fenster).toHaveTitle(/^Projekt .+ – LSA-Planer Professional$/u); |
| 102 | }); |
| 103 | |
| 104 | test('die Oberflaeche baut sich auf, nicht nur die leere Huelle', async () => { |
| 105 | const wurzel = fenster.locator('#anwendung'); |
| 106 | await expect(wurzel).toBeAttached(); |
| 107 | |
| 108 | // Ohne diese Pruefung wuerde auch eine Anwendung bestehen, die zwar ein |
| 109 | // Fenster oeffnet, in dem aber nur das <noscript> aus index.html steht. |
| 110 | await expect |
| 111 | .poll(async () => (await wurzel.innerHTML()).length, { timeout: 15_000 }) |
| 112 | .toBeGreaterThan(500); |
| 113 | }); |
| 114 | |
| 115 | test('das Fenster laesst sich nicht wegnavigieren', async () => { |
| 116 | /* |
| 117 | * DIE NAVIGATIONSGRENZE AM GEBAUTEN STAND. |
| 118 | * |
| 119 | * `will-navigate` in electron/main.ts bricht jede Navigation ab, die nicht |
| 120 | * die geladene `index.html` ist. Bis zu dieser Fassung pruefte das kein |
| 121 | * einziger Test - waehrend `vitest.config.ts` die Abdeckungsluecke in |
| 122 | * `electron/main.ts` ausdruecklich damit begruendete, "genau das" pruefe der |
| 123 | * Rauchtest hier. Sechs Faelle, null Nennungen: eine Ausnahme, gedeckt von |
| 124 | * einem Test, den es nicht gab. |
| 125 | * |
| 126 | * WARUM MIT `http:` UND NICHT MIT `https:`: Eine https-Adresse gibt die |
| 127 | * Wache an den Standardbrowser weiter - dieser Test oeffnete dann bei jedem |
| 128 | * Lauf ein Browserfenster auf dem Rechner dessen, der ihn faehrt. Geprueft |
| 129 | * wird deshalb hier die Verdrahtung an einer Adresse, die NICHT hinausgeht; |
| 130 | * dass die Entscheidung fuer jedes Protokoll richtig faellt, prueft |
| 131 | * `tests/tools/navigationGrenze.test.ts` an der reinen Funktion. |
| 132 | * |
| 133 | * Die Adresse zeigt auf `.invalid` - eine Namensendung, die nach RFC 2606 |
| 134 | * niemals aufgeloest wird. Selbst wenn die Wache fiele, ginge kein Abruf an |
| 135 | * einen fremden Rechner. |
| 136 | */ |
| 137 | const vorher = fenster.url(); |
| 138 | expect(vorher, 'der Rauchtest laeuft nicht auf der eigenen Seite').toContain('index.html'); |
| 139 | |
| 140 | await fenster.evaluate(() => { |
| 141 | globalThis.location.href = 'http://wegnavigiert.invalid/'; |
| 142 | }); |
| 143 | |
| 144 | // Kurz warten: Eine Navigation, die durchginge, braucht einen Augenblick. |
| 145 | await fenster.waitForTimeout(500); |
| 146 | expect(fenster.url(), 'das Fenster hat die eigene Seite verlassen').toBe(vorher); |
| 147 | |
| 148 | /* |
| 149 | * Und die Oberflaeche steht noch - ein Abbruch, der das Dokument leert, waere |
| 150 | * kein Abbruch. |
| 151 | * |
| 152 | * Ueber `evaluate` und nicht ueber einen Locator: Chromium fuehrt nach dem |
| 153 | * abgebrochenen Versuch noch eine schwebende Navigation, und `toBeAttached` |
| 154 | * wartet darauf, dass sie endet - sie endet nie, und der Fall lief in die |
| 155 | * Zeitschranke, obwohl die Wache getan hatte, was sie soll. GEMESSEN: Die |
| 156 | * Zeile darueber war gruen, erst diese fiel. `evaluate` fragt den |
| 157 | * gegenwaertigen Baum, ohne auf eine Navigation zu warten. |
| 158 | */ |
| 159 | const stehtNoch = await fenster.evaluate( |
| 160 | () => document.getElementById('anwendung')?.childElementCount ?? 0, |
| 161 | ); |
| 162 | expect(stehtNoch, 'die Oberflaeche ist beim Abbruch leer geworden').toBeGreaterThan(0); |
| 163 | }); |
| 164 | |
| 165 | test('der Renderer meldet beim Start keine Fehler', () => { |
| 166 | expect(fehlerBeimStart).toEqual([]); |
| 167 | }); |
| 168 | |
| 169 | test('die Bruecke ist vollstaendig freigegeben', async () => { |
| 170 | const vorhanden = await fenster.evaluate(() => { |
| 171 | const bruecke = (globalThis as unknown as { lsaPlaner?: Record<string, unknown> }).lsaPlaner; |
| 172 | return bruecke ? Object.keys(bruecke).sort() : null; |
| 173 | }); |
| 174 | |
| 175 | expect(vorhanden).not.toBeNull(); |
| 176 | for (const name of BRUECKE) { |
| 177 | expect(vorhanden).toContain(name); |
| 178 | } |
| 179 | }); |
| 180 | |
| 181 | test('der Renderer kommt nicht an Node heran', async () => { |
| 182 | // contextIsolation und sandbox stehen in electron/main.ts. Ob sie im |
| 183 | // gebauten Stand tatsaechlich greifen, zeigt sich nur hier: Kaeme der |
| 184 | // Renderer an `require`, waere die Abschottung offen, und eine praeparierte |
| 185 | // Projektdatei koennte beliebigen Code ausfuehren. |
| 186 | const erreichbar = await fenster.evaluate(() => { |
| 187 | const global = globalThis as unknown as Record<string, unknown>; |
| 188 | return { |
| 189 | require: typeof global.require, |
| 190 | process: typeof global.process, |
| 191 | module: typeof global.module, |
| 192 | electron: typeof global.electron, |
| 193 | }; |
| 194 | }); |
| 195 | |
| 196 | expect(erreichbar).toEqual({ |
| 197 | require: 'undefined', |
| 198 | process: 'undefined', |
| 199 | module: 'undefined', |
| 200 | electron: 'undefined', |
| 201 | }); |
| 202 | }); |
| 203 | |
| 204 | test('die Sicherheitsrichtlinie hat das Bauen ueberstanden', () => { |
| 205 | // Das Einsetzen erledigt ein Vite-Plugin (vite.config.ts, |
| 206 | // "lsa-inhaltssicherheitsrichtlinie") und nur im Auslieferungsstand. Faellt |
| 207 | // es aus, sieht man das dem laufenden Programm nicht an - die Anwendung |
| 208 | // startet weiterhin, nur ohne Richtlinie. |
| 209 | const html = readFileSync(join(WURZEL, 'build', 'renderer', 'index.html'), 'utf8'); |
| 210 | |
| 211 | expect(html).toContain('http-equiv="Content-Security-Policy"'); |
| 212 | expect(html).toContain("default-src 'none'"); |
| 213 | expect(html).not.toContain('<!--sicherheitsrichtlinie-->'); |
| 214 | }); |