lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests tools nodeVoraussetzung.test.ts
| 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 | }); |