// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Le livrable tel qu'electron-builder.config.cjs le décrit : son nom (§ 18.2), // sa forme (§ 16), ce qu'il emporte (§ 14.15) et l'identité inscrite dans sa // ressource de version ; et le nom de l'application, un seul pour Electron, // pour Capacitor et pour le titre de la fenêtre (§ 18.6). La configuration se // charge ici comme electron-builder la charge, par require. import assert from 'node:assert/strict'; import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createRequire, isBuiltin } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { deriver } from '../scripts/version.js'; import { titreAvecVersion } from '../src/interface/libelles.js'; import { VERSION } from '../src/version.genere.js'; import { chargements } from './chargements.js'; import { describe, test } from './lanceur.js'; import { versionVoisine } from './version_voisine.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); const CONFIGURATION = 'electron-builder.config.cjs'; const lireJson = (racine, chemin) => JSON.parse(readFileSync(join(racine, chemin), 'utf8')); const charger = (racine) => { const chemin = join(racine, CONFIGURATION); return createRequire(chemin)(chemin); }; // Fichiers d'electron/ sous une racine, à toute profondeur, en chemins // absolus. const fichiersDElectron = (racine) => readdirSync(join(racine, 'electron'), { recursive: true, withFileTypes: true }) .filter((entree) => entree.isFile()) .map((entree) => join(entree.parentPath, entree.name)); // Raison de refuser le chargement que demande un fichier du répertoire // electron donné, ou null quand il reste dans le paquet : electron, un module // de Node hors du lanceur d'épreuves, ou un fichier de ce répertoire. Le // paquet n'emporte ni node_modules ni rien de test/ : un chargement qui en // sort échoue au démarrage de l'exécutable. Un chargement calculé ne se // vérifie pas d'ici. function raisonDeRefus(electron, { fichier, specificateur, argument }) { if (specificateur === undefined) return `argument calculé, ${argument} : il ne se vérifie pas`; if (/^(?:vitest|node:test)(?:\/|$)|^@vitest\//.test(specificateur)) return "lanceur d'épreuves"; if (specificateur === 'electron' || isBuiltin(specificateur)) return null; if (/^\.{1,2}(?:\/|$)/.test(specificateur)) { return resolve(dirname(fichier), specificateur).startsWith(`${electron}${sep}`) ? null : "hors d'electron/"; } return "hors du paquet, qui n'emporte aucun node_modules"; } // Chargements refusés des fichiers d'electron/ sous une racine, par fichier // puis dans l'ordre du texte : chacun est { fichier, specificateur, raison } // ou { fichier, argument, raison }, fichier relatif à la racine. Lève quand // electron/ n'a aucun fichier, ou quand ses fichiers ne demandent aucun // chargement. function refusDElectron(racine) { const electron = join(racine, 'electron'); const fichiers = fichiersDElectron(racine).sort(); assert.ok(fichiers.length > 0, 'electron/ ne contient aucun fichier'); const demandes = fichiers.flatMap((fichier) => chargements(readFileSync(fichier, 'utf8')).map(({ specificateur, argument }) => specificateur === undefined ? { fichier, argument } : { fichier, specificateur }, ), ); assert.ok(demandes.length > 0, 'aucun chargement relevé dans electron/'); return demandes .map((demande) => ({ ...demande, fichier: relative(racine, demande.fichier), raison: raisonDeRefus(electron, demande) })) .filter(({ raison }) => raison !== null); } describe('livrable : nom', () => { test('le nom est celui que deriver calcule depuis version.json (§ 18.2)', () => { const { version } = lireJson(RACINE, 'version.json'); assert.equal(charger(RACINE).artifactName, deriver(version).nomFichier); }); test("le nom suit version.json : la configuration ne l'écrit pas elle-même", () => { // Un arbre où version.json porte la version voisine. La configuration et // scripts/version.js y sont recopiés tels quels. const { version } = lireJson(RACINE, 'version.json'); const autre = versionVoisine(version); const arbre = mkdtempSync(join(tmpdir(), 'livrable-')); try { for (const chemin of [CONFIGURATION, 'scripts/version.js', 'package.json', 'capacitor.config.json']) { mkdirSync(dirname(join(arbre, chemin)), { recursive: true }); copyFileSync(join(RACINE, chemin), join(arbre, chemin)); } writeFileSync(join(arbre, 'version.json'), `${JSON.stringify({ version: autre })}\n`); const nom = charger(arbre).artifactName; assert.equal(nom, deriver(autre).nomFichier); assert.notEqual(nom, deriver(version).nomFichier); } finally { rmSync(arbre, { recursive: true, force: true }); } }); }); describe('livrable : forme et contenu', () => { test('un exécutable portable à fichier unique, pour Windows x64 : jamais un installateur (§ 16)', () => { assert.deepEqual(charger(RACINE).win, { target: [{ target: 'portable', arch: ['x64'] }] }); }); test("sortie dans dist-electron/ ; le paquet n'emporte que la page, la coquille et package.json", () => { const configuration = charger(RACINE); assert.deepEqual(configuration.directories, { output: 'dist-electron' }); assert.deepEqual(configuration.files, ['www/**', 'electron/**', 'package.json']); }); test("la configuration n'a pas d'autre clé que celles que ces épreuves examinent (§ 14.15, § 18.1)", () => { // Une clé de plus change ce que le paquet emporte (extraResources, // extraFiles, asar) ou la version qu'il porte (buildVersion, buildNumber), // et aucune épreuve ne la lirait : elle échoue ici jusqu'à sa revue. Les // épreuves de chaque clé comparent sa valeur entière, si bien qu'une clé // ajoutée plus bas, sous win ou directories, échoue aussi. assert.deepEqual(Object.keys(charger(RACINE)).sort(), [ 'appId', 'artifactName', 'copyright', 'directories', 'files', 'productName', 'win', ]); }); test("electron/, que le paquet emporte en entier, ne contient aucune épreuve (§ 14.15)", () => { const fichiers = fichiersDElectron(RACINE); assert.ok(fichiers.length > 0, 'electron/ ne contient aucun fichier'); assert.deepEqual(fichiers.filter((chemin) => /\.test\.[cm]?js$/.test(chemin)), []); }); test("electron/ ne charge qu'electron, Node et ses propres fichiers : ni épreuve, ni lanceur, ni dépendance (§ 14.9)", () => { assert.deepEqual(refusDElectron(RACINE), []); }); test("la garde d'electron/ relève chaque chargement refusé, import à commentaire entre ses accolades compris, et admet electron, Node et les fichiers d'electron/", () => { const arbre = mkdtempSync(join(tmpdir(), 'livrable-')); try { mkdirSync(join(arbre, 'electron')); writeFileSync(join(arbre, 'electron', 'main.js'), [ "import { app } from 'electron';", "import { join } from 'node:path';", 'import {', " // l'API des épreuves", ' test,', "} from 'vitest';", 'import {', " // l'aide d'à côté", ' aide,', "} from '../test/aide.js';", "import { voisin } from './voisin.js';", "const prechargement = require('./preload.cjs');", 'const module = require(chemin);', "const { mount } = await import('svelte');", ].join('\n')); writeFileSync(join(arbre, 'electron', 'preload.cjs'), "const { contextBridge } = require('electron');\n"); assert.deepEqual(refusDElectron(arbre), [ { fichier: join('electron', 'main.js'), specificateur: 'vitest', raison: "lanceur d'épreuves" }, { fichier: join('electron', 'main.js'), specificateur: '../test/aide.js', raison: "hors d'electron/" }, { fichier: join('electron', 'main.js'), argument: 'chemin', raison: 'argument calculé, chemin : il ne se vérifie pas' }, { fichier: join('electron', 'main.js'), specificateur: 'svelte', raison: "hors du paquet, qui n'emporte aucun node_modules" }, ]); writeFileSync(join(arbre, 'electron', 'main.js'), '// Aucun chargement.\n'); writeFileSync(join(arbre, 'electron', 'preload.cjs'), ''); assert.throws(() => refusDElectron(arbre), /aucun chargement relevé dans electron\//); } finally { rmSync(arbre, { recursive: true, force: true }); } }); test("package.json ne déclare aucune dépendance d'exécution : le paquet n'emporte aucun node_modules", () => { const paquet = lireJson(RACINE, 'package.json'); assert.equal(paquet.dependencies, undefined); assert.equal(paquet.optionalDependencies, undefined); assert.match(paquet.devDependencies?.electron ?? '', /^\^44(\.|$)/); assert.match(paquet.devDependencies?.['electron-builder'] ?? '', /^\^26(\.|$)/); assert.equal(paquet.main, 'electron/main.js'); }); }); describe('livrable : identité', () => { test("l'identifiant est celui de Capacitor, le nom de produit celui de package.json", () => { const configuration = charger(RACINE); assert.equal(configuration.appId, lireJson(RACINE, 'capacitor.config.json').appId); assert.equal(configuration.productName, lireJson(RACINE, 'package.json').productName); // Une source muette rend undefined des deux côtés de l'égalité, et // electron-builder retombe alors sur ses valeurs par défaut : chaque // valeur a aussi sa forme. assert.match(configuration.appId ?? '', /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/); assert.ok( typeof configuration.productName === 'string' && configuration.productName.trim() !== '', `productName vide : ${configuration.productName}`, ); }); test('le titulaire entre dans la ressource de version, champ LegalCopyright', () => { assert.equal(charger(RACINE).copyright, '© 2026 TechnoLibre'); }); test("le nom de l'application est un seul : productName de package.json, appName de Capacitor, celui du titre de la fenêtre (§ 18.6)", () => { // electron-builder nomme l'application d'après productName, Capacitor // d'après appName, et la page pose le titre de la fenêtre par // titreAvecVersion : le nom suivi de la version affichée, que l'épreuve // retire pour lire le nom. const titre = titreAvecVersion(VERSION.affichee); const suite = ` — ${VERSION.affichee}`; assert.ok(titre.endsWith(suite), `le titre « ${titre} » ne finit pas sur « ${suite} »`); const nom = titre.slice(0, -suite.length); assert.ok(nom.trim() !== '', `le titre « ${titre} » ne porte aucun nom`); assert.deepEqual( { productName: lireJson(RACINE, 'package.json').productName, appName: lireJson(RACINE, 'capacitor.config.json').appName, }, { productName: nom, appName: nom }, ); }); });