gestion_table_tournante_libre/test/fichiers_electron.long.test.js
Mathieu Benoit 63efdb21a1 [FIX] matrix: UTF-8 locale for Chromium, skip ps reading when absent
On Linux, Chromium converts origin-private file names through the process
locale. The debian:13 and fedora:44 images set no LANG, so every accented
name failed with TypeMismatchError there, while Arch, which sets C.UTF-8,
passed. The container steps now run under LANG=C.UTF-8, and a browser test
names that cause when a Chromium cannot write an accented name. The long
test that reads the real ps skips with its reason when ps is missing.
Checked: debian-13 and fedora-44 test_navigateur and test_long green in
containers; 36 browser and 26 node-long tests locally.

--- FR ---

[FIX] matrice : locale UTF-8 pour Chromium, lecture ps sautée sans ps

Sous Linux, Chromium convertit les noms de fichiers de l'origine privée par
la locale du processus. Les images debian:13 et fedora:44 ne posent aucun
LANG : chaque nom accentué y échouait (TypeMismatchError), alors qu'Arch,
qui pose C.UTF-8, passait. Les étapes des conteneurs tournent sous
LANG=C.UTF-8, et une épreuve du navigateur nomme cette cause quand un
Chromium n'écrit pas un nom accentué. L'épreuve longue qui lit le vrai ps se
saute, raison dite, quand ps manque.
Vérifié : debian-13 et fedora-44 au vert en conteneur ; 36 + 26 en local.

Assisted-by: Claude Opus 5.5
2026-10-07 02:14:19 -04:00

409 lines
19 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le système de fichiers de la coquille sur un vrai disque (§ 8.6, § 8.8,
// § 14.4). La suite de contrat de test/contrat_fichiers.js s'y joue sur la
// page, src/stockage/fichiers_electron.js, derrière le vrai préchargement,
// devant les primitives d'electron/fichiers.js ; seul l'IPC d'Electron est
// remplacé, et ses arguments et réponses y sont copiés comme il les copie.
// Suivent la sonde d'écriture, qui ne s'éprouve utilement que contre un vrai
// système de fichiers, un dossier en lecture seule, un lien qui sort de la
// racine, un dossier voisin dont le nom prolonge celui de la racine, le
// renommage qui remplace la cible, le verrou d'un processus vivant ou
// terminé, sous Linux celui d'un pid vivant dont le démarrage n'est pas
// celui qu'il inscrit, et le démarrage que ps écrit en temps universel quel
// que soit le fuseau du processus qui le lit. Chaque épreuve écrit sous ses
// propres dossiers temporaires, retirés à sa fin, qu'elle passe ou non.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { hostname, tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compileFunction } from 'node:vm';
import { TEMOIN, creerFichiers, decrireSysteme, servir } from '../electron/fichiers.js';
import { ErreurStockage } from '../src/stockage/erreurs.js';
import { creerFichiersElectron } from '../src/stockage/fichiers_electron.js';
import { eprouverContrat } from './contrat_fichiers.js';
import { describe, test } from './lanceur.js';
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
const NOM_PRODUIT = 'Gestion table tournante Libre';
// Sous Windows, un dossier ne passe pas en lecture seule par ses droits, et
// un lien symbolique demande un privilège : un lien de dossier s'y pose en
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
const SOUS_WINDOWS = process.platform === 'win32';
const SOUS_LINUX = process.platform === 'linux';
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
// Dossiers temporaires de l'épreuve en cours.
const ouverts = [];
function dossierTemporaire() {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-fichiers-'));
ouverts.push(dossier);
return dossier;
}
// test du lanceur, dont chaque épreuve retire à sa fin les dossiers
// temporaires qu'elle a ouverts ; le corps reçoit le contexte de l'épreuve.
function testNettoye(nom, ...suite) {
const corps = suite.pop();
test(nom, ...suite, async (contexte) => {
try {
await corps(contexte);
} finally {
for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true });
}
});
}
// Ce que dit une commande lancée par spawnSync : l'exception qui l'a
// empêchée de démarrer, sinon sa sortie d'erreur. Toujours une chaîne,
// seul message que node:assert accepte.
const ditPar = (lancee) => String(lancee.error?.message ?? lancee.stderr ?? '');
// Charge electron/preload.cjs comme un rendu en bac à sable, contre un
// electron dont invoke porte chaque appel au gestionnaire que servir a posé
// sur ce canal ; arguments et réponse sont copiés comme l'IPC les copie, et
// d'une erreur levée ne passe que le message. Rend gtt.fichiers.
function parLePrechargement(fichiers) {
const gestionnaires = new Map();
servir(fichiers, (canal, gestionnaire) => gestionnaires.set(canal, gestionnaire));
let gtt;
const electron = {
contextBridge: {
exposeInMainWorld: (cle, api) => {
assert.equal(cle, 'gtt');
gtt = api;
},
},
ipcRenderer: {
async invoke(canal, ...parametres) {
const gestionnaire = gestionnaires.get(canal);
if (gestionnaire === undefined) throw new Error(`No handler registered for '${canal}'`);
let reponse;
try {
reponse = await gestionnaire({ sender: null }, ...structuredClone(parametres));
} catch (erreur) {
throw new Error(`Error invoking remote method '${canal}': ${erreur}`);
}
return structuredClone(reponse);
},
},
};
const module = { exports: {} };
const requerir = (nom) => {
if (nom === 'electron') return electron;
throw new Error(`module « ${nom} » indisponible dans un préchargement en bac à sable`);
};
compileFunction(readFileSync(PRECHARGEMENT, 'utf8'), ['require', 'module', 'exports'], {
filename: PRECHARGEMENT,
})(requerir, module, module.exports);
return gtt.fichiers;
}
// Les dialogues et l'explorateur ne servent à aucune de ces épreuves.
const SANS_APPEL = new Proxy({}, {
get: (_, nom) => () => assert.fail(`appel inattendu : ${String(nom)}`),
});
/**
* Le système de la page sur un dossier temporaire neuf : les Documents du
* poste y sont Documents/, et le dossier publié par le lanceur portable,
* quand il est donné, y est relatif. Rend le système et le chemin de ses
* racines sur le disque.
*/
function systemeSur(dossier, { publie = null } = {}) {
const chemins = { appData: join(dossier, 'AppData'), temp: tmpdir(), documents: join(dossier, 'Documents') };
const systeme = decrireSysteme({
env: publie === null ? {} : { PORTABLE_EXECUTABLE_DIR: join(dossier, publie) },
plateforme: process.platform,
cheminSysteme: (nom) => chemins[nom],
nomProduit: NOM_PRODUIT,
});
const fs = creerFichiersElectron(parLePrechargement(creerFichiers({ ...systeme, dialog: SANS_APPEL, shell: SANS_APPEL })));
return { fs, ...systeme.racines };
}
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
async function rejette(promesse, message) {
let raison;
await assert.rejects(
promesse,
(erreur) => {
raison = erreur;
return true;
},
message,
);
return raison;
}
// Attend le rejet d'une ErreurStockage de ce code et de ces détails.
async function echoue(promesse, code, details, message = code) {
const erreur = await rejette(promesse, message);
assert.ok(erreur instanceof ErreurStockage, `${message} : ${erreur}`);
assert.equal(erreur.code, code, message);
assert.deepEqual(erreur.details, details, message);
}
eprouverContrat(
'electron, sur un vrai dossier',
async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
return { fs, racine: (await fs.racines()).documents };
},
{
describe,
test: testNettoye,
egal: (reel, attendu, message) => assert.deepEqual(reel, attendu, message),
vrai: (valeur, message) => assert.ok(valeur, message),
rejette,
},
);
describe('coquille sur un vrai disque', () => {
testNettoye("la sonde crée la racine et ses parents, efface un témoin resté, et ne laisse que les autres fichiers", async () => {
const { fs, portable } = systemeSur(dossierTemporaire(), { publie: join('cle', 'soirees') });
const racine = (await fs.racines()).portable;
assert.equal(racine.chemin, portable);
assert.deepEqual(await fs.sonder(racine), { inscriptible: true, cause: null });
assert.deepEqual(readdirSync(portable), []);
writeFileSync(join(portable, TEMOIN), 'resté d\u{2019}une séance précédente');
writeFileSync(join(portable, 'garde.txt'), 'garde');
assert.deepEqual(await fs.sonder(racine), { inscriptible: true, cause: null });
assert.deepEqual(readdirSync(portable), ['garde.txt']);
});
testNettoye("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
const dossier = dossierTemporaire();
const { fs, portable } = systemeSur(dossier, { publie: join('cle', 'soirees') });
const racine = (await fs.racines()).portable;
await echoue(fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', {
chemin: 'corbeille/2026-01-02_03-04-05',
dossier: join(portable, 'corbeille'),
cause: 'ENOENT',
});
assert.deepEqual(readdirSync(dossier), []);
await fs.creerDossier(racine, '');
assert.deepEqual(readdirSync(portable), []);
await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05');
assert.deepEqual(readdirSync(join(portable, 'corbeille')), ['2026-01-02_03-04-05']);
});
testNettoye(
"un dossier en lecture seule fait échouer la sonde sur EACCES, qu'elle doive y écrire ou y créer la racine",
{ skip: SOUS_ROOT || SOUS_WINDOWS },
async () => {
const { fs, documents, portable } = systemeSur(dossierTemporaire(), {
publie: join('Documents', NOM_PRODUIT, 'cle'),
});
mkdirSync(documents, { recursive: true });
chmodSync(documents, 0o555);
try {
const racines = await fs.racines();
assert.deepEqual(await fs.sonder(racines.documents), { inscriptible: false, cause: 'EACCES' });
assert.deepEqual(await fs.sonder(racines.portable), { inscriptible: false, cause: 'EACCES' });
assert.ok(portable.startsWith(documents), portable);
assert.deepEqual(readdirSync(documents), []);
} finally {
chmodSync(documents, 0o755);
}
},
);
testNettoye("un lien qui sort de la racine lève CHEMIN_REFUSE, et rien ne s'écrit, ne se lit ni ne s'efface au-dehors", async () => {
const dossier = dossierTemporaire();
const dehors = join(dossierTemporaire(), 'dehors');
// Au-dehors, un fichier et un verrou de la séance qui appelle, que
// deverrouiller effacerait.
const verrou = `${JSON.stringify({ seance: 'seance-a', pid: 777, hote: 'poste-essai', depuis: '2026-10-06T19:02:11-04:00' })}\n`;
mkdirSync(dehors);
writeFileSync(join(dehors, 'secret.txt'), 'hors de la racine');
writeFileSync(join(dehors, 'soiree.gtt.verrou'), verrou);
const { fs, documents } = systemeSur(dossier);
mkdirSync(documents, { recursive: true });
writeFileSync(join(documents, 'ici.txt'), 'ici');
symlinkSync(dehors, join(documents, 'lien'), SOUS_WINDOWS ? 'junction' : 'dir');
const racine = (await fs.racines()).documents;
await echoue(fs.lireTexte(racine, 'lien/secret.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
await echoue(fs.ecrireAtomique(racine, 'lien/neuf.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.txt' });
await echoue(fs.ajouterLigne(racine, 'lien/secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
await echoue(fs.lister(racine, 'lien'), 'CHEMIN_REFUSE', { chemin: 'lien' });
await echoue(fs.deplacer(racine, 'ici.txt', 'lien/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/vole.txt' });
await echoue(fs.verrouiller(racine, 'lien/neuf.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.gtt.verrou' });
await echoue(fs.deverrouiller(racine, 'lien/soiree.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/soiree.gtt.verrou' });
if (!SOUS_WINDOWS) {
symlinkSync(join(dehors, 'secret.txt'), join(documents, 'secret.txt'));
await echoue(fs.lireTexte(racine, 'secret.txt'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
await echoue(fs.ajouterLigne(racine, 'secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
}
assert.deepEqual(readdirSync(dehors).sort(), ['secret.txt', 'soiree.gtt.verrou']);
assert.equal(readFileSync(join(dehors, 'secret.txt'), 'utf8'), 'hors de la racine');
assert.equal(readFileSync(join(dehors, 'soiree.gtt.verrou'), 'utf8'), verrou);
assert.equal(readFileSync(join(documents, 'ici.txt'), 'utf8'), 'ici');
});
testNettoye("un dossier voisin dont le nom prolonge celui de la racine est hors d'elle, et reste intact", async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
const voisin = `${documents} (copie)`;
mkdirSync(documents, { recursive: true });
mkdirSync(voisin);
writeFileSync(join(voisin, 'secret.txt'), 'dans le dossier voisin');
writeFileSync(join(documents, 'ici.txt'), 'ici');
symlinkSync(voisin, join(documents, 'voisin'), SOUS_WINDOWS ? 'junction' : 'dir');
const racine = (await fs.racines()).documents;
await echoue(fs.lireTexte(racine, 'voisin/secret.txt'), 'CHEMIN_REFUSE', { chemin: 'voisin/secret.txt' });
await echoue(fs.ecrireAtomique(racine, 'voisin/neuf.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'voisin/neuf.txt' });
await echoue(fs.deplacer(racine, 'ici.txt', 'voisin/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'voisin/vole.txt' });
assert.deepEqual(readdirSync(voisin), ['secret.txt']);
assert.equal(readFileSync(join(voisin, 'secret.txt'), 'utf8'), 'dans le dossier voisin');
assert.equal(readFileSync(join(documents, 'ici.txt'), 'utf8'), 'ici');
});
testNettoye("l'écriture atomique remplace la cible par un renommage : le nom passe à un autre fichier, sans résidu", async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
const racine = (await fs.racines()).documents;
const cible = join(documents, 'soiree.gtt.json');
writeFileSync(cible, 'ancien');
const avant = statSync(cible).ino;
await fs.ecrireAtomique(racine, 'soiree.gtt.json', 'nouveau');
assert.equal(readFileSync(cible, 'utf8'), 'nouveau');
assert.notEqual(statSync(cible).ino, avant);
assert.deepEqual(readdirSync(documents), ['soiree.gtt.json']);
});
testNettoye("le verrou d'un processus vivant de ce poste se dit vivant, celui d'un processus terminé non", async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
const racine = (await fs.racines()).documents;
assert.deepEqual(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
assert.deepEqual([pose.pid, pose.hote], [process.pid, hostname()]);
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
assert.deepEqual(tenu, { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true });
const termine = spawnSync(process.execPath, ['-e', '']);
assert.equal(termine.status, 0);
const depuis = '2026-10-06T19:02:11-04:00';
writeFileSync(
join(documents, 'ancienne.gtt.verrou'),
`${JSON.stringify({ seance: 'seance-terminee', pid: termine.pid, hote: hostname(), depuis })}\n`,
);
assert.deepEqual(await fs.verrouiller(racine, 'ancienne.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'seance-terminee',
depuis,
vivant: false,
});
});
testNettoye(
"sous Linux, le verrou inscrit le champ 22 de /proc/<pid>/stat ; un pid vivant d'un autre démarrage est une séance morte, et un verrou sans démarrage laisse vivant inconnu",
{ skip: !SOUS_LINUX },
async () => {
// Le démarrage d'un processus, lu ici sans passer par la coquille.
const demarrageDe = (cible) => {
const texte = readFileSync(`/proc/${cible}/stat`, 'utf8');
return texte.slice(texte.lastIndexOf(')') + 1).trim().split(/\s+/)[19];
};
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
const racine = (await fs.racines()).documents;
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
assert.deepEqual(Object.keys(pose), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
assert.equal(pose.demarrage, demarrageDe(process.pid));
// Le processus parent vit ; un verrou à son pid ne vit qu'avec son
// démarrage, et sans démarrage inscrit, rien ne dit s'il vit encore.
const parent = process.ppid;
const depuis = '2026-10-06T19:02:11-04:00';
for (const [nom, inscrit, vivant] of [
['son démarrage', { demarrage: demarrageDe(parent) }, true],
['son démarrage, une clé étrangère', { demarrage: demarrageDe(parent), inconnue: 1 }, true],
['un autre démarrage', { demarrage: `${demarrageDe(parent)}0` }, false],
['sans démarrage', {}, null],
]) {
writeFileSync(
join(documents, 'parent.gtt.verrou'),
`${JSON.stringify({ seance: 'seance-p', pid: parent, hote: hostname(), depuis, ...inscrit })}\n`,
);
assert.deepEqual(
await fs.verrouiller(racine, 'parent.gtt.verrou', 'seance-b'),
{ pris: false, seance: 'seance-p', depuis, vivant },
nom,
);
}
},
);
testNettoye(
"la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante",
{ skip: SOUS_WINDOWS },
async (contexte) => {
// L'épreuve joue macOS avec le ps de l'hôte. Sous Linux, la coquille
// lit /proc et ne demande pas ps, qu'une image de système minimale ne
// porte pas : sans lui, l'épreuve se saute et dit pourquoi.
if (spawnSync('ps', ['-p', String(process.pid)]).error?.code === 'ENOENT') {
contexte.skip("ps introuvable sur ce système : la lecture du démarrage par ps ne s'y éprouve pas");
return;
}
const documents = join(dossierTemporaire(), 'Documents');
mkdirSync(documents, { recursive: true });
const fichiers = creerFichiers({
emplacements: {},
racines: { portable: null, documents },
dialog: SANS_APPEL,
shell: SANS_APPEL,
plateforme: 'darwin',
});
assert.deepEqual(await fichiers.verrouiller({ id: 'documents' }, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
const enUtc = spawnSync('ps', ['-o', 'lstart=', '-p', String(process.pid)], {
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
encoding: 'utf8',
});
assert.equal(enUtc.status, 0, ditPar(enUtc));
assert.equal(pose.demarrage, enUtc.stdout.trim());
// Un second processus, dans un fuseau qui n'est pas celui de
// l'épreuve, relit le verrou par la même coquille.
const autre = Intl.DateTimeFormat().resolvedOptions().timeZone === 'Pacific/Kiritimati' ? 'Pacific/Pago_Pago' : 'Pacific/Kiritimati';
const lecteur = [
'const { creerFichiers } = await import(process.env.GTT_MODULE);',
'const fichiers = creerFichiers({ emplacements: {}, racines: { portable: null, documents: process.env.GTT_DOCUMENTS }, dialog: {}, shell: {}, plateforme: "darwin" });',
'const tenu = await fichiers.verrouiller({ id: "documents" }, "soiree.gtt.verrou", "seance-b");',
'process.stdout.write(JSON.stringify({ fuseau: Intl.DateTimeFormat().resolvedOptions().timeZone, tenu }));',
].join('\n');
const relu = spawnSync(process.execPath, ['--input-type=module', '-e', lecteur], {
env: {
...process.env,
TZ: autre,
GTT_MODULE: new URL('../electron/fichiers.js', import.meta.url).href,
GTT_DOCUMENTS: documents,
},
encoding: 'utf8',
});
assert.equal(relu.status, 0, ditPar(relu));
assert.deepEqual(JSON.parse(relu.stdout), {
fuseau: autre,
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },
});
},
);
});