From 47951140931213039386d31865b87cdb66730832 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 04:23:13 -0400 Subject: [PATCH] [ADD] tests: budget reporter, one import parser, key-order guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/moteur/integration.test.js | 26 ++- test/arborescence.test.js | 278 +++++++++++++++++++++++++++------ test/chargements.js | 40 +++++ test/chargements.test.js | 91 +++++++++++ test/livrable.test.js | 136 +++++++++++----- test/rapporteur.js | 189 ++++++++++++++++++++++ test/rapporteur.test.js | 249 +++++++++++++++++++++++++++++ vitest.config.js | 7 +- 8 files changed, 918 insertions(+), 98 deletions(-) create mode 100644 test/chargements.js create mode 100644 test/chargements.test.js create mode 100644 test/rapporteur.js create mode 100644 test/rapporteur.test.js diff --git a/src/moteur/integration.test.js b/src/moteur/integration.test.js index ea1860d..9c7abee 100644 --- a/src/moteur/integration.test.js +++ b/src/moteur/integration.test.js @@ -23,14 +23,24 @@ import { rechercher } from './recherche.js'; import { verifierIndicateurs, verifierInvariants } from './verification.js'; // Réglages de la génération : une graine fixée, écrite ici (§ 15.5, point 2). -// À 200 000 mouvements, avec cette graine, les trois propositions de chaque -// démonstration rencontrent 8 pour les douze, sans rencontre répétée ni -// retour choisi, et ne portent que les collisions que le diagnostic impose : -// aucune dans la petite démonstration, 4 dans sa variante. Les assertions -// des épreuves tiennent avec cette graine dès 80 000 mouvements : 200 000 -// leur laisse une marge de 2,5. Sur les graines 1 à 64, elles tiennent -// toutes à 200 000 ; à 150 000, deux graines en font échouer. -const REGLAGES = Object.freeze({ graine: 314_159, arret: 200_000, nombre: 3 }); +// À 100 000 mouvements, avec cette graine, les trois propositions de chaque +// démonstration rencontrent 8 pour les douze, sans rencontre répétée, et ne +// portent que les collisions que le diagnostic impose : aucune dans la +// petite démonstration, 4 dans sa variante. Une proposition de la variante +// garde 15 retours choisis, les cinq autres aucun. Les assertions des +// épreuves tiennent avec cette graine dès 80 000 mouvements, soit une marge +// de 1,25 ; en deçà, les trois propositions de la petite démonstration +// gardent des retours choisis. La marge est celle de cette graine : sur les +// graines 1 à 64, les assertions tiennent pour 43 à 100 000, pour toutes à +// 200 000. +// +// Le compte pèse sur la relance sous surveillance (§ 14.14) : toute +// modification du moteur, ou du générateur que lit le catalogue, rejoue ce +// fichier. Sur deux cœurs, la relance qui suit une modification de +// recherche.js tient en 1,6 s au mur, contre 2,0 s à 200 000 mouvements ; +// celle qui suit indicateurs.js, qui rejoue sept fichiers, prend 2,6 s, +// contre 2,9 s. +const REGLAGES = Object.freeze({ graine: 314_159, arret: 100_000, nombre: 3 }); // La même génération, arrêtée au premier mouvement. Aucune de ses // propositions n'atteint son plafond a priori : avec cette graine, leurs diff --git a/test/arborescence.test.js b/test/arborescence.test.js index 4c44ba4..3a7c6c9 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -3,20 +3,24 @@ // Gardes de l'arborescence, lues dans le texte des sources : le moteur et le // générateur de démonstrations n'appellent aucune source non reproductible -// (§ 14.7) ; le graphe d'imports du moteur et de la géométrie ne rejoint ni -// l'interface, ni le stockage, ni une plateforme (§ 13.4) ; chaque paquet -// importé est déclaré dans devDependencies. Chaque relevé lit le texte -// entier, commentaires et chaînes compris : un commentaire qui nomme un appel -// refusé fait échouer la garde comme l'appel lui-même. Chaque garde refuse de -// passer sur un balayage vide (§ 14.2), et d'autres épreuves la font tourner -// sur des arbres temporaires qui portent, à plus d'un niveau de profondeur, -// les formes qu'elle refuse et des formes voisines qu'elle admet. +// (§ 14.7), et ne parcourent les clés d'un objet que par exception nommée +// (§ 15.5, point 4) ; le graphe d'imports du moteur et de la géométrie ne +// rejoint ni l'interface, ni le stockage, ni une plateforme (§ 13.4) ; +// chaque paquet importé est déclaré dans devDependencies ; une valeur du +// contrat de données n'a qu'une définition, celle de configuration.js. +// Chaque relevé lit le texte entier, commentaires et chaînes compris : un +// commentaire qui nomme un appel refusé fait échouer la garde comme l'appel +// lui-même. Chaque garde refuse de passer sur un balayage vide (§ 14.2), et +// d'autres épreuves la font tourner sur des arbres temporaires qui portent, +// à plus d'un niveau de profondeur, les formes qu'elle refuse et des formes +// voisines qu'elle admet. import assert from 'node:assert/strict'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { isBuiltin } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, extname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { chargements } from './chargements.js'; import { describe, test } from './lanceur.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); @@ -91,28 +95,76 @@ const SOURCES_NON_REPRODUCTIBLES = [ ['crypto.randomFill', /\brandomFill(?:Sync)?\b/g], ]; -// Relevé « fichier:ligne appel » des sources non reproductibles dans les -// modules de src/moteur et de src/demo, par fichier puis par ligne, et sur -// une même ligne dans l'ordre de SOURCES_NON_REPRODUCTIBLES. Les épreuves en -// sont exclues : un tirage y sert légitimement (§ 14.12). Lève quand l'un des -// deux arbres ne fournit aucun module. -function releverDeterminisme(racine) { - return ['src/moteur', 'src/demo'].flatMap((arbre) => { +// Parcours des clés d'un objet (§ 15.5, point 4) : les clés entières y +// viennent d'abord, croissantes, puis les autres dans l'ordre où l'objet les +// a reçues. Un résultat qui en dépend change quand l'objet se construit +// autrement, sans qu'aucune épreuve ne le voie ; un départage se fait par +// identifiant entier. Les motifs à point suivent les règles de +// SOURCES_NON_REPRODUCTIBLES, et Object suivi d'un crochet est refusé quel +// que soit le membre nommé. for…in se relève sur une variable, un chemin +// pointé ou un motif de déstructuration suivi de « in », avec ou sans +// déclaration : la boucle à compteur, for…of et l'opérateur in hors d'une +// boucle n'en sont pas. +const PARCOURS_DE_CLES = [ + ['Object.keys', /\bObject\s*\??\.\s*keys\b/g], + ['Object.entries', /\bObject\s*\??\.\s*entries\b/g], + ['Object.values', /\bObject\s*\??\.\s*values\b/g], + ['Object[', /\bObject\s*(?:\?\.)?\s*\[/g], + ['for…in', /\bfor\s*\(\s*(?:(?:const|let|var)\s+)?(?:[\p{L}_$][\p{L}\p{N}_$.]*|\[[^\]]*\]|\{[^}]*\})\s+in\b/gu], + ['Reflect.ownKeys', /\bReflect\s*\??\.\s*ownKeys\b/g], + ['Object.getOwnPropertyNames', /\bObject\s*\??\.\s*getOwnPropertyNames\b/g], +]; + +// Parcours de clés admis, chacun nommé par son fichier, relatif à la racine, +// et par le texte de sa ligne, blancs de bord retirés ; sa raison dit +// pourquoi l'ordre n'y décide d'aucun résultat. Une exception admet chaque +// ligne de ce texte dans ce fichier, et rien d'autre : la ligne réécrite, ou +// un second parcours ajouté à côté, se relève de nouveau. Elle ne couvre +// aucune source de SOURCES_NON_REPRODUCTIBLES, que rien n'admet. +const PARCOURS_DE_CLES_ADMIS = []; + +// Relevé « fichier:ligne appel » des sources non reproductibles et des +// parcours de clés non admis dans les modules de src/moteur et de src/demo, +// par fichier puis par ligne, et sur une même ligne dans l'ordre de +// SOURCES_NON_REPRODUCTIBLES puis de PARCOURS_DE_CLES. Les épreuves en sont +// exclues : un tirage y sert légitimement (§ 14.12). Suit une ligne +// « exception sans objet : fichier « texte » » par exception qui n'admet +// rien, dans l'ordre de la liste. Lève quand l'un des deux arbres ne fournit +// aucun module. +function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) { + const servies = new Set(); + const estAdmis = (fichier, texteDeLigne) => { + const exception = admis.find((e) => e.fichier === fichier && e.ligne === texteDeLigne); + if (exception !== undefined) servies.add(exception); + return exception !== undefined; + }; + const releve = ['src/moteur', 'src/demo'].flatMap((arbre) => { const modules = modulesDe(join(racine, arbre)); assert.ok(modules.length > 0, `${arbre} ne fournit aucun module`); return modules.flatMap((fichier) => { + const nom = relative(racine, fichier); const texte = readFileSync(fichier, 'utf8'); - return SOURCES_NON_REPRODUCTIBLES.flatMap(([appel, motif]) => - [...texte.matchAll(motif)].map(({ index }) => ({ ligne: ligneDe(texte, index), appel })), - ) + const lignes = texte.split('\n'); + const trouves = (motifs) => + motifs.flatMap(([appel, motif]) => + [...texte.matchAll(motif)].map(({ index }) => ({ ligne: ligneDe(texte, index), appel })), + ); + return [ + ...trouves(SOURCES_NON_REPRODUCTIBLES), + ...trouves(PARCOURS_DE_CLES).filter(({ ligne }) => !estAdmis(nom, lignes[ligne - 1].trim())), + ] .sort((a, b) => a.ligne - b.ligne) - .map(({ ligne, appel }) => `${relative(racine, fichier)}:${ligne} ${appel}`); + .map(({ ligne, appel }) => `${nom}:${ligne} ${appel}`); }); }); + const sansObjet = admis + .filter((exception) => !servies.has(exception)) + .map(({ fichier, ligne }) => `exception sans objet : ${fichier} « ${ligne} »`); + return [...releve, ...sansObjet]; } describe('arborescence : déterminisme (§ 14.7)', () => { - test('ni src/moteur ni src/demo ne lisent une source non reproductible', () => { + test("ni src/moteur ni src/demo ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { assert.deepEqual(releverDeterminisme(RACINE), []); }); @@ -224,31 +276,90 @@ describe('arborescence : déterminisme (§ 14.7)', () => { /src\/demo ne fournit aucun module/, ); }); + + test("les parcours des clés d'un objet se relèvent par leur ligne, hors épreuves, et pas leurs voisins (§ 15.5, point 4)", () => { + const fichiers = { + 'src/moteur/cles.js': [ + 'export const a = Object.keys(o);', + 'export const b = Object.entries(o).map(f);', + 'export const c = Object.values(o);', + 'for (const k in o) t.push(k);', + 'for (k in o) t.push(k);', + 'export const d = Object', + ' .keys(o);', + 'export const e = Object?.entries?.(o);', + "export const g = Object['keys'](o);", + 'for(let k in o){}', + 'for (const [k] in o) t.push(k);', + 'for (var { length } in o) t.push(length);', + 'export const h = Reflect.ownKeys(o);', + 'export const i = Object.getOwnPropertyNames(o);', + 'for (const k of Object.keys(o)) t.push(k);', + 'for (a.b in o) t.push(a.b);', + ].join('\n'), + 'src/moteur/cles.test.js': 'export const x = Object.keys(o);\n', + 'src/demo/voisins.js': [ + 'for (let i = 0; i < n; i += 1) t.push(i);', + 'for (const v of Object.freeze([])) t.push(v);', + "export const f = 'k' in o;", + 'for (const [k, v] of m) t.push(k, v);', + 'export const z = monObject.keys + objectKeys;', + 'export const w = Object.fromEntries(paires);', + 'for (const index of inventaire) t.push(index);', + ].join('\n'), + }; + assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [ + 'src/moteur/cles.js:1 Object.keys', + 'src/moteur/cles.js:2 Object.entries', + 'src/moteur/cles.js:3 Object.values', + 'src/moteur/cles.js:4 for…in', + 'src/moteur/cles.js:5 for…in', + 'src/moteur/cles.js:6 Object.keys', + 'src/moteur/cles.js:8 Object.entries', + 'src/moteur/cles.js:9 Object[', + 'src/moteur/cles.js:10 for…in', + 'src/moteur/cles.js:11 for…in', + 'src/moteur/cles.js:12 for…in', + 'src/moteur/cles.js:13 Reflect.ownKeys', + 'src/moteur/cles.js:14 Object.getOwnPropertyNames', + 'src/moteur/cles.js:15 Object.keys', + 'src/moteur/cles.js:16 for…in', + ]); + }); + + test("une exception nommée n'admet que la ligne qu'elle cite, dans son fichier, et qu'un parcours de clés ; une exception sans objet se relève", () => { + const somme = 'export const total = (o) => Object.values(o).reduce((s, v) => s + v, 0);'; + const fichiers = { + 'src/moteur/a.js': 'export const a = Math.random();\n', + 'src/demo/somme.js': [ + somme, + 'export const nombre = (o) => Object.keys(o).length;', + 'export const double = (o) => Object.values(o).reduce((s, v) => s + 2 * v, 0);', + ].join('\n'), + 'src/demo/autre.js': `${somme}\n`, + }; + const exceptions = [ + { fichier: 'src/demo/somme.js', ligne: somme, raison: "une somme d'entiers ne dépend pas de l'ordre de ses termes" }, + { fichier: 'src/moteur/a.js', ligne: 'export const a = Math.random();', raison: 'un tirage ne se nomme pas en exception' }, + { fichier: 'src/demo/disparu.js', ligne: 'for (const k in o) t.push(k);', raison: 'la ligne a disparu' }, + ]; + assert.deepEqual(avecArbre(fichiers, (racine) => releverDeterminisme(racine, exceptions)), [ + 'src/moteur/a.js:1 Math.random', + 'src/demo/autre.js:1 Object.values', + 'src/demo/somme.js:2 Object.keys', + 'src/demo/somme.js:3 Object.values', + 'exception sans objet : src/moteur/a.js « export const a = Math.random(); »', + 'exception sans objet : src/demo/disparu.js « for (const k in o) t.push(k); »', + ]); + }); }); -// Chargements que demande un texte : import et export statiques, import() -// et require() d'une chaîne littérale, suivie ou non d'options. Chacun est -// { specificateur, indice }. Le relevé lit le texte entier, commentaires et -// chaînes compris ; un argument calculé ne désigne aucun spécificateur et -// n'est pas relevé. Une instruction à « from » se lit à partir de ce mot, -// que seuls import et export font suivre d'une chaîne : un motif parti du -// mot-clé s'arrêterait à l'apostrophe d'un commentaire placé entre les -// accolades. L'import d'un module seul exige un blanc après le mot-clé, et -// aucune arobase devant lui : une chaîne qui finit sur « import », comme -// l'argument « --import » d'une commande, n'en est pas un, ni la règle -// @import d'une feuille de style, dont la chaîne est une adresse relative à -// la feuille et non un paquet. -const DEPUIS = /\bfrom\s*(['"])(?[^'"\n]+)\1/g; -const IMPORT_SEUL = /(?[^'"\n]+)\1/g; -const APPEL = /\b(?:import|require)\s*\(\s*(['"`])(?[^'"`$\n]+)\1\s*[,)]/g; -function chargements(texte) { - return [DEPUIS, IMPORT_SEUL, APPEL].flatMap((motif) => - [...texte.matchAll(motif)].map(({ groups, index }) => ({ - specificateur: groups.specificateur, - indice: index, - })), - ); -} +// Chargements d'un texte qui désignent un spécificateur, dans l'ordre du +// texte, chacun { indice, specificateur } : ceux que relève l'analyseur +// commun (test/chargements.js), moins les arguments calculés, qui ne +// désignent aucun module. +const chargementsLitteraux = (texte) => + chargements(texte).filter(({ specificateur }) => specificateur !== undefined); // « ./ » ou « ../ » en tête, ou « . » et « .. » seuls. const RELATIF = /^\.{1,2}(?:\/|$)/; @@ -311,7 +422,7 @@ function releverFrontiere(racine) { indice: index, motif: nom + lu.at(-1), })); - for (const { specificateur, indice } of chargements(texte)) { + for (const { specificateur, indice } of chargementsLitteraux(texte)) { const cible = cibleDe(racine, fichier, specificateur); if (cible === null) { if (isBuiltin(specificateur) || PAQUETS_EXTERIEURS.test(nomDePaquet(specificateur))) { @@ -491,9 +602,7 @@ function releverDependances(racine) { const refus = []; for (const fichier of sourcesDuProjet(racine)) { const texte = readFileSync(fichier, 'utf8'); - const trouves = chargements(texte) - .filter(({ specificateur }) => estNu(specificateur)) - .sort((a, b) => a.indice - b.indice); + const trouves = chargementsLitteraux(texte).filter(({ specificateur }) => estNu(specificateur)); nus += trouves.length; for (const { specificateur, indice } of trouves) { if (!isBuiltin(specificateur) && !declares.has(nomDePaquet(specificateur))) { @@ -585,3 +694,78 @@ describe('arborescence : dépendances déclarées', () => { assert.throws(() => avecArbre(fichiers, releverDependances), /aucun spécificateur nu/); }); }); + +// Valeurs du contrat de données (types.js) : configuration.js les définit et +// les exporte, tout autre fichier de src/moteur les importe, épreuves +// comprises (§ 13.2). Une déclaration de l'un de ces noms ailleurs est une +// seconde définition, qui peut dériver de la première sans qu'aucune épreuve +// ne le voie. Le relevé lit le nom qui suit const, let ou var : une +// déstructuration de STATUT n'est pas relevée, elle lit la définition au lieu +// de la refaire. +const VALEURS_DU_CONTRAT = [ + 'STATUT', + 'MOBILE', + 'PARTIELLEMENT_FIXE', + 'ANCRE', + 'LIBRE', + 'RESERVE', + 'SANS_GROUPE', +]; +const DECLARATION_DE_VALEUR = new RegExp( + `\\b(?:const|let|var)\\s+(${VALEURS_DU_CONTRAT.join('|')})\\b`, + 'g', +); +const PROPRIETAIRE_DES_VALEURS = join('src', 'moteur', 'configuration.js'); + +// Relevé « fichier:ligne nom » des déclarations d'une valeur du contrat dans +// les fichiers JavaScript de src/moteur, à toute profondeur, épreuves +// comprises, configuration.js excepté ; par fichier, puis par ligne. Lève +// quand src/moteur ne fournit aucun fichier JavaScript. +function releverValeursDuContrat(racine) { + const fichiers = fichiersDe(join(racine, 'src', 'moteur')) + .filter((fichier) => EXTENSIONS_MODULE.has(extname(fichier))) + .sort(); + assert.ok(fichiers.length > 0, 'src/moteur ne fournit aucun fichier JavaScript'); + return fichiers + .filter((fichier) => relative(racine, fichier) !== PROPRIETAIRE_DES_VALEURS) + .flatMap((fichier) => { + const texte = readFileSync(fichier, 'utf8'); + return [...texte.matchAll(DECLARATION_DE_VALEUR)].map( + (trouve) => `${relative(racine, fichier)}:${ligneDe(texte, trouve.index)} ${trouve[1]}`, + ); + }); +} + +describe('arborescence : un seul propriétaire des valeurs du contrat (types.js)', () => { + test('hors de configuration.js, aucun fichier de src/moteur ne déclare STATUT, un statut, LIBRE, RESERVE ni SANS_GROUPE', () => { + assert.deepEqual(releverValeursDuContrat(RACINE), []); + }); + + test('la garde relève chaque déclaration par sa ligne, épreuves comprises, et admet le propriétaire, la déstructuration et les noms voisins', () => { + const fichiers = { + 'src/moteur/configuration.js': 'export const STATUT = Object.freeze({ ANCRE: 2 });\nexport const LIBRE = -1;\n', + 'src/moteur/a.js': [ + "import { STATUT } from './configuration.js';", + 'const { ANCRE } = STATUT;', + 'const ANCRES = [];', + 'let LIBRES = 0;', + ].join('\n'), + 'src/moteur/b.js': 'const ANCRE = 2;\nexport let RESERVE = -1;\n', + 'src/moteur/sous/c.test.js': '// Épreuve.\nvar SANS_GROUPE = -1;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverValeursDuContrat), [ + 'src/moteur/b.js:1 ANCRE', + 'src/moteur/b.js:2 RESERVE', + 'src/moteur/sous/c.test.js:2 SANS_GROUPE', + ]); + }); + + test('un src/moteur sans fichier JavaScript fait échouer la garde', () => { + for (const fichiers of [{ 'src/demo/a.js': '' }, { 'src/moteur/notes.md': '' }]) { + assert.throws( + () => avecArbre(fichiers, releverValeursDuContrat), + /src\/moteur ne fournit aucun fichier JavaScript/, + ); + } + }); +}); diff --git a/test/chargements.js b/test/chargements.js new file mode 100644 index 0000000..e3a8379 --- /dev/null +++ b/test/chargements.js @@ -0,0 +1,40 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Analyseur des chargements qu'un texte demande, seul lu par les gardes de +// l'arborescence (test/arborescence.test.js) et du livrable +// (test/livrable.test.js) : import et export statiques, import seul, import() +// et require(). +// +// Le relevé lit le texte entier, commentaires et chaînes compris. Une +// instruction à « from » se lit à partir de ce mot, que seuls import et +// export font suivre d'une chaîne : un motif parti du mot-clé s'arrêterait à +// l'apostrophe d'un commentaire placé entre les accolades. L'import d'un +// module seul exige un blanc après le mot-clé, et aucune arobase devant lui : +// une chaîne qui finit sur « import », comme l'argument « --import » d'une +// commande, n'en est pas un, ni la règle @import d'une feuille de style, dont +// la chaîne est une adresse relative à la feuille et non un paquet. import() +// et require() désignent un spécificateur quand leur argument est une chaîne +// littérale, entre guillemets, apostrophes ou accents graves sans +// interpolation, suivie ou non d'options ; tout autre argument est calculé. +const DEPUIS = /\bfrom\s*(['"])(?[^'"\n]+)\1/g; +const IMPORT_SEUL = /(?[^'"\n]+)\1/g; +const APPEL = + /\b(?:import|require)\s*\(\s*(?:(['"`])(?[^'"`$\n]+)\1\s*[,)]|(?[^)]*)\))/g; + +// Chargements que demande un texte, dans l'ordre du texte. Chacun est +// { indice, specificateur } pour une chaîne littérale, { indice, argument } +// pour un argument calculé, son texte jusqu'à la première parenthèse +// fermante, blancs de bord retirés. indice est la position du mot « from », +// de « import » ou de « require » dans le texte. +export function chargements(texte) { + return [DEPUIS, IMPORT_SEUL, APPEL] + .flatMap((motif) => + [...texte.matchAll(motif)].map(({ groups, index }) => + groups.specificateur === undefined + ? { indice: index, argument: groups.argument.trim() } + : { indice: index, specificateur: groups.specificateur }, + ), + ) + .sort((a, b) => a.indice - b.indice); +} diff --git a/test/chargements.test.js b/test/chargements.test.js new file mode 100644 index 0000000..74372bc --- /dev/null +++ b/test/chargements.test.js @@ -0,0 +1,91 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'analyseur des chargements, test/chargements.js, que lisent les gardes de +// l'arborescence et du livrable. Chaque épreuve donne un texte et les +// chargements qu'il demande, dans l'ordre du texte. Les spécificateurs nus +// des données sont des paquets de devDependencies ou des modules de Node : +// la garde des dépendances lit aussi ce fichier. +import assert from 'node:assert/strict'; +import { chargements } from './chargements.js'; +import { describe, test } from './lanceur.js'; + +// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné. +const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length; + +// Chaque chargement d'un texte, en « ligne spécificateur », ou en « ligne +// calculé argument » pour un argument qui n'est pas une chaîne littérale. +const lus = (texte) => + chargements(texte).map(({ indice, specificateur, argument }) => + specificateur === undefined + ? `${ligneDe(texte, indice)} calculé ${argument}` + : `${ligneDe(texte, indice)} ${specificateur}`, + ); + +describe('chargements', () => { + test("un import dont les accolades portent un commentaire à apostrophe se relève, comme celui qui le suit", () => { + const texte = [ + 'import {', + " // l'écran d'accueil", + ' App,', + "} from '../interface/App.svelte';", + "import { a } from './a.js';", + ].join('\n'); + assert.deepEqual( + chargements(texte).map(({ specificateur }) => specificateur), + ['../interface/App.svelte', './a.js'], + ); + }); + + test('import et export à from, import seul, import() et require() de chaîne, avec ou sans options, se relèvent à leur ligne, dans l’ordre du texte', () => { + const texte = [ + "import { a } from './a.js';", + 'import b, { c } from "../b.js";', + "export * from './c.js';", + "export { d } from'./d.js';", + "import 'svelte';", + 'const e = require("electron");', + "const f = await import('node:fs', { with: {} });", + 'const g = await import(`vitest`);', + "import * as h from 'svelte/store'; const i = require('./i.cjs');", + ].join('\n'); + assert.deepEqual(lus(texte), [ + '1 ./a.js', + '2 ../b.js', + '3 ./c.js', + '4 ./d.js', + '5 svelte', + '6 electron', + '7 node:fs', + '8 vitest', + '9 svelte/store', + '9 ./i.cjs', + ]); + }); + + test("un argument qui n'est pas une chaîne littérale se relève comme calculé, sans spécificateur", () => { + const texte = [ + 'const a = require(nom);', + 'const b = await import(`./${module}.js`);', + "const c = require('./' + nom);", + 'const d = await import( cible );', + ].join('\n'); + assert.deepEqual(lus(texte), [ + '1 calculé nom', + '2 calculé `./${module}.js`', + "3 calculé './' + nom", + '4 calculé cible', + ]); + }); + + test("ni une chaîne qui finit sur « import », ni la règle @import d'une feuille de style ne sont des chargements", () => { + const texte = [ + "spawnSync(process.execPath, ['--import', 'chargeur.js']);", + '', + "const mot = 'import';", + ].join('\n'); + assert.deepEqual(lus(texte), []); + }); +}); diff --git a/test/livrable.test.js b/test/livrable.test.js index cf46685..29879a9 100644 --- a/test/livrable.test.js +++ b/test/livrable.test.js @@ -3,8 +3,9 @@ // 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. La configuration se charge ici comme electron-builder -// la charge, par require. +// 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'; @@ -12,12 +13,14 @@ 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 ELECTRON = join(RACINE, 'electron'); const lireJson = (racine, chemin) => JSON.parse(readFileSync(join(racine, chemin), 'utf8')); const charger = (racine) => { @@ -25,47 +28,49 @@ const charger = (racine) => { return createRequire(chemin)(chemin); }; -// Fichiers d'electron/, à toute profondeur, en chemins absolus. -const fichiersDElectron = () => - readdirSync(ELECTRON, { recursive: true, withFileTypes: true }) +// 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)); -// Chargements que demande le texte d'un module : import et export statiques, -// import() et require(). Chacun est { specificateur } quand l'argument est -// une chaîne littérale, { argument } sinon. Le relevé lit le texte entier, -// commentaires compris. -const DEPUIS = /\b(?:import|export)\b[^'"`;]*?\bfrom\s*(['"])(?[^'"]+)\1/g; -const IMPORT_NU = /\bimport\s*(['"])(?[^'"]+)\1/g; -const APPEL = /\b(?:import|require)\s*\((?[^)]*)\)/g; -const LITTERAL = /^\s*(['"])(?[^'"]*)\1\s*$/; -function chargements(texte) { - return [ - ...[...texte.matchAll(DEPUIS), ...texte.matchAll(IMPORT_NU)].map(({ groups }) => ({ - specificateur: groups.specificateur, - })), - ...[...texte.matchAll(APPEL)].map(({ groups }) => { - const litteral = LITTERAL.exec(groups.argument); - return litteral === null ? { argument: groups.argument.trim() } : { specificateur: litteral.groups.specificateur }; - }), - ]; -} - -// Raison de refuser le chargement que demande un fichier d'electron/, ou -// null quand il reste dans le paquet : electron, un module de Node hors du -// lanceur d'épreuves, ou un fichier d'electron/. 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({ fichier, specificateur, argument }) { +// 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 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'); @@ -122,20 +127,48 @@ describe('livrable : forme et contenu', () => { }); test("electron/, que le paquet emporte en entier, ne contient aucune épreuve (§ 14.15)", () => { - const fichiers = fichiersDElectron(); + 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)", () => { - const demandes = fichiersDElectron().flatMap((fichier) => - chargements(readFileSync(fichier, 'utf8')).map((chargement) => ({ fichier, ...chargement })), - ); - assert.ok(demandes.length > 0, 'aucun chargement relevé dans electron/'); - const refusees = demandes - .map((demande) => ({ ...demande, fichier: relative(RACINE, demande.fichier), raison: raisonDeRefus(demande) })) - .filter(({ raison }) => raison !== null); - assert.deepEqual(refusees, []); + 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", () => { @@ -166,4 +199,23 @@ describe('livrable : identité', () => { 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 }, + ); + }); }); diff --git a/test/rapporteur.js b/test/rapporteur.js new file mode 100644 index 0000000..4780ecc --- /dev/null +++ b/test/rapporteur.js @@ -0,0 +1,189 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Rapporteur des durées de Vitest, déclaré dans vitest.config.js à côté du +// rapporteur par défaut (§ 14.14). À la fin de chaque exécution, en série +// simple comme à chaque relance sous surveillance, il imprime la durée +// totale, la durée au mur de chaque projet et les dix épreuves les plus +// lentes. Un projet qui dépasse son plafond fait sortir le lanceur en code 1, +// et l'erreur le nomme. +// +// La durée d'un projet est celle de l'exécution, au mur, moins le temps où +// seuls d'autres projets ont un fichier en cours. Un fichier est en cours du +// moment où un processus de Vitest le prend (onTestModuleQueued) à celui où +// sa dernière épreuve finit (onTestModuleEnd) ; le démarrage du processus +// qui le prend, avant, n'est en cours pour aucun projet, et compte donc à +// chacun. Un projet qui tourne seul dure ainsi toute l'exécution, comme la +// durée qu'affiche Vitest ; avec d'autres, le temps où il attend un +// processus occupé ailleurs ne lui est pas compté, si bien que son plafond +// ne dépend pas des projets lancés avec lui. Un fichier que le rapporteur +// n'a pas vu prendre rend la durée de son projet inconnue, et un projet à +// plafond refuse alors au lieu de passer sans mesure (§ 14.2). Une exécution +// interrompue imprime son bilan sans rien refuser : ses durées sont +// partielles. + +// Plafond au mur de chaque projet, en millisecondes, par nom d'exécution du +// projet : celui de vitest.config.js, que Vitest fait suivre du navigateur +// entre parenthèses pour un projet navigateur. Le refus est généreux +// (§ 14.14) : un plafond calé sur la mesure échouerait sur une machine +// chargée, et un test capricieux se désactive. +export const PLAFONDS = new Map([ + // La série node surveillée : trente secondes (§ 14.14). Seule, elle dure + // 1,8 s au mur sur 16 cœurs et 6,6 s sur 2 ; la commande entière, lanceur + // compris, prend 2,5 s sur 16 cœurs. + ['node', 30_000], + // Posé sur la mesure de node-long : seule, 17 s au mur sur 16 cœurs et + // 28 s sur un seul. Le plafond laisse un facteur 2,1 au cas le plus lent. + ['node-long', 60_000], +]); + +// Nombre d'épreuves que nomme le bilan, des plus lentes. +const PLUS_LENTES = 10; + +// Durée en secondes, deux décimales, virgule décimale. +const secondes = (ms) => `${(ms / 1000).toFixed(2).replace('.', ',')} s`; + +// Longueur, en millisecondes, de la réunion d'intervalles { debut, fin } +// donnés dans n'importe quel ordre. +export function dureeReunie(intervalles) { + let total = 0; + let finReunie = -Infinity; + for (const { debut, fin } of [...intervalles].sort((a, b) => a.debut - b.debut)) { + if (fin > finReunie) { + total += fin - Math.max(debut, finReunie); + finReunie = fin; + } + } + return total; +} + +// Les n épreuves les plus lentes parmi des { duree, projet, fichier, nom }, +// de la plus lente à la plus rapide ; à durée égale, par fichier puis par +// nom, comparés par points de code. +export function plusLentes(epreuves, n) { + const avant = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + return [...epreuves] + .sort((a, b) => b.duree - a.duree || avant(a.fichier, b.fichier) || avant(a.nom, b.nom)) + .slice(0, n); +} + +// Bilan d'une exécution : ses lignes et ses refus. duree est celle de +// l'exécution ; projets associe à chaque nom de projet { duree, nonSuivis }, +// nonSuivis listant les fichiers dont la durée manque ; epreuves porte chaque +// épreuve exécutée, { duree, projet, fichier, nom }. Les projets s'impriment +// triés par nom. Un projet refuse quand sa durée dépasse son plafond, ou +// quand il a un plafond et un fichier non suivi. +export function bilan({ duree, projets, epreuves, plafonds = PLAFONDS }) { + const lignes = [`Durée totale : ${secondes(duree)}`]; + const refus = []; + for (const nom of [...projets.keys()].sort()) { + const { duree: dureeProjet, nonSuivis } = projets.get(nom); + const plafond = plafonds.get(nom); + const mesure = + nonSuivis.length === 0 + ? `${secondes(dureeProjet)} au mur` + : `durée inconnue, ${nonSuivis.length} fichier${nonSuivis.length > 1 ? 's' : ''} non suivi${nonSuivis.length > 1 ? 's' : ''}`; + lignes.push(` ${nom} : ${mesure}${plafond === undefined ? '' : `, plafond ${secondes(plafond)}`}`); + if (plafond === undefined) continue; + if (nonSuivis.length > 0) { + refus.push( + `Le projet ${nom} a un plafond de ${secondes(plafond)}, et sa durée ne se mesure pas : ` + + `le rapporteur n'a pas suivi ${nonSuivis.join(', ')} (§ 14.14).`, + ); + } else if (dureeProjet > plafond) { + refus.push( + `Le projet ${nom} dure ${secondes(dureeProjet)} au mur, au-delà de son plafond de ${secondes(plafond)} (§ 14.14).`, + ); + } + } + if (epreuves.length === 0) { + lignes.push('Aucune épreuve exécutée.'); + } else { + const lentes = plusLentes(epreuves, PLUS_LENTES); + lignes.push(`Épreuves les plus lentes (${lentes.length} sur ${epreuves.length}) :`); + for (const { duree: dureeEpreuve, projet, fichier, nom } of lentes) { + lignes.push(`${String(Math.round(dureeEpreuve)).padStart(8)} ms ${projet} ${fichier} > ${nom}`); + } + } + return { lignes, refus }; +} + +// Le rapporteur que Vitest construit, sans option ; une épreuve lui passe sa +// propre horloge, en millisecondes, et ses propres plafonds. +export default class Rapporteur { + #horloge; + #plafonds; + #journal; + #debut; + // Intervalle { debut, fin } de chaque fichier pris pendant l'exécution + // courante, par identifiant de fichier ; fin reste undefined jusqu'à la + // fin du fichier. + #intervalles = new Map(); + + constructor({ horloge = () => performance.now(), plafonds = PLAFONDS } = {}) { + this.#horloge = horloge; + this.#plafonds = plafonds; + } + + onInit(vitest) { + this.#journal = vitest.logger; + } + + onTestRunStart() { + this.#debut = this.#horloge(); + this.#intervalles = new Map(); + } + + onTestModuleQueued(module) { + this.#intervalles.set(module.id, { debut: this.#horloge(), fin: undefined }); + } + + onTestModuleEnd(module) { + const intervalle = this.#intervalles.get(module.id); + if (intervalle !== undefined) intervalle.fin = this.#horloge(); + } + + // Un fichier pris et jamais fini est en cours jusqu'à la fin de + // l'exécution. Le temps où seuls d'autres projets ont un fichier en cours + // vaut la réunion de tous les fichiers moins celle du projet : la durée + // du projet est donc celle de l'exécution, moins la première, plus la + // seconde. + onTestRunEnd(modules, _erreurs, raison) { + const fin = this.#horloge(); + const duree = fin - this.#debut; + const parProjet = new Map(); + const tous = []; + const epreuves = []; + for (const module of modules) { + const projet = module.project.name; + if (!parProjet.has(projet)) parProjet.set(projet, { intervalles: [], nonSuivis: [] }); + const suivi = parProjet.get(projet); + const intervalle = this.#intervalles.get(module.id); + if (intervalle === undefined) { + suivi.nonSuivis.push(module.relativeModuleId); + } else { + const enCours = { debut: intervalle.debut, fin: intervalle.fin ?? fin }; + suivi.intervalles.push(enCours); + tous.push(enCours); + } + for (const epreuve of module.children.allTests()) { + const diagnostic = epreuve.diagnostic(); + if (diagnostic !== undefined) { + epreuves.push({ duree: diagnostic.duration, projet, fichier: module.relativeModuleId, nom: epreuve.fullName }); + } + } + } + const horsDeTout = duree - dureeReunie(tous); + const projets = new Map( + [...parProjet].map(([projet, { intervalles, nonSuivis }]) => [ + projet, + { duree: horsDeTout + dureeReunie(intervalles), nonSuivis }, + ]), + ); + const { lignes, refus } = bilan({ duree, projets, epreuves, plafonds: this.#plafonds }); + for (const ligne of lignes) this.#journal.log(ligne); + if (raison === 'interrupted') return; + for (const message of refus) this.#journal.error(message); + if (refus.length > 0) process.exitCode = 1; + } +} diff --git a/test/rapporteur.test.js b/test/rapporteur.test.js new file mode 100644 index 0000000..52425e2 --- /dev/null +++ b/test/rapporteur.test.js @@ -0,0 +1,249 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le rapporteur des durées (test/rapporteur.js, § 14.14) : sa mesure au mur, +// son bilan, son refus, et sa déclaration dans vitest.config.js. Le +// rapporteur s'éprouve ici hors de Vitest : une fausse horloge date chaque +// événement, et de faux fichiers portent les épreuves et leurs durées. +import assert from 'node:assert/strict'; +import configuration from '../vitest.config.js'; +import Rapporteur, { PLAFONDS, bilan, dureeReunie, plusLentes } from './rapporteur.js'; +import { describe, test } from './lanceur.js'; + +// Un faux fichier d'épreuves, tel que Vitest le passe au rapporteur : son +// identifiant, son projet, son chemin, et ses épreuves, chacune +// { nom, duree } ; une durée absente fait une épreuve sautée, sans +// diagnostic. +const fichier = (projet, chemin, epreuves = []) => ({ + id: `${projet}:${chemin}`, + project: { name: projet }, + relativeModuleId: chemin, + children: { + *allTests() { + for (const { nom, duree } of epreuves) { + yield { fullName: nom, diagnostic: () => (duree === undefined ? undefined : { duration: duree }) }; + } + }, + }, +}); + +// Une épreuve telle que la lisent plusLentes et bilan. +const epreuve = (duree, fichierDEpreuve, nom, projet = 'node') => ({ duree, projet, fichier: fichierDEpreuve, nom }); + +// Projets mesurés tels que les lit bilan, depuis [nom, durée] : chacun suivi +// en entier. +const mesures = (paires) => new Map(paires.map(([nom, duree]) => [nom, { duree, nonSuivis: [] }])); + +// Un rapporteur branché sur une horloge réglée à la main, sur des plafonds +// d'épreuve et sur un journal qui garde ses lignes. Rend le rapporteur, de +// quoi régler l'horloge, et les deux listes du journal. +function brancher() { + let maintenant = 0; + const journal = { lignes: [], erreurs: [] }; + const plafonds = new Map([['node', 30_000], ['node-long', 60_000]]); + const rapporteur = new Rapporteur({ horloge: () => maintenant, plafonds }); + rapporteur.onInit({ + logger: { log: (texte) => journal.lignes.push(texte), error: (texte) => journal.erreurs.push(texte) }, + }); + return { rapporteur, journal, regler: (t) => (maintenant = t) }; +} + +// Exécute fin(), puis rend le code de sortie qu'elle a posé et remet celui +// d'avant : l'épreuve ne laisse pas son code au processus qui l'exécute. +function codeDeSortieDe(fin) { + const avant = process.exitCode; + process.exitCode = undefined; + try { + fin(); + return process.exitCode; + } finally { + process.exitCode = avant; + } +} + +describe('rapporteur : mesure', () => { + test("dureeReunie mesure la réunion des intervalles, quel que soit leur ordre : chevauchés, emboîtés, bout à bout ou disjoints", () => { + assert.equal(dureeReunie([]), 0); + assert.equal(dureeReunie([{ debut: 5, fin: 9 }]), 4); + assert.equal(dureeReunie([{ debut: 20, fin: 25 }, { debut: 0, fin: 10 }]), 15); + assert.equal(dureeReunie([{ debut: 5, fin: 20 }, { debut: 0, fin: 10 }]), 20); + assert.equal(dureeReunie([{ debut: 0, fin: 30 }, { debut: 5, fin: 10 }, { debut: 12, fin: 31 }]), 31); + assert.equal(dureeReunie([{ debut: 10, fin: 20 }, { debut: 0, fin: 10 }]), 20); + }); + + test('plusLentes rend les n plus longues, de la plus lente à la plus rapide, à durée égale par fichier puis par nom', () => { + const epreuves = [ + epreuve(3, 'b.test.js', 'x'), + epreuve(9, 'a.test.js', 'y'), + epreuve(3, 'a.test.js', 'z'), + epreuve(3, 'a.test.js', 'w'), + epreuve(1, 'a.test.js', 'v'), + ]; + assert.deepEqual(plusLentes(epreuves, 4), [ + epreuve(9, 'a.test.js', 'y'), + epreuve(3, 'a.test.js', 'w'), + epreuve(3, 'a.test.js', 'z'), + epreuve(3, 'b.test.js', 'x'), + ]); + assert.deepEqual(plusLentes(epreuves.slice(0, 2), 10), [epreuve(9, 'a.test.js', 'y'), epreuve(3, 'b.test.js', 'x')]); + }); +}); + +describe('rapporteur : bilan et refus', () => { + test("un projet refuse au-delà de son plafond, pas à son plafond ; un projet sans plafond ne refuse jamais", () => { + const plafonds = new Map([['node', 30_000], ['node-long', 60_000]]); + const { refus } = bilan({ + duree: 70_000, + projets: mesures([['node', 30_001], ['node-long', 60_000], ['navigateur', 90_000]]), + epreuves: [], + plafonds, + }); + assert.deepEqual(refus, ['Le projet node dure 30,00 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']); + assert.deepEqual(bilan({ duree: 1, projets: mesures([['node', 30_000]]), epreuves: [], plafonds }).refus, []); + }); + + test("une exécution sans épreuve le dit au lieu d'une liste vide", () => { + assert.deepEqual(bilan({ duree: 4, projets: new Map(), epreuves: [], plafonds: new Map() }).lignes, [ + 'Durée totale : 0,00 s', + 'Aucune épreuve exécutée.', + ]); + }); + + test('le bilan donne la durée totale, chaque projet trié par nom avec son plafond, et les dix épreuves les plus lentes', () => { + const epreuves = Array.from({ length: 12 }, (_, i) => epreuve(10 * (i + 1), 'a.test.js', `e${i + 1}`)); + const { lignes } = bilan({ + duree: 2_345, + projets: mesures([['node-long', 2_000], ['navigateur', 1_234]]), + epreuves, + plafonds: new Map([['node-long', 60_000]]), + }); + assert.deepEqual(lignes, [ + 'Durée totale : 2,35 s', + ' navigateur : 1,23 s au mur', + ' node-long : 2,00 s au mur, plafond 60,00 s', + 'Épreuves les plus lentes (10 sur 12) :', + ' 120 ms node a.test.js > e12', + ' 110 ms node a.test.js > e11', + ' 100 ms node a.test.js > e10', + ' 90 ms node a.test.js > e9', + ' 80 ms node a.test.js > e8', + ' 70 ms node a.test.js > e7', + ' 60 ms node a.test.js > e6', + ' 50 ms node a.test.js > e5', + ' 40 ms node a.test.js > e4', + ' 30 ms node a.test.js > e3', + ]); + }); +}); + +describe('rapporteur : branché sur les événements de Vitest', () => { + test("chaque exécution compte à chaque projet sa durée moins le temps où seuls d'autres projets tournent, imprime son bilan, et sort en code 1 quand un projet dépasse son plafond", () => { + const { rapporteur, journal, regler } = brancher(); + const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 7 }, { nom: 'a > sautée' }]); + const b = fichier('node', 'src/b.test.js', [{ nom: 'b > deux', duree: 29_000 }]); + const c = fichier('node-long', 'src/c.long.test.js', [{ nom: 'c > trois', duree: 19_000 }]); + regler(0); + rapporteur.onTestRunStart([]); + regler(100); + rapporteur.onTestModuleQueued(a); + rapporteur.onTestModuleQueued(c); + regler(400); + rapporteur.onTestModuleEnd(a); + regler(20_100); + rapporteur.onTestModuleEnd(c); + rapporteur.onTestModuleQueued(b); + regler(50_000); + rapporteur.onTestModuleEnd(b); + regler(50_500); + const code = codeDeSortieDe(() => rapporteur.onTestRunEnd([a, b, c], [], 'passed')); + assert.equal(code, 1); + // Aucun fichier ne tourne pendant 0,6 s, compté à chaque projet. node y + // ajoute ses fichiers, [100, 400] et [20 100, 50 000], soit 30,8 s : les + // 19,7 s où c tourne seul ne lui sont pas comptées. node-long y ajoute + // [100, 20 100], soit 20,6 s. + assert.deepEqual(journal.lignes.slice(0, 3), [ + 'Durée totale : 50,50 s', + ' node : 30,80 s au mur, plafond 30,00 s', + ' node-long : 20,60 s au mur, plafond 60,00 s', + ]); + assert.deepEqual(journal.lignes.slice(3), [ + 'Épreuves les plus lentes (3 sur 3) :', + ' 29000 ms node src/b.test.js > b > deux', + ' 19000 ms node-long src/c.long.test.js > c > trois', + ' 7 ms node src/a.test.js > a > un', + ]); + assert.deepEqual(journal.erreurs, ['Le projet node dure 30,80 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']); + + // Une seconde exécution, comme une relance sous surveillance, repart de + // zéro. Seul, le projet dure toute l'exécution, temps d'avant la prise de + // son fichier compris ; sous son plafond, il ne pose aucun code de + // sortie. + journal.lignes.length = 0; + journal.erreurs.length = 0; + regler(60_000); + rapporteur.onTestRunStart([]); + regler(60_200); + rapporteur.onTestModuleQueued(a); + regler(60_300); + rapporteur.onTestModuleEnd(a); + regler(60_400); + assert.equal(codeDeSortieDe(() => rapporteur.onTestRunEnd([a], [], 'passed')), undefined); + assert.deepEqual(journal.lignes.slice(0, 2), ['Durée totale : 0,40 s', ' node : 0,40 s au mur, plafond 30,00 s']); + assert.deepEqual(journal.erreurs, []); + }); + + test("un fichier dont le rapporteur n'a vu ni la prise ni la fin rend la durée de son projet inconnue, et refuse quand le projet a un plafond", () => { + const { rapporteur, journal, regler } = brancher(); + const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 1 }]); + const b = fichier('node', 'src/b.test.js', [{ nom: 'b > deux', duree: 1 }]); + const n = fichier('navigateur', 'src/n.navigateur.test.js', [{ nom: 'n > trois', duree: 1 }]); + regler(0); + rapporteur.onTestRunStart([]); + rapporteur.onTestModuleQueued(a); + regler(10); + rapporteur.onTestModuleEnd(a); + const code = codeDeSortieDe(() => rapporteur.onTestRunEnd([a, b, n], [], 'passed')); + assert.equal(code, 1); + assert.deepEqual(journal.lignes.slice(0, 3), [ + 'Durée totale : 0,01 s', + ' navigateur : durée inconnue, 1 fichier non suivi', + ' node : durée inconnue, 1 fichier non suivi, plafond 30,00 s', + ]); + assert.deepEqual(journal.erreurs, [ + "Le projet node a un plafond de 30,00 s, et sa durée ne se mesure pas : le rapporteur n'a pas suivi src/b.test.js (§ 14.14).", + ]); + }); + + test("une exécution interrompue imprime son bilan sans rien refuser", () => { + const { rapporteur, journal, regler } = brancher(); + const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 1 }]); + regler(0); + rapporteur.onTestRunStart([]); + rapporteur.onTestModuleQueued(a); + regler(40_000); + assert.equal(codeDeSortieDe(() => rapporteur.onTestRunEnd([a], [], 'interrupted')), undefined); + assert.deepEqual(journal.lignes.slice(0, 2), ['Durée totale : 40,00 s', ' node : 40,00 s au mur, plafond 30,00 s']); + assert.deepEqual(journal.erreurs, []); + }); +}); + +describe('rapporteur : déclaration', () => { + test('vitest.config.js déclare le rapporteur à côté du rapporteur par défaut', () => { + assert.deepEqual(configuration.test.reporters, ['default', './test/rapporteur.js']); + }); + + test("chaque plafond nomme un projet de vitest.config.js sous son nom d'exécution, et node comme node-long ont le leur (§ 14.14)", () => { + // Vitest nomme chaque instance d'un projet navigateur d'après le projet + // et le navigateur, « navigateur (chromium) » : seul ce nom s'apparie à + // un plafond. + const projets = configuration.test.projects.flatMap(({ test: { name, browser } }) => + browser?.enabled ? browser.instances.map((instance) => `${name} (${instance.browser})`) : [name], + ); + assert.ok(projets.includes('navigateur (chromium)'), `noms d'exécution : ${projets.join(', ')}`); + assert.deepEqual([...PLAFONDS.keys()].filter((projet) => !projets.includes(projet)), []); + assert.ok(PLAFONDS.has('node'), 'aucun plafond pour node'); + assert.ok(PLAFONDS.has('node-long'), 'aucun plafond pour node-long'); + assert.equal(PLAFONDS.get('node'), 30_000); + assert.equal(PLAFONDS.get('node-long'), 60_000); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 03fe9aa..25e8279 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -9,7 +9,7 @@ // avec l'application : chacune dispose de deux minutes, là où le // délai par défaut de Vitest, 5 s, la ferait échouer par // dépassement et non sur un défaut. Ce délai arrête un test -// bloqué ; il n'est pas le plafond de la série (§ 14.14). +// bloqué ; le plafond de la série est celui du rapporteur. // navigateur un vrai moteur de rendu. Le projet étend vite.config.js : un // module s'y résout par les mêmes greffons et réglages que dans la // construction du livrable, sans seconde configuration à tenir @@ -19,6 +19,11 @@ import { playwright } from '@vitest/browser-playwright'; export default defineConfig({ test: { + // test/rapporteur.js imprime, après le bilan par défaut, la durée de + // chaque projet et les dix épreuves les plus lentes, et fait échouer + // l'exécution quand node ou node-long dépasse son plafond (§ 14.14). Un + // chemin relatif se lit depuis la racine du projet. + reporters: ['default', './test/rapporteur.js'], // Sous surveillance, une épreuve se relance quand un module de son graphe // d'imports change. Les fichiers des motifs se lisent hors de tout // graphe : le contrôle de version (§ 18.5) lit les premiers par le