diff --git a/scripts/verifier_systemes.sh b/scripts/verifier_systemes.sh index 658fda3..db02694 100755 --- a/scripts/verifier_systemes.sh +++ b/scripts/verifier_systemes.sh @@ -17,6 +17,12 @@ # que celle de l'hôte, NixOS lui-même (l'image nix éprouve shell.nix, sur # une base qui n'est pas NixOS), une vraie session graphique. # +# Les étapes tournent sous LANG=C.UTF-8, des paramètres régionaux UTF-8 que +# la plupart des images ne posent pas, et sous lesquels Chromium nomme une +# entrée hors ASCII de l'OPFS. La préparation installe ps (procps) là où +# l'image ne le porte pas : une épreuve de la coquille lit par lui l'heure de +# démarrage d'un processus. +# # GTT_PARALLELES (4 par défaut) borne les conteneurs simultanés ; les # journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les # téléchargements — npm, Electron, Chromium, Node — se partagent entre @@ -103,17 +109,18 @@ rm -rf "$source_copie" mkdir -p "$source_copie" (cd "$racine" && git ls-files -co --exclude-standard -z | tar --null -cf - -T - | tar -xf - -C "$source_copie") -# Préparation sous root, par famille : sudo et un compte ordinaire « dev » -# qui l'emploie sans mot de passe, comme un poste de développement. Son uid -# est le même dans chaque conteneur — 1500, libre aussi sous Ubuntu, dont -# l'image donne 1000 à son propre compte — : les caches partagés ne -# changent jamais de propriétaire. +# Préparation sous root, par famille : sudo, ps quand l'image ne le porte +# pas — procps-ng chez Fedora, AlmaLinux, Rocky et Arch, procps ailleurs —, +# et un compte ordinaire « dev » qui emploie sudo sans mot de passe, comme un +# poste de développement. Son uid est le même dans chaque conteneur — 1500, +# libre aussi sous Ubuntu, dont l'image donne 1000 à son propre compte — : +# les caches partagés ne changent jamais de propriétaire. preparation() { case "$1" in - ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo' ;; - fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils' ;; - opensuse-*) echo 'zypper --non-interactive install sudo shadow' ;; - arch) echo 'pacman -Syu --noconfirm sudo' ;; + ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo $(command -v ps > /dev/null || echo procps)' ;; + fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils $(command -v ps > /dev/null || echo procps-ng)' ;; + opensuse-*) echo 'zypper --non-interactive install sudo shadow $(command -v ps > /dev/null || echo procps)' ;; + arch) echo 'pacman -Syu --noconfirm sudo $(command -v ps > /dev/null || echo procps-ng)' ;; esac } diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index 46261ae..a95582c 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -266,6 +266,7 @@ const EXEMPLES = new Map([ ['DONNEES_APPLICATIVES', {}], ['SONDE_ECHOUEE', { cause: 'EROFS' }], ['RELECTURE_DIFFERENTE', {}], + ['NOM_NON_CONVERTI', {}], ['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }], ['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }], ['PLATEFORME_WEB', {}], diff --git a/src/application/libelles/stockage.js b/src/application/libelles/stockage.js index 8595429..9f1cb78 100644 --- a/src/application/libelles/stockage.js +++ b/src/application/libelles/stockage.js @@ -271,6 +271,13 @@ export const TABLE = new Map([ ], // Une cause de la sonde, que les textes citent entre parenthèses. ['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"], + // Une cause de l'OPFS : sous des paramètres régionaux qui ne sont pas + // UTF-8, le navigateur ne nomme pas un dossier ou un fichier hors ASCII. + [ + 'NOM_NON_CONVERTI', + () => + "le navigateur ne sait pas nommer un dossier ou un fichier aux caractères accentués sous les réglages de langue du système", + ], [ 'PORTABLE_NON_INSCRIPTIBLE', ({ racine, evenements, cause }, texte) => { diff --git a/src/stockage/fichiers_web.js b/src/stockage/fichiers_web.js index e5085f9..a91451f 100644 --- a/src/stockage/fichiers_web.js +++ b/src/stockage/fichiers_web.js @@ -33,9 +33,10 @@ // // L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit // en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou -// TypeMismatchError pour une entrée d'un autre genre que celui demandé — -// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une -// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les +// TypeMismatchError pour une entrée d'un autre genre que celui demandé, hors +// le cas d'un nom non converti ci-dessous — lève ABSENT ; toute autre +// exception d'une lecture lève LECTURE, car une panne n'est pas une +// absence ; tout échec d'une écriture lève ECRITURE. Les // deux portent {chemin, dossier, cause}, dont la cause est le nom de // l'exception : SecurityError pour un OPFS que le navigateur refuse à // l'origine, NotReadableError pour un fichier qui change pendant qu'on le @@ -43,6 +44,17 @@ // TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine // elle-même, un dossier. // +// Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium sous Linux +// ne convertit pas un nom hors ASCII et le résout sur le dossier qui le +// porte : un dossier de ce nom rend ce dossier sans lever, un fichier de ce +// nom lève TypeMismatchError comme pour une entrée d'un autre genre. Chaque +// dossier du chemin se compare donc au dossier qui le porte (isSameEntry) ; +// une TypeMismatchError sur un fichier se départage en demandant le même nom +// comme dossier, comparé de même. La même entrée lève, cause +// NOM_NON_CONVERTI — LECTURE pour une lecture, ECRITURE pour une écriture —, +// jamais ABSENT ni EXISTE : rien ne se lit ni ne s'écrit dans le dossier qui +// le porte, et un fichier présent ne se lit pas absent. +// // Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et // choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter // passe par un champ de fichier, que le navigateur n'ouvre que dans le @@ -69,6 +81,10 @@ const TYPE_DIFFERENT = 'TypeMismatchError'; // Cause d'une sonde dont le témoin relu diffère du texte écrit. const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE'; +// Cause d'un nom, de dossier ou de fichier, que l'OPFS résout sur le dossier +// qui le porte. +const NOM_NON_CONVERTI = 'NOM_NON_CONVERTI'; + // Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans // retirer celle qu'il porte, que Blob.text retirerait. Le décodage est // strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD. @@ -79,7 +95,9 @@ const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); const ignorer = () => {}; // Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou -// d'un autre genre que celui demandé. +// d'un autre genre que celui demandé. Une TypeMismatchError de fichier passe +// d'abord par fichierDe, qui lève NOM_NON_CONVERTI à sa place quand le nom +// n'est pas converti. const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT; // Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son @@ -134,17 +152,50 @@ async function ecrire(loc, travail) { } } +// Poignée du dossier nom sous parent. Lève une DOMException de nom +// NOM_NON_CONVERTI quand l'OPFS rend parent lui-même pour ce nom. +async function sousDossier(parent, nom, options) { + const dossier = await parent.getDirectoryHandle(nom, options); + if (await dossier.isSameEntry(parent)) throw new DOMException(nom, NOM_NON_CONVERTI); + return dossier; +} + +// Vrai quand l'OPFS résout nom, demandé comme dossier, sur dossier lui-même ; +// faux pour toute autre poignée, ou quand la demande lève. +async function resoutSurLuiMeme(dossier, nom) { + try { + return await (await dossier.getDirectoryHandle(nom)).isSameEntry(dossier); + } catch { + return false; + } +} + +// Poignée du fichier nom du dossier. Lève l'exception de l'OPFS, sauf une +// TypeMismatchError dont le nom se résout sur le dossier lui-même, qui +// devient une DOMException de nom NOM_NON_CONVERTI. +async function fichierDe(dossier, nom, options) { + try { + return await dossier.getFileHandle(nom, options); + } catch (erreur) { + if (erreur?.name === TYPE_DIFFERENT && (await resoutSurLuiMeme(dossier, nom))) { + throw new DOMException(nom, NOM_NON_CONVERTI); + } + throw erreur; + } +} + // Poignée du dossier que désignent les segments sous le dossier du produit ; // creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se // crée que pour des segments vides, qui le désignent : un chemin sous un // dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de // l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un -// fichier à sa place. +// fichier à sa place ; NOM_NON_CONVERTI pour un dossier résolu sur celui qui +// le porte. async function dossierDe(segments, creer = false) { const options = { create: creer }; const produit = { create: creer && segments.length === 0 }; - let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit); - for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options); + let dossier = await sousDossier(await navigator.storage.getDirectory(), DOSSIER_PRODUIT, produit); + for (const segment of segments) dossier = await sousDossier(dossier, segment, options); return dossier; } @@ -159,10 +210,10 @@ async function dossierExistant(segments) { } // Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle -// n'existe pas. +// n'existe pas. Un nom non converti lève NOM_NON_CONVERTI. async function genreDe(dossier, nom) { try { - await dossier.getFileHandle(nom); + await fichierDe(dossier, nom); return 'fichier'; } catch (erreur) { if (erreur?.name === TYPE_DIFFERENT) return 'dossier'; @@ -200,7 +251,7 @@ async function ecrireParFlux(dossier, nom, garder, remplir) { let poignee; let cree = false; try { - poignee = await dossier.getFileHandle(nom); + poignee = await fichierDe(dossier, nom); } catch (erreur) { if (erreur?.name !== 'NotFoundError') throw erreur; poignee = await dossier.getFileHandle(nom, { create: true }); @@ -235,7 +286,8 @@ async function decrire(poignee) { const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0); // La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand -// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier. +// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier ; +// NOM_NON_CONVERTI, que genreDe lève, avant toute lecture de ses octets. async function lireSource(loc) { if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT); const dossier = await dossierExistant(loc.parent); @@ -338,7 +390,7 @@ export function creerFichiersWeb() { if (loc.nom === null) throw absent(chemin); let lu; try { - lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom)); + lu = await lire(await fichierDe(await dossierDe(loc.parent), loc.nom)); } catch (erreur) { throw echecLecture(loc, erreur); } diff --git a/src/stockage/fichiers_web.navigateur.test.js b/src/stockage/fichiers_web.navigateur.test.js index 92dd605..bf98c00 100644 --- a/src/stockage/fichiers_web.navigateur.test.js +++ b/src/stockage/fichiers_web.navigateur.test.js @@ -246,6 +246,92 @@ describe("fichiers web : ce que l'OPFS refuse", () => { expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note'); }); + // Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium résout un + // nom de dossier hors ASCII sur le dossier qui le porte, sans lever. La + // doublure rend ce dossier pour le nom « é » : aucun geste ne lit ni n'écrit + // alors dans le dossier qui le porte, où un fichier de même nom attend. + test("un dossier dont la poignée est celle du dossier qui le porte lève NOM_NON_CONVERTI, ECRITURE à l'écriture et LECTURE à la lecture, et le dossier qui le porte reste intact", async () => { + const fs = await preparer(); + await fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'parent'); + const poignee = FileSystemDirectoryHandle.prototype.getDirectoryHandle; + vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation( + async function getDirectoryHandle(nom, options) { + return nom === '\u{E9}' ? this : poignee.call(this, nom, options); + }, + ); + const sous = `${CHEMIN_DOCUMENTS}/\u{E9}`; + const refus = (chemin, dossier = sous) => ({ chemin, dossier, cause: 'NOM_NON_CONVERTI' }); + const enfant = '\u{E9}/etat.json'; + + await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}'), 'ECRITURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'creerDossier'); + await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}/sous'), 'ECRITURE', refus('\u{E9}/sous'), 'creerDossier dessous'); + await echoue(fs.ecrireAtomique(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ecrireAtomique'); + await echoue(fs.ajouterLigne(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ajouterLigne'); + await echoue(fs.supprimer(DOCUMENTS, enfant), 'ECRITURE', refus(enfant), 'supprimer'); + await echoue(fs.deplacer(DOCUMENTS, enfant, 'copie.json'), 'ECRITURE', refus(enfant), 'deplacer depuis'); + await echoue(fs.deplacer(DOCUMENTS, 'etat.json', '\u{E9}/neuf.json'), 'ECRITURE', refus('\u{E9}/neuf.json'), 'deplacer vers'); + await echoue(fs.lireTexte(DOCUMENTS, enfant), 'LECTURE', refus(enfant), 'lireTexte'); + await echoue(fs.lister(DOCUMENTS, '\u{E9}'), 'LECTURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'lister'); + vi.restoreAllMocks(); + expect(await fs.lister(DOCUMENTS, '')).toMatchObject([{ nom: 'etat.json', type: 'fichier' }]); + expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('parent'); + + // Le dossier du produit se contrôle comme tout autre : une poignée qui + // serait la racine de l'OPFS fait échouer la sonde, qui n'y écrit rien. + vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation( + async function getDirectoryHandle(nom, options) { + return nom === DOSSIER ? this : poignee.call(this, nom, options); + }, + ); + expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NOM_NON_CONVERTI' }); + await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'LECTURE', refus('etat.json', CHEMIN_DOCUMENTS), 'produit'); + vi.restoreAllMocks(); + expect(await nomsOpfs()).toStrictEqual([DOSSIER]); + }); + + // Sous les mêmes paramètres régionaux, un nom de fichier hors ASCII se + // résout lui aussi sur le dossier qui le porte : getFileHandle lève + // TypeMismatchError, et getDirectoryHandle rend ce dossier. La doublure + // reproduit les deux pour le nom « ô.txt ». Le refus n'est alors ni une + // absence ni une entrée d'un autre genre : un état présent ne se lit pas + // ABSENT, une cible libre ne se dit pas EXISTE. + test("un fichier que l'OPFS résout sur le dossier qui le porte lève NOM_NON_CONVERTI, LECTURE à la lecture et ECRITURE à l'écriture, jamais ABSENT ni EXISTE", async () => { + const fs = await preparer(); + await fs.ecrireAtomique(DOCUMENTS, 'source.txt', 'source'); + const nom = '\u{F4}.txt'; + const fichier = FileSystemDirectoryHandle.prototype.getFileHandle; + const dossier = FileSystemDirectoryHandle.prototype.getDirectoryHandle; + vi.spyOn(FileSystemDirectoryHandle.prototype, 'getFileHandle').mockImplementation( + async function getFileHandle(entree, options) { + if (entree === nom) throw new DOMException(entree, 'TypeMismatchError'); + return fichier.call(this, entree, options); + }, + ); + vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation( + async function getDirectoryHandle(entree, options) { + return entree === nom ? this : dossier.call(this, entree, options); + }, + ); + const refus = { chemin: nom, dossier: CHEMIN_DOCUMENTS, cause: 'NOM_NON_CONVERTI' }; + + await echoue(fs.lireTexte(DOCUMENTS, nom), 'LECTURE', refus, 'lireTexte'); + await echoue(fs.ecrireAtomique(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ecrireAtomique'); + await echoue(fs.ajouterLigne(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ajouterLigne'); + await echoue(fs.supprimer(DOCUMENTS, nom), 'ECRITURE', refus, 'supprimer'); + await echoue(fs.deplacer(DOCUMENTS, nom, 'copie.txt'), 'ECRITURE', refus, 'deplacer depuis'); + await echoue(fs.deplacer(DOCUMENTS, 'source.txt', nom), 'ECRITURE', refus, 'deplacer vers'); + + // Un vrai dossier reste une entrée d'un autre genre : ABSENT à la lecture. + await fs.creerDossier(DOCUMENTS, 'corbeille'); + await echoue(fs.lireTexte(DOCUMENTS, 'corbeille'), 'ABSENT', { chemin: 'corbeille' }, 'vrai dossier'); + vi.restoreAllMocks(); + expect(await fs.lister(DOCUMENTS, '')).toMatchObject([ + { nom: 'corbeille', type: 'dossier' }, + { nom: 'source.txt', type: 'fichier' }, + ]); + expect(await fs.lireTexte(DOCUMENTS, 'source.txt')).toBe('source'); + }); + // Chaque panne frappe un temps de l'écriture par flux : son ouverture, // l'écriture, la fermeture qui porte le contenu sur la cible. const PANNES = [ diff --git a/test/chromium.js b/test/chromium.js index 52321bf..8986db6 100644 --- a/test/chromium.js +++ b/test/chromium.js @@ -6,7 +6,41 @@ // téléchargé ; GTT_CHROMIUM désigne un autre exécutable. Sous NixOS, où les // binaires téléchargés ne démarrent pas, scripts/installation/shell.nix y // met celui de nixpkgs. -export function optionsChromium(env) { - const chemin = env.GTT_CHROMIUM; - return chemin ? { launchOptions: { executablePath: chemin } } : {}; +// +// Sous Linux, Chromium convertit le nom d'une entrée de l'OPFS par les +// paramètres régionaux de son processus : hors UTF-8, un nom hors ASCII ne +// se convertit pas, et les épreuves du navigateur n'éprouvent plus le +// stockage. Les paramètres régionaux qui décident de cette conversion sont +// ceux de LC_CTYPE : LC_ALL, puis LC_CTYPE, puis LANG, la première variable +// non vide ; aucune, c'est « C ». Quand ils ne sont pas UTF-8, Chromium +// reçoit une copie de l'environnement sous LANG=C.UTF-8, LC_ALL et LC_CTYPE +// retirés puisqu'ils l'emporteraient sur LANG, comme +// scripts/verifier_systemes.sh le lance dans ses conteneurs. Seul le nom des +// paramètres régionaux se lit : un nom UTF-8 que l'hôte n'a pas générés +// laisse Chromium sous « C », et l'épreuve de l'OPFS du navigateur nomme +// alors cette cause. C.UTF-8 vient avec la glibc 2.35 et suivantes, et avec +// les distributions qui le livrent avant ; l'environnement reçu reste tel +// quel. + +// Un nom de paramètres régionaux dont le jeu de caractères est UTF-8 : +// « fr_CA.UTF-8 », « C.utf8 », « de_DE.UTF-8@euro ». +const NOM_UTF8 = /\.utf-?8(@[^.]*)?$/i; + +// Paramètres régionaux de LC_CTYPE que env donne, « C » sans aucun. +function caracteresDe(env) { + for (const variable of ['LC_ALL', 'LC_CTYPE', 'LANG']) { + if (env[variable]) return env[variable]; + } + return 'C'; +} + +export function optionsChromium(env) { + const lancement = {}; + if (env.GTT_CHROMIUM) lancement.executablePath = env.GTT_CHROMIUM; + if (!NOM_UTF8.test(caracteresDe(env))) { + lancement.env = { ...env, LANG: 'C.UTF-8' }; + delete lancement.env.LC_ALL; + delete lancement.env.LC_CTYPE; + } + return Object.keys(lancement).length > 0 ? { launchOptions: lancement } : {}; } diff --git a/test/chromium.test.js b/test/chromium.test.js index f54064d..9296b3b 100644 --- a/test/chromium.test.js +++ b/test/chromium.test.js @@ -2,20 +2,25 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Le Chromium des épreuves du navigateur : celui de Playwright par défaut, -// celui que GTT_CHROMIUM désigne sinon, et vitest.config.js applique la règle. +// celui que GTT_CHROMIUM désigne sinon ; sous des paramètres régionaux qui ne +// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. vitest.config.js +// applique la règle. import assert from 'node:assert/strict'; import configuration from '../vitest.config.js'; import { optionsChromium } from './chromium.js'; import { describe, test } from './lanceur.js'; +// Un environnement d'épreuve, sous des paramètres régionaux UTF-8. +const UTF8 = { PATH: '/usr/bin', LANG: 'fr_CA.UTF-8' }; + describe('chromium : l’exécutable des épreuves du navigateur', () => { test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => { - assert.deepEqual(optionsChromium({}), {}); - assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '' }), {}); + assert.deepEqual(optionsChromium(UTF8), {}); + assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '' }), {}); }); test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => { - assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), { + assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), { launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' }, }); }); @@ -25,3 +30,44 @@ describe('chromium : l’exécutable des épreuves du navigateur', () => { assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env)); }); }); + +describe('chromium : les paramètres régionaux du navigateur des épreuves', () => { + test('des paramètres régionaux UTF-8 laissent Chromium hériter de l’environnement : LC_ALL, puis LC_CTYPE, puis LANG décident', () => { + const utf8 = [ + { LANG: 'C.UTF-8' }, + { LANG: 'C.utf8' }, + { LANG: 'de_DE.UTF-8@euro' }, + { LANG: 'C', LC_CTYPE: 'fr_CA.utf8' }, + { LANG: 'C', LC_CTYPE: 'C', LC_ALL: 'fr_CA.UTF-8' }, + { LANG: 'fr_CA.UTF-8', LC_ALL: '', LC_CTYPE: '' }, + ]; + for (const env of utf8) assert.deepEqual(optionsChromium(env), {}, JSON.stringify(env)); + }); + + test('hors UTF-8, Chromium reçoit une copie de l’environnement sous LANG=C.UTF-8, sans LC_ALL ni LC_CTYPE', () => { + const cas = [ + [{}, {}], + [{ PATH: '/usr/bin', LC_ALL: 'C' }, { PATH: '/usr/bin' }], + [{ PATH: '/usr/bin', LANG: 'POSIX', LC_MESSAGES: 'fr_CA.UTF-8' }, { PATH: '/usr/bin', LC_MESSAGES: 'fr_CA.UTF-8' }], + [{ LANG: 'fr_CA.UTF-8', LC_CTYPE: 'C' }, {}], + [{ LANG: 'fr_CA.UTF-8', LC_ALL: 'fr_CA.ISO-8859-1' }, {}], + [{ LANG: 'C.UTF-8-ancien' }, {}], + ]; + for (const [env, reste] of cas) { + const avant = { ...env }; + assert.deepEqual( + optionsChromium(env), + { launchOptions: { env: { ...reste, LANG: 'C.UTF-8' } } }, + JSON.stringify(env), + ); + assert.deepEqual(env, avant, `${JSON.stringify(env)} : l'environnement reçu reste tel quel`); + } + }); + + test('hors UTF-8, GTT_CHROMIUM désigne toujours l’exécutable, que l’environnement accompagne', () => { + const env = { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' }; + assert.deepEqual(optionsChromium(env), { + launchOptions: { executablePath: '/opt/essai/chromium', env: { GTT_CHROMIUM: '/opt/essai/chromium', LANG: 'C.UTF-8' } }, + }); + }); +});