diff --git a/Makefile b/Makefile index d02718d..1f6920c 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,7 @@ verifier: ## Contrôle la version, le nom du livrable et le changelog (§ 18.5) $(AVEC_NODE) node scripts/version.js controler .PHONY: essai_demarrage -essai_demarrage: ## Lance Electron, lit le titre de sa fenêtre et ferme +essai_demarrage: ## Construit la page et joue une séance par gestes dans Electron, en mode portable $(AVEC_NODE) node scripts/essai_demarrage.js ifeq ($(OS),Windows_NT) diff --git a/scripts/essai_demarrage.js b/scripts/essai_demarrage.js index 1e8a223..9fbeadb 100644 --- a/scripts/essai_demarrage.js +++ b/scripts/essai_demarrage.js @@ -2,90 +2,382 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Essai de démarrage : construit la page, lance la vraie coquille Electron -// par Playwright, attend sa fenêtre, lit le titre, le bandeau et le premier -// écran, puis ferme. Il prouve ce que la coquille factice des épreuves -// (test/coquille.test.js) ne peut pas dire : qu'Electron démarre sur ce -// système, avec ses bibliothèques, y charge la page, et que l'application y -// monte jusqu'à la liste des événements. +// par Playwright, et y joue une courte séance par gestes. Il prouve ce que la +// coquille factice des épreuves (test/coquille.test.js) ne peut pas dire : +// qu'Electron démarre sur ce système, avec ses bibliothèques, y charge la +// page, que l'application y monte, que la détermination du dossier de +// travail retient le mode portable, qu'un travailleur de génération naît et +// rend son issue dans la page chargée sous file://, et que la sortie rend +// les verrous. // // node scripts/essai_demarrage.js // -// Le titre de la fenêtre doit valoir le nom suivi de la version affichée, et -// le bandeau — qui porte aussi provenance, thème, mode et « À propos » — -// contenir la version affichée. +// La séance, dans l'ordre : +// 1. le titre de la fenêtre vaut le nom suivi de la version affichée, le +// bandeau porte la version affichée, le collecteur d'erreurs est posé ; +// 2. la liste affiche le dossier de travail exe/data ; créer un événement, +// qui s'ouvre, revenir à la liste, puis charger la petite +// démonstration, qui s'ouvre ; +// 3. le mode portable : leurs deux .gtt.json sous exe/data, aucun sous home +// ni sous temp ; +// 4. « Modifier » prend un verrou sous exe/data ; le compte d'arrêt passe à +// 2 000 mouvements par son champ, puis « Générer », dans la page chargée +// sous file://, fait naître un travailleur et ajoute des propositions, +// que le fichier d'état porte, sans refus dans la section ni erreur +// recueillie ; +// 5. le collecteur est vide ; la fenêtre se ferme, le processus sort, et +// aucun .gtt.verrou ne reste sous exe/data. // // La coquille se lance comme publiée par le lanceur portable dans un dossier -// neuf, créé sous node_modules/.cache/ et retiré en sortant : la sonde de la -// détermination y écrit son dossier de travail. Sans lui, elle créerait le -// dossier du produit dans les vrais Documents de la machine ; et un dossier -// sous le dossier temporaire du système relève des données applicatives -// (§ 8.6, point 2), qui renvoient aux mêmes Documents. +// neuf, créé par mkdtemp sous le dossier temporaire du système et retiré en +// sortant. Il y prend trois dossiers distincts : exe, que le lanceur publie +// (PORTABLE_EXECUTABLE_DIR) ; home, le dossier personnel (HOME) ; temp, le +// dossier temporaire (TMPDIR, TEMP, TMP). XDG_CONFIG_HOME est retiré : sous +// Linux, les données applicatives et les Documents se déduisent alors de +// home seul. Sous Windows, le profil (USERPROFILE), AppData et LOCALAPPDATA +// passent eux aussi sous home : le temporaire du poste, où mkdtemp prend la +// racine, se range d'ordinaire sous LOCALAPPDATA. +// La coquille range le dossier temporaire et LOCALAPPDATA parmi les données +// applicatives (electron/fichiers.js, § 8.6 point 2) : un exe placé sous +// l'un d'eux ferait retenir les Documents. Sous Windows, les Documents sont +// un dossier connu qu'aucune variable ne déplace : leur sonde y créerait le +// dossier du produit, que la suppression de la racine ne retire pas. Avant +// tout lancement, l'essai refuse donc un exe que l'une des données +// applicatives prévisibles de la coquille porterait. // // Sous Linux sans session graphique, Electron se lance sur la plateforme // Ozone « headless » de Chromium : la fenêtre existe et la page s'y charge // et s'y met en page, sans serveur d'affichage. Sous root, ou avec // GTT_SANS_BAC_A_SABLE=1 là où le système refuse les espaces de noms // d'utilisateur — un conteneur —, Electron se lance sans son bac à sable. -// Sort à 0 sur un démarrage conforme, à 1 sinon. +// Sort à 0 sur un démarrage conforme, à 1 sinon : chaque constat s'imprime, +// et le premier échec arrête la séance. import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path, { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { _electron } from 'playwright'; +import { estSous } from '../src/stockage/dossier_travail.js'; import { VERSION } from '../src/version.genere.js'; import { titreAvecVersion } from '../src/interface/libelles.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); const ATTENTE_MS = 60_000; +const SONDAGE_MS = 100; -const sousRoot = typeof process.getuid === 'function' && process.getuid() === 0; -const sansBacASable = sousRoot || process.env.GTT_SANS_BAC_A_SABLE === '1'; -const sansEcran = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY; +// Le nom de l'événement créé, la démonstration chargée, le compte d'arrêt. +const NOM_EVENEMENT = "Soirée d'essai"; +const DEMONSTRATION = 'petite'; +const ARRET = 2000; -// npm est un script .cmd sous Windows, que Node ne lance que par un shell. -execFileSync('npm', ['run', '--silent', 'build'], { - cwd: RACINE, - stdio: 'inherit', - shell: process.platform === 'win32', -}); - -const cache = join(RACINE, 'node_modules', '.cache'); -mkdirSync(cache, { recursive: true }); -const publie = mkdtempSync(join(cache, 'essai-demarrage-')); - -const attendu = titreAvecVersion(VERSION.affichee); -let conforme = false; -try { - const application = await _electron.launch({ - args: [ - ...(sansBacASable ? ['--no-sandbox'] : []), - ...(sansEcran ? ['--ozone-platform=headless'] : []), - '.', - ], - cwd: RACINE, - env: { ...process.env, PORTABLE_EXECUTABLE_DIR: publie }, - timeout: ATTENTE_MS, - }); - try { - const fenetre = await application.firstWindow({ timeout: ATTENTE_MS }); - // La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté : - // le bandeau s'y voit par sa largeur minimale, la liste n'y est que - // présente. - await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS }); - await fenetre.locator('[data-ecran="accueil"]').waitFor({ state: 'attached', timeout: ATTENTE_MS }); - const titre = await fenetre.title(); - const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim(); - const erreurs = await fenetre.evaluate(() => globalThis.__gttErreurs ?? null); - conforme = titre === attendu && bandeau.includes(VERSION.affichee) && Array.isArray(erreurs) && erreurs.length === 0; - console.log(`Titre de la fenêtre : ${titre}`); - console.log(`Bandeau : ${bandeau}`); - console.log(`Erreurs recueillies : ${JSON.stringify(erreurs)}`); - if (!conforme) console.error(`Attendu : titre « ${attendu} », bandeau portant ${VERSION.affichee}, collecteur posé et vide`); - } finally { - await application.close(); - } -} finally { - rmSync(publie, { recursive: true, force: true }); +/** + * Les trois dossiers distincts de l'essai, sous racine. + * @param {string} racine + * @param {path.PlatformPath} [chemins] node:path, ou l'une de ses variantes + * @returns {{exe: string, home: string, temp: string}} + */ +export function dossiersDEssai(racine, chemins = path) { + return { exe: chemins.join(racine, 'exe'), home: chemins.join(racine, 'home'), temp: chemins.join(racine, 'temp') }; } -console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.'); -process.exit(conforme ? 0 : 1); + +/** + * L'environnement de la coquille : une copie de env où le lanceur portable + * publie exe, où home est le dossier personnel (HOME, USERPROFILE) et porte + * AppData (APPDATA) et LOCALAPPDATA, où temp est le dossier temporaire, sans + * XDG_CONFIG_HOME. Sous X11 (DISPLAY posé) sans XAUTHORITY, Xlib cherche son + * fichier d'autorisation dans le dossier personnel : XAUTHORITY nomme alors + * celui du dossier personnel de env, que le remplacement de HOME ferait + * perdre. env n'est pas modifié. + * @param {Record} env + * @param {{exe: string, home: string, temp: string}} dossiers + * @param {path.PlatformPath} [chemins] node:path, ou l'une de ses variantes + * @returns {Record} + */ +export function environnementDEssai(env, { exe, home, temp }, chemins = path) { + const { XDG_CONFIG_HOME: _retire, ...garde } = env; + const autorisation = + garde.DISPLAY && !garde.XAUTHORITY && garde.HOME ? { XAUTHORITY: chemins.join(garde.HOME, '.Xauthority') } : {}; + return { + ...garde, + ...autorisation, + PORTABLE_EXECUTABLE_DIR: exe, + HOME: home, + USERPROFILE: home, + APPDATA: chemins.join(home, 'AppData', 'Roaming'), + LOCALAPPDATA: chemins.join(home, 'AppData', 'Local'), + TMPDIR: temp, + TEMP: temp, + TMP: temp, + }; +} + +/** + * Les données applicatives que la coquille lancée sous env retiendrait et + * qui portent l'exécutable publié : vide quand le mode portable reste + * possible. La liste est celle du § 8.6 point 2, que decrireSysteme + * (electron/fichiers.js) compose : AppData, le LOCALAPPDATA de env, le + * temporaire ; estSous compare comme le système. AppData prend chaque + * valeur que app.getPath peut rendre : sous Windows, l'APPDATA de env et + * celle de poste, dossier connu qu'aucune variable ne déplace ; sous macOS, + * Library/Application Support sous l'un et l'autre HOME ; ailleurs, + * XDG_CONFIG_HOME ou .config sous HOME. Le temporaire est TMP, puis TEMP, + * sous Windows, TMPDIR, puis /tmp, ailleurs. L'ordre est celui de cette + * liste, sans doublon ; sans exécutable publié, rien ne le porte. + * @param {Record} env l'environnement de la coquille + * @param {Record} poste l'environnement du poste + * @param {string} plateforme process.platform + * @returns {string[]} + */ +export function donneesApplicativesSurExe(env, poste, plateforme) { + const executable = env.PORTABLE_EXECUTABLE_DIR || null; + if (executable === null) return []; + const chemins = plateforme === 'win32' ? path.win32 : path.posix; + const sousMaison = (maison, ...segments) => (maison ? chemins.join(maison, ...segments) : undefined); + const appData = + plateforme === 'win32' + ? [env.APPDATA, poste.APPDATA] + : plateforme === 'darwin' + ? [env.HOME, poste.HOME].map((maison) => sousMaison(maison, 'Library', 'Application Support')) + : [env.XDG_CONFIG_HOME || sousMaison(env.HOME, '.config')]; + const temp = plateforme === 'win32' ? env.TMP || env.TEMP : env.TMPDIR || '/tmp'; + const systeme = { insensibleCasse: plateforme === 'win32' || plateforme === 'darwin', separateur: chemins.sep }; + const portent = [...appData, env.LOCALAPPDATA, temp].filter( + (dossier) => typeof dossier === 'string' && dossier !== '' && estSous(executable, dossier, systeme), + ); + return [...new Set(portent)]; +} + +// Les fichiers sous dossier, récursivement, dont le nom finit par suffixe, +// relatifs à dossier et triés ; aucun quand dossier n'existe pas. +function fichiersSous(dossier, suffixe) { + let entrees; + try { + entrees = readdirSync(dossier, { recursive: true, withFileTypes: true }); + } catch (erreur) { + if (erreur?.code === 'ENOENT') return []; + throw erreur; + } + return entrees + .filter((e) => e.isFile() && e.name.endsWith(suffixe)) + .map((e) => relative(dossier, join(e.parentPath, e.name))) + .sort(); +} + +// Sonde condition() jusqu'à ce qu'elle rende une valeur vraie, qu'elle rend ; +// au-delà d'ATTENTE_MS, rend la dernière valeur lue. +async function attendre(condition) { + const limite = performance.now() + ATTENTE_MS; + let valeur = await condition(); + while (!valeur && performance.now() < limite) { + await new Promise((fin) => setTimeout(fin, SONDAGE_MS)); + valeur = await condition(); + } + return valeur; +} + +// La charge d'un fichier d'état (§ 8.9), ou null tant qu'il ne se lit pas. +function lireEtat(chemin) { + try { + return JSON.parse(readFileSync(chemin, 'utf8')).charge ?? null; + } catch { + return null; + } +} + +class EchecDEssai extends Error {} + +// Imprime un constat ; un constat faux arrête la séance. +function constater(libelle, vrai, detail) { + console.log(`${vrai ? '[conforme]' : '[ÉCHEC] '} ${libelle}${detail === undefined ? '' : ` : ${detail}`}`); + if (!vrai) throw new EchecDEssai(libelle); +} + +async function seance(application, dossiers) { + const data = join(dossiers.exe, 'data'); + const fenetre = await application.firstWindow({ timeout: ATTENTE_MS }); + // La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté : + // la taille de la coquille lui est rendue, pour que chaque geste atteigne + // un élément visible. + await application.evaluate(({ BrowserWindow }) => { + for (const w of BrowserWindow.getAllWindows()) w.setContentSize(1366, 768); + }); + const collecteur = () => fenetre.evaluate(() => globalThis.__gttErreurs ?? null); + + // 1. Titre, bandeau, collecteur. + await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS }); + await fenetre.locator('[data-ecran="accueil"]').waitFor({ timeout: ATTENTE_MS }); + const titre = await fenetre.title(); + constater('titre de la fenêtre', titre === titreAvecVersion(VERSION.affichee), titre); + const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim(); + constater('bandeau portant la version affichée', bandeau.includes(VERSION.affichee), bandeau); + const erreursAuDepart = await collecteur(); + constater('collecteur posé et vide', Array.isArray(erreursAuDepart) && erreursAuDepart.length === 0, JSON.stringify(erreursAuDepart)); + + // 2. Le dossier affiché, un événement créé, la démonstration chargée. + const affiche = (await fenetre.locator('[data-dossier-travail]').textContent()).trim(); + constater('dossier de travail affiché', resolve(affiche) === resolve(data), affiche); + await fenetre.locator('[data-ecran="accueil"] input[data-champ="nom"]').fill(NOM_EVENEMENT); + await fenetre.locator('[data-ecran="accueil"] [data-commande="creer"]').click(); + await fenetre.locator('[data-ecran="evenement"]').waitFor({ timeout: ATTENTE_MS }); + constater('événement créé et ouvert', true, NOM_EVENEMENT); + await fenetre.locator('[data-commande="liste"]').first().click(); + await fenetre.locator('[data-ecran="accueil"]').waitFor({ timeout: ATTENTE_MS }); + constater('événement créé dans la liste', (await fenetre.locator('[data-evenement]').count()) === 1); + await fenetre.locator(`[data-demonstration="${DEMONSTRATION}"]`).click(); + await fenetre.locator('[data-ecran="evenement"]').waitFor({ timeout: ATTENTE_MS }); + constater('démonstration ouverte', true, DEMONSTRATION); + + // 3. Le mode portable. + const etats = fichiersSous(data, '.gtt.json'); + constater('deux .gtt.json sous exe/data', etats.length === 2, etats.join(', ')); + const egares = [dossiers.home, dossiers.temp].flatMap((d) => fichiersSous(d, '.gtt.json').map((f) => join(d, f))); + constater('aucun .gtt.json sous home ni temp', egares.length === 0, egares.join(', ') || 'aucun'); + + // 4. « Modifier », le compte d'arrêt, « Générer ». + await fenetre.locator('[data-commande="modifier"]').first().click(); + const verrous = await attendre(() => { + const trouves = fichiersSous(data, '.gtt.verrou'); + return trouves.length > 0 ? trouves : null; + }); + constater('« Modifier » prend un verrou sous exe/data', verrous !== null, (verrous ?? []).join(', ')); + const avant = new Set(etats); + const ouvert = fichiersSous(data, '.gtt.json').find((f) => { + const etat = lireEtat(join(data, f)); + return etat !== null && etat.evenement?.nom !== NOM_EVENEMENT && avant.has(f); + }); + constater("fichier d'état de la démonstration", ouvert !== undefined, ouvert); + const cheminOuvert = join(data, ouvert); + await fenetre.locator('[data-commande="section"][data-cible="generation"]').click(); + const section = fenetre.locator('[data-section="generation"]'); + await section.waitFor({ timeout: ATTENTE_MS }); + const arret = section.locator('input[data-champ="generation.arret"]'); + await arret.fill(String(ARRET)); + await arret.press('Tab'); + const regle = await attendre(() => lireEtat(cheminOuvert)?.reglages?.generation?.arret === ARRET); + constater('compte d’arrêt écrit', regle, String(lireEtat(cheminOuvert)?.reglages?.generation?.arret)); + const propositionsAvant = lireEtat(cheminOuvert)?.propositions?.length ?? 0; + // Chaque génération prend un travailleur neuf (src/main.js) : Playwright + // en signale la naissance, qui attend son rattachement avant d'exécuter + // son script. Un exécuteur qui calcule dans la page n'en fait naître aucun. + constater('page chargée sous file://', fenetre.url().startsWith('file:'), fenetre.url()); + const travailleurs = []; + fenetre.on('worker', (travailleur) => travailleurs.push(travailleur.url())); + await section.locator('[data-commande="generer"]').click(); + // La première fin qui se montre : l'issue, un refus dans la section, ou + // une erreur recueillie — un travailleur qui ne se charge pas n'affiche + // ni l'une ni l'autre, et le collecteur seul le dit. + const texte = async (selecteur) => (await section.locator(selecteur).first().textContent()).replace(/\s+/g, ' ').trim(); + const fin = await attendre(async () => { + if ((await section.locator('[data-refus]').count()) > 0) return { issue: false, detail: await texte('[data-refus]') }; + const recueillies = await collecteur(); + if (recueillies?.length > 0) return { issue: false, detail: JSON.stringify(recueillies) }; + if ((await section.locator('[data-issue]').count()) > 0) return { issue: true, detail: await texte('[data-issue]') }; + return null; + }); + constater('génération achevée, sans refus ni erreur', fin?.issue === true, fin?.detail ?? `rien en ${ATTENTE_MS} ms`); + constater( + 'un travailleur de génération né après « Générer »', + travailleurs.length > 0, + travailleurs.length === 0 ? 'aucun' : travailleurs.map((url) => url.slice(0, 64)).join(', '), + ); + const ajoutees = await attendre(() => { + const propositions = lireEtat(cheminOuvert)?.propositions ?? []; + return propositions.length > propositionsAvant ? propositions.slice(propositionsAvant) : null; + }); + constater( + `propositions ajoutées au fichier d'état, chacune arrêtée à ${ARRET} mouvements`, + ajoutees !== null && ajoutees.every((p) => p.arret === ARRET), + ajoutees === null ? 'aucune' : ajoutees.map((p) => `${p.id}:${p.arret}`).join(', '), + ); + + // 5. Collecteur, fermeture, verrous. + const erreurs = await collecteur(); + constater('collecteur vide après la séance', Array.isArray(erreurs) && erreurs.length === 0, JSON.stringify(erreurs)); + const sortie = application.waitForEvent('close', { timeout: ATTENTE_MS }); + await application.evaluate(({ BrowserWindow }) => { + for (const w of BrowserWindow.getAllWindows()) w.close(); + }); + await sortie; + constater('la fermeture de la fenêtre termine la coquille', true); + const restants = fichiersSous(data, '.gtt.verrou'); + constater('aucun .gtt.verrou sous exe/data après la sortie', restants.length === 0, restants.join(', ') || 'aucun'); +} + +async function principal() { + const sousRoot = typeof process.getuid === 'function' && process.getuid() === 0; + const sansBacASable = sousRoot || process.env.GTT_SANS_BAC_A_SABLE === '1'; + const sansEcran = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY; + + // npm est un script .cmd sous Windows, que Node ne lance que par un shell. + execFileSync('npm', ['run', '--silent', 'build'], { + cwd: RACINE, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + const racine = mkdtempSync(join(tmpdir(), 'gtt-essai-demarrage-')); + const dossiers = dossiersDEssai(racine); + const env = environnementDEssai(process.env, dossiers); + for (const dossier of [...Object.values(dossiers), env.APPDATA, env.LOCALAPPDATA]) mkdirSync(dossier, { recursive: true }); + let conforme = false; + let retrait = null; + try { + // Un exe sous des données applicatives ferait retenir les Documents du + // poste, où la sonde écrirait : rien n'est lancé. + const portent = donneesApplicativesSurExe(env, process.env, process.platform); + constater( + 'exe hors des données applicatives de la coquille', + portent.length === 0, + portent.length === 0 ? dossiers.exe : `${dossiers.exe} sous ${portent.join(', ')}`, + ); + const { _electron } = await import('playwright'); + const application = await _electron.launch({ + args: [...(sansBacASable ? ['--no-sandbox'] : []), ...(sansEcran ? ['--ozone-platform=headless'] : []), '.'], + cwd: RACINE, + env, + timeout: ATTENTE_MS, + }); + try { + await seance(application, dossiers); + conforme = true; + } finally { + await application.close().catch(() => {}); + } + } catch (erreur) { + if (!(erreur instanceof EchecDEssai)) console.error(erreur); + } finally { + // Un retrait refusé (EBUSY sous Windows, un fichier encore ouvert) ne + // masque pas le verdict : il s'imprime après lui, en avertissement, et + // ne change pas le code de sortie. + try { + rmSync(racine, { recursive: true, force: true }); + } catch (erreur) { + retrait = erreur; + } + } + console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.'); + if (retrait !== null) console.warn(`Avertissement : ${racine} reste en place, son retrait a échoué : ${retrait.message}`); + process.exit(conforme ? 0 : 1); +} + +/** + * Vrai quand script, le fichier que node exécute (process.argv[1]), est + * fichier, ce module. Les deux chemins se comparent résolus par realpath : + * un script atteint par un lien symbolique est le fichier qu'il désigne. Un + * script absent ou illisible n'est pas ce module. + * @param {string|undefined} script + * @param {string} [fichier] + * @returns {boolean} + */ +export function estLanceDirectement(script, fichier = fileURLToPath(import.meta.url)) { + if (script === undefined) return false; + try { + return realpathSync(script) === realpathSync(fichier); + } catch { + return false; + } +} + +// L'essai ne part que lorsque node exécute ce fichier ; l'épreuve qui +// l'importe n'en lit que les fonctions. +if (estLanceDirectement(process.argv[1])) await principal(); diff --git a/scripts/essai_demarrage.test.js b/scripts/essai_demarrage.test.js new file mode 100644 index 0000000..72eccef --- /dev/null +++ b/scripts/essai_demarrage.test.js @@ -0,0 +1,153 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'environnement où l'essai de démarrage lance la coquille (§ 8.6) : trois +// dossiers distincts sous la racine de l'essai, aucun sous un autre ; le +// lanceur portable publie exe, le poste a home pour dossier personnel et +// temp pour dossier temporaire, et la configuration XDG du poste réel n'y +// entre pas. Le module se charge sans rien lancer : l'essai ne part que +// lorsque node l'exécute. +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path, { dirname, join, relative, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { estSous } from '../src/stockage/dossier_travail.js'; +import { describe, test } from '../test/lanceur.js'; +import { donneesApplicativesSurExe, dossiersDEssai, environnementDEssai, estLanceDirectement } from './essai_demarrage.js'; + +const RACINE_ESSAI = join('/', 'racine-essai', 'gtt-essai-demarrage-x'); + +// Vrai quand b est a ou se trouve sous a. +const sous = (a, b) => { + const chemin = relative(a, b); + return chemin === '' || (!chemin.startsWith('..') && !isAbsolute(chemin)); +}; + +describe('essai de démarrage : dossiers et environnement (§ 8.6)', () => { + test('trois dossiers distincts sous la racine, aucun sous un autre', () => { + const dossiers = dossiersDEssai(RACINE_ESSAI); + const noms = ['exe', 'home', 'temp']; + assert.deepEqual(Object.keys(dossiers).sort(), noms); + const chemins = noms.map((nom) => dossiers[nom]); + assert.equal(chemins.length, 3); + for (const chemin of chemins) assert.ok(sous(RACINE_ESSAI, chemin) && chemin !== RACINE_ESSAI, chemin); + for (const a of chemins) { + for (const b of chemins) if (a !== b) assert.ok(!sous(a, b), `${b} sous ${a}`); + } + assert.equal(new Set(chemins).size, 3); + }); + + test('exe publié, home et temp posés, XDG_CONFIG_HOME retiré, le reste gardé', () => { + const dossiers = dossiersDEssai(RACINE_ESSAI); + const poste = Object.freeze({ + HOME: join('/', 'maison-originale'), + XDG_CONFIG_HOME: join('/', 'maison-originale', 'configuration'), + TMPDIR: join('/', 'temporaire-du-poste'), + PATH: join('/', 'outils'), + LANG: 'fr_CA.UTF-8', + }); + const env = environnementDEssai(poste, dossiers); + assert.equal(env.PORTABLE_EXECUTABLE_DIR, dossiers.exe); + assert.equal(env.HOME, dossiers.home); + assert.equal(env.TMPDIR, dossiers.temp); + assert.equal(env.TEMP, dossiers.temp); + assert.equal(env.TMP, dossiers.temp); + assert.ok(!Object.hasOwn(env, 'XDG_CONFIG_HOME')); + assert.equal(env.PATH, poste.PATH); + assert.equal(env.LANG, poste.LANG); + // L'environnement du poste reste tel quel. + assert.equal(poste.HOME, join('/', 'maison-originale')); + }); + + test('sous X11 sans XAUTHORITY, le fichier d’autorisation reste celui du dossier personnel réel', () => { + const dossiers = dossiersDEssai(RACINE_ESSAI); + const maison = join('/', 'maison-originale'); + const avecX = environnementDEssai({ HOME: maison, DISPLAY: ':9' }, dossiers); + assert.equal(avecX.XAUTHORITY, join(maison, '.Xauthority')); + const pose = join('/', 'autorisation-posee'); + assert.equal(environnementDEssai({ HOME: maison, DISPLAY: ':9', XAUTHORITY: pose }, dossiers).XAUTHORITY, pose); + assert.ok(!Object.hasOwn(environnementDEssai({ HOME: maison }, dossiers), 'XAUTHORITY')); + }); + + test('sous Windows, un temporaire du poste rangé sous LOCALAPPDATA ne met pas exe sous les données applicatives', () => { + const W = path.win32; + const profil = 'C:\\Users\\profil-essai'; + const local = W.join(profil, 'AppData', 'Local'); + const poste = Object.freeze({ + USERPROFILE: profil, + APPDATA: W.join(profil, 'AppData', 'Roaming'), + LOCALAPPDATA: local, + TEMP: W.join(local, 'Temp'), + TMP: W.join(local, 'Temp'), + }); + const dossiers = dossiersDEssai(W.join(poste.TEMP, 'gtt-essai-demarrage-x'), W); + const env = environnementDEssai(poste, dossiers, W); + // Les données applicatives de la coquille (§ 8.6 point 2) : AppData, + // dossier connu que l'environnement ne déplace pas, le LOCALAPPDATA de + // son environnement, et le temporaire, que TMP désigne. + const emplacements = { insensibleCasse: true, separateur: '\\' }; + assert.equal(env.PORTABLE_EXECUTABLE_DIR, W.join(poste.TEMP, 'gtt-essai-demarrage-x', 'exe')); + assert.deepEqual( + [poste.APPDATA, env.LOCALAPPDATA, env.TMP].filter((dossier) => estSous(dossiers.exe, dossier, emplacements)), + [], + ); + // Le profil, AppData et LOCALAPPDATA de la coquille sont sous home. + assert.equal(env.USERPROFILE, dossiers.home); + assert.ok(estSous(env.APPDATA, dossiers.home, emplacements) && env.APPDATA !== dossiers.home, env.APPDATA); + assert.ok(estSous(env.LOCALAPPDATA, dossiers.home, emplacements) && env.LOCALAPPDATA !== dossiers.home, env.LOCALAPPDATA); + assert.deepEqual(donneesApplicativesSurExe(env, poste, 'win32'), []); + assert.equal(poste.LOCALAPPDATA, local); + }); + + test("la garde nomme les données applicatives qui portent exe, avant tout lancement", () => { + // Sous Linux : des chemins composés par path.posix de bout en bout, que + // le système hôte de l'épreuve ne change pas. + const P = path.posix; + const R = P.join('/', 'racine-essai', 'gtt-essai-demarrage-x'); + const dossiers = dossiersDEssai(R, P); + const env = environnementDEssai({ HOME: P.join('/', 'maison-originale') }, dossiers, P); + assert.deepEqual(donneesApplicativesSurExe(env, {}, 'linux'), []); + // Le temporaire de la coquille au-dessus d'exe. + assert.deepEqual(donneesApplicativesSurExe({ ...env, TMPDIR: R }, {}, 'linux'), [R]); + // Le dossier de configuration XDG au-dessus d'exe. + assert.deepEqual(donneesApplicativesSurExe({ ...env, XDG_CONFIG_HOME: R }, {}, 'linux'), [R]); + // Sous Windows : l'AppData du poste, dossier connu, que l'environnement + // de la coquille ne déplace pas ; puis le LOCALAPPDATA de la coquille. + const W = path.win32; + const racine = W.join('D:\\', 'essais', 'gtt-essai-demarrage-x'); + const dossiersW = dossiersDEssai(racine, W); + const envW = environnementDEssai({}, dossiersW, W); + assert.deepEqual(donneesApplicativesSurExe(envW, {}, 'win32'), []); + assert.deepEqual(donneesApplicativesSurExe(envW, { APPDATA: W.join('D:\\', 'essais') }, 'win32'), [ + W.join('D:\\', 'essais'), + ]); + assert.deepEqual(donneesApplicativesSurExe({ ...envW, LOCALAPPDATA: 'd:\\ESSAIS' }, {}, 'win32'), ['d:\\ESSAIS']); + // Sans exécutable publié, rien ne le porte. + assert.deepEqual(donneesApplicativesSurExe({ TMPDIR: '/' }, {}, 'linux'), []); + }); +}); + +describe('essai de démarrage : lancement direct', () => { + const MODULE = fileURLToPath(new URL('./essai_demarrage.js', import.meta.url)); + + // Le lien désigne le dossier du module : une jonction sous Windows, qui se + // crée sans privilège, un lien symbolique ailleurs. + test('le script atteint par un lien symbolique est le module', () => { + const dossier = mkdtempSync(join(tmpdir(), 'gtt-essai-lien-')); + try { + const lien = join(dossier, 'scripts'); + symlinkSync(dirname(MODULE), lien, 'junction'); + assert.equal(estLanceDirectement(join(lien, 'essai_demarrage.js')), true); + assert.equal(estLanceDirectement(MODULE), true); + } finally { + rmSync(dossier, { recursive: true, force: true }); + } + }); + + test('un autre script, un script absent ou aucun script ne lance pas l’essai', () => { + assert.equal(estLanceDirectement(fileURLToPath(import.meta.url)), false); + assert.equal(estLanceDirectement(join(tmpdir(), 'gtt-essai-absent', 'essai_demarrage.js')), false); + assert.equal(estLanceDirectement(undefined), false); + }); +}); diff --git a/test/construction.long.test.js b/test/construction.long.test.js index d3c3a32..5f712bf 100644 --- a/test/construction.long.test.js +++ b/test/construction.long.test.js @@ -3,14 +3,19 @@ // Le paquet livré n'emporte rien de l'outillage d'épreuve (§ 14.9, § 14.15) : // ni fichier de test, ni le lanceur, ni donnée d'épreuve, ni paquet d'un outil -// d'épreuve. Le test construit la page par l'API de Vite, avec la +// d'épreuve, ni le client du serveur de développement ; et il ne charge rien +// du réseau. Le test construit la page par l'API de Vite, avec la // configuration et le mode de la livraison, dans un répertoire temporaire ; // il relit les modules que contient le paquet, les noms d'origine de chaque -// actif, puis le chemin et le contenu de chaque fichier écrit. +// actif, puis le chemin et le contenu de chaque fichier écrit. Le paquet du +// travailleur de génération, que « ?worker&inline » construit à part et +// incruste en chaîne dans le module principal, n'apparaît pas dans les +// moduleIds de celui-ci : un greffon posé par l'option worker.plugins relève +// les modules et le code de ses morceaux, qui passent par les mêmes relevés. import assert from 'node:assert/strict'; import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, relative, resolve, sep } from 'node:path'; +import { extname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; import { describe, test } from './lanceur.js'; @@ -39,11 +44,141 @@ function moduleDEpreuve(id) { // portable, § 8.6), et un code minifié peut écrire « x.test.call ». const TRACES_EPREUVE = [/vitest/i, /node:assert/, /lanceur\.js/, /\.test\.[cm]?js/]; +// Le client du serveur de développement de Vite : une page qui le nomme +// attend un serveur qui n'existe pas chez l'opérateur. +const CLIENT_DE_DEVELOPPEMENT = '/@vite/client'; + +// Un littéral de chaîne JavaScript, entre guillemets doubles, simples ou +// accents graves sans interpolation ; son contenu est dans l'un des trois +// groupes. +const LITTERAL = String.raw`(?:"([^"\n]*)"|'([^'\n]*)'|\x60([^\x60$]*)\x60)`; + +// Les positions où un fichier construit charge une adresse, par nature : +// attributs src et href du HTML ; url() et @import des feuilles ; argument +// littéral d'import(), de fetch, d'importScripts et des constructeurs de +// travailleur, et spécificateur d'un import statique ou d'une réexportation +// en JavaScript. Un composant Svelte compile son balisage en chaînes du +// JavaScript : les attributs src, href, xlink:href et srcset et les url() +// d'un attribut style y sont relevés dans tout le texte, sans apparier les +// littéraux, qu'une expression régulière ou un guillemet échappé du code +// minifié décalerait ; un guillemet échappé (\") y ouvre la valeur comme un +// guillemet nu, et la valeur s'arrête au premier guillemet ou à la première +// barre oblique inverse. Le balisage compilé écrit en entité HTML un +// guillemet de la valeur d'un attribut — url("…") —, que le texte +// JavaScript voit décodé avant les motifs. url( n'y compte qu'en minuscules, +// hors d'un nom plus long : new URL(…) n'est pas une feuille. Chaque +// candidat d'un srcset compte, l'adresse avant son descripteur de largeur +// ou de densité. Une chaîne hors de ces positions — l'adresse +// https://svelte.dev/e/… d'une erreur de Svelte, un espace de noms XML +// http://www.w3.org/… — ne charge rien et n'est pas relevée. Les +// commentaires du HTML et des feuilles sont retirés d'abord : une balise +// commentée ne charge rien. +// +// Par nature : commentaires, le motif des commentaires retirés d'abord ; +// entites, vrai quand les guillemets écrits en entité HTML se décodent +// avant les motifs ; motifs, dont chaque correspondance porte une adresse +// dans l'un de ses groupes ; listes, dont chaque correspondance porte la +// valeur d'un srcset, liste de candidats séparés par des virgules. +const POSITIONS = { + html: { + commentaires: //g, + entites: false, + motifs: [/\s(?:src|(?:xlink:)?href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi], + listes: [/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi], + }, + css: { + commentaires: /\/\*[\s\S]*?\*\//g, + entites: false, + motifs: [/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/gi, /@import\s+(?:"([^"]*)"|'([^']*)')/gi], + listes: [], + }, + js: { + commentaires: null, + entites: true, + motifs: [ + new RegExp(String.raw`(?:\bimport|\bfetch|\bimportScripts|\bWorker|\bSharedWorker)\s*\(\s*${LITTERAL}`, 'g'), + new RegExp(String.raw`(?:\bimport|\bfrom)\s*${LITTERAL}`, 'g'), + /\s(?:src|(?:xlink:)?href)\s*=\s*\\?(?:"([^"\\]*)|'([^'\\]*)|([^\s"'>\\]+))/gi, + /(?\\]+))/gi], + }, +}; + +// Les guillemets qu'une entité HTML écrit, nommée ou numérique, décimale ou +// hexadécimale, zéros de tête et casse indifférents. +const GUILLEMETS_EN_ENTITE = /&(?:(quot)|apos|#0*(34)|#0*39|#x0*(22)|#x0*27);/gi; + +// Le texte où chaque guillemet écrit en entité est rendu à son caractère. +const decoderGuillemets = (texte) => + texte.replace(GUILLEMETS_EN_ENTITE, (_entite, quot, decimal, hexadecimal) => + quot || decimal || hexadecimal ? '"' : "'", + ); + +// Les adresses d'une valeur de srcset : le premier mot de chaque candidat. +const candidatsDe = (valeur) => + valeur + .split(',') + .map((candidat) => candidat.trim().split(/\s+/)[0]) + .filter((adresse) => adresse !== ''); + +// La nature d'un fichier construit, d'après son extension ; null pour un +// fichier qu'aucune position ne concerne. +function natureDe(nom) { + const extension = extname(nom).toLowerCase(); + if (extension === '.html') return 'html'; + if (extension === '.css') return 'css'; + if (['.js', '.mjs', '.cjs'].includes(extension)) return 'js'; + return null; +} + +// Les adresses qu'un texte de la nature donnée charge, dans l'ordre du +// texte, motif par motif, puis celles des srcset. +function adressesChargees(nature, texte) { + const { commentaires, entites, motifs, listes } = POSITIONS[nature]; + const sansCommentaires = commentaires === null ? texte : texte.replace(commentaires, ' '); + const nettoye = entites ? decoderGuillemets(sansCommentaires) : sansCommentaires; + const valeurs = (motif) => [...nettoye.matchAll(motif)].map((m) => m.slice(1).find((groupe) => groupe !== undefined)); + return [...motifs.flatMap(valeurs), ...listes.flatMap((motif) => valeurs(motif).flatMap(candidatsDe))]; +} + +// Vrai pour une adresse qui sort de la machine : http, https, ou relative au +// protocole (« //hôte/… »). +const distante = (adresse) => /^(?:https?:)?\/\//i.test(adresse.trim()); + +// Le relevé du travailleur : un greffon de construction des travailleurs, +// qui garde le nom, les modules et le code de chacun de leurs morceaux. +function releveDuTravailleur() { + const morceaux = []; + const greffon = { + name: 'releve-du-travailleur', + generateBundle(_options, paquet) { + for (const sortie of Object.values(paquet)) { + if (sortie.type === 'chunk') { + morceaux.push({ nom: sortie.fileName, moduleIds: sortie.moduleIds, code: sortie.code }); + } + } + }, + }; + return { morceaux, plugins: () => [greffon] }; +} + +// Les balises script d'un HTML, dans l'ordre : leur src, si elles portent +// type="module", et si elles portent async ou defer. Les commentaires sont +// retirés d'abord. +function scriptsDe(html) { + return [...html.replace(POSITIONS.html.commentaires, ' ').matchAll(/]*)>/gi)].map(([, attributs]) => ({ + src: /\ssrc\s*=\s*["']?([^"'\s>]+)/i.exec(attributs)?.[1] ?? null, + module: /\stype\s*=\s*["']?module\b/i.test(attributs), + differe: /\s(?:async|defer)\b/i.test(attributs), + })); +} + // Construit la page comme la livraison : vite.config.js, mode livraison, // NODE_ENV=production. Vitest pose NODE_ENV=test, dont Vite déduirait une // construction de développement — composants compilés en mode dev, paquets // résolus sous la condition « development ». -async function construireLivraison(sortie) { +async function construireLivraison(sortie, travailleur) { const nodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; try { @@ -55,6 +190,7 @@ async function construireLivraison(sortie) { // Sans incrustation, chaque actif sort en fichier et garde ses noms // d'origine ; incrusté en data:, il ne laisserait aucune trace lisible. build: { outDir: sortie, emptyOutDir: true, assetsInlineLimit: 0 }, + worker: { plugins: travailleur.plugins }, }); } finally { if (nodeEnv === undefined) delete process.env.NODE_ENV; @@ -62,11 +198,111 @@ async function construireLivraison(sortie) { } } +describe('construction : le relevé des adresses chargées', () => { + test('chaque position relève son adresse ; commentaires, espaces de noms et adresses d’erreur ne comptent pas', () => { + const html = [ + '', + '', + '', + "", + '', + '', + '', + ].join('\n'); + assert.deepEqual(adressesChargees('html', html), [ + 'https://exemple.invalid/a.js', + 'http://exemple.invalid/k.js', + '//exemple.invalid/b.css', + './local.png', + 'https://exemple.invalid/q.png', + './r.png', + 'https://exemple.invalid/r2.png', + ]); + const css = [ + '/* url(https://commente.invalid/f.png) */', + 'a { background: url( "https://exemple.invalid/c.png" ) }', + "@import 'https://exemple.invalid/d.css';", + 'b { background: url(./e.png) }', + ].join('\n'); + assert.deepEqual(adressesChargees('css', css), [ + 'https://exemple.invalid/c.png', + './e.png', + 'https://exemple.invalid/d.css', + ]); + const js = [ + 'fetch("https://exemple.invalid/e.json");', + 'fetch("HTTP://exemple.invalid/l");', + "import('https://exemple.invalid/f.js');", + 'importScripts(`https://exemple.invalid/g.js`);', + 'new Worker("./h.js");', + 'import"https://exemple.invalid/i.js";', + 'export{a}from"https://exemple.invalid/j.js";', + 'const n="http://www.w3.org/2000/svg",l="https://svelte.dev/e/effect_orphan";', + 'fetch(u);import(`./${m}.js`);', + // Le balisage qu'un composant compilé porte en chaîne, guillemets nus + // ou échappés, gabarit interpolé compris ; l'espace de noms d'une + // balise ne charge rien. + `var t=$.from_html('');`, + 'var a="x";', + "var d=`
${x}
`;", + 'var s="";', + 'var o=new URL(e,import.meta.url);', + // Le guillemet d'une valeur d'attribut écrit en entité HTML, comme + // Svelte compile style='background: url("…")'. + 'var p=$.from_html(`
`);', + 'var q="
";', + 'var r=\'\';', + // xlink:href et srcset, dont chaque candidat compte. + 'var v=$.from_html(``);', + 'var w="";', + ].join(''); + assert.deepEqual(adressesChargees('js', js), [ + 'https://exemple.invalid/e.json', + 'HTTP://exemple.invalid/l', + 'https://exemple.invalid/f.js', + 'https://exemple.invalid/g.js', + './h.js', + 'https://exemple.invalid/i.js', + 'https://exemple.invalid/j.js', + 'https://exemple.invalid/k.png', + 'https://exemple.invalid/m.html', + '#a', + 'https://exemple.invalid/s.png', + 'https://exemple.invalid/n.png', + 'https://exemple.invalid/o.png', + 'https://exemple.invalid/p.png', + 'https://exemple.invalid/q.png', + './t.png', + 'https://exemple.invalid/t2.png', + ]); + const distantes = [html, css, js].flatMap((texte, rang) => + adressesChargees(['html', 'css', 'js'][rang], texte).filter(distante), + ); + assert.equal(distantes.length, 21); + assert.ok(!distantes.some((a) => a.includes('svelte.dev') || a.includes('w3.org') || a.includes('commente'))); + }); + + test('scriptsDe lit les balises script dans l’ordre, commentaires retirés', () => { + const html = [ + '', + '', + '', + "", + ].join(''); + assert.deepEqual(scriptsDe(html), [ + { src: './c.js', module: false, differe: false }, + { src: './m.js', module: true, differe: false }, + { src: './d.js', module: false, differe: true }, + ]); + }); +}); + describe('construction', () => { - test("le paquet livré n'emporte aucun fichier ni module d'épreuve (§ 14.9)", async () => { + test("le paquet livré n'emporte aucun fichier ni module d'épreuve et ne charge rien du réseau (§ 14.9, § 14.15)", async () => { const sortie = mkdtempSync(join(tmpdir(), 'construction-')); + const travailleur = releveDuTravailleur(); try { - const resultat = await construireLivraison(sortie); + const resultat = await construireLivraison(sortie, travailleur); const sorties = (Array.isArray(resultat) ? resultat : [resultat]).flatMap( (r) => r.output, ); @@ -87,6 +323,16 @@ describe('construction', () => { assert.ok(actifs.length > 0, `aucun actif d'origine connue parmi ${sorties.length} sorties`); assert.deepEqual(actifs.filter(moduleDEpreuve), []); + // Le paquet du travailleur : ses modules, dont le point d'entrée de la + // génération, passent par le même relevé que ceux du module principal. + const modulesDuTravailleur = travailleur.morceaux.flatMap((m) => m.moduleIds); + assert.ok(travailleur.morceaux.length > 0, 'aucun morceau de travailleur relevé'); + assert.ok( + modulesDuTravailleur.includes(join(RACINE, 'src', 'application', 'generation_travailleur.js')), + `generation_travailleur.js absent des ${modulesDuTravailleur.length} modules du travailleur`, + ); + assert.deepEqual(modulesDuTravailleur.filter(moduleDEpreuve), []); + const fichiers = readdirSync(sortie, { recursive: true, withFileTypes: true }) .filter((e) => e.isFile()) .map((e) => join(e.parentPath, e.name)); @@ -94,14 +340,48 @@ describe('construction', () => { fichiers.some((f) => f.endsWith('.js')), `aucun script parmi ${fichiers.length} fichiers écrits`, ); - const traces = fichiers.flatMap((f) => { - const nom = relative(sortie, f); - const contenu = readFileSync(f, 'utf8'); - return TRACES_EPREUVE.filter((m) => m.test(nom) || m.test(contenu)).map( - (m) => `${nom} : ${m}`, - ); - }); + // Les fichiers écrits, puis le code de chaque morceau du travailleur, + // qui n'est écrit qu'incrusté en chaîne et s'y lirait échappé. + const textes = [ + ...fichiers.map((f) => ({ nom: relative(sortie, f), contenu: readFileSync(f, 'utf8') })), + ...travailleur.morceaux.map((m) => ({ nom: `travailleur:${m.nom}`, contenu: m.code })), + ]; + const traces = textes.flatMap(({ nom, contenu }) => + [...TRACES_EPREUVE, CLIENT_DE_DEVELOPPEMENT] + .filter((m) => (typeof m === 'string' ? nom.includes(m) || contenu.includes(m) : m.test(nom) || m.test(contenu))) + .map((m) => `${nom} : ${m}`), + ); assert.deepEqual(traces, []); + + // Les adresses chargées : aucune ne sort de la machine. Le HTML en + // porte au moins une, le collecteur, le module d'entrée et sa feuille. + const releves = textes.flatMap(({ nom, contenu }) => { + const nature = natureDe(nom.replace(/^travailleur:/, '')); + return nature === null ? [] : adressesChargees(nature, contenu).map((adresse) => ({ nom, nature, adresse })); + }); + assert.ok( + releves.filter(({ nature }) => nature === 'html').length >= 3, + `${releves.length} adresses relevées, dont trop peu dans le HTML`, + ); + assert.deepEqual( + releves.filter(({ adresse }) => distante(adresse)).map(({ nom, adresse }) => `${nom} : ${adresse}`), + [], + ); + + // index.html : la langue, puis le collecteur, script classique copié + // tel quel depuis public/, en tête des scripts et sans async ni defer : + // il s'exécute pendant l'analyse du HTML, avant tout module, que le + // navigateur diffère à la fin de l'analyse. + const html = readFileSync(join(sortie, 'index.html'), 'utf8'); + assert.match(html, /]*\slang="fr"/); + const scripts = scriptsDe(html); + const modulesDuHtml = scripts.filter((s) => s.module); + assert.ok(modulesDuHtml.length > 0, `aucun script module parmi ${scripts.length} scripts`); + assert.deepEqual(scripts[0], { src: './collecteur.js', module: false, differe: false }); + assert.equal( + readFileSync(join(sortie, 'collecteur.js'), 'utf8'), + readFileSync(join(RACINE, 'public', 'collecteur.js'), 'utf8'), + ); } finally { rmSync(sortie, { recursive: true, force: true }); }