erplibre/test/test_qemu_monitor_telemetry.py
Mathieu Benoit b4310665fc [ADD] script todo: dire depuis quand un journal d'installation est muet
Le sablier ne distingue pas une installation qui travaille d'une qui est morte :
le marqueur de sortie manque dans les deux cas. Une session ssh emportée, et le
tableau de bord a affiché « ⏳ » pendant 54 minutes sans que rien ne cloche à
l'œil.

La colonne d'état porte maintenant le silence du journal — « ⏳ silence 48min ».
C'est un chiffre, pas un verdict. Le seuil de dix minutes vient d'une mesure :
le téléchargement d'Android Studio tient ~5 min sans une ligne, et l'étape
« APK debug » davantage, son détail partant dans le journal de la VM. Plus bas,
chaque installation deviendrait une alerte, et l'alerte cesserait d'être lue.

--- EN ---

The hourglass does not tell a working install from a dead one: the exit marker
is missing in both cases. An ssh session was reaped, and the dashboard showed
"⏳" for 54 minutes with nothing looking wrong.

The state column now carries the log's silence — "⏳ silent 48min". It is a
figure, not a verdict. The ten-minute threshold comes from a measurement: the
Android Studio download holds ~5 min without a line, and the "debug APK" step
longer, its detail going to the VM's own log. Any lower and every install would
become an alert, and the alert would stop being read.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00

206 lines
7.9 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Barre de télémétrie du suivi d'installation : CPU, RAM, disque.
La RAM manquait, et son absence a coûté : une compilation mobile s'est fait
tuer par le noyau sur une VM de 12 Go sans swap, pendant que le suivi affichait
sereinement le CPU et le disque. L'épuisement mémoire ne se voit nulle part
ailleurs — le disque va bien, la charge CPU aussi, et la machine meurt.
Ce qui se vérifie ici : le chiffre lu est celui que le noyau dit pouvoir
rendre, le swap n'occupe la barre que s'il existe, et une lecture impossible
laisse la barre utile au lieu de la vider.
"""
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo"))
import qemu_install_monitor as m # noqa: E402
GB = 1 << 30
class TestHostMem(unittest.TestCase):
def test_it_reads_the_real_proc(self):
total, avail, sw_total, sw_free = m._host_mem()
self.assertGreater(total, 0, "aucune RAM lue dans /proc/meminfo")
self.assertLessEqual(avail, total)
self.assertLessEqual(sw_free, sw_total)
def test_it_reports_what_the_kernel_can_give_back(self):
"""« MemAvailable » et non « MemFree » : sur une machine qui travaille,
MemFree est presque nul parce que le cache occupe le reste, et
alarmerait pour rien."""
proc = (
"MemTotal: 12000000 kB\n"
"MemFree: 100000 kB\n"
"MemAvailable: 8000000 kB\n"
"SwapTotal: 0 kB\n"
"SwapFree: 0 kB\n"
)
with mock.patch("builtins.open", mock.mock_open(read_data=proc)):
total, avail, _, _ = m._host_mem()
self.assertEqual(avail, 8000000 * 1024)
self.assertEqual(total, 12000000 * 1024)
def test_an_unreadable_proc_gives_zeros_not_a_crash(self):
"""Le suivi tourne pendant une heure d'installation : il ne meurt pas
parce qu'une lecture a échoué."""
with mock.patch("builtins.open", side_effect=OSError):
self.assertEqual(m._host_mem(), (0, 0, 0, 0))
def test_a_malformed_line_is_not_fatal(self):
with mock.patch(
"builtins.open", mock.mock_open(read_data="MemTotal: beaucoup\n")
):
self.assertEqual(m._host_mem(), (0, 0, 0, 0))
class TestMemSegment(unittest.TestCase):
def test_the_used_share_is_total_minus_available(self):
got = m._mem_tele(12 * GB, 3 * GB, 0, 0)
self.assertIn("9.0G/12.0G", got)
self.assertIn("(75%)", got)
def test_the_available_figure_is_shown_as_such(self):
self.assertIn("3.0G", m._mem_tele(12 * GB, 3 * GB, 0, 0))
def test_swap_appears_only_when_the_machine_has_some(self):
"""Un « swap 0/0 » occuperait la barre pour ne rien dire. Mais dès
qu'il existe, il est montré même à zéro : une machine qui commence à
échanger explique une lenteur, et c'est ce qu'on cherche ici."""
self.assertNotIn("swap", m._mem_tele(12 * GB, 3 * GB, 0, 0))
self.assertIn(
"swap 0K/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 4 * GB)
)
self.assertIn(
"swap 1.0G/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 3 * GB)
)
def test_nothing_read_means_no_segment_not_a_zero_segment(self):
"""La barre garde alors le CPU et le disque, qui eux ont répondu."""
self.assertEqual(m._mem_tele(0, 0, 0, 0), "")
def test_more_available_than_total_does_not_show_negative_use(self):
"""Cas absurde mais possible entre deux lectures : on ne veut pas
« -1.0G » dans la barre."""
self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0))
class TestLogSilence(unittest.TestCase):
"""Une installation morte et une qui travaille portent le même sablier.
Vécu : une session ssh emportée, l'installation morte sans marqueur de
sortie, et le tableau de bord a montré « ⏳ » pendant 54 minutes. Le
marqueur manque dans les deux cas — seule la date d'écriture du journal
les sépare.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.log = Path(self.tmp.name) / "vm.log"
self.log.write_text("== installation ==\n")
def tearDown(self):
self.tmp.cleanup()
def test_a_fresh_log_is_not_silent(self):
self.assertLess(m.log_idle(str(self.log)), 5)
def test_an_old_log_reports_its_age(self):
import os
import time
old = time.time() - 3000
os.utime(self.log, (old, old))
self.assertAlmostEqual(m.log_idle(str(self.log)), 3000, delta=5)
def test_a_missing_log_is_not_reported_as_silent(self):
"""-1 plutôt que 0 : « absent » n'est pas « à l'instant », et surtout
pas « silencieux depuis toujours »."""
self.assertEqual(m.log_idle("/nonexistent/erplibre.log"), -1.0)
self.assertEqual(m.state_mark("⏳", -1.0), "⏳")
def test_below_the_threshold_the_cell_stays_bare(self):
"""Plusieurs étapes sont muettes quelques minutes — un téléchargement
d'Android Studio, une compilation dont la sortie va ailleurs."""
self.assertEqual(m.state_mark("⏳", 0), "⏳")
self.assertEqual(m.state_mark("⏳", m.IDLE_HINT_SECS), "⏳")
def test_above_the_threshold_the_cell_says_how_long(self):
mark = m.state_mark("⏳", 2900)
self.assertNotEqual(mark, "⏳")
self.assertIn("48", mark)
def test_the_threshold_clears_the_longest_measured_silence(self):
"""Mesuré : le téléchargement d'Android Studio reste ~5 min sans une
ligne. Un seuil en dessous transformerait chaque installation en alerte,
et l'alerte cesserait d'être lue."""
studio_download = 5 * 60
self.assertGreater(m.IDLE_HINT_SECS, studio_download)
# Et pas si haut qu'une installation morte passe la demi-heure.
self.assertLessEqual(m.IDLE_HINT_SECS, 1200)
class TestTheRealBar(unittest.TestCase):
"""La barre telle que le suivi la construit, sans lancer la TUI."""
def setUp(self):
try:
import textual # noqa: F401
except ImportError:
self.skipTest("textual absent de ce venv")
self.tmp = tempfile.TemporaryDirectory()
log = Path(self.tmp.name) / "vm-a.log"
log.write_text("== installation ==\n")
manifest = Path(self.tmp.name) / "session.json"
manifest.write_text(
json.dumps(
{
"branch": "develop",
"started": 0,
"vms": [
{
"name": "vm-a",
"ip": "192.168.123.2",
"log": str(log),
"ssh": "ssh erplibre@192.168.123.2",
}
],
}
)
)
self.app = m.run_monitor(str(manifest), run_app=False)
def tearDown(self):
self.tmp.cleanup()
def test_the_bar_carries_the_three_resources(self):
bar = self.app._collect_tele()
self.assertIn("CPU", bar)
self.assertIn("RAM", bar)
self.assertIn("💽", bar)
def test_ram_sits_between_cpu_and_disk(self):
"""L'ordre est celui du coût : le CPU se voit ailleurs, la RAM nulle
part, le disque partout."""
bar = self.app._collect_tele()
self.assertLess(bar.index("CPU"), bar.index("RAM"))
self.assertLess(bar.index("RAM"), bar.index("💽"))
def test_the_bar_survives_a_mute_proc(self):
with mock.patch.object(m, "_host_mem", return_value=(0, 0, 0, 0)):
bar = self.app._collect_tele()
self.assertIn("CPU", bar)
self.assertIn("💽", bar)
self.assertNotIn("RAM", bar)
if __name__ == "__main__":
unittest.main()