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