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
189 lines
8 KiB
JavaScript
189 lines
8 KiB
JavaScript
// © 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;
|
|
}
|
|
}
|