import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; /* * Werkbank. Zusagen, die nichts rechnen. * * F.1 `.githooks/pre-commit` faehrt die Schnellstufe, greift aber nur ueber * `core.hooksPath` in `.git/config`. Das steht ausserhalb des Baumes und * wird von `git clone` nicht mitgefuehrt; wer das Einrichten von Hand * vergisst, committet ungeprueft. `package.json` richtet den Haken jetzt * mit einem `prepare`-Skript ein - npm faehrt es bei jedem `npm install`. * Bewacht wird nicht der Wortlaut des Skripts, sondern seine Wirkung: Es * laeuft hier in einer Wegwerf-Arbeitskopie und muss dort den Eintrag * setzen; ohne `.git` muss es folgenlos mit 0 enden, weil ein * fehlschlagendes `prepare` das ganze `npm install` abbricht. * * F.2 `jspdf`, `jspdf-autotable`, Electron und Chromium gehen mit der Fassung * an den Anwender, waren aber nirgends als Fremdbestandteil genannt. * `docs/fremdbestandteile.md` fuehrt sie jetzt auf. Ein solches * Verzeichnis ist nur so viel wert wie seine Aktualitaet: Diese Wache * haelt jeden Eintrag gegen das installierte Paket, die Menge der * Eintraege gegen das Bauergebnis und die Zahl der erhaltenen * Urhebervermerke gegen die Ausgabedateien. Ein `npm install`, das eine * Fassung anhebt, macht sie rot. * * SEIT 5.12.0 KOMMEN DIE MITGELIEFERTEN LIZENZTEXTE DAZU. Das Verzeichnis * fuehrte drei Punkte als ausstehend: keine Lizenzdatei im Paket, im * uebersetzten Kode nur vier von dreizehn Urhebervermerken, und das * Verzeichnis selbst nur im Quellbaum. Punkt 1 und 3 sind behoben - * `scripts/lizenztexte.mjs` sammelt die Texte aus `node_modules` ein, und * `build.extraFiles` legt sie samt dem Verzeichnis neben die * Programmdatei, dorthin, wo electron-builder auch seine eigenen beiden * Lizenzdateien ablegt. Bewacht wird beides: dass package.json es mitgibt * UND dass die Sammeldatei zu JEDER gebuendelten Bibliothek einen Text * fuehrt. Eine Sammeldatei, in der eine fehlt, sieht nach Erfuellung aus * und ist keine - deshalb prueft der Fall je Bibliothek den Namen ihres * Rechteinhabers nach, und ein weiterer prueft, dass das Skript lieber * abbricht als eine unvollstaendige Datei zu schreiben. * * SEIT 5.14.0 IST AUCH PUNKT 2 BEHOBEN. Der Vermerk wird in der Ausgabe * nicht mehr gesucht, sondern hineingeschrieben: `lizenzbanner` in * `vite.config.ts` setzt je Ausgabebrocken einen legalen Kommentar * (`/*!`) mit den Lizenztexten der Bibliotheken, deren Kode wirklich * darin steht. Vier Faelle halten das fest, und zwar am gebauten Stand: * dass jede der dreizehn ihren Urhebervermerk in ihrer Ausgabedatei * traegt; dass der Banner GENAU die Bibliotheken nennt, die die * Quellkarte dieser Datei zuweist - ein Vermerk am falschen Ort ist eine * falsche Angabe, keine Erfuellung; dass der Text woertlich der des * installierten Pakets ist; und dass eine Ausgabedatei ohne * Fremdbestandteil gar keinen Banner bekommt. * * DIE GEGENPROBE IST DER EIGENTLICHE WAECHTER. Der Banner entsteht zur * Bauzeit aus `chunk.moduleIds`, die Zuordnung hier aus den Quellkarten - * zwei Ableitungen desselben Sachverhalts. Laufen sie auseinander, faellt * einer der beiden Faelle. Ohne die Gegenprobe genuegte es, jeder Datei * alle dreizehn Texte mitzugeben; das saehe im Bericht genauso gruen aus. * * DREI WEITERE FAELLE HALTEN DIE ZAHLEN IM TEXT FEST und brauchen dafuer * kein Bauergebnis: dass der Kopfkommentar hier so viele uebersprungene * Faelle nennt, wie die Datei fuehrt; dass Verzeichnis und * `vite.config.ts` so viele Wortlaute des Genehmigungsvermerks nennen, * wie die Pakete fuehren; und dass die Groesse der DOMPurify-Lizenzdatei * in Byte dasteht, nicht in Zeichen. Alle drei Zahlen standen einmal * falsch da - eine Zahl im Text, die niemand nachrechnet, wandert. * * SECHS DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS. `build/` steht in * `.gitignore`, und kein Skript baut vor den Tests: `gate:schnell` baut * nicht, `gate` baut erst nach dem Testlauf. Eine frische Arbeitskopie * hat also nichts zu messen. Die sechs Faelle werden dann * UEBERSPRUNGEN und nicht etwa schwaecher geprueft - ein stiller * Rueckfall auf eine Ersatzzusicherung sieht im Bericht aus wie eine * bestandene Messung und ist keine. * * NACHGETRAGEN ZU 5.43.0: DIE LAUFZEIT JENSEITS VON ELECTRON. Gehalten * war bis dahin nur die Zeile "Electron" der Tabelle "Die Laufzeit". Mit * 5.26.0 kam Electron 44.3.0; die Zeilen Electron und Chromium wurden von * Hand nachgezogen, die Zeile Node.js nicht - sie nannte bis 5.42.1 * weiter 20.18.3, waehrend das ausgelieferte Programm Node.js 24.20.0 * mitbringt. Kein Fall merkte es. Ebenso stand bis 5.42.1 eine Zaehlung * reziproker Lizenzen als Messung am ausgelieferten Stand da, deren neun * Namen aus der Uebersicht von Electron 33.4.11 stammten; einer davon * steht in der heutigen nicht mehr. Zwei Faelle halten das jetzt: Die * Fassungen von Chromium und Node.js fragt der eine bei der * Programmdatei selbst ab (`process.versions` unter * `ELECTRON_RUN_AS_NODE`), nicht an einer Zeichenkette im Text; der andere * prueft je genanntem Namen an `LICENSES.chromium.html`, ob er dort als * Eintrag steht, und verlangt, dass das Verzeichnis genau die fehlenden * als fehlend nennt. Beide brauchen das heruntergeladene Electron unter * `node_modules/electron/dist` und ueberspringen sich ohne es sichtbar. * * WARUM HIER `git` UND `node` ANGEWORFEN WERDEN: Die Punkte sind Zusagen * ueber das Verhalten von Werkzeugen, nicht ueber Text. Ein Textabgleich haette * bei F.1 nur belegt, dass eine Zeichenkette dasteht - nicht, dass npm sie in * der Kommandozeile von Windows wie in der von POSIX ohne Abbruch faehrt. Die * Laeufe kosten zusammen unter einer Sekunde. */ const WURZEL = process.cwd(); interface Paketdatei { readonly version: string; readonly scripts: Record; readonly dependencies?: Record; readonly devDependencies: Record; readonly build: { readonly files: readonly string[]; readonly extraFiles?: readonly { readonly from?: string; readonly to?: string }[]; }; } const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as Paketdatei; /* ------------------------------------------------------------------------- * F.1 - das prepare-Skript * ---------------------------------------------------------------------- */ describe('F.1: package.json richtet den pre-commit-Haken selbst ein', () => { const PREPARE = PAKET.scripts['prepare']; let ablage = ''; beforeAll(() => { ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-prepare-')); }); afterAll(() => { rmSync(ablage, { recursive: true, force: true }); }); /** Faehrt das prepare-Skript so, wie npm es faehrt: durch die Kommandozeile des Systems. */ function faehrtIn(wo: string): { status: number | null; ausgabe: string } { const lauf = spawnSync(PREPARE ?? '', { cwd: wo, shell: true, encoding: 'utf8' }); return { status: lauf.status, ausgabe: `${lauf.stdout}${lauf.stderr}` }; } /** Der oertliche Wert von core.hooksPath in der angegebenen Arbeitskopie, oder ''. */ function hakenpfad(wo: string): string { const lauf = spawnSync('git', ['config', '--local', '--get', 'core.hooksPath'], { cwd: wo, encoding: 'utf8', }); return lauf.stdout.trim(); } it('fuehrt package.json ueberhaupt ein prepare-Skript', () => { expect(PREPARE, 'kein prepare-Skript in package.json').toBeDefined(); expect(PREPARE).toContain('core.hooksPath'); expect(PREPARE).toContain('.githooks'); }); it('setzt es in einer frischen Arbeitskopie core.hooksPath auf .githooks', () => { const wo = path.join(ablage, 'mitgit'); const anlegen = spawnSync('git', ['init', '-q', wo], { encoding: 'utf8' }); expect(anlegen.status, `git init: ${anlegen.stderr}`).toBe(0); expect(hakenpfad(wo), 'die frische Arbeitskopie hat den Eintrag schon').toBe(''); const lauf = faehrtIn(wo); expect(lauf.status, `prepare brach ab: ${lauf.ausgabe}`).toBe(0); expect(hakenpfad(wo), 'prepare hat den Eintrag nicht gesetzt').toBe('.githooks'); }); it('endet es ohne .git-Verzeichnis folgenlos mit 0', () => { // Ein fehlschlagendes prepare bricht npm install ab. Auslieferung als // Tarball und Bauumgebungen ohne Arbeitskopie duerfen daran nicht scheitern. const wo = path.join(ablage, 'ohnegit'); const lauf = spawnSync('git', ['init', '-q', wo], { encoding: 'utf8' }); expect(lauf.status, `git init: ${lauf.stderr}`).toBe(0); rmSync(path.join(wo, '.git'), { recursive: true, force: true }); const ergebnis = faehrtIn(wo); expect(ergebnis.status, `prepare brach ab: ${ergebnis.ausgabe}`).toBe(0); expect(existsSync(path.join(wo, '.git')), 'prepare hat ein .git angelegt').toBe(false); }); }); /* ------------------------------------------------------------------------- * F.2 - das Verzeichnis der Fremdbestandteile * ---------------------------------------------------------------------- */ const DOK_PFAD = 'docs/fremdbestandteile.md'; const DOK = readFileSync(DOK_PFAD, 'utf8'); interface Eintrag { readonly name: string; readonly fassung: string; readonly lizenz: string; } /** Die Tabellenzeilen eines Abschnitts, zerlegt in Name, Fassung und Lizenz. */ function eintraege(ueberschrift: string): Eintrag[] { const anfang = DOK.indexOf(`## ${ueberschrift}`); expect(anfang, `Abschnitt "${ueberschrift}" nicht gefunden`).toBeGreaterThanOrEqual(0); const naechste = DOK.indexOf('\n## ', anfang + 1); const abschnitt = DOK.slice(anfang, naechste < 0 ? DOK.length : naechste); const gefunden: Eintrag[] = []; for (const zeile of abschnitt.split('\n')) { if (!zeile.startsWith('|')) continue; const spalten = zeile.split('|').map((s) => s.trim()); // spalten[0] ist leer (die Zeile beginnt mit |), dann Name, Fassung, Lizenz. const name = (spalten[1] ?? '').replace(/`/g, ''); if (name === '' || name === 'Bestandteil' || name.startsWith('---')) continue; gefunden.push({ name, fassung: spalten[2] ?? '', lizenz: (spalten[3] ?? '').replace(/`/g, ''), }); } return gefunden; } /** Fassung und Lizenzangabe, wie sie im installierten Paket stehen. */ function installiert(name: string): { fassung: string; lizenz: string } { const roh = JSON.parse( readFileSync(path.join('node_modules', name, 'package.json'), 'utf8'), ) as Record; return { fassung: String(roh['version']), lizenz: String(roh['license']) }; } const BAUABLAGE = 'build/renderer/assets'; /** * Welches Paket in welchen Ausgabedateien des gebauten Renderers steckt - aus * den Quellkarten gelesen. Ist nichts gebaut, gibt es nichts zu vergleichen; * dann steht hier null, und die davon abhaengigen Faelle werden uebersprungen. * * Die Zuordnung Paket -> Datei ist nicht bloss Beiwerk: Der Fall zu den * Urhebervermerken sucht je Paket NUR in der Datei seines eigenen Buendels. * Sucht man ueber alle Ausgabedateien, faengt man jsPDFs mitgefuehrte * Fremdteile ein und zaehlt sie fremden Paketen zu. */ function buendelzuordnung(): Map | null { if (!existsSync(BAUABLAGE)) return null; const karten = readdirSync(BAUABLAGE).filter((d) => d.endsWith('.js.map')); if (karten.length === 0) return null; const gefunden = new Map>(); for (const karte of karten) { const inhalt = JSON.parse(readFileSync(path.join(BAUABLAGE, karte), 'utf8')) as { sources?: string[]; }; for (const quelle of inhalt.sources ?? []) { const stelle = quelle.lastIndexOf('node_modules/'); if (stelle < 0) continue; const teile = quelle.slice(stelle + 'node_modules/'.length).split('/'); const name = teile[0]?.startsWith('@') ? `${teile[0]}/${teile[1]}` : String(teile[0]); const eintrag = gefunden.get(name) ?? new Set(); eintrag.add(karte.slice(0, -'.map'.length)); gefunden.set(name, eintrag); } } return new Map([...gefunden].map(([name, dateien]) => [name, [...dateien].sort()])); } const BUENDEL = buendelzuordnung(); /** Ohne Bauergebnis ist nichts zu messen - die betroffenen Faelle entfallen sichtbar. */ const OHNE_BAU = BUENDEL === null; /** * Der Rechteinhaber je Paket, wie ihn die Lizenzdatei des installierten Pakets * nennt. Er dient als Fuehler: Steht der Name in der Ausgabedatei, steht dort * der Urhebervermerk. Ein Fall unten prueft jeden Fuehler zuerst am * installierten Paket nach - ein Schreibfehler ginge sonst stumm als * "Vermerk fehlt" durch und machte die Zaehlung wertlos. */ const RECHTEINHABER: Readonly> = { '@babel/runtime': 'Sebastian McKenzie', canvg: 'Gabe Lerner', 'core-js': 'Denis Pushkarev', dompurify: 'Cure53', fflate: 'Arjun Barrett', html2canvas: 'Niklas von Hertzen', jspdf: 'James Hall', 'jspdf-autotable': 'Simon Bengtsson', 'performance-now': 'Braveg1rl', raf: 'Chris Dickinson', rgbcolor: 'Stoyan Stefanov', 'stackblur-canvas': 'Mario Klingemann', 'svg-pathdata': 'Nicolas Froidure', }; /** Zahlwoerter, so weit das Verzeichnis sie braucht - dreizehn Pakete, nicht mehr. */ const ZAHLWORT: Readonly> = { 0: 'keines', 1: 'eines', 2: 'zwei', 3: 'drei', 4: 'vier', 5: 'fünf', 6: 'sechs', 7: 'sieben', 8: 'acht', 9: 'neun', 10: 'zehn', 11: 'elf', 12: 'zwölf', 13: 'dreizehn', }; /** * Woran der Banner aus `vite.config.ts` zu erkennen ist. * * `/*!` und nicht `/*`: esbuild gibt nur legale Kommentare wieder. Steht diese * Zeichenfolge nicht am Anfang, hat der Banner das Verkuerzen nicht ueberlebt - * und dann faellt der Fall, der ihn sucht. */ const BANNERBEGINN = '/*!\nLSA-Planer Professional '; /** * Die Bibliotheken, die der Banner einer Ausgabedatei nennt - gelesen an den * Herkunftszeilen `(node_modules//)`, die er je Text * fuehrt, nicht an einer Aufzaehlung im Kopf. */ function bannergenannt(datei: string): string[] { const code = readFileSync(path.join(BAUABLAGE, datei), 'utf8'); const anfang = code.indexOf(BANNERBEGINN); if (anfang < 0) return []; const banner = code.slice(anfang, code.indexOf('*/', anfang)); const namen = new Set(); for (const treffer of banner.matchAll(/^\(node_modules\/(.+)\/[^/]+\)$/gm)) { namen.add(String(treffer[1])); } return [...namen].sort(); } /** Die Lizenzdateien, die neben `package.json` eines installierten Pakets liegen. */ function lizenzdateien(name: string): string[] { const ablage = path.join('node_modules', name); return readdirSync(ablage) .filter((d) => /^(licen[sc]e|copying)/i.test(d)) .map((d) => path.join(ablage, d)); } /** `a`, `b` und `c` - die Aufzaehlung, wie das Verzeichnis sie schreibt. */ function aufzaehlung(namen: readonly string[]): string { const gesetzt = namen.map((n) => `\`${n}\``); if (gesetzt.length <= 1) return gesetzt.join(''); return `${gesetzt.slice(0, -1).join(', ')} und ${gesetzt[gesetzt.length - 1]}`; } /** Tausenderabstand, wie ihn die Unterlagen setzen: 27 729, nicht 27729. */ function mitAbstand(zahl: number): string { return String(zahl).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } /** Grosser Anfangsbuchstabe - das Verzeichnis beginnt Saetze mit dem Zahlwort. */ function gross(wort: string): string { return wort.slice(0, 1).toUpperCase() + wort.slice(1); } /** Der Satz, mit dem der MIT-Text seinen Genehmigungsvermerk beginnt. */ const GENEHMIGUNG = 'Permission is hereby granted'; /** Diese Datei selbst - der Fall zum Kopfkommentar liest sie. */ const EIGENE_QUELLE = fileURLToPath(import.meta.url); /** Wo `npm install` die Laufzeit von Electron ablegt. */ const ELECTRON_ABLAGE = path.join('node_modules', 'electron', 'dist'); /** Die Bestandteiluebersicht von Chromium, die Electron mitliefert. */ const CHROMIUM_UEBERSICHT = path.join(ELECTRON_ABLAGE, 'LICENSES.chromium.html'); /** * Die Programmdatei des installierten Electron, oder null. * * Ihren Namen je Plattform schreibt das Paket beim Herunterladen nach * `node_modules/electron/path.txt`; unter Windows ist es `electron.exe`. */ const ELECTRON_PROGRAMM = ((): string | null => { const namensdatei = path.join('node_modules', 'electron', 'path.txt'); if (!existsSync(namensdatei)) return null; const programm = path.join(ELECTRON_ABLAGE, readFileSync(namensdatei, 'utf8').trim()); return existsSync(programm) ? programm : null; })(); /** Ohne heruntergeladenes Electron gibt es nichts abzufragen - die Faelle entfallen sichtbar. */ const OHNE_ELECTRON = ELECTRON_PROGRAMM === null || !existsSync(CHROMIUM_UEBERSICHT); /** Ein Text auf eine Zeile gebracht - das Verzeichnis bricht Saetze beliebig um. */ function einzeilig(text: string): string { return text.replace(/\s+/g, ' '); } /** Die Namen der Eintraege in `LICENSES.chromium.html`, Zeichenreferenzen aufgeloest. */ function chromiumEintraege(): Set { const html = readFileSync(CHROMIUM_UEBERSICHT, 'utf8'); const namen = new Set(); for (const treffer of html.matchAll(/([^<]*)<\/span>/g)) { namen.add( String(treffer[1]) .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') .trim(), ); } return namen; } describe('F.2: docs/fremdbestandteile.md haelt gegen den Baum', () => { const GEBUENDELT = eintraege('In den Renderer gebündelt'); /** Wegwerfablage fuer die Laeufe von scripts/lizenztexte.mjs. */ let ablage = ''; beforeAll(() => { ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-lizenztexte-')); }); afterAll(() => { rmSync(ablage, { recursive: true, force: true }); }); it('nennt zu jedem Eintrag die Fassung und die Lizenz des installierten Pakets', () => { expect(GEBUENDELT.length, 'keine Tabellenzeilen gefunden').toBeGreaterThan(0); for (const eintrag of GEBUENDELT) { const ist = installiert(eintrag.name); expect(eintrag.fassung, `Fassung von ${eintrag.name}`).toBe(ist.fassung); expect(eintrag.lizenz, `Lizenz von ${eintrag.name}`).toBe(ist.lizenz); } }); it.skipIf(OHNE_BAU)( 'deckt sich die Aufstellung mit dem, was im gebauten Renderer steckt (nur mit Bauergebnis messbar)', () => { // Ohne gebauten Stand entfaellt dieser Fall. Kein Rueckfall auf eine // schwaechere Zusicherung: Was hier gemessen wird, ist der Zuwachs, und // den sieht man nur am Bauergebnis. Es bleibt der Fall oben, der jeden // GENANNTEN Eintrag am installierten Paket prueft. const ist = [...(BUENDEL as Map).keys()].sort(); expect(GEBUENDELT.map((e) => e.name).sort()).toEqual(ist); }, ); it('sind die Fuehler fuer die Urhebervermerke am installierten Paket belegt', () => { // Ohne diesen Fall bewacht der naechste nichts: Ein Name, der im Paket // selbst nirgends steht, steht auch in der Ausgabedatei nicht - und die // Zaehlung meldete brav "Vermerk fehlt". expect(Object.keys(RECHTEINHABER).sort()).toEqual(GEBUENDELT.map((e) => e.name).sort()); for (const [name, inhaber] of Object.entries(RECHTEINHABER)) { const dateien = lizenzdateien(name); expect(dateien.length, `keine Lizenzdatei in node_modules/${name}`).toBeGreaterThan(0); const steht = dateien.some((d) => readFileSync(d, 'utf8').includes(inhaber)); expect(steht, `"${inhaber}" steht in keiner Lizenzdatei von ${name}`).toBe(true); } }); it.skipIf(OHNE_BAU)( 'traegt jede gebuendelte Bibliothek ihren Urhebervermerk in ihrer Ausgabedatei (nur mit Bauergebnis messbar)', () => { const zuordnung = BUENDEL as Map; const ohne: string[] = []; for (const [name, inhaber] of Object.entries(RECHTEINHABER)) { // Nur die Ausgabedateien DIESES Pakets. Ueber alle Dateien gesucht // faende man Stoyan Stefanov in pdf-*.js und schriebe ihn rgbcolor zu, // obwohl das dort jsPDFs eigene mitgefuehrte Farbwertzerlegung ist. const dateien = zuordnung.get(name) ?? []; expect(dateien.length, `${name} steckt in keiner Ausgabedatei`).toBeGreaterThan(0); const steht = dateien.some((d) => readFileSync(path.join(BAUABLAGE, d), 'utf8').includes(inhaber), ); if (!steht) ohne.push(name); } expect(ohne.sort(), 'diese Bibliotheken tragen ihren Urhebervermerk nicht').toEqual([]); // Und das Verzeichnis gibt die Messung wortwoertlich wieder - mit der // Zahl, die hier herauskommt, nicht mit einer gepflegten. const anzahl = Object.keys(RECHTEINHABER).length; const flach = DOK.replace(/\s+/g, ' '); expect(flach, `alle ${anzahl} Bibliotheken tragen ihren Vermerk`).toContain( `tragen alle **${ZAHLWORT[anzahl]}** gebündelten Bibliotheken ihren Urheber- und Genehmigungsvermerk.`, ); }, ); it.skipIf(OHNE_BAU)( 'nennt der Banner je Ausgabedatei genau die Bibliotheken, deren Kode darin steckt (nur mit Bauergebnis messbar)', () => { /* * DIE GEGENPROBE ZUM FALL DARUEBER. Dort wird gesucht, ob der Vermerk * DA ist; hier, ob er am RICHTIGEN Ort steht. Ohne diesen Fall waere die * billigste Loesung, jeder Ausgabedatei alle dreizehn Texte mitzugeben - * das saehe genauso gruen aus und waere eine falsche Angabe: In * index-*.js steckt kein fremder Kode, und ein Vermerk dort behauptete * das Gegenteil. * * Gemessen werden zwei Ableitungen desselben Sachverhalts gegeneinander: * Der Banner entsteht zur Bauzeit aus `chunk.moduleIds`, die Zuordnung * hier nach dem Bau aus den Quellkarten. Laufen sie auseinander, faellt * dieser Fall. */ const zuordnung = BUENDEL as Map; for (const datei of readdirSync(BAUABLAGE).filter((d) => d.endsWith('.js'))) { const erwartet = [...zuordnung] .filter(([, dateien]) => dateien.includes(datei)) .map(([name]) => name) .sort(); expect(bannergenannt(datei), `Banner von ${datei}`).toEqual(erwartet); } }, ); it.skipIf(OHNE_BAU)( 'traegt der Banner den Lizenztext woertlich aus dem installierten Paket (nur mit Bauergebnis messbar)', () => { // Der Name des Rechteinhabers allein belegt nur, dass IRGENDETWAS // dasteht. Der MIT-Text verlangt Urheber- UND Genehmigungsvermerk; // geprueft wird deshalb der ganze Text, Zeichen fuer Zeichen. const zuordnung = BUENDEL as Map; for (const [name, dateien] of zuordnung) { for (const quelle of lizenzdateien(name)) { const text = readFileSync(quelle, 'utf8').replace(/\r\n/g, '\n').trim(); for (const datei of dateien) { const code = readFileSync(path.join(BAUABLAGE, datei), 'utf8'); expect(code, `${quelle} steht nicht in ${datei}`).toContain(text); } } } }, ); it.skipIf(OHNE_BAU)( 'bekommt eine Ausgabedatei ohne Fremdbestandteil keinen Banner (nur mit Bauergebnis messbar)', () => { // Der Einstiegsbrocken enthaelt nur eigenen Kode. Ein Banner dort waere // nicht bloss ueberfluessig, sondern unrichtig - und er kostete in der // groessten Ausgabedatei am meisten. const zuordnung = BUENDEL as Map; const belegt = new Set([...zuordnung.values()].flat()); const leere = readdirSync(BAUABLAGE) .filter((d) => d.endsWith('.js')) .filter((d) => !belegt.has(d)); expect(leere.length, 'keine Ausgabedatei ohne Fremdbestandteil gefunden').toBeGreaterThan(0); for (const datei of leere) { expect( readFileSync(path.join(BAUABLAGE, datei), 'utf8'), `${datei} traegt einen Banner, obwohl kein fremder Kode darin steckt`, ).not.toContain(BANNERBEGINN); } }, ); it('nennt der Kopfkommentar so viele uebersprungene Faelle, wie die Datei fuehrt', () => { /* * EINE ZAHL IM TEXT, DIE NIEMAND NACHRECHNET, WANDERT. Genau das ist hier * passiert: Der Kopfkommentar sprach von drei Faellen, waehrend die Datei * sechs fuehrte, und das Verzeichnis sagte schon sechs. Gezaehlt wird * deshalb am Kode - an `it.skipIf(OHNE_BAU)` -, und beide Texte muessen * dieselbe Zahl nennen. */ const quelle = readFileSync(EIGENE_QUELLE, 'utf8'); const anzahl = (quelle.match(/it\.skipIf\(OHNE_BAU\)\(/g) ?? []).length; expect(anzahl, 'kein uebersprungener Fall gefunden').toBeGreaterThan(0); const wort = String(ZAHLWORT[anzahl]); expect(quelle, `${anzahl} Faelle setzen ein Bauergebnis voraus`).toContain( `${wort.toUpperCase()} DIESER FAELLE SETZEN EIN BAUERGEBNIS VORAUS`, ); expect(quelle, 'der Kopfkommentar nennt eine andere Zahl').toContain( `Die ${wort} Faelle werden dann`, ); const flach = DOK.replace(/\s+/g, ' '); expect(flach, 'das Verzeichnis nennt eine andere Zahl').toContain( `**${gross(wort)} dieser Zusagen setzen ein Bauergebnis voraus**`, ); expect(flach, 'das Verzeichnis nennt eine andere Zahl').toContain(`Alle ${wort} messen an`); expect(flach, 'das Verzeichnis nennt eine andere Zahl').toContain( `diese ${wort} Fälle sichtbar`, ); }); it('nennt der Text so viele Wortlaute des Genehmigungsvermerks, wie die Pakete fuehren', () => { /* * "Drei verschiedene Wortlaute" und "drei Pakete weichen ab" ist nicht * dasselbe - gemessen sind es drei Wortlaute bei zwei abweichenden * Paketen. Die Zaehlung steht hier, damit der Satz nicht wieder das eine * sagt und das andere meint. Verglichen wird der Absatz ab * "Permission is hereby granted", Leerraum vereinheitlicht: Zeilenumbrueche * unterscheiden keinen Wortlaut. */ const gruppen = new Map(); for (const name of Object.keys(RECHTEINHABER)) { const text = lizenzdateien(name) .map((d) => readFileSync(d, 'utf8').replace(/\r\n/g, '\n')) .join('\n'); const stelle = text.indexOf(GENEHMIGUNG); if (stelle < 0) continue; const absatz = text.slice(stelle).replace(/\s+/g, ' ').trim(); gruppen.set(absatz, [...(gruppen.get(absatz) ?? []), name]); } const mitVermerk = [...gruppen.values()].flat(); const groesste = [...gruppen.values()].sort((a, b) => b.length - a.length)[0] ?? []; const abweichend = mitVermerk.filter((n) => !groesste.includes(n)).sort(); const flach = DOK.replace(/\s+/g, ' '); expect(flach, `${mitVermerk.length} Pakete fuehren einen Genehmigungsvermerk`).toContain( `die ${ZAHLWORT[mitVermerk.length]} MIT-Pakete führen den Genehmigungsvermerk`, ); expect(flach, `${gruppen.size} verschiedene Wortlaute`).toContain( `in **${ZAHLWORT[gruppen.size]}** verschiedenen Wortlauten`, ); expect( flach, `${groesste.length} gleichlautend, abweichend ${abweichend.join(', ')}`, ).toContain( `${ZAHLWORT[groesste.length]} sind gleichlautend, ${aufzaehlung(abweichend)} weichen je für sich ab`, ); // Und vite.config.ts sagt dasselbe. Der Kommentar dort begruendet, warum // der ganze Text in den Banner geht; er zaehlt deshalb mit und ist beim // letzten Mal gerade nicht mitgezogen worden. const konfiguration = readFileSync('vite.config.ts', 'utf8'); expect(konfiguration, 'vite.config.ts nennt eine andere Zahl').toContain( `${ZAHLWORT[gruppen.size]} verschiedenen Wortlauten`, ); for (const name of abweichend) { expect(konfiguration, `vite.config.ts nennt ${name} nicht`).toContain(name); } }); it('nennt das Verzeichnis die Groesse der DOMPurify-Lizenzdatei in Byte, nicht in Zeichen', () => { /* * Der Unterschied ist keine Spitzfindigkeit: Die Datei fuehrt 92 * Mehrbyte-Zeichen, Zeichen- und Bytezahl gehen um eben diese 92 * auseinander, und jede andere Zahl des Abschnitts ist in Byte. Sie * begruendet den groessten Einzelposten der Groessentafel. */ const dateien = lizenzdateien('dompurify'); expect(dateien.length, 'keine Lizenzdatei in node_modules/dompurify').toBe(1); const roh = readFileSync(String(dateien[0])); const imBanner = roh.toString('utf8').replace(/\r\n/g, '\n').trim(); const flach = DOK.replace(/\s+/g, ' '); expect(flach, 'das Verzeichnis nennt eine andere Groesse').toContain( `in einer Datei von ${mitAbstand(roh.length)} Byte mit, von denen ${mitAbstand( Buffer.byteLength(imBanner, 'utf8'), )} in den Banner gehen`, ); expect(flach, 'die Zeichenzahl steht als Bytezahl da').not.toContain( `${mitAbstand(imBanner.length)} Byte`, ); }); it('nennt es die Laufzeit mit der Fassung des installierten Electron', () => { const laufzeit = eintraege('Die Laufzeit'); const electron = laufzeit.find((e) => e.name === 'Electron'); expect(electron, 'kein Eintrag "Electron"').toBeDefined(); expect(electron?.fassung, 'Fassung von Electron').toBe(installiert('electron').fassung); }); it.skipIf(OHNE_ELECTRON)( 'nennt es Chromium und Node.js mit den Fassungen, die das installierte Electron meldet (nur mit heruntergeladenem Electron messbar)', () => { /* * Gefragt wird die Programmdatei selbst und nicht eine Liste hier: Die * Zeile Node.js stand von 5.26.0 bis 5.42.1 auf 20.18.3, waehrend * Electron 44.3.0 Node.js 24.20.0 mitbringt - eine Fassung, die ein * Mensch nachzieht, bleibt beim naechsten Sprung wieder stehen. */ const lauf = spawnSync( String(ELECTRON_PROGRAMM), ['-p', 'JSON.stringify(process.versions)'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }, ); expect(lauf.status, `Electron meldete keine Fassungen: ${lauf.stderr}`).toBe(0); const gemeldet = JSON.parse(lauf.stdout) as Readonly>; // Die Gegenprobe, dass hier die Laufzeit des installierten Pakets antwortet. expect(gemeldet['electron'], 'die Programmdatei gehoert zu einem anderen Electron').toBe( installiert('electron').fassung, ); const laufzeit = eintraege('Die Laufzeit'); for (const [name, schluessel] of [ ['Chromium', 'chrome'], ['Node.js', 'node'], ] as const) { const eintrag = laufzeit.find((e) => e.name === name); expect(eintrag, `kein Eintrag "${name}"`).toBeDefined(); expect(gemeldet[schluessel], `Electron meldet keine Fassung fuer ${name}`).toBeDefined(); expect(eintrag?.fassung, `Fassung von ${name}`).toBe(gemeldet[schluessel]); } }, ); it.skipIf(OHNE_ELECTRON)( 'nennt es die Namen aus der alten Lizenzzaehlung so, wie die heutige Chromium-Uebersicht sie fuehrt (nur mit heruntergeladenem Electron messbar)', () => { /* * Die Zaehlung reziproker Lizenzen stammt aus der Uebersicht von * Electron 33.4.11 und ist an der heutigen nicht wiederholt. Was das * Verzeichnis ueber die heutige sagt, ist deshalb nur dies: welche der * aufgezaehlten Namen dort noch als Eintrag stehen. Genau das wird hier * nachgesehen - mit der Zahl und mit den Namen, die fehlen. */ const absatz = einzeilig( DOK.slice( DOK.indexOf('**Die Einträge beschreiben den Quellbaum'), DOK.indexOf('\n\n', DOK.indexOf('**Die Einträge beschreiben den Quellbaum')), ), ); const aufgezaehlt = /darunter ((?:`[^`]+`(?:, | und ))+`[^`]+`), also/.exec(absatz); expect(aufgezaehlt, 'der Absatz zaehlt keine Namen mehr auf').not.toBeNull(); const namen = [...String(aufgezaehlt?.[1]).matchAll(/`([^`]+)`/g)].map((t) => String(t[1])); const heute = chromiumEintraege(); const vorhanden = namen.filter((n) => heute.has(n)); const fehlend = namen.filter((n) => !heute.has(n)); expect(absatz, `${namen.length} Namen sind aufgezaehlt`).toContain( `welche der ${String(ZAHLWORT[namen.length])} Namen`, ); const aussage = /noch als Eintrag führt: (\S+); ((?:`[^`]+`(?:, | und )?)+) steh(?:t|en) nicht mehr darin/.exec( absatz, ); expect(aussage, 'der Absatz sagt nicht, welche Namen heute fehlen').not.toBeNull(); expect(aussage?.[1], 'so viele der Namen stehen heute darin').toBe( ZAHLWORT[vorhanden.length], ); expect( [...String(aussage?.[2]).matchAll(/`([^`]+)`/g)].map((t) => String(t[1])).sort(), 'diese Namen fehlen in der heutigen Uebersicht', ).toEqual([...fehlend].sort()); // Und der Satz zu Node.js nennt Eintraege, die es gibt. const nodeSatz = /Es steht als Eintrag „([^"]+)" in `LICENSES\.chromium\.html`, ebenso wie „([^"]+)"/.exec( einzeilig(DOK), ); expect(nodeSatz, 'der Satz zu Node.js in der Uebersicht fehlt').not.toBeNull(); for (const name of [nodeSatz?.[1], nodeSatz?.[2]]) { expect(heute.has(String(name)), `„${String(name)}" ist kein Eintrag der Uebersicht`).toBe( true, ); } }, ); it('stimmt die Zahl der Eintraege in der Chromium-Uebersicht', () => { const uebersicht = 'node_modules/electron/dist/LICENSES.chromium.html'; if (!existsSync(uebersicht)) return; const anzahl = (readFileSync(uebersicht, 'utf8').match(/
/g) ?? []).length; expect(DOK, `LICENSES.chromium.html fuehrt ${anzahl} Eintraege`).toContain( `**${anzahl} Einträge**`, ); }); it('fuehrt es jeden kuenftigen Eintrag aus dependencies mit', () => { const laufzeitpakete = Object.keys(PAKET.dependencies ?? {}); if (laufzeitpakete.length === 0) { expect(DOK, 'der Text sagt weiterhin, dependencies sei leer').toContain( 'Der Abschnitt `dependencies` ist leer', ); return; } for (const name of laufzeitpakete) { expect(DOK, `${name} steht in dependencies, aber nicht im Verzeichnis`).toContain( `\`${name}\``, ); } }); it('gibt package.json die Lizenztexte und das Verzeichnis mit dem Paket mit', () => { // extraFiles und nicht extraResources: electron-builder legt seine eigenen // beiden Lizenzdateien NEBEN die Programmdatei, nicht nach resources/. const mitgegeben = PAKET.build.extraFiles ?? []; expect(mitgegeben.map((e) => e.from)).toContain('build/DRITTANBIETER-LIZENZEN.txt'); expect(mitgegeben.map((e) => e.from)).toContain('docs/fremdbestandteile.md'); // Ohne diesen Aufruf legte electron-builder eine Sammeldatei von gestern // bei - `npm run dist` baut, packt und rechnet die Pruefsummen, aber // sammelt nicht von sich aus ein. expect(PAKET.scripts['lizenztexte'], 'kein Skript lizenztexte').toContain( 'scripts/lizenztexte.mjs', ); expect(PAKET.scripts['build'], 'npm run build ruft das Sammelskript nicht').toContain( 'lizenztexte', ); // Und das Verzeichnis darf den behobenen Punkt nicht weiter als offen // fuehren. expect(DOK).not.toContain( 'Die Lizenztexte der gebündelten Bibliotheken liegen dem Paket nicht', ); expect(DOK).toContain('## Was dem Paket beiliegt'); }); it('datiert es seine Messungen auf die Fassung aus package.json', () => { /* * Die Fassungsnummer steht einmal - in package.json. Zwei Stellen des * Verzeichnisses datieren eine Messung und muessen sie deshalb nennen: der * Kopf und der Spiegelstrich "Was im Paket ankommt". Letzterer zeigte * zunaechst noch auf einen gebauten Stand 5.11.0 und belegte damit gerade * NICHT, was der Abschnitt "Was dem Paket beiliegt" zusagt: Ein Stand vor * 5.12.0 kann DRITTANBIETER-LIZENZEN.txt und FREMDBESTANDTEILE.md nicht * enthalten haben, weil es beide erst seit 5.12.0 gibt. * * Der Rueckblick weiter unten ("Bis 5.11.0 stand hier ...") ist keine * Messung, sondern eine Angabe darueber, was frueher dastand; er liegt in * keinem der beiden hier geprueften Stuecke und darf eine aeltere Fassung * nennen. */ const kopf = DOK.split('\n').find((z) => z.startsWith('Stand: Fassung ')); expect(kopf, 'Zeile "Stand: Fassung ..." nicht gefunden').toBeDefined(); expect(kopf, 'der Kopf datiert auf eine andere Fassung').toContain( `Stand: Fassung ${PAKET.version},`, ); const zeilen = DOK.split('\n'); const anfang = zeilen.findIndex((z) => z.startsWith('- **Was im Paket ankommt**')); expect(anfang, 'Spiegelstrich "Was im Paket ankommt" nicht gefunden').toBeGreaterThanOrEqual(0); let ende = anfang + 1; while ((zeilen[ende] ?? '').startsWith(' ')) ende += 1; const spiegelstrich = zeilen.slice(anfang, ende).join(' ').replace(/\s+/g, ' '); const stand = /Standes (\d+\.\d+\.\d+)/.exec(spiegelstrich); expect(stand, 'der Spiegelstrich nennt keinen gebauten Stand').not.toBeNull(); expect(stand?.[1], 'der Beleg zeigt nicht auf die ausgelieferte Fassung').toBe(PAKET.version); }); it('zaehlt der Abschnitt "Was dem Paket beiliegt" nur die Dateien mit Lizenztext als solche', () => { /* * Der Einleitungssatz geht ueber `build.extraFiles` woertlich an den * Anwender. Er darf deshalb nicht mehr zusagen als die Tabelle darunter: * FREMDBESTANDTEILE.md fuehrt keinen Lizenztext, sondern sagt, was das ist, * woher es kommt und wozu es dient. "Vier Dateien mit Lizenztexten" waere * eine Zusage, die das Verzeichnis mit seinem "Das ist keine * Rechtsauskunft" ausdruecklich nicht geben will. * * Gemessen wird an der Tabelle, nicht an einer zweiten Zahl im Text: Wer * eine Zeile hinzunimmt, muss beide Zahlwoerter nachziehen. */ const anfang = DOK.indexOf('## Was dem Paket beiliegt'); expect(anfang, 'Abschnitt "Was dem Paket beiliegt" nicht gefunden').toBeGreaterThanOrEqual(0); const naechste = DOK.indexOf('\n## ', anfang + 1); const abschnitt = DOK.slice(anfang, naechste < 0 ? DOK.length : naechste); const reihen = abschnitt .split('\n') .filter((z) => z.startsWith('|')) .map((z) => z.split('|').map((s) => s.trim())) .filter((s) => (s[1] ?? '') !== 'Datei' && !(s[1] ?? '').startsWith('---')); expect(reihen.length, 'keine Tabellenzeilen gefunden').toBeGreaterThan(0); // Die Zeile, die dieses Verzeichnis selbst mitgibt, ist keine mit // Lizenztext - sie steht in der Spalte "Kommt von" als solche da. const selbst = reihen.filter((s) => (s[2] ?? '').includes('dieses Verzeichnis')).length; expect(selbst, 'keine Zeile fuer das Verzeichnis selbst').toBe(1); const flach = DOK.replace(/\s+/g, ' '); expect(flach, `die Tabelle fuehrt ${reihen.length} Dateien`).toContain( `Neben der Programmdatei liegen ${ZAHLWORT[reihen.length]} Dateien`, ); expect(flach, `${selbst} davon fuehrt keinen Lizenztext`).toContain( `${ZAHLWORT[reihen.length - selbst]} mit Lizenztexten`, ); }); it.skipIf(OHNE_BAU)( 'schreibt scripts/lizenztexte.mjs zu jeder gebuendelten Bibliothek einen Text (nur mit Bauergebnis messbar)', () => { const ziel = path.join(ablage, 'DRITTANBIETER-LIZENZEN.txt'); const lauf = spawnSync(process.execPath, ['scripts/lizenztexte.mjs', '--ziel', ziel], { cwd: WURZEL, encoding: 'utf8', }); expect(lauf.status, `das Skript brach ab: ${lauf.stdout}${lauf.stderr}`).toBe(0); const sammlung = readFileSync(ziel, 'utf8'); const gebuendelt = [...(BUENDEL as Map).keys()].sort(); // Nicht "irgendwelche Texte", sondern zu JEDER Bibliothek einer: Der // Name des Rechteinhabers ist derselbe Fuehler, den der Fall oben am // installierten Paket belegt hat. for (const name of gebuendelt) { const inhaber = RECHTEINHABER[name]; expect(inhaber, `kein Fuehler fuer ${name}`).toBeDefined(); expect(sammlung, `${name} steht mit keinem Abschnitt in der Sammeldatei`).toContain( `${name} ${installiert(name).fassung}`, ); expect(sammlung, `der Text von ${name} fehlt in der Sammeldatei`).toContain( String(inhaber), ); } expect(sammlung, 'die Sammeldatei nennt eine andere Zahl').toContain( `Es folgen ${gebuendelt.length} Texte.`, ); // Mit Bytefolgemarke - sonst zeigt ein Windows-Editor aus den Umlauten // Buchstabensalat, und die Datei liegt beim Anwender, nicht hier. expect(sammlung.charCodeAt(0), 'keine Bytefolgemarke').toBe(0xfeff); }, ); it('schreibt es lieber nichts, als eine Bibliothek zu unterschlagen', () => { // Der gefaehrliche Ausgang waere nicht der Abbruch, sondern eine Datei // ohne den fehlenden Text: Sie sieht nach Erfuellung aus. Gemessen an // einer erfundenen Quellkarte, die ein nicht installiertes Paket nennt - // das braucht kein Bauergebnis und greift auch in einer frischen // Arbeitskopie. const bau = path.join(ablage, 'erfundener-bau'); mkdirSync(bau, { recursive: true }); writeFileSync( path.join(bau, 'index-AAAAAAAA.js.map'), JSON.stringify({ sources: ['../../node_modules/gibt-es-nicht/index.js'] }), 'utf8', ); const ziel = path.join(bau, 'soll-nicht-entstehen.txt'); const lauf = spawnSync( process.execPath, ['scripts/lizenztexte.mjs', '--bau', bau, '--ziel', ziel], { cwd: WURZEL, encoding: 'utf8' }, ); expect(lauf.status, 'das Skript endete mit 0').not.toBe(0); expect(`${lauf.stdout}${lauf.stderr}`).toContain('gibt-es-nicht'); expect(existsSync(ziel), 'es hat trotzdem eine Sammeldatei geschrieben').toBe(false); }); it('gibt es sich nicht als Rechtsauskunft aus', () => { expect(DOK).toContain('**Das ist keine Rechtsauskunft.**'); expect(DOK).toContain('## Was aussteht'); }); });