The time budget of § 14.14 had no enforcement: a reporter now prints the total and the ten slowest tests, and fails a run whose node project exceeds 30 s or node-long 60 s. Two guards parsed imports with two patterns, one blind past an apostrophe in a comment; both now use one parser anchored on « from ». The determinism guard refuses key-order walks, and one test binds the application name across its three declarations. The end-to-end generations drop to 100 000 moves. Checked: each ceiling, the apostrophe case and a key walk are shown failing before the guard that catches them. --- FR --- [ADD] épreuves : rapporteur de budget, imports, ordre des clés Le budget de temps du § 14.14 n'avait aucune garde : un rapporteur imprime désormais la durée totale et les dix épreuves les plus lentes, et fait échouer une exécution dont le projet node dépasse 30 s ou node-long 60 s. Deux gardes lisaient les imports par deux motifs, l'un aveugle après une apostrophe en commentaire ; les deux passent par un seul analyseur ancré sur « from ». La garde de déterminisme refuse les parcours de clés, et une épreuve lie le nom de l'application à ses trois déclarations. Les générations de bout en bout passent à 100 000. Vérifié : chaque plafond, le cas de l'apostrophe et un parcours de clés sont montrés en échec avant la garde qui les attrape. Assisted-by: Claude Opus 5.5
221 lines
11 KiB
JavaScript
221 lines
11 KiB
JavaScript
// © 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 },
|
|
);
|
|
});
|
|
});
|