diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a26e35 --- /dev/null +++ b/Makefile @@ -0,0 +1,108 @@ +# © 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 diff --git a/README.md b/README.md index e95033f..772678f 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,52 @@ 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 | |---|---| -| `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` | +| `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`. L'exécutable porte la version datée du logiciel dans son nom, selon le gabarit `gestion_table_tournante_libre_v___.exe`. diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..dfe2822 --- /dev/null +++ b/run.sh @@ -0,0 +1,67 @@ +#!/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[@]}"} diff --git a/scripts/essai_demarrage.js b/scripts/essai_demarrage.js new file mode 100644 index 0000000..a09070a --- /dev/null +++ b/scripts/essai_demarrage.js @@ -0,0 +1,62 @@ +// © 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); diff --git a/scripts/verifier_systemes.sh b/scripts/verifier_systemes.sh new file mode 100755 index 0000000..3c4c7d9 --- /dev/null +++ b/scripts/verifier_systemes.sh @@ -0,0 +1,244 @@ +#!/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" diff --git a/test/installation.test.js b/test/installation.test.js new file mode 100644 index 0000000..83e1654 --- /dev/null +++ b/test/installation.test.js @@ -0,0 +1,112 @@ +// © 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}"/); + }); +});