import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; /** * Die Node-Fassung, die die README als Voraussetzung nennt, gegen die * Angaben der Bauabhaengigkeiten. * * WARUM ES DIESEN FALL GIBT. Die README nannte "Node.js 20 oder neuer". Die * Bauabhaengigkeiten verlangen selbst mehr: Electron mindestens 22.12, ESLint * `^20.19.0 || ^22.13.0 || >=24`, womit auch Node 23 herausfaellt. Unter * Node 20 liefe der Bau also ausserhalb dessen, was die Pakete zusagen; was * dabei geschieht, ist nicht gemessen. Die README ist die Bauanleitung, und * die Zahl stand ungeprueft da, weil kein Fall sie las. * * WOGEGEN GEPRUEFT WIRD: die Angabe `engines.node` jedes Pakets in * `package-lock.json`, das `npm ci` unter Windows auf x64 einrichtet. * Ausgenommen sind nur die Pakete, deren `os` oder `cpu` diese Plattform * ausschliesst - die Bausteine fuer andere Plattformen, die npm dort gar nicht * laedt. Die Markierung `optional` entscheidet das nicht: Sie tragen auch * Pakete, die npm unter Windows einrichtet. Verlangt wird zweierlei: Die * genannte Fassung genuegt jedem Paket, und die Fassung knapp darunter genuegt * einem nicht - die README nennt also die Untergrenze und keine hoehere Zahl. * Jede Hauptfassung darueber, die ein Paket ausschliesst, muss die README * ausdruecklich ausnehmen. * * Die Bereichsangaben liest ein kleiner Vergleich hier, weil `semver` nur * mittelbar im Baum liegt. Er kennt genau die Formen, die in der Sperrdatei * vorkommen, und bricht bei jeder anderen ab, statt sie fuer erfuellt zu * halten. */ interface Fassung { readonly haupt: number; readonly neben: number; readonly flick: number; } type Vergleich = (f: Fassung) => boolean; function vergleiche(a: Fassung, b: Fassung): number { return a.haupt - b.haupt || a.neben - b.neben || a.flick - b.flick; } function fassung(text: string): Fassung { const teile = /^(\d+)\.(\d+)\.(\d+)$/.exec(text); if (teile === null) throw new Error(`Keine Fassung: ${text}`); return { haupt: Number(teile[1]), neben: Number(teile[2]), flick: Number(teile[3]) }; } /** Ein einzelnes Vergleichsglied, etwa `>=22.12.0`, `^20.19.0`, `20`, `10.*`. */ function glied(text: string): Vergleich { const form = /^(>=|>|<=|<|=|\^)?v?(\d+)(?:\.(\d+|\*|x))?(?:\.(\d+|\*|x))?$/.exec(text); if (form === null) throw new Error(`Bereichsangabe nicht gelesen: "${text}"`); const [, zeichen, h, n, f] = form; const zahl = (teil: string | undefined): number | null => teil === undefined || teil === '*' || teil === 'x' ? null : Number(teil); const haupt = Number(h); const neben = zahl(n); const flick = zahl(f); const unten: Fassung = { haupt, neben: neben ?? 0, flick: flick ?? 0 }; switch (zeichen) { case '>=': return (v) => vergleiche(v, unten) >= 0; case '>': if (neben === null || flick === null) throw new Error(`Unvollstaendig: "${text}"`); return (v) => vergleiche(v, unten) > 0; case '<': return (v) => vergleiche(v, unten) < 0; case '<=': if (neben === null || flick === null) throw new Error(`Unvollstaendig: "${text}"`); return (v) => vergleiche(v, unten) <= 0; case '^': if (haupt === 0) throw new Error(`^0.x wird hier nicht gelesen: "${text}"`); return (v) => vergleiche(v, unten) >= 0 && v.haupt === haupt; case '=': case undefined: // Ohne Zeichen ein Bereich ueber die fehlenden Stellen: "20" ist 20.x.x. return (v) => v.haupt === haupt && (neben === null || v.neben === neben) && (flick === null || v.flick === flick); default: throw new Error(`Unbekanntes Zeichen in "${text}"`); } } /** Eine ganze Angabe wie `^20.19.0 || ^22.13.0 || >=24` oder `>=16 || 14 >=14.17`. */ function bereich(text: string): Vergleich { const wege = text.split('||').map((weg) => { const glieder = weg .trim() .replace(/(>=|<=|>|<|=|\^)\s+/g, '$1') .split(/\s+/) .filter((g) => g !== '' && g !== '*') .map(glied); return (v: Fassung) => glieder.every((g) => g(v)); }); return (v) => wege.some((w) => w(v)); } interface Paketeintrag { readonly optional?: boolean; readonly os?: readonly string[]; readonly cpu?: readonly string[]; readonly engines?: { readonly node?: string }; } /** * Laesst eine Angabe `os` oder `cpu` den Wert zu? So liest npm sie: Fehlt sie, * gilt jeder Wert; ein Eintrag mit "!" schliesst seinen Wert aus; gibt es * Eintraege ohne "!", muss der Wert unter ihnen stehen. */ function laesstZu(angabe: readonly string[] | undefined, wert: string): boolean { if (angabe === undefined) return true; if (angabe.includes(`!${wert}`)) return false; const erlaubt = angabe.filter((eintrag) => !eintrag.startsWith('!')); return erlaubt.length === 0 || erlaubt.includes(wert); } /** Richtet npm das Paket unter Windows auf x64 ein? */ function aufWindowsX64(eintrag: Paketeintrag): boolean { return laesstZu(eintrag.os, 'win32') && laesstZu(eintrag.cpu, 'x64'); } const SPERRDATEI = JSON.parse(readFileSync('package-lock.json', 'utf8')) as { readonly packages: Readonly>; }; /** Je eingerichtetem Paket seine Node-Angabe. */ const ANGABEN: readonly { readonly paket: string; readonly passt: Vergleich }[] = Object.entries( SPERRDATEI.packages, ) .filter(([pfad, eintrag]) => pfad !== '' && aufWindowsX64(eintrag)) .flatMap(([pfad, eintrag]) => { const angabe = eintrag.engines?.node; return angabe === undefined ? [] : [{ paket: pfad, passt: bereich(angabe) }]; }); /** Die Pakete, denen die Fassung nicht genuegt. */ function abgelehntVon(v: Fassung): string[] { return ANGABEN.filter(({ passt }) => !passt(v)).map(({ paket }) => paket); } const README = readFileSync('README.md', 'utf8').replace(/\s+/g, ' '); const VORAUSSETZUNGEN = README.slice( README.indexOf('## Voraussetzungen'), README.indexOf('## Bauen'), ); describe('Die Node-Fassung in der README gegen die Bauabhaengigkeiten', () => { const untergrenze = /Node\.js (\d+)\.(\d+) oder neuer/.exec(VORAUSSETZUNGEN); const ausgenommen = [...VORAUSSETZUNGEN.matchAll(/außer Node\.js (\d+)/g)].map((t) => Number(t[1]), ); it('liest die Sperrdatei ueberhaupt', () => { // Liefe der Vergleich ueber eine leere Liste, waere alles unten gruen. expect(ANGABEN.length, 'Pakete mit Node-Angabe').toBeGreaterThan(50); expect( ANGABEN.some(({ paket }) => paket === 'node_modules/electron'), 'Electron fehlt in der Liste', ).toBe(true); }); /* * `optional` ist kein Plattformmerkmal: Auch Pakete, die npm unter Windows * einrichtet, tragen es - etwa die Bibliotheken, die jsPDF wahlweise * einbindet, oder der esbuild-Baustein fuer Windows selbst. Ausgenommen * werden darf nur, was npm dort gar nicht einrichtet. */ it('prueft jedes Paket, das npm unter Windows auf x64 einrichtet, auch ein optionales', () => { const gefuehrt = new Set(ANGABEN.map(({ paket }) => paket)); for (const paket of ['node_modules/html2canvas', 'node_modules/@esbuild/win32-x64']) { expect(SPERRDATEI.packages[paket]?.optional, `${paket} ist optional`).toBe(true); expect(gefuehrt.has(paket), `${paket} fehlt im Vergleich`).toBe(true); } for (const paket of [ 'node_modules/@esbuild/linux-x64', 'node_modules/@esbuild/win32-arm64', 'node_modules/@napi-rs/lzma-linux-x64-gnu', ]) { expect(gefuehrt.has(paket), `${paket} wird unter Windows x64 nicht eingerichtet`).toBe(false); } }); it('nennt eine Untergrenze mit Haupt- und Nebenfassung', () => { expect(untergrenze, 'README: "Node.js . oder neuer" fehlt').not.toBeNull(); }); it('nennt eine Fassung, die jedem Paket genuegt, und keine hoehere als noetig', () => { const haupt = Number(untergrenze?.[1]); const neben = Number(untergrenze?.[2]); expect(abgelehntVon({ haupt, neben, flick: 0 }), 'lehnen die genannte Fassung ab').toEqual([]); const darunter: Fassung = neben > 0 ? { haupt, neben: neben - 1, flick: 999 } : { haupt: haupt - 1, neben: 999, flick: 999 }; expect( abgelehntVon(darunter).length, `${String(darunter.haupt)}.${String(darunter.neben)} genuegte auch - die README nennt eine zu hohe Untergrenze`, ).toBeGreaterThan(0); }); it('nimmt jede Hauptfassung darueber aus, die ein Paket ablehnt, und nur diese', () => { const haupt = Number(untergrenze?.[1]); const abgelehnt: number[] = []; for (let h = haupt + 1; h <= haupt + 10; h += 1) { if (abgelehntVon({ haupt: h, neben: 0, flick: 0 }).length > 0) abgelehnt.push(h); } expect(ausgenommen, 'README: "außer Node.js "').toEqual(abgelehnt); for (const h of ausgenommen) { expect( abgelehntVon({ haupt: h, neben: 999, flick: 999 }).length, `Node.js ${String(h)} ist ausgenommen, genuegt aber in jeder Nebenfassung`, ).toBeGreaterThan(0); } }); }); describe('Der Vergleich selbst', () => { // Ohne diese Faelle stuende die Pruefung oben auf einer ungeprueften // Zeichenkettenrechnung. const v = fassung; it('liest die Formen der Sperrdatei so, wie npm sie liest', () => { expect(bereich('>= 22.12.0')(v('22.12.0'))).toBe(true); expect(bereich('>= 22.12.0')(v('22.11.9'))).toBe(false); expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('22.13.0'))).toBe(true); expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('22.12.5'))).toBe(false); expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('23.4.0'))).toBe(false); expect(bereich('^20.19.0 || ^22.13.0 || >=24')(v('24.0.0'))).toBe(true); expect(bereich('18 || 20 || >=22')(v('21.0.0'))).toBe(false); expect(bereich('18 || 20 || >=22')(v('20.3.1'))).toBe(true); expect(bereich('>=16 || 14 >=14.17')(v('14.16.0'))).toBe(false); expect(bereich('>=16 || 14 >=14.17')(v('14.17.0'))).toBe(true); expect(bereich('6.* || 8.* || >= 10.*')(v('7.0.0'))).toBe(false); expect(bereich('6.* || 8.* || >= 10.*')(v('10.0.0'))).toBe(true); expect(bereich('*')(v('1.0.0'))).toBe(true); }); it('bricht bei einer Form ab, die er nicht kennt, statt sie fuer erfuellt zu halten', () => { expect(() => bereich('~1.2.3')).toThrow(); expect(() => bereich('1.2.3 - 2.0.0')).toThrow(); }); it('liest os und cpu so, wie npm sie liest', () => { expect(aufWindowsX64({})).toBe(true); expect(aufWindowsX64({ os: ['win32'], cpu: ['x64'] })).toBe(true); expect(aufWindowsX64({ os: ['win32', 'linux'] })).toBe(true); expect(aufWindowsX64({ os: ['!darwin'], cpu: ['!arm64'] })).toBe(true); expect(aufWindowsX64({ os: ['darwin'] })).toBe(false); expect(aufWindowsX64({ os: ['!win32'] })).toBe(false); expect(aufWindowsX64({ os: ['win32'], cpu: ['arm64'] })).toBe(false); expect(aufWindowsX64({ cpu: ['!x64'] })).toBe(false); }); });