[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:
parent
6c67cba949
commit
4795114093
8 changed files with 918 additions and 98 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
40
test/chargements.js
Normal 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
91
test/chargements.test.js
Normal 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), []);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
189
test/rapporteur.js
Normal 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
249
test/rapporteur.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue