gestion_table_tournante_libre/test/navigateur/commandes_documentation.js
Mathieu Benoit 43dbfad8a5 [ADD] docs: photographed walkthrough publishes doc/captures
The screenshots must come from the tested walkthrough, not from a separate
script that can drift. The browser walkthrough runs on the simulated file
system with a driven clock and the direct executor, light theme, 1366 x
700 at scale 1, transitions off, and photographs each step once its
condition holds. make captures writes the images and the manifest; the
plain test run compares them instead. Eighteen captures are published, the
later ones named as postponed. The forged-names scan skips the manifest,
which records the demo names shown on screen.
Checked: red first; 3620 node, 681 browser and 101 long tests green from
the index alone.

--- FR ---

[ADD] docs : parcours photographié, publication de doc/captures

Les captures doivent venir du parcours éprouvé, non d'un script à part qui
dérive. Le parcours navigateur joue sur le système de fichiers simulé,
l'horloge pilotée et l'exécuteur direct, thème clair, 1366 × 700 au facteur
1, transitions coupées, et photographie chaque étape quand sa condition
tient. make captures écrit les images et le manifeste ; la série ordinaire
les compare au lieu de les écrire. Dix-huit captures sont publiées, les
suivantes nommées reportées. Le balayage des noms forgés saute le
manifeste, qui relève les noms de démonstration affichés.
Vérifié : rouge d'abord ; 3620 node, 681 navigateur et 101 longues vertes
depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-09 15:33:59 -04:00

478 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 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));
}