From 8bdc959183786ef9b7dff42a2988e380b8cd93e5 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 13:04:14 -0400 Subject: [PATCH] [ADD] mutation: run one mutant through node --test with a loader hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutant counts only when the tests that import its module actually run against the mutated source. A loader hook (module.registerHooks) serves the mutated text in place of the module; the import graph picks the tests that reach it, and node --test runs them in a child with a deadline. The verdict is killed, survived, timed out or error; an empty request, a missing test or a non-positive deadline is refused before anything starts. Checked: red first; 3418 node and 76 long tests green from the index, including a real run on a small invented module. --- FR --- [ADD] mutation : jouer un mutant sous node --test, crochet de chargement Un mutant ne compte que si les épreuves qui importent son module jouent vraiment contre le source muté. Un crochet de chargement (module.registerHooks) sert le texte muté à la place du module ; le graphe des imports choisit les épreuves qui l'atteignent, et node --test les joue dans un enfant sous délai. Le verdict est tué, survivant, délai ou erreur ; une demande vide, une épreuve absente ou un délai non positif sont refusés avant tout lancement. Vérifié : rouge d'abord ; 3418 node et 76 longues vertes depuis l'index, dont un vrai lancement sur un petit module inventé. Assisted-by: Claude Opus 5.5 --- scripts/mutation/crochets.js | 66 ++++++++ scripts/mutation/executer.js | 199 +++++++++++++++++++++++ scripts/mutation/graphe.js | 118 ++++++++++++++ scripts/mutation/graphe.test.js | 96 +++++++++++ test/fixtures/mutation/calcul.epreuve.js | 21 +++ test/fixtures/mutation/calcul.js | 19 +++ test/mutation.long.test.js | 112 +++++++++++++ 7 files changed, 631 insertions(+) create mode 100644 scripts/mutation/crochets.js create mode 100644 scripts/mutation/executer.js create mode 100644 scripts/mutation/graphe.js create mode 100644 scripts/mutation/graphe.test.js create mode 100644 test/fixtures/mutation/calcul.epreuve.js create mode 100644 test/fixtures/mutation/calcul.js create mode 100644 test/mutation.long.test.js diff --git a/scripts/mutation/crochets.js b/scripts/mutation/crochets.js new file mode 100644 index 0000000..2d9b90d --- /dev/null +++ b/scripts/mutation/crochets.js @@ -0,0 +1,66 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les crochets de chargement de l'audit par mutation (§ 14.13). executer.js +// charge ce module par --import dans le processus de node --test, qui le +// transmet à chaque processus d'épreuve. Deux substitutions, par URL : +// +// test/lanceur.js sert « export { test, describe } from 'node:test' » : +// les épreuves tournent sous le repli du § 14.8, sans +// qu'un seul fichier d'épreuve change ; +// le module muté sert le source muté à la place de celui du disque, +// que rien n'écrit. +// +// Les données arrivent par la variable d'environnement VARIABLE, que les +// processus d'épreuve héritent : le chemin d'un fichier JSON +// {fichier, remplacement} — fichier, chemin absolu du module muté ; +// remplacement, son source muté entier —, ou la chaîne vide pour la série non +// mutée. Sans la variable, importer ce module ne pose aucun crochet. +// +// Les crochets sont synchrones et posés dans le fil du module +// (module.registerHooks) : module.register, qui les poserait dans un fil à +// part, est déprécié par Node. +import { readFileSync, realpathSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const VARIABLE = 'GTT_MUTATION'; + +// Les URL se comparent après résolution des liens : Node charge un module +// sous son chemin réel. +const urlReelle = (chemin) => pathToFileURL(realpathSync(chemin)).href; + +const LANCEUR = urlReelle(fileURLToPath(new URL('../../test/lanceur.js', import.meta.url))); +const LANCEUR_DE_REPLI = "export { test, describe } from 'node:test';\n"; + +// Le module muté : { url, remplacement }, ou null pour la série non mutée. +let cible = null; + +/** + * Désigne le module muté et son source muté. Lève sur un fichier absent. + * @param {{fichier: string, remplacement: string}} donnees fichier : chemin absolu + */ +export function initialize({ fichier, remplacement }) { + if (typeof fichier !== 'string' || typeof remplacement !== 'string') { + throw new TypeError('initialize attend { fichier, remplacement }, deux chaînes'); + } + cible = { url: urlReelle(fichier), remplacement }; +} + +/** + * Crochet load de module.registerHooks : le lanceur de repli pour + * test/lanceur.js, le source muté pour le module désigné, le chargement + * ordinaire sinon. Le format du module muté reste celui que Node lui + * reconnaît. + */ +export function load(url, contexte, suivant) { + if (url === LANCEUR) return { format: 'module', source: LANCEUR_DE_REPLI, shortCircuit: true }; + if (cible !== null && url === cible.url) return { ...suivant(url, contexte), source: cible.remplacement }; + return suivant(url, contexte); +} + +const donnees = process.env[VARIABLE]; +if (donnees !== undefined) { + if (donnees !== '') initialize(JSON.parse(readFileSync(donnees, 'utf8'))); + registerHooks({ load }); +} diff --git a/scripts/mutation/executer.js b/scripts/mutation/executer.js new file mode 100644 index 0000000..15c9d11 --- /dev/null +++ b/scripts/mutation/executer.js @@ -0,0 +1,199 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'exécution d'un mutant, pour l'audit par mutation (§ 14.13) : un processus +// node --test par mutant, sous le repli du § 14.8, les crochets de +// crochets.js servant le source muté sans rien écrire dans le projet. Le +// processus est chef de son groupe : au délai, le groupe entier est tué, les +// processus d'épreuve que node --test a lancés compris. +// +// Issues d'un mutant : +// erreur le source muté ne se lit pas (syntaxe), ou node --test sort +// autrement que par 0 ou 1 : l'audit les compte à part ; +// tue une épreuve échoue, chargement du module compris ; +// survivant toutes les épreuves passent ; +// delai le délai expire avant la fin : une boucle sans fin, le plus +// souvent. +// +// La durée se lit à l'horloge réelle (performance.now) et ne s'écrit nulle +// part. L'extrait ne porte aucun chemin du poste : la racine du projet est +// retirée, le dossier personnel devient « ~ ». +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseAst } from 'vite'; +import { VARIABLE } from './crochets.js'; +import { appliquer } from './mutants.js'; + +const RACINE = fileURLToPath(new URL('../..', import.meta.url)); +const CROCHETS = new URL('./crochets.js', import.meta.url).href; + +// Ce que l'on garde de la sortie d'un processus : son début, un mébioctet au +// plus ; une boucle qui imprime ne remplit pas la mémoire. +const SORTIE_MAX_OCTETS = 1 << 20; +// La longueur d'un extrait, en caractères, et ses lignes au plus. +const EXTRAIT_MAX = 2_000; +const LIGNES_MAX = 15; + +/** + * @typedef {Object} Serie + * @property {number|null} code code de sortie de node --test, null s'il est tué + * @property {string|null} signal + * @property {boolean} delai vrai quand le délai a tué le groupe + * @property {number} dureeMs + * @property {string} sortie sortie standard et d'erreur mêlées, TAP, bornée + */ + +// Lève sur une demande que node --test lirait autrement : sans fichier, il +// chercherait lui-même les épreuves de tout le projet ; un chemin absent, il +// le lirait comme un motif sans correspondance et échouerait, ce qui se +// confondrait avec un mutant tué. +function verifierDemande({ tests, delaiMs, racine }) { + if (!Array.isArray(tests) || tests.length === 0) throw new TypeError('aucune épreuve à jouer'); + for (const test of tests) { + if (typeof test !== 'string' || !existsSync(join(racine, test))) throw new Error(`épreuve introuvable : ${test}`); + } + if (!Number.isFinite(delaiMs) || delaiMs <= 0) throw new RangeError(`délai invalide : ${delaiMs}`); +} + +// Tue le processus et son groupe. ESRCH : le groupe est déjà vide. +function tuerGroupe(enfant) { + try { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(enfant.pid), '/t', '/f'], { windowsHide: true }); + } else { + process.kill(-enfant.pid, 'SIGKILL'); + } + } catch (erreur) { + if (erreur.code !== 'ESRCH') throw erreur; + } +} + +/** + * Joue les épreuves sous le repli node --test, le module désigné muté s'il y + * en a un. Une liste d'épreuves vide, une épreuve absente ou un délai non + * positif sont refusés avant tout lancement. + * @param {Object} demande + * @param {string[]} demande.tests chemins relatifs à la racine + * @param {number} demande.delaiMs + * @param {null|{fichier: string, remplacement: string}} [demande.mutation] + * fichier : chemin absolu du module muté ; null : la série non mutée + * @param {string} [demande.racine] la racine du projet, celle de ce script par défaut + * @param {string[]} [demande.argumentsNode] options de Node ajoutées avant --test + * @returns {Promise} + */ +export async function executerSerie({ tests, delaiMs, mutation = null, racine = RACINE, argumentsNode = [] }) { + verifierDemande({ tests, delaiMs, racine }); + const dossier = mutation === null ? null : mkdtempSync(join(tmpdir(), 'gtt-mutation-')); + let donnees = ''; + if (dossier !== null) { + donnees = join(dossier, 'mutation.json'); + writeFileSync(donnees, JSON.stringify(mutation)); + } + try { + return await new Promise((resoudre, rejeter) => { + const debut = performance.now(); + const enfant = spawn( + process.execPath, + ['--import', CROCHETS, ...argumentsNode, '--test', '--test-concurrency=1', '--test-reporter=tap', ...tests], + { + cwd: racine, + env: { ...process.env, [VARIABLE]: donnees }, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + const morceaux = []; + let octets = 0; + const garder = (morceau) => { + if (octets >= SORTIE_MAX_OCTETS) return; + const reste = morceau.subarray(0, SORTIE_MAX_OCTETS - octets); + morceaux.push(reste); + octets += reste.length; + }; + enfant.stdout.on('data', garder); + enfant.stderr.on('data', garder); + let delai = false; + const minuterie = setTimeout(() => { + delai = true; + tuerGroupe(enfant); + }, delaiMs); + enfant.once('error', (erreur) => { + clearTimeout(minuterie); + rejeter(erreur); + }); + enfant.once('close', (code, signal) => { + clearTimeout(minuterie); + resoudre({ + code, + signal, + delai, + dureeMs: performance.now() - debut, + sortie: Buffer.concat(morceaux).toString('utf8'), + }); + }); + }); + } finally { + if (dossier !== null) rmSync(dossier, { recursive: true, force: true }); + } +} + +// Le texte sans chemin du poste : la racine (chemin et URL, liens résolus ou +// non) retirée, le dossier personnel remplacé par « ~ ». +function sansChemins(texte, racine) { + const avecFin = (chemin) => (chemin.endsWith(sep) ? chemin : chemin + sep); + const racines = [...new Set([avecFin(racine), avecFin(realpathSync(racine))])]; + let propre = texte; + for (const chemin of racines) { + propre = propre.replaceAll(pathToFileURL(chemin).href, '').replaceAll(chemin, ''); + } + return propre.replaceAll(homedir(), '~'); +} + +// Au plus LIGNES_MAX lignes et EXTRAIT_MAX caractères. +const borner = (lignes) => lignes.slice(0, LIGNES_MAX).join('\n').slice(0, EXTRAIT_MAX); + +// Le premier échec du TAP : sa ligne « not ok » et son diagnostic, jusqu'à la +// pile exclue ; les dernières lignes de la sortie à défaut. +function premierEchec(sortie) { + const lignes = sortie.split('\n'); + const debut = lignes.findIndex((ligne) => /^\s*not ok \d+/u.test(ligne)); + if (debut === -1) return borner(lignes.slice(-LIGNES_MAX)); + const retrait = lignes[debut].search(/\S/u); + const fin = lignes.findIndex( + (ligne, i) => i > debut && (/^\s*stack:/u.test(ligne) || (ligne.trim() === '...' && ligne.search(/\S/u) > retrait)), + ); + return borner(lignes.slice(debut, fin === -1 ? undefined : fin)); +} + +/** + * Joue un mutant contre ses épreuves. + * @param {Object} demande + * @param {import('./mutants.js').Mutant} demande.mutant fichier relatif à la racine + * @param {string[]} demande.tests chemins relatifs à la racine, non vide + * @param {number} demande.delaiMs > 0 + * @param {string} [demande.racine] + * @returns {Promise<{issue: 'tue'|'survivant'|'delai'|'erreur', dureeMs: number, extrait: string}>} + * rejetée sur une demande invalide, ou un mutant que le source ne + * porte plus à sa place (appliquer) + */ +export async function executerMutant({ mutant, tests, delaiMs, racine = RACINE }) { + verifierDemande({ tests, delaiMs, racine }); + const fichier = join(racine, mutant.fichier); + const remplacement = appliquer(readFileSync(fichier, 'utf8'), mutant); + const debut = performance.now(); + try { + parseAst(remplacement); + } catch (erreur) { + return { issue: 'erreur', dureeMs: performance.now() - debut, extrait: borner([sansChemins(String(erreur.message), racine)]) }; + } + const serie = await executerSerie({ tests, delaiMs, mutation: { fichier, remplacement }, racine }); + const sortie = sansChemins(serie.sortie, racine); + if (serie.delai) return { issue: 'delai', dureeMs: serie.dureeMs, extrait: '' }; + if (serie.code === 0) return { issue: 'survivant', dureeMs: serie.dureeMs, extrait: '' }; + if (serie.code === 1) return { issue: 'tue', dureeMs: serie.dureeMs, extrait: premierEchec(sortie) }; + return { issue: 'erreur', dureeMs: serie.dureeMs, extrait: borner(sortie.split('\n').slice(-LIGNES_MAX)) }; +} diff --git a/scripts/mutation/graphe.js b/scripts/mutation/graphe.js new file mode 100644 index 0000000..417a824 --- /dev/null +++ b/scripts/mutation/graphe.js @@ -0,0 +1,118 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le graphe des imports, pour l'audit par mutation (§ 14.13) : un mutant ne +// se joue que contre les épreuves dont les imports atteignent le module muté. +// Un chemin est relatif à la racine du projet, séparé par « / » sur tout +// système. Seuls comptent les chargements relatifs écrits en clair — import, +// export … from, import() d'une chaîne : un spécificateur nu ou natif mène +// hors du projet, et un chemin calculé ne désigne aucun module avant +// l'exécution. Une épreuve qui n'atteint un module que par un chemin calculé +// ne se joue donc pas contre ses mutants. +import { readdirSync, readFileSync } from 'node:fs'; +import { join, posix, relative, sep } from 'node:path'; +import { parseAst } from 'vite'; + +// « ./ » ou « ../ » en tête, ou « . » et « .. » seuls. +const RELATIF = /^\.{1,2}(?:\/|$)/; + +// Une épreuve, quel que soit son niveau : .test.js, .long.test.js, +// .navigateur.test.js. +const estEpreuve = (fichier) => fichier.endsWith('.test.js'); + +// Les spécificateurs écrits en clair d'un nœud, s'il en charge un. +function specificateurDe(noeud) { + switch (noeud.type) { + case 'ImportDeclaration': + case 'ExportAllDeclaration': + case 'ExportNamedDeclaration': + case 'ImportExpression': { + const source = noeud.source; + return source && source.type === 'Literal' && typeof source.value === 'string' ? source.value : null; + } + default: + return null; + } +} + +// Visite chaque nœud de l'arbre, en profondeur. +function parcourir(noeud, visiter) { + visiter(noeud); + for (const valeur of Object.values(noeud)) { + const enfants = Array.isArray(valeur) ? valeur : [valeur]; + for (const enfant of enfants) { + if (enfant !== null && typeof enfant === 'object' && typeof enfant.type === 'string') parcourir(enfant, visiter); + } + } +} + +/** + * Les modules que le source charge par un chemin relatif, résolus depuis la + * racine, sans le suffixe de requête (« ?raw »), triés et sans doublon. + * Lève sur un source que parseAst refuse. + * @param {string} source + * @param {string} fichier chemin du module, relatif à la racine + * @returns {string[]} + */ +export function importsDe(source, fichier) { + const dossier = posix.dirname(fichier); + const vus = new Set(); + parcourir(parseAst(source), (noeud) => { + const specificateur = specificateurDe(noeud); + if (specificateur !== null && RELATIF.test(specificateur)) { + vus.add(posix.normalize(posix.join(dossier, specificateur.replace(/\?.*$/su, '')))); + } + }); + return [...vus].sort(); +} + +/** + * Le graphe des fichiers donnés : chacun, dans l'ordre trié, vers ses imports. + * Un import hors de la liste reste une feuille. + * @param {string[]} fichiers chemins relatifs à la racine + * @param {(fichier: string) => string} lire le texte d'un fichier + * @returns {Map} + */ +export function construireGraphe(fichiers, lire) { + return new Map([...fichiers].sort().map((fichier) => [fichier, importsDe(lire(fichier), fichier)])); +} + +/** + * Le graphe des modules .js des dossiers donnés, lus sous la racine. + * @param {string} racine chemin absolu de la racine du projet + * @param {string[]} dossiers relatifs à la racine + * @returns {Map} + */ +export function grapheDuProjet(racine, dossiers) { + const fichiers = dossiers.flatMap((dossier) => + readdirSync(join(racine, dossier), { recursive: true, withFileTypes: true }) + .filter((entree) => entree.isFile() && entree.name.endsWith('.js')) + .map((entree) => relative(racine, join(entree.parentPath, entree.name)).split(sep).join('/')), + ); + return construireGraphe(fichiers, (fichier) => readFileSync(join(racine, fichier), 'utf8')); +} + +/** + * Les épreuves du graphe (*.test.js) dont les imports, de proche en proche, + * atteignent le module muté ; triées. Un cycle se parcourt une fois. + * @param {string} fichierMute chemin relatif à la racine + * @param {Map} graphe + * @returns {string[]} + */ +export function testsConcernes(fichierMute, graphe) { + const atteint = (depart) => { + const vus = new Set([depart]); + const pile = [depart]; + while (pile.length > 0) { + for (const suivant of graphe.get(pile.pop()) ?? []) { + if (suivant === fichierMute) return true; + if (!vus.has(suivant)) { + vus.add(suivant); + pile.push(suivant); + } + } + } + return false; + }; + return [...graphe.keys()].filter((fichier) => estEpreuve(fichier) && atteint(fichier)).sort(); +} diff --git a/scripts/mutation/graphe.test.js b/scripts/mutation/graphe.test.js new file mode 100644 index 0000000..84fee35 --- /dev/null +++ b/scripts/mutation/graphe.test.js @@ -0,0 +1,96 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du graphe des imports de l'audit par mutation (§ 14.13) : un arbre +// inventé, tenu en mémoire, où une épreuve atteint un module par un autre ; un +// module isolé n'est atteint par aucune ; puis le moteur réel, lu sans rien +// écrire. +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from '../../test/lanceur.js'; +import { construireGraphe, grapheDuProjet, importsDe, testsConcernes } from './graphe.js'; + +const RACINE = fileURLToPath(new URL('../..', import.meta.url)); + +// Un arbre inventé : chemin relatif à la racine → texte du module. +const ARBRE = new Map([ + ['src/verger/pommes.test.js', "import { cueillir } from './panier.js';\nimport assert from 'node:assert/strict';\n"], + ['src/verger/panier.js', "export { cueillir } from '../serre/tige.js';\n"], + ['src/serre/tige.js', "import { racine } from './sol.js';\nexport const cueillir = () => racine;\n"], + ['src/serre/sol.js', 'export const racine = 1;\n'], + ['src/serre/rocaille.js', 'export const caillou = 2;\n'], + ['src/serre/boucle.js', "import './boucle.js';\nimport { racine } from './sol.js';\n"], + ['src/serre/tardive.test.js', "const { racine } = await import('./sol.js');\nexport * from './boucle.js';\n"], + ['src/verger/poires.test.js', "import { test } from 'vitest';\nimport '../../test/lanceur.js';\n"], +]); +const lire = (fichier) => ARBRE.get(fichier); + +describe('importsDe', () => { + test('rend les chargements relatifs, statiques, réexportés ou dynamiques, résolus depuis la racine', () => { + const source = [ + "import a from './a.js';", + "import { b } from '../haut/b.js';", + "export { c } from './sous/c.js';", + "export * from './d.js';", + "const e = await import('./e.js');", + "import 'node:fs';", + "import { parseAst } from 'vite';", + "const f = await import(`./${nom}.js`);", + "const g = './g.js';", + "import h from './a.js';", + ].join('\n'); + assert.deepEqual(importsDe(source, 'src/milieu/m.js'), [ + 'src/haut/b.js', + 'src/milieu/a.js', + 'src/milieu/d.js', + 'src/milieu/e.js', + 'src/milieu/sous/c.js', + ]); + }); + + test('un suffixe de requête ne fait pas un autre module', () => { + assert.deepEqual(importsDe("import p from '../feuilles/x.css?raw';\n", 'src/a/b.js'), ['src/feuilles/x.css']); + }); +}); + +describe('testsConcernes', () => { + const graphe = construireGraphe([...ARBRE.keys()], lire); + + test('un module importé indirectement est atteint par les épreuves qui y mènent', () => { + assert.deepEqual(testsConcernes('src/serre/tige.js', graphe), ['src/verger/pommes.test.js']); + assert.deepEqual(testsConcernes('src/serre/sol.js', graphe), [ + 'src/serre/tardive.test.js', + 'src/verger/pommes.test.js', + ]); + }); + + test("un module isolé n'est atteint par aucune épreuve", () => { + assert.deepEqual(testsConcernes('src/serre/rocaille.js', graphe), []); + }); + + test("un cycle d'imports se parcourt sans boucler", () => { + assert.deepEqual(testsConcernes('src/serre/boucle.js', graphe), ['src/serre/tardive.test.js']); + }); + + test("un module que le graphe ne connaît pas n'est atteint par rien", () => { + assert.deepEqual(testsConcernes('src/ailleurs/absent.js', graphe), []); + }); + + test('le graphe rend, par module, ses imports triés', () => { + assert.deepEqual([...graphe.keys()], [...ARBRE.keys()].sort()); + assert.deepEqual(graphe.get('src/serre/boucle.js'), ['src/serre/boucle.js', 'src/serre/sol.js']); + assert.deepEqual(graphe.get('src/verger/poires.test.js'), ['test/lanceur.js']); + }); +}); + +describe('le moteur réel', () => { + test("l'épreuve d'un module du moteur l'atteint, et le graphe ne rend que des épreuves", () => { + const graphe = grapheDuProjet(RACINE, ['src/moteur']); + assert.ok(graphe.size > 10, `${graphe.size} modules`); + assert.ok([...graphe.keys()].every((f) => f.startsWith('src/moteur/') && f.endsWith('.js'))); + const concernes = testsConcernes('src/moteur/plafond.js', graphe); + assert.ok(concernes.includes('src/moteur/plafond.test.js'), concernes.join(', ')); + assert.ok(concernes.every((f) => f.endsWith('.test.js'))); + assert.deepEqual(concernes, [...concernes].sort()); + }); +}); diff --git a/test/fixtures/mutation/calcul.epreuve.js b/test/fixtures/mutation/calcul.epreuve.js new file mode 100644 index 0000000..d53e089 --- /dev/null +++ b/test/fixtures/mutation/calcul.epreuve.js @@ -0,0 +1,21 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'épreuve inventée de calcul.js, que test/mutation.long.test.js joue sous +// le repli node --test (§ 14.8). Son nom ne finit pas par .test.js : aucune +// série de Vitest ne la ramasse. +import assert from 'node:assert/strict'; +import { describe, test } from '../../lanceur.js'; +import { plusGrand, somme } from './calcul.js'; + +describe('calcul', () => { + test('somme', () => { + assert.equal(somme(4), 10); + assert.equal(somme(0), 0); + }); + + test('plusGrand', () => { + assert.equal(plusGrand(2, 5), 5); + assert.equal(plusGrand(5, 2), 5); + }); +}); diff --git a/test/fixtures/mutation/calcul.js b/test/fixtures/mutation/calcul.js new file mode 100644 index 0000000..6f5b967 --- /dev/null +++ b/test/fixtures/mutation/calcul.js @@ -0,0 +1,19 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Module inventé que mute test/mutation.long.test.js : chaque fonction porte +// un site dont le mutant a une issue connue sous calcul.epreuve.js. + +// La somme des entiers de 1 à n. « total += i » → « total -= i » change la +// somme ; « i++ » → « i-- » ne termine pas dès que n ≥ 1. +export function somme(n) { + let total = 0; + for (let i = 1; i <= n; i++) total += i; + return total; +} + +// Le plus grand de deux nombres. « a > b » → « a >= b » rend le même nombre +// pour tout couple : un mutant équivalent, qui survit. +export function plusGrand(a, b) { + return a > b ? a : b; +} diff --git a/test/mutation.long.test.js b/test/mutation.long.test.js new file mode 100644 index 0000000..a997bef --- /dev/null +++ b/test/mutation.long.test.js @@ -0,0 +1,112 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'exécution d'un mutant (§ 14.13), sur le module inventé de +// test/fixtures/mutation/ et son épreuve, jouée sous le repli node --test +// (§ 14.8) : les quatre issues — tué, survivant, délai, erreur. Chaque cas +// lance de vrais processus de Node, d'où sa place hors de la série +// surveillée (§ 14.14). Le cas du délai vérifie qu'aucun processus de +// l'épreuve ne reste après le délai, groupe entier compris. +import assert from 'node:assert/strict'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { executerMutant } from '../scripts/mutation/executer.js'; +import { mutants } from '../scripts/mutation/mutants.js'; +import { describe, test } from './lanceur.js'; + +const RACINE = fileURLToPath(new URL('..', import.meta.url)); +const MODULE = 'test/fixtures/mutation/calcul.js'; +const EPREUVE = 'test/fixtures/mutation/calcul.epreuve.js'; +const SOURCE = readFileSync(join(RACINE, MODULE), 'utf8'); + +// Un délai large pour les cas qui terminent : la série longue se joue à +// côté d'autres épreuves, et le démarrage de Node s'en ressent. +const DELAI_LARGE_MS = 30_000; +const DELAI_COURT_MS = 3_000; + +// Le seul mutant que rend mutants() pour cet opérateur et de cet original, dans calcul.js. +function unMutant(operateur, original) { + const trouves = mutants(SOURCE, MODULE).filter((m) => m.operateur === operateur && m.original === original); + assert.equal(trouves.length, 1, `${operateur} ${original}`); + return trouves[0]; +} + +// Les processus de Node vivants qui jouent l'épreuve, lus dans /proc : le +// premier argument est ce Node, un autre est le chemin de l'épreuve. Un +// processus mort non encore recueilli a une ligne vide ; un shell qui nomme +// l'épreuve dans sa commande n'est pas un processus de Node. +function processusDeLEpreuve() { + return readdirSync('/proc') + .filter((nom) => /^\d+$/u.test(nom)) + .filter((pid) => { + try { + const [programme, ...argumentsLus] = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0'); + return programme === process.execPath && argumentsLus.some((a) => a.endsWith(EPREUVE)); + } catch { + return false; + } + }); +} + +describe('executerMutant', () => { + test('un mutant que le mini-test attrape est tué, et son extrait ne porte aucun chemin du poste', async () => { + const resultat = await executerMutant({ mutant: unMutant('mise-a-jour', '+='), tests: [EPREUVE], delaiMs: DELAI_LARGE_MS }); + assert.equal(resultat.issue, 'tue', resultat.extrait); + assert.match(resultat.extrait, /not ok/u); + assert.match(resultat.extrait, /somme/u); + assert.ok(!resultat.extrait.includes(RACINE), resultat.extrait); + assert.ok(!resultat.extrait.includes(homedir()), resultat.extrait); + assert.ok(resultat.dureeMs > 0 && resultat.dureeMs < DELAI_LARGE_MS); + }); + + test('un mutant neutre survit', async () => { + const resultat = await executerMutant({ mutant: unMutant('frontiere', '>'), tests: [EPREUVE], delaiMs: DELAI_LARGE_MS }); + assert.equal(resultat.issue, 'survivant', resultat.extrait); + }); + + test('une boucle infinie finit en délai, sous le délai + 2 s, sans processus orphelin', async () => { + const resultat = await executerMutant({ mutant: unMutant('mise-a-jour', '++'), tests: [EPREUVE], delaiMs: DELAI_COURT_MS }); + assert.equal(resultat.issue, 'delai', resultat.extrait); + assert.ok(resultat.dureeMs >= DELAI_COURT_MS, `${resultat.dureeMs} ms`); + assert.ok(resultat.dureeMs < DELAI_COURT_MS + 2_000, `${resultat.dureeMs} ms`); + if (existsSync('/proc/self/cmdline')) { + // Le noyau retire un processus tué en quelques millisecondes ; la + // relecture borne l'attente à deux secondes. + let restants = processusDeLEpreuve(); + for (const fin = performance.now() + 2_000; restants.length > 0 && performance.now() < fin; ) { + await new Promise((suite) => setTimeout(suite, 50)); + restants = processusDeLEpreuve(); + } + // Un orphelin tourne sans fin : il est tué avant le verdict, pour + // qu'un échec ne laisse aucun processus derrière la série. + for (const pid of restants) { + try { + process.kill(Number(pid), 'SIGKILL'); + } catch { + // déjà terminé + } + } + assert.deepEqual(restants, []); + } + }); + + test('une syntaxe cassée est une erreur, comptée à part, sans lancer l’épreuve', async () => { + const reel = unMutant('mise-a-jour', '+='); + const casse = { ...reel, cle: `${reel.cle}:casse`, remplacement: '+=+=' }; + const resultat = await executerMutant({ mutant: casse, tests: [EPREUVE], delaiMs: DELAI_LARGE_MS }); + assert.equal(resultat.issue, 'erreur', resultat.extrait); + assert.notEqual(resultat.extrait, ''); + }); + + test('une liste d’épreuves vide, ou une épreuve absente, est refusée avant tout lancement', async () => { + const mutant = unMutant('frontiere', '>'); + await assert.rejects(executerMutant({ mutant, tests: [], delaiMs: DELAI_LARGE_MS }), /aucune épreuve/u); + await assert.rejects( + executerMutant({ mutant, tests: ['test/fixtures/mutation/absente.epreuve.js'], delaiMs: DELAI_LARGE_MS }), + /absente\.epreuve\.js/u, + ); + await assert.rejects(executerMutant({ mutant, tests: [EPREUVE], delaiMs: 0 }), RangeError); + }); +});