[ADD] build: no remote load in the bundle, portable startup trial
The delivery build is scanned for test modules, runner traces, the Vite client and any loading http(s) URL, the worker bundle included and quote entities decoded. The startup trial runs the real shell in portable mode from separate exe, home and temp folders: it creates an event, loads the small demonstration, generates in the worker, closes, and checks that the files sit under exe/data and no lock is left. It runs only when launched as itself, symlinks resolved, so it never exits green without running. Checked: 2699 node, 62 long tests, make essai_demarrage conforme. --- FR --- [ADD] construction : rien de distant, essai de démarrage portable La construction de livraison est balayée : ni module d'épreuve, ni trace du lanceur, ni client de Vite, ni URL http(s) qui charge, paquet du travailleur compris et entités de guillemets décodées. L'essai lance la vraie coquille en mode portable, depuis des dossiers exe, home et temp distincts : il crée un événement, charge la petite démonstration, génère dans le travailleur, ferme, et vérifie les fichiers sous exe/data et aucun verrou restant. Il ne part que lancé lui-même, liens résolus : jamais un vert sans essai. Vérifié : 2699 node, 62 longues, make essai_demarrage conforme. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
dd7ad19cc9
commit
03a6f73337
4 changed files with 805 additions and 80 deletions
2
Makefile
2
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)
|
||||
|
|
|
|||
|
|
@ -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<string, string|undefined>} env
|
||||
* @param {{exe: string, home: string, temp: string}} dossiers
|
||||
* @param {path.PlatformPath} [chemins] node:path, ou l'une de ses variantes
|
||||
* @returns {Record<string, string|undefined>}
|
||||
*/
|
||||
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<string, string|undefined>} env l'environnement de la coquille
|
||||
* @param {Record<string, string|undefined>} 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();
|
||||
|
|
|
|||
153
scripts/essai_demarrage.test.js
Normal file
153
scripts/essai_demarrage.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: /<!--[\s\S]*?-->/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,
|
||||
/(?<![\w$.])url\(\s*\\?["']?([^)"'\\\s]*)/g,
|
||||
],
|
||||
listes: [/\ssrcset\s*=\s*\\?(?:"([^"\\]*)|'([^'\\]*)|([^\s"'>\\]+))/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(/<script\b([^>]*)>/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 = [
|
||||
'<!-- <script src="https://commente.invalid/a.js"></script> -->',
|
||||
'<script src="https://exemple.invalid/a.js"></script>',
|
||||
'<script src="http://exemple.invalid/k.js"></script>',
|
||||
"<link rel=stylesheet href='//exemple.invalid/b.css'>",
|
||||
'<img src=./local.png>',
|
||||
'<svg><image xlink:href="https://exemple.invalid/q.png"/></svg>',
|
||||
'<img srcset="./r.png 1x, https://exemple.invalid/r2.png 2x">',
|
||||
].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('<img src="https://exemple.invalid/k.png">');`,
|
||||
'var a="<a href=\\"https://exemple.invalid/m.html\\">x</a>";',
|
||||
"var d=`<div style=\"background: url('https://exemple.invalid/n.png')\">${x}</div>`;",
|
||||
'var s="<svg xmlns=\\"http://www.w3.org/2000/svg\\"><use href=\\"#a\\"/></svg>";',
|
||||
'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(`<div style="background: url("https://exemple.invalid/o.png")"></div>`);',
|
||||
'var q="<div style=\\"background: url('https://exemple.invalid/p.png')\\"></div>";',
|
||||
'var r=\'<i style="background:url('https://exemple.invalid/q.png')"></i>\';',
|
||||
// xlink:href et srcset, dont chaque candidat compte.
|
||||
'var v=$.from_html(`<svg><image xlink:href="https://exemple.invalid/s.png"></image></svg>`);',
|
||||
'var w="<img srcset=\\"./t.png 1x, https://exemple.invalid/t2.png 2x\\">";',
|
||||
].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 = [
|
||||
'<!-- <script type="module" src="./x.js"></script> -->',
|
||||
'<script src="./c.js"></script>',
|
||||
'<script type="module" crossorigin src="./m.js"></script>',
|
||||
"<script defer src='./d.js'></script>",
|
||||
].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, /<html\b[^>]*\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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue