§ 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
1421 lines
67 KiB
JavaScript
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',
|
|
]);
|
|
});
|
|
});
|