La console du runner de SITE-Chezlepro ne montrait rien. Elle a douze machines.
inventaire_api sert serveurs en LISTE, inventaire_api_du_site le servait en TABLE. Les deux
sont vides et la page ne les lit pas pareil : (data.serveurs || []).map trouve {} — truthy,
sans .map — et charger() leve. La console d'un site mourait avant sa premiere vue, et le
message accusait une methode manquante plutot qu'une forme qui ment.
Et la vue regardait au mauvais endroit : le registre vide est voulu, mais la page en tirait
le message d'un locataire sans serveurs et les douze machines servies dans hotes n'etaient
dessinees nulle part. Une console de site dessine desormais SES machines, en lecture seule,
avec leur adresse, leur VMID, leurs roles et leur point de vie ; le panneau droit dit ce
qu'elle peut et renvoie aux Assistants.
C'est le defaut du 16 septembre par une autre porte : la premiere fois l'inventaire etait
vide et la page dessinait ce vide comme un plan vide ; cette fois il est plein et c'est la
vue qui regarde ailleurs.
Ce qui a trouve la cause n'est pas une lecture mais le BANC : en donnant pour la premiere
fois a test_rendu_gui la charge d'une console de site, il a leve sur charger(). Sans lui
j'aurais livre une vue juste par dessus un charger() qui leve.
Valide : node --check sur le bloc script, 7 tests de rendu dont deux neufs, make test a 0
echec, runbooks.py verifier a 0 ecart, P81 et P83 vertes. P02 reste en echec pour la raison
anterieure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
355 lines
18 KiB
Python
355 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Le GUI REND-IL ? Chaque vue est dessinee pour de vrai, dans un DOM simule.
|
|
|
|
CE QUE `verifier_gui.py` NE POUVAIT PAS VOIR (2026-09-08). Il passe le JS a
|
|
`node --check` : la syntaxe, donc, et rien d'autre. Le formulaire genere des bases
|
|
lisait `data.schema` — or il n'existe aucun `data` global dans cette page ; c'est une
|
|
const locale de `charger()`. Syntaxe parfaite, `ReferenceError` a l'ouverture. Pire dans
|
|
`sauvegarderBases`, ou `const data` est declare PLUS BAS dans la meme fonction : zone
|
|
morte temporelle, l'enregistrement jetait avant meme d'envoyer.
|
|
|
|
Le defaut a survecu a une verification sous node parce que le banc APPELAIT la fonction
|
|
en lui passant `data` — il reproduisait la fonction, pas sa PORTEE. Une garantie qu'on
|
|
n'a jamais vue echouer n'est pas une garantie.
|
|
|
|
Ce banc-ci charge le JS entier, tel que la page le recoit, dans un DOM simule, appelle
|
|
`charger()` sur la VRAIE reponse de l'API, puis dessine CHAQUE vue. Toute reference a un
|
|
identifiant qui n'existe pas leve, et le test echoue.
|
|
|
|
Ce qu'il ne fait PAS : juger l'apparence. Il dit que ca rend, pas que c'est beau.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
import inventory_gui as g # noqa: E402
|
|
|
|
# Un DOM juste assez reel : tout element repond, retient son innerHTML, et n'invente
|
|
# rien. Un `getElementById` qui rendrait `null` masquerait les fautes qu'on cherche.
|
|
BANC = r"""
|
|
'use strict';
|
|
function Elem(id) {
|
|
this.id = id; this.innerHTML = ''; this.textContent = ''; this.value = '';
|
|
this.hidden = false; this.disabled = false; this.title = ''; this.className = '';
|
|
this.style = {}; this.dataset = {}; this.children = [];
|
|
this.classList = {add(){}, remove(){}, toggle(){}, contains(){ return false; }};
|
|
}
|
|
Elem.prototype.appendChild = function (e) { this.children.push(e); return e; };
|
|
Elem.prototype.removeChild = function (e) { return e; };
|
|
Elem.prototype.setAttribute = function () {};
|
|
Elem.prototype.getAttribute = function () { return ''; };
|
|
Elem.prototype.addEventListener = function () {};
|
|
Elem.prototype.querySelector = function () { return null; };
|
|
// `querySelectorAll('[data-champ]')` rend vraiment les controles du dernier rendu : le
|
|
// GUI s'en sert pour valider avant d'enregistrer, et un stub qui rend toujours [] ferait
|
|
// passer cette validation sur ZERO champ — le banc mesurerait alors moins que rien.
|
|
Elem.prototype.querySelectorAll = function (sel) {
|
|
if (!/data-champ/.test(String(sel))) { return []; }
|
|
var html = this.innerHTML || '';
|
|
var out = [], re = /<(input|select)([^>]*?)data-champ="([^"]*)"([^>]*)>/g, m;
|
|
while ((m = re.exec(html)) !== null) {
|
|
var attrs = m[2] + m[4];
|
|
var v = /\svalue="([^"]*)"/.exec(attrs);
|
|
out.push({__champ: m[3], value: v ? v[1] : '',
|
|
getAttribute: function (n) { return n === 'data-champ' ? this.__champ : null; },
|
|
classList: {add: function () {}, remove: function () {}, toggle: function () {}},
|
|
focus: function () {}});
|
|
}
|
|
return out;
|
|
};
|
|
Elem.prototype.focus = function () {};
|
|
Elem.prototype.remove = function () {};
|
|
Elem.prototype.closest = function () { return null; };
|
|
Elem.prototype.scrollIntoView = function () {};
|
|
|
|
var __elems = {};
|
|
var document = {
|
|
getElementById: function (id) { return __elems[id] || (__elems[id] = new Elem(id)); },
|
|
createElement: function () { return new Elem('cree'); },
|
|
querySelector: function () { return null; },
|
|
// `#detail [data-champ]` : le GUI interroge le DOCUMENT, pas l'element. Un stub qui
|
|
// rendait [] ici faisait croire que le formulaire n'etait pas marque — et la
|
|
// sauvegarde des serveurs refusait, silencieusement, dans le banc seul.
|
|
querySelectorAll: function (sel) {
|
|
var m = /^#([\w-]+)\s+(.+)$/.exec(String(sel));
|
|
if (m && __elems[m[1]]) { return __elems[m[1]].querySelectorAll(m[2]); }
|
|
return [];
|
|
},
|
|
addEventListener: function () {},
|
|
body: new Elem('body'),
|
|
documentElement: new Elem('html'),
|
|
};
|
|
var window = {addEventListener: function () {}, location: {href: '', reload: function () {}},
|
|
matchMedia: function () { return {matches: false, addEventListener: function () {}}; }};
|
|
var navigator = {clipboard: {writeText: function () { return Promise.resolve(); }}};
|
|
function MutationObserver(f) { this.observe = function () {}; this.disconnect = function () {}; }
|
|
function requestAnimationFrame(f) { return 0; }
|
|
function cancelAnimationFrame() {}
|
|
function EventSource() { this.addEventListener = function () {}; this.close = function () {}; }
|
|
function alert() {} function confirm() { return false; } function prompt() { return null; }
|
|
var localStorage = {getItem: function () { return null; }, setItem: function () {}, removeItem: function () {}};
|
|
var __PAYLOAD__ = JSON.parse(process.env.PAYLOAD);
|
|
var __postsCaptures = {};
|
|
// `fetch` rend la VRAIE reponse de /api/inventaire ; les autres routes rendent un objet
|
|
// vide plutot que de jeter, pour que les sondes de fond ne masquent pas l'erreur cherchee.
|
|
function fetch(url, opts) {
|
|
if (opts && opts.method === 'POST') { __postsCaptures[String(url)] = JSON.parse(opts.body); }
|
|
// Un POST rend la MEME charge que l'inventaire : le GUI recharge apres sauvegarde, et
|
|
// lui rendre {} le ferait repartir d'un plan vide — un faux vert, pas un test.
|
|
var corps = (String(url).indexOf('/api/inventaire') === 0 || (opts && opts.method === 'POST'))
|
|
? __PAYLOAD__ : {};
|
|
return Promise.resolve({ok: true, status: 200, json: function () { return Promise.resolve(corps); },
|
|
text: function () { return Promise.resolve(''); }});
|
|
}
|
|
var __erreurs = [];
|
|
function __essayer(quoi, f) { try { f(); } catch (e) { __erreurs.push(quoi + ' : ' + e); } }
|
|
"""
|
|
|
|
APRES = r"""
|
|
// --- L'EPREUVE -----------------------------------------------------------------
|
|
charger().then(function () {
|
|
['serveurs', 'applications', 'bases', 'domaines', 'nomenclature',
|
|
'integrations', 'flux', 'couches', 'reseau'].forEach(function (v) {
|
|
__essayer('setVue(' + v + ')', function () { setVue(v); });
|
|
});
|
|
// Les vues a maitre-detail ne montrent leur formulaire QU'UNE FOIS une entree
|
|
// choisie : dessiner la grille seule ne prouve rien du formulaire genere.
|
|
__essayer('bases + selection', function () {
|
|
setVue('bases');
|
|
if (typeof bdApplis !== 'undefined' && bdApplis.length) { choisirBase('bd:0'); }
|
|
if (typeof bdServeurs !== 'undefined' && bdServeurs.length) { choisirBase('srv:0'); }
|
|
});
|
|
__essayer('nomenclature + selection', function () {
|
|
setVue('nomenclature');
|
|
var f = Object.keys(nomEdit.fonctions || {});
|
|
var c = Object.keys(nomEdit.categories || {});
|
|
if (f.length) { choisirNom('fon:' + f[0]); }
|
|
if (c.length) { choisirNom('cat:' + c[0]); }
|
|
});
|
|
__essayer('domaines + selection', function () { setVue('domaines'); if (domaines.length) choisirDomaine(0); });
|
|
__essayer('applications + selection', function () { setVue('applications'); if (applications.length) ouvrirApp(applications[0].id); });
|
|
console.log('__RESULTAT__' + JSON.stringify(__erreurs));
|
|
}).catch(function (e) {
|
|
console.log('__RESULTAT__' + JSON.stringify(['charger() : ' + (e && e.stack || e)]));
|
|
});
|
|
"""
|
|
|
|
|
|
# LE BANC D'UNE CONSOLE DE SITE. On ne rejoue pas les neuf vues : on regarde CE QUE
|
|
# la vue d'accueil dessine, parce que c'est la qu'un site ne montrait rien.
|
|
APRES_SITE = r"""
|
|
charger().then(function () {
|
|
var sortie = {erreurs: [], grille: '', detail: '', detailChoisi: '', hotes: 0};
|
|
try {
|
|
sortie.hotes = (typeof hotes !== 'undefined' && hotes) ? hotes.length : 0;
|
|
setVue('serveurs');
|
|
sortie.grille = document.getElementById('grilles').innerHTML;
|
|
sortie.detail = document.getElementById('detail').innerHTML;
|
|
if (sortie.hotes) { choisirMachine(0); sortie.detailChoisi = document.getElementById('detail').innerHTML; }
|
|
} catch (e) { sortie.erreurs.push(String((e && e.stack) || e)); }
|
|
console.log('__RESULTAT__' + JSON.stringify(sortie));
|
|
}).catch(function (e) {
|
|
console.log('__RESULTAT__' + JSON.stringify({erreurs: ['charger() : ' + ((e && e.stack) || e)]}));
|
|
});
|
|
"""
|
|
|
|
|
|
class TestFormeDesCharges(unittest.TestCase):
|
|
"""Les deux charges que la page consomme ont-elles la MEME FORME ?
|
|
|
|
Une console de locataire recoit `inventaire_api`, une console de site
|
|
`inventaire_api_du_site`. La page est la MEME : ce qu'elle sait lire d'un cote, elle
|
|
doit pouvoir le lire de l'autre.
|
|
|
|
Le 2026-09-20, `serveurs` valait `[]` d'un cote et `{}` de l'autre. Les deux sont
|
|
« vides », et la page ne les lit pas pareil : `(data.serveurs || []).map(...)` trouve
|
|
une table — truthy, sans `.map` — et `charger()` LEVE. La console d'un site mourait
|
|
avant de dessiner, et l'erreur accusait une methode manquante plutot qu'une forme.
|
|
|
|
Un vide doit avoir la FORME de ce qu'il remplace. Ce test ne demande pas que les deux
|
|
charges portent les memes VALEURS — un site n'a pas de plan de locataire — seulement
|
|
qu'elles portent les memes TYPES. Ce test n'a pas besoin de node.
|
|
"""
|
|
|
|
# Ce que les deux charges ne partagent legitimement pas, et pourquoi.
|
|
ECARTS_ADMIS = {
|
|
"contexte": "ajoute par la route GET pour un locataire, deja present pour un site",
|
|
"intrants": "un site n'a pas de plan de locataire dont tirer des intrants",
|
|
}
|
|
|
|
def test_les_deux_charges_portent_les_memes_types(self):
|
|
if not g.FICHIER_UNDERLAY.exists():
|
|
self.skipTest("aucune fabric montee : pas de charge de site a comparer")
|
|
ctx = dict(g.contexte())
|
|
ctx.update(portee="site", ecosysteme=None)
|
|
site = g.inventaire_api_du_site(ctx)
|
|
tenant = g.inventaire_api(g.INVENTAIRE_DEFAUT)
|
|
|
|
ecarts = []
|
|
for clef in sorted(set(tenant) & set(site)):
|
|
if clef in self.ECARTS_ADMIS:
|
|
continue
|
|
a, b = type(tenant[clef]).__name__, type(site[clef]).__name__
|
|
if a != b:
|
|
ecarts.append(f"{clef} : locataire={a}, site={b}")
|
|
self.assertEqual(ecarts, [],
|
|
"des clefs changent de type selon la console :\n - "
|
|
+ "\n - ".join(ecarts))
|
|
|
|
def test_un_ecart_admis_doit_encore_exister(self):
|
|
"""Une exemption qui survit a son objet est un mensonge tranquille."""
|
|
tenant = g.inventaire_api(g.INVENTAIRE_DEFAUT)
|
|
for clef in self.ECARTS_ADMIS:
|
|
self.assertTrue(clef in tenant or clef == "contexte",
|
|
f"{clef} n'existe plus : son exemption n'a plus d'objet")
|
|
|
|
|
|
class TestRenduGui(unittest.TestCase):
|
|
"""Chaque vue du GUI se dessine sans lever."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
if not shutil.which("node"):
|
|
raise unittest.SkipTest("node absent")
|
|
html = g.HTML.replace("__JETON__", "TEST")
|
|
blocs = re.findall(r"<script>(.*?)</script>", html, re.S)
|
|
assert blocs, "aucun bloc <script>"
|
|
cls.blocs = blocs
|
|
cls.js = BANC + "\n".join(blocs) + APRES
|
|
|
|
def _jouer(self, js: str, payload: dict) -> list:
|
|
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as f:
|
|
f.write(js)
|
|
chemin = f.name
|
|
try:
|
|
r = subprocess.run(["node", chemin], capture_output=True, text=True, timeout=120,
|
|
env={**__import__("os").environ, "PAYLOAD": json.dumps(payload)})
|
|
finally:
|
|
Path(chemin).unlink(missing_ok=True)
|
|
marque = "__RESULTAT__"
|
|
for ligne in r.stdout.splitlines():
|
|
if ligne.startswith(marque):
|
|
return json.loads(ligne[len(marque):])
|
|
self.fail(f"le banc n'a rien rendu.\nstdout: {r.stdout[-2000:]}\nstderr: {r.stderr[-2000:]}")
|
|
|
|
def test_toutes_les_vues_rendent(self):
|
|
"""La vraie reponse de l'API, les neuf vues, zero exception."""
|
|
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
|
|
erreurs = self._jouer(self.js, payload)
|
|
self.assertEqual(erreurs, [], "des vues ont leve :\n - " + "\n - ".join(erreurs))
|
|
|
|
def test_une_console_de_site_montre_ses_machines(self):
|
|
"""UN SITE A DOUZE MACHINES ET SA CONSOLE N'EN MONTRAIT AUCUNE (2026-09-20).
|
|
|
|
`inventaire_api_du_site` sert volontairement `serveurs: {}` — un site declare ses
|
|
machines dans SON plan, avec `ip` et `vmid` ecrits, la ou un locataire les derive.
|
|
Mais la vue d'accueil tirait de ce registre vide le message d'un locataire sans
|
|
serveurs (« + Serveur », `make serveurs-bootstrap`), et les machines servies dans
|
|
`hotes` n'etaient dessinees nulle part.
|
|
|
|
C'est le defaut du 2026-09-16 par une autre porte : la premiere fois l'inventaire
|
|
etait vide ; cette fois il est PLEIN et c'est la vue qui regarde au mauvais endroit.
|
|
"""
|
|
if not g.FICHIER_UNDERLAY.exists():
|
|
self.skipTest("aucune fabric montee : pas de console de site a simuler")
|
|
ctx = dict(g.contexte())
|
|
ctx.update(portee="site", ecosysteme=None,
|
|
peut={"editer_le_plan": False, "configurer": False,
|
|
"materialiser": True, "fabric": True})
|
|
payload = g.inventaire_api_du_site(ctx)
|
|
if not payload["hotes"]:
|
|
self.skipTest("l'inventaire du site ne rend aucune machine ici")
|
|
|
|
js = BANC + "\n".join(self.blocs) + APRES_SITE
|
|
r = self._jouer(js, payload)
|
|
self.assertEqual(r["erreurs"], [], "la vue a leve :\n - " + "\n - ".join(r["erreurs"]))
|
|
self.assertEqual(r["hotes"], len(payload["hotes"]))
|
|
|
|
noms = [h["nom"] for h in payload["hotes"]]
|
|
manquants = [n for n in noms if n not in r["grille"]]
|
|
self.assertEqual(manquants, [],
|
|
f"des machines du site ne sont pas dessinees : {manquants}")
|
|
# Et surtout : plus le message d'un locataire vide, qui envoyait l'exploitant
|
|
# vers un amorcage de plan que ce site n'a pas.
|
|
self.assertNotIn("serveurs-bootstrap", r["grille"])
|
|
self.assertIn("Machines de", r["grille"])
|
|
# Le panneau droit dit ce que cette console PEUT, au lieu de rester muet.
|
|
self.assertIn("matérialise", r["detail"])
|
|
self.assertIn(noms[0], r["detailChoisi"])
|
|
|
|
def test_controle_negatif_reference_absente(self):
|
|
"""LE CONTROLE. Une lecture d'identifiant inexistant DOIT faire echouer le banc.
|
|
|
|
Sans lui, ce test passerait aussi bien sur un banc qui n'execute rien. On rejoue
|
|
exactement la faute du 2026-09-08 : le formulaire des bases lit un global qui
|
|
n'existe pas.
|
|
"""
|
|
casse = self.js.replace("const reg = schemaPlan[nomRegistre] || null;",
|
|
"const reg = (donneesQuiNexistentPas.schema || {})[nomRegistre] || null;")
|
|
self.assertNotEqual(casse, self.js, "le motif du controle negatif n'existe plus")
|
|
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
|
|
erreurs = self._jouer(casse, payload)
|
|
self.assertTrue(any("donneesQuiNexistentPas" in e for e in erreurs),
|
|
f"le banc n'a pas vu la reference absente : {erreurs}")
|
|
|
|
def test_charger_puis_sauvegarder_ne_change_pas_le_plan(self):
|
|
"""L'EPREUVE DECISIVE : ouvrir une vue et enregistrer sans rien toucher.
|
|
|
|
Les six formulaires sont GENERES, et leur sauvegarde l'est aussi : les champs
|
|
ecrits sont ceux que le schema declare. C'est ce qui evite le pire des deux
|
|
mondes — un champ visible a l'ecran et perdu en silence a l'enregistrement.
|
|
|
|
Encore faut-il le mesurer. On charge la vraie reponse de l'API, on ouvre chaque
|
|
vue, on appelle sa sauvegarde, on intercepte le POST, et on compare la charge au
|
|
plan REEL lu par le moteur. Aucune entite perdue, aucune en trop, aucun champ
|
|
different : sinon un simple aller-retour appauvrirait le plan.
|
|
"""
|
|
js = self.js.replace(APRES, r"""
|
|
charger()
|
|
.then(function () { setVue('serveurs'); choisirServeur(0); return sauvegarderServeurs(); })
|
|
.then(function () { setVue('applications'); if (applications.length) ouvrirApp(applications[0].id); return sauvegarderApplications(); })
|
|
.then(function () { setVue('domaines'); if (domaines.length) choisirDomaine(0); return sauvegarderDomaines(); })
|
|
.then(function () { setVue('bases'); return sauvegarderBases(); })
|
|
.then(function () { console.log('__RESULTAT__' + JSON.stringify(__postsCaptures)); })
|
|
.catch(function (e) { console.log('__RESULTAT__' + JSON.stringify({erreur: String(e && e.stack || e)})); });
|
|
""")
|
|
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
|
|
posts = self._jouer(js, payload)
|
|
self.assertNotIn("erreur", posts, posts.get("erreur", ""))
|
|
|
|
attendu = {
|
|
"/api/serveurs": (g.charger_serveurs(g.FICHIER_SERVEURS), "serveurs"),
|
|
"/api/applications": (g.charger_applications(g.FICHIER_APPLICATIONS), "applications"),
|
|
"/api/domaines": (g.charger_domaines(g.FICHIER_DOMAINES), "domaines_publics"),
|
|
"/api/bases": (g.charger_bases_donnees(g.FICHIER_BASES), "bases_donnees"),
|
|
}
|
|
for url, (plan, racine) in attendu.items():
|
|
with self.subTest(route=url):
|
|
envoye = (posts.get(url) or {}).get(racine)
|
|
self.assertIsNotNone(envoye, f"{url} n'a pas ete appele — la sauvegarde a-t-elle refuse ?")
|
|
reel = plan.get(racine) or {}
|
|
self.assertEqual(sorted(envoye), sorted(reel), "entites perdues ou ajoutees")
|
|
for cle in sorted(reel):
|
|
self.assertEqual(envoye[cle], reel[cle],
|
|
f"{racine}.{cle} : l'aller-retour a change l'entite")
|
|
|
|
def test_le_schema_est_bien_servi(self):
|
|
"""Le formulaire ne peut rien generer si l'API n'envoie pas le schema."""
|
|
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
|
|
self.assertIn("schema", payload, "l'API ne sert pas le schema du plan")
|
|
self.assertIn("nomenclature", payload["schema"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|