[ADD] tests: budget reporter, one import parser, key-order guard

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
This commit is contained in:
Mathieu Benoit 2026-10-06 04:23:13 -04:00
parent 6c67cba949
commit 4795114093
8 changed files with 918 additions and 98 deletions

View file

@ -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

View file

@ -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*(['"])(?<specificateur>[^'"\n]+)\1/g;
const IMPORT_SEUL = /(?<!@)\bimport\s+(['"])(?<specificateur>[^'"\n]+)\1/g;
const APPEL = /\b(?:import|require)\s*\(\s*(['"`])(?<specificateur>[^'"`$\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/,
);
}
});
});

40
test/chargements.js Normal file
View file

@ -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*(['"])(?<specificateur>[^'"\n]+)\1/g;
const IMPORT_SEUL = /(?<!@)\bimport\s+(['"])(?<specificateur>[^'"\n]+)\1/g;
const APPEL =
/\b(?:import|require)\s*\(\s*(?:(['"`])(?<specificateur>[^'"`$\n]+)\1\s*[,)]|(?<argument>[^)]*)\))/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);
}

91
test/chargements.test.js Normal file
View file

@ -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']);",
'<style>',
" @import 'jetons.css';",
'</style>',
"const mot = 'import';",
].join('\n');
assert.deepEqual(lus(texte), []);
});
});

View file

@ -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*(['"])(?<specificateur>[^'"]+)\1/g;
const IMPORT_NU = /\bimport\s*(['"])(?<specificateur>[^'"]+)\1/g;
const APPEL = /\b(?:import|require)\s*\((?<argument>[^)]*)\)/g;
const LITTERAL = /^\s*(['"])(?<specificateur>[^'"]*)\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 },
);
});
});

189
test/rapporteur.js Normal file
View file

@ -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;
}
}

249
test/rapporteur.test.js Normal file
View file

@ -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);
});
});

View file

@ -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