lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests tools eigeneLizenzImPaket.test.ts
| 1 | import { createHash } from 'node:crypto'; |
| 2 | import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; |
| 3 | import path from 'node:path'; |
| 4 | import { fileURLToPath } from 'node:url'; |
| 5 | import { describe, expect, it } from 'vitest'; |
| 6 | |
| 7 | /* |
| 8 | * DIE EIGENE LIZENZ GEHOERT INS PAKET. |
| 9 | * |
| 10 | * Anlass war eine Anwenderfrage: Wo kann ich die Lizenzen im Programm ansehen? |
| 11 | * Fuer die FREMDEN Bestandteile lautete die Antwort seit 5.12.0 "neben der |
| 12 | * Programmdatei", fuer die eigene Lizenz "gar nicht". |
| 13 | * |
| 14 | * DIE MESSUNG, DIE DAS ZEIGTE (07.09.2026, am gebauten Stand 5.14.0): Die |
| 15 | * Zeichenfolge EUROPEAN UNION PUBLIC LICENCE kam in |
| 16 | * release/win-unpacked/resources/app.asar null Mal vor, im Setup null Mal und |
| 17 | * in keiner einzigen Datei des Installationsverzeichnisses; dasselbe fuer |
| 18 | * OPEN-SOURCE-LIZENZ FUER DIE EUROPAEISCHE UNION. Beigelegen haben |
| 19 | * DRITTANBIETER-LIZENZEN.txt, FREMDBESTANDTEILE.md, LICENSE.electron.txt und |
| 20 | * LICENSES.chromium.html - vier Dateien, alle vier zu Fremdbestandteilen. |
| 21 | * `LICENSE` und `LICENSE.de.txt` standen nur im Quellbaum. |
| 22 | * |
| 23 | * Artikel 5 der EUPL verlangt bei jeder Verbreitung eine Kopie der Lizenz beim |
| 24 | * Werk. `build.extraFiles` gibt sie jetzt mit. |
| 25 | * |
| 26 | * WARUM DIESE WACHE AM GEBAUTEN STAND MISST. Ein Eintrag in `package.json` |
| 27 | * belegt nur eine Absicht. Ob die Datei beim Anwender ankommt, entscheiden |
| 28 | * electron-builder und der Pfad in `from` - genau daran ist der Befund |
| 29 | * entstanden, dass zwei Dateien im Quellbaum liegen und keine im Paket. |
| 30 | * VIER DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS und werden ohne eines |
| 31 | * uebersprungen. Kein Rueckfall auf eine schwaechere Zusicherung: Ein Fall, |
| 32 | * der ohne Paket auf `package.json` ausweicht, misst wieder die Absicht. |
| 33 | * |
| 34 | * WAS HIER NICHT NOCHMAL GEPRUEFT WIRD: dass der Wortlaut im QUELLBAUM der |
| 35 | * amtliche ist und dass README und `package.json` dieselbe Lizenz nennen. Das |
| 36 | * ist die Wache `tests/domain/lizenzEupl.test.ts`. Hier steht die Gliederung |
| 37 | * ein zweites Mal, weil eine Kopie unterwegs verkuerzt oder umgewandelt werden |
| 38 | * kann - gemessen wird an der Kopie im Paket, nicht an ihrer Vorlage. |
| 39 | * |
| 40 | * DIE BENENNUNG IM PAKET - eine getroffene Entscheidung. Neben der |
| 41 | * Programmdatei liegen jetzt vier Dateien mit LICENSE im Namen, und nur zwei |
| 42 | * davon sind die des Programms. Sie behalten trotzdem ihre Namen aus dem |
| 43 | * Quellbaum, weil genau darin der Unterschied liegt: Jede fremde Beilage |
| 44 | * nennt ihren Bestandteil im Namen (LICENSE.electron.txt, |
| 45 | * LICENSES.chromium.html, DRITTANBIETER-LIZENZEN.txt, FREMDBESTANDTEILE.md), |
| 46 | * die des Werks nennt keinen. Ein eigener Paketname kehrte das um: Die |
| 47 | * eigene Lizenz saehe aus wie die eines weiteren Bestandteils, und |
| 48 | * dasselbe Schriftstueck hiesse im Quellbaum anders als im Paket - eine |
| 49 | * zweite Stelle, an der ein Name auseinanderlaufen kann. Der Preis ist |
| 50 | * benannt: `LICENSE` traegt keine Endung und wird unter Windows nicht von |
| 51 | * selbst in einem Editor geoeffnet. Der Fall unten haelt die Entscheidung |
| 52 | * fest, damit sie nicht stillschweigend umfaellt. |
| 53 | * |
| 54 | * KEINE LIZENZSEITE IM INSTALLER - ebenfalls entschieden. `build.nsis.license` |
| 55 | * gaebe es; electron-builder setzt daraus `!insertmacro MUI_PAGE_LICENSE` |
| 56 | * (nachgelesen in der Datei nsisLicense.js von app-builder-lib, die der |
| 57 | * vorletzte Fall dieser Datei mitprueft). Das ist in NSIS keine Anzeige, |
| 58 | * sondern eine Zustimmungsschranke: Die |
| 59 | * Weiter-Schaltflaeche heisst dort auf Deutsch "Annehmen", und der Seitentext |
| 60 | * lautet "Sie muessen die Lizenzvereinbarungen anerkennen, um ... installieren |
| 61 | * zu koennen" (Contrib/Language files/German.nlf und German.nsh der von |
| 62 | * electron-builder verwendeten NSIS-Fassung 3.0.4.1). Die EUPL ist eine |
| 63 | * Lizenz fuer Weitergabe und Bearbeitung, keine Nutzungsbedingung: Artikel 2 |
| 64 | * erlaubt die Nutzung uneingeschraenkt und knuepft sie an keine Zustimmung. |
| 65 | * Eine erzwungene Zustimmung waere eine Aussage ueber die Rechtslage, die |
| 66 | * dieses Programm nicht treffen soll - und sie erschiene ausgerechnet vor der |
| 67 | * Einrichtung, wo ein Nein die Einrichtung abbraeche. Also nicht gesetzt. |
| 68 | */ |
| 69 | |
| 70 | interface Beilage { |
| 71 | readonly from?: string; |
| 72 | readonly to?: string; |
| 73 | } |
| 74 | |
| 75 | interface Paketdatei { |
| 76 | readonly version: string; |
| 77 | readonly build: { |
| 78 | readonly productName: string; |
| 79 | readonly directories: { readonly buildResources: string }; |
| 80 | readonly extraFiles?: readonly Beilage[]; |
| 81 | readonly nsis?: Readonly<Record<string, unknown>>; |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as Paketdatei; |
| 86 | |
| 87 | /** Das Installationsverzeichnis, wie electron-builder es unter Windows ablegt. */ |
| 88 | const INSTALLVERZEICHNIS = path.join('release', 'win-unpacked'); |
| 89 | |
| 90 | /** |
| 91 | * Ohne fertig geschnuertes Paket ist nichts zu messen. Gefuehlt wird an der |
| 92 | * Programmdatei und nicht am Ordner: `electron-builder --dir` legt ihn als |
| 93 | * erstes an, und ein halb gefuellter Ordner darf nicht als Bauergebnis |
| 94 | * durchgehen. Der Name kommt aus `package.json`, damit eine Umbenennung des |
| 95 | * Erzeugnisses diese Wache nicht stumm ueberspringt. |
| 96 | */ |
| 97 | const OHNE_PAKET = !existsSync(path.join(INSTALLVERZEICHNIS, `${PAKET.build.productName}.exe`)); |
| 98 | |
| 99 | /** Die beiden Sprachfassungen: Name im Quellbaum, Kopfzeile des amtlichen Textes. */ |
| 100 | const SPRACHFASSUNGEN = [ |
| 101 | ['LICENSE', 'EUROPEAN UNION PUBLIC LICENCE v. 1.2'], |
| 102 | ['LICENSE.de.txt', 'OPEN-SOURCE-LIZENZ FÜR DIE EUROPÄISCHE UNION v. 1.2'], |
| 103 | ] as const; |
| 104 | |
| 105 | const ZAHLWORT: Readonly<Record<number, string>> = { |
| 106 | 1: 'einer', |
| 107 | 2: 'zwei', |
| 108 | 3: 'drei', |
| 109 | 4: 'vier', |
| 110 | 5: 'fünf', |
| 111 | 6: 'sechs', |
| 112 | }; |
| 113 | |
| 114 | const EIGENE_QUELLE = fileURLToPath(import.meta.url); |
| 115 | |
| 116 | /** Die Artikelueberschriften "1." bis "15." am Zeilenanfang. */ |
| 117 | function artikel(text: string): number[] { |
| 118 | return [...text.matchAll(/^(\d+)\. /gm)].map((t) => Number(t[1])); |
| 119 | } |
| 120 | |
| 121 | function summe(datei: string): string { |
| 122 | return createHash('sha256').update(readFileSync(datei)).digest('hex'); |
| 123 | } |
| 124 | |
| 125 | /** Was im Installationsverzeichnis unmittelbar neben der Programmdatei liegt. */ |
| 126 | function beilagen(): string[] { |
| 127 | return readdirSync(INSTALLVERZEICHNIS) |
| 128 | .filter((d) => statSync(path.join(INSTALLVERZEICHNIS, d)).isFile()) |
| 129 | .sort(); |
| 130 | } |
| 131 | |
| 132 | describe('Die eigene Lizenz geht mit dem Paket an den Anwender', () => { |
| 133 | /* |
| 134 | * DIE VORBEDINGUNG. Sie ist der einzige Fall, der auch in einer frischen |
| 135 | * Arbeitskopie ohne Paket greift - und er misst nur die Absicht. Was |
| 136 | * daraus wird, messen die vier darunter. |
| 137 | */ |
| 138 | it('gibt package.json beide Sprachfassungen ueber build.extraFiles mit', () => { |
| 139 | const mitgegeben = PAKET.build.extraFiles ?? []; |
| 140 | expect(mitgegeben.length, 'Eintraege in build.extraFiles').toBeGreaterThan(0); |
| 141 | |
| 142 | for (const [datei] of SPRACHFASSUNGEN) { |
| 143 | const eintrag = mitgegeben.find((e) => e.from === datei); |
| 144 | expect(eintrag, `build.extraFiles gibt ${datei} nicht mit`).toBeDefined(); |
| 145 | // Derselbe Name wie im Quellbaum - siehe "Die Benennung im Paket" oben. |
| 146 | expect(eintrag?.to, `Name von ${datei} im Paket`).toBe(datei); |
| 147 | } |
| 148 | }); |
| 149 | |
| 150 | it.skipIf(OHNE_PAKET)( |
| 151 | 'liegen beide Sprachfassungen neben der Programmdatei (nur mit Bauergebnis messbar)', |
| 152 | () => { |
| 153 | for (const [datei] of SPRACHFASSUNGEN) { |
| 154 | expect(beilagen(), `${datei} liegt nicht im Installationsverzeichnis`).toContain(datei); |
| 155 | } |
| 156 | }, |
| 157 | ); |
| 158 | |
| 159 | it.skipIf(OHNE_PAKET)( |
| 160 | 'sind die beiliegenden Fassungen vollstaendig und dieselbe Lizenz (nur mit Bauergebnis messbar)', |
| 161 | () => { |
| 162 | const erwartet = Array.from({ length: 15 }, (_, i) => i + 1); |
| 163 | for (const [datei, kopf] of SPRACHFASSUNGEN) { |
| 164 | const text = readFileSync(path.join(INSTALLVERZEICHNIS, datei), 'utf8'); |
| 165 | expect(text.split('\n')[0]?.trim(), `Kopfzeile von ${datei} im Paket`).toBe(kopf); |
| 166 | expect(artikel(text), `Artikel von ${datei} im Paket`).toEqual(erwartet); |
| 167 | // Ohne die Anlage waere die Copyleft-Wirkung eine andere: Sie fuehrt |
| 168 | // die vertraeglichen Lizenzen auf. |
| 169 | expect(text, `vertraegliche Lizenzen in ${datei} im Paket`).toContain( |
| 170 | 'GNU General Public License (GPL) v. 2, v. 3', |
| 171 | ); |
| 172 | expect(text, `vertraegliche Lizenzen in ${datei} im Paket`).toContain( |
| 173 | 'Mozilla Public Licence (MPL) v. 2', |
| 174 | ); |
| 175 | } |
| 176 | }, |
| 177 | ); |
| 178 | |
| 179 | it.skipIf(OHNE_PAKET)( |
| 180 | 'steht im Paket Byte fuer Byte der Text des Quellbaums (nur mit Bauergebnis messbar)', |
| 181 | () => { |
| 182 | /* |
| 183 | * Der Fall darueber prueft die Gliederung; sie ueberlebt auch eine |
| 184 | * Umwandlung, die Zeilenenden oder die Kodierung veraendert. Hier wird |
| 185 | * Zeichen fuer Zeichen verglichen - eine Lizenzkopie, die nicht mehr die |
| 186 | * Vorlage ist, ist keine Kopie der Lizenz. |
| 187 | */ |
| 188 | for (const [datei] of SPRACHFASSUNGEN) { |
| 189 | expect( |
| 190 | summe(path.join(INSTALLVERZEICHNIS, datei)), |
| 191 | `${datei} im Paket weicht vom Quellbaum ab`, |
| 192 | ).toBe(summe(datei)); |
| 193 | } |
| 194 | }, |
| 195 | ); |
| 196 | |
| 197 | it.skipIf(OHNE_PAKET)( |
| 198 | 'bleibt die eigene Lizenz neben den fremden erkennbar (nur mit Bauergebnis messbar)', |
| 199 | () => { |
| 200 | /* |
| 201 | * DIE ENTSCHEIDUNG ZUR BENENNUNG, als Fall. Vier Dateien tragen LICENSE |
| 202 | * im Namen; unterschieden werden sie daran, dass die fremden ihren |
| 203 | * Bestandteil nennen und die eigenen nicht. Waechst eine fuenfte Beilage |
| 204 | * ohne Bestandteil im Namen hinzu, faellt dieser Fall - und mit ihm die |
| 205 | * Regel, an der ein Anwender die Lizenz des Programms findet. |
| 206 | */ |
| 207 | const mitLizenzImNamen = beilagen().filter((d) => /^licen[cs]e/i.test(d)); |
| 208 | expect(mitLizenzImNamen, 'Dateien mit LICENSE im Namen').toEqual([ |
| 209 | 'LICENSE', |
| 210 | 'LICENSE.de.txt', |
| 211 | 'LICENSE.electron.txt', |
| 212 | 'LICENSES.chromium.html', |
| 213 | ]); |
| 214 | |
| 215 | const ohneBestandteil = mitLizenzImNamen.filter((d) => !/electron|chromium/i.test(d)); |
| 216 | expect(ohneBestandteil, 'Dateien ohne Fremdbestandteil im Namen').toEqual( |
| 217 | SPRACHFASSUNGEN.map(([datei]) => datei), |
| 218 | ); |
| 219 | |
| 220 | // Und sie sagen es in ihrer ersten Zeile, nicht erst auf Seite drei. |
| 221 | for (const [datei, kopf] of SPRACHFASSUNGEN) { |
| 222 | const text = readFileSync(path.join(INSTALLVERZEICHNIS, datei), 'utf8'); |
| 223 | expect(text.split('\n')[0]?.trim(), `Kopfzeile von ${datei}`).toBe(kopf); |
| 224 | } |
| 225 | }, |
| 226 | ); |
| 227 | |
| 228 | it('stellt der Installer der EUPL keine Zustimmungsschranke voran', () => { |
| 229 | /* |
| 230 | * Zwei Wege fuehren zu einer Lizenzseite im NSIS-Installer, und beide |
| 231 | * bleiben zu: die ausdrueckliche Angabe `build.nsis.license` und der |
| 232 | * Rueckfall, mit dem electron-builder in `buildResources` selbst nach |
| 233 | * license.*, eula.* oder license_<sprache>.* sucht |
| 234 | * (out/util/license.js). Der dritte Ausdruck belegt, dass die Angabe |
| 235 | * wirklich die Zustimmungsseite erzeugt - faellt er, hat electron-builder |
| 236 | * sein Verhalten geaendert und die Entscheidung oben ist neu zu treffen. |
| 237 | */ |
| 238 | expect(Object.keys(PAKET.build.nsis ?? {}), 'Schluessel unter build.nsis').not.toContain( |
| 239 | 'license', |
| 240 | ); |
| 241 | |
| 242 | const bauhilfen = readdirSync(PAKET.build.directories.buildResources); |
| 243 | expect( |
| 244 | bauhilfen.filter((d) => /^(license|eula)[._]/i.test(d)), |
| 245 | 'electron-builder faende in buildResources von selbst eine Lizenzseite', |
| 246 | ).toEqual([]); |
| 247 | |
| 248 | const erzeuger = 'node_modules/app-builder-lib/out/targets/nsis/nsisLicense.js'; |
| 249 | expect(existsSync(erzeuger), `${erzeuger} nicht gefunden`).toBe(true); |
| 250 | expect( |
| 251 | readFileSync(erzeuger, 'utf8'), |
| 252 | 'electron-builder erzeugt keine MUI_PAGE_LICENSE mehr - Entscheidung neu treffen', |
| 253 | ).toContain('MUI_PAGE_LICENSE'); |
| 254 | }); |
| 255 | |
| 256 | it('nennt der Kopfkommentar so viele uebersprungene Faelle, wie die Datei fuehrt', () => { |
| 257 | /* |
| 258 | * Eine Zahl im Text, die niemand nachrechnet, wandert. Gezaehlt wird |
| 259 | * deshalb am Kode. Und jeder uebersprungene Fall muss den Grund im Namen |
| 260 | * tragen: Im Bericht steht sonst nur "skipped", und ein uebersprungener |
| 261 | * Fall sieht wie ein ausgelassener aus. |
| 262 | */ |
| 263 | const quelle = readFileSync(EIGENE_QUELLE, 'utf8'); |
| 264 | const anzahl = (quelle.match(/it\.skipIf\(OHNE_PAKET\)\(/g) ?? []).length; |
| 265 | expect(anzahl, 'kein uebersprungener Fall gefunden').toBeGreaterThan(0); |
| 266 | |
| 267 | expect(quelle, `${anzahl} Faelle setzen ein Bauergebnis voraus`).toContain( |
| 268 | `${String(ZAHLWORT[anzahl]).toUpperCase()} DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS`, |
| 269 | ); |
| 270 | |
| 271 | const titel = [...quelle.matchAll(/it\.skipIf\(OHNE_PAKET\)\(\s*'([^']*)'/g)].map((t) => t[1]); |
| 272 | expect(titel.length, 'nicht jeder uebersprungene Fall hat einen lesbaren Namen').toBe(anzahl); |
| 273 | for (const name of titel) { |
| 274 | expect(name, `"${String(name)}" sagt den Grund des Ueberspringens nicht`).toContain( |
| 275 | '(nur mit Bauergebnis messbar)', |
| 276 | ); |
| 277 | } |
| 278 | }); |
| 279 | }); |