lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests tools werkbank.test.ts
| 1 | import { spawnSync } from 'node:child_process'; |
| 2 | import { |
| 3 | existsSync, |
| 4 | mkdirSync, |
| 5 | mkdtempSync, |
| 6 | readFileSync, |
| 7 | readdirSync, |
| 8 | rmSync, |
| 9 | writeFileSync, |
| 10 | } from 'node:fs'; |
| 11 | import os from 'node:os'; |
| 12 | import path from 'node:path'; |
| 13 | import { fileURLToPath } from 'node:url'; |
| 14 | import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 15 | |
| 16 | /* |
| 17 | * Werkbank. Zusagen, die nichts rechnen. |
| 18 | * |
| 19 | * F.1 `.githooks/pre-commit` faehrt die Schnellstufe, greift aber nur ueber |
| 20 | * `core.hooksPath` in `.git/config`. Das steht ausserhalb des Baumes und |
| 21 | * wird von `git clone` nicht mitgefuehrt; wer das Einrichten von Hand |
| 22 | * vergisst, committet ungeprueft. `package.json` richtet den Haken jetzt |
| 23 | * mit einem `prepare`-Skript ein - npm faehrt es bei jedem `npm install`. |
| 24 | * Bewacht wird nicht der Wortlaut des Skripts, sondern seine Wirkung: Es |
| 25 | * laeuft hier in einer Wegwerf-Arbeitskopie und muss dort den Eintrag |
| 26 | * setzen; ohne `.git` muss es folgenlos mit 0 enden, weil ein |
| 27 | * fehlschlagendes `prepare` das ganze `npm install` abbricht. |
| 28 | * |
| 29 | * F.2 `jspdf`, `jspdf-autotable`, Electron und Chromium gehen mit der Fassung |
| 30 | * an den Anwender, waren aber nirgends als Fremdbestandteil genannt. |
| 31 | * `docs/fremdbestandteile.md` fuehrt sie jetzt auf. Ein solches |
| 32 | * Verzeichnis ist nur so viel wert wie seine Aktualitaet: Diese Wache |
| 33 | * haelt jeden Eintrag gegen das installierte Paket, die Menge der |
| 34 | * Eintraege gegen das Bauergebnis und die Zahl der erhaltenen |
| 35 | * Urhebervermerke gegen die Ausgabedateien. Ein `npm install`, das eine |
| 36 | * Fassung anhebt, macht sie rot. |
| 37 | * |
| 38 | * SEIT 5.12.0 KOMMEN DIE MITGELIEFERTEN LIZENZTEXTE DAZU. Das Verzeichnis |
| 39 | * fuehrte drei Punkte als ausstehend: keine Lizenzdatei im Paket, im |
| 40 | * uebersetzten Kode nur vier von dreizehn Urhebervermerken, und das |
| 41 | * Verzeichnis selbst nur im Quellbaum. Punkt 1 und 3 sind behoben - |
| 42 | * `scripts/lizenztexte.mjs` sammelt die Texte aus `node_modules` ein, und |
| 43 | * `build.extraFiles` legt sie samt dem Verzeichnis neben die |
| 44 | * Programmdatei, dorthin, wo electron-builder auch seine eigenen beiden |
| 45 | * Lizenzdateien ablegt. Bewacht wird beides: dass package.json es mitgibt |
| 46 | * UND dass die Sammeldatei zu JEDER gebuendelten Bibliothek einen Text |
| 47 | * fuehrt. Eine Sammeldatei, in der eine fehlt, sieht nach Erfuellung aus |
| 48 | * und ist keine - deshalb prueft der Fall je Bibliothek den Namen ihres |
| 49 | * Rechteinhabers nach, und ein weiterer prueft, dass das Skript lieber |
| 50 | * abbricht als eine unvollstaendige Datei zu schreiben. |
| 51 | * |
| 52 | * SEIT 5.14.0 IST AUCH PUNKT 2 BEHOBEN. Der Vermerk wird in der Ausgabe |
| 53 | * nicht mehr gesucht, sondern hineingeschrieben: `lizenzbanner` in |
| 54 | * `vite.config.ts` setzt je Ausgabebrocken einen legalen Kommentar |
| 55 | * (`/*!`) mit den Lizenztexten der Bibliotheken, deren Kode wirklich |
| 56 | * darin steht. Vier Faelle halten das fest, und zwar am gebauten Stand: |
| 57 | * dass jede der dreizehn ihren Urhebervermerk in ihrer Ausgabedatei |
| 58 | * traegt; dass der Banner GENAU die Bibliotheken nennt, die die |
| 59 | * Quellkarte dieser Datei zuweist - ein Vermerk am falschen Ort ist eine |
| 60 | * falsche Angabe, keine Erfuellung; dass der Text woertlich der des |
| 61 | * installierten Pakets ist; und dass eine Ausgabedatei ohne |
| 62 | * Fremdbestandteil gar keinen Banner bekommt. |
| 63 | * |
| 64 | * DIE GEGENPROBE IST DER EIGENTLICHE WAECHTER. Der Banner entsteht zur |
| 65 | * Bauzeit aus `chunk.moduleIds`, die Zuordnung hier aus den Quellkarten - |
| 66 | * zwei Ableitungen desselben Sachverhalts. Laufen sie auseinander, faellt |
| 67 | * einer der beiden Faelle. Ohne die Gegenprobe genuegte es, jeder Datei |
| 68 | * alle dreizehn Texte mitzugeben; das saehe im Bericht genauso gruen aus. |
| 69 | * |
| 70 | * DREI WEITERE FAELLE HALTEN DIE ZAHLEN IM TEXT FEST und brauchen dafuer |
| 71 | * kein Bauergebnis: dass der Kopfkommentar hier so viele uebersprungene |
| 72 | * Faelle nennt, wie die Datei fuehrt; dass Verzeichnis und |
| 73 | * `vite.config.ts` so viele Wortlaute des Genehmigungsvermerks nennen, |
| 74 | * wie die Pakete fuehren; und dass die Groesse der DOMPurify-Lizenzdatei |
| 75 | * in Byte dasteht, nicht in Zeichen. Alle drei Zahlen standen einmal |
| 76 | * falsch da - eine Zahl im Text, die niemand nachrechnet, wandert. |
| 77 | * |
| 78 | * SECHS DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS. `build/` steht in |
| 79 | * `.gitignore`, und kein Skript baut vor den Tests: `gate:schnell` baut |
| 80 | * nicht, `gate` baut erst nach dem Testlauf. Eine frische Arbeitskopie |
| 81 | * hat also nichts zu messen. Die sechs Faelle werden dann |
| 82 | * UEBERSPRUNGEN und nicht etwa schwaecher geprueft - ein stiller |
| 83 | * Rueckfall auf eine Ersatzzusicherung sieht im Bericht aus wie eine |
| 84 | * bestandene Messung und ist keine. |
| 85 | * |
| 86 | * NACHGETRAGEN ZU 5.43.0: DIE LAUFZEIT JENSEITS VON ELECTRON. Gehalten |
| 87 | * war bis dahin nur die Zeile "Electron" der Tabelle "Die Laufzeit". Mit |
| 88 | * 5.26.0 kam Electron 44.3.0; die Zeilen Electron und Chromium wurden von |
| 89 | * Hand nachgezogen, die Zeile Node.js nicht - sie nannte bis 5.42.1 |
| 90 | * weiter 20.18.3, waehrend das ausgelieferte Programm Node.js 24.20.0 |
| 91 | * mitbringt. Kein Fall merkte es. Ebenso stand bis 5.42.1 eine Zaehlung |
| 92 | * reziproker Lizenzen als Messung am ausgelieferten Stand da, deren neun |
| 93 | * Namen aus der Uebersicht von Electron 33.4.11 stammten; einer davon |
| 94 | * steht in der heutigen nicht mehr. Zwei Faelle halten das jetzt: Die |
| 95 | * Fassungen von Chromium und Node.js fragt der eine bei der |
| 96 | * Programmdatei selbst ab (`process.versions` unter |
| 97 | * `ELECTRON_RUN_AS_NODE`), nicht an einer Zeichenkette im Text; der andere |
| 98 | * prueft je genanntem Namen an `LICENSES.chromium.html`, ob er dort als |
| 99 | * Eintrag steht, und verlangt, dass das Verzeichnis genau die fehlenden |
| 100 | * als fehlend nennt. Beide brauchen das heruntergeladene Electron unter |
| 101 | * `node_modules/electron/dist` und ueberspringen sich ohne es sichtbar. |
| 102 | * |
| 103 | * WARUM HIER `git` UND `node` ANGEWORFEN WERDEN: Die Punkte sind Zusagen |
| 104 | * ueber das Verhalten von Werkzeugen, nicht ueber Text. Ein Textabgleich haette |
| 105 | * bei F.1 nur belegt, dass eine Zeichenkette dasteht - nicht, dass npm sie in |
| 106 | * der Kommandozeile von Windows wie in der von POSIX ohne Abbruch faehrt. Die |
| 107 | * Laeufe kosten zusammen unter einer Sekunde. |
| 108 | */ |
| 109 | |
| 110 | const WURZEL = process.cwd(); |
| 111 | |
| 112 | interface Paketdatei { |
| 113 | readonly version: string; |
| 114 | readonly scripts: Record<string, string>; |
| 115 | readonly dependencies?: Record<string, string>; |
| 116 | readonly devDependencies: Record<string, string>; |
| 117 | readonly build: { |
| 118 | readonly files: readonly string[]; |
| 119 | readonly extraFiles?: readonly { readonly from?: string; readonly to?: string }[]; |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as Paketdatei; |
| 124 | |
| 125 | /* ------------------------------------------------------------------------- |
| 126 | * F.1 - das prepare-Skript |
| 127 | * ---------------------------------------------------------------------- */ |
| 128 | |
| 129 | describe('F.1: package.json richtet den pre-commit-Haken selbst ein', () => { |
| 130 | const PREPARE = PAKET.scripts['prepare']; |
| 131 | let ablage = ''; |
| 132 | |
| 133 | beforeAll(() => { |
| 134 | ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-prepare-')); |
| 135 | }); |
| 136 | |
| 137 | afterAll(() => { |
| 138 | rmSync(ablage, { recursive: true, force: true }); |
| 139 | }); |
| 140 | |
| 141 | /** Faehrt das prepare-Skript so, wie npm es faehrt: durch die Kommandozeile des Systems. */ |
| 142 | function faehrtIn(wo: string): { status: number | null; ausgabe: string } { |
| 143 | const lauf = spawnSync(PREPARE ?? '', { cwd: wo, shell: true, encoding: 'utf8' }); |
| 144 | return { status: lauf.status, ausgabe: `${lauf.stdout}${lauf.stderr}` }; |
| 145 | } |
| 146 | |
| 147 | /** Der oertliche Wert von core.hooksPath in der angegebenen Arbeitskopie, oder ''. */ |
| 148 | function hakenpfad(wo: string): string { |
| 149 | const lauf = spawnSync('git', ['config', '--local', '--get', 'core.hooksPath'], { |
| 150 | cwd: wo, |
| 151 | encoding: 'utf8', |
| 152 | }); |
| 153 | return lauf.stdout.trim(); |
| 154 | } |
| 155 | |
| 156 | it('fuehrt package.json ueberhaupt ein prepare-Skript', () => { |
| 157 | expect(PREPARE, 'kein prepare-Skript in package.json').toBeDefined(); |
| 158 | expect(PREPARE).toContain('core.hooksPath'); |
| 159 | expect(PREPARE).toContain('.githooks'); |
| 160 | }); |
| 161 | |
| 162 | it('setzt es in einer frischen Arbeitskopie core.hooksPath auf .githooks', () => { |
| 163 | const wo = path.join(ablage, 'mitgit'); |
| 164 | const anlegen = spawnSync('git', ['init', '-q', wo], { encoding: 'utf8' }); |
| 165 | expect(anlegen.status, `git init: ${anlegen.stderr}`).toBe(0); |
| 166 | expect(hakenpfad(wo), 'die frische Arbeitskopie hat den Eintrag schon').toBe(''); |
| 167 | |
| 168 | const lauf = faehrtIn(wo); |
| 169 | expect(lauf.status, `prepare brach ab: ${lauf.ausgabe}`).toBe(0); |
| 170 | expect(hakenpfad(wo), 'prepare hat den Eintrag nicht gesetzt').toBe('.githooks'); |
| 171 | }); |
| 172 | |
| 173 | it('endet es ohne .git-Verzeichnis folgenlos mit 0', () => { |
| 174 | // Ein fehlschlagendes prepare bricht npm install ab. Auslieferung als |
| 175 | // Tarball und Bauumgebungen ohne Arbeitskopie duerfen daran nicht scheitern. |
| 176 | const wo = path.join(ablage, 'ohnegit'); |
| 177 | const lauf = spawnSync('git', ['init', '-q', wo], { encoding: 'utf8' }); |
| 178 | expect(lauf.status, `git init: ${lauf.stderr}`).toBe(0); |
| 179 | rmSync(path.join(wo, '.git'), { recursive: true, force: true }); |
| 180 | |
| 181 | const ergebnis = faehrtIn(wo); |
| 182 | expect(ergebnis.status, `prepare brach ab: ${ergebnis.ausgabe}`).toBe(0); |
| 183 | expect(existsSync(path.join(wo, '.git')), 'prepare hat ein .git angelegt').toBe(false); |
| 184 | }); |
| 185 | }); |
| 186 | |
| 187 | /* ------------------------------------------------------------------------- |
| 188 | * F.2 - das Verzeichnis der Fremdbestandteile |
| 189 | * ---------------------------------------------------------------------- */ |
| 190 | |
| 191 | const DOK_PFAD = 'docs/fremdbestandteile.md'; |
| 192 | const DOK = readFileSync(DOK_PFAD, 'utf8'); |
| 193 | |
| 194 | interface Eintrag { |
| 195 | readonly name: string; |
| 196 | readonly fassung: string; |
| 197 | readonly lizenz: string; |
| 198 | } |
| 199 | |
| 200 | /** Die Tabellenzeilen eines Abschnitts, zerlegt in Name, Fassung und Lizenz. */ |
| 201 | function eintraege(ueberschrift: string): Eintrag[] { |
| 202 | const anfang = DOK.indexOf(`## ${ueberschrift}`); |
| 203 | expect(anfang, `Abschnitt "${ueberschrift}" nicht gefunden`).toBeGreaterThanOrEqual(0); |
| 204 | const naechste = DOK.indexOf('\n## ', anfang + 1); |
| 205 | const abschnitt = DOK.slice(anfang, naechste < 0 ? DOK.length : naechste); |
| 206 | const gefunden: Eintrag[] = []; |
| 207 | for (const zeile of abschnitt.split('\n')) { |
| 208 | if (!zeile.startsWith('|')) continue; |
| 209 | const spalten = zeile.split('|').map((s) => s.trim()); |
| 210 | // spalten[0] ist leer (die Zeile beginnt mit |), dann Name, Fassung, Lizenz. |
| 211 | const name = (spalten[1] ?? '').replace(/`/g, ''); |
| 212 | if (name === '' || name === 'Bestandteil' || name.startsWith('---')) continue; |
| 213 | gefunden.push({ |
| 214 | name, |
| 215 | fassung: spalten[2] ?? '', |
| 216 | lizenz: (spalten[3] ?? '').replace(/`/g, ''), |
| 217 | }); |
| 218 | } |
| 219 | return gefunden; |
| 220 | } |
| 221 | |
| 222 | /** Fassung und Lizenzangabe, wie sie im installierten Paket stehen. */ |
| 223 | function installiert(name: string): { fassung: string; lizenz: string } { |
| 224 | const roh = JSON.parse( |
| 225 | readFileSync(path.join('node_modules', name, 'package.json'), 'utf8'), |
| 226 | ) as Record<string, unknown>; |
| 227 | return { fassung: String(roh['version']), lizenz: String(roh['license']) }; |
| 228 | } |
| 229 | |
| 230 | const BAUABLAGE = 'build/renderer/assets'; |
| 231 | |
| 232 | /** |
| 233 | * Welches Paket in welchen Ausgabedateien des gebauten Renderers steckt - aus |
| 234 | * den Quellkarten gelesen. Ist nichts gebaut, gibt es nichts zu vergleichen; |
| 235 | * dann steht hier null, und die davon abhaengigen Faelle werden uebersprungen. |
| 236 | * |
| 237 | * Die Zuordnung Paket -> Datei ist nicht bloss Beiwerk: Der Fall zu den |
| 238 | * Urhebervermerken sucht je Paket NUR in der Datei seines eigenen Buendels. |
| 239 | * Sucht man ueber alle Ausgabedateien, faengt man jsPDFs mitgefuehrte |
| 240 | * Fremdteile ein und zaehlt sie fremden Paketen zu. |
| 241 | */ |
| 242 | function buendelzuordnung(): Map<string, string[]> | null { |
| 243 | if (!existsSync(BAUABLAGE)) return null; |
| 244 | const karten = readdirSync(BAUABLAGE).filter((d) => d.endsWith('.js.map')); |
| 245 | if (karten.length === 0) return null; |
| 246 | const gefunden = new Map<string, Set<string>>(); |
| 247 | for (const karte of karten) { |
| 248 | const inhalt = JSON.parse(readFileSync(path.join(BAUABLAGE, karte), 'utf8')) as { |
| 249 | sources?: string[]; |
| 250 | }; |
| 251 | for (const quelle of inhalt.sources ?? []) { |
| 252 | const stelle = quelle.lastIndexOf('node_modules/'); |
| 253 | if (stelle < 0) continue; |
| 254 | const teile = quelle.slice(stelle + 'node_modules/'.length).split('/'); |
| 255 | const name = teile[0]?.startsWith('@') ? `${teile[0]}/${teile[1]}` : String(teile[0]); |
| 256 | const eintrag = gefunden.get(name) ?? new Set<string>(); |
| 257 | eintrag.add(karte.slice(0, -'.map'.length)); |
| 258 | gefunden.set(name, eintrag); |
| 259 | } |
| 260 | } |
| 261 | return new Map([...gefunden].map(([name, dateien]) => [name, [...dateien].sort()])); |
| 262 | } |
| 263 | |
| 264 | const BUENDEL = buendelzuordnung(); |
| 265 | /** Ohne Bauergebnis ist nichts zu messen - die betroffenen Faelle entfallen sichtbar. */ |
| 266 | const OHNE_BAU = BUENDEL === null; |
| 267 | |
| 268 | /** |
| 269 | * Der Rechteinhaber je Paket, wie ihn die Lizenzdatei des installierten Pakets |
| 270 | * nennt. Er dient als Fuehler: Steht der Name in der Ausgabedatei, steht dort |
| 271 | * der Urhebervermerk. Ein Fall unten prueft jeden Fuehler zuerst am |
| 272 | * installierten Paket nach - ein Schreibfehler ginge sonst stumm als |
| 273 | * "Vermerk fehlt" durch und machte die Zaehlung wertlos. |
| 274 | */ |
| 275 | const RECHTEINHABER: Readonly<Record<string, string>> = { |
| 276 | '@babel/runtime': 'Sebastian McKenzie', |
| 277 | canvg: 'Gabe Lerner', |
| 278 | 'core-js': 'Denis Pushkarev', |
| 279 | dompurify: 'Cure53', |
| 280 | fflate: 'Arjun Barrett', |
| 281 | html2canvas: 'Niklas von Hertzen', |
| 282 | jspdf: 'James Hall', |
| 283 | 'jspdf-autotable': 'Simon Bengtsson', |
| 284 | 'performance-now': 'Braveg1rl', |
| 285 | raf: 'Chris Dickinson', |
| 286 | rgbcolor: 'Stoyan Stefanov', |
| 287 | 'stackblur-canvas': 'Mario Klingemann', |
| 288 | 'svg-pathdata': 'Nicolas Froidure', |
| 289 | }; |
| 290 | |
| 291 | /** Zahlwoerter, so weit das Verzeichnis sie braucht - dreizehn Pakete, nicht mehr. */ |
| 292 | const ZAHLWORT: Readonly<Record<number, string>> = { |
| 293 | 0: 'keines', |
| 294 | 1: 'eines', |
| 295 | 2: 'zwei', |
| 296 | 3: 'drei', |
| 297 | 4: 'vier', |
| 298 | 5: 'fünf', |
| 299 | 6: 'sechs', |
| 300 | 7: 'sieben', |
| 301 | 8: 'acht', |
| 302 | 9: 'neun', |
| 303 | 10: 'zehn', |
| 304 | 11: 'elf', |
| 305 | 12: 'zwölf', |
| 306 | 13: 'dreizehn', |
| 307 | }; |
| 308 | |
| 309 | /** |
| 310 | * Woran der Banner aus `vite.config.ts` zu erkennen ist. |
| 311 | * |
| 312 | * `/*!` und nicht `/*`: esbuild gibt nur legale Kommentare wieder. Steht diese |
| 313 | * Zeichenfolge nicht am Anfang, hat der Banner das Verkuerzen nicht ueberlebt - |
| 314 | * und dann faellt der Fall, der ihn sucht. |
| 315 | */ |
| 316 | const BANNERBEGINN = '/*!\nLSA-Planer Professional '; |
| 317 | |
| 318 | /** |
| 319 | * Die Bibliotheken, die der Banner einer Ausgabedatei nennt - gelesen an den |
| 320 | * Herkunftszeilen `(node_modules/<paket>/<lizenzdatei>)`, die er je Text |
| 321 | * fuehrt, nicht an einer Aufzaehlung im Kopf. |
| 322 | */ |
| 323 | function bannergenannt(datei: string): string[] { |
| 324 | const code = readFileSync(path.join(BAUABLAGE, datei), 'utf8'); |
| 325 | const anfang = code.indexOf(BANNERBEGINN); |
| 326 | if (anfang < 0) return []; |
| 327 | const banner = code.slice(anfang, code.indexOf('*/', anfang)); |
| 328 | const namen = new Set<string>(); |
| 329 | for (const treffer of banner.matchAll(/^\(node_modules\/(.+)\/[^/]+\)$/gm)) { |
| 330 | namen.add(String(treffer[1])); |
| 331 | } |
| 332 | return [...namen].sort(); |
| 333 | } |
| 334 | |
| 335 | /** Die Lizenzdateien, die neben `package.json` eines installierten Pakets liegen. */ |
| 336 | function lizenzdateien(name: string): string[] { |
| 337 | const ablage = path.join('node_modules', name); |
| 338 | return readdirSync(ablage) |
| 339 | .filter((d) => /^(licen[sc]e|copying)/i.test(d)) |
| 340 | .map((d) => path.join(ablage, d)); |
| 341 | } |
| 342 | |
| 343 | /** `a`, `b` und `c` - die Aufzaehlung, wie das Verzeichnis sie schreibt. */ |
| 344 | function aufzaehlung(namen: readonly string[]): string { |
| 345 | const gesetzt = namen.map((n) => `\`${n}\``); |
| 346 | if (gesetzt.length <= 1) return gesetzt.join(''); |
| 347 | return `${gesetzt.slice(0, -1).join(', ')} und ${gesetzt[gesetzt.length - 1]}`; |
| 348 | } |
| 349 | |
| 350 | /** Tausenderabstand, wie ihn die Unterlagen setzen: 27 729, nicht 27729. */ |
| 351 | function mitAbstand(zahl: number): string { |
| 352 | return String(zahl).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); |
| 353 | } |
| 354 | |
| 355 | /** Grosser Anfangsbuchstabe - das Verzeichnis beginnt Saetze mit dem Zahlwort. */ |
| 356 | function gross(wort: string): string { |
| 357 | return wort.slice(0, 1).toUpperCase() + wort.slice(1); |
| 358 | } |
| 359 | |
| 360 | /** Der Satz, mit dem der MIT-Text seinen Genehmigungsvermerk beginnt. */ |
| 361 | const GENEHMIGUNG = 'Permission is hereby granted'; |
| 362 | |
| 363 | /** Diese Datei selbst - der Fall zum Kopfkommentar liest sie. */ |
| 364 | const EIGENE_QUELLE = fileURLToPath(import.meta.url); |
| 365 | |
| 366 | /** Wo `npm install` die Laufzeit von Electron ablegt. */ |
| 367 | const ELECTRON_ABLAGE = path.join('node_modules', 'electron', 'dist'); |
| 368 | |
| 369 | /** Die Bestandteiluebersicht von Chromium, die Electron mitliefert. */ |
| 370 | const CHROMIUM_UEBERSICHT = path.join(ELECTRON_ABLAGE, 'LICENSES.chromium.html'); |
| 371 | |
| 372 | /** |
| 373 | * Die Programmdatei des installierten Electron, oder null. |
| 374 | * |
| 375 | * Ihren Namen je Plattform schreibt das Paket beim Herunterladen nach |
| 376 | * `node_modules/electron/path.txt`; unter Windows ist es `electron.exe`. |
| 377 | */ |
| 378 | const ELECTRON_PROGRAMM = ((): string | null => { |
| 379 | const namensdatei = path.join('node_modules', 'electron', 'path.txt'); |
| 380 | if (!existsSync(namensdatei)) return null; |
| 381 | const programm = path.join(ELECTRON_ABLAGE, readFileSync(namensdatei, 'utf8').trim()); |
| 382 | return existsSync(programm) ? programm : null; |
| 383 | })(); |
| 384 | |
| 385 | /** Ohne heruntergeladenes Electron gibt es nichts abzufragen - die Faelle entfallen sichtbar. */ |
| 386 | const OHNE_ELECTRON = ELECTRON_PROGRAMM === null || !existsSync(CHROMIUM_UEBERSICHT); |
| 387 | |
| 388 | /** Ein Text auf eine Zeile gebracht - das Verzeichnis bricht Saetze beliebig um. */ |
| 389 | function einzeilig(text: string): string { |
| 390 | return text.replace(/\s+/g, ' '); |
| 391 | } |
| 392 | |
| 393 | /** Die Namen der Eintraege in `LICENSES.chromium.html`, Zeichenreferenzen aufgeloest. */ |
| 394 | function chromiumEintraege(): Set<string> { |
| 395 | const html = readFileSync(CHROMIUM_UEBERSICHT, 'utf8'); |
| 396 | const namen = new Set<string>(); |
| 397 | for (const treffer of html.matchAll(/<span class="title">([^<]*)<\/span>/g)) { |
| 398 | namen.add( |
| 399 | String(treffer[1]) |
| 400 | .replace(/</g, '<') |
| 401 | .replace(/>/g, '>') |
| 402 | .replace(/"/g, '"') |
| 403 | .replace(/'/g, "'") |
| 404 | .replace(/&/g, '&') |
| 405 | .trim(), |
| 406 | ); |
| 407 | } |
| 408 | return namen; |
| 409 | } |
| 410 | |
| 411 | describe('F.2: docs/fremdbestandteile.md haelt gegen den Baum', () => { |
| 412 | const GEBUENDELT = eintraege('In den Renderer gebündelt'); |
| 413 | |
| 414 | /** Wegwerfablage fuer die Laeufe von scripts/lizenztexte.mjs. */ |
| 415 | let ablage = ''; |
| 416 | |
| 417 | beforeAll(() => { |
| 418 | ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-lizenztexte-')); |
| 419 | }); |
| 420 | |
| 421 | afterAll(() => { |
| 422 | rmSync(ablage, { recursive: true, force: true }); |
| 423 | }); |
| 424 | |
| 425 | it('nennt zu jedem Eintrag die Fassung und die Lizenz des installierten Pakets', () => { |
| 426 | expect(GEBUENDELT.length, 'keine Tabellenzeilen gefunden').toBeGreaterThan(0); |
| 427 | for (const eintrag of GEBUENDELT) { |
| 428 | const ist = installiert(eintrag.name); |
| 429 | expect(eintrag.fassung, `Fassung von ${eintrag.name}`).toBe(ist.fassung); |
| 430 | expect(eintrag.lizenz, `Lizenz von ${eintrag.name}`).toBe(ist.lizenz); |
| 431 | } |
| 432 | }); |
| 433 | |
| 434 | it.skipIf(OHNE_BAU)( |
| 435 | 'deckt sich die Aufstellung mit dem, was im gebauten Renderer steckt (nur mit Bauergebnis messbar)', |
| 436 | () => { |
| 437 | // Ohne gebauten Stand entfaellt dieser Fall. Kein Rueckfall auf eine |
| 438 | // schwaechere Zusicherung: Was hier gemessen wird, ist der Zuwachs, und |
| 439 | // den sieht man nur am Bauergebnis. Es bleibt der Fall oben, der jeden |
| 440 | // GENANNTEN Eintrag am installierten Paket prueft. |
| 441 | const ist = [...(BUENDEL as Map<string, string[]>).keys()].sort(); |
| 442 | expect(GEBUENDELT.map((e) => e.name).sort()).toEqual(ist); |
| 443 | }, |
| 444 | ); |
| 445 | |
| 446 | it('sind die Fuehler fuer die Urhebervermerke am installierten Paket belegt', () => { |
| 447 | // Ohne diesen Fall bewacht der naechste nichts: Ein Name, der im Paket |
| 448 | // selbst nirgends steht, steht auch in der Ausgabedatei nicht - und die |
| 449 | // Zaehlung meldete brav "Vermerk fehlt". |
| 450 | expect(Object.keys(RECHTEINHABER).sort()).toEqual(GEBUENDELT.map((e) => e.name).sort()); |
| 451 | for (const [name, inhaber] of Object.entries(RECHTEINHABER)) { |
| 452 | const dateien = lizenzdateien(name); |
| 453 | expect(dateien.length, `keine Lizenzdatei in node_modules/${name}`).toBeGreaterThan(0); |
| 454 | const steht = dateien.some((d) => readFileSync(d, 'utf8').includes(inhaber)); |
| 455 | expect(steht, `"${inhaber}" steht in keiner Lizenzdatei von ${name}`).toBe(true); |
| 456 | } |
| 457 | }); |
| 458 | |
| 459 | it.skipIf(OHNE_BAU)( |
| 460 | 'traegt jede gebuendelte Bibliothek ihren Urhebervermerk in ihrer Ausgabedatei (nur mit Bauergebnis messbar)', |
| 461 | () => { |
| 462 | const zuordnung = BUENDEL as Map<string, string[]>; |
| 463 | const ohne: string[] = []; |
| 464 | for (const [name, inhaber] of Object.entries(RECHTEINHABER)) { |
| 465 | // Nur die Ausgabedateien DIESES Pakets. Ueber alle Dateien gesucht |
| 466 | // faende man Stoyan Stefanov in pdf-*.js und schriebe ihn rgbcolor zu, |
| 467 | // obwohl das dort jsPDFs eigene mitgefuehrte Farbwertzerlegung ist. |
| 468 | const dateien = zuordnung.get(name) ?? []; |
| 469 | expect(dateien.length, `${name} steckt in keiner Ausgabedatei`).toBeGreaterThan(0); |
| 470 | const steht = dateien.some((d) => |
| 471 | readFileSync(path.join(BAUABLAGE, d), 'utf8').includes(inhaber), |
| 472 | ); |
| 473 | if (!steht) ohne.push(name); |
| 474 | } |
| 475 | expect(ohne.sort(), 'diese Bibliotheken tragen ihren Urhebervermerk nicht').toEqual([]); |
| 476 | |
| 477 | // Und das Verzeichnis gibt die Messung wortwoertlich wieder - mit der |
| 478 | // Zahl, die hier herauskommt, nicht mit einer gepflegten. |
| 479 | const anzahl = Object.keys(RECHTEINHABER).length; |
| 480 | const flach = DOK.replace(/\s+/g, ' '); |
| 481 | expect(flach, `alle ${anzahl} Bibliotheken tragen ihren Vermerk`).toContain( |
| 482 | `tragen alle **${ZAHLWORT[anzahl]}** gebündelten Bibliotheken ihren Urheber- und Genehmigungsvermerk.`, |
| 483 | ); |
| 484 | }, |
| 485 | ); |
| 486 | |
| 487 | it.skipIf(OHNE_BAU)( |
| 488 | 'nennt der Banner je Ausgabedatei genau die Bibliotheken, deren Kode darin steckt (nur mit Bauergebnis messbar)', |
| 489 | () => { |
| 490 | /* |
| 491 | * DIE GEGENPROBE ZUM FALL DARUEBER. Dort wird gesucht, ob der Vermerk |
| 492 | * DA ist; hier, ob er am RICHTIGEN Ort steht. Ohne diesen Fall waere die |
| 493 | * billigste Loesung, jeder Ausgabedatei alle dreizehn Texte mitzugeben - |
| 494 | * das saehe genauso gruen aus und waere eine falsche Angabe: In |
| 495 | * index-*.js steckt kein fremder Kode, und ein Vermerk dort behauptete |
| 496 | * das Gegenteil. |
| 497 | * |
| 498 | * Gemessen werden zwei Ableitungen desselben Sachverhalts gegeneinander: |
| 499 | * Der Banner entsteht zur Bauzeit aus `chunk.moduleIds`, die Zuordnung |
| 500 | * hier nach dem Bau aus den Quellkarten. Laufen sie auseinander, faellt |
| 501 | * dieser Fall. |
| 502 | */ |
| 503 | const zuordnung = BUENDEL as Map<string, string[]>; |
| 504 | for (const datei of readdirSync(BAUABLAGE).filter((d) => d.endsWith('.js'))) { |
| 505 | const erwartet = [...zuordnung] |
| 506 | .filter(([, dateien]) => dateien.includes(datei)) |
| 507 | .map(([name]) => name) |
| 508 | .sort(); |
| 509 | expect(bannergenannt(datei), `Banner von ${datei}`).toEqual(erwartet); |
| 510 | } |
| 511 | }, |
| 512 | ); |
| 513 | |
| 514 | it.skipIf(OHNE_BAU)( |
| 515 | 'traegt der Banner den Lizenztext woertlich aus dem installierten Paket (nur mit Bauergebnis messbar)', |
| 516 | () => { |
| 517 | // Der Name des Rechteinhabers allein belegt nur, dass IRGENDETWAS |
| 518 | // dasteht. Der MIT-Text verlangt Urheber- UND Genehmigungsvermerk; |
| 519 | // geprueft wird deshalb der ganze Text, Zeichen fuer Zeichen. |
| 520 | const zuordnung = BUENDEL as Map<string, string[]>; |
| 521 | for (const [name, dateien] of zuordnung) { |
| 522 | for (const quelle of lizenzdateien(name)) { |
| 523 | const text = readFileSync(quelle, 'utf8').replace(/\r\n/g, '\n').trim(); |
| 524 | for (const datei of dateien) { |
| 525 | const code = readFileSync(path.join(BAUABLAGE, datei), 'utf8'); |
| 526 | expect(code, `${quelle} steht nicht in ${datei}`).toContain(text); |
| 527 | } |
| 528 | } |
| 529 | } |
| 530 | }, |
| 531 | ); |
| 532 | |
| 533 | it.skipIf(OHNE_BAU)( |
| 534 | 'bekommt eine Ausgabedatei ohne Fremdbestandteil keinen Banner (nur mit Bauergebnis messbar)', |
| 535 | () => { |
| 536 | // Der Einstiegsbrocken enthaelt nur eigenen Kode. Ein Banner dort waere |
| 537 | // nicht bloss ueberfluessig, sondern unrichtig - und er kostete in der |
| 538 | // groessten Ausgabedatei am meisten. |
| 539 | const zuordnung = BUENDEL as Map<string, string[]>; |
| 540 | const belegt = new Set([...zuordnung.values()].flat()); |
| 541 | const leere = readdirSync(BAUABLAGE) |
| 542 | .filter((d) => d.endsWith('.js')) |
| 543 | .filter((d) => !belegt.has(d)); |
| 544 | expect(leere.length, 'keine Ausgabedatei ohne Fremdbestandteil gefunden').toBeGreaterThan(0); |
| 545 | for (const datei of leere) { |
| 546 | expect( |
| 547 | readFileSync(path.join(BAUABLAGE, datei), 'utf8'), |
| 548 | `${datei} traegt einen Banner, obwohl kein fremder Kode darin steckt`, |
| 549 | ).not.toContain(BANNERBEGINN); |
| 550 | } |
| 551 | }, |
| 552 | ); |
| 553 | |
| 554 | it('nennt der Kopfkommentar so viele uebersprungene Faelle, wie die Datei fuehrt', () => { |
| 555 | /* |
| 556 | * EINE ZAHL IM TEXT, DIE NIEMAND NACHRECHNET, WANDERT. Genau das ist hier |
| 557 | * passiert: Der Kopfkommentar sprach von drei Faellen, waehrend die Datei |
| 558 | * sechs fuehrte, und das Verzeichnis sagte schon sechs. Gezaehlt wird |
| 559 | * deshalb am Kode - an `it.skipIf(OHNE_BAU)` -, und beide Texte muessen |
| 560 | * dieselbe Zahl nennen. |
| 561 | */ |
| 562 | const quelle = readFileSync(EIGENE_QUELLE, 'utf8'); |
| 563 | const anzahl = (quelle.match(/it\.skipIf\(OHNE_BAU\)\(/g) ?? []).length; |
| 564 | expect(anzahl, 'kein uebersprungener Fall gefunden').toBeGreaterThan(0); |
| 565 | const wort = String(ZAHLWORT[anzahl]); |
| 566 | |
| 567 | expect(quelle, `${anzahl} Faelle setzen ein Bauergebnis voraus`).toContain( |
| 568 | `${wort.toUpperCase()} DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS`, |
| 569 | ); |
| 570 | expect(quelle, 'der Kopfkommentar nennt eine andere Zahl').toContain( |
| 571 | `Die ${wort} Faelle werden dann`, |
| 572 | ); |
| 573 | |
| 574 | const flach = DOK.replace(/\s+/g, ' '); |
| 575 | expect(flach, 'das Verzeichnis nennt eine andere Zahl').toContain( |
| 576 | `**${gross(wort)} dieser Zusagen setzen ein Bauergebnis voraus**`, |
| 577 | ); |
| 578 | expect(flach, 'das Verzeichnis nennt eine andere Zahl').toContain(`Alle ${wort} messen an`); |
| 579 | expect(flach, 'das Verzeichnis nennt eine andere Zahl').toContain( |
| 580 | `diese ${wort} Fälle sichtbar`, |
| 581 | ); |
| 582 | }); |
| 583 | |
| 584 | it('nennt der Text so viele Wortlaute des Genehmigungsvermerks, wie die Pakete fuehren', () => { |
| 585 | /* |
| 586 | * "Drei verschiedene Wortlaute" und "drei Pakete weichen ab" ist nicht |
| 587 | * dasselbe - gemessen sind es drei Wortlaute bei zwei abweichenden |
| 588 | * Paketen. Die Zaehlung steht hier, damit der Satz nicht wieder das eine |
| 589 | * sagt und das andere meint. Verglichen wird der Absatz ab |
| 590 | * "Permission is hereby granted", Leerraum vereinheitlicht: Zeilenumbrueche |
| 591 | * unterscheiden keinen Wortlaut. |
| 592 | */ |
| 593 | const gruppen = new Map<string, string[]>(); |
| 594 | for (const name of Object.keys(RECHTEINHABER)) { |
| 595 | const text = lizenzdateien(name) |
| 596 | .map((d) => readFileSync(d, 'utf8').replace(/\r\n/g, '\n')) |
| 597 | .join('\n'); |
| 598 | const stelle = text.indexOf(GENEHMIGUNG); |
| 599 | if (stelle < 0) continue; |
| 600 | const absatz = text.slice(stelle).replace(/\s+/g, ' ').trim(); |
| 601 | gruppen.set(absatz, [...(gruppen.get(absatz) ?? []), name]); |
| 602 | } |
| 603 | const mitVermerk = [...gruppen.values()].flat(); |
| 604 | const groesste = [...gruppen.values()].sort((a, b) => b.length - a.length)[0] ?? []; |
| 605 | const abweichend = mitVermerk.filter((n) => !groesste.includes(n)).sort(); |
| 606 | |
| 607 | const flach = DOK.replace(/\s+/g, ' '); |
| 608 | expect(flach, `${mitVermerk.length} Pakete fuehren einen Genehmigungsvermerk`).toContain( |
| 609 | `die ${ZAHLWORT[mitVermerk.length]} MIT-Pakete führen den Genehmigungsvermerk`, |
| 610 | ); |
| 611 | expect(flach, `${gruppen.size} verschiedene Wortlaute`).toContain( |
| 612 | `in **${ZAHLWORT[gruppen.size]}** verschiedenen Wortlauten`, |
| 613 | ); |
| 614 | expect( |
| 615 | flach, |
| 616 | `${groesste.length} gleichlautend, abweichend ${abweichend.join(', ')}`, |
| 617 | ).toContain( |
| 618 | `${ZAHLWORT[groesste.length]} sind gleichlautend, ${aufzaehlung(abweichend)} weichen je für sich ab`, |
| 619 | ); |
| 620 | |
| 621 | // Und vite.config.ts sagt dasselbe. Der Kommentar dort begruendet, warum |
| 622 | // der ganze Text in den Banner geht; er zaehlt deshalb mit und ist beim |
| 623 | // letzten Mal gerade nicht mitgezogen worden. |
| 624 | const konfiguration = readFileSync('vite.config.ts', 'utf8'); |
| 625 | expect(konfiguration, 'vite.config.ts nennt eine andere Zahl').toContain( |
| 626 | `${ZAHLWORT[gruppen.size]} verschiedenen Wortlauten`, |
| 627 | ); |
| 628 | for (const name of abweichend) { |
| 629 | expect(konfiguration, `vite.config.ts nennt ${name} nicht`).toContain(name); |
| 630 | } |
| 631 | }); |
| 632 | |
| 633 | it('nennt das Verzeichnis die Groesse der DOMPurify-Lizenzdatei in Byte, nicht in Zeichen', () => { |
| 634 | /* |
| 635 | * Der Unterschied ist keine Spitzfindigkeit: Die Datei fuehrt 92 |
| 636 | * Mehrbyte-Zeichen, Zeichen- und Bytezahl gehen um eben diese 92 |
| 637 | * auseinander, und jede andere Zahl des Abschnitts ist in Byte. Sie |
| 638 | * begruendet den groessten Einzelposten der Groessentafel. |
| 639 | */ |
| 640 | const dateien = lizenzdateien('dompurify'); |
| 641 | expect(dateien.length, 'keine Lizenzdatei in node_modules/dompurify').toBe(1); |
| 642 | const roh = readFileSync(String(dateien[0])); |
| 643 | const imBanner = roh.toString('utf8').replace(/\r\n/g, '\n').trim(); |
| 644 | |
| 645 | const flach = DOK.replace(/\s+/g, ' '); |
| 646 | expect(flach, 'das Verzeichnis nennt eine andere Groesse').toContain( |
| 647 | `in einer Datei von ${mitAbstand(roh.length)} Byte mit, von denen ${mitAbstand( |
| 648 | Buffer.byteLength(imBanner, 'utf8'), |
| 649 | )} in den Banner gehen`, |
| 650 | ); |
| 651 | expect(flach, 'die Zeichenzahl steht als Bytezahl da').not.toContain( |
| 652 | `${mitAbstand(imBanner.length)} Byte`, |
| 653 | ); |
| 654 | }); |
| 655 | |
| 656 | it('nennt es die Laufzeit mit der Fassung des installierten Electron', () => { |
| 657 | const laufzeit = eintraege('Die Laufzeit'); |
| 658 | const electron = laufzeit.find((e) => e.name === 'Electron'); |
| 659 | expect(electron, 'kein Eintrag "Electron"').toBeDefined(); |
| 660 | expect(electron?.fassung, 'Fassung von Electron').toBe(installiert('electron').fassung); |
| 661 | }); |
| 662 | |
| 663 | it.skipIf(OHNE_ELECTRON)( |
| 664 | 'nennt es Chromium und Node.js mit den Fassungen, die das installierte Electron meldet (nur mit heruntergeladenem Electron messbar)', |
| 665 | () => { |
| 666 | /* |
| 667 | * Gefragt wird die Programmdatei selbst und nicht eine Liste hier: Die |
| 668 | * Zeile Node.js stand von 5.26.0 bis 5.42.1 auf 20.18.3, waehrend |
| 669 | * Electron 44.3.0 Node.js 24.20.0 mitbringt - eine Fassung, die ein |
| 670 | * Mensch nachzieht, bleibt beim naechsten Sprung wieder stehen. |
| 671 | */ |
| 672 | const lauf = spawnSync( |
| 673 | String(ELECTRON_PROGRAMM), |
| 674 | ['-p', 'JSON.stringify(process.versions)'], |
| 675 | { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }, |
| 676 | ); |
| 677 | expect(lauf.status, `Electron meldete keine Fassungen: ${lauf.stderr}`).toBe(0); |
| 678 | const gemeldet = JSON.parse(lauf.stdout) as Readonly<Record<string, string>>; |
| 679 | // Die Gegenprobe, dass hier die Laufzeit des installierten Pakets antwortet. |
| 680 | expect(gemeldet['electron'], 'die Programmdatei gehoert zu einem anderen Electron').toBe( |
| 681 | installiert('electron').fassung, |
| 682 | ); |
| 683 | |
| 684 | const laufzeit = eintraege('Die Laufzeit'); |
| 685 | for (const [name, schluessel] of [ |
| 686 | ['Chromium', 'chrome'], |
| 687 | ['Node.js', 'node'], |
| 688 | ] as const) { |
| 689 | const eintrag = laufzeit.find((e) => e.name === name); |
| 690 | expect(eintrag, `kein Eintrag "${name}"`).toBeDefined(); |
| 691 | expect(gemeldet[schluessel], `Electron meldet keine Fassung fuer ${name}`).toBeDefined(); |
| 692 | expect(eintrag?.fassung, `Fassung von ${name}`).toBe(gemeldet[schluessel]); |
| 693 | } |
| 694 | }, |
| 695 | ); |
| 696 | |
| 697 | it.skipIf(OHNE_ELECTRON)( |
| 698 | 'nennt es die Namen aus der alten Lizenzzaehlung so, wie die heutige Chromium-Uebersicht sie fuehrt (nur mit heruntergeladenem Electron messbar)', |
| 699 | () => { |
| 700 | /* |
| 701 | * Die Zaehlung reziproker Lizenzen stammt aus der Uebersicht von |
| 702 | * Electron 33.4.11 und ist an der heutigen nicht wiederholt. Was das |
| 703 | * Verzeichnis ueber die heutige sagt, ist deshalb nur dies: welche der |
| 704 | * aufgezaehlten Namen dort noch als Eintrag stehen. Genau das wird hier |
| 705 | * nachgesehen - mit der Zahl und mit den Namen, die fehlen. |
| 706 | */ |
| 707 | const absatz = einzeilig( |
| 708 | DOK.slice( |
| 709 | DOK.indexOf('**Die Einträge beschreiben den Quellbaum'), |
| 710 | DOK.indexOf('\n\n', DOK.indexOf('**Die Einträge beschreiben den Quellbaum')), |
| 711 | ), |
| 712 | ); |
| 713 | const aufgezaehlt = /darunter ((?:`[^`]+`(?:, | und ))+`[^`]+`), also/.exec(absatz); |
| 714 | expect(aufgezaehlt, 'der Absatz zaehlt keine Namen mehr auf').not.toBeNull(); |
| 715 | const namen = [...String(aufgezaehlt?.[1]).matchAll(/`([^`]+)`/g)].map((t) => String(t[1])); |
| 716 | |
| 717 | const heute = chromiumEintraege(); |
| 718 | const vorhanden = namen.filter((n) => heute.has(n)); |
| 719 | const fehlend = namen.filter((n) => !heute.has(n)); |
| 720 | |
| 721 | expect(absatz, `${namen.length} Namen sind aufgezaehlt`).toContain( |
| 722 | `welche der ${String(ZAHLWORT[namen.length])} Namen`, |
| 723 | ); |
| 724 | const aussage = |
| 725 | /noch als Eintrag führt: (\S+); ((?:`[^`]+`(?:, | und )?)+) steh(?:t|en) nicht mehr darin/.exec( |
| 726 | absatz, |
| 727 | ); |
| 728 | expect(aussage, 'der Absatz sagt nicht, welche Namen heute fehlen').not.toBeNull(); |
| 729 | expect(aussage?.[1], 'so viele der Namen stehen heute darin').toBe( |
| 730 | ZAHLWORT[vorhanden.length], |
| 731 | ); |
| 732 | expect( |
| 733 | [...String(aussage?.[2]).matchAll(/`([^`]+)`/g)].map((t) => String(t[1])).sort(), |
| 734 | 'diese Namen fehlen in der heutigen Uebersicht', |
| 735 | ).toEqual([...fehlend].sort()); |
| 736 | |
| 737 | // Und der Satz zu Node.js nennt Eintraege, die es gibt. |
| 738 | const nodeSatz = |
| 739 | /Es steht als Eintrag „([^"]+)" in `LICENSES\.chromium\.html`, ebenso wie „([^"]+)"/.exec( |
| 740 | einzeilig(DOK), |
| 741 | ); |
| 742 | expect(nodeSatz, 'der Satz zu Node.js in der Uebersicht fehlt').not.toBeNull(); |
| 743 | for (const name of [nodeSatz?.[1], nodeSatz?.[2]]) { |
| 744 | expect(heute.has(String(name)), `„${String(name)}" ist kein Eintrag der Uebersicht`).toBe( |
| 745 | true, |
| 746 | ); |
| 747 | } |
| 748 | }, |
| 749 | ); |
| 750 | |
| 751 | it('stimmt die Zahl der Eintraege in der Chromium-Uebersicht', () => { |
| 752 | const uebersicht = 'node_modules/electron/dist/LICENSES.chromium.html'; |
| 753 | if (!existsSync(uebersicht)) return; |
| 754 | const anzahl = (readFileSync(uebersicht, 'utf8').match(/<div class="product">/g) ?? []).length; |
| 755 | expect(DOK, `LICENSES.chromium.html fuehrt ${anzahl} Eintraege`).toContain( |
| 756 | `**${anzahl} Einträge**`, |
| 757 | ); |
| 758 | }); |
| 759 | |
| 760 | it('fuehrt es jeden kuenftigen Eintrag aus dependencies mit', () => { |
| 761 | const laufzeitpakete = Object.keys(PAKET.dependencies ?? {}); |
| 762 | if (laufzeitpakete.length === 0) { |
| 763 | expect(DOK, 'der Text sagt weiterhin, dependencies sei leer').toContain( |
| 764 | 'Der Abschnitt `dependencies` ist leer', |
| 765 | ); |
| 766 | return; |
| 767 | } |
| 768 | for (const name of laufzeitpakete) { |
| 769 | expect(DOK, `${name} steht in dependencies, aber nicht im Verzeichnis`).toContain( |
| 770 | `\`${name}\``, |
| 771 | ); |
| 772 | } |
| 773 | }); |
| 774 | |
| 775 | it('gibt package.json die Lizenztexte und das Verzeichnis mit dem Paket mit', () => { |
| 776 | // extraFiles und nicht extraResources: electron-builder legt seine eigenen |
| 777 | // beiden Lizenzdateien NEBEN die Programmdatei, nicht nach resources/. |
| 778 | const mitgegeben = PAKET.build.extraFiles ?? []; |
| 779 | expect(mitgegeben.map((e) => e.from)).toContain('build/DRITTANBIETER-LIZENZEN.txt'); |
| 780 | expect(mitgegeben.map((e) => e.from)).toContain('docs/fremdbestandteile.md'); |
| 781 | |
| 782 | // Ohne diesen Aufruf legte electron-builder eine Sammeldatei von gestern |
| 783 | // bei - `npm run dist` baut, packt und rechnet die Pruefsummen, aber |
| 784 | // sammelt nicht von sich aus ein. |
| 785 | expect(PAKET.scripts['lizenztexte'], 'kein Skript lizenztexte').toContain( |
| 786 | 'scripts/lizenztexte.mjs', |
| 787 | ); |
| 788 | expect(PAKET.scripts['build'], 'npm run build ruft das Sammelskript nicht').toContain( |
| 789 | 'lizenztexte', |
| 790 | ); |
| 791 | |
| 792 | // Und das Verzeichnis darf den behobenen Punkt nicht weiter als offen |
| 793 | // fuehren. |
| 794 | expect(DOK).not.toContain( |
| 795 | 'Die Lizenztexte der gebündelten Bibliotheken liegen dem Paket nicht', |
| 796 | ); |
| 797 | expect(DOK).toContain('## Was dem Paket beiliegt'); |
| 798 | }); |
| 799 | |
| 800 | it('datiert es seine Messungen auf die Fassung aus package.json', () => { |
| 801 | /* |
| 802 | * Die Fassungsnummer steht einmal - in package.json. Zwei Stellen des |
| 803 | * Verzeichnisses datieren eine Messung und muessen sie deshalb nennen: der |
| 804 | * Kopf und der Spiegelstrich "Was im Paket ankommt". Letzterer zeigte |
| 805 | * zunaechst noch auf einen gebauten Stand 5.11.0 und belegte damit gerade |
| 806 | * NICHT, was der Abschnitt "Was dem Paket beiliegt" zusagt: Ein Stand vor |
| 807 | * 5.12.0 kann DRITTANBIETER-LIZENZEN.txt und FREMDBESTANDTEILE.md nicht |
| 808 | * enthalten haben, weil es beide erst seit 5.12.0 gibt. |
| 809 | * |
| 810 | * Der Rueckblick weiter unten ("Bis 5.11.0 stand hier ...") ist keine |
| 811 | * Messung, sondern eine Angabe darueber, was frueher dastand; er liegt in |
| 812 | * keinem der beiden hier geprueften Stuecke und darf eine aeltere Fassung |
| 813 | * nennen. |
| 814 | */ |
| 815 | const kopf = DOK.split('\n').find((z) => z.startsWith('Stand: Fassung ')); |
| 816 | expect(kopf, 'Zeile "Stand: Fassung ..." nicht gefunden').toBeDefined(); |
| 817 | expect(kopf, 'der Kopf datiert auf eine andere Fassung').toContain( |
| 818 | `Stand: Fassung ${PAKET.version},`, |
| 819 | ); |
| 820 | |
| 821 | const zeilen = DOK.split('\n'); |
| 822 | const anfang = zeilen.findIndex((z) => z.startsWith('- **Was im Paket ankommt**')); |
| 823 | expect(anfang, 'Spiegelstrich "Was im Paket ankommt" nicht gefunden').toBeGreaterThanOrEqual(0); |
| 824 | let ende = anfang + 1; |
| 825 | while ((zeilen[ende] ?? '').startsWith(' ')) ende += 1; |
| 826 | const spiegelstrich = zeilen.slice(anfang, ende).join(' ').replace(/\s+/g, ' '); |
| 827 | const stand = /Standes (\d+\.\d+\.\d+)/.exec(spiegelstrich); |
| 828 | expect(stand, 'der Spiegelstrich nennt keinen gebauten Stand').not.toBeNull(); |
| 829 | expect(stand?.[1], 'der Beleg zeigt nicht auf die ausgelieferte Fassung').toBe(PAKET.version); |
| 830 | }); |
| 831 | |
| 832 | it('zaehlt der Abschnitt "Was dem Paket beiliegt" nur die Dateien mit Lizenztext als solche', () => { |
| 833 | /* |
| 834 | * Der Einleitungssatz geht ueber `build.extraFiles` woertlich an den |
| 835 | * Anwender. Er darf deshalb nicht mehr zusagen als die Tabelle darunter: |
| 836 | * FREMDBESTANDTEILE.md fuehrt keinen Lizenztext, sondern sagt, was das ist, |
| 837 | * woher es kommt und wozu es dient. "Vier Dateien mit Lizenztexten" waere |
| 838 | * eine Zusage, die das Verzeichnis mit seinem "Das ist keine |
| 839 | * Rechtsauskunft" ausdruecklich nicht geben will. |
| 840 | * |
| 841 | * Gemessen wird an der Tabelle, nicht an einer zweiten Zahl im Text: Wer |
| 842 | * eine Zeile hinzunimmt, muss beide Zahlwoerter nachziehen. |
| 843 | */ |
| 844 | const anfang = DOK.indexOf('## Was dem Paket beiliegt'); |
| 845 | expect(anfang, 'Abschnitt "Was dem Paket beiliegt" nicht gefunden').toBeGreaterThanOrEqual(0); |
| 846 | const naechste = DOK.indexOf('\n## ', anfang + 1); |
| 847 | const abschnitt = DOK.slice(anfang, naechste < 0 ? DOK.length : naechste); |
| 848 | const reihen = abschnitt |
| 849 | .split('\n') |
| 850 | .filter((z) => z.startsWith('|')) |
| 851 | .map((z) => z.split('|').map((s) => s.trim())) |
| 852 | .filter((s) => (s[1] ?? '') !== 'Datei' && !(s[1] ?? '').startsWith('---')); |
| 853 | expect(reihen.length, 'keine Tabellenzeilen gefunden').toBeGreaterThan(0); |
| 854 | |
| 855 | // Die Zeile, die dieses Verzeichnis selbst mitgibt, ist keine mit |
| 856 | // Lizenztext - sie steht in der Spalte "Kommt von" als solche da. |
| 857 | const selbst = reihen.filter((s) => (s[2] ?? '').includes('dieses Verzeichnis')).length; |
| 858 | expect(selbst, 'keine Zeile fuer das Verzeichnis selbst').toBe(1); |
| 859 | |
| 860 | const flach = DOK.replace(/\s+/g, ' '); |
| 861 | expect(flach, `die Tabelle fuehrt ${reihen.length} Dateien`).toContain( |
| 862 | `Neben der Programmdatei liegen ${ZAHLWORT[reihen.length]} Dateien`, |
| 863 | ); |
| 864 | expect(flach, `${selbst} davon fuehrt keinen Lizenztext`).toContain( |
| 865 | `${ZAHLWORT[reihen.length - selbst]} mit Lizenztexten`, |
| 866 | ); |
| 867 | }); |
| 868 | |
| 869 | it.skipIf(OHNE_BAU)( |
| 870 | 'schreibt scripts/lizenztexte.mjs zu jeder gebuendelten Bibliothek einen Text (nur mit Bauergebnis messbar)', |
| 871 | () => { |
| 872 | const ziel = path.join(ablage, 'DRITTANBIETER-LIZENZEN.txt'); |
| 873 | const lauf = spawnSync(process.execPath, ['scripts/lizenztexte.mjs', '--ziel', ziel], { |
| 874 | cwd: WURZEL, |
| 875 | encoding: 'utf8', |
| 876 | }); |
| 877 | expect(lauf.status, `das Skript brach ab: ${lauf.stdout}${lauf.stderr}`).toBe(0); |
| 878 | |
| 879 | const sammlung = readFileSync(ziel, 'utf8'); |
| 880 | const gebuendelt = [...(BUENDEL as Map<string, string[]>).keys()].sort(); |
| 881 | // Nicht "irgendwelche Texte", sondern zu JEDER Bibliothek einer: Der |
| 882 | // Name des Rechteinhabers ist derselbe Fuehler, den der Fall oben am |
| 883 | // installierten Paket belegt hat. |
| 884 | for (const name of gebuendelt) { |
| 885 | const inhaber = RECHTEINHABER[name]; |
| 886 | expect(inhaber, `kein Fuehler fuer ${name}`).toBeDefined(); |
| 887 | expect(sammlung, `${name} steht mit keinem Abschnitt in der Sammeldatei`).toContain( |
| 888 | `${name} ${installiert(name).fassung}`, |
| 889 | ); |
| 890 | expect(sammlung, `der Text von ${name} fehlt in der Sammeldatei`).toContain( |
| 891 | String(inhaber), |
| 892 | ); |
| 893 | } |
| 894 | expect(sammlung, 'die Sammeldatei nennt eine andere Zahl').toContain( |
| 895 | `Es folgen ${gebuendelt.length} Texte.`, |
| 896 | ); |
| 897 | // Mit Bytefolgemarke - sonst zeigt ein Windows-Editor aus den Umlauten |
| 898 | // Buchstabensalat, und die Datei liegt beim Anwender, nicht hier. |
| 899 | expect(sammlung.charCodeAt(0), 'keine Bytefolgemarke').toBe(0xfeff); |
| 900 | }, |
| 901 | ); |
| 902 | |
| 903 | it('schreibt es lieber nichts, als eine Bibliothek zu unterschlagen', () => { |
| 904 | // Der gefaehrliche Ausgang waere nicht der Abbruch, sondern eine Datei |
| 905 | // ohne den fehlenden Text: Sie sieht nach Erfuellung aus. Gemessen an |
| 906 | // einer erfundenen Quellkarte, die ein nicht installiertes Paket nennt - |
| 907 | // das braucht kein Bauergebnis und greift auch in einer frischen |
| 908 | // Arbeitskopie. |
| 909 | const bau = path.join(ablage, 'erfundener-bau'); |
| 910 | mkdirSync(bau, { recursive: true }); |
| 911 | writeFileSync( |
| 912 | path.join(bau, 'index-AAAAAAAA.js.map'), |
| 913 | JSON.stringify({ sources: ['../../node_modules/gibt-es-nicht/index.js'] }), |
| 914 | 'utf8', |
| 915 | ); |
| 916 | const ziel = path.join(bau, 'soll-nicht-entstehen.txt'); |
| 917 | |
| 918 | const lauf = spawnSync( |
| 919 | process.execPath, |
| 920 | ['scripts/lizenztexte.mjs', '--bau', bau, '--ziel', ziel], |
| 921 | { cwd: WURZEL, encoding: 'utf8' }, |
| 922 | ); |
| 923 | expect(lauf.status, 'das Skript endete mit 0').not.toBe(0); |
| 924 | expect(`${lauf.stdout}${lauf.stderr}`).toContain('gibt-es-nicht'); |
| 925 | expect(existsSync(ziel), 'es hat trotzdem eine Sammeldatei geschrieben').toBe(false); |
| 926 | }); |
| 927 | |
| 928 | it('gibt es sich nicht als Rechtsauskunft aus', () => { |
| 929 | expect(DOK).toContain('**Das ist keine Rechtsauskunft.**'); |
| 930 | expect(DOK).toContain('## Was aussteht'); |
| 931 | }); |
| 932 | }); |