diff --git a/spec.md b/spec.md index d590c6b..881cc1b 100644 --- a/spec.md +++ b/spec.md @@ -3494,11 +3494,16 @@ couverture : | relance sous surveillance, après une modification | **2 secondes** | le développeur commence à grouper ses modifications, et le test cesse de désigner laquelle a cassé | | série `node` surveillée complète, à froid | **10 secondes** | le développeur change de fenêtre ; le retour de contexte coûte plus que l'épreuve, et le niveau glisse vers « avant un commit » | -**Le budget se mesure sur une machine de développement d'au moins quatre -cœurs.** Sur deux, le lanceur ne porte la série que dans un seul processus : -la relance qui suit la modification d'un module de base — celui que presque -toutes les épreuves importent — dépasse deux secondes, et ce dépassement y est -accepté. Le budget à froid de dix secondes, lui, y tient. +**La série surveillée s'adapte au nombre de cœurs, et le budget tient sur toute +machine.** À partir de quatre cœurs, des processus complets portent la série +entière. En deçà, le lanceur n'en porterait la série que dans un seul +processus, et la relance qui suit un module de base — celui que presque toutes +les épreuves importent — dépasserait deux secondes. La série s'y joue donc en +threads sur tous les cœurs, et ses épreuves lourdes passent dans la série +`node:long`. Aucune épreuve ne disparaît : elle change de série, et ce qu'elle +éprouve ne dépend pas de la machine. Une garde refuse, dans ces séries, ce +qu'un thread refuserait et qu'un processus accepte, et le lanceur annonce à +chaque exécution le mode qu'il a retenu. **Pourquoi ces deux valeurs.** En deçà de deux secondes, l'attention reste sur le code et le résultat se lit comme la suite du geste. Au-delà de dix, elle part diff --git a/test/fils.test.js b/test/fils.test.js new file mode 100644 index 0000000..cd85b65 --- /dev/null +++ b/test/fils.test.js @@ -0,0 +1,123 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Sur une petite machine, les séries node et node-long se jouent en threads +// (test/machine.js). Un thread de Node refuse ce qui changerait le processus +// entier — process.chdir, process.umask avec un masque, les changements +// d'identité — là où un processus complet l'accepte : une épreuve qui s'en +// servirait passerait sur une grande machine et échouerait sur une petite. +// La garde les refuse dans tout le code que ces séries exécutent : les +// épreuves de src/ et de scripts/, les modules sous test/, et les scripts +// qu'elles importent. L'environnement, lui, est copié dans chaque thread : +// une épreuve qui écrit process.env ne trouble pas sa voisine, et la garde ne +// le refuse pas. +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from './lanceur.js'; + +const RACINE = fileURLToPath(new URL('..', import.meta.url)); + +// Appels qu'un thread de Node refuse. Un motif exige la parenthèse de +// l'appel : une phrase qui nomme process.chdir n'en est pas un. umask sans +// argument lit le masque, ce qu'un thread accepte. +const REFUSES_DANS_UN_THREAD = [ + ['process.chdir', /\bprocess\s*\??\.\s*chdir\s*(?:\?\.)?\s*\(/g], + ['process.umask(masque)', /\bprocess\s*\??\.\s*umask\s*(?:\?\.)?\s*\(\s*[^)\s]/g], + ['process.set…id', /\bprocess\s*\??\.\s*(?:set(?:e?[ug]id|groups)|initgroups)\s*(?:\?\.)?\s*\(/g], +]; + +// Ce fichier nomme ces appels dans ses motifs et dans ses données d'épreuve : +// il est hors de son propre balayage. +const CE_FICHIER = fileURLToPath(import.meta.url); + +// Fichiers JavaScript que les séries node exécutent : les épreuves de src/ et +// de scripts/, hors épreuves du navigateur, tout module sous test/, et les +// scripts de scripts/, que des épreuves importent. +function fichiersExecutes(racine) { + const sous = (dossier) => { + try { + return readdirSync(join(racine, dossier), { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile() && /\.[cm]?js$/.test(e.name)) + .map((e) => join(e.parentPath, e.name)); + } catch { + return []; + } + }; + return [ + ...sous('src').filter((f) => /\.test\.[cm]?js$/.test(f) && !/\.navigateur\.test\.[cm]?js$/.test(f)), + ...sous('scripts'), + ...sous('test').filter((f) => !f.includes(`${join('test', 'fixtures')}`) && f !== CE_FICHIER), + ].sort(); +} + +// Relevé « fichier:ligne appel » de ces appels, par fichier puis par ligne. +// Lève quand aucun fichier n'est examiné (§ 14.2). +function releverAppelsRefuses(racine) { + const fichiers = fichiersExecutes(racine); + assert.ok(fichiers.length > 0, 'aucun fichier examiné'); + return fichiers.flatMap((fichier) => { + const texte = readFileSync(fichier, 'utf8'); + return REFUSES_DANS_UN_THREAD.flatMap(([appel, motif]) => + [...texte.matchAll(motif)].map(({ index }) => ({ ligne: texte.slice(0, index).split('\n').length, appel })), + ) + .sort((a, b) => a.ligne - b.ligne) + .map(({ ligne, appel }) => `${relative(racine, fichier)}:${ligne} ${appel}`); + }); +} + +// Écrit les fichiers donnés dans une racine temporaire, rend le relevé, et +// efface la racine. +function avecArbre(fichiers) { + const racine = mkdtempSync(join(tmpdir(), 'fils-')); + try { + for (const [chemin, texte] of Object.entries(fichiers)) { + mkdirSync(dirname(join(racine, chemin)), { recursive: true }); + writeFileSync(join(racine, chemin), texte); + } + return releverAppelsRefuses(racine); + } finally { + rmSync(racine, { recursive: true, force: true }); + } +} + +describe('fils : ce qu’un thread refuse, hors des séries node (test/machine.js)', () => { + test('aucune épreuve, aucun module sous test/ ni aucun script n’appelle ce qu’un thread refuse', () => { + assert.deepEqual(releverAppelsRefuses(RACINE), []); + }); + + test('la garde relève chaque appel par sa ligne, dans les épreuves, les modules sous test/ et les scripts, et pas ses voisins', () => { + const fichiers = { + 'src/moteur/a.test.js': [ + "process.chdir('/tmp');", + 'process.umask(0o022);', + 'process.setuid(1000);', + 'process?.chdir?.(ici);', + ].join('\n'), + 'scripts/outil.js': 'process.setgroups([]);\n', + 'test/aide.js': 'process.initgroups("x", 1);\n', + 'src/moteur/voisins.test.js': [ + 'const ici = process.cwd();', + 'const masque = process.umask();', + "process.env.SONDE = 'copie par thread';", + '// process.chdir changerait le processus entier.', + ].join('\n'), + 'src/moteur/module.js': "process.chdir('/tmp');\n", + 'src/interface/App.navigateur.test.js': "process.chdir('/tmp');\n", + }; + assert.deepEqual(avecArbre(fichiers), [ + 'scripts/outil.js:1 process.set…id', + 'src/moteur/a.test.js:1 process.chdir', + 'src/moteur/a.test.js:2 process.umask(masque)', + 'src/moteur/a.test.js:3 process.set…id', + 'src/moteur/a.test.js:4 process.chdir', + 'test/aide.js:1 process.set…id', + ]); + }); + + test('un arbre sans fichier exécuté par ces séries fait échouer la garde', () => { + assert.throws(() => avecArbre({ 'src/moteur/module.js': 'export const a = 1;\n' }), /aucun fichier examiné/); + }); +}); diff --git a/test/machine.js b/test/machine.js new file mode 100644 index 0000000..bff46be --- /dev/null +++ b/test/machine.js @@ -0,0 +1,61 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Réglages des séries node selon le nombre de cœurs de la machine (§ 14.14). +// +// Le budget de relance, deux secondes, tient avec des processus complets à +// partir de quatre cœurs. En deçà, le lanceur ne porte la série que dans un +// seul processus, et la relance qui suit un module de base — que presque +// toutes les épreuves importent — dépasse ce budget : de l'ordre de trois +// secondes sur deux cœurs. Une petite machine prend donc des threads, moins +// coûteux à démarrer qu'un processus, sur tous ses cœurs, et les épreuves +// lourdes de la série surveillée passent dans la série longue : la même +// relance y tient alors sous une seconde et demie. +// +// Aucune épreuve ne disparaît : elle change de série, et ce qu'elle éprouve +// ne dépend pas de la machine. Les threads partagent un processus : la garde +// de test/fils.test.js tient la série node à l'écart de l'état global du +// processus, sans quoi une épreuve en troublerait une autre. +import { availableParallelism } from 'node:os'; + +// Nombre de cœurs à partir duquel la série surveillée garde des processus +// complets et toutes ses épreuves. +export const SEUIL_COEURS = 4; + +// Épreuves de la série surveillée qui passent dans node-long sur une petite +// machine. L'épreuve de bout en bout lance de vraies recherches : c'est la +// plus lourde de la série, et toute modification du moteur la rejoue. +export const LOURDES = Object.freeze(['src/moteur/integration.test.js']); + +/** + * Réglages des projets node et node-long pour un nombre de cœurs. + * + * @param {number} coeurs entier ≥ 1 + * @returns {{petiteMachine: boolean, coeurs: number, pool: 'forks'|'threads', + * maxWorkers: number|undefined, horsSerieSurveillee: string[]}} + * maxWorkers undefined laisse au lanceur son défaut + */ +export function reglagesSeries(coeurs) { + if (!Number.isInteger(coeurs) || coeurs < 1) { + throw new RangeError(`nombre de cœurs ${String(coeurs)} : entier ≥ 1 attendu`); + } + if (coeurs >= SEUIL_COEURS) { + return { petiteMachine: false, coeurs, pool: 'forks', maxWorkers: undefined, horsSerieSurveillee: [] }; + } + return { petiteMachine: true, coeurs, pool: 'threads', maxWorkers: coeurs, horsSerieSurveillee: [...LOURDES] }; +} + +// Cœurs que ce processus peut occuper. availableParallelism suit l'affinité +// du processus : sous « taskset -c 0,1 », il rend 2 sur une machine qui en a +// seize, ce qui permet d'éprouver le mode d'une petite machine sur une grande. +export const coeursDisponibles = () => availableParallelism(); + +// Ligne que le rapporteur imprime, pour qu'un développeur sache sur quelle +// série sa relance a porté. +export function ligneMachine({ petiteMachine, coeurs, horsSerieSurveillee }) { + const compte = coeurs === 1 ? '1 cœur' : `${coeurs} cœurs`; + if (!petiteMachine) return `Machine : ${compte} — processus complets, série surveillée entière.`; + const ou = coeurs === 1 ? 'sur son seul cœur' : `sur les ${coeurs} cœurs`; + return `Machine : ${compte}, en deçà de ${SEUIL_COEURS} — threads ${ou} ; ` + + `en série longue : ${horsSerieSurveillee.join(', ')}.`; +} diff --git a/test/machine.test.js b/test/machine.test.js new file mode 100644 index 0000000..308c437 --- /dev/null +++ b/test/machine.test.js @@ -0,0 +1,104 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les réglages des séries node selon le nombre de cœurs (§ 14.14), et leur +// application dans vitest.config.js. +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { availableParallelism } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import configuration from '../vitest.config.js'; +import { describe, test } from './lanceur.js'; +import { LOURDES, SEUIL_COEURS, coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js'; + +const RACINE = fileURLToPath(new URL('..', import.meta.url)); +const projet = (nom) => configuration.test.projects.find(({ test: { name } }) => name === nom).test; + +describe('machine : les réglages selon le nombre de cœurs (§ 14.14)', () => { + test('à partir de quatre cœurs, des processus complets et la série surveillée entière', () => { + for (const coeurs of [SEUIL_COEURS, 8, 16, 64]) { + assert.deepEqual(reglagesSeries(coeurs), { + petiteMachine: false, + coeurs, + pool: 'forks', + maxWorkers: undefined, + horsSerieSurveillee: [], + }, `${coeurs} cœurs`); + } + }); + + test('en deçà de quatre cœurs, des threads sur tous les cœurs, et les épreuves lourdes en série longue', () => { + for (const coeurs of [1, 2, 3]) { + assert.deepEqual(reglagesSeries(coeurs), { + petiteMachine: true, + coeurs, + pool: 'threads', + maxWorkers: coeurs, + horsSerieSurveillee: [...LOURDES], + }, `${coeurs} cœurs`); + } + }); + + test('un nombre de cœurs qui n’est pas un entier ≥ 1 lève RangeError', () => { + for (const coeurs of [0, -1, 2.5, Number.NaN, '4', undefined]) { + assert.throws(() => reglagesSeries(coeurs), RangeError, String(coeurs)); + } + }); + + test('les épreuves lourdes existent, sont de la série surveillée, et la liste n’est pas vide', () => { + assert.ok(LOURDES.length > 0, 'aucune épreuve lourde nommée'); + for (const fichier of LOURDES) { + assert.ok(existsSync(join(RACINE, fichier)), `${fichier} absent`); + assert.match(fichier, /\.test\.js$/); + assert.doesNotMatch(fichier, /\.(long|navigateur)\.test\.js$/, `${fichier} n'est pas de la série surveillée`); + } + }); + + test('les cœurs disponibles suivent l’affinité du processus, comme availableParallelism', () => { + assert.equal(coeursDisponibles(), availableParallelism()); + assert.ok(coeursDisponibles() >= 1); + }); + + test('la ligne annoncée dit le mode, sur une petite machine comme sur une grande', () => { + assert.equal( + ligneMachine(reglagesSeries(2)), + 'Machine : 2 cœurs, en deçà de 4 — threads sur les 2 cœurs ; ' + + 'en série longue : src/moteur/integration.test.js.', + ); + assert.equal( + ligneMachine(reglagesSeries(1)), + 'Machine : 1 cœur, en deçà de 4 — threads sur son seul cœur ; ' + + 'en série longue : src/moteur/integration.test.js.', + ); + assert.equal(ligneMachine(reglagesSeries(16)), 'Machine : 16 cœurs — processus complets, série surveillée entière.'); + }); +}); + +describe('machine : vitest.config.js applique les réglages de cette machine', () => { + const reglages = reglagesSeries(coeursDisponibles()); + + test('les projets node et node-long prennent le pool et le nombre de processus retenus', () => { + for (const nom of ['node', 'node-long']) { + assert.equal(projet(nom).pool, reglages.pool, nom); + assert.equal(projet(nom).maxWorkers, reglages.maxWorkers, nom); + } + }); + + test('une épreuve hors de la série surveillée entre dans la série longue, et nulle part ailleurs', () => { + for (const fichier of reglages.horsSerieSurveillee) { + assert.ok(projet('node').exclude.includes(fichier), `${fichier} reste dans node`); + assert.ok(projet('node-long').include.includes(fichier), `${fichier} manque à node-long`); + } + for (const fichier of LOURDES.filter((f) => !reglages.horsSerieSurveillee.includes(f))) { + assert.ok(!projet('node').exclude.includes(fichier), `${fichier} sort de node sur cette machine`); + assert.ok(!projet('node-long').include.includes(fichier), `${fichier} entre dans node-long sur cette machine`); + } + }); + + test('le projet navigateur ne prend aucun de ces réglages', () => { + const navigateur = projet('navigateur'); + assert.equal(navigateur.pool, undefined); + assert.equal(navigateur.maxWorkers, undefined); + }); +}); diff --git a/test/rapporteur.js b/test/rapporteur.js index 4780ecc..c01c69e 100644 --- a/test/rapporteur.js +++ b/test/rapporteur.js @@ -20,7 +20,10 @@ // 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. +// 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 @@ -29,8 +32,8 @@ // 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. + // 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. @@ -182,6 +185,7 @@ export default class Rapporteur { ); 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; diff --git a/test/rapporteur.test.js b/test/rapporteur.test.js index 52425e2..90870d4 100644 --- a/test/rapporteur.test.js +++ b/test/rapporteur.test.js @@ -9,6 +9,7 @@ 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'; +import { coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js'; // Un faux fichier d'épreuves, tel que Vitest le passe au rapporteur : son // identifiant, son projet, son chemin, et ses épreuves, chacune @@ -171,6 +172,8 @@ describe('rapporteur : branché sur les événements de Vitest', () => { ' 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', + // Le bilan finit sur le mode que cette machine fait retenir. + ligneMachine(reglagesSeries(coeursDisponibles())), ]); assert.deepEqual(journal.erreurs, ['Le projet node dure 30,80 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']); diff --git a/vitest.config.js b/vitest.config.js index 25e8279..7821bbf 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -16,6 +16,13 @@ // d'accord avec la première. import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; +import { coeursDisponibles, reglagesSeries } from './test/machine.js'; + +// Les séries node suivent le nombre de cœurs de la machine (test/machine.js) : +// en deçà de quatre, des threads sur tous les cœurs, et les épreuves lourdes +// de la série surveillée passent dans la série longue (§ 14.14). +const SERIES = reglagesSeries(coeursDisponibles()); +const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers }; export default defineConfig({ test: { @@ -50,12 +57,13 @@ export default defineConfig({ ], projects: [ { test: { - name: 'node', environment: 'node', + name: 'node', environment: 'node', ...PROCESSUS, include: ['src/**/*.test.js', 'scripts/**/*.test.js', 'test/**/*.test.js'], - exclude: ['**/*.long.test.js', '**/*.navigateur.test.js', 'node_modules/**'] } }, + exclude: ['**/*.long.test.js', '**/*.navigateur.test.js', 'node_modules/**', + ...SERIES.horsSerieSurveillee] } }, { test: { - name: 'node-long', environment: 'node', - include: ['src/**/*.long.test.js', 'test/**/*.long.test.js'], + name: 'node-long', environment: 'node', ...PROCESSUS, + include: ['src/**/*.long.test.js', 'test/**/*.long.test.js', ...SERIES.horsSerieSurveillee], testTimeout: 120_000 } }, { extends: './vite.config.js', test: {