gestion_table_tournante_libre/test/coquille.test.js
Mathieu Benoit a3354e2b0b [ADD] electron: own shell, portable Windows exe built under podman
The deliverable is a single-file portable executable whose Electron the
project updates itself. The shell keeps context isolation on, Node
integration off and the page sandboxed; one IPC channel returns the
folder the portable launcher publishes. The build runs in a no-Wine image
pinned by digest, relays no host variable, refuses to overwrite a built
number, removes what a failed build leaves, and accepts only the name
version.json gives. The spec stops claiming the portable target needs
Wine.

Checked: exe built in 86 s, resource FileVersion 2026.1005.1.0 and
© 2026 TechnoLibre; 109 node tests pass; two end-of-build mutations fail.

--- FR ---

[ADD] electron : coquille propre, exécutable portable construit sous podman

Le livrable est un exécutable portable à fichier unique, dont le projet
met lui-même Electron à jour. La coquille garde l'isolation de contexte,
coupe l'intégration de Node et isole la page ; un seul canal IPC rend le
dossier que publie le lanceur portable. La construction s'exécute dans
une image sans Wine épinglée par empreinte, ne relaie aucune variable de
l'hôte, refuse d'écraser un numéro construit, retire ce qu'un échec
laisse, et n'accepte que le nom que donne version.json. Le spec cesse
d'affirmer que la cible portable exige Wine.

Vérifié : exécutable construit en 86 s, ressource FileVersion
2026.1005.1.0 et © 2026 TechnoLibre ; 109 épreuves node passent ; deux
mutations du contrôle de fin échouent.

Assisted-by: Claude Opus 5.5
2026-10-05 13:13:32 -04:00

140 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 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 pont étroit que le préchargement expose, et le
// trajet du dossier publié par le lanceur portable jusqu'à la page (§ 8.6,
// point 1).
//
// 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.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
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 CANAL = 'gtt:dossier-executable';
// 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;
// Exécute la coquille contre le factice et rend son journal. 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.
function executerLaCoquille() {
const environnement = { ...process.env };
delete environnement.PORTABLE_EXECUTABLE_DIR;
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
try {
const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], {
cwd: ailleurs,
encoding: 'utf8',
env: environnement,
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 JSON.parse(resultat.stdout);
} finally {
rmSync(ailleurs, { recursive: true, force: true });
}
}
// L'exécution est partagée par les épreuves : le processus ne se lance
// qu'une fois, et son échec se rend à chacune sans relancer.
let execution;
function journalDeLaCoquille() {
if (execution === undefined) {
try {
execution = { journal: executerLaCoquille() };
} catch (erreur) {
execution = { erreur };
}
}
if (execution.erreur !== undefined) throw execution.erreur;
return execution.journal;
}
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('la fenêtre charge la page que Vite construit dans www/', () => {
assert.deepEqual(journalDeLaCoquille().chargements, [
{ loadFile: join(RACINE, 'www', 'index.html') },
]);
});
test(`un seul canal IPC, ${CANAL}, posé avant le chargement de la page`, () => {
const { canaux, sequence } = journalDeLaCoquille();
assert.deepEqual(canaux, [CANAL]);
const pose = sequence.indexOf(`ipcMain.handle:${CANAL}`);
const charge = sequence.indexOf('loadFile');
assert.ok(pose !== -1 && charge !== -1 && pose < charge, `ordre des appels : ${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));
});
});
describe('coquille : préchargement', () => {
test("n'expose que window.gtt, et 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: ['dossierExecutable', 'plateforme'] },
]);
assert.equal(prechargement.plateforme, 'electron');
});
test(`gtt.dossierExecutable() demande à chaque appel, par ${CANAL}, le dossier publié par le lanceur portable, null sans lui`, () => {
const { prechargement, invocations } = journalDeLaCoquille();
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
assert.deepEqual(prechargement.reponses, {
publie: { valeur: 'E:\\soirees' },
absent: { valeur: null },
vide: { valeur: null },
});
// Le préchargement voit lui aussi process.env : seules les invocations
// montrent que la réponse vient du processus principal, une par appel.
assert.deepEqual(invocations, [CANAL, CANAL, CANAL]);
});
});