[ADD] mutation: run one mutant through node --test with a loader hook

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
This commit is contained in:
Mathieu Benoit 2026-10-09 13:04:14 -04:00
parent 079b158587
commit 8bdc959183
7 changed files with 631 additions and 0 deletions

View file

@ -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 });
}

View file

@ -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<Serie>}
*/
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)) };
}

118
scripts/mutation/graphe.js Normal file
View file

@ -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<string, string[]>}
*/
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<string, string[]>}
*/
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<string, string[]>} 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();
}

View file

@ -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());
});
});

View file

@ -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);
});
});

19
test/fixtures/mutation/calcul.js vendored Normal file
View file

@ -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;
}

112
test/mutation.long.test.js Normal file
View file

@ -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);
});
});