import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; /* * DIE EIGENE LIZENZ GEHOERT INS PAKET. * * Anlass war eine Anwenderfrage: Wo kann ich die Lizenzen im Programm ansehen? * Fuer die FREMDEN Bestandteile lautete die Antwort seit 5.12.0 "neben der * Programmdatei", fuer die eigene Lizenz "gar nicht". * * DIE MESSUNG, DIE DAS ZEIGTE (07.09.2026, am gebauten Stand 5.14.0): Die * Zeichenfolge EUROPEAN UNION PUBLIC LICENCE kam in * release/win-unpacked/resources/app.asar null Mal vor, im Setup null Mal und * in keiner einzigen Datei des Installationsverzeichnisses; dasselbe fuer * OPEN-SOURCE-LIZENZ FUER DIE EUROPAEISCHE UNION. Beigelegen haben * DRITTANBIETER-LIZENZEN.txt, FREMDBESTANDTEILE.md, LICENSE.electron.txt und * LICENSES.chromium.html - vier Dateien, alle vier zu Fremdbestandteilen. * `LICENSE` und `LICENSE.de.txt` standen nur im Quellbaum. * * Artikel 5 der EUPL verlangt bei jeder Verbreitung eine Kopie der Lizenz beim * Werk. `build.extraFiles` gibt sie jetzt mit. * * WARUM DIESE WACHE AM GEBAUTEN STAND MISST. Ein Eintrag in `package.json` * belegt nur eine Absicht. Ob die Datei beim Anwender ankommt, entscheiden * electron-builder und der Pfad in `from` - genau daran ist der Befund * entstanden, dass zwei Dateien im Quellbaum liegen und keine im Paket. * VIER DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS und werden ohne eines * uebersprungen. Kein Rueckfall auf eine schwaechere Zusicherung: Ein Fall, * der ohne Paket auf `package.json` ausweicht, misst wieder die Absicht. * * WAS HIER NICHT NOCHMAL GEPRUEFT WIRD: dass der Wortlaut im QUELLBAUM der * amtliche ist und dass README und `package.json` dieselbe Lizenz nennen. Das * ist die Wache `tests/domain/lizenzEupl.test.ts`. Hier steht die Gliederung * ein zweites Mal, weil eine Kopie unterwegs verkuerzt oder umgewandelt werden * kann - gemessen wird an der Kopie im Paket, nicht an ihrer Vorlage. * * DIE BENENNUNG IM PAKET - eine getroffene Entscheidung. Neben der * Programmdatei liegen jetzt vier Dateien mit LICENSE im Namen, und nur zwei * davon sind die des Programms. Sie behalten trotzdem ihre Namen aus dem * Quellbaum, weil genau darin der Unterschied liegt: Jede fremde Beilage * nennt ihren Bestandteil im Namen (LICENSE.electron.txt, * LICENSES.chromium.html, DRITTANBIETER-LIZENZEN.txt, FREMDBESTANDTEILE.md), * die des Werks nennt keinen. Ein eigener Paketname kehrte das um: Die * eigene Lizenz saehe aus wie die eines weiteren Bestandteils, und * dasselbe Schriftstueck hiesse im Quellbaum anders als im Paket - eine * zweite Stelle, an der ein Name auseinanderlaufen kann. Der Preis ist * benannt: `LICENSE` traegt keine Endung und wird unter Windows nicht von * selbst in einem Editor geoeffnet. Der Fall unten haelt die Entscheidung * fest, damit sie nicht stillschweigend umfaellt. * * KEINE LIZENZSEITE IM INSTALLER - ebenfalls entschieden. `build.nsis.license` * gaebe es; electron-builder setzt daraus `!insertmacro MUI_PAGE_LICENSE` * (nachgelesen in der Datei nsisLicense.js von app-builder-lib, die der * vorletzte Fall dieser Datei mitprueft). Das ist in NSIS keine Anzeige, * sondern eine Zustimmungsschranke: Die * Weiter-Schaltflaeche heisst dort auf Deutsch "Annehmen", und der Seitentext * lautet "Sie muessen die Lizenzvereinbarungen anerkennen, um ... installieren * zu koennen" (Contrib/Language files/German.nlf und German.nsh der von * electron-builder verwendeten NSIS-Fassung 3.0.4.1). Die EUPL ist eine * Lizenz fuer Weitergabe und Bearbeitung, keine Nutzungsbedingung: Artikel 2 * erlaubt die Nutzung uneingeschraenkt und knuepft sie an keine Zustimmung. * Eine erzwungene Zustimmung waere eine Aussage ueber die Rechtslage, die * dieses Programm nicht treffen soll - und sie erschiene ausgerechnet vor der * Einrichtung, wo ein Nein die Einrichtung abbraeche. Also nicht gesetzt. */ interface Beilage { readonly from?: string; readonly to?: string; } interface Paketdatei { readonly version: string; readonly build: { readonly productName: string; readonly directories: { readonly buildResources: string }; readonly extraFiles?: readonly Beilage[]; readonly nsis?: Readonly>; }; } const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as Paketdatei; /** Das Installationsverzeichnis, wie electron-builder es unter Windows ablegt. */ const INSTALLVERZEICHNIS = path.join('release', 'win-unpacked'); /** * Ohne fertig geschnuertes Paket ist nichts zu messen. Gefuehlt wird an der * Programmdatei und nicht am Ordner: `electron-builder --dir` legt ihn als * erstes an, und ein halb gefuellter Ordner darf nicht als Bauergebnis * durchgehen. Der Name kommt aus `package.json`, damit eine Umbenennung des * Erzeugnisses diese Wache nicht stumm ueberspringt. */ const OHNE_PAKET = !existsSync(path.join(INSTALLVERZEICHNIS, `${PAKET.build.productName}.exe`)); /** Die beiden Sprachfassungen: Name im Quellbaum, Kopfzeile des amtlichen Textes. */ const SPRACHFASSUNGEN = [ ['LICENSE', 'EUROPEAN UNION PUBLIC LICENCE v. 1.2'], ['LICENSE.de.txt', 'OPEN-SOURCE-LIZENZ FÜR DIE EUROPÄISCHE UNION v. 1.2'], ] as const; const ZAHLWORT: Readonly> = { 1: 'einer', 2: 'zwei', 3: 'drei', 4: 'vier', 5: 'fünf', 6: 'sechs', }; const EIGENE_QUELLE = fileURLToPath(import.meta.url); /** Die Artikelueberschriften "1." bis "15." am Zeilenanfang. */ function artikel(text: string): number[] { return [...text.matchAll(/^(\d+)\. /gm)].map((t) => Number(t[1])); } function summe(datei: string): string { return createHash('sha256').update(readFileSync(datei)).digest('hex'); } /** Was im Installationsverzeichnis unmittelbar neben der Programmdatei liegt. */ function beilagen(): string[] { return readdirSync(INSTALLVERZEICHNIS) .filter((d) => statSync(path.join(INSTALLVERZEICHNIS, d)).isFile()) .sort(); } describe('Die eigene Lizenz geht mit dem Paket an den Anwender', () => { /* * DIE VORBEDINGUNG. Sie ist der einzige Fall, der auch in einer frischen * Arbeitskopie ohne Paket greift - und er misst nur die Absicht. Was * daraus wird, messen die vier darunter. */ it('gibt package.json beide Sprachfassungen ueber build.extraFiles mit', () => { const mitgegeben = PAKET.build.extraFiles ?? []; expect(mitgegeben.length, 'Eintraege in build.extraFiles').toBeGreaterThan(0); for (const [datei] of SPRACHFASSUNGEN) { const eintrag = mitgegeben.find((e) => e.from === datei); expect(eintrag, `build.extraFiles gibt ${datei} nicht mit`).toBeDefined(); // Derselbe Name wie im Quellbaum - siehe "Die Benennung im Paket" oben. expect(eintrag?.to, `Name von ${datei} im Paket`).toBe(datei); } }); it.skipIf(OHNE_PAKET)( 'liegen beide Sprachfassungen neben der Programmdatei (nur mit Bauergebnis messbar)', () => { for (const [datei] of SPRACHFASSUNGEN) { expect(beilagen(), `${datei} liegt nicht im Installationsverzeichnis`).toContain(datei); } }, ); it.skipIf(OHNE_PAKET)( 'sind die beiliegenden Fassungen vollstaendig und dieselbe Lizenz (nur mit Bauergebnis messbar)', () => { const erwartet = Array.from({ length: 15 }, (_, i) => i + 1); for (const [datei, kopf] of SPRACHFASSUNGEN) { const text = readFileSync(path.join(INSTALLVERZEICHNIS, datei), 'utf8'); expect(text.split('\n')[0]?.trim(), `Kopfzeile von ${datei} im Paket`).toBe(kopf); expect(artikel(text), `Artikel von ${datei} im Paket`).toEqual(erwartet); // Ohne die Anlage waere die Copyleft-Wirkung eine andere: Sie fuehrt // die vertraeglichen Lizenzen auf. expect(text, `vertraegliche Lizenzen in ${datei} im Paket`).toContain( 'GNU General Public License (GPL) v. 2, v. 3', ); expect(text, `vertraegliche Lizenzen in ${datei} im Paket`).toContain( 'Mozilla Public Licence (MPL) v. 2', ); } }, ); it.skipIf(OHNE_PAKET)( 'steht im Paket Byte fuer Byte der Text des Quellbaums (nur mit Bauergebnis messbar)', () => { /* * Der Fall darueber prueft die Gliederung; sie ueberlebt auch eine * Umwandlung, die Zeilenenden oder die Kodierung veraendert. Hier wird * Zeichen fuer Zeichen verglichen - eine Lizenzkopie, die nicht mehr die * Vorlage ist, ist keine Kopie der Lizenz. */ for (const [datei] of SPRACHFASSUNGEN) { expect( summe(path.join(INSTALLVERZEICHNIS, datei)), `${datei} im Paket weicht vom Quellbaum ab`, ).toBe(summe(datei)); } }, ); it.skipIf(OHNE_PAKET)( 'bleibt die eigene Lizenz neben den fremden erkennbar (nur mit Bauergebnis messbar)', () => { /* * DIE ENTSCHEIDUNG ZUR BENENNUNG, als Fall. Vier Dateien tragen LICENSE * im Namen; unterschieden werden sie daran, dass die fremden ihren * Bestandteil nennen und die eigenen nicht. Waechst eine fuenfte Beilage * ohne Bestandteil im Namen hinzu, faellt dieser Fall - und mit ihm die * Regel, an der ein Anwender die Lizenz des Programms findet. */ const mitLizenzImNamen = beilagen().filter((d) => /^licen[cs]e/i.test(d)); expect(mitLizenzImNamen, 'Dateien mit LICENSE im Namen').toEqual([ 'LICENSE', 'LICENSE.de.txt', 'LICENSE.electron.txt', 'LICENSES.chromium.html', ]); const ohneBestandteil = mitLizenzImNamen.filter((d) => !/electron|chromium/i.test(d)); expect(ohneBestandteil, 'Dateien ohne Fremdbestandteil im Namen').toEqual( SPRACHFASSUNGEN.map(([datei]) => datei), ); // Und sie sagen es in ihrer ersten Zeile, nicht erst auf Seite drei. for (const [datei, kopf] of SPRACHFASSUNGEN) { const text = readFileSync(path.join(INSTALLVERZEICHNIS, datei), 'utf8'); expect(text.split('\n')[0]?.trim(), `Kopfzeile von ${datei}`).toBe(kopf); } }, ); it('stellt der Installer der EUPL keine Zustimmungsschranke voran', () => { /* * Zwei Wege fuehren zu einer Lizenzseite im NSIS-Installer, und beide * bleiben zu: die ausdrueckliche Angabe `build.nsis.license` und der * Rueckfall, mit dem electron-builder in `buildResources` selbst nach * license.*, eula.* oder license_.* sucht * (out/util/license.js). Der dritte Ausdruck belegt, dass die Angabe * wirklich die Zustimmungsseite erzeugt - faellt er, hat electron-builder * sein Verhalten geaendert und die Entscheidung oben ist neu zu treffen. */ expect(Object.keys(PAKET.build.nsis ?? {}), 'Schluessel unter build.nsis').not.toContain( 'license', ); const bauhilfen = readdirSync(PAKET.build.directories.buildResources); expect( bauhilfen.filter((d) => /^(license|eula)[._]/i.test(d)), 'electron-builder faende in buildResources von selbst eine Lizenzseite', ).toEqual([]); const erzeuger = 'node_modules/app-builder-lib/out/targets/nsis/nsisLicense.js'; expect(existsSync(erzeuger), `${erzeuger} nicht gefunden`).toBe(true); expect( readFileSync(erzeuger, 'utf8'), 'electron-builder erzeugt keine MUI_PAGE_LICENSE mehr - Entscheidung neu treffen', ).toContain('MUI_PAGE_LICENSE'); }); it('nennt der Kopfkommentar so viele uebersprungene Faelle, wie die Datei fuehrt', () => { /* * Eine Zahl im Text, die niemand nachrechnet, wandert. Gezaehlt wird * deshalb am Kode. Und jeder uebersprungene Fall muss den Grund im Namen * tragen: Im Bericht steht sonst nur "skipped", und ein uebersprungener * Fall sieht wie ein ausgelassener aus. */ const quelle = readFileSync(EIGENE_QUELLE, 'utf8'); const anzahl = (quelle.match(/it\.skipIf\(OHNE_PAKET\)\(/g) ?? []).length; expect(anzahl, 'kein uebersprungener Fall gefunden').toBeGreaterThan(0); expect(quelle, `${anzahl} Faelle setzen ein Bauergebnis voraus`).toContain( `${String(ZAHLWORT[anzahl]).toUpperCase()} DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS`, ); const titel = [...quelle.matchAll(/it\.skipIf\(OHNE_PAKET\)\(\s*'([^']*)'/g)].map((t) => t[1]); expect(titel.length, 'nicht jeder uebersprungene Fall hat einen lesbaren Namen').toBe(anzahl); for (const name of titel) { expect(name, `"${String(name)}" sagt den Grund des Ueberspringens nicht`).toContain( '(nur mit Bauergebnis messbar)', ); } }); });