From f2cbad29df5c535b949aca129aaa16cc86153900 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 10 Oct 2026 18:54:15 -0400 Subject: [PATCH] [ADD] captures: headless mode, preview bar and machine identity checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three capture rules held only by convention. The browser project must run headless, MODE_AFFICHAGE names that mode, and a screenshot refuses a page whose user agent is not headless. Captures 22 and 23 assert the preview bar has scrolled out of the frame before they are taken. Publishing refuses a text carrying the machine's host or user name, or an instant outside the driven clock; the checks are shown red on invented names, and the default identity is read from node:os. Checked: 3883 node and 767 browser tests from the index alone. --- FR --- [ADD] captures : mode sans tête, barre de l'aperçu, identité de machine Trois règles des captures ne tenaient que par convention. Le projet navigateur tourne sans tête, MODE_AFFICHAGE nomme ce mode, et une capture refuse une page dont l'agent n'est pas sans tête. Les captures 22 et 23 vérifient que la barre de l'aperçu est sortie du cadre. La publication refuse un texte qui porte le nom d'hôte ou d'utilisateur de la machine, ou un instant hors de l'horloge pilotée ; montré rouge sur des noms inventés, l'identité par défaut lue de node:os. Vérifié : 3883 node et 767 navigateur depuis l'index seul. Assisted-by: Claude Opus 5.5 --- src/interface/parcours.navigateur.test.js | 14 + src/interface/transversal.navigateur.test.js | 20 ++ test/chromium.test.js | 11 +- test/navigateur/captures.js | 17 +- test/navigateur/commandes_documentation.js | 253 +++++++++++++++++- .../commandes_documentation.test.js | 174 +++++++++++- 6 files changed, 474 insertions(+), 15 deletions(-) diff --git a/src/interface/parcours.navigateur.test.js b/src/interface/parcours.navigateur.test.js index 09173c8..abd0dcd 100644 --- a/src/interface/parcours.navigateur.test.js +++ b/src/interface/parcours.navigateur.test.js @@ -602,6 +602,18 @@ async function cadrerSur(elements) { await images(2); } +// La barre de l'aperçu — l'enfant de [data-ecran="apercu"] qui porte sa +// commande fermerApercu — est hors de l'image : son bas au bord haut de la +// fenêtre ou au-dessus (§ 19.3, 22 et 23). Une barre collante resterait +// dans la fenêtre au défilement de la feuille. +function barreHorsDeLImage() { + const ecran = dans('[data-ecran="apercu"]'); + let barre = ecran.querySelector('[data-commande="fermerApercu"]'); + expect(barre, 'la commande fermerApercu de l’aperçu').not.toBeNull(); + while (barre.parentElement !== ecran) barre = barre.parentElement; + expect(barre.getBoundingClientRect().bottom, 'le bas de la barre de l’aperçu, au-dessus de la fenêtre').toBeLessThanOrEqual(0); +} + // --- La planche de badges photographiée (§ 19.6) -------------------------------------- // Les images de la planche (§ 19.3, § 19.6) : la feuille de contrôle, page 1 @@ -1454,6 +1466,7 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE const filigrane = dans('[data-impression] [data-feuille] [data-filigrane] [data-marque="filigrane"]'); await cadrerSur([filigrane, dans('[data-impression] [data-feuille] [data-pied]')]); memoire.cadrage = window.scrollY; + barreHorsDeLImage(); await photographier(m, '22-plan-brouillon', { condition: [ '[data-impression] [data-feuille] [data-filigrane] [data-marque="filigrane"]', @@ -1491,6 +1504,7 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE window.scrollTo(0, memoire.cadrage); await images(2); expect(window.scrollY).toBe(memoire.cadrage); + barreHorsDeLImage(); await photographier(m, '23-plan-retenu', { condition: CONDITIONS_FEUILLE, graine: GRAINE }); await cliquer('[data-ecran="apercu"] [data-commande="fermerApercu"]'); diff --git a/src/interface/transversal.navigateur.test.js b/src/interface/transversal.navigateur.test.js index 06964dd..3947274 100644 --- a/src/interface/transversal.navigateur.test.js +++ b/src/interface/transversal.navigateur.test.js @@ -1411,6 +1411,26 @@ describe('les captures d’écran : chaque contrôle échoue en nommant la captu }); }); + // Le mode d'affichage du manifeste (§ 19.4) : Chromium sans tête, que + // navigator.userAgent nomme HeadlessChrome. La page lit un userAgent + // remplacé le temps d'une capture. + test('mode d’affichage : la page des épreuves est sans tête ; un userAgent sans HeadlessChrome refuse la capture en la nommant', async () => { + expect(navigator.userAgent).toContain('HeadlessChrome'); + await avecMontage(async (m, { ecrire }) => { + const avecTete = navigator.userAgent.replace('HeadlessChrome', 'Chrome'); + Object.defineProperty(navigator, 'userAgent', { value: avecTete, configurable: true }); + try { + await expect(photographier(m, '01-premier-ecran', ecrire)).rejects.toThrow( + 'capture 01-premier-ecran : navigateur avec tête, navigator.userAgent sans HeadlessChrome : le mode d’affichage nommé au manifeste est chromium-headless-shell', + ); + } finally { + delete navigator.userAgent; + } + expect(navigator.userAgent).toContain('HeadlessChrome'); + expect((await photographier(m, '01-premier-ecran', ecrire)).mode).toBe('chromium-headless-shell'); + }); + }); + test('relevé des styles : un porteur à demi hors de la fenêtre ne fait rien défiler et n’entre pas dans les styles', async () => { await avecMontage(async (m) => { const releve = async () => { diff --git a/test/chromium.test.js b/test/chromium.test.js index 5cee6dd..72ed55e 100644 --- a/test/chromium.test.js +++ b/test/chromium.test.js @@ -13,7 +13,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import configuration from '../vitest.config.js'; -import { ARGUMENTS_NAVIGATEUR, horsBoucle, optionsChromium, optionsNavigateur, suivreRequetes } from './chromium.js'; +import { ARGUMENTS_NAVIGATEUR, MODE_AFFICHAGE, horsBoucle, optionsChromium, optionsNavigateur, suivreRequetes } from './chromium.js'; import { describe, test } from './lanceur.js'; import * as commandesDocumentation from './navigateur/commandes_documentation.js'; import * as commandesImpression from './navigateur/commandes_impression.js'; @@ -178,6 +178,15 @@ describe('chromium : aucun réseau hors de la boucle locale, le facteur d’éch describe('le projet navigateur : les commandes et le mode des captures d’écran (§ 19.5)', () => { const navigateur = configuration.test.projects.find(({ test: { name } }) => name === 'navigateur'); + // Un seul mode d'affichage, nommé au manifeste (§ 19.4) : le projet + // navigateur lance Chromium sans tête, et photographier refuse une page + // dont le userAgent ne le dit pas. + test('le projet navigateur tourne sans tête, et MODE_AFFICHAGE le nomme', () => { + assert.equal(navigateur.test.browser.headless, true); + assert.deepEqual(navigateur.test.browser.instances, [{ browser: 'chromium' }]); + assert.equal(MODE_AFFICHAGE, 'chromium-headless-shell'); + }); + test('modeCaptures : verifier par défaut, ecrire sous GTT_CAPTURES=ecrire ; une autre valeur lève en la nommant', () => { assert.equal(commandesDocumentation.modeCaptures({}), 'verifier'); assert.equal(commandesDocumentation.modeCaptures({ GTT_CAPTURES: '' }), 'verifier'); diff --git a/test/navigateur/captures.js b/test/navigateur/captures.js index 2ae6cd4..f267c46 100644 --- a/test/navigateur/captures.js +++ b/test/navigateur/captures.js @@ -647,6 +647,11 @@ function conditionVisible(element) { return entiereDansLaFenetre(element) ? true : 'sa boîte déborde de la fenêtre ou d’un ancêtre qui la rogne : l’image la coupe'; } +// Ce que porte le userAgent de Chromium sans tête, le seul mode d'affichage +// des captures, MODE_AFFICHAGE au manifeste (§ 19.4) : les métriques de +// texte diffèrent d'un mode à l'autre. +const SANS_TETE = 'HeadlessChrome'; + // L'identifiant du cadre que pose cliche. const CADRE_DE_CAPTURE = 'gtt-cadre-de-capture'; @@ -682,10 +687,11 @@ async function cliche(m, fichier, racine) { * CAPTURES. Dans l'ordre, chaque contrôle échoue en nommant la capture et * la nature de la faute : capture déclarée ; feuille sans mouvement posée ; * conditions visibles ; collecteur vide ; aucune requête hors de la boucle - * locale ; cadre relu au CADRE, facteur d'échelle 1 ; thème clair ; chaîne - * témoin dans sa tolérance ; aucun chemin hors de la racine de travail - * dans le texte relevé ; aucune faute de libellé ; styles relevés dans les - * deux thèmes, sous leurs seuils ; empreinte d'état. L'entrée se compose, + * locale ; cadre relu au CADRE, facteur d'échelle 1 ; navigateur sans + * tête, que nomme MODE_AFFICHAGE ; thème clair ; chaîne témoin dans sa + * tolérance ; aucun chemin hors de la racine de travail dans le texte + * relevé ; aucune faute de libellé ; styles relevés dans les deux thèmes, + * sous leurs seuils ; empreinte d'état. L'entrée se compose, * puis 'verifier' la compare au manifeste — un écart nomme l'étape et sa * nature —, et 'ecrire' dépose l'image de la fenêtre, prise avant les * relevés, et l'entrée. @@ -719,6 +725,9 @@ export async function photographier(m, fichier, { condition = [], graine = null, throw echec(`cadre ${innerWidth} × ${innerHeight}, attendu ${CADRE.largeur} × ${CADRE.hauteur}`); } if (devicePixelRatio !== 1) throw echec(`facteur d’échelle ${devicePixelRatio}, attendu 1`); + if (!navigator.userAgent.includes(SANS_TETE)) { + throw echec(`navigateur avec tête, navigator.userAgent sans ${SANS_TETE} : le mode d’affichage nommé au manifeste est ${MODE_AFFICHAGE}`); + } const theme = document.documentElement.getAttribute('data-theme'); if (theme !== 'clair') throw echec(`thème « ${theme} », l’image se prend en thème clair`); const { regulier } = await m.polices(); diff --git a/test/navigateur/commandes_documentation.js b/test/navigateur/commandes_documentation.js index a8c1741..f3c85b4 100644 --- a/test/navigateur/commandes_documentation.js +++ b/test/navigateur/commandes_documentation.js @@ -40,10 +40,20 @@ // et test/pdf.js se chargent au premier appel : la configuration du lanceur // importe ce module sans eux. La publication tire son pilote des seules // captures du navigateur : une page de PDF a la taille de sa page. +// +// Le manifeste ne nomme pas sa machine (§ 19.5, contrainte 27). Outre +// fautesDeMachine — clés fermées, chemins hors de la racine neutre —, la +// publication refuse toute chaîne qui porte, comme mot entier, le nom +// d'hôte de la machine, son premier libellé ou son utilisateur, lus de +// node:os — sauf un nom que les sources fixes du scénario portent déjà, un +// prénom de la démonstration par exemple, qu'aucun texte ne distingue de la +// machine —, et tout instant, celui de la capture ou un instant écrit dans +// un texte, que l'horloge pilotée du montage n'atteint pas : une lecture de +// l'horloge du système tombe hors de son intervalle. import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; +import { hostname, tmpdir, userInfo } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -61,6 +71,9 @@ import { lireEntetePng, verifierOrdre, } from '../../scripts/documentation/manifeste.js'; +import { creerHorlogePilotee } from '../../src/application/horloge.js'; +import { libelle } from '../../src/application/libelles.js'; +import { estHorodatage } from '../../src/stockage/journal.js'; import { MODE_AFFICHAGE, suivreRequetes } from '../chromium.js'; /** Les deux modes des captures : verifier compare au manifeste, ecrire @@ -305,6 +318,224 @@ async function deposees(attente) { return depots; } +/** + * L'identité de la machine que la publication refuse de publier : son nom + * d'hôte, et son utilisateur, null quand le système n'en connaît aucun. + * + * @returns {{hote: string, utilisateur: string|null}} + */ +export function machineCourante() { + let utilisateur = null; + try { + utilisateur = userInfo().username; + } catch { + // Un processus sans entrée dans la base des comptes : aucun nom à taire. + } + return { hote: hostname(), utilisateur }; +} + +/** La portée de l'horloge pilotée qu'admet un instant de capture, en ms + * depuis son départ : le parcours l'avance de quelques minutes ; une + * lecture de l'horloge du système tombe des jours plus loin. */ +export const PORTEE_PILOTEE_MS = 24 * 60 * 60 * 1000; + +/** Le premier et le dernier horodatage qu'admet un instant de capture : + * ceux de creerHorlogePilotee à son départ, puis avancée de + * PORTEE_PILOTEE_MS. Les deux portent le décalage du départ. */ +export const INSTANTS_PILOTES = (() => { + const horloge = creerHorlogePilotee(); + const debut = horloge.horodatage(); + horloge.avancer(PORTEE_PILOTEE_MS); + return Object.freeze({ debut, fin: horloge.horodatage() }); +})(); + +// Vrai quand l'instant est un horodatage que l'horloge pilotée atteint : +// sa forme, le décalage du départ, entre début et fin. À forme et décalage +// égaux, l'ordre des textes est celui des instants. +function instantPilote(instant) { + const { debut, fin } = INSTANTS_PILOTES; + return ( + typeof instant === 'string' && + estHorodatage(instant) && + instant.slice(19) === debut.slice(19) && + instant >= debut && + instant <= fin + ); +} + +// Un instant écrit dans un texte, sous les formes de l'application : un +// horodatage ISO, millisecondes et « Z » compris, que seul instantPilote +// admet ; en lettres, la date de format.date suivie, après « , » ou « à », +// de l'heure de format.heure (format.horodatage, dater) ou de HH:MM:SS +// (planche.pied.horodatage). +const INSTANT_ISO = /(? String(n).padStart(2, '0'); + +// La clé AAAA-MM-JJTHH:MM[:SS] d'un instant en lettres, lu par les textes +// mêmes qui l'écrivent : le mois est celui dont format.date redonne la +// date telle quelle, l'heure celle que format.heure redonne telle quelle. +// null quand le texte n'est pas une forme de l'application. +function cleEnLettres([, jourEcrit, nomDuMois, anneeEcrite, heureH, minuteH, heureS, minuteS, seconde]) { + const annee = Number(anneeEcrite); + const jour = jourEcrit === '1er' ? 1 : Number(jourEcrit); + const dateEcrite = `${jourEcrit} ${nomDuMois} ${anneeEcrite}`; + const mois = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find((m) => libelle('format.date', { annee, mois: m, jour }) === dateEcrite); + if (mois === undefined) return null; + const jourCle = `${anneeEcrite}-${deuxChiffres(mois)}-${deuxChiffres(jour)}`; + if (heureS !== undefined) return `${jourCle}T${heureS}:${minuteS}:${seconde}`; + const [heure, minute] = [Number(heureH), Number(minuteH)]; + if (libelle('format.heure', { heure, minute }) !== `${heureH} h ${minuteH}`) return null; + return `${jourCle}T${deuxChiffres(heure)}:${minuteH}`; +} + +// Le premier instant d'un texte que l'horloge pilotée n'atteint pas, tel +// qu'il s'écrit, ou null. Un instant en lettres se lit à l'heure locale du +// départ, à la minute ou à la seconde qu'il porte : sa clé se compare au +// même préfixe de début et de fin. +function instantEtranger(texte) { + for (const [ecrit] of texte.matchAll(INSTANT_ISO)) if (!instantPilote(ecrit)) return ecrit; + const { debut, fin } = INSTANTS_PILOTES; + for (const morceaux of texte.matchAll(INSTANT_EN_LETTRES)) { + const cle = cleEnLettres(morceaux.map((m) => m?.replace(/\s/gu, ' '))); + if (cle !== null && (cle < debut.slice(0, cle.length) || cle > fin.slice(0, cle.length))) return morceaux[0]; + } + return null; +} + +const echapper = (texte) => texte.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Le motif d'un nom pris comme mot entier, sans égard à la casse : ni +// lettre, ni chiffre, ni « _ » ne le touche. +const motEntier = (nom) => new RegExp(`(? (a < b ? -1 : a > b ? 1 : 0)) + .flatMap((cle) => [cle, ...chaines(valeur[cle])]); + } + return []; +} + +// Les sources fixes dont le scénario des captures tire ses textes, sous le +// projet : les réservoirs et le catalogue de la démonstration, les +// démonstrations livrées, les CSV d'exemple, les tables de libellés, et le +// parcours, qui tape les siens. Un dossier se lit fichier par fichier, sans +// ses sous-dossiers ni ses épreuves. +export const SOURCES_DU_SCENARIO = Object.freeze([ + 'src/demo', + 'src/demo/livrees', + 'exemples', + 'src/application/libelles', + 'src/interface/libelles', + 'src/interface/parcours.navigateur.test.js', +]); + +// Le texte d'un fichier : UTF-8 quand ses octets le sont, windows-1252 +// sinon, l'encodage d'un CSV d'exemple. +function decoderSource(octets) { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(octets); + } catch { + return new TextDecoder('windows-1252').decode(octets); + } +} + +/** + * Les textes des SOURCES_DU_SCENARIO, lus sous le projet, dossier par + * dossier dans l'ordre de la liste, chaque dossier dans l'ordre des points + * de code de ses noms. + * + * @returns {Promise} + */ +export async function lireSourcesDuScenario() { + const textes = []; + for (const source of SOURCES_DU_SCENARIO) { + const chemin = join(PROJET, source); + let fichiers = [chemin]; + if (!source.endsWith('.js')) { + const entrees = await readdir(chemin, { withFileTypes: true }); + fichiers = entrees + .filter((entree) => entree.isFile() && !entree.name.includes('.test.')) + .map(({ name }) => name) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + .map((nom) => join(chemin, nom)); + } + for (const fichier of fichiers) textes.push(decoderSource(await readFile(fichier))); + } + return textes; +} + +/** + * Les fautes d'identité d'un manifeste (§ 19.5, contrainte 27) : une + * chaîne, clés comprises, qui porte comme mot entier le nom d'hôte de la + * machine, le premier libellé de ce nom ou son utilisateur ; l'instant + * d'une capture que l'horloge pilotée n'atteint pas (INSTANTS_PILOTES) ; + * un instant écrit dans une chaîne qu'elle n'atteint pas (instantEtranger). + * Un nom que l'une des sources porte déjà comme mot entier ne se cherche + * pas : le texte qui le porte vient du scénario, et le chercher refuserait + * toute machine nommée comme un prénom de la démonstration. Le pilote + * d'abord, puis chaque capture et chaque reportée, dans leur ordre ; une + * faute par sorte de nom — hôte, utilisateur — et par entrée, qui ne cite + * pas le nom ; une par entrée pour les instants de ses chaînes, qui cite le + * premier. Un nom vide ou null ne se cherche pas. + * + * @param {Object} manifeste + * @param {{hote: string|null, utilisateur: string|null}} machine + * @param {{sources?: string[]}} [options] les textes des sources du + * scénario (lireSourcesDuScenario), aucun par défaut + * @returns {Array<{chemin: string, faute: string}>} + */ +export function fautesDIdentite(manifeste, machine, { sources = [] } = {}) { + const noms = []; + const hote = machine?.hote ?? ''; + if (hote !== '') { + noms.push(['nom d’hôte de la machine', hote]); + const court = hote.split('.')[0]; + if (court !== '' && court !== hote) noms.push(['nom d’hôte de la machine', court]); + } + if ((machine?.utilisateur ?? '') !== '') noms.push(['utilisateur de la machine', machine.utilisateur]); + const motifs = noms + .map(([quoi, nom]) => [quoi, motEntier(nom)]) + .filter(([, motif]) => !sources.some((source) => motif.test(source))); + const { debut, fin } = INSTANTS_PILOTES; + const fautes = []; + // Les noms, puis les instants écrits dans les chaînes, hors de la clé + // instant d'une capture, contrôlée à part. + const lire = (chemin, valeur, instantAPart = false) => { + const textes = chaines(valeur); + const vus = new Set(); + for (const [quoi, motif] of motifs) { + if (vus.has(quoi) || !textes.some((texte) => motif.test(texte))) continue; + vus.add(quoi); + fautes.push({ chemin, faute: quoi }); + } + for (const texte of instantAPart ? chaines({ ...valeur, instant: null }) : textes) { + const ecrit = instantEtranger(texte); + if (ecrit === null) continue; + fautes.push({ chemin, faute: `instant « ${ecrit} » dans un texte, hors de l’horloge pilotée, de « ${debut} » à « ${fin} »` }); + break; + } + }; + lire(CHEMIN_MANIFESTE, manifeste.pilote ?? null); + for (const entree of manifeste.captures ?? []) { + lire(cheminImage(entree?.fichier), entree, true); + if (!instantPilote(entree?.instant)) { + const instant = typeof entree?.instant === 'string' ? `« ${entree.instant} »` : JSON.stringify(entree?.instant ?? null); + fautes.push({ chemin: cheminImage(entree?.fichier), faute: `instant ${instant} hors de l’horloge pilotée, de « ${debut} » à « ${fin} »` }); + } + } + for (const entree of manifeste.reportees ?? []) lire(cheminImage(entree?.fichier), entree); + return fautes; +} + // Le pilote du manifeste : le navigateur et sa version, le mode nommé, le // facteur d'échelle et le cadre, communs à toutes les captures du // navigateur. Une page de PDF n'en relève pas : son cadre est sa page à @@ -330,7 +561,7 @@ function piloteDe(context, captures) { // 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) { +async function preparer(context, base, declarees, racineNeutre, machine, sources) { 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'); } @@ -378,7 +609,11 @@ async function preparer(context, base, declarees, racineNeutre) { 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)]; + const fautes = [ + ...verifierOrdre(manifeste, { aVenir }), + ...fautesDeMachine(manifeste, racineNeutre), + ...fautesDIdentite(manifeste, machine, { sources }), + ]; if (fautes.length > 0) { throw new Error(`publication refusée :\n${fautes.map(({ chemin, faute }) => `${chemin} : ${faute}`).join('\n')}`); } @@ -402,16 +637,20 @@ async function preparer(context, base, declarees, racineNeutre) { * 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. + * relevés peuvent nommer (fautesDeMachine). Le troisième argument, que la + * page ne passe jamais, remplace l'identité de la machine que + * fautesDIdentite cherche, machineCourante() par défaut ; les noms que + * lireSourcesDuScenario porte déjà ne s'y cherchent pas. 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 } = {}) { +export async function publierCaptures(context, { declarees, racine = null, racineNeutre } = {}, { machine = machineCourante() } = {}) { const base = racineDe(racine); try { - const { texte, aEcrire, gardees, retirees } = await preparer(context, base, declarees, racineNeutre); + const sources = await lireSourcesDuScenario(); + const { texte, aEcrire, gardees, retirees } = await preparer(context, base, declarees, racineNeutre, machine, sources); 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))); diff --git a/test/navigateur/commandes_documentation.test.js b/test/navigateur/commandes_documentation.test.js index 92ac4e4..d351689 100644 --- a/test/navigateur/commandes_documentation.test.js +++ b/test/navigateur/commandes_documentation.test.js @@ -17,11 +17,17 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; +import { hostname, userInfo } from 'node:os'; import { join } from 'node:path'; import fontkit from '@pdf-lib/fontkit'; import { PDFDocument, grayscale } from 'pdf-lib'; import { describe, test } from '../lanceur.js'; import { lirePolices } from '../polices.js'; +import { creerFormat } from '../../src/application/format.js'; +import { creerHorlogePilotee, creerHorlogeSysteme } from '../../src/application/horloge.js'; +import { libelle } from '../../src/application/libelles.js'; +import { dater } from '../../src/application/libelles/aides.js'; +import { PRENOMS } from '../../src/demo/noms.js'; import { VERSION } from '../../src/version.genere.js'; import { CHEMIN_MANIFESTE, DOSSIER_CAPTURES, lireEntetePng } from '../../scripts/documentation/manifeste.js'; import { @@ -34,7 +40,9 @@ import { dossierEpreuve, effacerDossierEpreuve, empreintesDossier, + fautesDIdentite, lireManifeste, + machineCourante, publierCaptures, tramer, tramerPdf, @@ -250,11 +258,15 @@ const CONTEXTE = Object.freeze({ page: { context: () => ({ browser: () => null } const ECRANS = await construireEcrans(); +// L'instant d'une capture : le départ de l'horloge pilotée, celle du +// montage des épreuves du navigateur. +const INSTANT_PILOTE = creerHorlogePilotee().horodatage(); + // Dépose sous le dossier d'attente une capture de la source donnée : la page // donnée, tramée par tramerPdf, en est l'image, et sa taille le cadre. Une // page de PDF se tire du PDF des épreuves, une capture du navigateur des -// pages d'écran. -async function deposer(racine, { fichier, etape, source, page }) { +// pages d'écran. remplacer remplace des champs de l'entrée. +async function deposer(racine, { fichier, etape, source, page }, remplacer = {}) { const octets = source === 'pdf' ? OCTETS : ECRANS; const { pdf, mode, pages } = await tramerPdf(null, { octetsB64: enBase64(octets), pages: [{ page, fichier }], racine }); const [{ cadre, chemin }] = pages; @@ -268,11 +280,12 @@ async function deposer(racine, { fichier, etape, source, page }) { facteurEchelle: 1, mode: source === 'pdf' ? mode : 'chromium-headless-shell', theme: 'clair', - instant: '2031-05-06T20:15:00-04:00', + instant: INSTANT_PILOTE, texte: [fichier], styles: { clair: [], sombre: [] }, etat: null, pdf: source === 'pdf' ? pdf : null, + ...remplacer, }; return deposerCapture(null, { fichier, entree, chemin, racine }); } @@ -330,3 +343,158 @@ describe('publierCaptures : une page de PDF à côté des captures du navigateur }); }); }); + +// Une machine inventée : ni son hôte, ni son domaine, ni son utilisateur +// n'existent ailleurs dans le dépôt. Le nom d'hôte est pleinement qualifié ; +// un texte peut ne porter que son premier libellé. +const MACHINE = Object.freeze({ hote: 'cormoran-ecluse9.tourbiere.invalid', utilisateur: 'rubanier' }); + +describe('publierCaptures : ni l’hôte, ni l’utilisateur, ni un instant étranger (§ 19.5, contrainte 27)', () => { + test('machineCourante lit le nom d’hôte et l’utilisateur de node:os, deux chaînes', () => { + const { hote, utilisateur } = machineCourante(); + assert.equal(typeof hote, 'string'); + assert.ok(utilisateur === null || typeof utilisateur === 'string', String(utilisateur)); + // Les valeurs sont celles du système, lues au moment de l'appel. + assert.equal(hote, hostname()); + let attendu = null; + try { + attendu = userInfo().username; + } catch { + // Sans entrée dans la base des comptes, machineCourante rend null. + } + assert.equal(utilisateur, attendu); + }); + + test('publierCaptures refuse un texte qui porte le nom d’hôte ou l’utilisateur de la machine, et un instant hors de l’horloge pilotée', async (contexte) => { + if (!canevasDisponible()) contexte.skip(SANS_CANEVAS); + const declarees = ['01-premier-ecran', '24-badges-controle']; + // Dépose les deux captures, la première sous les champs remplacés, et + // rend la publication de la machine inventée. + const publier = (racine, remplacer, pdf = {}) => + (async () => { + await deposer(racine, { fichier: '01-premier-ecran', etape: '1', source: 'navigateur', page: 1 }, remplacer); + await deposer(racine, { fichier: '24-badges-controle', etape: '12', source: 'pdf', page: 1 }, pdf); + return publierCaptures(CONTEXTE, { declarees, racine, racineNeutre: RACINE_NEUTRE }, { machine: MACHINE }); + })(); + const refus = [ + [{ texte: ['Poste CORMORAN-ECLUSE9.tourbiere.invalid'] }, {}, /doc\/captures\/01-premier-ecran\.png : nom d’hôte de la machine/], + [{ texte: ['Ouvert sur cormoran-ecluse9, salle 2'] }, {}, /doc\/captures\/01-premier-ecran\.png : nom d’hôte de la machine/], + [{ texte: ['Dossier de travail', 'session rubanier'] }, {}, /doc\/captures\/01-premier-ecran\.png : utilisateur de la machine/], + [{ styles: { clair: [{ jeton: 'rubanier' }], sombre: [] } }, {}, /doc\/captures\/01-premier-ecran\.png : utilisateur de la machine/], + [{ instant: creerHorlogeSysteme().horodatage() }, {}, /doc\/captures\/01-premier-ecran\.png : instant « [^»]+ » hors de l’horloge pilotée/], + [{}, { instant: '2031-05-06T20:15:00-04:00' }, /doc\/captures\/24-badges-controle\.png : instant « 2031-05-06T20:15:00-04:00 » hors de l’horloge pilotée/], + [{ instant: '2026-10-06T18:59:59-04:00' }, {}, /instant « 2026-10-06T18:59:59-04:00 » hors de l’horloge pilotée/], + [{ instant: '2026-10-06T23:00:00+00:00' }, {}, /instant « 2026-10-06T23:00:00\+00:00 » hors de l’horloge pilotée/], + [{ instant: null }, {}, /instant null hors de l’horloge pilotée/], + ]; + for (const [remplacer, pdf, motif] of refus) { + await dansUnDossier(async (racine) => { + await assert.rejects(publier(racine, remplacer, pdf), motif, JSON.stringify(remplacer)); + assert.deepEqual(await empreintesDossier(null, { racine }), [], JSON.stringify(remplacer)); + }); + } + // Un mot qui ne fait que contenir l'un des noms passe, comme un instant + // que l'horloge pilotée atteint ; la publication se fait. + await dansUnDossier(async (racine) => { + const horloge = creerHorlogePilotee(); + horloge.avancer(3 * 60 * 1000); + const bilan = await publier(racine, { texte: ['rubaniers', 'cormoran-ecluse90'], instant: horloge.horodatage() }); + assert.deepEqual(bilan.ecrites, declarees); + }); + }); + + test('fautesDIdentite ne cherche pas un nom que les sources du scénario portent déjà comme mot entier', () => { + const manifeste = { + pilote: null, + captures: [{ fichier: '01-premier-ecran', instant: INSTANT_PILOTE, texte: ['Ombrelin Bruvelin, table 3'] }], + reportees: [], + }; + const machine = { hote: 'ombrelin.local', utilisateur: 'ombrelin' }; + assert.deepEqual(fautesDIdentite(manifeste, machine, { sources: [] }), [ + { chemin: 'doc/captures/01-premier-ecran.png', faute: 'nom d’hôte de la machine' }, + { chemin: 'doc/captures/01-premier-ecran.png', faute: 'utilisateur de la machine' }, + ]); + assert.deepEqual(fautesDIdentite(manifeste, machine, { sources: ['Ombrelin;Bruvelin;3'] }), []); + // Un nom que la source ne porte que dans un mot plus long reste cherché. + assert.deepEqual(fautesDIdentite(manifeste, machine, { sources: ['Ombrelines'] }).length, 2); + // L'instant de la capture se contrôle à part : une seule faute le nomme. + const etranger = { ...manifeste, captures: [{ ...manifeste.captures[0], instant: '2031-05-06T20:15:00-04:00' }] }; + assert.deepEqual( + fautesDIdentite(etranger, machine, { sources: ['Ombrelin'] }).map(({ faute }) => faute.split(' hors ')[0]), + ['instant « 2031-05-06T20:15:00-04:00 »'], + ); + }); + + test('une machine nommée comme un prénom de la démonstration publie ; un nom absent des sources du scénario reste refusé', async (contexte) => { + if (!canevasDisponible()) contexte.skip(SANS_CANEVAS); + const declarees = ['01-premier-ecran', '24-badges-controle']; + // Le prénom vient de la liste de la démonstration, que le scénario + // affiche : l'épreuve n'en écrit aucun. + const prenom = PRENOMS[0]; + const texte = [`${prenom} Bruvelin`, 'session rubanier']; + const publier = (racine, machine) => + (async () => { + await deposer(racine, { fichier: '01-premier-ecran', etape: '1', source: 'navigateur', page: 1 }, { texte }); + await deposer(racine, { fichier: '24-badges-controle', etape: '12', source: 'pdf', page: 1 }); + return publierCaptures(CONTEXTE, { declarees, racine, racineNeutre: RACINE_NEUTRE }, { machine }); + })(); + for (const machine of [ + { hote: `${prenom.toLowerCase()}-poste.invalid`, utilisateur: prenom.toLowerCase() }, + { hote: `${prenom.toLowerCase()}.local`, utilisateur: null }, + ]) { + await dansUnDossier(async (racine) => { + const bilan = await publier(racine, machine); + assert.deepEqual(bilan.ecrites, declarees, JSON.stringify(machine)); + }); + } + await dansUnDossier(async (racine) => { + await assert.rejects( + publier(racine, { hote: `${prenom.toLowerCase()}.local`, utilisateur: 'rubanier' }), + /doc\/captures\/01-premier-ecran\.png : utilisateur de la machine/, + ); + assert.deepEqual(await empreintesDossier(null, { racine }), []); + }); + }); + + test('publierCaptures refuse un instant écrit dans un texte hors de l’horloge pilotée, sous les formes de l’application ; un instant qu’elle atteint passe', async (contexte) => { + if (!canevasDisponible()) contexte.skip(SANS_CANEVAS); + const declarees = ['01-premier-ecran', '24-badges-controle']; + const publier = (racine, remplacer, pdf = {}) => + (async () => { + await deposer(racine, { fichier: '01-premier-ecran', etape: '1', source: 'navigateur', page: 1 }, remplacer); + await deposer(racine, { fichier: '24-badges-controle', etape: '12', source: 'pdf', page: 1 }, pdf); + return publierCaptures(CONTEXTE, { declarees, racine, racineNeutre: RACINE_NEUTRE }, { machine: MACHINE }); + })(); + const format = creerFormat(libelle); + const systeme = creerHorlogeSysteme().horodatage(); + const dansUnTexte = (fichier) => new RegExp(`doc/captures/${fichier}\\.png : instant « [^»]+ » dans un texte, hors de l’horloge pilotée`); + const refus = [ + [{ texte: ['Aperçu', format.horodatage(systeme)] }, {}, dansUnTexte('01-premier-ecran')], + [{}, { texte: [libelle('planche.pied.horodatage', { horodatage: systeme })] }, dansUnTexte('24-badges-controle')], + [{ texte: [`Modifié ${dater(systeme, libelle)}`] }, {}, dansUnTexte('01-premier-ecran')], + [{ texte: [`journal ${systeme}`] }, {}, dansUnTexte('01-premier-ecran')], + [{ styles: { clair: [{ releve: '2026-10-10T18:03:00.000Z' }], sombre: [] } }, {}, dansUnTexte('01-premier-ecran')], + [{ texte: [format.horodatage('2026-10-06T18:59:00-04:00')] }, {}, /instant « 6 octobre 2026, 18 h 59 » dans un texte/], + [{}, { texte: [libelle('planche.pied.horodatage', { horodatage: '2026-10-07T19:00:01-04:00' })] }, /instant « 7 octobre 2026 à 19:00:01 » dans un texte/], + ]; + for (const [remplacer, pdf, motif] of refus) { + await dansUnDossier(async (racine) => { + await assert.rejects(publier(racine, remplacer, pdf), motif, JSON.stringify([remplacer, pdf])); + assert.deepEqual(await empreintesDossier(null, { racine }), [], JSON.stringify(remplacer)); + }); + } + // Les mêmes formes d'un instant que l'horloge pilotée atteint, et une + // date seule, sans heure, passent. + await dansUnDossier(async (racine) => { + const horloge = creerHorlogePilotee(); + horloge.avancer(2 * 60 * 1000); + const instant = horloge.horodatage(); + const bilan = await publier( + racine, + { texte: [format.horodatage(instant), `Modifié ${dater(instant, libelle)}`, instant, 'Soirée du 17 mai 2031'] }, + { texte: [libelle('planche.pied.horodatage', { horodatage: instant })] }, + ); + assert.deepEqual(bilan.ecrites, declarees); + }); + }); +});