lsa-planer

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

/ tests tools nodeVoraussetzung.test.ts

10,6 KB Rohdatei
tests/tools/nodeVoraussetzung.test.ts — 255 Zeilen
1 import { readFileSync } from 'node:fs';
2 import { describe, expect, it } from 'vitest';
3
4 /**
5 * Die Node-Fassung, die die README als Voraussetzung nennt, gegen die
6 * Angaben der Bauabhaengigkeiten.
7 *
8 * WARUM ES DIESEN FALL GIBT. Die README nannte "Node.js 20 oder neuer". Die
9 * Bauabhaengigkeiten verlangen selbst mehr: Electron mindestens 22.12, ESLint
10 * `^20.19.0 || ^22.13.0 || >=24`, womit auch Node 23 herausfaellt. Unter
11 * Node 20 liefe der Bau also ausserhalb dessen, was die Pakete zusagen; was
12 * dabei geschieht, ist nicht gemessen. Die README ist die Bauanleitung, und
13 * die Zahl stand ungeprueft da, weil kein Fall sie las.
14 *
15 * WOGEGEN GEPRUEFT WIRD: die Angabe `engines.node` jedes Pakets in
16 * `package-lock.json`, das `npm ci` unter Windows auf x64 einrichtet.
17 * Ausgenommen sind nur die Pakete, deren `os` oder `cpu` diese Plattform
18 * ausschliesst - die Bausteine fuer andere Plattformen, die npm dort gar nicht
19 * laedt. Die Markierung `optional` entscheidet das nicht: Sie tragen auch
20 * Pakete, die npm unter Windows einrichtet. Verlangt wird zweierlei: Die
21 * genannte Fassung genuegt jedem Paket, und die Fassung knapp darunter genuegt
22 * einem nicht - die README nennt also die Untergrenze und keine hoehere Zahl.
23 * Jede Hauptfassung darueber, die ein Paket ausschliesst, muss die README
24 * ausdruecklich ausnehmen.
25 *
26 * Die Bereichsangaben liest ein kleiner Vergleich hier, weil `semver` nur
27 * mittelbar im Baum liegt. Er kennt genau die Formen, die in der Sperrdatei
28 * vorkommen, und bricht bei jeder anderen ab, statt sie fuer erfuellt zu
29 * halten.
30 */
31
32 interface Fassung {
33 readonly haupt: number;
34 readonly neben: number;
35 readonly flick: number;
36 }
37
38 type Vergleich = (f: Fassung) => boolean;
39
40 function vergleiche(a: Fassung, b: Fassung): number {
41 return a.haupt - b.haupt || a.neben - b.neben || a.flick - b.flick;
42 }
43
44 function fassung(text: string): Fassung {
45 const teile = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
46 if (teile === null) throw new Error(`Keine Fassung: ${text}`);
47 return { haupt: Number(teile[1]), neben: Number(teile[2]), flick: Number(teile[3]) };
48 }
49
50 /** Ein einzelnes Vergleichsglied, etwa `>=22.12.0`, `^20.19.0`, `20`, `10.*`. */
51 function glied(text: string): Vergleich {
52 const form = /^(>=|>|<=|<|=|\^)?v?(\d+)(?:\.(\d+|\*|x))?(?:\.(\d+|\*|x))?$/.exec(text);
53 if (form === null) throw new Error(`Bereichsangabe nicht gelesen: "${text}"`);
54 const [, zeichen, h, n, f] = form;
55 const zahl = (teil: string | undefined): number | null =>
56 teil === undefined || teil === '*' || teil === 'x' ? null : Number(teil);
57 const haupt = Number(h);
58 const neben = zahl(n);
59 const flick = zahl(f);
60 const unten: Fassung = { haupt, neben: neben ?? 0, flick: flick ?? 0 };
61 switch (zeichen) {
62 case '>=':
63 return (v) => vergleiche(v, unten) >= 0;
64 case '>':
65 if (neben === null || flick === null) throw new Error(`Unvollstaendig: "${text}"`);
66 return (v) => vergleiche(v, unten) > 0;
67 case '<':
68 return (v) => vergleiche(v, unten) < 0;
69 case '<=':
70 if (neben === null || flick === null) throw new Error(`Unvollstaendig: "${text}"`);
71 return (v) => vergleiche(v, unten) <= 0;
72 case '^':
73 if (haupt === 0) throw new Error(`^0.x wird hier nicht gelesen: "${text}"`);
74 return (v) => vergleiche(v, unten) >= 0 && v.haupt === haupt;
75 case '=':
76 case undefined:
77 // Ohne Zeichen ein Bereich ueber die fehlenden Stellen: "20" ist 20.x.x.
78 return (v) =>
79 v.haupt === haupt &&
80 (neben === null || v.neben === neben) &&
81 (flick === null || v.flick === flick);
82 default:
83 throw new Error(`Unbekanntes Zeichen in "${text}"`);
84 }
85 }
86
87 /** Eine ganze Angabe wie `^20.19.0 || ^22.13.0 || >=24` oder `>=16 || 14 >=14.17`. */
88 function bereich(text: string): Vergleich {
89 const wege = text.split('||').map((weg) => {
90 const glieder = weg
91 .trim()
92 .replace(/(>=|<=|>|<|=|\^)\s+/g, '$1')
93 .split(/\s+/)
94 .filter((g) => g !== '' && g !== '*')
95 .map(glied);
96 return (v: Fassung) => glieder.every((g) => g(v));
97 });
98 return (v) => wege.some((w) => w(v));
99 }
100
101 interface Paketeintrag {
102 readonly optional?: boolean;
103 readonly os?: readonly string[];
104 readonly cpu?: readonly string[];
105 readonly engines?: { readonly node?: string };
106 }
107
108 /**
109 * Laesst eine Angabe `os` oder `cpu` den Wert zu? So liest npm sie: Fehlt sie,
110 * gilt jeder Wert; ein Eintrag mit "!" schliesst seinen Wert aus; gibt es
111 * Eintraege ohne "!", muss der Wert unter ihnen stehen.
112 */
113 function laesstZu(angabe: readonly string[] | undefined, wert: string): boolean {
114 if (angabe === undefined) return true;
115 if (angabe.includes(`!${wert}`)) return false;
116 const erlaubt = angabe.filter((eintrag) => !eintrag.startsWith('!'));
117 return erlaubt.length === 0 || erlaubt.includes(wert);
118 }
119
120 /** Richtet npm das Paket unter Windows auf x64 ein? */
121 function aufWindowsX64(eintrag: Paketeintrag): boolean {
122 return laesstZu(eintrag.os, 'win32') && laesstZu(eintrag.cpu, 'x64');
123 }
124
125 const SPERRDATEI = JSON.parse(readFileSync('package-lock.json', 'utf8')) as {
126 readonly packages: Readonly<Record<string, Paketeintrag>>;
127 };
128
129 /** Je eingerichtetem Paket seine Node-Angabe. */
130 const ANGABEN: readonly { readonly paket: string; readonly passt: Vergleich }[] = Object.entries(
131 SPERRDATEI.packages,
132 )
133 .filter(([pfad, eintrag]) => pfad !== '' && aufWindowsX64(eintrag))
134 .flatMap(([pfad, eintrag]) => {
135 const angabe = eintrag.engines?.node;
136 return angabe === undefined ? [] : [{ paket: pfad, passt: bereich(angabe) }];
137 });
138
139 /** Die Pakete, denen die Fassung nicht genuegt. */
140 function abgelehntVon(v: Fassung): string[] {
141 return ANGABEN.filter(({ passt }) => !passt(v)).map(({ paket }) => paket);
142 }
143
144 const README = readFileSync('README.md', 'utf8').replace(/\s+/g, ' ');
145 const VORAUSSETZUNGEN = README.slice(
146 README.indexOf('## Voraussetzungen'),
147 README.indexOf('## Bauen'),
148 );
149
150 describe('Die Node-Fassung in der README gegen die Bauabhaengigkeiten', () => {
151 const untergrenze = /Node\.js (\d+)\.(\d+) oder neuer/.exec(VORAUSSETZUNGEN);
152 const ausgenommen = [...VORAUSSETZUNGEN.matchAll(/außer Node\.js (\d+)/g)].map((t) =>
153 Number(t[1]),
154 );
155
156 it('liest die Sperrdatei ueberhaupt', () => {
157 // Liefe der Vergleich ueber eine leere Liste, waere alles unten gruen.
158 expect(ANGABEN.length, 'Pakete mit Node-Angabe').toBeGreaterThan(50);
159 expect(
160 ANGABEN.some(({ paket }) => paket === 'node_modules/electron'),
161 'Electron fehlt in der Liste',
162 ).toBe(true);
163 });
164
165 /*
166 * `optional` ist kein Plattformmerkmal: Auch Pakete, die npm unter Windows
167 * einrichtet, tragen es - etwa die Bibliotheken, die jsPDF wahlweise
168 * einbindet, oder der esbuild-Baustein fuer Windows selbst. Ausgenommen
169 * werden darf nur, was npm dort gar nicht einrichtet.
170 */
171 it('prueft jedes Paket, das npm unter Windows auf x64 einrichtet, auch ein optionales', () => {
172 const gefuehrt = new Set(ANGABEN.map(({ paket }) => paket));
173 for (const paket of ['node_modules/html2canvas', 'node_modules/@esbuild/win32-x64']) {
174 expect(SPERRDATEI.packages[paket]?.optional, `${paket} ist optional`).toBe(true);
175 expect(gefuehrt.has(paket), `${paket} fehlt im Vergleich`).toBe(true);
176 }
177 for (const paket of [
178 'node_modules/@esbuild/linux-x64',
179 'node_modules/@esbuild/win32-arm64',
180 'node_modules/@napi-rs/lzma-linux-x64-gnu',
181 ]) {
182 expect(gefuehrt.has(paket), `${paket} wird unter Windows x64 nicht eingerichtet`).toBe(false);
183 }
184 });
185
186 it('nennt eine Untergrenze mit Haupt- und Nebenfassung', () => {
187 expect(untergrenze, 'README: "Node.js <x>.<y> oder neuer" fehlt').not.toBeNull();
188 });
189
190 it('nennt eine Fassung, die jedem Paket genuegt, und keine hoehere als noetig', () => {
191 const haupt = Number(untergrenze?.[1]);
192 const neben = Number(untergrenze?.[2]);
193 expect(abgelehntVon({ haupt, neben, flick: 0 }), 'lehnen die genannte Fassung ab').toEqual([]);
194 const darunter: Fassung =
195 neben > 0
196 ? { haupt, neben: neben - 1, flick: 999 }
197 : { haupt: haupt - 1, neben: 999, flick: 999 };
198 expect(
199 abgelehntVon(darunter).length,
200 `${String(darunter.haupt)}.${String(darunter.neben)} genuegte auch - die README nennt eine zu hohe Untergrenze`,
201 ).toBeGreaterThan(0);
202 });
203
204 it('nimmt jede Hauptfassung darueber aus, die ein Paket ablehnt, und nur diese', () => {
205 const haupt = Number(untergrenze?.[1]);
206 const abgelehnt: number[] = [];
207 for (let h = haupt + 1; h <= haupt + 10; h += 1) {
208 if (abgelehntVon({ haupt: h, neben: 0, flick: 0 }).length > 0) abgelehnt.push(h);
209 }
210 expect(ausgenommen, 'README: "außer Node.js <x>"').toEqual(abgelehnt);
211 for (const h of ausgenommen) {
212 expect(
213 abgelehntVon({ haupt: h, neben: 999, flick: 999 }).length,
214 `Node.js ${String(h)} ist ausgenommen, genuegt aber in jeder Nebenfassung`,
215 ).toBeGreaterThan(0);
216 }
217 });
218 });
219
220 describe('Der Vergleich selbst', () => {
221 // Ohne diese Faelle stuende die Pruefung oben auf einer ungeprueften
222 // Zeichenkettenrechnung.
223 const v = fassung;
224 it('liest die Formen der Sperrdatei so, wie npm sie liest', () => {
225 expect(bereich('>= 22.12.0')(v('22.12.0'))).toBe(true);
226 expect(bereich('>= 22.12.0')(v('22.11.9'))).toBe(false);
227 expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('22.13.0'))).toBe(true);
228 expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('22.12.5'))).toBe(false);
229 expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('23.4.0'))).toBe(false);
230 expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('24.0.0'))).toBe(true);
231 expect(bereich('18 || 20 || >=22')(v('21.0.0'))).toBe(false);
232 expect(bereich('18 || 20 || >=22')(v('20.3.1'))).toBe(true);
233 expect(bereich('>=16 || 14 >=14.17')(v('14.16.0'))).toBe(false);
234 expect(bereich('>=16 || 14 >=14.17')(v('14.17.0'))).toBe(true);
235 expect(bereich('6.* || 8.* || >= 10.*')(v('7.0.0'))).toBe(false);
236 expect(bereich('6.* || 8.* || >= 10.*')(v('10.0.0'))).toBe(true);
237 expect(bereich('*')(v('1.0.0'))).toBe(true);
238 });
239
240 it('bricht bei einer Form ab, die er nicht kennt, statt sie fuer erfuellt zu halten', () => {
241 expect(() => bereich('~1.2.3')).toThrow();
242 expect(() => bereich('1.2.3 - 2.0.0')).toThrow();
243 });
244
245 it('liest os und cpu so, wie npm sie liest', () => {
246 expect(aufWindowsX64({})).toBe(true);
247 expect(aufWindowsX64({ os: ['win32'], cpu: ['x64'] })).toBe(true);
248 expect(aufWindowsX64({ os: ['win32', 'linux'] })).toBe(true);
249 expect(aufWindowsX64({ os: ['!darwin'], cpu: ['!arm64'] })).toBe(true);
250 expect(aufWindowsX64({ os: ['darwin'] })).toBe(false);
251 expect(aufWindowsX64({ os: ['!win32'] })).toBe(false);
252 expect(aufWindowsX64({ os: ['win32'], cpu: ['arm64'] })).toBe(false);
253 expect(aufWindowsX64({ cpu: ['!x64'] })).toBe(false);
254 });
255 });