gestion_table_tournante_libre/test/rapporteur.js
Mathieu Benoit de46c582f4 [IMP] tests: watched series adapts to the core count, threads below 4
Below four cores the runner carried the watched series in one process,
and a relaunch after a base module took 2.5 to 3 s, over the 2 s budget
of § 14.14. The configuration now reads the cores the process may use:
from four, full processes and the whole series, as before; below, threads
on every core, and the end-to-end test moves to the long series. No test
disappears or changes behaviour. A guard refuses, in these series, what a
thread refuses and a process accepts; the reporter names the mode.

Checked: relaunch on 2 cores 1.0 to 1.3 s, was 2.5 to 3.0; on 4 cores
1.2 to 1.5 s; process.env is copied per thread, measured.

--- FR ---

[IMP] épreuves : la série surveillée suit les cœurs, threads sous 4

Sous quatre cœurs, le lanceur portait la série surveillée dans un seul
processus, et une relance après un module de base prenait 2,5 à 3 s,
au-delà du budget de 2 s du § 14.14. La configuration lit les cœurs que
le processus peut occuper : à partir de quatre, processus complets et
série entière, comme avant ; en deçà, threads sur tous les cœurs, et
l'épreuve de bout en bout passe dans la série longue. Aucune épreuve ne
disparaît ni ne change. Une garde refuse dans ces séries ce qu'un thread
refuse et qu'un processus accepte ; le rapporteur nomme le mode.

Vérifié : relance sur 2 cœurs en 1,0 à 1,3 s, contre 2,5 à 3,0 ; sur 4
cœurs en 1,2 à 1,5 s ; process.env est copié par thread, mesuré.

Assisted-by: Claude Opus 5.5
2026-10-06 04:54:01 -04:00

193 lines
8.3 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. Le bilan finit sur le mode que la machine a fait retenir
// (test/machine.js) : un développeur sait ainsi sur quelle série sa relance a
// porté.
import { coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
// 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 3,7 s sur 2, où elle se joue en threads
// (test/machine.js).
['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);
this.#journal.log(ligneMachine(reglagesSeries(coeursDisponibles())));
if (raison === 'interrupted') return;
for (const message of refus) this.#journal.error(message);
if (refus.length > 0) process.exitCode = 1;
}
}