[ADD] storage: web file system over the origin private file system

Under the web platform, used to develop and test on Linux, events live
in the browser's origin private file system: one root, with no
executable folder, no atomic rename and no lock, which it declares so
the start-up banner can say so (§ 8.8). Imports come through a file
input and exports through a download link. The same contract suite as
the test double runs on it in Chromium, its exclusivity check skipped
because there is no lock to test.

Checked: the contract suite in the browser project; one review round.

--- FR ---

[ADD] stockage : système de fichiers web, sur le stockage de l'origine

Sous la plateforme web, qui sert au développement et aux épreuves sous
Linux, les événements vivent dans le stockage privé de l'origine du
navigateur : une seule racine, sans dossier d'exécutable, sans renommage
atomique ni verrou, ce qu'il déclare pour que le bandeau de démarrage le
dise (§ 8.8). Les imports passent par un champ de fichier, les exports
par un lien de téléchargement. La suite de contrat du double s'y joue
dans Chromium, sauf l'exclusivité, faute de verrou à éprouver.

Vérifié : la suite de contrat sous le projet navigateur ; une revue.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
Mathieu Benoit 2026-10-06 17:03:45 -04:00
parent 2bca543e7e
commit 99524aa9bf
2 changed files with 939 additions and 0 deletions

View file

@ -0,0 +1,441 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Système de fichiers de la plateforme web (§ 13.1, § 13.4) : l'interface de
// systeme_fichiers.js portée par l'OPFS du navigateur, que
// navigator.storage.getDirectory() ouvre. Avec fichiers_electron.js, ce module
// est l'une des deux exceptions nommées de la frontière des couches : il
// touche navigator pour l'OPFS, et document pour le champ de fichier et le
// lien de téléchargement. Il n'y touche qu'à l'appel d'une primitive : le
// charger hors d'un navigateur ne lève rien.
//
// Une seule racine, documents : le dossier gestion_table_tournante_libre de
// l'OPFS, affiché « /gestion_table_tournante_libre ». Ni exécutable publié,
// ni données applicatives, ni racine portable ; le support est inconnu.
//
// La plateforme n'offre ni renommage atomique ni verrou (§ 8.8) et le dit :
// renommageAtomique et verrouDisponible sont faux, et l'application
// l'annonce au démarrage. ecrireAtomique et ajouterLigne écrivent par un flux
// de l'OPFS ouvert sur la cible (createWritable) : le contenu n'atteint la
// cible qu'à la fermeture du flux ; un échec avant elle abandonne le flux et
// retire le fichier que l'appel a créé, si bien que la cible reste telle
// qu'avant l'appel. Rien ne garantit ce que devient une fermeture
// interrompue. deplacer copie les octets de la source sous la cible, puis
// retire la source ; un échec du retrait retire la copie. verrouiller rend
// { pris: true } sans rien écrire, et deverrouiller n'efface rien.
//
// L'OPFS compare les noms à la lettre : la casse et la forme de normalisation
// distinguent deux fichiers, d'où insensibleCasse faux. Il ne date pas un
// dossier, que lister date de 0. Pendant l'écriture d'un flux, Chromium peut
// poser à côté de la cible un fichier d'échange, <nom>.crswap, que lister
// rend comme toute entrée ; un fichier qui disparaît entre la liste et sa
// lecture n'est pas rendu.
//
// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit
// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou
// TypeMismatchError pour une entrée d'un autre genre que celui demandé —
// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une
// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les
// deux portent {chemin, dossier, cause}, dont la cause est le nom de
// l'exception : SecurityError pour un OPFS que le navigateur refuse à
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le
// lit, NotFoundError pour une écriture dans un dossier absent,
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
// elle-même, un dossier.
//
// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
// passe par un champ de fichier, que le navigateur n'ouvre que dans le
// prolongement d'un geste de l'opérateur, et qui ne dit du fichier choisi
// que son nom : l'échec de sa lecture le nomme, dossier null.
// enregistrerSous passe par un lien de téléchargement : le navigateur range
// le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend
// le nom proposé.
import { ErreurStockage } from './erreurs.js';
import { exigerCheminRelatif } from './systeme_fichiers.js';
// Le dossier du produit dans l'OPFS, et la racine documents qui le désigne.
const DOSSIER_PRODUIT = 'gestion_table_tournante_libre';
const DOCUMENTS = 'documents';
const CHEMIN_DOCUMENTS = `/${DOSSIER_PRODUIT}`;
// Nom du témoin de la sonde (§ 8.6), qui porte aussi ce nom pour texte.
const TEMOIN = '.gtt-temoin';
// Cause d'une écriture qui vise un dossier — la racine elle-même, ou un
// dossier là où un fichier est attendu —, celle que lève l'OPFS.
const TYPE_DIFFERENT = 'TypeMismatchError';
// Cause d'une sonde dont le témoin relu diffère du texte écrit.
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
// retirer celle qu'il porte, que Blob.text retirerait. Un octet qui n'est pas
// de l'UTF-8 se lit U+FFFD.
const UTF8 = new TextEncoder();
const DECODEUR = new TextDecoder('utf-8', { ignoreBOM: true });
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
const ignorer = () => {};
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
// d'un autre genre que celui demandé.
const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT;
// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son
// texte quand elle n'en porte pas.
const causeDe = (erreur) => (typeof erreur?.name === 'string' && erreur.name !== '' ? erreur.name : String(erreur));
function exigerChaine(valeur, quoi) {
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
}
function exigerSeance(seance) {
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
}
// Ce que désigne un chemin relatif à la racine : ses segments, ceux du
// dossier qui le porte, son nom — null pour '', la racine elle-même —, et le
// chemin affichable de ce dossier, celui de la racine pour ''. CHEMIN_REFUSE
// pour un chemin refusé ou une racine inconnue, reconnue par son seul
// identifiant.
function localiser(racine, chemin) {
const segments = exigerCheminRelatif(chemin);
if (racine?.id !== DOCUMENTS) throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
const parent = segments.slice(0, -1);
return {
chemin,
segments,
parent,
nom: segments.length > 0 ? segments[segments.length - 1] : null,
dossier: [CHEMIN_DOCUMENTS, ...parent].join('/'),
};
}
const refus = (loc, cause) => new ErreurStockage('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause });
const absent = (chemin) => new ErreurStockage('ABSENT', { chemin });
// Ce que devient l'exception d'une lecture de loc : ABSENT quand l'entrée
// manque, LECTURE sinon.
const echecLecture = (loc, erreur) =>
estAbsence(erreur)
? absent(loc.chemin)
: new ErreurStockage('LECTURE', { chemin: loc.chemin, dossier: loc.dossier, cause: causeDe(erreur) });
// Exécute travail, une écriture sur loc, et rend ce qu'il rend : une
// ErreurStockage passe telle quelle, toute autre exception devient ECRITURE.
async function ecrire(loc, travail) {
try {
return await travail();
} catch (erreur) {
if (erreur instanceof ErreurStockage) throw erreur;
throw refus(loc, causeDe(erreur));
}
}
// Poignée du dossier que désignent les segments sous le dossier du produit ;
// creer crée ce qui manque, dossier du produit compris. Lève l'exception de
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
// fichier à sa place.
async function dossierDe(segments, creer = false) {
const options = { create: creer };
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, options);
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
return dossier;
}
// Le dossier que désignent les segments, ou null quand il n'existe pas.
async function dossierExistant(segments) {
try {
return await dossierDe(segments);
} catch (erreur) {
if (estAbsence(erreur)) return null;
throw erreur;
}
}
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
// n'existe pas.
async function genreDe(dossier, nom) {
try {
await dossier.getFileHandle(nom);
return 'fichier';
} catch (erreur) {
if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
if (erreur?.name === 'NotFoundError') return null;
throw erreur;
}
}
// Retire l'entrée nom du dossier ; une entrée absente n'est pas une faute.
async function retirer(dossier, nom) {
try {
await dossier.removeEntry(nom);
} catch (erreur) {
if (erreur?.name !== 'NotFoundError') throw erreur;
}
}
// Texte d'un fichier : ses octets décodés tels quels.
const lire = async (poignee) => DECODEUR.decode(await (await poignee.getFile()).arrayBuffer());
// Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux,
// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur
// un contenu vide. Le contenu n'atteint le fichier qu'à la fermeture du flux.
// Un échec abandonne le flux, retire le fichier quand l'appel l'a créé, puis
// se relance : le fichier reste tel qu'avant l'appel.
async function ecrireParFlux(dossier, nom, garder, remplir) {
let poignee;
let cree = false;
try {
poignee = await dossier.getFileHandle(nom);
} catch (erreur) {
if (erreur?.name !== 'NotFoundError') throw erreur;
poignee = await dossier.getFileHandle(nom, { create: true });
cree = true;
}
let flux = null;
try {
flux = await poignee.createWritable({ keepExistingData: garder });
await remplir(flux, poignee);
await flux.close();
} catch (erreur) {
if (flux !== null) await flux.abort().catch(ignorer);
if (cree) await dossier.removeEntry(nom).catch(ignorer);
throw erreur;
}
}
// Description d'une entrée d'un dossier, ou null pour un fichier disparu
// entre la liste et sa lecture.
async function decrire(poignee) {
if (poignee.kind === 'directory') return { nom: poignee.name, type: 'dossier', taille: 0, modifie: 0 };
try {
const fichier = await poignee.getFile();
return { nom: poignee.name, type: 'fichier', taille: fichier.size, modifie: fichier.lastModified };
} catch (erreur) {
if (erreur?.name === 'NotFoundError') return null;
throw erreur;
}
}
// Ordre des entrées : leurs noms comparés par unités UTF-16, une à une.
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier.
async function lireSource(loc) {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const dossier = await dossierExistant(loc.parent);
const genre = dossier === null ? null : await genreDe(dossier, loc.nom);
if (genre === null) throw absent(loc.chemin);
if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT);
const fichier = await (await dossier.getFileHandle(loc.nom)).getFile();
return { dossier, octets: await fichier.arrayBuffer() };
}
// Écrit les octets sous la cible de deplacer, et rend le dossier qui la
// porte. EXISTE quand une entrée y porte déjà ce nom, quel qu'en soit le
// genre, et pour '', la racine elle-même.
async function copierVers(loc, octets) {
if (loc.nom === null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin });
const dossier = await dossierDe(loc.parent);
if ((await genreDe(dossier, loc.nom)) !== null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin });
await ecrireParFlux(dossier, loc.nom, false, (flux) => flux.write(octets));
return dossier;
}
// Ouvre le dialogue du navigateur par un champ de fichier, hors du document,
// et rend le nom et les octets du fichier choisi ; null quand l'opérateur
// annule — l'événement cancel — ou que le champ revient sans fichier. Un
// fichier choisi qui ne se lit pas lève ABSENT ou LECTURE sous son nom.
function choisirFichier() {
return new Promise((resoudre, rejeter) => {
const champ = document.createElement('input');
champ.type = 'file';
champ.addEventListener('cancel', () => resoudre(null), { once: true });
champ.addEventListener(
'change',
() => {
const fichier = champ.files[0];
if (fichier === undefined) {
resoudre(null);
return;
}
fichier.arrayBuffer().then(
(tampon) => resoudre({ nom: fichier.name, octets: new Uint8Array(tampon) }),
(erreur) => rejeter(echecLecture({ chemin: fichier.name, dossier: null }, erreur)),
);
},
{ once: true },
);
champ.click();
});
}
/**
* Système de fichiers de la plateforme web, sur l'OPFS. Chaque appel rend un
* système neuf ; tous partagent l'OPFS de l'origine.
*
* @returns {import('./systeme_fichiers.js').SystemeFichiers}
*/
export function creerFichiersWeb() {
return {
nature: 'web',
renommageAtomique: false,
verrouDisponible: false,
emplacements: async () => ({ executable: null, donneesApplicatives: [], insensibleCasse: false, separateur: '/' }),
racines: async () => ({ portable: null, documents: { id: DOCUMENTS, chemin: CHEMIN_DOCUMENTS } }),
choisirDossier: async () => {
throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'choisirDossier' });
},
// Crée le dossier du produit, efface un témoin resté, écrit le témoin, le
// relit, l'efface. Un échec rend sa cause et efface le témoin qu'il
// laisse ; seule une racine inconnue lève.
sonder: async (racine) => {
localiser(racine, '');
let dossier = null;
try {
dossier = await dossierDe([], true);
await retirer(dossier, TEMOIN);
await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN)));
const relu = await lire(await dossier.getFileHandle(TEMOIN));
await retirer(dossier, TEMOIN);
return relu === TEMOIN
? { inscriptible: true, cause: null }
: { inscriptible: false, cause: RELECTURE_DIFFERENTE };
} catch (erreur) {
if (dossier !== null) await retirer(dossier, TEMOIN).catch(ignorer);
return { inscriptible: false, cause: causeDe(erreur) };
}
},
typeSupport: async (racine) => {
localiser(racine, '');
return 'inconnu';
},
lireTexte: async (racine, chemin) => {
const loc = localiser(racine, chemin);
if (loc.nom === null) throw absent(chemin);
try {
return await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
} catch (erreur) {
throw echecLecture(loc, erreur);
}
},
ecrireAtomique: async (racine, chemin, texte) => {
exigerChaine(texte, 'le texte');
const loc = localiser(racine, chemin);
await ecrire(loc, async () => {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const octets = UTF8.encode(texte);
await ecrireParFlux(await dossierDe(loc.parent), loc.nom, false, (flux) => flux.write(octets));
});
},
// Le flux garde le contenu présent et écrit la ligne à sa fin.
ajouterLigne: async (racine, chemin, ligne) => {
exigerChaine(ligne, 'la ligne');
if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne');
const loc = localiser(racine, chemin);
await ecrire(loc, async () => {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const octets = UTF8.encode(`${ligne}\n`);
await ecrireParFlux(await dossierDe(loc.parent), loc.nom, true, async (flux, poignee) => {
const { size } = await poignee.getFile();
await flux.write({ type: 'write', position: size, data: octets });
});
});
},
lister: async (racine, chemin) => {
const loc = localiser(racine, chemin);
const entrees = [];
try {
for await (const poignee of (await dossierDe(loc.segments)).values()) {
const entree = await decrire(poignee);
if (entree !== null) entrees.push(entree);
}
} catch (erreur) {
throw echecLecture(loc, erreur);
}
return entrees.sort(parNom);
},
creerDossier: async (racine, chemin) => {
const loc = localiser(racine, chemin);
await ecrire(loc, () => dossierDe(loc.segments, true));
},
// Un échec de la lecture de la source ou de son retrait nomme la source ;
// un échec de la copie nomme la cible.
deplacer: async (racine, de, vers) => {
const source = localiser(racine, de);
const cible = localiser(racine, vers);
const { dossier: dossierSource, octets } = await ecrire(source, () => lireSource(source));
const dossierCible = await ecrire(cible, () => copierVers(cible, octets));
await ecrire(source, async () => {
try {
await dossierSource.removeEntry(source.nom);
} catch (erreur) {
await dossierCible.removeEntry(cible.nom).catch(ignorer);
throw erreur;
}
});
},
// Retire un fichier ; un dossier, la racine comprise, lève ECRITURE
// TypeMismatchError et reste.
supprimer: async (racine, chemin) => {
const loc = localiser(racine, chemin);
await ecrire(loc, async () => {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const dossier = await dossierExistant(loc.parent);
const genre = dossier === null ? null : await genreDe(dossier, loc.nom);
if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT);
if (genre === 'fichier') await retirer(dossier, loc.nom);
});
},
verrouiller: async (racine, chemin, seance) => {
exigerSeance(seance);
localiser(racine, chemin);
return { pris: true };
},
deverrouiller: async (racine, chemin, seance) => {
exigerSeance(seance);
localiser(racine, chemin);
},
ouvrirDansExplorateur: async (racine) => {
localiser(racine, '');
throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' });
},
choisirFichierAImporter: () => choisirFichier(),
// Le clic remet au navigateur l'adresse du contenu, qui se libère à la
// tâche suivante.
enregistrerSous: async (nomPropose, octets) => {
if (typeof nomPropose !== 'string' || nomPropose === '') {
throw new TypeError("le nom proposé n'est pas une chaîne non vide");
}
if (!(octets instanceof Uint8Array)) throw new TypeError("les octets ne sont pas un Uint8Array");
const adresse = URL.createObjectURL(new Blob([octets], { type: 'application/octet-stream' }));
const lien = document.createElement('a');
lien.href = adresse;
lien.download = nomPropose;
lien.click();
setTimeout(() => URL.revokeObjectURL(adresse), 0);
return nomPropose;
},
};
}

View file

@ -0,0 +1,498 @@
// © 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 plateforme web (§ 8.8, § 13.1,
// § 13.4), dans Chromium, sur son OPFS réel, vidé avant chaque épreuve. La
// suite de contrat test/contrat_fichiers.js s'y joue avec expect ; suit ce
// qui n'appartient qu'à la plateforme web : sa nature, ses emplacements, sa
// racine unique — le dossier gestion_table_tournante_libre de l'OPFS —, la
// correspondance des refus de l'OPFS avec les codes du stockage, le verrou
// qui n'en est pas un, les primitives qui n'existent pas sous web, le choix
// d'un fichier à importer et l'enregistrement par téléchargement.
//
// Une panne de l'OPFS se provoque en remplaçant, le temps d'une épreuve, une
// méthode de ses prototypes (vi.spyOn), que afterEach rétablit ; un dialogue,
// en remplaçant le clic du champ de fichier ou du lien. Les mains de
// l'épreuve sur l'OPFS passent par navigator.storage, jamais par le module
// éprouvé. Le projet navigateur n'importe pas node:assert/strict : il emploie
// expect, et prend test, describe et les crochets chez Vitest. Les noms
// d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { eprouverContrat } from '../../test/contrat_fichiers.js';
import { ErreurStockage } from './erreurs.js';
import { creerFichiersWeb } from './fichiers_web.js';
// Le dossier du produit dans l'OPFS, et la racine qui le désigne.
const DOSSIER = 'gestion_table_tournante_libre';
const CHEMIN_DOCUMENTS = '/gestion_table_tournante_libre';
const DOCUMENTS = { id: 'documents', chemin: CHEMIN_DOCUMENTS };
// Octets d'un fichier venu d'ailleurs : marque d'ordre d'octets, CRLF, et un
// octet qui n'est pas de l'UTF-8.
const BRUTS = new Uint8Array([0xef, 0xbb, 0xbf, 0x6e, 0x6f, 0x6d, 0x3b, 0x0d, 0x0a, 0xff]);
const opfs = () => navigator.storage.getDirectory();
// Retire toute entrée de la racine de l'OPFS.
async function viderOpfs() {
const racine = await opfs();
const noms = [];
for await (const nom of racine.keys()) noms.push(nom);
for (const nom of noms) await racine.removeEntry(nom, { recursive: true });
}
// Noms des entrées de la racine de l'OPFS, triés.
async function nomsOpfs() {
const noms = [];
for await (const nom of (await opfs()).keys()) noms.push(nom);
return noms.sort();
}
// Le dossier du produit, lu dans l'OPFS ; NotFoundError quand il manque.
const dossierProduit = async () => (await opfs()).getDirectoryHandle(DOSSIER);
// Octets du fichier que désignent les segments sous le dossier du produit.
async function octetsOpfs(...segments) {
let dossier = await dossierProduit();
for (const segment of segments.slice(0, -1)) dossier = await dossier.getDirectoryHandle(segment);
const fichier = await (await dossier.getFileHandle(segments.at(-1))).getFile();
return new Uint8Array(await fichier.arrayBuffer());
}
// Pose des octets sous un nom, à la racine du dossier du produit.
async function poserOpfs(nom, octets) {
const flux = await (await (await dossierProduit()).getFileHandle(nom, { create: true })).createWritable();
await flux.write(octets);
await flux.close();
}
// Un système neuf sur un dossier du produit qui existe et ne porte rien.
async function preparer() {
await (await opfs()).getDirectoryHandle(DOSSIER, { create: true });
return creerFichiersWeb();
}
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
async function rejette(promesse, message) {
try {
await promesse;
} catch (raison) {
return raison;
}
return expect.unreachable(`${message} : la promesse se résout`);
}
// Attend le rejet d'une ErreurStockage de ce code et de ces détails.
async function echoue(promesse, code, details, message = code) {
const erreur = await rejette(promesse, message);
expect(erreur, message).toBeInstanceOf(ErreurStockage);
expect(erreur.code, message).toBe(code);
expect(erreur.details, message).toStrictEqual(details);
}
const noms = (entrees) => entrees.map((entree) => entree.nom);
beforeEach(viderOpfs);
afterEach(() => {
vi.restoreAllMocks();
});
eprouverContrat("web, sur l'OPFS de Chromium", async () => {
const fs = await preparer();
return { fs, racine: (await fs.racines()).documents };
}, {
describe,
test,
egal: (reel, attendu, message) => expect(reel, message).toStrictEqual(attendu),
vrai: (valeur, message) => expect(valeur, message).toBeTruthy(),
rejette,
});
describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
test("nature web, ni renommage atomique ni verrou ; ni exécutable ni données applicatives ; noms comparés à la lettre sous « / » ; support inconnu", async () => {
const fs = creerFichiersWeb();
expect(fs.nature).toBe('web');
expect(fs.renommageAtomique).toBe(false);
expect(fs.verrouDisponible).toBe(false);
expect(await fs.emplacements()).toStrictEqual({
executable: null,
donneesApplicatives: [],
insensibleCasse: false,
separateur: '/',
});
expect(await fs.racines()).toStrictEqual({ portable: null, documents: DOCUMENTS });
expect(await fs.typeSupport(DOCUMENTS)).toBe('inconnu');
});
test("la racine documents est le dossier gestion_table_tournante_libre de l'OPFS : ce qui s'y écrit s'y lit en UTF-8 sans marque d'ordre d'octets, et ce qui s'y pose se lit et se liste", async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await fs.ecrireAtomique(DOCUMENTS, 'corbeille/\u{E9}t\u{E9}.txt', '\u{E9}t\u{E9}');
await fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', '\u{AB} a \u{BB}');
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
expect(await octetsOpfs('corbeille', '\u{E9}t\u{E9}.txt')).toStrictEqual(
new Uint8Array([0xc3, 0xa9, 0x74, 0xc3, 0xa9]),
);
expect(await octetsOpfs('journal.jsonl')).toStrictEqual(
new Uint8Array([0xc2, 0xab, 0x20, 0x61, 0x20, 0xc2, 0xbb, 0x0a]),
);
// Un octet qui n'est pas de l'UTF-8 se lit U+FFFD ; la marque d'ordre
// d'octets et CRLF restent.
await poserOpfs('pose.csv', BRUTS);
expect(await fs.lireTexte(DOCUMENTS, 'pose.csv')).toBe('\u{FEFF}nom;\r\n\u{FFFD}');
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille', 'journal.jsonl', 'pose.csv']);
});
test("sonder crée le dossier du produit dans un OPFS vide, et n'y laisse aucun témoin", async () => {
const fs = creerFichiersWeb();
expect(await nomsOpfs()).toStrictEqual([]);
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: true, cause: null });
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
});
test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien ; creerDossier le crée", async () => {
const fs = creerFichiersWeb();
const refus = (chemin) => ({ chemin, dossier: CHEMIN_DOCUMENTS, cause: 'NotFoundError' });
await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'x'), 'ECRITURE', refus('etat.json'));
await echoue(fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'x'), 'ECRITURE', refus('journal.jsonl'));
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'ABSENT', { chemin: 'etat.json' });
await echoue(fs.lister(DOCUMENTS, ''), 'ABSENT', { chemin: '' });
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', 'copie.json'), 'ABSENT', { chemin: 'etat.json' });
await fs.supprimer(DOCUMENTS, 'etat.json');
expect(await nomsOpfs()).toStrictEqual([]);
await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05');
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
expect(noms(await fs.lister(DOCUMENTS, 'corbeille'))).toStrictEqual(['2026-01-02_03-04-05']);
});
});
describe("fichiers web : ce que l'OPFS refuse", () => {
test("une entrée d'un autre genre que celui demandé : lire ou lister lèvent ABSENT, écrire ECRITURE TypeMismatchError, qui nomme le dossier affichable de l'entrée", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'note.txt', 'note');
await fs.creerDossier(DOCUMENTS, 'corbeille');
const refus = (chemin, dossier) => ({ chemin, dossier, cause: 'TypeMismatchError' });
// Un dossier là où un fichier est attendu, et la racine elle-même.
await echoue(fs.lireTexte(DOCUMENTS, 'corbeille'), 'ABSENT', { chemin: 'corbeille' });
await echoue(fs.lireTexte(DOCUMENTS, ''), 'ABSENT', { chemin: '' });
await echoue(fs.ecrireAtomique(DOCUMENTS, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.ecrireAtomique(DOCUMENTS, '', 'x'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.ajouterLigne(DOCUMENTS, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.ajouterLigne(DOCUMENTS, '', 'x'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.supprimer(DOCUMENTS, 'corbeille'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.supprimer(DOCUMENTS, ''), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.deplacer(DOCUMENTS, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS));
await echoue(fs.deplacer(DOCUMENTS, '', 'ailleurs'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS));
await echoue(fs.deplacer(DOCUMENTS, 'note.txt', 'corbeille'), 'EXISTE', { chemin: 'corbeille' });
await echoue(fs.deplacer(DOCUMENTS, 'note.txt', ''), 'EXISTE', { chemin: '' });
// Un fichier là où un dossier est attendu.
const sousNote = `${CHEMIN_DOCUMENTS}/note.txt`;
await echoue(fs.lister(DOCUMENTS, 'note.txt'), 'ABSENT', { chemin: 'note.txt' });
await echoue(fs.lireTexte(DOCUMENTS, 'note.txt/x.txt'), 'ABSENT', { chemin: 'note.txt/x.txt' });
await echoue(fs.deplacer(DOCUMENTS, 'note.txt/x.txt', 'x.txt'), 'ABSENT', { chemin: 'note.txt/x.txt' });
await echoue(fs.ecrireAtomique(DOCUMENTS, 'note.txt/x.txt', 'x'), 'ECRITURE', refus('note.txt/x.txt', sousNote));
await echoue(fs.ajouterLigne(DOCUMENTS, 'note.txt/x.txt', 'x'), 'ECRITURE', refus('note.txt/x.txt', sousNote));
await echoue(fs.creerDossier(DOCUMENTS, 'note.txt/sous'), 'ECRITURE', refus('note.txt/sous', sousNote));
await echoue(fs.deplacer(DOCUMENTS, 'note.txt', 'note.txt/x.txt'), 'ECRITURE', refus('note.txt/x.txt', sousNote));
await fs.supprimer(DOCUMENTS, 'note.txt/x.txt');
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([
{ nom: 'corbeille', type: 'dossier' },
{ nom: 'note.txt', type: 'fichier' },
]);
expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]);
expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note');
});
// Chaque panne frappe un temps de l'écriture par flux : son ouverture,
// l'écriture, la fermeture qui porte le contenu sur la cible.
const PANNES = [
['ouverture du flux', () => FileSystemFileHandle.prototype, 'createWritable', 'NoModificationAllowedError'],
["écriture dans le flux", () => FileSystemWritableFileStream.prototype, 'write', 'QuotaExceededError'],
['fermeture du flux', () => FileSystemWritableFileStream.prototype, 'close', 'InvalidStateError'],
];
test("une panne de l'OPFS lève ECRITURE avec le nom de son exception, laisse la cible intacte et ne laisse aucun fichier neuf ni fichier d'échange", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'avant');
await fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'premiere');
for (const [temps, prototype, methode, cause] of PANNES) {
vi.spyOn(prototype(), methode).mockRejectedValue(new DOMException(temps, cause));
const appels = [
['etat.json', () => fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'apres')],
['neuf.json', () => fs.ecrireAtomique(DOCUMENTS, 'neuf.json', 'apres')],
['journal.jsonl', () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'seconde')],
['neuf.jsonl', () => fs.ajouterLigne(DOCUMENTS, 'neuf.jsonl', 'seconde')],
];
for (const [chemin, appel] of appels) {
await echoue(appel(), 'ECRITURE', { chemin, dossier: CHEMIN_DOCUMENTS, cause }, `${temps} : ${chemin}`);
}
vi.restoreAllMocks();
expect(await fs.lireTexte(DOCUMENTS, 'etat.json'), temps).toBe('avant');
expect(await fs.lireTexte(DOCUMENTS, 'journal.jsonl'), temps).toBe('premiere\n');
expect(noms(await fs.lister(DOCUMENTS, '')), temps).toStrictEqual(['etat.json', 'journal.jsonl']);
}
// Un rejet qui n'est pas une exception nommée donne son texte pour cause.
vi.spyOn(FileSystemWritableFileStream.prototype, 'write').mockRejectedValueOnce('flux rompu');
await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'apres'), 'ECRITURE', {
chemin: 'etat.json',
dossier: CHEMIN_DOCUMENTS,
cause: 'flux rompu',
});
expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('avant');
});
test('deplacer copie les octets de la source puis la retire ; un échec de la copie ou du retrait laisse la source seule', async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await poserOpfs('source.csv', BRUTS);
vi.spyOn(FileSystemWritableFileStream.prototype, 'write').mockRejectedValue(
new DOMException('copie', 'QuotaExceededError'),
);
await echoue(fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv'), 'ECRITURE', {
chemin: 'corbeille/source.csv',
dossier: `${CHEMIN_DOCUMENTS}/corbeille`,
cause: 'QuotaExceededError',
});
vi.restoreAllMocks();
expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]);
expect(await octetsOpfs('source.csv')).toStrictEqual(BRUTS);
vi.spyOn(FileSystemDirectoryHandle.prototype, 'removeEntry').mockRejectedValueOnce(
new DOMException('retrait', 'NoModificationAllowedError'),
);
await echoue(fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv'), 'ECRITURE', {
chemin: 'source.csv',
dossier: CHEMIN_DOCUMENTS,
cause: 'NoModificationAllowedError',
});
vi.restoreAllMocks();
expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]);
expect(await octetsOpfs('source.csv')).toStrictEqual(BRUTS);
await fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv');
expect(await octetsOpfs('corbeille', 'source.csv')).toStrictEqual(BRUTS);
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille']);
});
// Une lecture tombe en panne à l'un de ses temps : l'OPFS, que le
// navigateur refuse à l'origine ; la description d'un fichier ; ses octets,
// quand il change pendant qu'on le lit.
test("une lecture qui échoue autrement qu'en ne trouvant pas l'entrée lève LECTURE avec le nom de son exception, qui nomme le dossier affichable de l'entrée : une panne n'est pas une absence", async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await fs.ecrireAtomique(DOCUMENTS, 'corbeille/etat.json', 'etat');
const corbeille = `${CHEMIN_DOCUMENTS}/corbeille`;
const lecture = (chemin, dossier, cause) => ({ chemin, dossier, cause });
const etat = 'corbeille/etat.json';
vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce(new DOMException('change', 'NotReadableError'));
await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'NotReadableError'), 'octets');
vi.spyOn(FileSystemFileHandle.prototype, 'getFile').mockRejectedValueOnce(
new DOMException('illisible', 'NotReadableError'),
);
await echoue(
fs.lister(DOCUMENTS, 'corbeille'),
'LECTURE',
lecture('corbeille', CHEMIN_DOCUMENTS, 'NotReadableError'),
'description',
);
// Un rejet qui n'est pas une exception nommée donne son texte pour cause.
vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce('flux rompu');
await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'flux rompu'), 'rejet sans nom');
vi.spyOn(StorageManager.prototype, 'getDirectory').mockRejectedValue(new DOMException('refus', 'SecurityError'));
await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'SecurityError'), 'lireTexte');
await echoue(fs.lister(DOCUMENTS, ''), 'LECTURE', lecture('', CHEMIN_DOCUMENTS, 'SecurityError'), 'lister');
vi.restoreAllMocks();
expect(await fs.lireTexte(DOCUMENTS, etat)).toBe('etat');
});
test("lister omet un fichier qui disparaît pendant la liste, prend la date d'un fichier dans l'OPFS et date un dossier de 0", async () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
await fs.ecrireAtomique(DOCUMENTS, 'reste.txt', 'reste');
await fs.ecrireAtomique(DOCUMENTS, 'fugace.txt', 'fugace');
const modifie = (await (await (await dossierProduit()).getFileHandle('reste.txt')).getFile()).lastModified;
const lire = FileSystemFileHandle.prototype.getFile;
vi.spyOn(FileSystemFileHandle.prototype, 'getFile').mockImplementation(function getFile() {
return this.name === 'fugace.txt' ? Promise.reject(new DOMException('disparu', 'NotFoundError')) : lire.call(this);
});
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([
{ nom: 'corbeille', type: 'dossier', taille: 0, modifie: 0 },
{ nom: 'reste.txt', type: 'fichier', taille: 5, modifie },
]);
});
test("sonder rend la cause que lève l'OPFS, ou RELECTURE_DIFFERENTE, sans lever, et ne laisse aucun témoin", async () => {
const fs = creerFichiersWeb();
vi.spyOn(StorageManager.prototype, 'getDirectory').mockRejectedValueOnce(new DOMException('refus', 'SecurityError'));
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'SecurityError' });
expect(await nomsOpfs()).toStrictEqual([]);
vi.spyOn(FileSystemFileHandle.prototype, 'createWritable').mockRejectedValueOnce(
new DOMException('tenu', 'NoModificationAllowedError'),
);
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NoModificationAllowedError' });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
vi.spyOn(Blob.prototype, 'arrayBuffer').mockResolvedValueOnce(new TextEncoder().encode('autre').buffer);
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'RELECTURE_DIFFERENTE' });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
// Une relecture qui échoue suit l'écriture du témoin, que la sonde efface.
vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce(new DOMException('illisible', 'NotReadableError'));
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NotReadableError' });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
vi.restoreAllMocks();
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: true, cause: null });
});
});
describe('fichiers web : ni verrou, ni explorateur, ni dossier choisi (§ 8.8)', () => {
test("verrouiller rend { pris: true } sans rien écrire, et deverrouiller n'efface aucun verrou", async () => {
const fs = await preparer();
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true });
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true });
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
// Un verrou venu d'un dossier de la coquille reste où il est.
const verrou = '{"seance":"seance-b","pid":4321,"hote":"poste-voisin","depuis":"jamais"}\n';
await fs.ecrireAtomique(DOCUMENTS, 'soiree.gtt.verrou', verrou);
await fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-b');
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-c')).toStrictEqual({ pris: true });
expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou);
});
test("ouvrirDansExplorateur et choisirDossier lèvent NON_DISPONIBLE {primitive} ; une racine inconnue lève d'abord CHEMIN_REFUSE", async () => {
const fs = await preparer();
await echoue(fs.ouvrirDansExplorateur(DOCUMENTS), 'NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' });
await echoue(fs.choisirDossier(), 'NON_DISPONIBLE', { primitive: 'choisirDossier' });
const inconnue = { id: 'portable', chemin: CHEMIN_DOCUMENTS };
await echoue(fs.ouvrirDansExplorateur(inconnue), 'CHEMIN_REFUSE', { chemin: '' });
await echoue(fs.typeSupport(inconnue), 'CHEMIN_REFUSE', { chemin: '' });
});
test("deux casses ou deux formes de normalisation d'un nom sont deux fichiers, et deplacer vers une autre casse ne trouve pas de cible existante", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'casse.txt', 'minuscules');
await fs.ecrireAtomique(DOCUMENTS, 'CASSE.txt', 'capitales');
await fs.ecrireAtomique(DOCUMENTS, '\u{E9}.txt', 'NFC');
await fs.ecrireAtomique(DOCUMENTS, 'e\u{301}.txt', 'NFD');
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['CASSE.txt', 'casse.txt', 'e\u{301}.txt', '\u{E9}.txt']);
expect(await fs.lireTexte(DOCUMENTS, 'casse.txt')).toBe('minuscules');
expect(await fs.lireTexte(DOCUMENTS, '\u{E9}.txt')).toBe('NFC');
await fs.deplacer(DOCUMENTS, 'casse.txt', 'Casse.txt');
expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['CASSE.txt', 'Casse.txt', 'e\u{301}.txt', '\u{E9}.txt']);
expect(await fs.lireTexte(DOCUMENTS, 'Casse.txt')).toBe('minuscules');
expect(await fs.lireTexte(DOCUMENTS, 'CASSE.txt')).toBe('capitales');
});
});
describe('fichiers web : importer et enregistrer', () => {
test('choisirFichierAImporter ouvre un champ de fichier unique, hors du document, et rend le nom et les octets choisis tels quels', async () => {
const fs = creerFichiersWeb();
const champs = [];
vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function cliquer() {
champs.push({ type: this.type, multiple: this.multiple, dansLeDocument: this.isConnected });
const transfert = new DataTransfer();
transfert.items.add(new File([BRUTS], 'invit\u{E9}s.csv'));
this.files = transfert.files;
this.dispatchEvent(new Event('change'));
});
const choisi = await fs.choisirFichierAImporter();
expect(champs).toStrictEqual([{ type: 'file', multiple: false, dansLeDocument: false }]);
expect(choisi).toStrictEqual({ nom: 'invit\u{E9}s.csv', octets: BRUTS });
});
test("choisirFichierAImporter rend null quand l'opérateur annule, ou que le champ revient sans fichier", async () => {
const fs = creerFichiersWeb();
const clic = vi.spyOn(HTMLInputElement.prototype, 'click');
clic.mockImplementationOnce(function annuler() {
this.dispatchEvent(new Event('cancel'));
});
expect(await fs.choisirFichierAImporter()).toBeNull();
clic.mockImplementationOnce(function revenirVide() {
this.dispatchEvent(new Event('change'));
});
expect(await fs.choisirFichierAImporter()).toBeNull();
});
test("un fichier choisi qui ne se lit pas lève LECTURE, ou ABSENT quand il a disparu, et se nomme par son seul nom : le navigateur tait son dossier", async () => {
const fs = creerFichiersWeb();
vi.spyOn(Blob.prototype, 'arrayBuffer')
.mockRejectedValueOnce(new DOMException('change', 'NotReadableError'))
.mockRejectedValueOnce(new DOMException('disparu', 'NotFoundError'));
vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function cliquer() {
const transfert = new DataTransfer();
transfert.items.add(new File([BRUTS], 'invit\u{E9}s.csv'));
this.files = transfert.files;
this.dispatchEvent(new Event('change'));
});
await echoue(fs.choisirFichierAImporter(), 'LECTURE', {
chemin: 'invit\u{E9}s.csv',
dossier: null,
cause: 'NotReadableError',
});
await echoue(fs.choisirFichierAImporter(), 'ABSENT', { chemin: 'invit\u{E9}s.csv' });
});
test("enregistrerSous remet au navigateur un lien de téléchargement des octets sous le nom proposé, rend ce nom, puis libère l'adresse", async () => {
const fs = creerFichiersWeb();
const creer = vi.spyOn(URL, 'createObjectURL');
const liberer = vi.spyOn(URL, 'revokeObjectURL');
const liens = [];
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function cliquer() {
liens.push({
href: this.href,
download: this.download,
dansLeDocument: this.isConnected,
liberee: liberer.mock.calls.length > 0,
});
});
expect(await fs.enregistrerSous('invit\u{E9}s.csv', BRUTS)).toBe('invit\u{E9}s.csv');
expect(creer).toHaveBeenCalledTimes(1);
const [contenu] = creer.mock.calls[0];
const adresse = creer.mock.results[0].value;
expect(contenu).toBeInstanceOf(Blob);
expect(contenu.type).toBe('application/octet-stream');
expect(new Uint8Array(await contenu.arrayBuffer())).toStrictEqual(BRUTS);
expect(liens).toStrictEqual([
{ href: adresse, download: 'invit\u{E9}s.csv', dansLeDocument: false, liberee: false },
]);
await vi.waitFor(() => expect(liberer).toHaveBeenCalledWith(adresse));
expect(liberer).toHaveBeenCalledTimes(1);
});
});
describe('fichiers web : fautes de programmation', () => {
test('un texte, une ligne, une séance, un nom ou des octets mal formés lèvent TypeError, sans rien écrire ni rien proposer', async () => {
const fs = await preparer();
const creer = vi.spyOn(URL, 'createObjectURL');
const fautes = [
() => fs.ecrireAtomique(DOCUMENTS, 'etat.json', 42),
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 42),
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'deux\nlignes'),
() => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'retour\rchariot'),
() => fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', ''),
() => fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', null),
() => fs.enregistrerSous('', BRUTS),
() => fs.enregistrerSous('liste.csv', 'nom;prenom'),
];
for (const faute of fautes) await expect(faute(), String(faute)).rejects.toThrow(TypeError);
expect(creer).not.toHaveBeenCalled();
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
});
});