gestion_table_tournante_libre/test/navigateur/commandes_documentation.js

479 lines
21 KiB
JavaScript
Raw Normal View History

2026-10-09 15:33:59 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les commandes des captures d'écran, côté node (§ 19.4, § 19.5, § 19.7) :
// vitest.config.js les déclare dans browser.commands, et la page les appelle
// par commands de vitest/browser. Chacune reçoit d'abord le contexte de la
// commande — la page Playwright, le cadre de l'épreuve —, puis l'unique
// objet que la page lui passe ; elle rend du JSON. Aucun octet ne traverse
// en Uint8Array : le RPC des commandes sérialiserait un tableau typé en objet
// {"0": …}. La capture d'élément se prend ici, sur le cadre de l'épreuve, et
// s'écrit sur le disque sans repasser par la page.
//
// Les fichiers vivent sous une racine : celle du projet (racine null), ou
// un dossier d'épreuve que dossierEpreuve a créé, et que seul
// effacerDossierEpreuve retire. Toute autre racine est refusée.
//
// Le mode d'écriture. Chaque étape photographiée dépose, sous
// doc/captures/.attente/, son image <fichier>.png et son entrée complète
// <fichier>.json. publierCaptures publie d'un bloc : toutes les captures
// déclarées déposées, ni plus ni moins, chaque entrée relue et ses
// dimensions contrôlées, le manifeste assemblé et ses contrôles d'ordre et
// de machine tenus, alors seulement les images et le manifeste s'écrivent.
// Une image ne se réécrit que si l'empreinte de son étape a changé, ou si
// le fichier présent n'est plus celui que le manifeste décrit ; le
// manifeste ne se réécrit que si ses octets changent ; une image qui n'est
// plus déclarée part. Le dossier d'attente est jeté à la fin de chaque
// publication, réussie ou refusée : une publication refusée laisse le
// manifeste et les images tels qu'avant.
//
// Le mode de vérification compare l'entrée d'une étape à celle du
// manifeste (comparerCapture) et n'écrit rien.
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
A_VENIR,
CAPTURES,
CHEMIN_MANIFESTE,
DOSSIER_CAPTURES,
REPORTEES,
canoniser,
cheminImage,
cheminsHorsRacine as cheminsHors,
empreinteEtape,
fautesDImage,
fautesDeMachine,
lireEntetePng,
verifierOrdre,
} from '../../scripts/documentation/manifeste.js';
import { MODE_AFFICHAGE, suivreRequetes } from '../chromium.js';
/** Les deux modes des captures : verifier compare au manifeste, ecrire
* dépose puis publie (make captures). */
export const MODES = Object.freeze(['verifier', 'ecrire']);
/** Les commandes que vitest.config.js déclare, toutes exportées ici. */
export const COMMANDES = Object.freeze([
'requetesHorsBoucle',
'echecsHorsBoucle',
'declarationsCaptures',
'cheminsHorsRacine',
'lireManifeste',
'prendreCliche',
'deposerCapture',
'comparerCapture',
'publierCaptures',
'viderAttente',
'dossierEpreuve',
'effacerDossierEpreuve',
'empreintesDossier',
'copierDansEpreuve',
]);
/**
* Le mode des captures que GTT_CAPTURES demande : 'verifier' quand la
* variable est absente ou vide.
*
* @param {Record<string, string|undefined>} env
* @returns {'verifier'|'ecrire'}
* @throws {Error} une autre valeur, nommée
*/
export function modeCaptures(env) {
const valeur = env.GTT_CAPTURES ?? '';
if (valeur === '') return 'verifier';
if (MODES.includes(valeur)) return valeur;
throw new Error(`GTT_CAPTURES : « ${valeur} » n’est ni ${MODES.map((m) => `« ${m} »`).join(' ni ')}`);
}
// La racine du projet, deux dossiers au-dessus de ce module.
const PROJET = fileURLToPath(new URL('../..', import.meta.url));
// Le dossier d'attente, relatif à une racine.
const ATTENTE = `${DOSSIER_CAPTURES}/.attente`;
// Les dossiers d'épreuve créés par dossierEpreuve et pas encore effacés.
const DOSSIERS_EPREUVE = new Set();
// Un nom de capture : lettres, chiffres, tirets, sans point ni barre.
const NOM_CAPTURE = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
const sha256 = (octets) => createHash('sha256').update(octets).digest('hex');
// La racine absolue que désigne racine : le projet pour null, un dossier
// d'épreuve vivant, sinon un refus.
function racineDe(racine) {
if (racine === null || racine === undefined) return PROJET;
if (DOSSIERS_EPREUVE.has(racine)) return racine;
throw new Error(`captures : racine refusée « ${racine} » — ni le projet, ni un dossier d’épreuve vivant`);
}
function exigerNom(fichier) {
if (typeof fichier !== 'string' || !NOM_CAPTURE.test(fichier)) {
throw new Error(`captures : nom de capture refusé ${JSON.stringify(fichier)}`);
}
}
// Le contenu d'un fichier, null quand il n'existe pas.
async function lireSiPresent(chemin, encodage) {
try {
return await readFile(chemin, encodage);
} catch (erreur) {
if (erreur.code === 'ENOENT') return null;
throw erreur;
}
}
// Écrit un fichier par un fichier voisin renommé : un lecteur ne voit jamais
// un fichier à moitié écrit.
async function ecrireAtomique(chemin, contenu) {
const provisoire = `${chemin}.ecriture`;
await writeFile(provisoire, contenu);
await rename(provisoire, chemin);
}
// Le manifeste d'une racine absolue, analysé ; null quand il n'existe pas.
async function manifesteDe(base) {
const texte = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8');
if (texte === null) return null;
try {
return JSON.parse(texte);
} catch (erreur) {
throw new Error(`${CHEMIN_MANIFESTE} illisible : ${erreur.message}`);
}
}
/** Les requêtes hors de la boucle locale que la page a émises depuis
* l'appel précédent (test/chromium.js) ; le premier appel pose le suivi. */
export async function requetesHorsBoucle(context) {
return suivreRequetes(context.page).relever();
}
/** Les échecs des requêtes hors de la boucle locale, { adresse, cause },
* depuis l'appel précédent : la cause dit qui a refusé la connexion. */
export async function echecsHorsBoucle(context) {
return suivreRequetes(context.page).releverEchecs();
}
/** Les captures déclarées (CAPTURES) et celles qui sont à venir (A_VENIR) :
* la page ne charge pas scripts/documentation/manifeste.js, qui est de node. */
export async function declarationsCaptures() {
return { captures: CAPTURES.map((capture) => ({ ...capture })), aVenir: [...A_VENIR] };
}
/** Les chemins hors de la racine neutre que porte le texte relevé :
* cheminsHorsRacine de scripts/documentation/manifeste.js, sur les textes
* joints ligne à ligne. */
export async function cheminsHorsRacine(_context, { texte, racine }) {
if (!Array.isArray(texte) || texte.some((ligne) => typeof ligne !== 'string')) {
throw new TypeError('cheminsHorsRacine : texte est une liste de chaînes');
}
return cheminsHors(texte.join('\n'), racine);
}
/** Le manifeste de la racine, analysé ; null quand il n'existe pas. */
export async function lireManifeste(_context, { racine = null } = {}) {
return manifesteDe(racineDe(racine));
}
/**
* Photographie l'élément que désigne selecteur dans le cadre de l'épreuve
* et écrit l'image sous le dossier d'attente de la racine, à
* <fichier>.png. Le sélecteur doit désigner un seul élément.
*
* @returns {Promise<{chemin: string}>} le chemin absolu de l'image
*/
export async function prendreCliche(context, { fichier, selecteur, racine = null }) {
exigerNom(fichier);
const base = racineDe(racine);
const element = context.iframe.locator(selecteur);
const nombre = await element.count();
if (nombre !== 1) throw new Error(`capture ${fichier} : le sélecteur « ${selecteur} » désigne ${nombre} éléments, un attendu`);
const octets = await element.screenshot({ animations: 'disabled', caret: 'hide' });
const dossier = join(base, ATTENTE);
await mkdir(dossier, { recursive: true });
const chemin = join(dossier, `${fichier}.png`);
await ecrireAtomique(chemin, octets);
return { chemin };
}
/**
* Dépose l'entrée d'une étape à côté de son image, sous le dossier
* d'attente : l'image que prendreCliche a écrite à chemin, relue ; l'entrée
* complétée de image {sha256, largeur, hauteur} et de son empreinte
* d'étape. Des dimensions autres que cadre × facteur d'échelle refusent le
* dépôt.
*
* @returns {Promise<{empreinte: string}>}
*/
export async function deposerCapture(_context, { fichier, entree, chemin, racine = null }) {
exigerNom(fichier);
const base = racineDe(racine);
const attendu = join(base, ATTENTE, `${fichier}.png`);
if (typeof chemin !== 'string' || resolve(chemin) !== attendu) {
throw new Error(`capture ${fichier} : image attendue à ${relative(base, attendu)}, reçue ${JSON.stringify(chemin)}`);
}
if (entree?.fichier !== fichier) throw new Error(`capture ${fichier} : l’entrée porte le nom ${JSON.stringify(entree?.fichier)}`);
const octets = new Uint8Array(await readFile(attendu));
const { largeur, hauteur } = lireEntetePng(octets);
const complete = { ...entree, image: { sha256: sha256(octets), largeur, hauteur } };
const fautes = fautesDImage(complete, octets);
if (fautes.length > 0) throw new Error(`capture ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`);
complete.empreinte = empreinteEtape(complete);
await ecrireAtomique(join(base, ATTENTE, `${fichier}.json`), JSON.stringify(complete));
return { empreinte: complete.empreinte };
}
// Le texte JSON canonique d'une valeur, pour comparer deux champs.
const canonique = (valeur) => canoniser({ valeur });
// Le premier rang où deux listes de textes diffèrent, et les deux textes.
function premierEcart(avant, apres) {
const longueur = Math.max(avant.length, apres.length);
for (let rang = 0; rang < longueur; rang += 1) {
if (avant[rang] !== apres[rang]) return { rang, avant: avant[rang] ?? null, apres: apres[rang] ?? null };
}
return null;
}
// Un texte relevé en citation, ou « (rien) » au-delà de la fin.
const citer = (texte) => (texte === null ? '(rien)' : `« ${texte} »`);
/**
* Compare l'entrée d'une étape à celle du manifeste de la racine (§ 19.7) :
* l'empreinte d'étape d'abord ; quand elle diffère, chaque champ qui
* diffère est nommé, le texte avec son premier écart. Rien ne s'écrit.
*
* @returns {Promise<{ecarts: string[]}>} vide quand l'empreinte est la même
*/
export async function comparerCapture(_context, { fichier, entree, racine = null }) {
exigerNom(fichier);
const manifeste = await manifesteDe(racineDe(racine));
const nom = `${fichier} (étape ${entree?.etape})`;
if (manifeste === null) return { ecarts: [`${nom} : aucun manifeste, ${CHEMIN_MANIFESTE} — lancer make captures`] };
const precedente = (manifeste.captures ?? []).find((capture) => capture?.fichier === fichier);
if (precedente === undefined) return { ecarts: [`${nom} : absente du manifeste — lancer make captures`] };
if (empreinteEtape(entree) === precedente.empreinte) return { ecarts: [] };
const ecarts = [];
const exclus = new Set(['image', 'empreinte', 'versionReelle']);
const champs = [...new Set([...Object.keys(entree), ...Object.keys(precedente)])].filter((champ) => !exclus.has(champ)).sort();
for (const champ of champs) {
if (canonique(entree[champ] ?? null) === canonique(precedente[champ] ?? null)) continue;
if (champ === 'texte' && Array.isArray(entree.texte) && Array.isArray(precedente.texte)) {
const ecart = premierEcart(precedente.texte, entree.texte);
ecarts.push(`${nom} : écart de texte, rang ${ecart.rang} : ${citer(ecart.apres)} au lieu de ${citer(ecart.avant)}`);
} else {
ecarts.push(`${nom} : écart de ${champ}`);
}
}
if (ecarts.length === 0) ecarts.push(`${nom} : empreinte d’étape différente de celle du manifeste`);
return { ecarts };
}
// Les captures déposées sous le dossier d'attente : { fichier → {png, json} }.
// Un fichier d'une autre forme lève.
async function deposees(attente) {
let noms;
try {
noms = await readdir(attente);
} catch (erreur) {
if (erreur.code === 'ENOENT') return new Map();
throw erreur;
}
const depots = new Map();
for (const nom of noms.sort()) {
const forme = /^(.+)\.(png|json)$/.exec(nom);
if (forme === null || !NOM_CAPTURE.test(forme[1])) throw new Error(`publication : fichier inattendu dans ${ATTENTE} : ${nom}`);
const [, fichier, extension] = forme;
depots.set(fichier, { ...depots.get(fichier), [extension]: join(attente, nom) });
}
return depots;
}
// Le pilote du manifeste : le navigateur et sa version, le mode nommé, le
// facteur d'échelle et le cadre, communs à toutes les captures.
function piloteDe(context, captures) {
const [premiere] = captures;
for (const capture of captures) {
if (capture.facteurEchelle !== premiere.facteurEchelle || canonique(capture.cadre) !== canonique(premiere.cadre)) {
throw new Error(`publication : ${capture.fichier} n’a pas le cadre ni le facteur d’échelle de ${premiere.fichier}`);
}
}
const versionNavigateur = context.page.context().browser()?.version() ?? 'inconnue';
return {
navigateur: 'chromium',
versionNavigateur,
mode: MODE_AFFICHAGE,
facteurEchelle: premiere.facteurEchelle,
cadre: { largeur: premiere.cadre.largeur, hauteur: premiere.cadre.hauteur },
};
}
// Assemble la publication sans rien écrire : le manifeste, les images à
// écrire, les images à retirer. Lève sur la première raison de refuser.
async function preparer(context, base, declarees, racineNeutre) {
if (!Array.isArray(declarees) || declarees.length === 0) {
throw new Error('publication : aucune capture déclarée — zéro capture n’est pas zéro capture périmée');
}
const connues = new Map(CAPTURES.map((capture) => [capture.fichier, capture]));
for (const fichier of declarees) {
if (!connues.has(fichier)) throw new Error(`publication : ${JSON.stringify(fichier)} n’est pas déclarée dans CAPTURES`);
}
if (new Set(declarees).size !== declarees.length) throw new Error('publication : une capture déclarée deux fois');
const depots = await deposees(join(base, ATTENTE));
if (depots.size === 0) throw new Error('publication : aucune capture déposée — rien n’est publié');
const completes = [...depots].filter(([, depot]) => depot.png && depot.json).map(([fichier]) => fichier);
const manquantes = declarees.filter((fichier) => !completes.includes(fichier));
if (manquantes.length > 0) {
throw new Error(`publication : étapes sans capture déposée, rien n’est publié : ${manquantes.join(', ')}`);
}
const enTrop = [...depots.keys()].filter((fichier) => !declarees.includes(fichier));
if (enTrop.length > 0) throw new Error(`publication : captures déposées hors des déclarées : ${enTrop.join(', ')}`);
const ancien = await manifesteDe(base);
const anciennes = new Map((ancien?.captures ?? []).map((capture) => [capture?.fichier, capture]));
const captures = [];
const aEcrire = [];
const gardees = [];
for (const { fichier, etape } of CAPTURES.filter((capture) => declarees.includes(capture.fichier))) {
const { png, json } = depots.get(fichier);
const entree = JSON.parse(await readFile(json, 'utf8'));
const octets = new Uint8Array(await readFile(png));
if (entree.fichier !== fichier || entree.etape !== etape) {
throw new Error(`publication : ${fichier} déposée sous le nom ${entree.fichier}, étape ${entree.etape} ; étape ${etape} déclarée`);
}
if (entree.empreinte !== empreinteEtape(entree)) throw new Error(`publication : ${fichier} : l’empreinte déposée ne répond pas à l’entrée`);
const fautes = fautesDImage(entree, octets);
if (fautes.length > 0) throw new Error(`publication : ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`);
const precedente = anciennes.get(fichier);
const presente = await lireSiPresent(join(base, cheminImage(fichier)));
if (precedente?.empreinte === entree.empreinte && presente !== null && sha256(presente) === precedente.image?.sha256) {
captures.push({ ...entree, image: precedente.image });
gardees.push(fichier);
} else {
captures.push(entree);
aEcrire.push({ fichier, octets });
}
}
const manifeste = { format: 1, pilote: piloteDe(context, captures), captures, reportees: REPORTEES.map((r) => ({ ...r })) };
const aVenir = CAPTURES.map(({ fichier }) => fichier).filter((fichier) => !declarees.includes(fichier));
const fautes = [...verifierOrdre(manifeste, { aVenir }), ...fautesDeMachine(manifeste, racineNeutre)];
if (fautes.length > 0) {
throw new Error(`publication refusée :\n${fautes.map(({ chemin, faute }) => `${chemin} : ${faute}`).join('\n')}`);
}
let presentes;
try {
presentes = await readdir(join(base, DOSSIER_CAPTURES));
} catch (erreur) {
if (erreur.code !== 'ENOENT') throw erreur;
presentes = [];
}
const retirees = presentes
.filter((nom) => nom.endsWith('.png'))
.map((nom) => nom.slice(0, -'.png'.length))
.filter((fichier) => !declarees.includes(fichier))
.sort();
return { texte: canoniser(manifeste), aEcrire, gardees, retirees };
}
/**
* Publie d'un bloc les captures déposées (§ 19.5) : declarees, les noms de
* CAPTURES que cette publication porte — les autres comptent comme à venir
* au contrôle d'ordre ; racineNeutre, la racine de travail que les textes
* relevés peuvent nommer (fautesDeMachine). Le dossier d'attente est jeté,
* que la publication réussisse ou non.
*
* @returns {Promise<{ecrites: string[], gardees: string[], retirees: string[], manifeste: boolean}>}
* manifeste : vrai quand ses octets ont changé
*/
export async function publierCaptures(context, { declarees, racine = null, racineNeutre } = {}) {
const base = racineDe(racine);
try {
const { texte, aEcrire, gardees, retirees } = await preparer(context, base, declarees, racineNeutre);
await mkdir(join(base, DOSSIER_CAPTURES), { recursive: true });
for (const { fichier, octets } of aEcrire) await ecrireAtomique(join(base, cheminImage(fichier)), octets);
for (const fichier of retirees) await rm(join(base, cheminImage(fichier)));
const ancien = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8');
if (ancien !== texte) await ecrireAtomique(join(base, CHEMIN_MANIFESTE), texte);
return { ecrites: aEcrire.map(({ fichier }) => fichier), gardees, retirees, manifeste: ancien !== texte };
} finally {
await rm(join(base, ATTENTE), { recursive: true, force: true });
}
}
/** Vide le dossier d'attente de la racine : un parcours en écriture part
* de lui vide, et le dépôt d'un passage interrompu ne se publie jamais
* avec les captures d'un autre. */
export async function viderAttente(_context, { racine = null } = {}) {
await rm(join(racineDe(racine), ATTENTE), { recursive: true, force: true });
}
/** Crée un dossier d'épreuve neuf, sous le dossier temporaire du système,
* et rend son chemin absolu : une racine que les autres commandes
* acceptent jusqu'à effacerDossierEpreuve. */
export async function dossierEpreuve() {
const dossier = await mkdtemp(join(tmpdir(), 'gtt-captures-'));
DOSSIERS_EPREUVE.add(dossier);
return dossier;
}
/** Efface un dossier que dossierEpreuve a créé, et lui seul. */
export async function effacerDossierEpreuve(_context, racine) {
if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`);
DOSSIERS_EPREUVE.delete(racine);
await rm(racine, { recursive: true, force: true });
}
/**
* Copie un fichier d'un dossier d'épreuve sur un autre chemin du même
* dossier, les deux sous doc/captures : une image posée là où la
* publication ne l'a pas mise. La racine du projet est refusée.
*
* @param {Object} _context
* @param {{racine: string, depuis: string, vers: string}} chemins relatifs à racine
*/
export async function copierDansEpreuve(_context, { racine, depuis, vers }) {
if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`);
const captures = join(racine, DOSSIER_CAPTURES);
const dedans = (chemin) => {
const absolu = resolve(racine, String(chemin));
const relatif = relative(captures, absolu);
if (relatif === '' || relatif.startsWith('..') || isAbsolute(relatif)) {
throw new Error(`captures : ${JSON.stringify(chemin)} hors de ${DOSSIER_CAPTURES}`);
}
return absolu;
};
const source = dedans(depuis);
const cible = dedans(vers);
await mkdir(dirname(cible), { recursive: true });
await ecrireAtomique(cible, await readFile(source));
}
/** Les fichiers sous doc/captures de la racine, dossier d'attente compris :
* [chemin relatif à la racine, SHA-256], rangés par chemin. */
export async function empreintesDossier(_context, { racine = null } = {}) {
const base = racineDe(racine);
const fichiers = [];
async function parcourir(dossier) {
let entrees;
try {
entrees = await readdir(dossier, { withFileTypes: true });
} catch (erreur) {
if (erreur.code === 'ENOENT') return;
throw erreur;
}
for (const entree of entrees) {
const chemin = join(dossier, entree.name);
if (entree.isDirectory()) await parcourir(chemin);
else fichiers.push([relative(base, chemin).split('\\').join('/'), sha256(await readFile(chemin))]);
}
}
await parcourir(join(base, DOSSIER_CAPTURES));
return fichiers.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
}