lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests tools eigeneLizenzImPaket.test.ts

12,4 KB Rohdatei
tests/tools/eigeneLizenzImPaket.test.ts — 279 Zeilen
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 });