diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +26 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d26100d --- /dev/null +++ b/install.sh @@ -0,0 +1,73 @@ +#!/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." diff --git a/install_dev.sh b/install_dev.sh new file mode 100755 index 0000000..f33ab80 --- /dev/null +++ b/install_dev.sh @@ -0,0 +1,61 @@ +#!/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." diff --git a/scripts/construire_windows.test.js b/scripts/construire_windows.test.js index dae1362..a22e602 100644 --- a/scripts/construire_windows.test.js +++ b/scripts/construire_windows.test.js @@ -118,6 +118,11 @@ 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)) { @@ -248,7 +253,7 @@ const taperLeNom = (nom) => (projet) => let reussie; const constructionReussie = () => (reussie ??= construire({ produit: NOM })); -describe('construction Windows : refus avant de construire', () => { +describe('construction Windows : refus avant de construire', { skip: HORS_LINUX }, () => { test('une version sans section au changelog ne se construit pas (§ 16)', () => { const r = construire({ preparer: (projet) => @@ -321,7 +326,7 @@ describe('construction Windows : refus avant de construire', () => { }); }); -describe('construction Windows : la page', () => { +describe('construction Windows : la page', { skip: HORS_LINUX }, () => { 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); @@ -339,7 +344,7 @@ describe('construction Windows : la page', () => { }); }); -describe('construction Windows : la construction', () => { +describe('construction Windows : la construction', { skip: HORS_LINUX }, () => { 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); @@ -393,7 +398,7 @@ describe('construction Windows : la construction', () => { }); }); -describe('construction Windows : contrôle de fin de construction (§ 18.5)', () => { +describe('construction Windows : contrôle de fin de construction (§ 18.5)', { skip: HORS_LINUX }, () => { 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 }); @@ -434,7 +439,7 @@ describe('construction Windows : contrôle de fin de construction (§ 18.5)', () }); }); -describe('construction Windows : interruption', () => { +describe('construction Windows : interruption', { skip: HORS_LINUX }, () => { // 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 diff --git a/scripts/installation/avec_node.sh b/scripts/installation/avec_node.sh new file mode 100755 index 0000000..63da2ec --- /dev/null +++ b/scripts/installation/avec_node.sh @@ -0,0 +1,22 @@ +#!/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 [argument…]" + exit 2 +fi +[ "$(gtt_famille)" = windows ] && gtt_powershell avec_node.ps1 "$@" +gtt_dans_nix_shell "$0" "$@" +cd -- "$GTT_RACINE" +gtt_preparer +exec "$@" diff --git a/scripts/installation/commun.sh b/scripts/installation/commun.sh new file mode 100644 index 0000000..8824726 --- /dev/null +++ b/scripts/installation/commun.sh @@ -0,0 +1,497 @@ +#!/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 « 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 ' "$@")" +} diff --git a/scripts/installation/commun.test.js b/scripts/installation/commun.test.js new file mode 100644 index 0000000..70b2190 --- /dev/null +++ b/scripts/installation/commun.test.js @@ -0,0 +1,183 @@ +// © 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); + } + }); +}); diff --git a/scripts/installation/shell.nix b/scripts/installation/shell.nix new file mode 100644 index 0000000..6c93f14 --- /dev/null +++ b/scripts/installation/shell.nix @@ -0,0 +1,43 @@ +# © 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 ]; }; +} diff --git a/test/chromium.js b/test/chromium.js new file mode 100644 index 0000000..52321bf --- /dev/null +++ b/test/chromium.js @@ -0,0 +1,12 @@ +// © 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 } } : {}; +} diff --git a/test/chromium.test.js b/test/chromium.test.js new file mode 100644 index 0000000..f54064d --- /dev/null +++ b/test/chromium.test.js @@ -0,0 +1,27 @@ +// © 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)); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 7821bbf..bdd0e90 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -16,6 +16,7 @@ // 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) : @@ -69,7 +70,7 @@ export default defineConfig({ test: { name: 'navigateur', include: ['src/**/*.navigateur.test.js'], - browser: { enabled: true, headless: true, provider: playwright(), + browser: { enabled: true, headless: true, provider: playwright(optionsChromium(process.env)), instances: [{ browser: 'chromium' }] } } }, ], },