/** * Wie aktuell ist das gepackte Paket unter `release/win-unpacked/`? * * ## Warum es diese Datei gibt * * `e2e/gepackt.spec.ts` prüft die **gepackte** Anwendung – die Auflösung der * Pfade unter `resources/`, die Versionsanzeige, den Start aus dem Paket * heraus. Diese Prüfungen liefen bisher gegen das, was gerade in * `release/win-unpacked/` lag, gleich wie alt es war. * * Das hat in diesem Projekt **zweimal** einen echten Fehler verdeckt: * * 1. Beim Umbau der Reifekennzahl hing die Prüfung weiter am Wortlaut * „0 von 575 Fragen sicher“, den es nicht mehr gab. * 2. Als der Systemzustand den Quellort des Fragenkatalogs bekam, wurde der * Locator `.statusliste code` mehrdeutig. * * Beide Male meldete `npm run gate` grün, und beide Male fiel es erst beim * nächsten Paketbau auf – weil das Paket bis dahin die Änderung gar nicht * enthielt. * * `bauPruefen()` in `e2e/electron-hilfe.ts` führt dieses Argument für `out/` * schon selbst: „Ein Bau von gestern gegen den Quelltext von heute ist * schlimmer als gar keiner: Der Lauf ist grün und misst die falsche * Anwendung.“ Auf das Paket wurde es nie angewandt. * * ## Warum der Vergleich nicht bei `src/` aufhört * * Die erste Fassung verglich das Paket allein mit `app/src`. Das ist die * halbe Lieferung: Der **Inhalt** – Fragenkatalog, Erklärungen, Glossar, * Prüfzeichen – liegt gar nicht unter `app/src`, sondern unter `content/` * und wird über `extraResources` in `electron-builder.yml` mitgepackt. Wer * eine Erklärung ändert und dann prüft, bekam ein Paket gemeldet, das „so * jung wie der Quelltext“ sei – und die Prüfungen maßen die vorige * Fassung der Inhalte und meldeten grün. Genau die Fehlerklasse, gegen die * diese Datei angelegt wurde, nur eine Tür weiter. * * Deshalb liest der Vergleich die Liste der mitgelieferten Pfade **aus * `electron-builder.yml`** statt sie hier noch einmal aufzuschreiben. Zwei * Listen desselben Inhalts laufen beim nächsten Umbau auseinander, und dann * schweigt die Wache wieder. Was electron-builder packt, wird geprüft; was * dazukommt, wird ohne Zutun mitgeprüft. * * ## Warum eine eigene Datei und kein zweiter Abgleich * * Zwei Stellen brauchen dieselbe Antwort: die Prüfung selbst, damit sie sich * mit Grund überspringt, und `gate-stempel.mjs`, damit der Gate-Bericht sagt, * was er **nicht** geprüft hat. Zwei Umsetzungen desselben Vergleichs liefen * irgendwann auseinander, und dann widerspräche der Bericht dem Lauf. * * Als `.mjs` und nicht als TypeScript, weil `gate-stempel.mjs` sie ohne * Übersetzungsschritt lädt. */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; /** * Toleranz gegen die Auflösung des Dateisystems. * * Derselbe Wert wie in `bauPruefen()`: Ein Paketbau schreibt seine Ausgaben * nicht in derselben Millisekunde, in der er die Quellen liest. */ const TOLERANZ_MS = 2000; /** Verzeichnisse, die für den Vergleich nichts beitragen. */ const UEBERGANGEN = new Set(['node_modules', '.git', 'out', 'release', 'test-results']); /** * Was `out/` für den Vergleich ersetzt. * * `electron-builder.yml` packt `out/**` – das ist aber kein Quelltext, * sondern das Erzeugnis von `electron-vite build` aus `src/`. Auf `out/` * selbst zu schauen ginge zweimal daneben: `npm run gate` baut `out/` in * jedem Lauf neu, das Paket sähe also unmittelbar nach jedem Gate veraltet * aus; und die Frage, ob der Quelltext weitergewandert ist, beantwortet es * ohnehin nicht. `src/` ist die ehrliche Entsprechung. */ const ERSATZ_FUER_ERZEUGNIS = new Map([['out', 'src']]); /** Zeichen, an denen electron-builder einen Glob erkennt. */ const GLOB_ZEICHEN = /[*?[\]{}]/u; /** * Nimmt einer YAML-Skalarzeile Anführungszeichen und Zeilenkommentar ab. * * @param {string} roh * @returns {string} */ function entwerten(roh) { const wert = roh.trim(); for (const anfuehrung of ["'", '"']) { if (wert.startsWith(anfuehrung)) { const ende = wert.indexOf(anfuehrung, 1); if (ende < 0) { throw new Error(`electron-builder.yml: unbeendetes Anführungszeichen in ${wert}`); } return wert.slice(1, ende); } } const kommentar = wert.indexOf(' #'); return kommentar < 0 ? wert : wert.slice(0, kommentar).trim(); } /** * Liest die beiden Listen aus `electron-builder.yml`, die etwas ausliefern. * * Bewusst ein winziger, **strenger** Ausschnitt von YAML statt einer * Abhängigkeit: `js-yaml` liegt zwar unter `node_modules`, aber nur als * Beipack von electron-builder – ein Import darauf wäre eine nicht erklärte * Abhängigkeit. Der Ausschnitt versteht genau die Formen, die diese Datei * heute enthält, und **wirft** bei allem anderen. Ein lauter Fehler beim * nächsten Umbau ist der Punkt: Eine Wache, die eine neue Schreibweise still * überliest, bewacht wieder nichts. * * @param {string} text Inhalt von `electron-builder.yml`. * @returns {{ programm: string[], inhalte: string[] }} Muster, wie sie in der * Datei stehen: `programm` aus `files:`, `inhalte` aus `extraResources:`. */ export function musterAusBauplan(text) { /** @type {string[]} */ const programm = []; /** @type {string[]} */ const inhalte = []; /** @type {'files' | 'extraResources' | null} */ let block = null; // Einzug der `- `-Zeilen des laufenden Blocks; -1, solange unbekannt. let eintragsEinzug = -1; for (const zeile of text.split(/\r?\n/u)) { const ohneEinzug = zeile.trimStart(); if (ohneEinzug === '' || ohneEinzug.startsWith('#')) { continue; } const einzug = zeile.length - ohneEinzug.length; // Oberste Ebene: ein neuer Schlüssel beendet den laufenden Block. if (einzug === 0) { const schluessel = /^([A-Za-z][A-Za-z0-9_]*):/u.exec(ohneEinzug); const name = schluessel?.[1]; block = name === 'files' || name === 'extraResources' ? name : null; eintragsEinzug = -1; continue; } if (block === null) { continue; } if (!ohneEinzug.startsWith('- ')) { // Eigenschaft eines Eintrags (`to:`, `filter:`) – liefert selbst nichts. if (eintragsEinzug >= 0 && einzug > eintragsEinzug) { continue; } throw new Error(`electron-builder.yml: unverstandene Zeile im Block ${block}: ${ohneEinzug}`); } if (eintragsEinzug < 0) { eintragsEinzug = einzug; } if (einzug > eintragsEinzug) { // Unterliste eines Eintrags, etwa `filter:` – kein eigener Pfad. continue; } if (einzug < eintragsEinzug) { throw new Error( `electron-builder.yml: Listeneintrag mit fremdem Einzug im Block ${block}: ${ohneEinzug}`, ); } const wert = ohneEinzug.slice(2).trim(); // Beide Blöcke erlauben sowohl `- pfad` als auch `- from: pfad`. const ausFrom = /^from:\s*(.+)$/u.exec(wert); const muster = entwerten(ausFrom ? ausFrom[1] : wert); // Ausschlussmuster liefern nichts aus und tragen zum Alter nichts bei. if (muster.startsWith('!')) { continue; } (block === 'files' ? programm : inhalte).push(muster); } return { programm, inhalte }; } /** * Macht aus einem Auslieferungsmuster den Pfad, dessen Alter zählt. * * `out/**\/*` wird zu `src`, `../content/katalog` zum Verzeichnis selbst. * * @param {string} appWurzel * @param {string} muster * @returns {string} Absoluter Pfad. */ function pfadZuMuster(appWurzel, muster) { const teile = muster.split('/'); /** @type {string[]} */ const fest = []; for (const teil of teile) { if (GLOB_ZEICHEN.test(teil)) { break; } fest.push(teil); } if (fest.length === 0) { throw new Error( `electron-builder.yml: Muster ${muster} beginnt mit einem Platzhalter. ` + 'Es ließe sich nur auf das ganze Projektverzeichnis auflösen und ' + 'wäre als Altersvergleich wertlos.', ); } const ersatz = ERSATZ_FUER_ERZEUGNIS.get(fest[0]); if (ersatz !== undefined) { fest.splice(0, fest.length, ersatz); } const pfad = resolve(appWurzel, ...fest); if (!existsSync(pfad)) { throw new Error( `electron-builder.yml verweist auf ${muster}, unter ${pfad} liegt aber nichts. ` + 'Entweder ist der Bauplan veraltet oder der Altersvergleich in ' + 'tools/paketstand.mjs löst ihn falsch auf – beides muss auffallen.', ); } return pfad; } /** * Das Verzeichnis, aus dem electron-builder die Bauzutaten holt. * * Das Programmsymbol liegt weder unter `files:` noch unter `extraResources:` * – es steht in `directories.buildResources` und wird von electron-builder * in die exe und in das Installationsprogramm eingebaut. Genau deshalb fehlte * es hier zuerst: Wer das Symbol änderte, bekam ein Paket gemeldet, das „so * jung wie der Quelltext“ sei, und trug das alte Bild weiter. Dieselbe * Fehlerklasse wie damals bei `content/`, nur eine Tür weiter. * * Gelesen wird auch das aus dem Bauplan statt es hier aufzuschreiben. Zwei * Listen desselben Sachverhalts laufen beim nächsten Umbau auseinander. * * @param {string} text Inhalt von `electron-builder.yml`. * @returns {string | null} Der Verzeichnisname, oder `null`, wenn keiner steht. */ export function bauressourcenAusBauplan(text) { let imBlock = false; for (const zeile of text.split(/\r?\n/u)) { const ohneEinzug = zeile.trimStart(); if (ohneEinzug === '' || ohneEinzug.startsWith('#')) { continue; } if (zeile.length === ohneEinzug.length) { // Oberste Ebene: ein neuer Schlüssel beendet den laufenden Block. imBlock = /^directories:/u.test(ohneEinzug); continue; } if (imBlock) { const treffer = /^buildResources:\s*(\S+)/u.exec(ohneEinzug); if (treffer) { return treffer[1]; } } } return null; } /** * Alle Pfade, deren Alter über die Aktualität des Pakets entscheidet. * * Eigene Funktion und nicht in `paketstand()` versteckt, weil sie für sich * prüfbar sein muss: Sie ist die Stelle, an der Bauplan und Vergleich * auseinanderlaufen könnten. `paketstand()` beantwortet sie nicht, wenn gar * kein Paket dasteht – ein Test, der nur über `paketstand()` ginge, prüfte * die Auflösung an einem Arbeitsplatz ohne `release/` also gar nicht. * * @param {string} appWurzel Das Verzeichnis `app/`. * @returns {{ quelltext: string[], inhalte: string[] }} Absolute Pfade. */ export function ausgelieferteQuellen(appWurzel) { const bauplan = join(appWurzel, 'electron-builder.yml'); if (!existsSync(bauplan)) { throw new Error( `Kein Bauplan unter ${bauplan}. Ohne ihn ist nicht zu sagen, was das Paket ` + 'ausliefert – und ein Altersvergleich, der das nicht weiß, prüft nichts.', ); } const text = readFileSync(bauplan, 'utf8'); const muster = musterAusBauplan(text); const quelltext = muster.programm.map((m) => pfadZuMuster(appWurzel, m)); /* Der Bauplan selbst gehört dazu. Er sagt nicht nur, **was** ausgeliefert wird, sondern bestimmt auch, **wie**: Ziele, Dateilisten, ASAR-Entpackung, die Angaben des Store-Pakets. Wer daran etwas ändert, hat ein anderes Paket vor sich – bis Fassung 0.24.1 galt der alte Beleg trotzdem weiter, weil die Wache jeden Pfad ansah, den der Bauplan nennt, nur nicht ihn selbst. Derselbe blinde Fleck wie bei den Bauressourcen (7.23), eine Ebene höher. */ quelltext.push(bauplan); const bauressourcen = bauressourcenAusBauplan(text); if (bauressourcen !== null) { quelltext.push(pfadZuMuster(appWurzel, bauressourcen)); } return { quelltext, inhalte: muster.inhalte.map((m) => pfadZuMuster(appWurzel, m)), }; } /** * @typedef {object} Aenderung * @property {number} zeit Änderungszeit in Millisekunden; 0, wenn nichts da war. * @property {string} pfad Die Datei, von der sie stammt; leer bei 0. */ /** * Jüngste Änderung unterhalb eines Pfades – samt der Datei, die sie trägt. * * Die Datei mitzuführen kostet nichts und macht aus „irgendetwas ist neuer“ * eine nachprüfbare Aussage: Wer die Meldung liest, weiß sofort, ob er neu * bauen muss oder ob er selbst eine Datei angefasst hat. * * @param {string} pfad Datei oder Verzeichnis. * @returns {Aenderung} */ function juengsteAenderung(pfad) { const eigenschaften = statSync(pfad); if (!eigenschaften.isDirectory()) { return { zeit: eigenschaften.mtimeMs, pfad }; } /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' }; for (const eintrag of readdirSync(pfad, { withFileTypes: true })) { if (UEBERGANGEN.has(eintrag.name)) { continue; } const kind = juengsteAenderung(join(pfad, eintrag.name)); if (kind.zeit > juengste.zeit) { juengste = kind; } } return juengste; } /** * Jüngste Änderung über mehrere Pfade hinweg. * * @param {string[]} pfade * @returns {Aenderung} */ function juengsteUeber(pfade) { /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' }; for (const pfad of pfade) { const kandidat = juengsteAenderung(pfad); if (kandidat.zeit > juengste.zeit) { juengste = kandidat; } } return juengste; } /** * Pfad, wie ihn ein Mensch im Projekt sucht: relativ zur Projektwurzel. * * @param {string} appWurzel * @param {string} pfad * @returns {string} */ function lesbar(appWurzel, pfad) { return relative(dirname(appWurzel), pfad).replaceAll('\\', '/'); } /** * @typedef {object} Paketstand * @property {boolean} vorhanden Ob überhaupt ein gepacktes Paket dasteht. * @property {boolean} veraltet Ob es älter ist als das, was es ausliefert. * @property {number} alterSekunden Um wie viel es zurückliegt; 0, wenn aktuell. * @property {'quelltext' | 'inhalte' | 'beides' | null} ursache * Was weitergewandert ist. `null`, solange nichts veraltet ist. * @property {string} juengsteQuelle * Die Datei, die den Ausschlag gibt, relativ zur Projektwurzel; leer, wenn * das Paket aktuell ist oder fehlt. * @property {string} grund Ein Satz, der den Zustand benennt. */ /** * Vergleicht das gepackte Paket mit allem, was es ausliefert. * * @param {string} appWurzel Das Verzeichnis `app/`. * @returns {Paketstand} */ export function paketstand(appWurzel) { const exe = join(appWurzel, 'release', 'win-unpacked', 'Waffensachkunde Lernsoftware.exe'); if (!existsSync(exe)) { return { vorhanden: false, veraltet: false, alterSekunden: 0, ursache: null, juengsteQuelle: '', grund: 'Kein gepacktes Paket vorhanden. Die Prüfungen gegen das gebaute Paket ' + 'wurden übersprungen – zuerst `npm run dist:win` ausführen.', }; } const quellen = ausgelieferteQuellen(appWurzel); const quelltext = juengsteUeber(quellen.quelltext); const inhalte = juengsteUeber(quellen.inhalte); const gepacktAm = statSync(exe).mtimeMs; const grenze = gepacktAm + TOLERANZ_MS; const quelltextNeuer = quelltext.zeit > grenze; const inhalteNeuer = inhalte.zeit > grenze; if (!quelltextNeuer && !inhalteNeuer) { return { vorhanden: true, veraltet: false, alterSekunden: 0, ursache: null, juengsteQuelle: '', grund: 'Das gepackte Paket ist so jung wie Quelltext und mitgelieferte Inhalte.', }; } const juengste = quelltext.zeit >= inhalte.zeit ? quelltext : inhalte; const alterSekunden = Math.round((juengste.zeit - gepacktAm) / 1000); const quelle = lesbar(appWurzel, juengste.pfad); /* Der Grundtext sagt weiterhin, WARUM übersprungen wird – und jetzt auch, WAS weitergewandert ist. Der Unterschied ist keine Feinheit: „Quelltext“ heißt neu bauen, „Inhalte“ heißt, dass eine Änderung an content/ noch nicht im Paket steckt. Beim ersten Auftreten dieses Falls stand in der Meldung nichts davon, weil der Vergleich content/ gar nicht ansah. */ /** @type {'quelltext' | 'inhalte' | 'beides'} */ let ursache = 'beides'; if (!inhalteNeuer) { ursache = 'quelltext'; } else if (!quelltextNeuer) { ursache = 'inhalte'; } /* „das Programm“ statt „der Quelltext“, seit auch das Verzeichnis mit dem Programmsymbol mitzählt: Ein Symbol ist kein Quelltext, steckt aber genauso in der gebauten exe. Die Meldung nennt ohnehin die Datei, die den Ausschlag gibt – daran sieht man sofort, welcher Fall vorliegt. */ const benennung = { quelltext: `das Programm (${quelle})`, inhalte: `die mitgelieferten Inhalte (${quelle})`, beides: `Programm und mitgelieferte Inhalte (jüngste Änderung: ${quelle})`, }[ursache]; return { vorhanden: true, veraltet: true, alterSekunden, ursache, juengsteQuelle: quelle, grund: `Das gepackte Paket ist ${String(alterSekunden)} s älter als ${benennung}. ` + 'Die Prüfungen dagegen würden die vorige Fassung messen und grün melden; ' + 'sie wurden deshalb übersprungen – zuerst `npm run dist:win` ausführen.', }; }