Le volet « d » et le compteur du tableau de bord cherchaient la sous-chaîne « error ». Le journal de l'installation qui vient d'échouer — APK tué par le noyau sur erplibre-ubuntu-2604-gnome — n'en contient AUCUNE : 0 ligne sur 8765, mesuré. Le volet annonçait « aucune erreur détectée » sur une machine morte, et le tableau de bord 0 erreur. Comptent désormais les marqueurs qui ne disent jamais « error » : « ⚠ ÉCHEC », « FAILURE », une trace Python, un « fatal: » de git, une mort par mémoire. Le volet ouvre sur un résumé — l'étape en échec et son diagnostic, les signaux durs, puis les répétitions comptées par forme. Sur ce journal : 1 étape nommée, 2 signaux, là où il n'affichait rien. --- EN --- The "d" pane and the dashboard counter looked for the "error" substring. The log of the install that just failed — APK killed by the kernel on erplibre-ubuntu-2604-gnome — contains NONE: 0 lines out of 8765, measured. The pane said "no error detected" about a dead machine, and the dashboard 0 errors. Markers that never say "error" now count: "⚠ ÉCHEC", "FAILURE", a Python traceback, a git "fatal:", a death by memory. The pane opens on a summary — the failed step with its diagnostic, the hard signals, then repeats counted by shape. On that log: 1 named step and 2 signals, where it showed nothing. Assisted-by: Claude Opus 5
204 lines
7.3 KiB
Python
204 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
"""Résumé d'un journal d'installation : ce qui a échoué doit se voir.
|
||
|
||
Le détail des erreurs cherchait la sous-chaîne « error ». Or le journal de
|
||
l'installation qui a réellement échoué — erplibre-ubuntu-2604-gnome, APK tué
|
||
par le noyau — ne contient AUCUNE ligne « error » : 0 sur 8765, mesuré. Le
|
||
volet annonçait donc « aucune erreur détectée » sur une installation ratée,
|
||
et le tableau de bord comptait 0 erreur.
|
||
|
||
Ces tests fixent la règle inverse : une étape en échec, un « FAILURE » de
|
||
Gradle, une trace Python ou une mort par mémoire se voient, et le résumé les
|
||
présente AVANT les centaines de lignes du détail.
|
||
"""
|
||
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo"))
|
||
import qemu_install_monitor as m # noqa: E402
|
||
|
||
# Journal réduit à sa forme réelle : les marqueurs de l'installation, puis
|
||
# l'échec tel que Gradle l'écrit. Aucune ligne ne contient « error ».
|
||
LOG_GRADLE_OOM = """\
|
||
== ERPLibre mobile, SDK Android (long) ==
|
||
-> venv ERPLibre (tout ce qui suit en dépend)
|
||
-> dépendances npm
|
||
-> APK debug (gradle)
|
||
⚠ ÉCHEC : APK debug (gradle)
|
||
aucun motif connu, dernières lignes :
|
||
|
||
FAILURE: Build failed with an exception.
|
||
|
||
* What went wrong:
|
||
Gradle build daemon disappeared unexpectedly (it may have been killed)
|
||
⚠ aucun APK produit
|
||
__ERPLIBRE_EXIT__ 1
|
||
"""
|
||
|
||
|
||
def _log(text):
|
||
fh = tempfile.NamedTemporaryFile(
|
||
"w", suffix=".log", delete=False, encoding="utf-8"
|
||
)
|
||
fh.write(text)
|
||
fh.close()
|
||
return fh.name
|
||
|
||
|
||
class TestFailedStepsAreSeen(unittest.TestCase):
|
||
def setUp(self):
|
||
self.path = _log(LOG_GRADLE_OOM)
|
||
|
||
def tearDown(self):
|
||
Path(self.path).unlink(missing_ok=True)
|
||
|
||
def test_the_reference_log_has_no_line_saying_error(self):
|
||
"""La prémisse de tout le reste : la détection par sous-chaîne ne
|
||
pouvait RIEN trouver ici."""
|
||
self.assertNotIn("error", LOG_GRADLE_OOM.lower())
|
||
|
||
def test_the_failed_step_is_named(self):
|
||
got = m.scan_log_summary(self.path)
|
||
self.assertEqual(
|
||
[s["label"] for s in got["steps"]], ["APK debug (gradle)"]
|
||
)
|
||
|
||
def test_the_step_carries_its_diagnostic(self):
|
||
"""L'échec nomme l'étape ; c'est le diagnostic qui porte la cause."""
|
||
diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"])
|
||
self.assertIn("FAILURE: Build failed", diag)
|
||
self.assertIn("daemon disappeared", diag)
|
||
|
||
def test_the_exit_marker_is_not_a_diagnostic(self):
|
||
diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"])
|
||
self.assertNotIn(m.EXIT_MARKER, diag)
|
||
|
||
def test_the_diagnostic_stops_at_the_next_step(self):
|
||
"""Sinon le diagnostic avale la suite de l'installation et ne désigne
|
||
plus rien."""
|
||
text = LOG_GRADLE_OOM + " -> étape suivante\n bruit\n"
|
||
path = _log(text)
|
||
try:
|
||
diag = "\n".join(m.scan_log_summary(path)["steps"][0]["diag"])
|
||
finally:
|
||
Path(path).unlink(missing_ok=True)
|
||
self.assertNotIn("bruit", diag)
|
||
|
||
def test_hard_signals_are_listed(self):
|
||
hard = " ".join(
|
||
h["text"] for h in m.scan_log_summary(self.path)["hard"]
|
||
)
|
||
self.assertIn("FAILURE", hard)
|
||
self.assertIn("disappeared unexpectedly", hard)
|
||
|
||
def test_the_dashboard_no_longer_counts_zero_errors(self):
|
||
"""Le compte alimente le tableau de bord : « 0 erreur » sur une
|
||
installation morte est un mensonge, pas une nuance."""
|
||
nerr, _ = m.scan_log_errors(self.path)
|
||
self.assertGreater(nerr, 0)
|
||
|
||
def test_the_detail_pane_is_no_longer_empty(self):
|
||
errs, _ = m.scan_log_error_lines(self.path)
|
||
self.assertTrue(errs)
|
||
self.assertTrue(any("ÉCHEC" in e or "FAILURE" in e for e in errs))
|
||
|
||
|
||
class TestOtherRealFailures(unittest.TestCase):
|
||
"""Chaque motif dur est là parce qu'il est apparu dans un vrai journal."""
|
||
|
||
def _first_hard(self, line):
|
||
path = _log(f" -> étape\n{line}\n")
|
||
try:
|
||
return m.scan_log_summary(path)["hard"]
|
||
finally:
|
||
Path(path).unlink(missing_ok=True)
|
||
|
||
def test_python_traceback(self):
|
||
self.assertTrue(self._first_hard("Traceback (most recent call last):"))
|
||
|
||
def test_git_fatal(self):
|
||
self.assertTrue(self._first_hard("fatal: repository not found"))
|
||
|
||
def test_apt_missing_package(self):
|
||
self.assertTrue(
|
||
self._first_hard("E: Unable to locate package python3.12-venv")
|
||
)
|
||
|
||
def test_kernel_oom(self):
|
||
self.assertTrue(
|
||
self._first_hard("Out of memory: Killed process 37603 (java)")
|
||
)
|
||
|
||
def test_missing_command(self):
|
||
self.assertTrue(self._first_hard("bash: emulator: command not found"))
|
||
|
||
def test_a_benign_probe_is_not_a_hard_signal(self):
|
||
"""« No such file or directory » sortait 5 fois sur 7 d'une sonde
|
||
bénigne (« cat: .odoo-version ») : le bruit dilue un résumé dont tout
|
||
l'intérêt est d'être court."""
|
||
self.assertFalse(
|
||
self._first_hard("cat: .odoo-version: No such file or directory")
|
||
)
|
||
|
||
|
||
class TestGrouping(unittest.TestCase):
|
||
def test_repeats_are_counted_not_repeated(self):
|
||
"""Un journal répète la même erreur des centaines de fois avec un
|
||
chemin qui change : on veut « ×200 », pas 200 lignes."""
|
||
lines = "\n".join(
|
||
f"ERROR: cannot read /var/lib/x/file{i}.txt" for i in range(200)
|
||
)
|
||
path = _log(lines + "\n")
|
||
try:
|
||
groups = m.scan_log_summary(path)["groups"]
|
||
finally:
|
||
Path(path).unlink(missing_ok=True)
|
||
self.assertEqual(len(groups), 1)
|
||
self.assertEqual(groups[0]["count"], 200)
|
||
|
||
def test_the_most_frequent_comes_first(self):
|
||
path = _log(
|
||
"ERROR: rare thing\n"
|
||
+ "\n".join(f"ERROR: common {i}" for i in range(5))
|
||
+ "\n"
|
||
)
|
||
try:
|
||
groups = m.scan_log_summary(path)["groups"]
|
||
finally:
|
||
Path(path).unlink(missing_ok=True)
|
||
self.assertEqual(groups[0]["count"], 5)
|
||
|
||
def test_warnings_are_grouped_apart_from_errors(self):
|
||
path = _log("WARNING: a\nERROR: b\n")
|
||
try:
|
||
kinds = {g["kind"] for g in m.scan_log_summary(path)["groups"]}
|
||
finally:
|
||
Path(path).unlink(missing_ok=True)
|
||
self.assertEqual(kinds, {"error", "warning"})
|
||
|
||
|
||
class TestQuietLogs(unittest.TestCase):
|
||
def test_a_clean_log_stays_clean(self):
|
||
"""Le résumé ne doit pas inventer d'échec là où il n'y en a pas."""
|
||
path = _log("== installation ==\n -> étape\n ✅ terminé\n")
|
||
try:
|
||
got = m.scan_log_summary(path)
|
||
finally:
|
||
Path(path).unlink(missing_ok=True)
|
||
self.assertEqual(got["steps"], [])
|
||
self.assertEqual(got["hard"], [])
|
||
self.assertEqual(got["groups"], [])
|
||
|
||
def test_a_missing_log_is_not_a_crash(self):
|
||
got = m.scan_log_summary("/nonexistent/erplibre.log")
|
||
self.assertEqual(got["steps"], [])
|
||
self.assertEqual(got["nerr"], 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|