gestion_table_tournante_libre/test/fichiers_electron.test.js
Mathieu Benoit a3d7badf6d [ADD] storage: copy, rename, import, delete events; local settings
§ 8.7 operations reuse the event store's pairing and alignment instead of
copying them. Renaming writes the complete pair under the new name — the
store now writes a gesture with the pair it writes elsewhere — before
deleting the old one, so no cut leaves zero pairs; an unchanged name writes
nothing. Deleting moves state, .precedent, journal, lock and leftovers into a
dated trash, matching names as the case-insensitive disk does. creerDossier
creates only the root it is aimed at, so a vanished working folder never
comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings;
its deferred writer always keeps the last demand.
Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %.

--- FR ---

[ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux

Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au
lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom —
le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant
de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom
inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et
restes dans une corbeille datée, en comparant les noms comme le disque
insensible à la casse. creerDossier ne crée que la racine visée : un dossier
de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux
réglages du § 8.5 ; son écrivain différé garde la dernière demande.
Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:05 -04:00

1421 lines
67 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du système de fichiers de la coquille (§ 8.6, § 8.8, § 13.1),
// sous node et sans disque réel. electron/fichiers.js y reçoit un disque en
// mémoire qui répond comme node:fs/promises sous POSIX, liens symboliques
// compris, consigne chaque appel et refuse ceux qu'on lui désigne. Les
// dialogues, l'explorateur, le lanceur de commandes, l'horloge, les pauses
// entre deux renommages et le signal aux processus y sont injectés de même.
// Suivent la page, src/stockage/fichiers_electron.js, derrière le vrai
// préchargement, et les définitions que la coquille recopie de src/stockage
// faute de pouvoir les importer. La suite de contrat se joue sur un vrai
// dossier, dans test/fichiers_electron.long.test.js. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { posix } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compileFunction } from 'node:vm';
import {
DELAI_SUPPORT_MS,
PAUSE_INITIALE_MS,
PAUSE_MAX_MS,
PREFIXE_CANAL,
PRIMITIVES,
REESSAIS_RENOMMAGE,
RefusFichier,
SUFFIXE_ECRITURE,
TEMOIN,
creerFichiers,
decrireSysteme,
processusVivant,
repondre,
segmentsAdmis,
servir,
} from '../electron/fichiers.js';
import { ErreurStockage } from '../src/stockage/erreurs.js';
import { creerFichiersElectron } from '../src/stockage/fichiers_electron.js';
import { SUFFIXE_ECRITURE as SUFFIXE_DU_STOCKAGE } from '../src/stockage/noms.js';
import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js';
import { describe, test } from './lanceur.js';
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
// Le périphérique du disque en mémoire, majeur 8 et mineur 1 dans le codage
// de glibc, et la machine du processus d'épreuve.
const DEV_DISQUE = 0x801n;
const HOTE = 'poste-essai';
const PID = 4242;
// Instant que rend l'horloge injectée.
const INSTANT = new Date(Date.UTC(2026, 9, 6, 23, 2, 11));
// Les racines de l'épreuve, et ce que les échecs y nomment.
const DOCUMENTS = '/poste/exemple/Documents/Gestion table tournante Libre';
const PUBLIE = '/media/cle/soirees';
const DATA = `${PUBLIE}/data`;
const AILLEURS = '/poste/exemple/ailleurs';
const DOC = { id: 'documents', chemin: DOCUMENTS };
const PORTABLE = { id: 'portable', chemin: DATA };
const FENETRE = { fenetre: 'principale' };
const EMPLACEMENTS = Object.freeze({
executable: PUBLIE,
donneesApplicatives: Object.freeze(['/poste/exemple/.config', '/tmp']),
insensibleCasse: false,
separateur: '/',
});
// ---------------------------------------------------------------------------
// Le disque en mémoire
// Une erreur du système telle que node:fs la lève : un code, l'appel
// système, le chemin. La coquille ne traduit que celles-là.
function erreurSysteme(code, syscall, chemin) {
return Object.assign(new Error(`${code}: ${syscall} '${chemin}'`), { code, syscall, path: chemin });
}
/**
* Disque en mémoire, sous POSIX : dossiers, fichiers et liens symboliques à
* cible absolue. fs porte les fonctions de node:fs/promises dont la coquille
* se sert et lève les erreurs du système dans les mêmes cas — ENOENT,
* ENOTDIR, EISDIR, EEXIST, ELOOP. Un lien se suit partout, sauf en dernier
* segment de lstat, unlink, rename, mkdir sans recursive et d'une ouverture
* exclusive (wx). mkdir sans recursive crée un seul dossier, son parent
* présent ; avec, tous ceux qui manquent. Les noms que rend readdir sortent
* dans l'ordre inverse de leur création : un tri omis se voit.
*
* appels chaque appel, « fonction chemin [drapeaux | cible] », les
* écritures d'une poignée comprises (write, sync, close)
* panne(fonction, code, { fois, chemin }) les fois prochains appels de
* cette fonction lèvent ce code, sur ce chemin seulement quand il
* est donné
* deposer, deposerDossier, deposerLien, contenu, existe préparent et
* lisent le disque sans rien consigner
*/
function creerDisque() {
const noeuds = new Map([['/', { type: 'dossier', octets: null, cible: null, modifie: 0, dev: DEV_DISQUE }]]);
const appels = [];
const pannes = [];
let horloge = 0;
const tic = () => {
horloge += 1000;
return horloge;
};
function appel(fonction, chemin, ...suite) {
appels.push([fonction, chemin, ...suite].join(' '));
const panne = pannes.find(
(p) => p.fonction === fonction && p.fois > 0 && (p.chemin === undefined || p.chemin === chemin),
);
if (panne !== undefined) {
panne.fois -= 1;
throw erreurSysteme(panne.code, fonction, chemin);
}
}
// Ce que désigne un chemin absolu : son chemin résolu et son nœud,
// undefined quand le dernier segment manque. Les liens des segments
// intermédiaires se suivent toujours, celui du dernier quand suivre est
// vrai.
function resoudre(chemin, suivre, syscall, profondeur = 0) {
if (profondeur > 8) throw erreurSysteme('ELOOP', syscall, chemin);
const segments = chemin.split('/').filter((segment) => segment !== '');
let courant = '/';
for (let i = 0; i < segments.length; i += 1) {
const suivant = posix.join(courant, segments[i]);
const noeud = noeuds.get(suivant);
const dernier = i === segments.length - 1;
if (noeud === undefined) {
if (dernier) return { chemin: suivant, noeud: undefined };
throw erreurSysteme('ENOENT', syscall, chemin);
}
if (noeud.type === 'lien' && (suivre || !dernier)) {
return resoudre(posix.join(noeud.cible, ...segments.slice(i + 1)), suivre, syscall, profondeur + 1);
}
if (!dernier && noeud.type !== 'dossier') throw erreurSysteme('ENOTDIR', syscall, chemin);
courant = suivant;
}
return { chemin: courant, noeud: noeuds.get(courant) };
}
function creer(chemin, type, champs = {}) {
const parent = noeuds.get(posix.dirname(chemin));
const noeud = { type, octets: null, cible: null, modifie: tic(), dev: parent?.dev ?? DEV_DISQUE, ...champs };
noeuds.set(chemin, noeud);
return noeud;
}
function statistiques(noeud, enGrandsEntiers = false) {
return {
isFile: () => noeud.type === 'fichier',
isDirectory: () => noeud.type === 'dossier',
isSymbolicLink: () => noeud.type === 'lien',
size: noeud.type === 'fichier' ? noeud.octets.length : 4096,
mtimeMs: noeud.modifie,
dev: enGrandsEntiers ? noeud.dev : Number(noeud.dev),
};
}
function poignee(chemin, noeud) {
return {
async writeFile(donnees) {
appel('write', chemin);
noeud.octets = Buffer.concat([noeud.octets, Buffer.from(donnees)]);
noeud.modifie = tic();
},
async sync() {
appel('sync', chemin);
},
async close() {
appel('close', chemin);
},
};
}
const fs = {
async lstat(chemin) {
appel('lstat', chemin);
const { noeud } = resoudre(chemin, false, 'lstat');
if (noeud === undefined) throw erreurSysteme('ENOENT', 'lstat', chemin);
return statistiques(noeud);
},
async stat(chemin, options) {
appel('stat', chemin);
const { noeud } = resoudre(chemin, true, 'stat');
if (noeud === undefined) throw erreurSysteme('ENOENT', 'stat', chemin);
return statistiques(noeud, options?.bigint === true);
},
async realpath(chemin) {
appel('realpath', chemin);
const resolu = resoudre(chemin, true, 'realpath');
if (resolu.noeud === undefined) throw erreurSysteme('ENOENT', 'realpath', chemin);
return resolu.chemin;
},
async readFile(chemin) {
appel('readFile', chemin);
const { noeud } = resoudre(chemin, true, 'open');
if (noeud === undefined) throw erreurSysteme('ENOENT', 'open', chemin);
if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'read', chemin);
return Buffer.from(noeud.octets);
},
async open(chemin, drapeaux) {
appel('open', chemin, drapeaux);
const exclusif = drapeaux === 'wx';
const resolu = resoudre(chemin, !exclusif, 'open');
if (exclusif && resolu.noeud !== undefined) throw erreurSysteme('EEXIST', 'open', chemin);
if (resolu.noeud?.type === 'dossier') throw erreurSysteme('EISDIR', 'open', chemin);
let noeud = resolu.noeud;
if (noeud === undefined) noeud = creer(resolu.chemin, 'fichier', { octets: Buffer.alloc(0) });
else if (drapeaux === 'w') noeud.octets = Buffer.alloc(0);
return poignee(chemin, noeud);
},
async rename(de, vers) {
appel('rename', de, vers);
const source = resoudre(de, false, 'rename');
if (source.noeud === undefined) throw erreurSysteme('ENOENT', 'rename', de);
const cible = resoudre(vers, false, 'rename');
if (cible.noeud?.type === 'dossier') throw erreurSysteme('EISDIR', 'rename', vers);
noeuds.delete(source.chemin);
noeuds.set(cible.chemin, source.noeud);
},
async unlink(chemin) {
appel('unlink', chemin);
const { chemin: resolu, noeud } = resoudre(chemin, false, 'unlink');
if (noeud === undefined) throw erreurSysteme('ENOENT', 'unlink', chemin);
if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'unlink', chemin);
noeuds.delete(resolu);
},
async mkdir(chemin, options) {
appel('mkdir', chemin);
if (options === undefined) {
const { chemin: resolu, noeud } = resoudre(chemin, false, 'mkdir');
if (noeud !== undefined) throw erreurSysteme('EEXIST', 'mkdir', chemin);
creer(resolu, 'dossier');
return;
}
assert.deepEqual(options, { recursive: true }, 'mkdir aux options inconnues');
const segments = chemin.split('/').filter((segment) => segment !== '');
let courant = '/';
for (let i = 0; i < segments.length; i += 1) {
const resolu = resoudre(posix.join(courant, segments[i]), true, 'mkdir');
if (resolu.noeud === undefined) creer(resolu.chemin, 'dossier');
else if (resolu.noeud.type !== 'dossier') {
throw erreurSysteme(i === segments.length - 1 ? 'EEXIST' : 'ENOTDIR', 'mkdir', chemin);
}
courant = resolu.chemin;
}
},
async readdir(chemin) {
appel('readdir', chemin);
const { chemin: resolu, noeud } = resoudre(chemin, true, 'scandir');
if (noeud === undefined) throw erreurSysteme('ENOENT', 'scandir', chemin);
if (noeud.type !== 'dossier') throw erreurSysteme('ENOTDIR', 'scandir', chemin);
return [...noeuds.keys()]
.filter((cle) => cle !== '/' && posix.dirname(cle) === resolu)
.map((cle) => posix.basename(cle))
.reverse();
},
};
// Crée les dossiers d'un chemin absolu qui manquent, sans rien consigner.
function dossiers(chemin, champs) {
let courant = '/';
for (const segment of chemin.split('/').filter((s) => s !== '')) {
courant = posix.join(courant, segment);
if (!noeuds.has(courant)) creer(courant, 'dossier', champs);
}
}
return {
fs,
appels,
panne(fonction, code, { fois = Infinity, chemin } = {}) {
pannes.push({ fonction, code, fois, chemin });
},
deposer(chemin, texte) {
dossiers(posix.dirname(chemin));
creer(chemin, 'fichier', { octets: Buffer.from(texte) });
},
deposerDossier(chemin, champs = {}) {
dossiers(chemin, champs);
},
deposerLien(chemin, cible) {
dossiers(posix.dirname(chemin));
creer(chemin, 'lien', { cible });
},
// Le texte d'un fichier, liens suivis ; null pour un absent ou un dossier.
contenu(chemin) {
const { noeud } = resoudre(chemin, true, 'contenu');
return noeud?.type === 'fichier' ? noeud.octets.toString('utf8') : null;
},
// Vrai quand le chemin désigne un nœud, liens des dossiers parents
// suivis, celui du dernier segment non.
existe(chemin) {
try {
return resoudre(chemin, false, 'existe').noeud !== undefined;
} catch {
return false;
}
},
// Noms des entrées directes d'un dossier, triés.
noms: (chemin) =>
[...noeuds.keys()]
.filter((cle) => cle !== '/' && posix.dirname(cle) === chemin)
.map((cle) => posix.basename(cle))
.sort(),
};
}
// Appels qui écrivent, dans l'ordre : ouvertures, écritures, vidages,
// fermetures, renommages, effacements, créations de dossier.
const ECRITURES = /^(open|write|sync|close|rename|unlink|mkdir) /;
const ecritures = (disque) => disque.appels.filter((ligne) => ECRITURES.test(ligne));
// ---------------------------------------------------------------------------
// La coquille sur ce disque
// Un dialogue d'Electron dont chaque appel consigne sa méthode, sa fenêtre
// parente et ses options, puis rend la réponse suivante, ou une annulation.
function dialogueFactice(reponses = []) {
const appels = [];
return {
appels,
async showOpenDialog(parent, options) {
appels.push({ methode: 'showOpenDialog', parent, options });
return reponses.shift() ?? { canceled: true, filePaths: [] };
},
async showSaveDialog(parent, options) {
appels.push({ methode: 'showSaveDialog', parent, options });
return reponses.shift() ?? { canceled: true, filePath: '' };
},
};
}
/**
* La coquille sur un disque en mémoire où la racine documents existe. Rend
* le disque, les primitives, et ce que l'épreuve lit des injections : les
* pauses demandées, les dossiers ouverts dans l'explorateur, les commandes
* lancées, les pid signalés.
*/
function preparer({
disque = creerDisque(),
dialog = dialogueFactice(),
ouvrir = async () => '',
lancer = async () => '',
signaler = () => {},
plateforme = 'linux',
hote = HOTE,
fs = disque.fs,
} = {}) {
if (!disque.existe(DOCUMENTS)) disque.deposerDossier(DOCUMENTS);
const pauses = [];
const ouvertures = [];
const commandes = [];
const signaux = [];
const fichiers = creerFichiers({
emplacements: EMPLACEMENTS,
racines: { portable: DATA, documents: DOCUMENTS },
dialog,
shell: {
openPath: async (chemin) => {
ouvertures.push(chemin);
return ouvrir(chemin);
},
},
fenetre: () => FENETRE,
fs,
chemins: posix,
plateforme,
lancer: async (commande, arguments_, options) => {
commandes.push({ commande, arguments_, options });
return lancer(commande, arguments_, options);
},
attendre: async (ms) => {
pauses.push(ms);
},
signaler: (pid) => {
signaux.push(pid);
return signaler(pid);
},
pid: PID,
hote,
maintenant: () => INSTANT,
});
return { disque, fichiers, dialog, pauses, ouvertures, commandes, signaux };
}
// 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 un refus de la coquille, de ce code et de ces détails exactement.
async function refuse(promesse, code, details, message = code) {
const erreur = await rejette(promesse, message);
assert.ok(erreur instanceof RefusFichier, `${message} : ${erreur}`);
assert.equal(erreur.code, code, message);
assert.deepEqual(erreur.details, details, message);
return erreur;
}
const ecriture = (chemin, dossier, cause) => ({ chemin, dossier, cause });
// Les primitives qui reçoivent un chemin, chacune appelée avec le chemin
// donné à la place qu'elle éprouve. deplacer s'éprouve par sa source, vers
// libre.txt, puis par sa cible, depuis ici.txt : l'épreuve pose ici.txt et
// laisse libre.txt absent, si bien que seul le chemin éprouvé peut faire
// échouer l'appel.
const APPELS_A_CHEMIN = [
['lireTexte', (fichiers, racine, chemin) => fichiers.lireTexte(racine, chemin)],
['ecrireAtomique', (fichiers, racine, chemin) => fichiers.ecrireAtomique(racine, chemin, 'x')],
['ajouterLigne', (fichiers, racine, chemin) => fichiers.ajouterLigne(racine, chemin, 'x')],
['lister', (fichiers, racine, chemin) => fichiers.lister(racine, chemin)],
['creerDossier', (fichiers, racine, chemin) => fichiers.creerDossier(racine, chemin)],
['deplacer, source', (fichiers, racine, chemin) => fichiers.deplacer(racine, chemin, 'libre.txt')],
['deplacer, cible', (fichiers, racine, chemin) => fichiers.deplacer(racine, 'ici.txt', chemin)],
['supprimer', (fichiers, racine, chemin) => fichiers.supprimer(racine, chemin)],
['verrouiller', (fichiers, racine, chemin) => fichiers.verrouiller(racine, chemin, 'seance-a')],
['deverrouiller', (fichiers, racine, chemin) => fichiers.deverrouiller(racine, chemin, 'seance-a')],
];
// ---------------------------------------------------------------------------
describe('coquille, fichiers : écriture atomique (§ 8.8)', () => {
const CIBLE = `${DOCUMENTS}/etat.json`;
const TEMPORAIRE = `${CIBLE}${SUFFIXE_ECRITURE}`;
test("écrit <chemin>.ecriture, le vide sur le disque, le ferme, puis le renomme par-dessus la cible, qui n'est jamais ouverte", async () => {
const { disque, fichiers, pauses } = preparer();
disque.deposer(CIBLE, 'ancien \u{E9}tat');
await fichiers.ecrireAtomique(DOC, 'etat.json', 'nouvel \u{E9}tat\n');
assert.equal(disque.contenu(CIBLE), 'nouvel \u{E9}tat\n');
assert.equal(disque.existe(TEMPORAIRE), false);
assert.deepEqual(
ecritures(disque).filter((ligne) => !ligne.startsWith('unlink ')),
[
`open ${TEMPORAIRE} wx`,
`write ${TEMPORAIRE}`,
`sync ${TEMPORAIRE}`,
`close ${TEMPORAIRE}`,
`rename ${TEMPORAIRE} ${CIBLE}`,
],
);
assert.deepEqual(disque.appels.filter((ligne) => ligne.startsWith(`open ${CIBLE} `)), []);
assert.deepEqual(pauses, []);
});
test('un renommage refusé deux fois puis accepté réussit, après des pauses de 50 puis 100 ms', async () => {
const { disque, fichiers, pauses } = preparer();
disque.deposer(CIBLE, 'ancien');
disque.panne('rename', 'EBUSY', { fois: 2 });
await fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau');
assert.equal(disque.contenu(CIBLE), 'nouveau');
assert.equal(disque.existe(TEMPORAIRE), false);
assert.deepEqual(pauses, [50, 100]);
assert.equal(disque.appels.filter((ligne) => ligne.startsWith('rename ')).length, 3);
});
test('EPERM, EBUSY et EACCES se réessaient dix fois : dix refus passent, onze lèvent ECRITURE {chemin, dossier, cause}, la cible intacte, le temporaire retiré', async () => {
assert.equal(REESSAIS_RENOMMAGE, 10);
assert.deepEqual([PAUSE_INITIALE_MS, PAUSE_MAX_MS], [50, 500]);
const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500];
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
const passe = preparer();
passe.disque.deposer(CIBLE, 'ancien');
passe.disque.panne('rename', cause, { fois: 10 });
await passe.fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau');
assert.equal(passe.disque.contenu(CIBLE), 'nouveau', cause);
assert.deepEqual(passe.pauses, PAUSES, cause);
const echoue = preparer();
echoue.disque.deposer(CIBLE, 'ancien');
echoue.disque.panne('rename', cause, { fois: 11 });
await refuse(
echoue.fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'),
'ECRITURE',
ecriture('etat.json', DOCUMENTS, cause),
cause,
);
assert.equal(echoue.disque.contenu(CIBLE), 'ancien', cause);
assert.equal(echoue.disque.existe(TEMPORAIRE), false, cause);
assert.deepEqual(echoue.pauses, PAUSES, cause);
assert.equal(echoue.disque.appels.filter((ligne) => ligne.startsWith('rename ')).length, 11, cause);
}
});
test('une autre cause de refus du renommage ne se réessaie pas', async () => {
for (const cause of ['EXDEV', 'EROFS', 'ENOSPC']) {
const { disque, fichiers, pauses } = preparer();
disque.deposer(CIBLE, 'ancien');
disque.panne('rename', cause, { fois: 1 });
await refuse(fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'), 'ECRITURE', ecriture('etat.json', DOCUMENTS, cause), cause);
assert.deepEqual(pauses, [], cause);
assert.equal(disque.appels.filter((ligne) => ligne.startsWith('rename ')).length, 1, cause);
assert.equal(disque.contenu(CIBLE), 'ancien', cause);
assert.equal(disque.existe(TEMPORAIRE), false, cause);
}
});
test("un temporaire qui ne s'écrit, ne se vide ou ne se ferme pas est retiré sans renommage, et la cible reste intacte", async () => {
for (const [fonction, cause] of [['open', 'EROFS'], ['write', 'ENOSPC'], ['sync', 'EIO'], ['close', 'EIO']]) {
const { disque, fichiers } = preparer();
disque.deposer(CIBLE, 'ancien');
disque.panne(fonction, cause, { chemin: TEMPORAIRE });
await refuse(fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'), 'ECRITURE', ecriture('etat.json', DOCUMENTS, cause), fonction);
assert.equal(disque.contenu(CIBLE), 'ancien', fonction);
assert.equal(disque.existe(TEMPORAIRE), false, fonction);
assert.deepEqual(disque.appels.filter((ligne) => ligne.startsWith('rename ')), [], fonction);
}
});
test("un temporaire resté d'une écriture interrompue est remplacé, et un lien posé à sa place n'est pas suivi", async () => {
const { disque, fichiers } = preparer();
disque.deposer(TEMPORAIRE, 'reste d\u{2019}une panne');
await fichiers.ecrireAtomique(DOC, 'etat.json', 'neuf');
assert.equal(disque.contenu(CIBLE), 'neuf');
disque.deposer(`${AILLEURS}/garde.json`, 'hors de la racine');
disque.deposerLien(TEMPORAIRE, `${AILLEURS}/garde.json`);
await fichiers.ecrireAtomique(DOC, 'etat.json', 'encore');
assert.equal(disque.contenu(CIBLE), 'encore');
assert.equal(disque.contenu(`${AILLEURS}/garde.json`), 'hors de la racine');
});
test("le dossier de la cible doit exister, et une cible qui est un dossier n'est pas remplacée", async () => {
const { disque, fichiers } = preparer();
await refuse(
fichiers.ecrireAtomique(DOC, 'disparu/etat.json', 'x'),
'ECRITURE',
ecriture('disparu/etat.json', `${DOCUMENTS}/disparu`, 'ENOENT'),
);
assert.equal(disque.existe(`${DOCUMENTS}/disparu`), false);
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
const avant = disque.appels.length;
await refuse(fichiers.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', ecriture('corbeille', DOCUMENTS, 'EISDIR'));
// Refusé avant d'écrire : aucun temporaire, aucun renommage, que Windows
// réessaierait en vain.
assert.deepEqual(ecritures({ appels: disque.appels.slice(avant) }), []);
assert.deepEqual(disque.noms(`${DOCUMENTS}/corbeille`), []);
assert.deepEqual(disque.noms(DOCUMENTS), ['corbeille']);
});
test("le texte doit être une chaîne : autre chose lève TypeError, sans toucher au disque", async () => {
const { disque, fichiers } = preparer();
await assert.rejects(fichiers.ecrireAtomique(DOC, 'etat.json', 42), TypeError);
assert.deepEqual(disque.appels, []);
});
});
describe("coquille, fichiers : l'ajout en fin", () => {
const JOURNAL = `${DOCUMENTS}/soiree.gtt-journal.jsonl`;
test('ouvre en ajout, écrit la ligne et sa fin de ligne, vide sur le disque et ferme ; crée le fichier absent', async () => {
const { disque, fichiers } = preparer();
await fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"type":"journal"}');
await fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"revision":1}');
assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n{"revision":1}\n');
assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${JOURNAL} a`, `write ${JOURNAL}`, `sync ${JOURNAL}`, `close ${JOURNAL}`]);
});
test("refuse une ligne qui porte une fin de ligne, et un dossier absent lève ECRITURE sans le créer", async () => {
const { disque, fichiers } = preparer();
for (const ligne of ['a\nb', 'a\rb', 'fin\n', 42]) {
await assert.rejects(fichiers.ajouterLigne(DOC, 'journal.jsonl', ligne), TypeError, String(ligne));
}
assert.deepEqual(disque.appels, []);
await refuse(
fichiers.ajouterLigne(DOC, 'disparu/journal.jsonl', 'x'),
'ECRITURE',
ecriture('disparu/journal.jsonl', `${DOCUMENTS}/disparu`, 'ENOENT'),
);
assert.equal(disque.existe(`${DOCUMENTS}/disparu`), false);
});
test("un ajout que le système refuse lève ECRITURE et ne retire pas le fichier", async () => {
const { disque, fichiers } = preparer();
disque.deposer(JOURNAL, '{"type":"journal"}\n');
disque.panne('write', 'ENOSPC');
await refuse(fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', ecriture('soiree.gtt-journal.jsonl', DOCUMENTS, 'ENOSPC'));
assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n');
assert.ok(disque.appels.includes(`close ${JOURNAL}`), 'poignée non fermée');
});
});
describe('coquille, fichiers : confinement aux racines', () => {
test("un chemin absolu, remontant, sous Windows ou porteur d'un NUL lève CHEMIN_REFUSE {chemin} à chaque primitive qui prend un chemin, avant tout accès au disque", async () => {
const { disque, fichiers } = preparer();
for (const chemin of ['../x', '/etc/x', 'C:\\x', 'a\u{0}b', 'a/../../x', '..']) {
for (const [primitive, appel] of APPELS_A_CHEMIN) {
await refuse(appel(fichiers, DOC, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`);
}
}
assert.deepEqual(disque.appels, []);
});
test("une racine inconnue, absente ou de forme fausse lève CHEMIN_REFUSE, même sous le chemin d'une racine connue", async () => {
const { disque, fichiers } = preparer();
for (const racine of [{ id: 'choisi-1', chemin: DOCUMENTS }, { id: 'constructor', chemin: DOCUMENTS }, null, 'documents', { chemin: DOCUMENTS }]) {
await refuse(fichiers.lireTexte(racine, 'x.txt'), 'CHEMIN_REFUSE', { chemin: 'x.txt' }, JSON.stringify(racine));
await refuse(fichiers.sonder(racine), 'CHEMIN_REFUSE', { chemin: '' }, JSON.stringify(racine));
await refuse(fichiers.typeSupport(racine), 'CHEMIN_REFUSE', { chemin: '' }, JSON.stringify(racine));
await refuse(fichiers.ouvrirDansExplorateur(racine), 'CHEMIN_REFUSE', { chemin: '' }, JSON.stringify(racine));
}
assert.deepEqual(disque.appels, []);
});
test("un lien symbolique qui sort de la racine ne fait pas sortir : dossier en chemin, fichier visé, lien sans cible", async () => {
const { disque, fichiers } = preparer();
// 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: HOTE, depuis: '2026-10-06T19:02:11-04:00' })}\n`;
disque.deposer(`${AILLEURS}/secret.txt`, 'hors de la racine');
disque.deposer(`${AILLEURS}/soiree.gtt.verrou`, verrou);
disque.deposerLien(`${DOCUMENTS}/dehors`, AILLEURS);
disque.deposerLien(`${DOCUMENTS}/secret.txt`, `${AILLEURS}/secret.txt`);
disque.deposerLien(`${DOCUMENTS}/fuite.jsonl`, `${AILLEURS}/neuf.jsonl`);
disque.deposer(`${DOCUMENTS}/ici.txt`, 'ici');
// Par le dossier lié : chaque primitive, sur un absent, sur un fichier,
// sur le verrou ; et la liste du dossier lié lui-même.
for (const chemin of ['dehors/neuf.txt', 'dehors/secret.txt', 'dehors/soiree.gtt.verrou']) {
for (const [primitive, appel] of APPELS_A_CHEMIN) {
await refuse(appel(fichiers, DOC, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`);
}
}
await refuse(fichiers.lister(DOC, 'dehors'), 'CHEMIN_REFUSE', { chemin: 'dehors' });
// Par un lien de fichier, et par un lien sans cible, que l'ouverture en
// ajout suivrait pour créer sa cible au-dehors.
await refuse(fichiers.lireTexte(DOC, 'secret.txt'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
await refuse(fichiers.ajouterLigne(DOC, 'secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
await refuse(fichiers.ajouterLigne(DOC, 'fuite.jsonl', 'x'), 'CHEMIN_REFUSE', { chemin: 'fuite.jsonl' });
await refuse(fichiers.deplacer(DOC, 'secret.txt', 'pris.txt'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
assert.deepEqual(ecritures(disque), []);
assert.deepEqual(disque.noms(AILLEURS), ['secret.txt', 'soiree.gtt.verrou']);
assert.equal(disque.contenu(`${AILLEURS}/secret.txt`), 'hors de la racine');
assert.equal(disque.contenu(`${AILLEURS}/soiree.gtt.verrou`), verrou);
assert.equal(disque.contenu(`${DOCUMENTS}/ici.txt`), 'ici');
});
test("un dossier voisin dont le nom prolonge celui de la racine est hors d'elle : les chemins réels se comparent segment par segment", async () => {
const voisin = `${DOCUMENTS} (copie)`;
const { disque, fichiers } = preparer();
disque.deposer(`${voisin}/secret.txt`, 'dans le dossier voisin');
disque.deposerLien(`${DOCUMENTS}/voisin`, voisin);
disque.deposer(`${DOCUMENTS}/ici.txt`, 'ici');
for (const chemin of ['voisin/secret.txt', 'voisin/neuf.txt']) {
for (const [primitive, appel] of APPELS_A_CHEMIN) {
await refuse(appel(fichiers, DOC, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`);
}
}
await refuse(fichiers.lister(DOC, 'voisin'), 'CHEMIN_REFUSE', { chemin: 'voisin' });
assert.deepEqual(ecritures(disque), []);
assert.deepEqual(disque.noms(voisin), ['secret.txt']);
assert.equal(disque.contenu(`${voisin}/secret.txt`), 'dans le dossier voisin');
assert.equal(disque.contenu(`${DOCUMENTS}/ici.txt`), 'ici');
});
test("une racine qui finit par le séparateur, celle d'un volume, porte tout ce qu'il contient", async () => {
// Sous Windows, la racine d'une clé choisie, E:\ ; sur ce disque, /.
const dialog = dialogueFactice([{ canceled: false, filePaths: ['/'] }]);
const { disque, fichiers } = preparer({ dialog });
const volume = await fichiers.choisirDossier();
assert.deepEqual(volume, { id: 'choisi-1', chemin: '/' });
await fichiers.ecrireAtomique(volume, 'soiree.gtt.json', 'sur le volume');
assert.equal(disque.contenu('/soiree.gtt.json'), 'sur le volume');
assert.equal(await fichiers.lireTexte(volume, 'soiree.gtt.json'), 'sur le volume');
});
test("un lien qui reste sous la racine se suit", async () => {
const { disque, fichiers } = preparer();
disque.deposerDossier(`${DOCUMENTS}/vrai`);
disque.deposerLien(`${DOCUMENTS}/alias`, `${DOCUMENTS}/vrai`);
await fichiers.ecrireAtomique(DOC, 'alias/etat.json', 'par le lien');
assert.equal(disque.contenu(`${DOCUMENTS}/vrai/etat.json`), 'par le lien');
assert.equal(await fichiers.lireTexte(DOC, 'alias/etat.json'), 'par le lien');
});
test("la racine se résout par son identifiant, et un chemin réel se compare à celui de la racine, liens résolus des deux côtés", async () => {
const disque = creerDisque();
disque.deposerDossier('/volume/reel/Documents/Gestion table tournante Libre');
disque.deposerLien('/poste/exemple/Documents', '/volume/reel/Documents');
const { fichiers } = preparer({ disque });
await fichiers.ecrireAtomique(DOC, 'etat.json', 'sous un lien de racine');
assert.equal(disque.contenu('/volume/reel/Documents/Gestion table tournante Libre/etat.json'), 'sous un lien de racine');
});
});
describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppression', () => {
test("lireTexte rend le texte tel qu'écrit, marque d'ordre d'octets comprise ; ABSENT pour un absent, un dossier ou un parent fichier ; LECTURE pour un autre refus", async () => {
const { disque, fichiers } = preparer();
disque.deposer(`${DOCUMENTS}/bom.csv`, '\u{FEFF}nom;pr\u{E9}nom\r\n');
assert.equal(await fichiers.lireTexte(DOC, 'bom.csv'), '\u{FEFF}nom;pr\u{E9}nom\r\n');
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
for (const chemin of ['absent.gtt.json', 'corbeille', 'bom.csv/x']) {
await refuse(fichiers.lireTexte(DOC, chemin), 'ABSENT', { chemin }, chemin);
}
disque.panne('readFile', 'EACCES');
await refuse(fichiers.lireTexte(DOC, 'bom.csv'), 'LECTURE', { chemin: 'bom.csv', dossier: DOCUMENTS, cause: 'EACCES' });
});
test("lister trie par unités UTF-16, type les entrées, donne 0 octet à un dossier et passe un lien sans cible", async () => {
const { disque, fichiers } = preparer();
disque.deposer(`${DOCUMENTS}/b.txt`, 'bb');
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
disque.deposer(`${DOCUMENTS}/\u{E9}.txt`, '\u{E9}');
disque.deposer(`${DOCUMENTS}/Z.txt`, '\u{1D11E}');
disque.deposer(`${DOCUMENTS}/a.txt`, '');
disque.deposerLien(`${DOCUMENTS}/mort`, `${DOCUMENTS}/nulle-part`);
disque.deposerLien(`${DOCUMENTS}/vers-b`, `${DOCUMENTS}/b.txt`);
const entrees = await fichiers.lister(DOC, '');
assert.deepEqual(
entrees.map(({ nom, type, taille }) => ({ nom, type, taille })),
[
{ nom: 'Z.txt', type: 'fichier', taille: 4 },
{ nom: 'a.txt', type: 'fichier', taille: 0 },
{ nom: 'b.txt', type: 'fichier', taille: 2 },
{ nom: 'corbeille', type: 'dossier', taille: 0 },
{ nom: 'vers-b', type: 'fichier', taille: 2 },
{ nom: '\u{E9}.txt', type: 'fichier', taille: 2 },
],
);
assert.ok(entrees.every((entree) => Number.isFinite(entree.modifie)), 'modifie');
});
test("lister d'un dossier absent ou d'un fichier lève ABSENT, et un autre refus LECTURE", async () => {
const { disque, fichiers } = preparer();
disque.deposer(`${DOCUMENTS}/fichier.txt`, 'x');
await refuse(fichiers.lister(DOC, 'absent'), 'ABSENT', { chemin: 'absent' });
await refuse(fichiers.lister(DOC, 'fichier.txt'), 'ABSENT', { chemin: 'fichier.txt' });
disque.panne('readdir', 'EACCES');
await refuse(fichiers.lister(DOC, ''), 'LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EACCES' });
});
test("creerDossier crée les parents ; un fichier en travers lève ECRITURE", async () => {
const { disque, fichiers } = preparer();
await fichiers.creerDossier(DOC, 'corbeille/2026-01-02_03-04-05');
await fichiers.creerDossier(DOC, 'corbeille');
assert.deepEqual(disque.noms(`${DOCUMENTS}/corbeille`), ['2026-01-02_03-04-05']);
disque.deposer(`${DOCUMENTS}/occupe`, 'x');
await refuse(fichiers.creerDossier(DOC, 'occupe'), 'ECRITURE', ecriture('occupe', DOCUMENTS, 'EEXIST'));
await refuse(fichiers.creerDossier(DOC, 'occupe/sous'), 'ECRITURE', ecriture('occupe/sous', `${DOCUMENTS}/occupe`, 'ENOTDIR'));
});
test("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 { disque, fichiers } = preparer();
await refuse(
fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'),
'ECRITURE',
ecriture('corbeille/2026-01-02_03-04-05', `${DATA}/corbeille`, 'ENOENT'),
);
assert.equal(disque.existe(PUBLIE), false);
await fichiers.creerDossier(PORTABLE, '');
assert.deepEqual(disque.noms(DATA), []);
await fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05');
assert.deepEqual(disque.noms(`${DATA}/corbeille`), ['2026-01-02_03-04-05']);
});
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => {
const { disque, fichiers, pauses } = preparer();
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
await fichiers.deplacer(DOC, 'soiree.gtt.json', 'corbeille/soiree.gtt.json');
assert.equal(disque.contenu(`${DOCUMENTS}/corbeille/soiree.gtt.json`), '\u{E9}tat');
assert.equal(disque.existe(`${DOCUMENTS}/soiree.gtt.json`), false);
disque.deposer(`${DOCUMENTS}/source.txt`, 'source');
disque.deposer(`${DOCUMENTS}/cible.txt`, 'cible');
await refuse(fichiers.deplacer(DOC, 'source.txt', 'cible.txt'), 'EXISTE', { chemin: 'cible.txt' });
await refuse(fichiers.deplacer(DOC, 'absent.txt', 'ailleurs.txt'), 'ABSENT', { chemin: 'absent.txt' });
await refuse(fichiers.deplacer(DOC, 'corbeille', 'poubelle'), 'ECRITURE', ecriture('corbeille', DOCUMENTS, 'EISDIR'));
await refuse(
fichiers.deplacer(DOC, 'source.txt', 'disparu/source.txt'),
'ECRITURE',
ecriture('disparu/source.txt', `${DOCUMENTS}/disparu`, 'ENOENT'),
);
disque.panne('rename', 'EBUSY');
await refuse(fichiers.deplacer(DOC, 'source.txt', 'autre.txt'), 'ECRITURE', ecriture('source.txt', DOCUMENTS, 'EBUSY'));
assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`rename ${DOCUMENTS}/source.txt`)).length, 1);
assert.deepEqual(pauses, []);
assert.equal(disque.contenu(`${DOCUMENTS}/source.txt`), 'source');
assert.equal(disque.contenu(`${DOCUMENTS}/cible.txt`), 'cible');
});
test("supprimer retire un fichier ; un absent n'est pas une faute ; un dossier lève ECRITURE", async () => {
const { disque, fichiers } = preparer();
disque.deposer(`${DOCUMENTS}/retire.txt`, 'x');
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
await fichiers.supprimer(DOC, 'retire.txt');
await fichiers.supprimer(DOC, 'retire.txt');
assert.deepEqual(disque.noms(DOCUMENTS), ['corbeille']);
await refuse(fichiers.supprimer(DOC, 'corbeille'), 'ECRITURE', ecriture('corbeille', DOCUMENTS, 'EISDIR'));
disque.deposer(`${DOCUMENTS}/tenu.txt`, 'x');
disque.panne('unlink', 'EBUSY');
await refuse(fichiers.supprimer(DOC, 'tenu.txt'), 'ECRITURE', ecriture('tenu.txt', DOCUMENTS, 'EBUSY'));
});
});
describe('coquille, fichiers : le verrou (§ 8.8)', () => {
const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`;
const deposerVerrou = (disque, contenu) => disque.deposer(VERROU, `${JSON.stringify(contenu)}\n`);
test("se crée en exclusif, porte {seance, pid, hote, depuis} et une fin de ligne ; depuis est l'instant de l'horloge, à l'heure locale et avec son décalage", async () => {
const { disque, fichiers } = preparer();
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${VERROU} wx`, `write ${VERROU}`, `sync ${VERROU}`, `close ${VERROU}`]);
const texte = disque.contenu(VERROU);
assert.ok(texte.endsWith('}\n'), texte);
const contenu = JSON.parse(texte);
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis']);
assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null });
assert.match(contenu.depuis, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/);
assert.equal(Date.parse(contenu.depuis), INSTANT.getTime());
});
test('un verrou présent rend sa séance : vivant quand son processus répond sur cette machine, même sans droit de le signaler', async () => {
for (const [nom, signaler, vivant] of [
['répond', () => {}, true],
['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, true],
['ESRCH', () => { throw erreurSysteme('ESRCH', 'kill', ''); }, false],
]) {
const { disque, fichiers, signaux } = preparer({ signaler });
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: '2026-10-06T19:02:11-04:00' });
assert.deepEqual(
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'),
{ pris: false, seance: 'seance-a', depuis: '2026-10-06T19:02:11-04:00', vivant },
nom,
);
assert.deepEqual(signaux, [777], nom);
}
});
test("le verrou d'une autre machine rend vivant à null sans signaler personne ; un verrou illisible rend tout à null", async () => {
const { disque, fichiers, signaux } = preparer();
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' });
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'seance-a',
depuis: '2026-10-06T19:02:11-04:00',
vivant: null,
});
assert.deepEqual(signaux, []);
const illisible = { pris: false, seance: null, depuis: null, vivant: null };
for (const texte of ['{"seance":"seance-a"', '{"seance":"seance-a","pid":0,"hote":"poste-essai","depuis":"x"}', '[]']) {
disque.deposer(VERROU, texte);
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), illisible, texte);
}
assert.deepEqual(signaux, []);
});
test("deverrouiller n'efface que le verrou de sa séance ; ni un absent ni un illisible ne sont des fautes", async () => {
const { disque, fichiers } = preparer();
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
assert.equal(disque.existe(VERROU), true);
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(disque.existe(VERROU), false);
disque.deposer(VERROU, 'illisible');
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.equal(disque.contenu(VERROU), 'illisible');
});
test("un verrou qui ne s'écrit pas jusqu'au bout est retiré, et lève ECRITURE ; un dossier à sa place lève ECRITURE", async () => {
const { disque, fichiers } = preparer();
disque.panne('write', 'ENOSPC', { chemin: VERROU });
await refuse(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', ecriture('soiree.gtt.verrou', DOCUMENTS, 'ENOSPC'));
assert.equal(disque.existe(VERROU), false);
disque.deposerDossier(`${DOCUMENTS}/dossier.gtt.verrou`);
await refuse(fichiers.verrouiller(DOC, 'dossier.gtt.verrou', 'seance-a'), 'ECRITURE', ecriture('dossier.gtt.verrou', DOCUMENTS, 'EISDIR'));
});
test("processusVivant : un processus qui répond au signal 0 vit, comme celui qu'on n'a pas le droit de signaler ; un absent non", () => {
const signaux = [];
const signalerEnPanne = (code) => (pid) => {
signaux.push(pid);
if (code !== null) throw erreurSysteme(code, 'kill', '');
};
assert.equal(processusVivant(31, signalerEnPanne(null)), true);
assert.equal(processusVivant(32, signalerEnPanne('EPERM')), true);
assert.equal(processusVivant(33, signalerEnPanne('ESRCH')), false);
assert.deepEqual(signaux, [31, 32, 33]);
assert.equal(processusVivant(process.pid), true);
});
test('la séance est une chaîne non vide', async () => {
const { disque, fichiers } = preparer();
for (const seance of ['', null, 7]) {
await assert.rejects(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', seance), TypeError, String(seance));
await assert.rejects(fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', seance), TypeError, String(seance));
}
assert.deepEqual(disque.appels, []);
});
});
describe("coquille, fichiers : la sonde d'écriture (§ 8.6)", () => {
const TEMOIN_DATA = `${DATA}/${TEMOIN}`;
test('crée la racine et ses parents, efface un témoin resté, écrit un témoin en exclusif, le vide, le relit et l\u{2019}efface', async () => {
const { disque, fichiers } = preparer();
assert.equal(TEMOIN, '.gtt-temoin');
assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: true, cause: null });
assert.deepEqual(disque.noms(DATA), []);
const suite = ecritures(disque).filter((ligne) => !ligne.startsWith('unlink ') || ligne === `unlink ${TEMOIN_DATA}`);
assert.deepEqual(suite, [
`mkdir ${DATA}`,
`unlink ${TEMOIN_DATA}`,
`open ${TEMOIN_DATA} wx`,
`write ${TEMOIN_DATA}`,
`sync ${TEMOIN_DATA}`,
`close ${TEMOIN_DATA}`,
`unlink ${TEMOIN_DATA}`,
]);
assert.ok(disque.appels.includes(`readFile ${TEMOIN_DATA}`), 'témoin non relu');
disque.deposer(TEMOIN_DATA, 'resté d\u{2019}une séance précédente');
disque.deposer(`${DATA}/garde.txt`, 'garde');
assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: true, cause: null });
assert.deepEqual(disque.noms(DATA), ['garde.txt']);
});
test("rend la cause d'un échec sans lever, et ne laisse aucun témoin", async () => {
for (const [fonction, cause] of [['mkdir', 'EACCES'], ['open', 'EROFS'], ['write', 'ENOSPC'], ['readFile', 'EIO'], ['unlink', 'EPERM']]) {
const { disque, fichiers } = preparer();
disque.panne(fonction, cause, fonction === 'unlink' ? { fois: 1 } : {});
assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: false, cause }, fonction);
assert.deepEqual(disque.existe(DATA) ? disque.noms(DATA) : [], [], fonction);
}
});
test('un témoin relu autrement qu\u{2019}écrit fait échouer la sonde : RELECTURE', async () => {
const disque = creerDisque();
const fs = {
...disque.fs,
readFile: async (chemin) => (chemin.endsWith(TEMOIN) ? Buffer.from('autre chose') : disque.fs.readFile(chemin)),
};
const { fichiers } = preparer({ disque, fs });
assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: false, cause: 'RELECTURE' });
assert.deepEqual(disque.noms(DATA), []);
});
test("une racine occupée par un fichier ne s'éprouve pas : sa cause est rendue", async () => {
const { disque, fichiers } = preparer();
disque.deposer(DATA, 'un fichier, pas un dossier');
assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: false, cause: 'EEXIST' });
assert.equal(disque.contenu(DATA), 'un fichier, pas un dossier');
});
});
describe('coquille, fichiers : le support de la racine (§ 8.6)', () => {
// Numéro de périphérique d'un majeur et d'un mineur dans le codage de
// glibc : les douze bits bas du majeur au-dessus des huit bits bas du
// mineur, le reste du mineur au-dessus, le reste du majeur au-dessus de
// tout.
const peripherique = (majeur, mineur) =>
BigInt(mineur & 0xff) |
(BigInt(majeur & 0xfff) << 8n) |
(BigInt(mineur & ~0xff) << 12n) |
(BigInt(Math.floor(majeur / 0x1000)) << 44n);
// Un disque dont la racine documents est sur le périphérique donné, et
// dont /sys/dev/block décrit ce périphérique comme le fait Linux.
function surPeripherique(majeur, mineur, { partition, amovible }) {
const disque = creerDisque();
disque.deposerDossier('/poste', { dev: peripherique(majeur, mineur) });
disque.deposerDossier(DOCUMENTS);
const bloc = '/sys/devices/pci0000:00/usb1/1-1/block/sdq';
const appareil = partition ? `${bloc}/sdq1` : bloc;
disque.deposerDossier(appareil);
if (partition) disque.deposer(`${appareil}/partition`, '1\n');
disque.deposer(`${bloc}/removable`, `${amovible}\n`);
disque.deposerLien(`/sys/dev/block/${majeur}:${mineur}`, appareil);
return disque;
}
test("sous Linux, l'attribut removable du disque qui porte la racine : celui du parent pour une partition, le sien pour un disque entier", async () => {
for (const [majeur, mineur, partition, amovible, attendu] of [
[8, 17, true, 1, 'amovible'],
[8, 300, true, 0, 'fixe'],
[259, 3, false, 1, 'amovible'],
[4097, 5, true, 1, 'amovible'],
]) {
const { fichiers } = preparer({ disque: surPeripherique(majeur, mineur, { partition, amovible }) });
assert.equal(await fichiers.typeSupport(DOC), attendu, `${majeur}:${mineur}`);
}
});
test("sous Linux, une racine pas encore créée se lit sur son plus proche parent, et un périphérique que /sys ne décrit pas rend inconnu", async () => {
const disque = surPeripherique(8, 17, { partition: true, amovible: 1 });
const { fichiers } = preparer({ disque });
// data/ et le dossier publié manquent : le plus proche parent est la
// racine du disque, sur un périphérique que /sys/dev/block ne décrit pas.
assert.equal(await fichiers.typeSupport(PORTABLE), 'inconnu');
disque.deposerDossier(PUBLIE, { dev: peripherique(8, 17) });
assert.equal(await fichiers.typeSupport(PORTABLE), 'amovible');
});
test("sous Windows, DriveInfo par PowerShell, la racine passée par l'environnement, borné à cinq secondes", async () => {
assert.equal(DELAI_SUPPORT_MS, 5000);
for (const [sortie, attendu] of [['Removable\r\n', 'amovible'], ['Fixed\r\n', 'fixe'], ['Network\r\n', 'inconnu'], ['', 'inconnu']]) {
const { fichiers, commandes } = preparer({ plateforme: 'win32', lancer: async () => sortie });
assert.equal(await fichiers.typeSupport(DOC), attendu, sortie);
assert.deepEqual(commandes, [
{
commande: 'powershell.exe',
arguments_: ['-NoProfile', '-NonInteractive', '-Command', '[System.IO.DriveInfo]::new($env:GTT_RACINE).DriveType'],
options: { variables: { GTT_RACINE: DOCUMENTS }, delai: 5000 },
},
]);
}
const { fichiers: enEchec } = preparer({
plateforme: 'win32',
lancer: async () => {
throw Object.assign(new Error('délai dépassé'), { killed: true, signal: 'SIGTERM' });
},
});
assert.equal(await enEchec.typeSupport(DOC), 'inconnu');
});
test('ailleurs, inconnu, sans commande', async () => {
const { fichiers, commandes } = preparer({ plateforme: 'darwin' });
assert.equal(await fichiers.typeSupport(DOC), 'inconnu');
assert.deepEqual(commandes, []);
});
});
describe('coquille, fichiers : dialogues et explorateur', () => {
test("choisirDossier ouvre le dialogue sur la fenêtre et rend une racine choisi-N, que les primitives connaissent dès lors ; une annulation rend null", async () => {
const choisi = '/poste/exemple/Bureau/soirees';
const dialog = dialogueFactice([
{ canceled: false, filePaths: [choisi] },
{ canceled: true, filePaths: [] },
{ canceled: false, filePaths: [AILLEURS] },
]);
const { disque, fichiers } = preparer({ dialog });
disque.deposerDossier(choisi);
const racine = await fichiers.choisirDossier();
assert.deepEqual(racine, { id: 'choisi-1', chemin: choisi });
assert.equal(await fichiers.choisirDossier(), null);
assert.deepEqual(await fichiers.choisirDossier(), { id: 'choisi-2', chemin: AILLEURS });
assert.deepEqual(
dialog.appels.map(({ methode, parent, options }) => ({ methode, parent, options })),
Array(3).fill({ methode: 'showOpenDialog', parent: FENETRE, options: { properties: ['openDirectory', 'createDirectory'] } }),
);
await fichiers.ecrireAtomique({ id: 'choisi-1' }, 'etat.json', 'choisi');
assert.equal(disque.contenu(`${choisi}/etat.json`), 'choisi');
await refuse(fichiers.lireTexte({ id: 'choisi-3' }, 'etat.json'), 'CHEMIN_REFUSE', { chemin: 'etat.json' });
});
test("choisirFichierAImporter rend le nom et les octets du fichier choisi ; une annulation rend null", async () => {
const fichier = '/poste/exemple/T\u{E9}l\u{E9}chargements/liste.csv';
const dialog = dialogueFactice([{ canceled: false, filePaths: [fichier] }]);
const { disque, fichiers } = preparer({ dialog });
disque.deposer(fichier, 'nom\r\n\u{C9}lan\r\n');
const choisi = await fichiers.choisirFichierAImporter();
assert.equal(choisi.nom, 'liste.csv');
assert.ok(choisi.octets instanceof Uint8Array);
assert.deepEqual([...choisi.octets], [...Buffer.from('nom\r\n\u{C9}lan\r\n')]);
assert.deepEqual(dialog.appels.map(({ parent, options }) => ({ parent, options })), [{ parent: FENETRE, options: { properties: ['openFile'] } }]);
assert.equal(await fichiers.choisirFichierAImporter(), null);
});
test("enregistrerSous propose le nom dans les Documents, écrit le fichier choisi par écriture atomique et rend son chemin ; une annulation n'écrit rien", async () => {
const choisi = '/poste/exemple/Documents/liste-retenue.csv';
const dialog = dialogueFactice([{ canceled: false, filePath: choisi }]);
const { disque, fichiers } = preparer({ dialog });
const octets = new Uint8Array(Buffer.from('nom\n\u{C9}lan\n'));
assert.equal(await fichiers.enregistrerSous('liste.csv', octets), choisi);
assert.equal(disque.contenu(choisi), 'nom\n\u{C9}lan\n');
assert.ok(ecritures(disque).includes(`rename ${choisi}${SUFFIXE_ECRITURE} ${choisi}`), 'pas de renommage');
assert.equal(await fichiers.enregistrerSous('../../liste.csv', octets), null);
assert.deepEqual(
dialog.appels.map(({ parent, options }) => ({ parent, options })),
[
{ parent: FENETRE, options: { defaultPath: '/poste/exemple/Documents/liste.csv' } },
{ parent: FENETRE, options: { defaultPath: '/poste/exemple/Documents/liste.csv' } },
],
);
await assert.rejects(fichiers.enregistrerSous('liste.csv', 'pas des octets'), TypeError);
assert.equal(dialog.appels.length, 2);
});
test("enregistrerSous qui ne peut écrire lève ECRITURE en nommant le fichier et son dossier", async () => {
const choisi = '/poste/exemple/Documents/liste.csv';
const dialog = dialogueFactice([{ canceled: false, filePath: choisi }]);
const { disque, fichiers } = preparer({ dialog });
disque.panne('open', 'EROFS');
await refuse(fichiers.enregistrerSous('liste.csv', new Uint8Array(1)), 'ECRITURE', ecriture('liste.csv', '/poste/exemple/Documents', 'EROFS'));
});
test("ouvrirDansExplorateur ouvre la racine ; une racine absente lève ABSENT sans rien ouvrir, un refus de l'explorateur NON_DISPONIBLE", async () => {
const { fichiers, ouvertures } = preparer();
await fichiers.ouvrirDansExplorateur(DOC);
assert.deepEqual(ouvertures, [DOCUMENTS]);
await refuse(fichiers.ouvrirDansExplorateur(PORTABLE), 'ABSENT', { chemin: '' });
assert.deepEqual(ouvertures, [DOCUMENTS]);
const { fichiers: sansExplorateur } = preparer({ ouvrir: async () => 'Failed to open path' });
await refuse(sansExplorateur.ouvrirDansExplorateur(DOC), 'NON_DISPONIBLE', { cause: 'Failed to open path' });
});
});
describe('coquille, fichiers : emplacements et racines du système (§ 8.6)', () => {
// app.getPath d'un poste d'épreuve, par plateforme.
const CHEMINS = {
win32: {
appData: 'C:\\Users\\Exemple\\AppData\\Roaming',
temp: 'C:\\Users\\Exemple\\AppData\\Local\\Temp',
documents: 'C:\\Users\\Exemple\\Documents',
},
linux: { appData: '/poste/exemple/.config', temp: '/tmp', documents: '/poste/exemple/Documents' },
darwin: {
appData: '/Users/exemple/Library/Application Support',
temp: '/var/folders/xy/T',
documents: '/Users/exemple/Documents',
},
};
const decrire = (plateforme, env) =>
decrireSysteme({
env,
plateforme,
cheminSysteme: (nom) => {
assert.ok(Object.hasOwn(CHEMINS[plateforme], nom), `app.getPath('${nom}')`);
return CHEMINS[plateforme][nom];
},
nomProduit: 'Gestion table tournante Libre',
});
test("sous Windows : le dossier publié et son data/, les Documents du produit, AppData, LOCALAPPDATA et le temporaire, sans égard à la casse", () => {
assert.deepEqual(
decrire('win32', { PORTABLE_EXECUTABLE_DIR: 'E:\\soirees', LOCALAPPDATA: 'C:\\Users\\Exemple\\AppData\\Local' }),
{
emplacements: {
executable: 'E:\\soirees',
donneesApplicatives: [
'C:\\Users\\Exemple\\AppData\\Roaming',
'C:\\Users\\Exemple\\AppData\\Local',
'C:\\Users\\Exemple\\AppData\\Local\\Temp',
],
insensibleCasse: true,
separateur: '\\',
},
racines: {
portable: 'E:\\soirees\\data',
documents: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre',
},
},
);
});
test('sans dossier publié, ou publié vide, ni exécutable ni racine portable', () => {
for (const env of [{}, { PORTABLE_EXECUTABLE_DIR: '' }]) {
const { emplacements, racines } = decrire('win32', env);
assert.equal(emplacements.executable, null, JSON.stringify(env));
assert.equal(racines.portable, null, JSON.stringify(env));
assert.deepEqual(emplacements.donneesApplicatives, [
'C:\\Users\\Exemple\\AppData\\Roaming',
'C:\\Users\\Exemple\\AppData\\Local\\Temp',
]);
}
});
test('sous Linux la casse compte, sous macOS non ; le séparateur suit la plateforme', () => {
const linux = decrire('linux', { PORTABLE_EXECUTABLE_DIR: '/media/cle/soirees' });
assert.deepEqual(linux.emplacements, {
executable: '/media/cle/soirees',
donneesApplicatives: ['/poste/exemple/.config', '/tmp'],
insensibleCasse: false,
separateur: '/',
});
assert.deepEqual(linux.racines, {
portable: '/media/cle/soirees/data',
documents: '/poste/exemple/Documents/Gestion table tournante Libre',
});
assert.equal(decrire('darwin', {}).emplacements.insensibleCasse, true);
});
test('emplacements et racines rendent à chaque appel des copies de ce que la coquille a reçu', async () => {
const { fichiers } = preparer();
const premier = await fichiers.emplacements();
assert.deepEqual(premier, EMPLACEMENTS);
premier.donneesApplicatives.push('/ajout');
premier.executable = null;
assert.deepEqual(await fichiers.emplacements(), EMPLACEMENTS);
assert.deepEqual(await fichiers.racines(), { portable: PORTABLE, documents: DOC });
const sansPortable = creerFichiers({
emplacements: { ...EMPLACEMENTS, executable: null },
racines: { portable: null, documents: DOCUMENTS },
fs: creerDisque().fs,
chemins: posix,
});
assert.deepEqual(await sansPortable.racines(), { portable: null, documents: DOC });
await refuse(sansPortable.lireTexte(PORTABLE, 'x.txt'), 'CHEMIN_REFUSE', { chemin: 'x.txt' });
});
});
describe('coquille, fichiers : le service IPC', () => {
test('servir pose un canal gtt:fichiers:<primitive> par primitive, dans l\u{2019}ordre de l\u{2019}interface', () => {
assert.equal(PREFIXE_CANAL, 'gtt:fichiers:');
const canaux = [];
servir(preparer().fichiers, (canal, gestionnaire) => {
assert.equal(typeof gestionnaire, 'function');
canaux.push(canal);
});
assert.deepEqual(canaux, PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`));
assert.equal(PRIMITIVES.length, 17);
assert.ok(Object.isFrozen(PRIMITIVES));
});
test("repondre rend { ok: true, valeur } ou, pour un refus, { ok: false, code, details } ; toute autre erreur se lève", async () => {
assert.deepEqual(await repondre(async () => 3), { ok: true, valeur: 3 });
assert.deepEqual(await repondre(async () => undefined), { ok: true, valeur: undefined });
assert.deepEqual(await repondre(async () => { throw new RefusFichier('EXISTE', { chemin: 'a.txt' }); }), {
ok: false,
code: 'EXISTE',
details: { chemin: 'a.txt' },
});
await assert.rejects(repondre(async () => { throw new TypeError('faute'); }), TypeError);
});
test("le gestionnaire d'un canal reçoit l'évènement d'Electron, puis les arguments de la primitive", async () => {
const gestionnaires = new Map();
const { disque, fichiers } = preparer();
servir(fichiers, (canal, gestionnaire) => gestionnaires.set(canal, gestionnaire));
const evenement = { sender: null };
assert.deepEqual(await gestionnaires.get('gtt:fichiers:ecrireAtomique')(evenement, DOC, 'etat.json', 'servi'), { ok: true, valeur: undefined });
assert.equal(disque.contenu(`${DOCUMENTS}/etat.json`), 'servi');
assert.deepEqual(await gestionnaires.get('gtt:fichiers:lireTexte')(evenement, DOC, 'absent.txt'), {
ok: false,
code: 'ABSENT',
details: { chemin: 'absent.txt' },
});
});
});
describe('coquille, fichiers : ce que la coquille recopie de src/stockage', () => {
test('la règle des segments est celle de exigerCheminRelatif, chemin par chemin', () => {
const chemins = [
'', 'soiree.gtt.json', 'corbeille/2026-01-02_03-04-05/soiree.gtt.json', '.gtt-temoin', '..soiree', ' devant',
'Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json', '/x', '//serveur/partage/x', 'a//b', 'a/', '..', '../x', 'a/../../x',
'.', './x', 'a/./b', '...', 'a/.. /x', 'soiree.', 'soiree ', 'C:\\x', 'C:x', 'C:/x', '\\\\serveur\\partage\\x',
'a\\b', 'soiree.gtt.json:flux', 'a\u{0}b', null, undefined, 42,
];
for (const chemin of chemins) {
let attendu;
try {
attendu = { segments: exigerCheminRelatif(chemin) };
} catch (erreur) {
assert.ok(erreur instanceof ErreurStockage, String(erreur));
attendu = { code: erreur.code, details: erreur.details };
}
let obtenu;
try {
obtenu = { segments: segmentsAdmis(chemin) };
} catch (erreur) {
assert.ok(erreur instanceof RefusFichier, String(erreur));
obtenu = { code: erreur.code, details: erreur.details };
}
assert.deepEqual(obtenu, attendu, String(chemin));
}
});
test("le suffixe de l'écriture atomique est celui de src/stockage/noms.js", () => {
assert.equal(SUFFIXE_ECRITURE, SUFFIXE_DU_STOCKAGE);
});
});
// ---------------------------------------------------------------------------
// La page derrière le pont
// 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
// une erreur levée n'en laisse passer que le message. Rend ce que le
// préchargement expose sous gtt, et les canaux invoqués.
function parLePrechargement(fichiers) {
const gestionnaires = new Map();
servir(fichiers, (canal, gestionnaire) => gestionnaires.set(canal, gestionnaire));
const exposes = new Map();
const invoques = [];
const electron = {
contextBridge: {
exposeInMainWorld: (cle, api) => exposes.set(cle, api),
},
ipcRenderer: {
async invoke(canal, ...parametres) {
invoques.push(canal);
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);
assert.deepEqual([...exposes.keys()], ['gtt']);
return { gtt: exposes.get('gtt'), invoques };
}
describe('page : src/stockage/fichiers_electron.js', () => {
// Un pont qui consigne chaque appel et rend la réponse suivante.
function pontFactice(reponses) {
const appels = [];
const pont = {};
for (const primitive of PRIMITIVES) {
pont[primitive] = async (...parametres) => {
appels.push([primitive, ...parametres]);
return reponses.shift();
};
}
return { pont, appels };
}
test('nature electron, renommage atomique et verrou ; les dix-sept primitives, dans l\u{2019}ordre de la coquille', () => {
const fs = creerFichiersElectron(pontFactice([]).pont);
assert.deepEqual(
{ nature: fs.nature, renommageAtomique: fs.renommageAtomique, verrouDisponible: fs.verrouDisponible },
{ nature: 'electron', renommageAtomique: true, verrouDisponible: true },
);
assert.deepEqual(
Object.keys(fs).filter((cle) => typeof fs[cle] === 'function'),
[...PRIMITIVES],
);
});
test('chaque primitive passe ses arguments, et eux seuls, à la fonction du même nom du pont, et rend sa valeur', async () => {
const octets = new Uint8Array([1, 2]);
const appels = [
['emplacements', []],
['racines', []],
['choisirDossier', []],
['sonder', [DOC]],
['typeSupport', [DOC]],
['lireTexte', [DOC, 'a.txt']],
['ecrireAtomique', [DOC, 'a.txt', 'texte']],
['ajouterLigne', [DOC, 'a.jsonl', 'ligne']],
['lister', [DOC, '']],
['creerDossier', [DOC, 'corbeille']],
['deplacer', [DOC, 'a.txt', 'b.txt']],
['supprimer', [DOC, 'a.txt']],
['verrouiller', [DOC, 'a.gtt.verrou', 'seance-a']],
['deverrouiller', [DOC, 'a.gtt.verrou', 'seance-a']],
['ouvrirDansExplorateur', [DOC]],
['choisirFichierAImporter', []],
['enregistrerSous', ['liste.csv', octets]],
];
const { pont, appels: recus } = pontFactice(appels.map((_, rang) => ({ ok: true, valeur: rang })));
const fs = creerFichiersElectron(pont);
for (const [rang, [primitive, parametres]] of appels.entries()) {
assert.equal(await fs[primitive](...parametres, 'argument en trop'), rang, primitive);
}
assert.deepEqual(recus, appels.map(([primitive, parametres]) => [primitive, ...parametres]));
});
test("une réponse { ok: false } devient l'ErreurStockage de ce code et de ces détails ; une réponse d'une autre forme, une TypeError", async () => {
const { pont } = pontFactice([
{ ok: false, code: 'ECRITURE', details: { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' } },
{ ok: false, code: 'CHEMIN_REFUSE', details: { chemin: '../x' } },
undefined,
{ ok: 'oui', valeur: 1 },
{ ok: false, details: {} },
]);
const fs = creerFichiersElectron(pont);
const premiere = await rejette(fs.ecrireAtomique(DOC, 'a.txt', 'x'));
assert.ok(premiere instanceof ErreurStockage);
assert.deepEqual([premiere.code, premiere.details], ['ECRITURE', { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' }]);
const seconde = await rejette(fs.lireTexte(DOC, '../x'));
assert.deepEqual([seconde.code, seconde.details], ['CHEMIN_REFUSE', { chemin: '../x' }]);
for (let i = 0; i < 3; i += 1) {
const erreur = await rejette(fs.lister(DOC, ''));
assert.ok(erreur instanceof TypeError && !(erreur instanceof ErreurStockage), String(erreur));
}
});
test("à travers le vrai préchargement : chaque primitive de la page appelle son canal, et un refus de la coquille revient en ErreurStockage", async () => {
const { disque, fichiers } = preparer();
const { gtt, invoques } = parLePrechargement(fichiers);
assert.deepEqual(Object.keys(gtt), ['fichiers']);
assert.deepEqual(Object.keys(gtt.fichiers), [...PRIMITIVES]);
assert.ok(Object.values(gtt.fichiers).every((fonction) => typeof fonction === 'function'));
const fs = creerFichiersElectron(gtt.fichiers);
await fs.ecrireAtomique(DOC, 'soiree.gtt.json', '{"entete":{}}\n');
assert.equal(await fs.lireTexte(DOC, 'soiree.gtt.json'), '{"entete":{}}\n');
assert.equal(disque.contenu(`${DOCUMENTS}/soiree.gtt.json`), '{"entete":{}}\n');
const refus = await rejette(fs.deplacer(DOC, 'soiree.gtt.json', '../dehors.json'));
assert.ok(refus instanceof ErreurStockage, String(refus));
assert.deepEqual([refus.code, refus.details], ['CHEMIN_REFUSE', { chemin: '../dehors.json' }]);
const faute = await rejette(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 42));
assert.ok(!(faute instanceof ErreurStockage), String(faute));
assert.match(faute.message, /gtt:fichiers:ecrireAtomique/);
assert.deepEqual(invoques, [
'gtt:fichiers:ecrireAtomique',
'gtt:fichiers:lireTexte',
'gtt:fichiers:deplacer',
'gtt:fichiers:ecrireAtomique',
]);
});
});