// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Le script de construction de l'exécutable Windows (§ 16, § 18.2, § 18.5) : // ce qu'il refuse avant de construire, la commande qu'il passe au conteneur, // le contrôle de fin de construction, et ce qu'il laisse quand la // construction échoue ou qu'un signal l'interrompt. // // Chaque épreuve copie le script et les fichiers qu'il lit dans un arbre // temporaire, et le lance avec, en tête du PATH, un podman et un npm // factices. Ils consignent leurs arguments. podman écrit chaque nom de // FAUX_PRODUIT, un par ligne, dans le dossier de l'hôte qu'il reçoit monté // sur /project/dist-electron, comme electron-builder y écrirait // l'exécutable ; il y écrit aussi, à chaque lancement, les deux exécutables // qu'electron-builder pose toujours sous win-unpacked/, aux profondeurs 2 // et 3 ; puis il sort en FAUX_CODE ; sous FAUX_ATTENTE, il s'annonce sur // sa sortie et attend une ligne sur son entrée avant d'écrire. npm écrit // www/index.html, sauf sous FAUX_SANS_PAGE, puis sort en FAUX_CODE_NPM. import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { accessSync, appendFileSync, chmodSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, test } from '../test/lanceur.js'; import { versionVoisine } from '../test/version_voisine.js'; import { deriver } from './version.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); const VERSION = JSON.parse(readFileSync(join(RACINE, 'version.json'), 'utf8')).version; const NOM = deriver(VERSION).nomFichier; // Le livrable d'une autre version du même jour : il reste dans dist-electron/ // quoi que fasse la construction de la version courante. const NOM_AUTRE_VERSION = deriver(versionVoisine(VERSION)).nomFichier; // Délai d'un lancement du script. Il reste sous celui d'une épreuve de // Vitest, 5 s, qui n'interrompt pas un appel synchrone : un script qui ne // s'arrête pas fait échouer son épreuve en le disant, au lieu de figer la // série. Le délai écoulé, le script reçoit KILL : il piège TERM et attend // la fin de la commande en cours, si bien que TERM n'arrêterait pas un // script dont un enfant ne finit pas. const DELAI_MS = 4_000; // Ce que le script lit, et ce que lit le contrôle de version qu'il lance. const COPIES = [ 'version.json', 'package.json', 'package-lock.json', 'CHANGELOG.md', 'README.md', 'capacitor.config.json', 'electron-builder.config.cjs', 'scripts/version.js', 'scripts/construire_windows.sh', 'src/version.genere.js', 'src/main.js', ]; const LANCE = 'podman factice lancé'; const PODMAN_FACTICE = [ '#!/usr/bin/env bash', 'printf "%s\\0" "$@" > "$JOURNAL/podman"', 'echo podman >> "$JOURNAL/ordre"', 'for argument in "$@"; do', ' case "$argument" in', ' *:/project/dist-electron) sortie="${argument%:/project/dist-electron}" ;;', ' esac', 'done', 'if [[ -n "${FAUX_ATTENTE-}" ]]; then', ` echo '${LANCE}'`, ' read -r _', 'fi', 'mkdir -p "$sortie/win-unpacked/resources"', 'printf MZ > "$sortie/win-unpacked/Gestion table tournante Libre.exe"', 'printf MZ > "$sortie/win-unpacked/resources/elevate.exe"', 'while IFS= read -r produit; do', ' [[ -z "$produit" ]] || printf MZ > "$sortie/$produit"', 'done <<< "${FAUX_PRODUIT-}"', 'exit "${FAUX_CODE:-0}"', '', ].join('\n'); const NPM_FACTICE = [ '#!/usr/bin/env bash', 'printf "%s\\0" "$@" > "$JOURNAL/npm"', 'echo npm >> "$JOURNAL/ordre"', '[[ -n "${FAUX_SANS_PAGE-}" ]] || { mkdir -p www && echo "" > www/index.html; }', 'exit "${FAUX_CODE_NPM:-0}"', '', ].join('\n'); // node qui se consigne dans l'ordre des lancements, puis passe la main au // vrai : sert là où le PATH est réduit à bin/. const NODE_CONSIGNE = [ '#!/usr/bin/env bash', 'echo node >> "$JOURNAL/ordre"', `exec '${process.execPath}' "$@"`, '', ].join('\n'); // Outils que le script appelle, hors podman, npm et node. Un PATH réduit à // bin/ en reçoit des liens : seul podman y manque. const OUTILS = ['bash', 'dirname', 'find', 'mkdir', 'rm', 'wc', 'file']; // Chemin d'un exécutable dans le PATH de l'épreuve, ou null. function chercher(nom) { for (const dossier of (process.env.PATH ?? '').split(delimiter)) { try { accessSync(join(dossier, nom), constants.X_OK); return join(dossier, nom); } catch { // Absent de ce dossier : le suivant. } } return null; } const lireSiPresent = (chemin) => (existsSync(chemin) ? readFileSync(chemin, 'utf8') : ''); // Arguments consignés par un factice, ou null s'il n'a pas été lancé. function argumentsDe(journal, factice) { const texte = lireSiPresent(join(journal, factice)); return texte === '' ? null : texte.split('\0').slice(0, -1); } // Contenu de chaque fichier .exe au premier niveau de dist-electron/. function executables(projet) { const sortie = join(projet, 'dist-electron'); if (!existsSync(sortie)) return {}; return Object.fromEntries( readdirSync(sortie) .filter((nom) => nom.endsWith('.exe')) .sort() .map((nom) => [nom, readFileSync(join(sortie, nom), 'utf8')]), ); } // Un arbre neuf, prêt au lancement : le projet recopié, bin/ et ses // factices, le journal, l'environnement du script. produit est un nom ou une // liste de noms que podman écrit ; page: false et codeNpm règlent npm ; // attente règle podman. sansPodman réduit le PATH à bin/, qui reçoit les // OUTILS et NODE_CONSIGNE, sans podman. preparer reçoit la racine du projet. function preparerArbre({ produit, code, codeNpm, page = true, attente = false, sansPodman = false, preparer } = {}) { const base = mkdtempSync(join(tmpdir(), 'construire-windows-')); const projet = join(base, 'projet'); const bin = join(base, 'bin'); const journal = join(base, 'journal'); for (const chemin of COPIES) { mkdirSync(dirname(join(projet, chemin)), { recursive: true }); copyFileSync(join(RACINE, chemin), join(projet, chemin)); } mkdirSync(bin); mkdirSync(journal); const factices = sansPodman ? [['npm', NPM_FACTICE], ['node', NODE_CONSIGNE]] : [['podman', PODMAN_FACTICE], ['npm', NPM_FACTICE]]; for (const [nom, contenu] of factices) { writeFileSync(join(bin, nom), contenu); chmodSync(join(bin, nom), 0o755); } if (sansPodman) { for (const nom of OUTILS) { const chemin = chercher(nom); if (chemin !== null) symlinkSync(chemin, join(bin, nom)); } } preparer?.(projet); const environnement = { ...process.env, PATH: sansPodman ? bin : [bin, dirname(process.execPath), process.env.PATH].join(delimiter), JOURNAL: journal, }; for (const cle of ['FAUX_PRODUIT', 'FAUX_CODE', 'FAUX_ATTENTE', 'FAUX_SANS_PAGE', 'FAUX_CODE_NPM']) { delete environnement[cle]; } if (produit !== undefined) environnement.FAUX_PRODUIT = [produit].flat().join('\n'); if (code !== undefined) environnement.FAUX_CODE = String(code); if (codeNpm !== undefined) environnement.FAUX_CODE_NPM = String(codeNpm); if (!page) environnement.FAUX_SANS_PAGE = '1'; if (attente) environnement.FAUX_ATTENTE = '1'; return { base, projet, journal, environnement, script: join(projet, 'scripts', 'construire_windows.sh') }; } // Lance le script dans un arbre neuf, puis rend ce qu'il a imprimé, ce que // les factices ont consigné et les exécutables de dist-electron/. Les // paramètres sont ceux de preparerArbre, plus options, les arguments du // script, et relever, appelé sur la racine du projet avant son retrait : // son résultat est rendu sous « releve ». function construire({ options = [], relever, ...parametres } = {}) { const arbre = preparerArbre(parametres); try { const resultat = spawnSync(arbre.script, options, { cwd: arbre.base, encoding: 'utf8', env: arbre.environnement, timeout: DELAI_MS, killSignal: 'SIGKILL', }); assert.notEqual( resultat.error?.code, 'ETIMEDOUT', `scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms :\n${resultat.stderr}`, ); return { statut: resultat.status, sortie: resultat.stdout, erreurs: `${resultat.error?.message ?? ''}${resultat.stderr}`, projet: arbre.projet, ordre: lireSiPresent(join(arbre.journal, 'ordre')).split('\n').filter(Boolean), npm: argumentsDe(arbre.journal, 'npm'), podman: argumentsDe(arbre.journal, 'podman'), executables: executables(arbre.projet), releve: relever?.(arbre.projet), }; } finally { rmSync(arbre.base, { recursive: true, force: true }); } } const ecrireExecutable = (contenu, nom = NOM) => (projet) => { mkdirSync(join(projet, 'dist-electron'), { recursive: true }); writeFileSync(join(projet, 'dist-electron', nom), contenu); }; // La configuration recopiée tape le nom du livrable : la ligne ajoutée // remplace celui qu'elle tient de deriver. const taperLeNom = (nom) => (projet) => appendFileSync(join(projet, 'electron-builder.config.cjs'), `module.exports.artifactName = ${JSON.stringify(nom)};\n`); // Une construction réussie, partagée par les épreuves qui la lisent. let reussie; const constructionReussie = () => (reussie ??= construire({ produit: NOM })); describe('construction Windows : refus avant de construire', () => { test('une version sans section au changelog ne se construit pas (§ 16)', () => { const r = construire({ preparer: (projet) => writeFileSync(join(projet, 'CHANGELOG.md'), '# Changelog — Gestion table tournante Libre\n'), }); assert.equal(r.statut, 1, r.erreurs); assert.match(r.erreurs, /^3\. CHANGELOG\.md/m); assert.match(r.erreurs, /Construction refusée/); assert.deepEqual(r.ordre, []); }); test("un exécutable du même nom n'est pas écrasé (§ 18.2)", () => { const r = construire({ produit: NOM, preparer: ecrireExecutable('ancien') }); assert.equal(r.statut, 1, r.erreurs); assert.ok(r.erreurs.includes(`dist-electron/${NOM}`), r.erreurs); assert.match(r.erreurs, /--reconstruire/); assert.deepEqual(r.ordre, []); assert.deepEqual(r.executables, { [NOM]: 'ancien' }); }); test('une option inconnue sort en 2 et rappelle l’usage', () => { const r = construire({ options: ['--forcer'] }); assert.equal(r.statut, 2, r.erreurs); assert.match(r.erreurs, /usage .*--reconstruire/); assert.deepEqual(r.ordre, []); }); test('sans podman dans le PATH, refuse avant le contrôle de version', () => { const r = construire({ sansPodman: true }); assert.equal(r.statut, 1, r.erreurs); assert.match(r.erreurs, /podman introuvable/); assert.deepEqual(r.ordre, []); }); test("une configuration d'electron-builder qui ne se charge pas arrête tout, avant la page", () => { const r = construire({ preparer: (projet) => writeFileSync(join(projet, 'electron-builder.config.cjs'), "throw new Error('configuration illisible');\n"), }); assert.equal(r.statut, 1, r.erreurs); assert.match(r.erreurs, /electron-builder\.config\.cjs ne se charge pas/); assert.deepEqual(r.ordre, []); }); test('une configuration qui nomme le livrable autrement que deriver depuis version.json arrête tout, avant la page (§ 18.2)', () => { // podman produirait le nom tapé : seul le nom calculé depuis version.json // distingue cette construction d'une construction conforme. const r = construire({ produit: NOM_AUTRE_VERSION, preparer: taperLeNom(NOM_AUTRE_VERSION) }); assert.equal(r.statut, 1, r.erreurs); assert.ok(r.erreurs.includes(NOM_AUTRE_VERSION) && r.erreurs.includes(NOM), r.erreurs); assert.deepEqual(r.ordre, []); assert.deepEqual(r.executables, {}); }); test("--reconstruire n'efface rien quand la configuration nomme le livrable autrement : ni l'exécutable, ni le fichier qu'elle désigne", () => { const tape = '../version.json'; const r = construire({ options: ['--reconstruire'], preparer: (projet) => { ecrireExecutable('ancien')(projet); taperLeNom(tape)(projet); }, relever: (projet) => existsSync(join(projet, 'version.json')), }); assert.equal(r.statut, 1, r.erreurs); assert.ok(r.erreurs.includes(tape) && r.erreurs.includes(NOM), r.erreurs); assert.deepEqual(r.ordre, []); assert.deepEqual(r.executables, { [NOM]: 'ancien' }); assert.equal(r.releve, true, 'version.json effacé'); }); }); describe('construction Windows : la page', () => { test('une page qui ne se construit pas arrête tout, avant podman', () => { const r = construire({ produit: NOM, codeNpm: 1 }); assert.equal(r.statut, 1, r.erreurs); assert.match(r.erreurs, /la construction de la page échoue/); assert.deepEqual(r.ordre, ['npm']); assert.deepEqual(r.executables, {}); }); test('une page absente après sa construction arrête tout, avant podman', () => { const r = construire({ produit: NOM, page: false }); assert.equal(r.statut, 1, r.erreurs); assert.match(r.erreurs, /www\/index\.html manque/); assert.deepEqual(r.ordre, ['npm']); assert.deepEqual(r.executables, {}); }); }); describe('construction Windows : la construction', () => { test("construit la page puis l'exécutable, et imprime son chemin, sa taille et son type", () => { const r = constructionReussie(); assert.equal(r.statut, 0, r.erreurs); assert.deepEqual(r.ordre, ['npm', 'podman']); assert.deepEqual(r.npm, ['run', '--silent', 'build:livraison']); assert.deepEqual(r.executables, { [NOM]: 'MZ' }); assert.ok(r.sortie.includes(join(r.projet, 'dist-electron', NOM)), r.sortie); assert.match(r.sortie, /^Taille +: 2 octets$/m); assert.match(r.sortie, /^Type +: \S/m); }); test("le conteneur : image épinglée, projet en lecture seule, node_modules de l'hôte masqué, aucune variable de l'hôte, quel que soit containers.conf", () => { const { statut, erreurs, projet, podman } = constructionReussie(); assert.equal(statut, 0, erreurs); assert.equal(podman[0], 'run'); // Tout ce qui suit l'image est la commande du conteneur : les options de // podman la précèdent toutes. const image = podman.findIndex((a) => /^docker\.io\/electronuserland\/builder@sha256:[0-9a-f]{64}$/.test(a)); assert.notEqual(image, -1, 'image non épinglée par empreinte'); assert.deepEqual(podman.slice(image + 1, -1), ['bash', '-c']); const options = podman.slice(1, image); assert.ok(options.includes('--rm')); assert.ok(options.includes(`${projet}:/project:ro`), 'projet non monté en lecture seule'); assert.ok(options.includes(`${projet}/dist-electron:/project/dist-electron`)); assert.ok( options.some((a) => /^[\w.-]+:\/project\/node_modules$/.test(a)), 'node_modules non masqué par un volume nommé', ); // Aucune variable passée, et l'environnement de l'hôte refusé sur la ligne // de commande : un env_host = true de containers.conf le transmettrait // entier, BUILD_NUMBER compris. assert.deepEqual(options.filter((a) => a === '-e' || a.startsWith('--env')), ['--env-host=false']); const commande = podman.at(-1); assert.match(commande, /npm ci --ignore-scripts/); assert.match( commande, /electron-builder --win --publish never --config electron-builder\.config\.cjs/, ); }); test("accepte le livrable quand dist-electron/ garde celui d'une autre version et les exécutables de win-unpacked/", () => { const r = construire({ produit: NOM, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) }); assert.equal(r.statut, 0, r.erreurs); assert.deepEqual(r.executables, { [NOM]: 'MZ', [NOM_AUTRE_VERSION]: 'autre' }); }); test("--reconstruire remplace un exécutable du même nom", () => { const r = construire({ options: ['--reconstruire'], produit: NOM, preparer: ecrireExecutable('ancien') }); assert.equal(r.statut, 0, r.erreurs); assert.deepEqual(r.executables, { [NOM]: 'MZ' }); }); }); describe('construction Windows : contrôle de fin de construction (§ 18.5)', () => { test("refuse un exécutable dont le nom ne dérive pas de version.json", () => { const autre = 'Gestion table tournante Libre.exe'; const r = construire({ produit: autre }); assert.equal(r.statut, 1, r.sortie); assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs); assert.doesNotMatch(r.sortie, /^Taille/m); }); test("refuse quand aucun exécutable n'apparaît", () => { const r = construire(); assert.equal(r.statut, 1, r.sortie); assert.match(r.erreurs, /aucun exécutable/); }); test("un échec sous podman ne laisse aucun exécutable sous ce nom, même avec --reconstruire", () => { const r = construire({ options: ['--reconstruire'], code: 7, preparer: ecrireExecutable('ancien') }); assert.equal(r.statut, 1, r.sortie); assert.match(r.erreurs, /podman.*7/); assert.deepEqual(r.executables, {}); }); test("un échec sous podman retire ce qu'electron-builder a déjà écrit sous ce nom, et rien d'autre", () => { const r = construire({ produit: NOM, code: 7, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) }); assert.equal(r.statut, 1, r.sortie); assert.match(r.erreurs, /podman.*7/); assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs); assert.deepEqual(r.executables, { [NOM_AUTRE_VERSION]: 'autre' }); }); test("deux exécutables apparus : refusé, et celui qui porte le nom attendu est retiré", () => { const autre = 'Gestion table tournante Libre.exe'; const r = construire({ produit: [NOM, autre] }); assert.equal(r.statut, 1, r.sortie); assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs); assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs); assert.equal(r.executables[NOM], undefined, `dist-electron/${NOM} reste après un refus`); assert.doesNotMatch(r.sortie, /^Taille/m); }); }); describe('construction Windows : interruption', () => { // Le signal ne vise que le shell, comme un kill depuis un autre terminal. // podman écrit après l'avoir reçu, comme electron-builder qui achève son // travail : l'épreuve ne réagit qu'à des événements, l'annonce du podman // factice puis la fin du script, et le délai ne fait que les borner. for (const [signal, code] of [['SIGTERM', 143], ['SIGINT', 130]]) { test(`${signal} reçu pendant podman : le script attend sa fin, retire ce qu'il a écrit et sort en ${code}`, async () => { const arbre = preparerArbre({ produit: NOM, attente: true }); const script = spawn(arbre.script, [], { cwd: arbre.base, env: arbre.environnement }); script.stdin.on('error', () => {}); let sortie = ''; let erreurs = ''; script.stderr.setEncoding('utf8').on('data', (morceau) => (erreurs += morceau)); const lance = new Promise((resoudre) => { script.stdout.setEncoding('utf8').on('data', (morceau) => { sortie += morceau; if (sortie.includes(LANCE)) resoudre(); }); }); const fin = once(script, 'close', { signal: AbortSignal.timeout(DELAI_MS) }).catch((erreur) => { throw new Error(`scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms (${erreur.name})\n${erreurs}`); }); fin.catch(() => {}); try { await Promise.race([lance, fin]); assert.ok(sortie.includes(LANCE), `podman factice jamais lancé\n${erreurs}`); script.kill(signal); script.stdin.end('\n'); const [statut, signalDeFin] = await fin; assert.deepEqual({ statut, signalDeFin }, { statut: code, signalDeFin: null }, erreurs); assert.ok(erreurs.includes(`dist-electron/${NOM} retiré`), erreurs); assert.deepEqual(executables(arbre.projet), {}); } finally { script.stdin.destroy(); script.kill('SIGKILL'); rmSync(arbre.base, { recursive: true, force: true }); } }); } });