Printed views and the badge sheet leave the app through the platform. Under Electron a gtt:imprimer channel prints with the paper and orientation asked for and reports sent, cancelled, failed or unknown; an exception is logged to the console and never shown raw to the operator. Every file system, real or simulated, writes bytes atomically from a copy taken at the call. The sheet name carries event, date and imposition, and a failed print becomes IMPRESSION_ECHEC. Checked: red first, twelve mutations caught; 3502 node, 659 browser and 79 long tests green from the index alone. --- FR --- [ADD] plateforme : pont d'impression, octets atomiques, nom de planche Les vues imprimables et la planche sortent de l'application par la plateforme. Sous Electron, un canal gtt:imprimer imprime au papier et à l'orientation demandés et rend envoyé, annulé, échec ou inconnu ; une exception va à la console, jamais brute à l'opérateur. Chaque système de fichiers, réel ou simulé, écrit des octets de façon atomique, depuis une copie prise à l'appel. Le nom de la planche porte événement, date et imposition, et une impression échouée devient IMPRESSION_ECHEC. Vérifié : rouge d'abord, douze mutations attrapées ; 3502 node, 659 navigateur et 79 longues vertes depuis l'index seul. Assisted-by: Claude Opus 5.5
501 lines
24 KiB
JavaScript
501 lines
24 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// La coquille Electron (§ 13.1) : la fenêtre qu'ouvre le processus principal,
|
||
// la page qu'elle charge, le profil de Chromium hors d'AppData (§ 8.6), le
|
||
// pont étroit et nommé que le préchargement expose — une fonction par
|
||
// primitive du système de fichiers, un canal par fonction, la copie dans
|
||
// le presse-papiers et l'impression —, ce que le processus principal y
|
||
// répond : emplacements, racines, dialogues, explorateur, presse-papiers,
|
||
// impression (§ 11.7, § 13.4) ; et les verrous que la page a pris, effacés
|
||
// à la sortie (§ 8.8). La page elle-même, index.html, charge le collecteur
|
||
// d'erreurs avant son module d'entrée (§ 19.2).
|
||
//
|
||
// electron/main.js s'exécute sous Node, dans un processus à part, contre
|
||
// test/electron_factice.js, qui consigne ce que la coquille demande à
|
||
// Electron puis rend son journal en JSON. Ce qui s'éprouve ici est ce que la
|
||
// coquille demande ; qu'Electron l'applique relève d'une épreuve dans un vrai
|
||
// Electron (make essai_demarrage). Les primitives elles-mêmes s'éprouvent
|
||
// dans test/fichiers_electron.test.js ; le gestionnaire d'impression
|
||
// (electron/impression.js) s'éprouve ici, sur un contenu de fenêtre factice.
|
||
import assert from 'node:assert/strict';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { basename, dirname, join, sep } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { PRIMITIVES } from '../electron/fichiers.js';
|
||
import { CANAL_IMPRESSION, FORMATS_DE_PAGE, imprimer, optionsImpression, servirImpression } from '../electron/impression.js';
|
||
import { PAPIERS } from '../src/geometrie/papiers.js';
|
||
import { describe, test } from './lanceur.js';
|
||
|
||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||
const FACTICE = pathToFileURL(join(RACINE, 'test', 'electron_factice.js')).href;
|
||
const PRINCIPAL = join(RACINE, 'electron', 'main.js');
|
||
const PRECHARGEMENT = join(RACINE, 'electron', 'preload.cjs');
|
||
const NOM_PRODUIT = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8')).productName;
|
||
const CANAUX = PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`);
|
||
const CANAL_COPIE = 'gtt:copier';
|
||
const CANAL_IMPRIMER = 'gtt:imprimer';
|
||
|
||
// Dossier que publie le lanceur portable, et LOCALAPPDATA, dans le scénario
|
||
// qui les donne. Aucun des deux n'est lu ni écrit.
|
||
const PUBLIE = join(sep, 'media', 'cle', 'soirees');
|
||
const LOCAL = join(sep, 'donnees', 'locales');
|
||
|
||
// Ce que la coquille voit de l'environnement, par scénario.
|
||
const SCENARIOS = {
|
||
publie: { PORTABLE_EXECUTABLE_DIR: PUBLIE, LOCALAPPDATA: LOCAL },
|
||
absent: {},
|
||
vide: { PORTABLE_EXECUTABLE_DIR: '' },
|
||
};
|
||
|
||
// Délai de l'exécution de la coquille. Le factice ne joue la page qu'une
|
||
// fois le processus sans rien à faire ; un minuteur ou un écouteur laissé
|
||
// actif l'en empêche, et l'exécution ne finit jamais. Le délai reste sous
|
||
// celui d'une épreuve de Vitest, 5 s, qui n'interrompt pas un appel
|
||
// synchrone : l'épreuve échoue en le disant, au lieu de figer la série. Le
|
||
// délai écoulé, le processus reçoit KILL, qu'aucun gestionnaire de signal
|
||
// ne retient.
|
||
const DELAI_MS = 4_000;
|
||
|
||
// Le pid d'un processus terminé : aucun processus de ce poste n'y répond
|
||
// plus.
|
||
const PID_TERMINE = spawnSync(process.execPath, ['-e', '']).pid;
|
||
|
||
// Ce que le dossier temporaire porte avant la coquille : les Documents du
|
||
// produit, où l'explorateur s'ouvre ; le profil qu'a laissé une séance
|
||
// terminée, celui d'une séance qui tourne encore — ce processus d'épreuve —,
|
||
// et un dossier dont le nom ne porte aucun pid.
|
||
const PROFIL_TERMINE = `gtt-profil-${PID_TERMINE}-Ab12Cd`;
|
||
const PROFIL_VIVANT = `gtt-profil-${process.pid}-Ef34Gh`;
|
||
const AUTRE = 'gtt-profil-sans-pid';
|
||
const DOSSIERS_POSES = ['Documents', PROFIL_TERMINE, PROFIL_VIVANT, AUTRE];
|
||
|
||
// Le verrou qu'une autre séance tient dans les Documents du produit, posé
|
||
// avant le lancement : la sortie de la coquille ne le touche pas.
|
||
const VERROU_AUTRE = 'autre.gtt.verrou';
|
||
const CONTENU_AUTRE = {
|
||
seance: 'seance-voisine',
|
||
pid: PID_TERMINE,
|
||
hote: 'poste-voisin',
|
||
depuis: '2026-10-06T19:00:00-04:00',
|
||
demarrage: null,
|
||
};
|
||
|
||
// Exécute la coquille contre le factice dans un scénario, et rend son
|
||
// journal, son pid, le dossier temporaire qu'elle a reçu, et ce qu'elle y a
|
||
// laissé. Le répertoire courant est un dossier vide hors du projet : dans
|
||
// l'exécutable, il n'est jamais l'intérieur d'app.asar, si bien qu'un chemin
|
||
// résolu contre lui ne désigne ni la page ni le préchargement. Le dossier
|
||
// temporaire est neuf, et porte les DOSSIERS_POSES.
|
||
function executerLaCoquille(scenario) {
|
||
const environnement = { ...process.env };
|
||
delete environnement.PORTABLE_EXECUTABLE_DIR;
|
||
delete environnement.LOCALAPPDATA;
|
||
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
|
||
const temporaire = mkdtempSync(join(tmpdir(), 'coquille-temp-'));
|
||
mkdirSync(join(temporaire, 'Documents', NOM_PRODUIT), { recursive: true });
|
||
writeFileSync(join(temporaire, 'Documents', NOM_PRODUIT, VERROU_AUTRE), `${JSON.stringify(CONTENU_AUTRE)}\n`);
|
||
for (const nom of [PROFIL_TERMINE, PROFIL_VIVANT, AUTRE]) {
|
||
mkdirSync(join(temporaire, nom, 'Session Storage'), { recursive: true });
|
||
writeFileSync(join(temporaire, nom, 'Local State'), '{}');
|
||
}
|
||
try {
|
||
const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], {
|
||
cwd: ailleurs,
|
||
encoding: 'utf8',
|
||
env: { ...environnement, TMPDIR: temporaire, TEMP: temporaire, TMP: temporaire, ...SCENARIOS[scenario] },
|
||
timeout: DELAI_MS,
|
||
killSignal: 'SIGKILL',
|
||
});
|
||
assert.notEqual(
|
||
resultat.error?.code,
|
||
'ETIMEDOUT',
|
||
`la coquille ne s'arrête pas en ${DELAI_MS} ms : un minuteur ou un écouteur la tient en vie`,
|
||
);
|
||
assert.equal(resultat.status, 0, `la coquille ne s'exécute pas :\n${resultat.stderr}`);
|
||
return {
|
||
journal: JSON.parse(resultat.stdout),
|
||
pid: resultat.pid,
|
||
temporaire,
|
||
restes: readdirSync(temporaire).sort(),
|
||
};
|
||
} finally {
|
||
rmSync(ailleurs, { recursive: true, force: true });
|
||
rmSync(temporaire, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
// Chaque scénario ne s'exécute qu'une fois, et son échec se rend à chaque
|
||
// épreuve qui le demande, sans relancer.
|
||
const executions = new Map();
|
||
function coquille(scenario = 'publie') {
|
||
if (!executions.has(scenario)) {
|
||
try {
|
||
executions.set(scenario, { resultat: executerLaCoquille(scenario) });
|
||
} catch (erreur) {
|
||
executions.set(scenario, { erreur });
|
||
}
|
||
}
|
||
const execution = executions.get(scenario);
|
||
if (execution.erreur !== undefined) throw execution.erreur;
|
||
return execution.resultat;
|
||
}
|
||
const journalDeLaCoquille = (scenario) => coquille(scenario).journal;
|
||
|
||
// L'appel de la page à une fonction du pont, consigné par le factice.
|
||
const appelDe = (journal, membre) => journal.prechargement.appels.find((appel) => appel.membre === membre);
|
||
|
||
describe('coquille : processus principal', () => {
|
||
test("une seule fenêtre, 1366 × 768 : page isolée, sans Node, en bac à sable, préchargée par le pont", () => {
|
||
const { fenetres } = journalDeLaCoquille();
|
||
assert.equal(fenetres.length, 1);
|
||
const [{ width, height, webPreferences }] = fenetres;
|
||
assert.deepEqual({ width, height }, { width: 1366, height: 768 });
|
||
assert.deepEqual(webPreferences, {
|
||
preload: PRECHARGEMENT,
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
sandbox: true,
|
||
});
|
||
assert.ok(existsSync(webPreferences.preload), `${webPreferences.preload} absent`);
|
||
});
|
||
|
||
test("aucun menu d'application : le menu par défaut d'Electron, en anglais, est retiré avant la première fenêtre (§ 2)", () => {
|
||
const { menus, sequence } = journalDeLaCoquille();
|
||
assert.deepEqual(menus, [null]);
|
||
const retrait = sequence.indexOf('Menu.setApplicationMenu');
|
||
const fenetre = sequence.indexOf('new BrowserWindow');
|
||
assert.ok(retrait !== -1 && fenetre !== -1 && retrait < fenetre, `ordre des appels : ${sequence.join(', ')}`);
|
||
});
|
||
|
||
test('la fenêtre charge la page que Vite construit dans www/', () => {
|
||
assert.deepEqual(journalDeLaCoquille().chargements, [
|
||
{ loadFile: join(RACINE, 'www', 'index.html') },
|
||
]);
|
||
});
|
||
|
||
test("un canal gtt:fichiers:<primitive> par primitive, puis celui du presse-papiers, puis celui de l'impression, et aucun autre, chacun posé avant le chargement de la page", () => {
|
||
const { canaux, sequence } = journalDeLaCoquille();
|
||
assert.equal(CANAL_IMPRESSION, CANAL_IMPRIMER);
|
||
assert.deepEqual(canaux, [...CANAUX, CANAL_COPIE, CANAL_IMPRIMER]);
|
||
assert.equal(CANAUX.length, 18);
|
||
const charge = sequence.indexOf('loadFile');
|
||
assert.notEqual(charge, -1);
|
||
for (const canal of [...CANAUX, CANAL_COPIE, CANAL_IMPRIMER]) {
|
||
const pose = sequence.indexOf(`ipcMain.handle:${canal}`);
|
||
assert.ok(pose !== -1 && pose < charge, `${canal} : ${sequence.join(', ')}`);
|
||
}
|
||
});
|
||
|
||
test("la page n'ouvre aucune fenêtre et ne navigue nulle part", () => {
|
||
const { ouvertures, navigations } = journalDeLaCoquille();
|
||
assert.deepEqual(ouvertures, [{ action: 'deny' }]);
|
||
assert.ok(navigations.length > 0, 'aucun écouteur de will-navigate');
|
||
assert.deepEqual(navigations, navigations.map(() => true));
|
||
});
|
||
|
||
test('le profil de Chromium est un dossier neuf sous le dossier temporaire, nommé par le pid de la séance, posé avant whenReady, retiré à la sortie (§ 8.6)', () => {
|
||
const { journal, pid, temporaire } = coquille();
|
||
assert.equal(journal.setPath.length, 1, JSON.stringify(journal.setPath));
|
||
const [{ nom, chemin }] = journal.setPath;
|
||
assert.equal(nom, 'userData');
|
||
assert.equal(dirname(chemin), temporaire);
|
||
assert.ok(basename(chemin).startsWith(`gtt-profil-${pid}-`), basename(chemin));
|
||
const pose = journal.sequence.indexOf('app.setPath:userData');
|
||
const pret = journal.sequence.indexOf('app.whenReady');
|
||
assert.ok(pose !== -1 && pret !== -1 && pose < pret, `ordre des appels : ${journal.sequence.join(', ')}`);
|
||
assert.deepEqual(journal.profil, { avant: true, apres: false });
|
||
});
|
||
|
||
test("à la sortie, le verrou que la page a pris s'efface ; celui qu'une autre séance a repris depuis, et celui d'une autre séance, restent (§ 8.8)", () => {
|
||
for (const scenario of ['publie', 'absent']) {
|
||
const { prechargement, verrous } = journalDeLaCoquille(scenario);
|
||
assert.deepEqual(prechargement.verrou.reponse, { ok: true, valeur: { pris: true } }, scenario);
|
||
// Le second verrou de la page est au relevé de la coquille, mais son
|
||
// contenu porte désormais une autre séance : la sortie le relit et le
|
||
// laisse.
|
||
assert.deepEqual(prechargement.repris.reponse, { ok: true, valeur: { pris: true } }, scenario);
|
||
assert.deepEqual(
|
||
verrous,
|
||
{
|
||
page: { avant: true, apres: false },
|
||
repris: { avant: true, apres: true },
|
||
autre: { avant: true, apres: true },
|
||
},
|
||
scenario,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("à la sortie, la coquille confie les verrous tenus à rendreVerrousTenus, seul lecteur de leur format : elle n'en analyse aucun", () => {
|
||
const texte = readFileSync(PRINCIPAL, 'utf8');
|
||
assert.match(texte, /app\.on\('quit', \(\) => fichiers\.rendreVerrousTenus\(\)\)/);
|
||
assert.doesNotMatch(texte, /JSON\.parse|\.verrousTenus\(/);
|
||
});
|
||
|
||
test("au démarrage, les profils des séances terminées se retirent : ni celui d'une séance vivante, ni un dossier sans pid", () => {
|
||
const { restes } = coquille();
|
||
assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort());
|
||
});
|
||
});
|
||
|
||
describe('coquille : préchargement', () => {
|
||
test("n'expose que window.gtt.fichiers, une fonction par primitive, gtt.copier et gtt.imprimer, deux fonctions ; ne requiert qu'electron", () => {
|
||
const { prechargement } = journalDeLaCoquille();
|
||
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
|
||
assert.deepEqual(prechargement.requis, ['electron']);
|
||
assert.deepEqual(prechargement.exposes, [{ cle: 'gtt', membres: ['copier', 'fichiers', 'imprimer'] }]);
|
||
assert.equal(prechargement.copie.type, 'function');
|
||
assert.equal(prechargement.impression.type, 'function');
|
||
assert.deepEqual(prechargement.fichiers, [...PRIMITIVES]);
|
||
assert.deepEqual(
|
||
prechargement.appels.map(({ membre, type }) => ({ membre, type })),
|
||
PRIMITIVES.map((membre) => ({ membre, type: 'function' })),
|
||
);
|
||
});
|
||
|
||
test('chaque fonction invoque son propre canal, une fois par appel, et rend la réponse du processus principal', () => {
|
||
const journal = journalDeLaCoquille();
|
||
assert.deepEqual(
|
||
journal.prechargement.appels.map(({ membre, canaux }) => ({ membre, canaux })),
|
||
PRIMITIVES.map((membre) => ({ membre, canaux: [`gtt:fichiers:${membre}`] })),
|
||
);
|
||
// Après le pont : la copie, les deux verrous réels que prend la page,
|
||
// puis ses cinq demandes d'impression.
|
||
assert.deepEqual(journal.invocations, [
|
||
...CANAUX,
|
||
CANAL_COPIE,
|
||
'gtt:fichiers:verrouiller',
|
||
'gtt:fichiers:verrouiller',
|
||
...Array(5).fill(CANAL_IMPRIMER),
|
||
]);
|
||
// Une racine que le processus principal ne connaît pas est refusée par
|
||
// lui, en réponse et non en erreur.
|
||
for (const [membre, chemin] of [
|
||
['sonder', ''],
|
||
['typeSupport', ''],
|
||
['lireTexte', 'soiree.gtt.json'],
|
||
['ecrireAtomique', 'soiree.gtt.json'],
|
||
['ecrireOctetsAtomique', 'soiree.badges.pdf'],
|
||
['ajouterLigne', 'soiree.gtt-journal.jsonl'],
|
||
['lister', ''],
|
||
['creerDossier', 'corbeille'],
|
||
['deplacer', 'soiree.gtt.json'],
|
||
['supprimer', 'soiree.gtt.json'],
|
||
['verrouiller', 'soiree.gtt.verrou'],
|
||
['deverrouiller', 'soiree.gtt.verrou'],
|
||
]) {
|
||
assert.deepEqual(
|
||
appelDe(journal, membre).reponse,
|
||
{ ok: false, code: 'CHEMIN_REFUSE', details: { chemin } },
|
||
membre,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("emplacements et racines viennent du processus principal : le dossier publié et son data/, AppData, LOCALAPPDATA, le temporaire, les Documents du produit", () => {
|
||
const journal = journalDeLaCoquille('publie');
|
||
const { chemins } = journal;
|
||
assert.deepEqual(appelDe(journal, 'emplacements').reponse, {
|
||
ok: true,
|
||
valeur: {
|
||
executable: PUBLIE,
|
||
donneesApplicatives: [chemins.appData, LOCAL, chemins.temp],
|
||
insensibleCasse: process.platform === 'win32' || process.platform === 'darwin',
|
||
separateur: sep,
|
||
},
|
||
});
|
||
assert.deepEqual(appelDe(journal, 'racines').reponse, {
|
||
ok: true,
|
||
valeur: {
|
||
portable: { id: 'portable', chemin: join(PUBLIE, 'data') },
|
||
documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) },
|
||
},
|
||
});
|
||
});
|
||
|
||
test('sans dossier publié, ou publié vide, ni exécutable ni racine portable', () => {
|
||
for (const scenario of ['absent', 'vide']) {
|
||
const journal = journalDeLaCoquille(scenario);
|
||
const { chemins } = journal;
|
||
assert.deepEqual(
|
||
appelDe(journal, 'emplacements').reponse.valeur,
|
||
{
|
||
executable: null,
|
||
donneesApplicatives: [chemins.appData, chemins.temp],
|
||
insensibleCasse: process.platform === 'win32' || process.platform === 'darwin',
|
||
separateur: sep,
|
||
},
|
||
scenario,
|
||
);
|
||
assert.deepEqual(
|
||
appelDe(journal, 'racines').reponse.valeur,
|
||
{ portable: null, documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) } },
|
||
scenario,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("les dialogues s'ouvrent sur la fenêtre de la page, et une annulation rend null", () => {
|
||
const journal = journalDeLaCoquille();
|
||
assert.deepEqual(journal.dialogues, [
|
||
{ methode: 'showOpenDialog', parent: true, options: { properties: ['openDirectory', 'createDirectory'] } },
|
||
{ methode: 'showOpenDialog', parent: true, options: { properties: ['openFile'] } },
|
||
{ methode: 'showSaveDialog', parent: true, options: { defaultPath: join(journal.chemins.documents, 'liste.csv') } },
|
||
]);
|
||
for (const membre of ['choisirDossier', 'choisirFichierAImporter', 'enregistrerSous']) {
|
||
assert.deepEqual(appelDe(journal, membre).reponse, { ok: true, valeur: null }, membre);
|
||
}
|
||
});
|
||
|
||
test('gtt.copier invoque le canal gtt:copier, une fois, et le presse-papiers reçoit le texte tel quel', () => {
|
||
const { prechargement, pressePapiers } = journalDeLaCoquille();
|
||
const { copie } = prechargement;
|
||
assert.deepEqual(copie.canaux, [CANAL_COPIE]);
|
||
assert.equal(copie.erreur, undefined, copie.erreur);
|
||
assert.equal(typeof copie.texte, 'string');
|
||
assert.notEqual(copie.texte, '');
|
||
assert.deepEqual(pressePapiers, [copie.texte]);
|
||
});
|
||
|
||
test("gtt.imprimer invoque le canal gtt:imprimer, une fois par appel : le processus principal imprime la fenêtre qui le demande, avec le papier et l'orientation reçus, et rend l'issue du rappel", () => {
|
||
const { prechargement, impressions, sequence } = journalDeLaCoquille();
|
||
const { appels } = prechargement.impression;
|
||
assert.deepEqual(
|
||
appels.map(({ canaux }) => canaux),
|
||
appels.map(() => [CANAL_IMPRIMER]),
|
||
);
|
||
assert.deepEqual(
|
||
appels.slice(0, 3).map(({ reponse, erreur }) => ({ reponse, erreur })),
|
||
[
|
||
{ reponse: { issue: 'envoye', cause: null }, erreur: undefined },
|
||
{ reponse: { issue: 'annule', cause: null }, erreur: undefined },
|
||
{ reponse: { issue: 'echec', cause: 'failed' }, erreur: undefined },
|
||
],
|
||
);
|
||
assert.deepEqual(impressions, [
|
||
{ silent: false, printBackground: true, pageSize: 'A4', landscape: false },
|
||
{ silent: false, printBackground: true, pageSize: 'Letter', landscape: true },
|
||
{ silent: false, printBackground: true, pageSize: 'A4', landscape: true },
|
||
]);
|
||
// Une option de print brute, un papier inconnu : refusés, sans imprimer.
|
||
for (const { options, reponse, erreur } of appels.slice(3)) {
|
||
assert.equal(reponse, undefined, JSON.stringify(options));
|
||
assert.equal(typeof erreur, 'string', JSON.stringify(options));
|
||
}
|
||
assert.equal(sequence.filter((appel) => appel === 'webContents.print').length, 3);
|
||
});
|
||
|
||
test("l'explorateur s'ouvre sur la racine que la page désigne", () => {
|
||
const journal = journalDeLaCoquille();
|
||
assert.deepEqual(journal.explorateur, [join(journal.chemins.documents, NOM_PRODUIT)]);
|
||
// La réponse porte valeur: undefined, que le journal en JSON ne garde pas.
|
||
assert.deepEqual(appelDe(journal, 'ouvrirDansExplorateur').reponse, { ok: true });
|
||
});
|
||
});
|
||
|
||
describe('coquille : la page', () => {
|
||
test("index.html charge le collecteur d'erreurs, script classique, avant le module d'entrée (§ 19.2)", () => {
|
||
const page = readFileSync(join(RACINE, 'index.html'), 'utf8');
|
||
const scripts = [...page.matchAll(/<script\b([^>]*)>/g)].map(([, attributs]) => attributs.trim());
|
||
assert.deepEqual(scripts, ['src="./collecteur.js"', 'type="module" src="/src/main.js"']);
|
||
assert.ok(existsSync(join(RACINE, 'public', 'collecteur.js')), 'public/collecteur.js absent');
|
||
});
|
||
});
|
||
|
||
describe('coquille : le gestionnaire d’impression (§ 11.7, § 13.4)', () => {
|
||
// Le contenu d'une fenêtre qui consigne les options de chaque print et
|
||
// répond à son rappel par (succes, raison), ou lève l'erreur donnée.
|
||
function contenuFactice(succes = true, raison = '', leve = null) {
|
||
const contenu = {
|
||
options: [],
|
||
print(options, rappel) {
|
||
contenu.options.push(options);
|
||
if (leve !== null) throw leve;
|
||
queueMicrotask(() => rappel(succes, raison));
|
||
},
|
||
};
|
||
return contenu;
|
||
}
|
||
|
||
test('un format de page par papier, que la coquille recopie des clés de PAPIERS : A4 reste A4, Lettre devient Letter', () => {
|
||
assert.deepEqual(FORMATS_DE_PAGE, { A4: 'A4', Lettre: 'Letter' });
|
||
assert.deepEqual(Object.keys(FORMATS_DE_PAGE), Object.keys(PAPIERS));
|
||
assert.ok(Object.isFrozen(FORMATS_DE_PAGE));
|
||
});
|
||
|
||
test("le papier et l'orientation deviennent les seules options de print : boîte de dialogue montrée, fonds imprimés, format de page du papier, paysage pour paysage", () => {
|
||
for (const papier of Object.keys(PAPIERS)) {
|
||
for (const [orientation, landscape] of [['portrait', false], ['paysage', true]]) {
|
||
assert.deepEqual(optionsImpression({ papier, orientation }), {
|
||
silent: false,
|
||
printBackground: true,
|
||
pageSize: FORMATS_DE_PAGE[papier],
|
||
landscape,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
test('toute autre option est refusée par TypeError, sans appeler print : une option de print brute, un papier ou une orientation hors domaine, une demande qui n’est pas un objet', async () => {
|
||
const refusees = [
|
||
{ papier: 'A4', orientation: 'portrait', silent: true },
|
||
{ papier: 'A4', orientation: 'portrait', deviceName: 'imprimante-voisine' },
|
||
{ papier: 'A4', orientation: 'portrait', pageSize: 'A3' },
|
||
{ papier: 'A3', orientation: 'portrait' },
|
||
{ papier: 'Letter', orientation: 'portrait' },
|
||
{ papier: 'toString', orientation: 'portrait' },
|
||
{ papier: 'A4', orientation: 'landscape' },
|
||
{ papier: 'A4' },
|
||
{},
|
||
null,
|
||
'A4',
|
||
['A4', 'portrait'],
|
||
undefined,
|
||
];
|
||
for (const demande of refusees) {
|
||
const contenu = contenuFactice();
|
||
await assert.rejects(imprimer(contenu, demande), TypeError, JSON.stringify(demande));
|
||
assert.deepEqual(contenu.options, [], JSON.stringify(demande));
|
||
}
|
||
});
|
||
|
||
test('le rappel donne l’issue : succès → envoyé ; « cancelled » → annulé ; une autre raison → échec, qu’elle nomme ; un print qui lève → échec sans cause, le message gardé pour la console', async () => {
|
||
const cas = [
|
||
[[true, ''], { issue: 'envoye', cause: null }],
|
||
[[false, 'cancelled'], { issue: 'annule', cause: null }],
|
||
[[false, 'failed'], { issue: 'echec', cause: 'failed' }],
|
||
[[false, 'Invalid printer settings'], { issue: 'echec', cause: 'Invalid printer settings' }],
|
||
[[false, ''], { issue: 'echec', cause: null }],
|
||
[[false, undefined], { issue: 'echec', cause: null }],
|
||
];
|
||
for (const [[succes, raison], attendu] of cas) {
|
||
const contenu = contenuFactice(succes, raison);
|
||
assert.deepEqual(await imprimer(contenu, { papier: 'Lettre', orientation: 'paysage' }), attendu, String(raison));
|
||
assert.equal(contenu.options.length, 1);
|
||
}
|
||
const leve = contenuFactice(true, '', new Error('Object has been destroyed'));
|
||
const console0 = console.error;
|
||
const journal = [];
|
||
console.error = (...valeurs) => journal.push(valeurs);
|
||
try {
|
||
assert.deepEqual(await imprimer(leve, { papier: 'A4', orientation: 'portrait' }), { issue: 'echec', cause: null });
|
||
} finally {
|
||
console.error = console0;
|
||
}
|
||
assert.equal(journal.length, 1);
|
||
assert.ok(String(journal[0].at(-1)?.message ?? journal[0].at(-1)).includes('Object has been destroyed'));
|
||
});
|
||
|
||
test('servirImpression pose le seul canal gtt:imprimer ; son gestionnaire imprime l’expéditeur de la demande', async () => {
|
||
const poses = [];
|
||
servirImpression((canal, gestionnaire) => poses.push({ canal, gestionnaire }));
|
||
assert.deepEqual(poses.map(({ canal }) => canal), [CANAL_IMPRIMER]);
|
||
const contenu = contenuFactice(false, 'cancelled');
|
||
const issue = await poses[0].gestionnaire({ sender: contenu }, { papier: 'A4', orientation: 'portrait' });
|
||
assert.deepEqual(issue, { issue: 'annule', cause: null });
|
||
assert.deepEqual(contenu.options, [{ silent: false, printBackground: true, pageSize: 'A4', landscape: false }]);
|
||
});
|
||
});
|