erplibre/test/test_longtest_install_nixos.py
Mathieu Benoit 5c87238dd5 [ADD] long_test : ERPLibre installé sur NixOS, de bout en bout
Une machine, une question binaire : le chemin que le MENU emprunte
aboutit-il sur un système déclaratif. La commande d'installation est celle
du menu, prise telle quelle — un test qui installerait par ses propres
soins prouverait SON chemin, pas celui du produit, et c'est là que les
pannes se cachaient.

Le verdict est l'ÉTAT de la machine, non un code de retour :
« nixos-rebuild » rend 4 quand une unité n'a pas redémarré alors que le
système est activé. Il vit dans long_test/ parce qu'il crée une VM et
prend des heures ; le lanceur unitaire doit rester lançable en secondes.

--- EN ---

One machine, one binary question: does the path the MENU takes succeed on
a declarative system. The install command is the menu's, taken as is — a
test installing by its own means would prove ITS path, not the product's,
and that is where the failures hid.

The verdict is the machine's STATE, not an exit code: "nixos-rebuild"
returns 4 when a unit failed to restart while the system is active. It
lives in long_test/ because it creates a VM and takes hours; the unit
runner must stay runnable in seconds.

Assisted-by: Claude Opus 5
2026-09-16 21:31:03 -04:00

316 lines
12 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le test long qui installe ERPLibre sur NixOS.
Lui crée une VM et prend des heures ; ceux-ci gardent sa forme en quelques
millisecondes. Ce qu'ils tiennent :
- il envoie la commande du MENU, pas une chaîne à lui. Un test qui
installerait par ses propres soins prouverait SON chemin, et c'est
précisément là que se cachaient les pannes ;
- le verdict porte sur l'ÉTAT de la machine, pas sur un code de retour :
« nixos-rebuild switch » rend 4 sur un système pourtant activé, et chaque
bloc d'outil du menu rend 0 par construction ;
- l'installation part en UNE session ssh, comme le déploiement le fait. C'est
la condition qui expose la panne du premier passage — celle qui disparaît
dès qu'on rejoue dans une session neuve ;
- il est dans long_test/ et le lanceur unitaire ne le ramasse pas ;
- « --dry-run » ne crée rien et le dit.
"""
import ast
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
SCRIPT = RACINE / "long_test/install_nixos.py"
SRC = SCRIPT.read_text(encoding="utf-8")
class LaPlace(unittest.TestCase):
def test_it_lives_out_of_the_unit_runner(self):
"""Le lanceur balaie « test/test_*.py » et doit rester lançable en
quelques secondes, même sans virtualisation."""
self.assertTrue(SCRIPT.exists())
self.assertFalse((RACINE / "test/test_install_nixos_long.py").exists())
def test_it_is_executable(self):
"""Le menu et le README l'appellent directement."""
self.assertTrue(os.access(SCRIPT, os.X_OK))
def test_it_parses(self):
ast.parse(SRC)
class LeCheminEprouve(unittest.TestCase):
def test_the_install_command_comes_from_the_menu(self):
"""Deux copies d'une chaîne d'installation divergent, et c'est la
copie du test qui reste verte pendant que le produit casse."""
self.assertIn("_qemu_erplibre_remote_cmd", SRC)
# Aucune chaîne d'installation écrite ici.
self.assertNotIn("make install_odoo", SRC)
self.assertNotIn("git clone", SRC)
def test_the_block_goes_in_one_ssh_session(self):
"""La session est ouverte AVANT que « make install_os » n'applique le
module : c'est ce qui expose la panne du premier passage."""
self.assertIn('["bash -s"]', SRC.replace("'", '"'))
def test_the_verdict_is_the_state_not_the_return_code(self):
"""Le code de retour est noté, pas cru."""
self.assertIn("all(controles.values())", SRC)
def test_what_must_compile_is_named(self):
"""Les paquets sans roue amont sont ceux qui tombent quand les
en-têtes manquent, et eux seuls."""
for module in ("psycopg2", "ldap", "cups", "MySQLdb"):
with self.subTest(module=module):
self.assertIn(module, SRC)
def test_a_missing_answer_is_not_a_success(self):
"""« 000 » est l'absence de réponse : l'accepter ferait passer une VM
où rien n'écoute."""
self.assertNotIn('"000"', SRC)
self.assertIn('"303"', SRC)
class LePlan(unittest.TestCase):
"""Ce que la machine doit avoir AVANT qu'on crée quoi que ce soit."""
def test_the_free_space_asked_for_is_what_gets_written(self):
"""Un qcow2 n'est pas préalloué : il ne prend que ce qu'on y écrit.
Comparer les 40 Go du disque VIRTUEL à l'espace libre faisait refuser
le test sur une machine qui pouvait parfaitement le mener."""
self.assertIn("DISQUE_ECRIT_GO", SRC)
i = SRC.index("if disque <")
self.assertIn("DISQUE_ECRIT_GO", SRC[i : i + 120])
self.assertNotIn("if disque < DISQUE_GO", SRC)
def test_the_written_figure_is_smaller_than_the_virtual_disk(self):
"""Si l'un rattrapait l'autre, la distinction n'aurait plus d'objet et
le seuil serait faux dans l'autre sens."""
vals = {}
for ligne in SRC.splitlines():
for nom in ("DISQUE_GO", "DISQUE_ECRIT_GO"):
if ligne.startswith(f"{nom} = "):
vals[nom] = int(ligne.split("=")[1])
self.assertLess(vals["DISQUE_ECRIT_GO"], vals["DISQUE_GO"])
def test_it_refuses_before_creating_anything(self):
"""Annoncer un plan qui ne tient pas coûte une heure pour rien : le
contrôle passe AVANT la création."""
self.assertLess(SRC.index("def plan_tient"), SRC.index("def mener"))
i = SRC.index("if not args.dry_run and not plan_tient")
self.assertLess(i, SRC.index("nom, uuid = creer_vm"))
class LEssaiABlanc(unittest.TestCase):
def test_it_creates_nothing_and_says_so(self):
fini = subprocess.run(
[
str(RACINE / ".venv.erplibre/bin/python"),
str(SCRIPT),
"--dry-run",
],
capture_output=True,
text=True,
timeout=180,
cwd=RACINE,
)
self.assertEqual(0, fini.returncode, fini.stderr)
self.assertIn("--dry-run", fini.stdout)
# Le plan montre la commande de création, sans la lancer.
self.assertIn("deploy_qemu.py", fini.stdout)
self.assertIn("--distro nixos", fini.stdout)
def test_the_dry_run_report_is_named_apart(self):
"""Un rapport d'essai à blanc n'a rien créé : le confondre avec un
vrai ferait détruire d'après une liste vide."""
self.assertIn("-dryrun.json", SRC)
class RienNInterrompLaCourse(unittest.TestCase):
"""Un test lancé pour des heures sans surveillance doit rendre un
VERDICT, pas une trace d'exception.
`subprocess.run(..., timeout=...)` lève TimeoutExpired quand la commande
dépasse son délai — et c'est le cas normal ici, pas l'exception : un ssh
pendu, une création qui traîne, une installation qui n'en finit pas."""
def test_every_bounded_call_is_caught(self):
arbre = ast.parse(SRC)
# Les appels bornés, et la ligne de chacun.
bornes = []
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.Call):
continue
cible = ast.unparse(noeud.func)
if cible != "subprocess.run":
continue
if any(m.arg == "timeout" for m in noeud.keywords):
bornes.append(noeud.lineno)
self.assertGreaterEqual(len(bornes), 4)
# Les lignes couvertes par un « try ».
couvertes = set()
for noeud in ast.walk(arbre):
if isinstance(noeud, ast.Try):
for corps in noeud.body:
for interne in ast.walk(corps):
if hasattr(interne, "lineno"):
couvertes.add(interne.lineno)
for ligne in bornes:
with self.subTest(ligne=ligne):
self.assertIn(ligne, couvertes)
def test_a_timeout_is_reported_not_raised(self):
"""Ce que le journal doit porter à la place de la trace."""
self.assertIn("installation interrompue", SRC)
class LeDefaire(unittest.TestCase):
"""La forme du rapport n'est pas libre : c'est le CONTRAT du défaire
partagé de descente.py, et rien dans le langage ne l'impose.
Le rapport écrit d'abord n'avait pas d'« etages » — dernier_rapport
l'écartait comme « rien créé », « --detruire » répondait « rien à
défaire », et la VM survivait à ce qui devait l'effacer."""
ETAGE = {
"niveau": 1,
"nom": "long-nixos-000000",
"uuid": "aaaa-bbbb",
"alias": "long-nixos-000000",
"cree": True,
}
def setUp(self):
self.maison = tempfile.mkdtemp(prefix="longtest-nixos-")
self.dossier = os.path.join(self.maison, ".erplibre/longtest")
os.makedirs(self.dossier)
self.addCleanup(shutil.rmtree, self.maison, ignore_errors=True)
def _defaire(self, rapport):
"""Le script, à blanc, sur un HOME qui ne contient que ce rapport."""
with open(
os.path.join(self.dossier, "long-nixos-19990101-000000.json"),
"w",
encoding="utf-8",
) as fh:
json.dump(rapport, fh)
env = dict(os.environ, HOME=self.maison)
fini = subprocess.run(
[
str(RACINE / ".venv.erplibre/bin/python"),
str(SCRIPT),
"--detruire",
"--dry-run",
],
capture_output=True,
text=True,
timeout=180,
cwd=RACINE,
env=env,
)
return fini.stdout
def test_the_machine_named_is_the_one_the_report_carries(self):
"""Et non un nom recalculé : le script nomme ses VM avec l'heure, là
où le repli du défaire partagé dirait « long-nixos-1 »."""
sortie = self._defaire(
{
"outil": "install_nixos",
"dry_run": False,
"pid": 1,
"etages": [self.ETAGE],
}
)
self.assertIn("long-nixos-000000", sortie)
self.assertNotIn("long-nixos-1 ", sortie)
# Une seule machine : aucune imbriquée.
self.assertIn("0 VM imbriquée(s)", sortie)
self.assertIn("rien ne sera détruit", sortie)
def test_a_report_without_that_shape_finds_nothing(self):
"""La preuve que le contrat compte : la forme écrite d'abord."""
sortie = self._defaire(
{
"outil": "install_nixos",
"dry_run": False,
"vm": {"nom": "long-nixos-000000", "uuid": "aaaa"},
}
)
self.assertIn("rien à défaire", sortie)
def test_a_dry_run_report_is_never_used_to_destroy(self):
"""Un plan n'a rien créé : détruire d'après lui viserait des machines
qui n'existent pas, ou pire, celles d'une autre course."""
sortie = self._defaire(
{
"outil": "install_nixos",
"dry_run": True,
"pid": 1,
"etages": [self.ETAGE],
}
)
self.assertIn("rien à défaire", sortie)
def test_the_report_carries_the_pid_of_its_run(self):
"""Sans lui, « --detruire » lancé pendant une installation prendrait
le rapport de la course EN COURS — le plus récent — et détruirait la
machine sous elle."""
self.assertIn('"pid": os.getpid()', SRC)
class LeVerrouEtLeMenu(unittest.TestCase):
def test_it_shares_the_lock_of_the_other_long_tests(self):
"""Deux tests longs se disputent la RAM, le disque et ~/.ssh/config
aussi sûrement que deux descentes de la même pile."""
src = (RACINE / "long_test/descente.py").read_text(encoding="utf-8")
self.assertIn('"install_nixos.py"', src)
def test_the_menu_offers_it_dry_and_for_real(self):
from script.todo.longtest_menu import SCRIPTS_DEFAISABLES
menu = (RACINE / "script/todo/longtest_menu.py").read_text(
encoding="utf-8"
)
self.assertIn("ERPLibre on NixOS: run it", menu)
self.assertIn("ERPLibre on NixOS: plan only (dry-run)", menu)
self.assertIn("install_nixos.py", SCRIPTS_DEFAISABLES)
def test_the_menu_does_not_pass_a_depth(self):
"""Une seule machine : « --depth » n'a pas de sens ici, et le script
le refuserait."""
menu = (RACINE / "script/todo/longtest_menu.py").read_text(
encoding="utf-8"
)
bloc = menu.split('if script == "install_nixos.py":')[1][:400]
self.assertNotIn("--depth", bloc)
def test_every_label_is_translated(self):
from script.todo.todo_i18n import TRANSLATIONS
for cle in (
"ERPLibre on NixOS: plan only (dry-run)",
"ERPLibre on NixOS: run it",
"Where does the install run?",
"Create a fresh NixOS VM",
"Use a NixOS machine you already have",
):
with self.subTest(cle=cle):
self.assertIn(cle, TRANSLATIONS)
self.assertTrue(TRANSLATIONS[cle].get("fr"))
self.assertTrue(TRANSLATIONS[cle].get("en"))
if __name__ == "__main__":
unittest.main()