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
199 lines
8.6 KiB
JavaScript
199 lines
8.6 KiB
JavaScript
// © 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)) };
|
|
}
|