Compare commits

..

No commits in common. "b9090508ff3a1527b1348baa028f675825d3a94c" and "de46c582f4fe28a8b5b7d05444d31f278480fb6c" have entirely different histories.

26 changed files with 30 additions and 1891 deletions

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
* text=auto eol=lf
*.cmd text eol=crlf

View file

@ -1 +0,0 @@
26

108
Makefile
View file

@ -1,108 +0,0 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
#
# Cibles du projet. Chacune délègue à un script qui porte la logique et se
# lance aussi sans make : ./run.sh, ./install.sh, ./install_dev.sh, et sous
# Windows run.cmd, install.cmd, install_dev.cmd. npm, npx et node passent par
# avec_node, qui pose le Node du projet en tête du PATH après avoir vérifié
# l'installation.
#
# make lance l'application, comme ./run.sh
# make help la liste des cibles
ifeq ($(OS),Windows_NT)
PS := powershell.exe -NoProfile -ExecutionPolicy Bypass
LANCER := $(PS) -File scripts/windows/lancer.ps1
INSTALLER := $(PS) -File scripts/windows/installer.ps1
INSTALLER_DEV := $(PS) -File scripts/windows/installer.ps1 -Dev
AVEC_NODE := $(PS) -File scripts/windows/avec_node.ps1
else
# bash, où qu'il soit : /bin/bash n'existe pas sous NixOS, et make s'y
# arrêterait avant la première recette.
SHELL := $(shell command -v bash 2>/dev/null || echo /bin/bash)
LANCER := ./run.sh
INSTALLER := ./install.sh
INSTALLER_DEV := ./install_dev.sh
AVEC_NODE := scripts/installation/avec_node.sh
endif
.DEFAULT_GOAL := run
.PHONY: run
run: ## Lance l'application : Electron sur une session graphique, sinon le serveur de Vite
$(LANCER)
.PHONY: web
web: ## Sert l'application à un navigateur, http://localhost:5173
$(LANCER) --web
.PHONY: electron
electron: ## Lance la coquille Electron
$(LANCER) --electron
.PHONY: install
install: ## Installe de quoi lancer l'application et jouer les épreuves sous node
$(INSTALLER)
.PHONY: install_dev
install_dev: ## Installe aussi Chromium, des polices et podman
$(INSTALLER_DEV)
.PHONY: test
test: ## Joue la série surveillée, sous node (§ 14.4)
$(AVEC_NODE) npm test
.PHONY: test_long
test_long: ## Joue la série longue, sous node
$(AVEC_NODE) npm run test:long
.PHONY: test_navigateur
test_navigateur: ## Joue les épreuves du navigateur, sous Chromium
$(AVEC_NODE) npm run test:navigateur
.PHONY: test_tout
test_tout: ## Joue les trois séries
$(AVEC_NODE) npm run test:tout
.PHONY: build
build: ## Construit la page dans www/
$(AVEC_NODE) npm run build
.PHONY: verifier
verifier: ## Contrôle la version, le nom du livrable et le changelog (§ 18.5)
$(AVEC_NODE) node scripts/version.js controler
.PHONY: essai_demarrage
essai_demarrage: ## Lance Electron, lit le titre de sa fenêtre et ferme
$(AVEC_NODE) node scripts/essai_demarrage.js
ifeq ($(OS),Windows_NT)
.PHONY: windows
windows: ##W Construit l'exécutable Windows portable (sous Linux seulement, § 16)
@echo La construction de l'executable se fait sous Linux, en conteneur : make windows.
@exit 1
.PHONY: clean
clean: ##W Retire la page construite, www/
$(PS) -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue www"
.PHONY: help
help: ##W Affiche cette liste
@findstr /R /C:"^[a-z_]*:.*## " /C:"^[a-z_]*:.*##W " Makefile
else
.PHONY: windows
windows: ##U Construit l'exécutable Windows portable, sous podman (§ 16)
$(AVEC_NODE) scripts/construire_windows.sh
.PHONY: verifier_systemes
verifier_systemes: ##U Installe et éprouve sur chaque système du catalogue, en conteneurs
scripts/verifier_systemes.sh
.PHONY: clean
clean: ##U Retire la page construite, www/
rm -rf www
.PHONY: help
help: ##U Affiche cette liste
@awk 'BEGIN { FS = ":.*##U? " } /^[a-z_]+:.*##U? / { printf " make %-18s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
endif

View file

@ -4,52 +4,18 @@ Logiciel hors ligne qui place les personnes d'un événement autour de tables et
les fait tourner sur plusieurs tours, pour que chacune rencontre le plus de
monde possible.
## Installer et lancer
| système | installer | lancer |
|---|---|---|
| Linux, macOS | `./install.sh` | `./run.sh`, ou `make` |
| Windows | `install.cmd` | `run.cmd` |
| NixOS | `./install.sh` | `./run.sh` ; `make` dans `nix-shell scripts/installation/shell.nix` |
`./install_dev.sh` (sous Windows `install_dev.cmd`) installe en plus le Chromium
des épreuves du navigateur, des polices et, sur amd64, `podman`, sous lequel se
construit l'exécutable Windows.
L'installation pose les paquets du système qui manquent — apt, dnf, zypper ou
pacman, par `sudo` —, puis Node de la majeure écrite dans `.node-version`,
téléchargé de nodejs.org et vérifié contre sa somme, hors du dépôt :
`~/.local/share/gestion_table_tournante_libre`, sous Windows
`%LOCALAPPDATA%\gestion_table_tournante_libre`. Node n'exige donc aucun droit
d'administration. Sous NixOS, `scripts/installation/shell.nix` déclare Node,
Electron et Chromium, d'un nixpkgs épinglé.
Systèmes éprouvés, en conteneurs, par `make verifier_systemes` : Ubuntu 24.04,
25.10 et 26.04, Debian 12 et 13 (Proxmox VE 9 en dérive), Fedora 41 à 44,
AlmaLinux et Rocky Linux 9 et 10, openSUSE Leap 16.0 et Tumbleweed, Arch Linux,
et Nix ; les scripts Windows sous `pwsh`.
`./run.sh` lance la coquille Electron sur une session graphique ; sans écran,
ou là où Electron n'est pas publié (s390x, ppc64le), il sert l'application à un
navigateur sur http://localhost:5173. `./run.sh --web` et `./run.sh --electron`
forcent l'un ou l'autre.
## Commandes
| commande | effet |
|---|---|
| `make` | lance l'application, comme `./run.sh` |
| `make test` | série `node` : les fonctions pures, à chaque modification |
| `make test_long` | épreuves lourdes, avant un commit |
| `make test_navigateur` | épreuves de rendu dans Chromium, avant une livraison |
| `make build` | construit la page dans `www/` |
| `make windows` | construit l'exécutable Windows portable, sous `podman`, depuis Linux |
| `make essai_demarrage` | lance la vraie coquille Electron, lit son titre et ferme |
| `make help` | toutes les cibles |
Chaque cible passe par le Node du projet. Les commandes npm d'origine restent
valables avec un Node de la bonne majeure dans le `PATH` : `npm test`,
`npm run dev`, `npm run build`.
| `npm install` | installe les outils de construction et d'épreuve |
| `npx playwright install chromium` | télécharge le Chromium des épreuves navigateur, une fois par machine |
| `npm run dev` | sert l'application en développement, sur la plateforme web |
| `npm test` | série `node` : les fonctions pures, à chaque modification |
| `npm run test:long` | épreuves lourdes, avant un commit |
| `npm run test:navigateur` | épreuves de rendu dans Chromium, avant une livraison |
| `npm run build` | construit la page dans `www/` |
| `scripts/construire_windows.sh` | construit l'exécutable Windows portable, sous `podman` |
L'exécutable porte la version datée du logiciel dans son nom, selon le gabarit
`gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.

View file

@ -1,6 +0,0 @@
@REM © 2026 TechnoLibre (http://www.technolibre.ca)
@REM License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
@echo off
rem Installe de quoi lancer l'application depuis les sources : scripts\windows\installer.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows\installer.ps1" %*
exit /b %ERRORLEVEL%

View file

@ -1,73 +0,0 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Installe de quoi lancer l'application depuis les sources et jouer les
# épreuves sous node : les outils et les bibliothèques du système, Node de la
# majeure de .node-version, et les dépendances de package-lock.json.
# Relancé, il ne refait que ce qui manque.
#
# ./install.sh
#
# Sous Windows (bash de Git), la main passe à install.cmd ; sous NixOS, à
# scripts/installation/shell.nix, qui déclare ce que les autres systèmes
# installent.
set -euo pipefail
cd -- "$(dirname -- "$0")"
. scripts/installation/commun.sh
for argument in "$@"; do
case "$argument" in
-h | --help)
sed -n '5,9p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
gtt_dire "usage : ./install.sh"
exit 2
;;
esac
done
famille="$(gtt_famille)"
case "$famille" in
windows) gtt_powershell installer.ps1 ;;
nixos)
gtt_dans_nix_shell ./install.sh
gtt_npm_ci
gtt_dire "Installation terminée. Lancer : ./run.sh, ou make."
exit 0
;;
macos)
xcode-select -p > /dev/null 2>&1 \
|| gtt_dire "Outils de ligne de commande de Xcode absents, dont make : xcode-select --install"
;;
debian | fedora | suse | arch)
paquets="$(gtt_paquets_outils "$famille")"
if gtt_electron_disponible; then
paquets="$paquets $(gtt_paquets_electron "$famille")"
fi
# shellcheck disable=SC2086
gtt_installer_paquets "$famille" $paquets
;;
*)
gtt_refuser "système non reconnu ($(uname -s), /etc/os-release). Systèmes pris en charge :" \
"Debian, Ubuntu, Proxmox VE, Fedora, AlmaLinux, Rocky Linux, openSUSE, Arch, NixOS, macOS, Windows." \
"Un dérivé se rattache à sa famille : GTT_FAMILLE=debian ./install.sh (debian, fedora, suse, arch)."
;;
esac
gtt_poser_node
gtt_activer_node
if ! gtt_electron_disponible; then
gtt_dire "Electron n'est pas publié pour $(gtt_os_node)-$(gtt_arch_node) :" \
"./run.sh servira l'application à un navigateur."
fi
gtt_npm_ci
if gtt_electron_disponible && [ "$(gtt_os_node)" = linux ]; then
manquantes="$(gtt_bibliotheques_manquantes node_modules/electron/dist/electron)"
[ -z "$manquantes" ] || gtt_refuser "Electron ne trouve pas :" "$manquantes"
gtt_preparer_bac_a_sable
fi
gtt_dire "Installation terminée. Lancer : ./run.sh, ou make."

View file

@ -1,6 +0,0 @@
@REM © 2026 TechnoLibre (http://www.technolibre.ca)
@REM License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
@echo off
rem Installe l'environnement de developpement : scripts\windows\installer.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows\installer.ps1" -Dev %*
exit /b %ERRORLEVEL%

View file

@ -1,61 +0,0 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Installe l'environnement de développement : ce qu'installe ./install.sh,
# puis le Chromium de Playwright des épreuves du navigateur, des polices, et
# podman, sous lequel se construit l'exécutable Windows (§ 16) sur amd64.
#
# ./install_dev.sh
set -euo pipefail
cd -- "$(dirname -- "$0")"
. scripts/installation/commun.sh
for argument in "$@"; do
case "$argument" in
-h | --help)
sed -n '5,10p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
gtt_dire "usage : ./install_dev.sh"
exit 2
;;
esac
done
famille="$(gtt_famille)"
case "$famille" in
windows) gtt_powershell installer.ps1 -Dev ;;
# shell.nix déclare aussi Chromium et ses polices.
nixos) exec ./install.sh ;;
esac
./install.sh
case "$famille" in
debian | fedora | suse | arch)
paquets="$(gtt_paquets_developpement "$famille")"
if [ "$(gtt_arch_node)" = x64 ]; then
paquets="$paquets podman"
fi
# shellcheck disable=SC2086
gtt_installer_paquets "$famille" $paquets
;;
esac
gtt_activer_node
if gtt_chromium_disponible; then
npx --no-install playwright install --only-shell chromium
if [ "$(gtt_os_node)" = linux ]; then
navigateurs="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}"
coquille="$(find "$navigateurs" -maxdepth 3 -type f \
\( -name headless_shell -o -name chrome-headless-shell \) 2> /dev/null | head -n 1)"
[ -n "$coquille" ] || gtt_refuser "Chromium de Playwright introuvable sous $navigateurs."
manquantes="$(gtt_bibliotheques_manquantes "$coquille")"
[ -z "$manquantes" ] || gtt_refuser "Chromium de Playwright ne trouve pas :" "$manquantes"
fi
else
gtt_dire "Playwright ne publie pas Chromium pour $(gtt_os_node)-$(gtt_arch_node) :" \
"les épreuves du navigateur ne s'y jouent pas."
fi
gtt_dire "Environnement de développement prêt : make help."

View file

@ -1,6 +0,0 @@
@REM © 2026 TechnoLibre (http://www.technolibre.ca)
@REM License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
@echo off
rem Lance l'application depuis les sources : scripts\windows\lancer.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows\lancer.ps1" %*
exit /b %ERRORLEVEL%

67
run.sh
View file

@ -1,67 +0,0 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Lance l'application depuis les sources. Sur une session graphique, la
# coquille Electron charge la page que Vite construit dans www/ (§ 13.1) ;
# sans écran, ou là où Electron n'est pas publié, le serveur de
# développement de Vite la sert à un navigateur.
#
# ./run.sh Electron si possible, sinon le serveur de Vite
# ./run.sh --web le serveur de Vite, http://localhost:5173
# ./run.sh --electron Electron, ou un refus qui dit pourquoi
set -euo pipefail
cd -- "$(dirname -- "$0")"
. scripts/installation/commun.sh
mode=auto
for argument in "$@"; do
case "$argument" in
--web) mode=web ;;
--electron) mode=electron ;;
-h | --help)
sed -n '5,12p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
gtt_dire "usage : ./run.sh [--web | --electron]"
exit 2
;;
esac
done
[ "$(gtt_famille)" = windows ] && gtt_powershell lancer.ps1 "$@"
gtt_dans_nix_shell ./run.sh "$@"
gtt_preparer
if [ "$mode" = auto ]; then
if gtt_electron_disponible && gtt_session_graphique; then
mode=electron
else
mode=web
fi
fi
if [ "$mode" = web ]; then
gtt_dire "Serveur de développement de Vite : http://localhost:5173 (Ctrl+C pour l'arrêter)."
exec npm run --silent dev
fi
gtt_electron_disponible \
|| gtt_refuser "Electron n'est pas publié pour $(gtt_os_node)-$(gtt_arch_node) : ./run.sh --web."
gtt_session_graphique \
|| gtt_refuser "aucune session graphique (DISPLAY, WAYLAND_DISPLAY) : ./run.sh --web ; make essai_demarrage lance Electron sans écran."
options=()
if [ "$(gtt_os_node)" = linux ]; then
if [ "$(id -u)" = 0 ]; then
gtt_dire "Sous root, Chromium refuse son bac à sable : Electron se lance sans."
options+=(--no-sandbox)
elif ! gtt_bac_a_sable_pret; then
gtt_dire "Le bac à sable d'Electron demande chrome-sandbox setuid root (espaces de noms restreints)."
gtt_confirmer "Le poser par sudo ?" \
|| gtt_refuser "bac à sable non posé : ./install.sh le pose, puis relancer."
gtt_preparer_bac_a_sable
fi
fi
npm run --silent build
exec npx --no-install electron . ${options[@]+"${options[@]}"}

View file

@ -118,11 +118,6 @@ const NODE_CONSIGNE = [
// bin/ en reçoit des liens : seul podman y manque.
const OUTILS = ['bash', 'dirname', 'find', 'mkdir', 'rm', 'wc', 'file'];
// La construction se fait sous Linux (§ 16) : le script emploie bash 4 et le
// find de GNU, que ni macOS ni Windows ne livrent. Ces épreuves ne se jouent
// pas ailleurs.
const HORS_LINUX = process.platform !== 'linux';
// Chemin d'un exécutable dans le PATH de l'épreuve, ou null.
function chercher(nom) {
for (const dossier of (process.env.PATH ?? '').split(delimiter)) {
@ -253,7 +248,7 @@ const taperLeNom = (nom) => (projet) =>
let reussie;
const constructionReussie = () => (reussie ??= construire({ produit: NOM }));
describe('construction Windows : refus avant de construire', { skip: HORS_LINUX }, () => {
describe('construction Windows : refus avant de construire', () => {
test('une version sans section au changelog ne se construit pas (§ 16)', () => {
const r = construire({
preparer: (projet) =>
@ -326,7 +321,7 @@ describe('construction Windows : refus avant de construire', { skip: HORS_LINUX
});
});
describe('construction Windows : la page', { skip: HORS_LINUX }, () => {
describe('construction Windows : la page', () => {
test('une page qui ne se construit pas arrête tout, avant podman', () => {
const r = construire({ produit: NOM, codeNpm: 1 });
assert.equal(r.statut, 1, r.erreurs);
@ -344,7 +339,7 @@ describe('construction Windows : la page', { skip: HORS_LINUX }, () => {
});
});
describe('construction Windows : la construction', { skip: HORS_LINUX }, () => {
describe('construction Windows : la construction', () => {
test("construit la page puis l'exécutable, et imprime son chemin, sa taille et son type", () => {
const r = constructionReussie();
assert.equal(r.statut, 0, r.erreurs);
@ -398,7 +393,7 @@ describe('construction Windows : la construction', { skip: HORS_LINUX }, () => {
});
});
describe('construction Windows : contrôle de fin de construction (§ 18.5)', { skip: HORS_LINUX }, () => {
describe('construction Windows : contrôle de fin de construction (§ 18.5)', () => {
test("refuse un exécutable dont le nom ne dérive pas de version.json", () => {
const autre = 'Gestion table tournante Libre.exe';
const r = construire({ produit: autre });
@ -439,7 +434,7 @@ describe('construction Windows : contrôle de fin de construction (§ 18.5)', {
});
});
describe('construction Windows : interruption', { skip: HORS_LINUX }, () => {
describe('construction Windows : interruption', () => {
// Le signal ne vise que le shell, comme un kill depuis un autre terminal.
// podman écrit après l'avoir reçu, comme electron-builder qui achève son
// travail : l'épreuve ne réagit qu'à des événements, l'annonce du podman

View file

@ -1,62 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Essai de démarrage : construit la page, lance la vraie coquille Electron
// par Playwright, attend sa fenêtre, lit le titre et le bandeau, puis ferme.
// Il prouve ce que la coquille factice des épreuves (test/coquille.test.js)
// ne peut pas dire : qu'Electron démarre sur ce système, avec ses
// bibliothèques, et y charge la page.
//
// node scripts/essai_demarrage.js
//
// Sous Linux sans session graphique, Electron se lance sur la plateforme
// Ozone « headless » de Chromium : la fenêtre existe et la page s'y charge
// et s'y met en page, sans serveur d'affichage. Sous root, ou avec
// GTT_SANS_BAC_A_SABLE=1 là où le système refuse les espaces de noms
// d'utilisateur — un conteneur —, Electron se lance sans son bac à sable.
// Sort à 0 sur un démarrage conforme, à 1 sinon.
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { _electron } from 'playwright';
import { VERSION } from '../src/version.genere.js';
import { titreAvecVersion } from '../src/interface/libelles.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const ATTENTE_MS = 60_000;
const sousRoot = typeof process.getuid === 'function' && process.getuid() === 0;
const sansBacASable = sousRoot || process.env.GTT_SANS_BAC_A_SABLE === '1';
const sansEcran = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
// npm est un script .cmd sous Windows, que Node ne lance que par un shell.
execFileSync('npm', ['run', '--silent', 'build'], {
cwd: RACINE,
stdio: 'inherit',
shell: process.platform === 'win32',
});
const attendu = titreAvecVersion(VERSION.affichee);
const application = await _electron.launch({
args: [
...(sansBacASable ? ['--no-sandbox'] : []),
...(sansEcran ? ['--ozone-platform=headless'] : []),
'.',
],
cwd: RACINE,
timeout: ATTENTE_MS,
});
let conforme = false;
try {
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
await fenetre.locator('header.bandeau .titre').waitFor({ timeout: ATTENTE_MS });
const titre = await fenetre.title();
const bandeau = (await fenetre.locator('header.bandeau .titre').textContent()).trim();
conforme = titre === attendu && bandeau === attendu;
console.log(`Titre de la fenêtre : ${titre}`);
console.log(`Bandeau : ${bandeau}`);
if (!conforme) console.error(`Attendu : ${attendu}`);
} finally {
await application.close();
}
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
process.exit(conforme ? 0 : 1);

View file

@ -1,22 +0,0 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Exécute une commande à la racine du projet, le Node du projet en tête du
# PATH, une fois l'installation vérifiée : c'est par lui que le Makefile
# lance npm, npx et node. Sous NixOS, la commande s'exécute dans
# scripts/installation/shell.nix.
#
# scripts/installation/avec_node.sh npm test
set -euo pipefail
. "$(dirname -- "$0")/commun.sh"
if [ $# -eq 0 ]; then
gtt_dire "usage : scripts/installation/avec_node.sh <commande> [argument…]"
exit 2
fi
[ "$(gtt_famille)" = windows ] && gtt_powershell avec_node.ps1 "$@"
gtt_dans_nix_shell "$0" "$@"
cd -- "$GTT_RACINE"
gtt_preparer
exec "$@"

View file

@ -1,497 +0,0 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Fonctions communes d'install.sh, d'install_dev.sh, de run.sh et
# d'avec_node.sh : reconnaître le système, poser le Node du projet, installer
# des paquets. Ce fichier se source, il ne s'exécute pas.
#
# Il reste lisible par le bash 3.2 de macOS : ni tableau associatif, ni
# ${v,,}, ni mapfile, ni readlink -f.
GTT_RACINE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
gtt_dire() {
printf '%s\n' "$*" >&2
}
gtt_refuser() {
printf 'Refusé : %s\n' "$*" >&2
exit 1
}
# Famille d'un fichier os-release : debian, fedora, suse, arch, nixos ou
# inconnu. ID puis ID_LIKE décident, et non le nom affiché : AlmaLinux et
# Rocky se déclarent « rhel centos fedora », Linux Mint « ubuntu debian »,
# Proxmox VE « debian ». NixOS passe en premier : sa famille ne pose aucun
# paquet.
gtt_famille_depuis_os_release() {
local fichier="$1" id id_like
if [ ! -r "$fichier" ]; then
echo inconnu
return
fi
id="$(sed -n 's/^ID=//p' "$fichier" | tr -d "\"'" | head -n 1)"
id_like="$(sed -n 's/^ID_LIKE=//p' "$fichier" | tr -d "\"'" | head -n 1)"
case " $id $id_like " in
*" nixos "*) echo nixos ;;
*" debian "* | *" ubuntu "*) echo debian ;;
*" suse "* | *" opensuse "* | *" opensuse-leap "* | *" opensuse-tumbleweed "* | *" sles "*) echo suse ;;
*" fedora "* | *" rhel "* | *" centos "*) echo fedora ;;
*" arch "*) echo arch ;;
*) echo inconnu ;;
esac
}
# Famille du système courant : celles d'os-release, plus macos et windows
# (bash de Git, MSYS2, Cygwin). GTT_FAMILLE la force, pour un dérivé que
# os-release ne rattache à rien. uname -s interroge le noyau ; $OSTYPE, figé
# à la compilation de bash, vaut « linux » sous openSUSE et « linux-gnu »
# ailleurs.
gtt_famille() {
if [ -n "${GTT_FAMILLE:-}" ]; then
echo "$GTT_FAMILLE"
return
fi
case "$(uname -s)" in
Darwin) echo macos ;;
MINGW* | MSYS* | CYGWIN*) echo windows ;;
Linux) gtt_famille_depuis_os_release /etc/os-release ;;
*) echo inconnu ;;
esac
}
# Architecture d'une machine au sens de nodejs.org (x64, arm64, s390x,
# ppc64le), « inconnue » ailleurs.
gtt_arch_node_depuis() {
case "$1" in
x86_64 | amd64) echo x64 ;;
aarch64 | arm64) echo arm64 ;;
s390x) echo s390x ;;
ppc64le) echo ppc64le ;;
*) echo inconnue ;;
esac
}
gtt_arch_node() {
gtt_arch_node_depuis "$(uname -m)"
}
# Système au sens de nodejs.org : linux, darwin, win.
gtt_os_node() {
case "$(gtt_famille)" in
macos) echo darwin ;;
windows) echo win ;;
*) echo linux ;;
esac
}
# Vrai quand Electron publie un binaire pour ce système et cette
# architecture. Ailleurs — s390x, ppc64le —, l'application se sert dans un
# navigateur.
gtt_electron_publie() {
case "$1-$2" in
linux-x64 | linux-arm64 | darwin-x64 | darwin-arm64) return 0 ;;
*) return 1 ;;
esac
}
gtt_electron_disponible() {
gtt_electron_publie "$(gtt_os_node)" "$(gtt_arch_node)"
}
# Vrai quand Playwright publie son Chromium pour ce système et cette
# architecture : les épreuves du navigateur n'ont pas d'autre moteur.
gtt_chromium_disponible() {
gtt_electron_publie "$(gtt_os_node)" "$(gtt_arch_node)"
}
# Majeure de Node que vise le projet. .node-version en est la seule source ;
# une valeur qui n'est pas un entier est refusée plutôt que passée à une URL.
gtt_node_majeure() {
local majeure
majeure="$(tr -d ' \t\r\n' < "$GTT_RACINE/.node-version")"
case "$majeure" in
'' | *[!0-9]*) gtt_refuser ".node-version doit porter une majeure entière : « $majeure »." ;;
esac
echo "$majeure"
}
# Dossiers hors du dépôt : le Node posé, et les archives téléchargées. Rangé
# hors du dépôt, Node échappe à l'observateur de Vite, au balayage des
# épreuves et à « git clean », et sert à chaque copie de travail.
gtt_dossier_donnees() {
echo "${XDG_DATA_HOME:-$HOME/.local/share}/gestion_table_tournante_libre"
}
gtt_dossier_cache() {
echo "${XDG_CACHE_HOME:-$HOME/.cache}/gestion_table_tournante_libre"
}
gtt_dossier_node() {
echo "$(gtt_dossier_donnees)/node-$(gtt_node_majeure)-$(gtt_os_node)-$(gtt_arch_node)"
}
# Somme et nom de l'archive .tar.xz d'une plateforme dans un SHASUMS256.txt
# de nodejs.org, lignes « <somme> node-v26.1.0-linux-x64.tar.xz » ; rien
# quand la plateforme n'y figure pas. Le suffixe se compare en entier :
# « linux-x64 » ne retient ni « linux-x64-musl » ni une archive .tar.gz.
gtt_archive_node() {
local sommes="$1" os="$2" arch="$3"
awk -v suffixe="-$os-$arch.tar.xz" '
$2 ~ /^node-v[0-9]+\.[0-9]+\.[0-9]+-/ &&
length($2) > length(suffixe) &&
substr($2, length($2) - length(suffixe) + 1) == suffixe {
print $1, $2
exit
}' "$sommes"
}
gtt_telecharger() {
local url="$1" destination="$2"
if command -v curl > /dev/null 2>&1; then
curl -fsSL --retry 3 -o "$destination" "$url"
elif command -v wget > /dev/null 2>&1; then
wget -q -O "$destination" "$url"
else
gtt_refuser "ni curl ni wget : impossible de télécharger $url"
fi
}
gtt_sha256() {
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "$1" | cut -d ' ' -f 1
else
shasum -a 256 "$1" | cut -d ' ' -f 1
fi
}
# Pose la dernière version publiée de la majeure voulue, une fois : un Node
# déjà posé qui démarre est gardé. L'archive est vérifiée contre la somme
# que nodejs.org publie, puis extraite à côté de la cible et renommée, si
# bien qu'une extraction interrompue ne laisse jamais une cible à moitié
# écrite.
gtt_poser_node() {
local majeure os arch cible cache sommes ligne somme nom archive partiel tmp manquantes
majeure="$(gtt_node_majeure)"
os="$(gtt_os_node)"
arch="$(gtt_arch_node)"
[ "$arch" != inconnue ] || gtt_refuser "architecture $(uname -m) : nodejs.org n'y publie pas Node."
cible="$(gtt_dossier_node)"
if [ -x "$cible/bin/node" ] && "$cible/bin/node" --version > /dev/null 2>&1; then
gtt_dire "Node $("$cible/bin/node" --version) : $cible"
return 0
fi
cache="$(gtt_dossier_cache)"
mkdir -p "$(gtt_dossier_donnees)" "$cache"
sommes="$cache/SHASUMS256-v$majeure.txt"
gtt_telecharger "https://nodejs.org/dist/latest-v$majeure.x/SHASUMS256.txt" "$sommes"
ligne="$(gtt_archive_node "$sommes" "$os" "$arch")"
[ -n "$ligne" ] || gtt_refuser "nodejs.org ne publie pas Node $majeure pour $os-$arch."
somme="${ligne%% *}"
nom="${ligne#* }"
archive="$cache/$nom"
if [ ! -f "$archive" ] || [ "$(gtt_sha256 "$archive")" != "$somme" ]; then
gtt_dire "Téléchargement de $nom"
# Nom unique : deux installations qui partagent le cache téléchargent
# chacune dans son fichier.
partiel="$(mktemp "$archive.XXXXXX")"
gtt_telecharger "https://nodejs.org/dist/latest-v$majeure.x/$nom" "$partiel"
if [ "$(gtt_sha256 "$partiel")" != "$somme" ]; then
rm -f "$partiel"
gtt_refuser "$nom : la somme SHA-256 ne correspond pas à celle que publie nodejs.org."
fi
mv -f "$partiel" "$archive"
fi
tmp="$(mktemp -d "$(gtt_dossier_donnees)/extraction.XXXXXX")"
tar -xJf "$archive" -C "$tmp"
rm -rf "$cible"
mv "$tmp/${nom%.tar.xz}" "$cible"
rmdir "$tmp"
if ! "$cible/bin/node" --version > /dev/null 2>&1; then
manquantes="$(gtt_bibliotheques_manquantes "$cible/bin/node")"
rm -rf "$cible"
gtt_refuser "le Node de nodejs.org ne démarre pas ici${manquantes:+ ; bibliothèques absentes : $manquantes}."
fi
gtt_dire "Node $("$cible/bin/node" --version) posé : $cible"
}
# Met le Node du projet en tête du PATH ; faux quand il n'est pas posé.
gtt_activer_node() {
local bin
bin="$(gtt_dossier_node)/bin"
[ -x "$bin/node" ] || return 1
PATH="$bin:$PATH"
export PATH
}
# Vrai quand les dépendances npm suivent package-lock.json : npm écrit
# node_modules/.package-lock.json à chaque installation, et un verrou plus
# récent que lui annonce une installation à refaire.
gtt_dependances_a_jour() {
local recu="$GTT_RACINE/node_modules/.package-lock.json"
[ -f "$recu" ] && [ ! "$GTT_RACINE/package-lock.json" -nt "$recu" ]
}
# Vrai sur une réponse affirmative au terminal — o, oui, y, yes, sans égard à
# la casse. Le défaut est non, et sans terminal rien ne consent. GTT_OUI=1
# répond oui d'avance, pour une installation sans opérateur.
gtt_confirmer() {
local reponse
[ "${GTT_OUI:-}" = 1 ] && return 0
[ -t 0 ] || return 1
printf '%s [o/N] ' "$1" >&2
read -r reponse || return 1
case "$(printf '%s' "$reponse" | tr '[:upper:]' '[:lower:]')" in
o | oui | y | yes) return 0 ;;
*) return 1 ;;
esac
}
# Exécute une commande d'administration : telle quelle sous root, par sudo
# ailleurs.
gtt_admin() {
if [ "$(id -u)" = 0 ]; then
"$@"
elif command -v sudo > /dev/null 2>&1; then
sudo "$@"
else
gtt_refuser "sudo introuvable : relancer en root, ou exécuter : $*"
fi
}
# Nom d'un paquet apt sur ce système. Debian 13 et Ubuntu 24.04 ont renommé
# en « t64 » les bibliothèques dont l'interface a changé avec le temps sur
# 64 bits ; l'ancien nom n'y est plus qu'un paquet virtuel, qu'apt-get refuse
# d'installer sans qu'on choisisse son fournisseur.
gtt_nom_apt() {
if apt-cache show "${1}t64" > /dev/null 2>&1; then
echo "${1}t64"
else
echo "$1"
fi
}
# Paquets de la liste qui ne sont pas installés, un par ligne, sous le nom
# que le gestionnaire connaît. Sous apt, les noms t64 se résolvent par
# apt-cache, qui ne connaît que les paquets des listes déjà téléchargées.
gtt_paquets_manquants() {
local famille="$1" paquet nom
shift
case "$famille" in
arch) pacman -T "$@" 2> /dev/null || true ;;
*)
for paquet in "$@"; do
case "$famille" in
debian)
nom="$(gtt_nom_apt "$paquet")"
[ "$(dpkg-query -W -f='${db:Status-Abbrev}' "$nom" 2> /dev/null)" = "ii " ] || echo "$nom"
;;
fedora | suse) rpm -q "$paquet" > /dev/null 2>&1 || echo "$paquet" ;;
*) echo "$paquet" ;;
esac
done
;;
esac
}
# Installe ce qui manque de la liste par le gestionnaire de la famille, sans
# question ; rien, et sans sudo, quand tout est là. pacman met tout le
# système à jour : Arch ne prend pas en charge les mises à jour partielles.
# $noms se découpe en paquets, un par mot.
# shellcheck disable=SC2086
gtt_installer_paquets() {
local famille="$1" paquet noms
shift
[ $# -gt 0 ] || return 0
if [ "$famille" = debian ] && ! apt-cache show libc6 > /dev/null 2>&1; then
gtt_admin env DEBIAN_FRONTEND=noninteractive apt-get update -q
fi
noms="$(gtt_paquets_manquants "$famille" "$@" | tr '\n' ' ')"
if [ -z "${noms// /}" ]; then
gtt_dire "Paquets du système : déjà installés."
return 0
fi
gtt_dire "Paquets du système à installer : $noms"
case "$famille" in
debian)
gtt_admin env DEBIAN_FRONTEND=noninteractive apt-get update -q
gtt_admin env DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends $noms
;;
fedora) gtt_admin dnf install -y $noms ;;
suse) gtt_admin zypper --non-interactive install --no-recommends $noms ;;
arch) gtt_admin pacman -Syu --noconfirm --needed $noms ;;
*) gtt_refuser "aucun gestionnaire de paquets connu pour la famille « $famille »." ;;
esac
}
# Outils dont les scripts et les épreuves se servent, par famille : de quoi
# télécharger et extraire Node, la libatomic que réclame le Node de
# nodejs.org, awk, que l'image de Tumbleweed n'a pas, make, et file et find,
# qu'appelle l'épreuve de scripts/construire_windows.sh. curl n'est demandé qu'en l'absence de curl
# et de wget : Fedora et ses dérivés livrent curl-minimal, qu'un paquet curl
# contredirait.
gtt_paquets_outils() {
local liste="make file tar"
if ! command -v curl > /dev/null 2>&1 && ! command -v wget > /dev/null 2>&1; then
liste="$liste curl"
fi
case "$1" in
debian) echo "$liste ca-certificates xz-utils findutils libatomic1" ;;
fedora) echo "$liste ca-certificates xz findutils libatomic" ;;
suse) echo "$liste ca-certificates xz findutils gawk libatomic1" ;;
arch) echo "$liste ca-certificates xz findutils gcc-libs" ;;
esac
}
# Bibliothèques qu'Electron charge au démarrage, par famille : GTK 3, NSS,
# ALSA, GBM, et les extensions X11 du compositeur. L'installation vérifie
# ensuite par ldd qu'aucune ne manque.
gtt_paquets_electron() {
case "$1" in
debian)
echo "libgtk-3-0 libnss3 libasound2 libgbm1 libdrm2 libxkbcommon0" \
"libatk-bridge2.0-0 libcups2 libxcomposite1 libxdamage1 libxrandr2" \
"libpango-1.0-0 libcairo2 libatspi2.0-0"
;;
fedora)
echo "gtk3 nss alsa-lib mesa-libgbm libdrm libxkbcommon at-spi2-atk" \
"cups-libs libXcomposite libXdamage libXrandr pango cairo"
;;
suse)
echo "libgtk-3-0 mozilla-nss libasound2 libgbm1 libdrm2 libxkbcommon0" \
"libatk-bridge-2_0-0 libcups2 libXcomposite1 libXdamage1 libXrandr2" \
"libpango-1_0-0 libcairo2"
;;
arch)
echo "gtk3 nss alsa-lib mesa libdrm libxkbcommon at-spi2-core libcups" \
"libxcomposite libxdamage libxrandr pango cairo"
;;
esac
}
# Ce que le développement ajoute : des polices, sans lesquelles Chromium
# dessine un texte sans largeur. Lancer Electron sans écran ne demande rien
# de plus : scripts/essai_demarrage.js emploie la plateforme Ozone headless.
gtt_paquets_developpement() {
case "$1" in
debian) echo "fonts-dejavu-core" ;;
fedora) echo "dejavu-sans-fonts" ;;
suse) echo "dejavu-fonts" ;;
arch) echo "ttf-dejavu" ;;
esac
}
# Bibliothèques qu'un exécutable réclame sans les trouver, sur une ligne ;
# rien quand toutes se résolvent, ou quand ldd manque. Un exécutable absent
# est nommé comme tel.
gtt_bibliotheques_manquantes() {
if [ ! -f "$1" ]; then
echo "$1 (absent)"
return 0
fi
command -v ldd > /dev/null 2>&1 || return 0
{ ldd "$1" 2> /dev/null || true; } | awk '/not found/ { print $1 }' | sort -u | tr '\n' ' ' | sed 's/ $//'
}
# Vrai quand une session graphique est ouverte : Electron a où dessiner.
gtt_session_graphique() {
case "$(gtt_famille)" in
macos | windows) return 0 ;;
esac
[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]
}
# Vrai quand Chromium ne peut pas cloisonner ses processus par des espaces
# de noms d'utilisateur : Ubuntu depuis 24.04 les restreint par AppArmor, un
# noyau peut les couper. Electron se rabat alors sur son assistant
# chrome-sandbox, qui doit appartenir à root et porter le bit setuid.
gtt_espaces_utilisateur_restreints() {
[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2> /dev/null)" = 1 ] && return 0
[ "$(cat /proc/sys/kernel/unprivileged_userns_clone 2> /dev/null)" = 0 ] && return 0
[ "$(cat /proc/sys/user/max_user_namespaces 2> /dev/null)" = 0 ] && return 0
return 1
}
GTT_ASSISTANT_BAC_A_SABLE="node_modules/electron/dist/chrome-sandbox"
# Vrai quand le bac à sable d'Electron démarre : espaces de noms libres, ou
# assistant setuid root. npm ci réécrit l'assistant sans ce bit.
gtt_bac_a_sable_pret() {
local assistant="$GTT_RACINE/$GTT_ASSISTANT_BAC_A_SABLE"
[ -f "$assistant" ] || return 0
gtt_espaces_utilisateur_restreints || return 0
[ -u "$assistant" ] && [ "$(stat -c %u "$assistant")" = 0 ]
}
gtt_preparer_bac_a_sable() {
local assistant="$GTT_RACINE/$GTT_ASSISTANT_BAC_A_SABLE"
gtt_bac_a_sable_pret && return 0
gtt_dire "Espaces de noms d'utilisateur restreints : chrome-sandbox devient setuid root."
gtt_admin chown root:root "$assistant"
gtt_admin chmod 4755 "$assistant"
}
# Propose ./install.sh, et le lance sur consentement ; sans lui, refuse.
gtt_proposer_installation() {
gtt_dire "$1"
if ! gtt_confirmer "Lancer ./install.sh (paquets du système par sudo, Node, dépendances npm) ?"; then
gtt_refuser "installation non lancée. Lancer ./install.sh, puis recommencer."
fi
"$GTT_RACINE/install.sh"
}
# Rend prêt à exécuter : le Node du projet en tête du PATH, et des
# dépendances npm qui suivent package-lock.json. Sous NixOS, le Node vient
# de scripts/installation/shell.nix, dans lequel l'appelant s'exécute déjà.
# Des dépendances en retard se réinstallent sans question : node_modules ne
# porte que ce que le verrou décrit.
gtt_preparer() {
if [ "$(gtt_famille)" = nixos ]; then
command -v node > /dev/null 2>&1 \
|| gtt_refuser "node absent : lancer depuis scripts/installation/shell.nix."
elif ! gtt_activer_node; then
gtt_proposer_installation "Node $(gtt_node_majeure) n'est pas posé."
gtt_activer_node || gtt_refuser "Node $(gtt_node_majeure) reste introuvable après l'installation."
fi
if ! gtt_dependances_a_jour; then
gtt_dire "Les dépendances npm ne suivent pas package-lock.json : npm ci."
(cd -- "$GTT_RACINE" && gtt_npm_ci)
else
(cd -- "$GTT_RACINE" && gtt_poser_electron)
fi
}
# Pose le binaire d'Electron de la version de package-lock.json. Le paquet
# electron ne le télécharge plus à l'installation mais au premier
# chargement ; install-electron le fait d'avance, contre les sommes que le
# paquet embarque, et ne refait rien quand il est déjà là. Rien là où
# Electron n'est pas publié, ni sous Nix, où ELECTRON_OVERRIDE_DIST_PATH
# désigne celui de nixpkgs.
gtt_poser_electron() {
gtt_electron_disponible || return 0
[ -z "${ELECTRON_OVERRIDE_DIST_PATH:-}" ] || return 0
npx --no-install install-electron
}
gtt_npm_ci() {
npm ci --no-audit --no-fund
gtt_poser_electron
}
# Ligne de commande de PowerShell pour un script de scripts/windows, depuis
# le bash de Git sous Windows.
gtt_powershell() {
local script="$1"
shift
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$GTT_RACINE/scripts/windows/$script" "$@"
}
# Relance la commande dans scripts/installation/shell.nix, sous NixOS, quand
# elle n'y est pas déjà. shell.nix pose GTT_SHELL_NIX : IN_NIX_SHELL, que
# pose tout nix-shell, ne dit pas lequel.
gtt_dans_nix_shell() {
[ "$(gtt_famille)" = nixos ] || return 0
[ "${GTT_SHELL_NIX:-}" != 1 ] || return 0
exec nix-shell "$GTT_RACINE/scripts/installation/shell.nix" --run "$(printf '%q ' "$@")"
}

View file

@ -1,183 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Fonctions de scripts/installation/commun.sh, appelées par bash : la
// famille d'un os-release, l'architecture et la plateforme au sens de
// nodejs.org et d'Electron, l'archive de Node dans un SHASUMS256.txt, la
// majeure de .node-version, l'état des dépendances npm, le consentement.
// Sous Windows, ces scripts passent la main à PowerShell et ces épreuves ne
// se jouent pas.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
const COMMUN = fileURLToPath(new URL('./commun.sh', import.meta.url));
const SOUS_WINDOWS = process.platform === 'win32';
// Exécute un script bash après avoir sourcé commun.sh, dans un
// environnement réduit au PATH, à HOME et à ce qu'on lui passe ; l'entrée
// standard n'est jamais un terminal. Rend { statut, sortie, erreurs }.
function bash(script, env = {}) {
const r = spawnSync('bash', ['-c', `. "$1"\n${script}`, 'bash', COMMUN], {
encoding: 'utf8',
env: { PATH: process.env.PATH, HOME: process.env.HOME, ...env },
input: '',
});
return { statut: r.status, sortie: r.stdout.trim(), erreurs: r.stderr.trim() };
}
// Écrit des fichiers dans un dossier temporaire, appelle f(dossier), efface.
function avecDossier(fichiers, f) {
const dossier = mkdtempSync(join(tmpdir(), 'installation-'));
try {
for (const [chemin, texte] of Object.entries(fichiers)) {
mkdirSync(join(dossier, chemin, '..'), { recursive: true });
writeFileSync(join(dossier, chemin), texte);
}
return f(dossier);
} finally {
rmSync(dossier, { recursive: true, force: true });
}
}
describe('installation : la famille du système, lue dans os-release', { skip: SOUS_WINDOWS }, () => {
// [contenu d'os-release, famille attendue] : les systèmes du catalogue de
// machines virtuelles, deux dérivés, des guillemets simples, un inconnu.
const CAS = [
['ID=ubuntu\nID_LIKE=debian\nVERSION_ID="24.04"\n', 'debian'],
['ID=debian\nVERSION_ID="13"\n', 'debian'],
['ID=linuxmint\nID_LIKE="ubuntu debian"\n', 'debian'],
['ID=fedora\nVERSION_ID=43\n', 'fedora'],
['ID="almalinux"\nID_LIKE="rhel centos fedora"\n', 'fedora'],
['ID="rocky"\nID_LIKE="rhel centos fedora"\n', 'fedora'],
['ID="opensuse-leap"\nID_LIKE="suse opensuse"\n', 'suse'],
['ID="opensuse-tumbleweed"\nID_LIKE="opensuse suse"\n', 'suse'],
['ID=arch\n', 'arch'],
['ID=manjaro\nID_LIKE=arch\n', 'arch'],
['ID=nixos\nVERSION_ID="25.11"\n', 'nixos'],
["ID='fedora'\n", 'fedora'],
['ID=void\n', 'inconnu'],
['NAME="sans identifiant"\n', 'inconnu'],
];
test('chaque os-release donne sa famille, par ID puis ID_LIKE', () => {
const ecarts = avecDossier({}, (dossier) =>
CAS.flatMap(([contenu, attendue], rang) => {
const fichier = join(dossier, `os-release-${rang}`);
writeFileSync(fichier, contenu);
const { sortie } = bash(`gtt_famille_depuis_os_release "${fichier}"`);
return sortie === attendue ? [] : [`${JSON.stringify(contenu)} → ${sortie}, attendu ${attendue}`];
}),
);
assert.deepEqual(ecarts, []);
});
test('un os-release absent donne inconnu', () => {
assert.equal(bash('gtt_famille_depuis_os_release /chemin/absent/os-release').sortie, 'inconnu');
});
test('GTT_FAMILLE l’emporte sur le système', () => {
assert.equal(bash('gtt_famille', { GTT_FAMILLE: 'suse' }).sortie, 'suse');
});
});
describe('installation : architecture et plateforme', { skip: SOUS_WINDOWS }, () => {
test('uname -m se traduit en architecture de nodejs.org', () => {
const CAS = [
['x86_64', 'x64'], ['amd64', 'x64'], ['aarch64', 'arm64'], ['arm64', 'arm64'],
['s390x', 's390x'], ['ppc64le', 'ppc64le'], ['riscv64', 'inconnue'], ['i686', 'inconnue'],
];
for (const [machine, attendue] of CAS) {
assert.equal(bash(`gtt_arch_node_depuis ${machine}`).sortie, attendue, machine);
}
});
test('Electron et le Chromium de Playwright ne sont publiés que pour linux et darwin, x64 et arm64', () => {
const CAS = [
['linux', 'x64', 0], ['linux', 'arm64', 0], ['darwin', 'x64', 0], ['darwin', 'arm64', 0],
['linux', 's390x', 1], ['linux', 'ppc64le', 1], ['linux', 'inconnue', 1],
];
for (const [os, arch, statut] of CAS) {
assert.equal(bash(`gtt_electron_publie ${os} ${arch}`).statut, statut, `${os}-${arch}`);
}
});
});
describe('installation : l’archive de Node dans SHASUMS256.txt', { skip: SOUS_WINDOWS }, () => {
// Sommes inventées ; noms de la forme que publie nodejs.org.
const SOMMES = [
'a1 node-v26.10.0-darwin-arm64.tar.gz',
'a2 node-v26.10.0-darwin-arm64.tar.xz',
'b1 node-v26.10.0-linux-x64.tar.gz',
'b2 node-v26.10.0-linux-x64-musl.tar.xz',
'b3 node-v26.10.0-linux-x64.tar.xz',
'c1 node-v26.10.0-linux-s390x.tar.xz',
'd1 node-v26.10.0-win-x64.zip',
'e1 node-v26.10.0.tar.xz',
'e2 node-v26.10.0-headers.tar.xz',
'e3 win-x64/node.exe',
'',
].join('\n');
test('chaque plateforme publiée donne sa somme et son archive .tar.xz, et rien d’autre', () => {
const CAS = [
['linux', 'x64', 'b3 node-v26.10.0-linux-x64.tar.xz'],
['linux', 's390x', 'c1 node-v26.10.0-linux-s390x.tar.xz'],
['darwin', 'arm64', 'a2 node-v26.10.0-darwin-arm64.tar.xz'],
['linux', 'ppc64le', ''],
['win', 'x64', ''],
];
avecDossier({ 'SHASUMS256.txt': SOMMES }, (dossier) => {
for (const [os, arch, attendu] of CAS) {
const { sortie } = bash(`gtt_archive_node "${join(dossier, 'SHASUMS256.txt')}" ${os} ${arch}`);
assert.equal(sortie, attendu, `${os}-${arch}`);
}
});
});
});
describe('installation : .node-version, dépendances, consentement', { skip: SOUS_WINDOWS }, () => {
test('la majeure de .node-version est un entier, et rien d’autre ne passe', () => {
const CAS = [['26\n', 0, '26'], [' 26 \r\n', 0, '26'], ['v26\n', 1, ''], ['26.10\n', 1, ''], ['', 1, '']];
for (const [contenu, statut, sortie] of CAS) {
avecDossier({ '.node-version': contenu }, (dossier) => {
const r = bash(`GTT_RACINE="${dossier}"\ngtt_node_majeure`);
assert.deepEqual([r.statut, r.sortie], [statut, sortie], JSON.stringify(contenu));
if (statut !== 0) assert.match(r.erreurs, /\.node-version doit porter une majeure entière/);
});
}
});
test('les dépendances suivent le verrou tant que node_modules/.package-lock.json n’est pas plus ancien', () => {
avecDossier({ 'package-lock.json': '{}', 'node_modules/.package-lock.json': '{}' }, (dossier) => {
const aJour = () => bash(`GTT_RACINE="${dossier}"\ngtt_dependances_a_jour`).statut;
const date = (chemin, secondes) => utimesSync(join(dossier, chemin), secondes, secondes);
date('package-lock.json', 1_000_000);
date('node_modules/.package-lock.json', 2_000_000);
assert.equal(aJour(), 0, 'installation postérieure au verrou');
date('package-lock.json', 3_000_000);
assert.equal(aJour(), 1, 'verrou modifié depuis l’installation');
rmSync(join(dossier, 'node_modules'), { recursive: true });
assert.equal(aJour(), 1, 'aucune installation');
});
});
test('sans terminal, rien ne consent ; GTT_OUI=1 consent d’avance', () => {
assert.equal(bash('gtt_confirmer "Question ?"').statut, 1);
assert.equal(bash('gtt_confirmer "Question ?"', { GTT_OUI: '1' }).statut, 0);
});
test('chaque famille qui pose des paquets a ses outils, ses bibliothèques d’Electron et ses outils de développement', () => {
for (const famille of ['debian', 'fedora', 'suse', 'arch']) {
for (const liste of ['gtt_paquets_outils', 'gtt_paquets_electron', 'gtt_paquets_developpement']) {
assert.notEqual(bash(`${liste} ${famille}`).sortie, '', `${liste} ${famille}`);
}
assert.match(bash(`gtt_paquets_electron ${famille}`).sortie, /gtk/, famille);
assert.match(bash(`gtt_paquets_outils ${famille}`).sortie, /\bmake\b/, famille);
}
});
});

View file

@ -1,43 +0,0 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
#
# Environnement de développement sous NixOS : install.sh, run.sh et
# avec_node.sh s'y relancent d'eux-mêmes. Les binaires que les autres
# systèmes téléchargent — Node, Electron, le Chromium des épreuves —
# attendent un chargeur dynamique et des bibliothèques à des chemins que
# NixOS n'a pas ; ils viennent ici de nixpkgs. nixpkgs est épinglé par
# révision et par somme : l'environnement est le même sur toute machine, et
# ne dépend pas du canal du système.
#
# nix-shell scripts/installation/shell.nix
let
# nixos-26.05 : Node 26.10, Electron 43, Chromium 154.
nixpkgs = fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/b25309931cfda5f0b8805f462a29897eeae50168.tar.gz";
sha256 = "1svxsx57g7x76cjsgpw5vvg0l194mshk57fdvdqqgd5xdy0ari58";
};
pkgs = import nixpkgs { };
# La majeure de Node vient de .node-version, seule source du projet.
majeure = builtins.replaceStrings [ "\n" "\r" " " ] [ "" "" "" ]
(builtins.readFile ../../.node-version);
in
pkgs.mkShell {
packages = [
pkgs."nodejs_${majeure}"
pkgs.electron
pkgs.chromium
pkgs.gnumake
pkgs.file
];
# Les scripts du projet savent par là qu'ils s'exécutent ici.
GTT_SHELL_NIX = "1";
# Le paquet npm electron lance le binaire de ce dossier au lieu du sien.
ELECTRON_OVERRIDE_DIST_PATH = "${pkgs.electron}/bin";
# Les épreuves du navigateur lancent ce Chromium : celui que télécharge
# Playwright ne démarre pas sous NixOS (vitest.config.js).
GTT_CHROMIUM = "${pkgs.chromium}/bin/chromium";
# Des polices pour Chromium et Electron, que les autres systèmes reçoivent
# d'install_dev.sh : un NixOS minimal n'en déclare aucune, et un texte s'y
# dessine sans largeur.
FONTCONFIG_FILE = pkgs.makeFontsConf { fontDirectories = [ pkgs.dejavu_fonts ]; };
}

View file

@ -1,244 +0,0 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Installe et éprouve le projet sur chaque système du catalogue de machines
# virtuelles de TODO (ERPLibre), chacun dans un conteneur podman : sous un
# compte ordinaire qui passe par sudo, ./install_dev.sh, puis make test,
# make test_long, make test_navigateur, l'essai de démarrage d'Electron, et
# le serveur de Vite. Les scripts PowerShell s'éprouvent sous pwsh.
#
# scripts/verifier_systemes.sh tout le catalogue
# scripts/verifier_systemes.sh debian-13 arch ces systèmes
# scripts/verifier_systemes.sh --liste les systèmes connus
#
# Ce qu'un conteneur n'éprouve pas : le bac à sable d'Electron (pas d'espaces
# de noms d'utilisateur, Electron s'y lance sans), une autre architecture
# que celle de l'hôte, NixOS lui-même (l'image nix éprouve shell.nix, sur
# une base qui n'est pas NixOS), une vraie session graphique.
#
# GTT_PARALLELES (4 par défaut) borne les conteneurs simultanés ; les
# journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les
# téléchargements — npm, Electron, Chromium, Node — se partagent entre
# conteneurs par des volumes nommés gttl-cache-*.
set -euo pipefail
racine="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
# Système, image : le catalogue de script/qemu/deploy_qemu.py d'ERPLibre.
# Proxmox VE 9 est Debian 13 sous ses paquets : debian-13 le représente.
CATALOGUE='
ubuntu-24.04 docker.io/library/ubuntu:24.04
ubuntu-25.10 docker.io/library/ubuntu:25.10
ubuntu-26.04 docker.io/library/ubuntu:26.04
debian-12 docker.io/library/debian:12
debian-13 docker.io/library/debian:13
fedora-41 docker.io/library/fedora:41
fedora-42 docker.io/library/fedora:42
fedora-43 docker.io/library/fedora:43
fedora-44 docker.io/library/fedora:44
almalinux-9 docker.io/library/almalinux:9
almalinux-10 docker.io/library/almalinux:10
rocky-9 docker.io/rockylinux/rockylinux:9
rocky-10 docker.io/rockylinux/rockylinux:10
opensuse-leap-16.0 registry.opensuse.org/opensuse/leap:16.0
opensuse-tumbleweed registry.opensuse.org/opensuse/tumbleweed:latest
arch docker.io/library/archlinux:latest
nix docker.io/nixos/nix:latest
pwsh mcr.microsoft.com/powershell:latest
'
usage() {
sed -n '9,12p' "$0" | sed 's/^# \{0,1\}//'
}
image_de() {
printf '%s\n' "$CATALOGUE" | awk -v s="$1" '$1 == s { print $2 }'
}
systemes=()
for argument in "$@"; do
case "$argument" in
--liste)
printf '%s\n' "$CATALOGUE" | awk 'NF { print $1 }'
exit 0
;;
-h | --help)
usage
exit 0
;;
-*)
usage >&2
exit 2
;;
*)
[ -n "$(image_de "$argument")" ] || { echo "système inconnu : $argument (--liste)" >&2; exit 2; }
systemes+=("$argument")
;;
esac
done
if [ ${#systemes[@]} -eq 0 ]; then
while read -r s _; do
[ -n "$s" ] && systemes+=("$s")
done <<< "$CATALOGUE"
fi
command -v podman > /dev/null || { echo "podman introuvable." >&2; exit 1; }
journaux="${GTT_JOURNAUX:-$(mktemp -d "${TMPDIR:-/tmp}/verifier_systemes.XXXXXX")}"
mkdir -p "$journaux"
# Étiquette des conteneurs de cette vérification : une interruption arrête
# ceux-là, et aucun autre conteneur de la machine.
ETIQUETTE="gttl-verification=$$"
arreter() {
podman ps -q --filter "label=$ETIQUETTE" | xargs -r podman stop -t 2 > /dev/null 2>&1 || true
}
trap 'arreter; exit 130' INT
trap 'arreter; exit 143' TERM
# Copie de l'arbre de travail tel qu'il est — fichiers suivis et nouveaux
# non ignorés —, sans node_modules : chaque conteneur part de zéro.
source_copie="$journaux/source"
rm -rf "$source_copie"
mkdir -p "$source_copie"
(cd "$racine" && git ls-files -co --exclude-standard -z | tar --null -cf - -T - | tar -xf - -C "$source_copie")
# Préparation sous root, par famille : sudo et un compte ordinaire « dev »
# qui l'emploie sans mot de passe, comme un poste de développement. Son uid
# est le même dans chaque conteneur — 1500, libre aussi sous Ubuntu, dont
# l'image donne 1000 à son propre compte — : les caches partagés ne
# changent jamais de propriétaire.
preparation() {
case "$1" in
ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo' ;;
fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils' ;;
opensuse-*) echo 'zypper --non-interactive install sudo shadow' ;;
arch) echo 'pacman -Syu --noconfirm sudo' ;;
esac
}
# Étapes sous le compte dev, chacune annoncée par une ligne « === étape
# statut » que le bilan relit. L'essai et le serveur de Vite ne se jouent
# que sur une installation réussie.
ETAPES_DEV='
etape() { local nom="$1"; shift; if "$@"; then echo "=== $nom ok"; else echo "=== $nom ÉCHEC"; return 1; fi; }
cd "$HOME/projet"
etape install_dev ./install_dev.sh || exit 1
etape test make test
etape test_long make test_long
etape test_navigateur make test_navigateur
etape essai_demarrage env GTT_SANS_BAC_A_SABLE=1 make essai_demarrage
web() {
./run.sh --web > "$HOME/web.log" 2>&1 &
scripts/installation/avec_node.sh node -e "
const fin = Date.now() + 60000;
(async () => {
while (Date.now() < fin) {
try {
const page = await (await fetch(\"http://localhost:5173/\")).text();
if (page.includes(\"/src/main.js\")) process.exit(0);
} catch {}
await new Promise((r) => setTimeout(r, 500));
}
process.exit(1);
})();"
}
etape web web
'
verifier_linux() {
local systeme="$1" image="$2"
podman run --rm --env-host=false --label "$ETIQUETTE" \
-v "$source_copie:/source:ro" \
-v gttl-cache-npm:/home/dev/.npm \
-v gttl-cache-outils:/home/dev/.cache \
"$image" bash -c "
set -e
$(preparation "$systeme")
useradd -m -u 1500 -s /bin/bash dev 2> /dev/null || true
echo 'dev ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/dev
cp -a /source /home/dev/projet
chown -R dev: /home/dev/projet
chown dev: /home/dev /home/dev/.npm /home/dev/.cache
sudo -u dev -H bash -c '$ETAPES_DEV'
"
}
# L'image nix tourne sous root, sans sudo ni useradd : elle éprouve
# shell.nix, que GTT_FAMILLE désigne, sur une base qui n'est pas NixOS.
# ./install.sh y entre de lui-même ; make n'existe que dans shell.nix, où
# les étapes suivantes s'exécutent, comme sous NixOS.
verifier_nix() {
podman run --rm --env-host=false --label "$ETIQUETTE" -e GTT_FAMILLE=nixos \
-v "$source_copie:/source:ro" \
"$2" sh -c "
cp -a /source /root/projet
export HOME=/root
cd /root/projet
if nix-shell -p bash --run ./install.sh; then echo '=== entree_shell_nix ok'; else echo '=== entree_shell_nix ÉCHEC'; exit 1; fi
nix-shell scripts/installation/shell.nix --run 'bash -c '\''$ETAPES_DEV'\'''
"
}
# Les scripts PowerShell sous pwsh : l'analyse syntaxique de chacun, puis la
# lecture de .node-version, le choix de l'archive dans SHASUMS256.txt, et le
# téléchargement, la vérification et l'extraction de Node pour win-x64 —
# node.exe ne s'exécute pas ici, son extraction se constate.
verifier_pwsh() {
podman run --rm --env-host=false --label "$ETIQUETTE" -v "$source_copie:/source:ro" "$2" pwsh -NoProfile -Command '
$ErrorActionPreference = "Stop"
$erreurs = 0
foreach ($f in Get-ChildItem /source/scripts/windows/*.ps1) {
$jetons = $null; $fautes = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$jetons, [ref]$fautes)
if ($fautes.Count -gt 0) { $erreurs++; Write-Host "$($f.Name) : $($fautes[0].Message)" }
}
if ($erreurs -eq 0) { Write-Host "=== analyse ok" } else { Write-Host "=== analyse ÉCHEC"; exit 1 }
Copy-Item -Recurse /source /tmp/projet
$env:LOCALAPPDATA = "/tmp/donnees"
$env:PROCESSOR_ARCHITECTURE = "AMD64"
try { . /tmp/projet/scripts/windows/commun.ps1 } catch { Write-Host "=== chargement ÉCHEC $_"; exit 1 }
Write-Host "=== chargement ok"
$choix = Find-GttArchiveNode @("a1 node-v26.10.0-win-x64.7z", "b2 node-v26.10.0-win-x64.zip", "c3 node-v26.10.0-win-arm64.zip") "x64"
if ("$choix" -eq "b2 node-v26.10.0-win-x64.zip" -and (Get-GttMajeure) -match "^[0-9]+$") { Write-Host "=== choix ok" } else { Write-Host "=== choix ÉCHEC $choix"; exit 1 }
try { Install-GttNode } catch { }
if (Test-Path (Join-Path (Get-GttDossierNode) "node.exe")) { Write-Host "=== node_windows ok" } else { Write-Host "=== node_windows ÉCHEC"; exit 1 }
'
}
verifier() {
local systeme="$1" image journal
image="$(image_de "$systeme")"
journal="$journaux/$systeme.log"
{
echo "### $systeme ($image)"
case "$systeme" in
nix) verifier_nix "$systeme" "$image" ;;
pwsh) verifier_pwsh "$systeme" "$image" ;;
*) verifier_linux "$systeme" "$image" ;;
esac
} > "$journal" 2>&1 || true
echo "$systeme : $(grep -c '^=== .* ok$' "$journal") ok, $(grep -c '^=== .* ÉCHEC' "$journal") échec(s)"
}
paralleles="${GTT_PARALLELES:-4}"
echo "Journaux : $journaux"
for systeme in "${systemes[@]}"; do
while [ "$(jobs -rp | wc -l)" -ge "$paralleles" ]; do
sleep 2
done
verifier "$systeme" &
done
wait
echo
echo "Bilan :"
statut=0
for systeme in "${systemes[@]}"; do
ligne="$(grep '^=== ' "$journaux/$systeme.log" | sed 's/^=== //' | tr '\n' ' ')"
case "$ligne" in
'' | *ÉCHEC*) statut=1 ;;
esac
printf ' %-22s %s\n' "$systeme" "${ligne:-aucune étape (voir $journaux/$systeme.log)}"
done
exit "$statut"

View file

@ -1,33 +0,0 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Exécute une commande à la racine du projet, le Node du projet en tête du
# PATH, une fois l'installation vérifiée : c'est par lui que le Makefile
# lance npm, npx et node sous Windows.
#
# powershell -File scripts\windows\avec_node.ps1 npm test
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Commande)
. (Join-Path $PSScriptRoot 'commun.ps1')
$elements = @($Commande | Where-Object { $_ })
if ($elements.Count -eq 0) {
Write-Gtt 'usage : scripts\windows\avec_node.ps1 <commande> [argument…]'
exit 2
}
Initialize-Gtt
Push-Location $script:GttRacine
try {
$programme = $elements[0]
# npm et npx se lancent par leur .cmd : leur variante .ps1 dépend de la
# stratégie d'exécution de la machine.
if (@('npm', 'npx') -contains $programme) { $programme = "$programme.cmd" }
if ($elements.Count -gt 1) {
$reste = $elements[1..($elements.Count - 1)]
& $programme @reste
} else {
& $programme
}
exit $LASTEXITCODE
} finally {
Pop-Location
}

View file

@ -1,171 +0,0 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Fonctions communes des scripts Windows : le Node du projet, posé hors du
# dépôt sous %LOCALAPPDATA%, et la vérification de l'installation. Ce
# fichier se charge par « . ». Il s'en tient à Windows PowerShell 5.1, livré
# avec Windows : ni ??, ni ?., ni opérateur ternaire. Il est enregistré en
# UTF-8 avec marque d'ordre d'octets, sans laquelle Windows PowerShell 5.1 le
# lit dans la page de code du système et défigure les accents.
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 3.0
# Sans ces réglages, Windows PowerShell 5.1 écrit les accents dans la page de
# code de la console, peut refuser TLS 1.2 que nodejs.org exige, et ralentit
# chaque téléchargement par sa barre de progression.
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$ProgressPreference = 'SilentlyContinue'
$script:GttRacine = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Write-Gtt([string]$Message) {
[Console]::Error.WriteLine($Message)
}
function Stop-Gtt([string]$Message) {
Write-Gtt "Refusé : $Message"
exit 1
}
# Majeure de Node que vise le projet ; .node-version en est la seule source.
function Get-GttMajeure {
$majeure = (Get-Content -Raw (Join-Path $script:GttRacine '.node-version')).Trim()
if ($majeure -notmatch '^[0-9]+$') {
Stop-Gtt ".node-version doit porter une majeure entière : « $majeure »."
}
return $majeure
}
# Architecture au sens de nodejs.org. PROCESSOR_ARCHITEW6432 nomme celle du
# système quand PowerShell lui-même tourne en 32 bits.
function Get-GttArchNode {
$arch = $env:PROCESSOR_ARCHITEW6432
if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE }
switch ($arch) {
'AMD64' { return 'x64' }
'ARM64' { return 'arm64' }
default { Stop-Gtt "architecture $arch : nodejs.org n'y publie pas Node pour Windows." }
}
}
function Get-GttDossierDonnees {
return (Join-Path $env:LOCALAPPDATA 'gestion_table_tournante_libre')
}
function Get-GttDossierNode {
return (Join-Path (Get-GttDossierDonnees) ('node-{0}-win-{1}' -f (Get-GttMajeure), (Get-GttArchNode)))
}
# Somme et nom de l'archive .zip d'une architecture dans les lignes d'un
# SHASUMS256.txt de nodejs.org ; $null quand elle n'y figure pas.
function Find-GttArchiveNode([string[]]$Lignes, [string]$Arch) {
$suffixe = "-win-$Arch.zip"
foreach ($ligne in $Lignes) {
$champs = @($ligne.Trim() -split '\s+')
if ($champs.Count -ge 2 -and $champs[1] -match '^node-v[0-9]+\.[0-9]+\.[0-9]+-' -and
$champs[1].EndsWith($suffixe, [StringComparison]::Ordinal)) {
return @($champs[0], $champs[1])
}
}
return $null
}
# Pose la dernière version publiée de la majeure voulue, une fois. L'archive
# est vérifiée contre la somme que publie nodejs.org, puis extraite à côté de
# la cible et renommée : une extraction interrompue ne laisse jamais une
# cible à moitié écrite.
function Install-GttNode {
$majeure = Get-GttMajeure
$arch = Get-GttArchNode
$cible = Get-GttDossierNode
$node = Join-Path $cible 'node.exe'
if (Test-Path $node) {
Write-Gtt ('Node {0} : {1}' -f (& $node --version), $cible)
return
}
$cache = Join-Path (Get-GttDossierDonnees) 'cache'
New-Item -ItemType Directory -Force -Path $cache | Out-Null
$base = "https://nodejs.org/dist/latest-v$majeure.x"
$sommes = Join-Path $cache "SHASUMS256-v$majeure.txt"
Invoke-WebRequest -UseBasicParsing -Uri "$base/SHASUMS256.txt" -OutFile $sommes
$trouve = Find-GttArchiveNode (Get-Content $sommes) $arch
if (-not $trouve) { Stop-Gtt "nodejs.org ne publie pas Node $majeure pour win-$arch." }
$somme = $trouve[0]
$nom = $trouve[1]
$archive = Join-Path $cache $nom
if (-not (Test-Path $archive) -or (Get-FileHash -Algorithm SHA256 $archive).Hash -ne $somme) {
Write-Gtt "Téléchargement de $nom"
$partiel = "$archive.$PID.partiel"
Invoke-WebRequest -UseBasicParsing -Uri "$base/$nom" -OutFile $partiel
if ((Get-FileHash -Algorithm SHA256 $partiel).Hash -ne $somme) {
Remove-Item -Force $partiel
Stop-Gtt "$nom : la somme SHA-256 ne correspond pas à celle que publie nodejs.org."
}
Move-Item -Force $partiel $archive
}
$tmp = Join-Path (Get-GttDossierDonnees) ('extraction.' + [guid]::NewGuid().ToString('N'))
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $tmp)
if (Test-Path $cible) { Remove-Item -Recurse -Force $cible }
Move-Item (Join-Path $tmp ([IO.Path]::GetFileNameWithoutExtension($nom))) $cible
Remove-Item -Force $tmp
Write-Gtt ('Node {0} posé : {1}' -f (& $node --version), $cible)
}
# Met le Node du projet en tête du PATH de la séance ; faux quand il n'est
# pas posé.
function Enable-GttNode {
$cible = Get-GttDossierNode
if (-not (Test-Path (Join-Path $cible 'node.exe'))) { return $false }
$env:Path = "$cible;$env:Path"
return $true
}
# Vrai quand les dépendances npm suivent package-lock.json : npm écrit
# node_modules\.package-lock.json à chaque installation.
function Test-GttDependances {
$recu = Join-Path $script:GttRacine 'node_modules\.package-lock.json'
if (-not (Test-Path $recu)) { return $false }
$verrou = Get-Item (Join-Path $script:GttRacine 'package-lock.json')
return ($verrou.LastWriteTimeUtc -le (Get-Item $recu).LastWriteTimeUtc)
}
# Vrai sur une réponse affirmative — o, oui, y, yes. Le défaut est non, et
# une entrée redirigée ne consent à rien. GTT_OUI=1 répond oui d'avance.
function Confirm-Gtt([string]$Question) {
if ($env:GTT_OUI -eq '1') { return $true }
if ([Console]::IsInputRedirected) { return $false }
$reponse = Read-Host "$Question [o/N]"
return (@('o', 'oui', 'y', 'yes') -contains $reponse.Trim().ToLowerInvariant())
}
# Exécute un programme et refuse quand il échoue, en le nommant.
function Invoke-Gtt([string]$Programme, [string[]]$Arguments) {
& $Programme @Arguments
if ($LASTEXITCODE -ne 0) { Stop-Gtt "$Programme $($Arguments -join ' ') échoue (code $LASTEXITCODE)." }
}
function Invoke-GttNpmCi {
Invoke-Gtt 'npm.cmd' @('ci', '--no-audit', '--no-fund')
}
# Rend prêt à exécuter : le Node du projet en tête du PATH, et des
# dépendances npm qui suivent package-lock.json. Node absent, il propose
# install.cmd ; des dépendances en retard se réinstallent sans question.
function Initialize-Gtt {
if (-not (Enable-GttNode)) {
Write-Gtt "Node $(Get-GttMajeure) n'est pas posé."
if (-not (Confirm-Gtt 'Lancer install.cmd (Node, dépendances npm) ?')) {
Stop-Gtt 'installation non lancée. Lancer install.cmd, puis recommencer.'
}
& (Join-Path $PSScriptRoot 'installer.ps1')
if (-not (Enable-GttNode)) { Stop-Gtt "Node $(Get-GttMajeure) reste introuvable après l'installation." }
}
if (-not (Test-GttDependances)) {
Write-Gtt 'Les dépendances npm ne suivent pas package-lock.json : npm ci.'
Push-Location $script:GttRacine
try { Invoke-GttNpmCi } finally { Pop-Location }
}
}

View file

@ -1,23 +0,0 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Installe sous Windows de quoi lancer l'application depuis les sources et
# jouer les épreuves : Node de la majeure de .node-version, posé sous
# %LOCALAPPDATA% sans droits d'administrateur, et les dépendances de
# package-lock.json. -Dev ajoute le Chromium de Playwright des épreuves du
# navigateur. install.cmd et install_dev.cmd l'appellent.
param([switch]$Dev)
. (Join-Path $PSScriptRoot 'commun.ps1')
Install-GttNode
[void](Enable-GttNode)
Push-Location $script:GttRacine
try {
Invoke-GttNpmCi
if ($Dev) {
Invoke-Gtt 'npx.cmd' @('--no-install', 'playwright', 'install', '--only-shell', 'chromium')
}
} finally {
Pop-Location
}
Write-Gtt 'Installation terminée. Lancer : run.cmd'

View file

@ -1,38 +0,0 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Lance l'application depuis les sources, sous Windows : la coquille
# Electron charge la page que Vite construit dans www\ (§ 13.1). run.cmd
# l'appelle, ses options sont celles de run.sh.
#
# run.cmd Electron
# run.cmd --web le serveur de Vite, http://localhost:5173
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Options)
. (Join-Path $PSScriptRoot 'commun.ps1')
$mode = 'electron'
foreach ($option in @($Options | Where-Object { $_ })) {
switch ($option) {
'--web' { $mode = 'web' }
'--electron' { $mode = 'electron' }
default {
Write-Gtt 'usage : run.cmd [--web | --electron]'
exit 2
}
}
}
Initialize-Gtt
Push-Location $script:GttRacine
try {
if ($mode -eq 'web') {
Write-Gtt "Serveur de développement de Vite : http://localhost:5173 (Ctrl+C pour l'arrêter)."
& npm.cmd run --silent dev
exit $LASTEXITCODE
}
Invoke-Gtt 'npm.cmd' @('run', '--silent', 'build')
& npx.cmd --no-install electron .
exit $LASTEXITCODE
} finally {
Pop-Location
}

View file

@ -1,12 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Options du Chromium des épreuves du navigateur, que vitest.config.js passe
// au fournisseur Playwright. Playwright lance par défaut le Chromium qu'il a
// téléchargé ; GTT_CHROMIUM désigne un autre exécutable. Sous NixOS, où les
// binaires téléchargés ne démarrent pas, scripts/installation/shell.nix y
// met celui de nixpkgs.
export function optionsChromium(env) {
const chemin = env.GTT_CHROMIUM;
return chemin ? { launchOptions: { executablePath: chemin } } : {};
}

View file

@ -1,27 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le Chromium des épreuves du navigateur : celui de Playwright par défaut,
// celui que GTT_CHROMIUM désigne sinon, et vitest.config.js applique la règle.
import assert from 'node:assert/strict';
import configuration from '../vitest.config.js';
import { optionsChromium } from './chromium.js';
import { describe, test } from './lanceur.js';
describe('chromium : l’exécutable des épreuves du navigateur', () => {
test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => {
assert.deepEqual(optionsChromium({}), {});
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '' }), {});
});
test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => {
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' },
});
});
test('vitest.config.js passe au projet navigateur les options de cet environnement', () => {
const navigateur = configuration.test.projects.find(({ test: { name } }) => name === 'navigateur');
assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env));
});
});

View file

@ -1,112 +0,0 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les points d'entrée de l'installation et du lancement : ./install.sh,
// ./install_dev.sh, ./run.sh et le Makefile sous Linux et macOS ;
// install.cmd, install_dev.cmd et run.cmd sous Windows, qui passent la main
// à PowerShell ; shell.nix sous NixOS. Ces épreuves gardent ce qui les casse
// sans rien faire échouer ailleurs : un bit d'exécution perdu, un chemin
// renommé, une commande npm qui contourne le Node du projet, une fin de ligne
// d'Unix dans un fichier de commandes, une marque d'ordre d'octets absente.
import assert from 'node:assert/strict';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const lire = (chemin) => readFileSync(join(RACINE, chemin), 'utf8');
const SCRIPTS_BASH = ['install.sh', 'install_dev.sh', 'run.sh', 'scripts/installation/avec_node.sh'];
describe('installation : les scripts bash', () => {
test('ils sont exécutables, et cherchent bash dans le PATH : /bin/bash manque sous NixOS', () => {
for (const script of SCRIPTS_BASH) {
assert.equal(lire(script).split('\n')[0], '#!/usr/bin/env bash', script);
if (process.platform !== 'win32') {
assert.equal(statSync(join(RACINE, script)).mode & 0o111, 0o111, `${script} n'est pas exécutable`);
}
}
});
test('ils sourcent la bibliothèque commune, et s’arrêtent à la première erreur', () => {
for (const script of SCRIPTS_BASH) {
const texte = lire(script);
assert.match(texte, /^set -euo pipefail$/m, script);
assert.match(texte, /commun\.sh/, script);
}
});
});
describe('installation : le Makefile', () => {
const makefile = lire('Makefile');
const recettes = makefile.split('\n').filter((ligne) => ligne.startsWith('\t'));
test('make sans cible lance l’application, comme ./run.sh', () => {
assert.match(makefile, /^\.DEFAULT_GOAL := run$/m);
assert.match(makefile, /^LANCER := \.\/run\.sh$/m);
assert.match(makefile, /^run: .*\n\t\$\(LANCER\)$/m);
});
test('chaque script que le Makefile nomme existe', () => {
const chemins = [...makefile.matchAll(/(?:^|[\s=])(?:\.\/)?((?:scripts\/[\w/.-]+\.(?:sh|ps1|js))|[\w-]+\.sh)\b/gm)]
.map(([, chemin]) => chemin);
assert.ok(chemins.length > 0, 'aucun script relevé');
assert.deepEqual(chemins.filter((chemin) => !existsSync(join(RACINE, chemin))), []);
});
test('npm, npx et node ne s’y lancent que par avec_node, qui pose le Node du projet', () => {
const directes = recettes.filter((ligne) => /\b(?:npm|npx|node)\b/.test(ligne) && !ligne.includes('$(AVEC_NODE)'));
assert.ok(recettes.length > 0, 'aucune recette relevée');
assert.deepEqual(directes, []);
});
});
describe('installation : Windows', () => {
const commandes = readdirSync(RACINE).filter((nom) => nom.endsWith('.cmd')).sort();
test('install.cmd, install_dev.cmd et run.cmd existent à la racine', () => {
assert.deepEqual(commandes, ['install.cmd', 'install_dev.cmd', 'run.cmd']);
});
test('chaque fichier de commandes appelle un script PowerShell qui existe, avec ses arguments', () => {
for (const nom of commandes) {
const appel = lire(nom).match(/-File "%~dp0(scripts\\windows\\[\w.]+\.ps1)"[^\r\n]*%\*/);
assert.ok(appel, `${nom} n'appelle aucun script PowerShell avec %*`);
assert.ok(existsSync(join(RACINE, ...appel[1].split('\\'))), `${nom} → ${appel[1]} absent`);
}
});
test('les fichiers de commandes ont des fins de ligne de Windows', () => {
for (const nom of commandes) {
const texte = lire(nom);
assert.equal(texte.replaceAll('\r\n', '').includes('\n'), false, `${nom} a une fin de ligne d'Unix`);
}
});
test('les scripts PowerShell portent la marque d’ordre d’octets UTF-8 : sans elle, Windows PowerShell 5.1 défigure les accents', () => {
const scripts = readdirSync(join(RACINE, 'scripts', 'windows')).filter((nom) => nom.endsWith('.ps1'));
assert.ok(scripts.length > 0, 'aucun script PowerShell');
for (const nom of scripts) {
const octets = readFileSync(join(RACINE, 'scripts', 'windows', nom));
assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf], nom);
}
});
});
describe('installation : la majeure de Node a une seule source', () => {
test('.node-version porte une majeure entière', () => {
assert.match(lire('.node-version'), /^\d+\n$/);
});
test('bash, PowerShell et shell.nix la lisent dans .node-version au lieu de l’écrire', () => {
assert.match(lire('scripts/installation/commun.sh'), /\$GTT_RACINE\/\.node-version/);
assert.match(lire('scripts/windows/commun.ps1'), /'\.node-version'/);
assert.match(lire('scripts/installation/shell.nix'), /readFile \.\.\/\.\.\/\.node-version/);
});
test('shell.nix épingle nixpkgs par révision et par somme', () => {
const nix = lire('scripts/installation/shell.nix');
assert.match(nix, /archive\/[0-9a-f]{40}\.tar\.gz/);
assert.match(nix, /sha256 = "[0-9a-z]{52}"/);
});
});

View file

@ -3,13 +3,12 @@
// Chaque fichier source du projet s'ouvre sur l'en-tête de licence : la ligne
// du titulaire, puis celle de la licence, après un éventuel shebang ou la
// déclaration <!doctype html>, et après la marque d'ordre d'octets que
// portent les scripts PowerShell. Les deux lignes sont des commentaires du
// déclaration <!doctype html>. Les deux lignes sont des commentaires du
// langage du fichier ; le texte ne varie pas. package.json déclare la même
// licence et le même titulaire, que reprennent les outils d'emballage.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { basename, extname, join, relative, sep } from 'node:path';
import { extname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from './lanceur.js';
@ -18,11 +17,8 @@ const RACINE = fileURLToPath(new URL('..', import.meta.url));
const TITULAIRE = /^© \d{4} TechnoLibre \(http:\/\/www\.technolibre\.ca\)$/;
const LICENCE = 'License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)';
// Marqueurs de commentaire, par extension de fichier source, ou par nom pour
// un fichier sans extension. Un commentaire de ligne n'a pas de fermant. Un
// ouvrant est une chaîne qui ouvre la ligne, ou un motif : REM ouvre une
// ligne de commandes Windows, précédé ou non de @, quelle que soit sa casse,
// et suivi d'un blanc — @REMARQUE est une commande.
// Marqueurs de commentaire, par extension de fichier source. Un commentaire
// de ligne n'a pas de fermant.
const COMMENTAIRE = new Map([
['.js', { ouvrant: '//', fermant: null }],
['.cjs', { ouvrant: '//', fermant: null }],
@ -31,16 +27,8 @@ const COMMENTAIRE = new Map([
['.svelte', { ouvrant: '<!--', fermant: '-->' }],
['.html', { ouvrant: '<!--', fermant: '-->' }],
['.css', { ouvrant: '/*', fermant: '*/' }],
['.ps1', { ouvrant: '#', fermant: null }],
['.nix', { ouvrant: '#', fermant: null }],
['.cmd', { ouvrant: /^@?rem(?=\s)/i, fermant: null }],
['Makefile', { ouvrant: '#', fermant: null }],
]);
// Clé d'un fichier dans COMMENTAIRE : son nom quand il y figure, son
// extension sinon.
const cle = (chemin) => (COMMENTAIRE.has(basename(chemin)) ? basename(chemin) : extname(chemin));
// Périmètre : les fichiers de la racine et les arbres des sources, des
// épreuves, des scripts et de la coquille. Les dépendances et les sorties de
// construction n'y entrent pas, ni les données d'épreuve, qui sont des
@ -67,7 +55,7 @@ function fichiersSources() {
}
}
return chemins
.filter((chemin) => COMMENTAIRE.has(cle(chemin)))
.filter((chemin) => COMMENTAIRE.has(extname(chemin)))
.filter((chemin) => !chemin.startsWith(DONNEES_EPREUVE))
.sort();
}
@ -81,9 +69,8 @@ function fichiersSources() {
// invalidée dans une feuille, une commande dans un script.
function enCommentaire(premiere, seconde, { ouvrant, fermant }) {
const suite = seconde.trim();
const ouvre = (ligne) => (typeof ouvrant === 'string' ? ligne.startsWith(ouvrant) : ouvrant.test(ligne));
if (!ouvre(premiere)) return false;
if (fermant === null) return ouvre(suite);
if (!premiere.startsWith(ouvrant)) return false;
if (fermant === null) return suite.startsWith(ouvrant);
const courant = !premiere.includes(fermant) && suite.endsWith(fermant);
const repete =
premiere.trimEnd().endsWith(fermant) &&
@ -92,22 +79,21 @@ function enCommentaire(premiere, seconde, { ouvrant, fermant }) {
return courant || repete;
}
// Vrai quand le contenu s'ouvre sur l'en-tête, après une éventuelle marque
// d'ordre d'octets, un shebang ou la déclaration <!doctype html> : deux
// lignes de commentaire du langage de la clé, extension ou nom, dont le
// texte, débarrassé des marqueurs, est celui de l'en-tête. Les fins de ligne
// sont celles d'Unix ou de Windows.
function porteLEnTete(contenu, cleFichier) {
let lignes = contenu.replace(/^\uFEFF/, '').split(/\r?\n/);
// Vrai quand le contenu s'ouvre sur l'en-tête, après un éventuel shebang ou
// la déclaration <!doctype html> : deux lignes de commentaire du langage de
// l'extension, dont le texte, débarrassé des marqueurs, est celui de
// l'en-tête.
function porteLEnTete(contenu, extension) {
let lignes = contenu.split('\n');
if (lignes[0].startsWith('#!')) lignes = lignes.slice(1);
if (/^<!doctype html>$/i.test(lignes[0])) lignes = lignes.slice(1);
const [premiere = '', seconde = ''] = lignes;
const texte = (ligne) =>
ligne
.replace(/^\s*(?:\/\/|#|<!--|\/\*|\*|@?rem(?=\s))?\s*/i, '')
.replace(/^\s*(?:\/\/|#|<!--|\/\*|\*)?\s*/, '')
.replace(/\s*(?:-->|\*\/)?\s*$/, '');
return (
enCommentaire(premiere, seconde, COMMENTAIRE.get(cleFichier)) &&
enCommentaire(premiere, seconde, COMMENTAIRE.get(extension)) &&
TITULAIRE.test(texte(premiere)) &&
texte(seconde) === LICENCE
);
@ -118,7 +104,7 @@ describe('licence', () => {
const fichiers = fichiersSources();
assert.ok(fichiers.length > 0, 'aucun fichier source examiné');
const sansEnTete = fichiers
.filter((chemin) => !porteLEnTete(readFileSync(chemin, 'utf8'), cle(chemin)))
.filter((chemin) => !porteLEnTete(readFileSync(chemin, 'utf8'), extname(chemin)))
.map((chemin) => relative(RACINE, chemin));
assert.deepEqual(sansEnTete, []);
});
@ -143,16 +129,6 @@ describe('licence', () => {
['.html', `<!-- ${titulaire}\n ${LICENCE}\n-->\n`, false],
['.css', `/* ${titulaire} */\n${LICENCE}\n`, false],
['.css', `/* ${titulaire} */\n * ${LICENCE} */\n`, false],
['.ps1', `\uFEFF# ${titulaire}\r\n# ${LICENCE}\r\n`, true],
['.ps1', `# ${titulaire}\n# ${LICENCE}\n`, true],
['.nix', `# ${titulaire}\n# ${LICENCE}\n`, true],
['.cmd', `@REM ${titulaire}\r\n@REM ${LICENCE}\r\n@echo off\r\n`, true],
['.cmd', `REM ${titulaire}\r\nrem ${LICENCE}\r\n`, true],
['Makefile', `# ${titulaire}\n# ${LICENCE}\n`, true],
['.ps1', `\uFEFF# ${titulaire}\r\nWrite-Host '${LICENCE}'\r\n`, false],
['.cmd', `@REM ${titulaire}\r\n@echo ${LICENCE}\r\n`, false],
['.cmd', `@REMARQUE ${titulaire}\r\n@REM ${LICENCE}\r\n`, false],
['Makefile', `# ${titulaire}\n\t${LICENCE}\n`, false],
];
const ecarts = cas
.filter(([extension, contenu, admis]) => porteLEnTete(contenu, extension) !== admis)

View file

@ -16,7 +16,6 @@
// d'accord avec la première.
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { optionsChromium } from './test/chromium.js';
import { coeursDisponibles, reglagesSeries } from './test/machine.js';
// Les séries node suivent le nombre de cœurs de la machine (test/machine.js) :
@ -70,7 +69,7 @@ export default defineConfig({
test: {
name: 'navigateur',
include: ['src/**/*.navigateur.test.js'],
browser: { enabled: true, headless: true, provider: playwright(optionsChromium(process.env)),
browser: { enabled: true, headless: true, provider: playwright(),
instances: [{ browser: 'chromium' }] } } },
],
},