diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index b2de944..1d750bb 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -36,6 +36,7 @@ Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. import json import os +import re import subprocess import sys @@ -134,6 +135,115 @@ def failures(events): return [e for e in events if e["status"] and e["kind"] == "test"] +FICHIER_VERSION = ".odoo-version" + + +def checkout_version(racine=None): + """La version d'Odoo sur laquelle le checkout est posé, ou None. + + Un entier, pas « 18.0 » : c'est ce que porte le nom des bases de + palier et ce que la chaîne de migration rend, et deux formes du même + fait finissent toujours par diverger. + """ + base = racine or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + try: + with open( + os.path.join(base, FICHIER_VERSION), encoding="utf-8" + ) as handle: + return int(float(handle.read().strip())) + except (OSError, ValueError): + return None + + +def verdicts(events): + """TOUS les verdicts de test, réussis compris. + + N'afficher que les échecs répondait à « qu'est-ce qui a raté » et à + rien d'autre. Or la question qu'on se pose devant une base migrée est + « qu'a-t-on vérifié », et un test réussi au palier 17 est une preuve + au même titre qu'un raté au 14 — c'est même la seule façon de voir + qu'un échec du 14 a été RATTRAPÉ ensuite. + """ + return [e for e in events if e["kind"] == "test"] + + +def version_of(database, dct): + """La version d'Odoo de cette base, lue dans la chaîne de la migration. + + Plus sûr que le suffixe du nom : la base de DÉPART n'en porte pas — + « test_neutralize » ne dit pas 12.0 — et son palier retombait alors + sur le compteur du pilote, qui affichait « 2 ». + """ + for version, base in chain(dct): + if base == database: + return version + return None + + +# Ce que la migration écrit dans le journal d'une étape autour d'un test : +# +# [2026-08-26 03:19:44.166204] $ .venv…/python3 ./script/…/smoke.py -d … +# [2026-08-26 03:19:59.846406] -> 1 +# [2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1 +# +# Entre le « $ » et le « -> », RIEN : mesuré sur trois exécutions du même +# test, la sortie de l'outil n'est pas capturée. Ce qui explique l'échec +# est donc AVANT, dans ce qu'Odoo écrivait juste avant qu'on le teste — +# et c'est pour cela que l'extrait remonte, au lieu de descendre. +MARQUEUR_COMMANDE = "] $ " +MOTIF_HORODATAGE = re.compile(r"^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)") + + +def _horodatage(ligne): + """La seconde portée par cette ligne de journal, ou "".""" + found = MOTIF_HORODATAGE.match(ligne) + return found.group(1) if found else "" + + +def event_tool(event): + """Le script que ce verdict a lancé — « smoke_public_url.py ».""" + for mot in (event.get("detail") or "").split(): + if mot.endswith(".py"): + return os.path.basename(mot) + return "" + + +def event_excerpt(lignes, event, avant=18): + """(extrait, rang de la commande) — le passage qui entoure ce verdict. + + Quand la migration a été rejouée, le même test apparaît plusieurs fois + dans le même fichier. On retient donc l'occurrence la plus PROCHE de + l'horodatage du verdict, et non la dernière : la dernière appartient + peut-être à une exécution qui n'est pas celle qu'on regarde. + """ + outil = event_tool(event) + if not outil or not lignes: + return [], None + base = event_database(event) + quand = (event.get("at") or "")[:19] + candidats = [ + rang + for rang, ligne in enumerate(lignes) + if MARQUEUR_COMMANDE in ligne + and outil in ligne + and (not base or base in ligne) + ] + if not candidats: + return [], None + avant_le_verdict = [ + rang for rang in candidats if _horodatage(lignes[rang]) <= quand + ] + rang = (avant_le_verdict or candidats)[-1] + fin = rang + for suivant in range(rang, min(rang + 400, len(lignes))): + if "[test] " in lignes[suivant] and event["name"] in lignes[suivant]: + fin = suivant + break + return lignes[max(0, rang - avant) : fin + 1], rang + + def event_database(event): """La base sur laquelle ce verdict portait, lue dans sa commande.""" for mot in event.get("detail", "").split(): diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index d0b8d31..691b42e 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -12,7 +12,9 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux états contradictoires de la même migration. """ +import io import os +import shlex import subprocess import sys @@ -128,17 +130,20 @@ def extra_rows(presents, dct=None): cible = presents[-1]["database"] if presents else "" lst = [] - # ── Succès ── + # ── Verdicts ── evenements = quality.read_events(dct) + tous = quality.verdicts(evenements) ratés = quality.failures(evenements) lst.append( { "kind": "header", "label": f"── {t('Verdicts')} ──", - "detail": str(len(ratés)) if ratés else "✅", + "detail": ( + f"{len(ratés)}/{len(tous)}" if ratés else f"✅ {len(tous)}" + ), } ) - if not evenements: + if not tous: lst.append( { "kind": "verdict-none", @@ -146,28 +151,30 @@ def extra_rows(presents, dct=None): "detail": "", } ) - for event in ratés: + for event in tous: base = quality.event_database(event) - # Le PALIER dans le libellé : « smoke_public_url » quatre fois de - # suite ne dit pas lequel a échoué, et c'est la seule chose qu'on - # veut savoir en parcourant la colonne. - palier = quality.event_step(event, base) + # Le PALIER dans le libellé : « smoke_public_url » sept fois de + # suite ne dit pas lequel on regarde, et c'est la seule chose + # qu'on veut savoir en parcourant la colonne. Il vient de la + # CHAÎNE de la migration, car la base de départ ne le porte pas + # dans son nom — « test_neutralize » ne dit pas 12.0. + version = quality.version_of(base, dct) + palier = str(version) if version else quality.event_step(event, base) + icone = "❌" if event["status"] else "✅" lst.append( { "kind": "verdict", - "label": f" ❌ {palier:<5} {event['name'][:18]}", + "label": f" {icone} {palier:<5} {event['name'][:18]}", "detail": "▶", "event": event, "database": base or cible, + "step": palier, + "log": status.step_log_path(dct, event.get("step")), "command": rerun_command(event, base or cible), - } - ) - if evenements and not ratés: - lst.append( - { - "kind": "verdict-ok", - "label": f" ✅ {len(evenements)} {t('checks, all passed')}", - "detail": "", + "switch": switch_needed(base or cible, dct), + "capture": run_log_path( + "verdict_%s_%s" % (event["name"], base or cible) + ), } ) @@ -208,6 +215,19 @@ def extra_rows(presents, dct=None): if clef and (cible or "{db}" not in commande) else "" ), + # Les étapes qui nomment une base ont le même écueil que + # les verdicts : le checkout doit être au bon palier. + "switch": ( + switch_needed(cible, dct) if "{db}" in commande else None + ), + # Le « shell » est un REPL : le passer par un tube lui + # ferait perdre son invite. Les sept autres écrivent et + # s'arrêtent, on peut donc en garder une copie. + "capture": ( + run_log_path(f"review_{clef}") + if clef and clef != "shell" + else None + ), } ) return lst @@ -298,7 +318,6 @@ EXTRA_KINDS = ( "header", "verdict", "verdict-none", - "verdict-ok", "source", "logscan", "review", @@ -321,8 +340,6 @@ def extra_pane(row, colour=False): t("not in command_executed, which only lists what ran."), ] ) - if genre == "verdict-ok": - return t("Every recorded check returned zero.") if genre == "source": return source_pane(row, colour) if genre == "logscan": @@ -332,11 +349,95 @@ def extra_pane(row, colour=False): return "" -def verdict_pane(row, colour=False): - """Ce qu'un verdict raté dit, et ce que son code de retour signifie.""" - event = row.get("event") or {} +def log_excerpt_lines(row, colour=False): + """Le passage du journal d'étape qui entoure ce verdict. + + Ce que le journal contient VRAIMENT, dit sans détour : la commande et + son code, pas la sortie de l'outil. Le pilote l'explique — ce qui + passe par `run_on_terminal` n'a pas de sortie capturable, un tube y + ferait renoncer les pleins écrans. Ce qui précède la commande est donc + tout ce qu'on a, et c'est déjà ce qu'Odoo écrivait au moment du test. + + Le taire enverrait chercher un fichier plus complet qui n'existe pas. + """ + chemin = row.get("log") + if not chemin: + return [ + status.paint(f" ── {t('step log')} ──", "step", colour), + f" {t('no log file for this step')}", + "", + ] + try: + with io.open(chemin, encoding="utf-8", errors="replace") as handle: + brut = handle.read().splitlines() + except OSError: + return [] + extrait, _rang = quality.event_excerpt(brut, row.get("event") or {}) lignes = [ - status.paint(f"❌ {event.get('name')}", "fail", colour), + status.paint(f" ── {t('step log')} ──", "step", colour), + status.paint(f" {chemin}", "dim", colour), + status.paint(f" {len(brut)} {t('lines in all')}", "dim", colour), + "", + ] + if not extrait: + lignes.append(f" {t('this verdict is not in it')}") + lignes.append("") + return lignes + for ligne in extrait: + lignes.append(f" {ligne[:150]}") + lignes.append("") + lignes.append( + status.paint( + f" {t('the tool output is not in there: it goes to the')}", + "dim", + colour, + ) + ) + lignes.append( + status.paint( + f" {t('terminal, which a pipe would make full-screen tools')}", + "dim", + colour, + ) + ) + lignes.append( + status.paint( + f" {t('give up. What precedes is what Odoo was writing.')}", + "dim", + colour, + ) + ) + lignes.append("") + return lignes + + +def switch_needed(database, dct): + """(version, cible make) si le checkout n'est pas au palier de cette base. + + Lancer un outil sur une base d'un autre palier n'est pas seulement + inutile : Odoo ÉCRIT dedans avant d'échouer. Le test de fumée s'en + garde déjà et sort en 2 ; l'écran, lui, peut proposer la bascule au + lieu de laisser relancer trois fois la même erreur. + """ + version = quality.version_of(database, dct) + if not version: + return None + courante = quality.checkout_version() + if not courante or courante == version: + return None + return (version, "switch_odoo_%d" % version) + + +def verdict_pane(row, colour=False): + """Ce qu'un verdict dit, ce que son code signifie, et ce qu'on en lit.""" + event = row.get("event") or {} + rate = bool(event.get("status")) + lignes = [ + status.paint( + f"{'❌' if rate else '✅'} {event.get('name')}", + "fail" if rate else "ok", + colour, + ), "", # Le palier est la version d'ODOO, pas le compteur du pilote : # « 4.1.I » désigne la première étape du quatrième bloc, et la @@ -359,9 +460,26 @@ def verdict_pane(row, colour=False): "1 means leftovers remain that it could not drop." ), }.get(event.get("name"), "") - if sens: + if sens and rate: lignes.append(f" {sens}") lignes.append("") + lignes.extend(log_excerpt_lines(row, colour)) + lignes.extend(run_log_lines(row, colour)) + if row.get("command") and row.get("switch"): + version, cible = row["switch"] + lignes.append( + status.paint( + f" ⚠ {t('the checkout is on Odoo')}" + f" {quality.checkout_version()}.0," + f" {t('this database is on')} {version}.0", + "warn", + colour, + ) + ) + lignes.append( + f" {t('r offers to run')} « make {cible} » {t('first')}" + ) + lignes.append("") if row.get("command"): lignes.append( status.paint( @@ -443,6 +561,40 @@ def logscan_pane(colour=False): return "\n".join(lignes) +def run_log_lines(row, colour=False): + """Ce que la dernière exécution de cette ligne a écrit, ou l'invitation. + + Le panneau défile — c'est un VerticalScroll — donc on n'ampute pas : + montrer vingt lignes d'un rapport qui en fait deux cents obligerait à + le relancer dans un terminal pour lire la suite, et l'écran n'aurait + servi qu'à donner envie. + """ + chemin = row.get("capture") + if not chemin: + return [] + lst, total = read_run_log(chemin) + if not lst: + if not row.get("command"): + return [] + return [ + status.paint(f" ── {t('last run')} ──", "step", colour), + status.paint( + f" {t('never run from here yet')}", "dim", colour + ), + "", + ] + lignes = [ + status.paint(f" ── {t('last run')} ──", "step", colour), + status.paint(f" {chemin}", "dim", colour), + status.paint(f" {total} {t('lines in all')}", "dim", colour), + "", + ] + for ligne in lst: + lignes.append(f" {ligne}") + lignes.append("") + return lignes + + def review_pane(row, colour=False): """Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas.""" lignes = [ @@ -457,7 +609,7 @@ def review_pane(row, colour=False): ) else: lignes.append(f" {t('Read it in the Verdicts section above.')}") - lignes.append("") + lignes.extend(run_log_lines(row, colour)) lignes.append(f" {t('What none of these can see')}") lignes.append( f" {t('They all read the DATABASE. A module that kept a')}" @@ -536,7 +688,94 @@ def mode_label(mode): return t(mode) -def run_in_terminal(command, wait=True): +# La sortie d'une exécution lancée DEPUIS l'écran, gardée pour être +# relue. Celle des tests de la migration ne l'était pas — le pilote lance +# par `run_on_terminal`, qui n'a pas de sortie capturable — mais ce que +# l'on lance soi-même, on peut le garder. +REVIEW_LOG_DIR = os.path.join(".venv.erplibre", "screen_log") + + +def run_log_path(clef): + """Le fichier où garder la sortie de cette commande, ou None. + + Une clé par ligne de l'écran, et non par commande : deux exécutions + de la même étape doivent se remplacer, pas s'empiler. On veut « ce + que ça donne maintenant », jamais un historique qu'il faudrait trier. + """ + if not clef: + return None + propre = "".join(c if c.isalnum() or c in "._-" else "_" for c in clef) + return os.path.join(REPO_ROOT, REVIEW_LOG_DIR, propre + ".log") + + +def read_run_log(chemin, limite=4000): + """(lignes, total) de la derniere execution, ou ([], 0).""" + if not chemin: + return [], 0 + try: + with io.open(chemin, encoding="utf-8", errors="replace") as handle: + lst = handle.read().splitlines() + except OSError: + return [], 0 + return lst[-limite:], len(lst) + + +def ask_yes(question, defaut=True): + """Poser une question fermée dans le terminal rendu. Entrée = défaut. + + `input()` et non une boîte de dialogue : on est DÉJÀ sorti de l'écran + quand la question se pose, et y rentrer pour un oui/non ferait + clignoter tout l'affichage entre deux commandes. + """ + suffixe = " [O/n] " if defaut else " [o/N] " + try: + reponse = input(question + suffixe).strip().lower() + except (EOFError, KeyboardInterrupt): + return False + if not reponse: + return defaut + return reponse[0] in ("o", "y") + + +def run_with_switch(command, switch, wait=True, capture=None): + """Lancer, en proposant d'abord de basculer le checkout s'il le faut. + + Sans cela on relance trois fois la même erreur : le test de fumée + refuse d'ouvrir une base 16 avec un checkout 18 — « l'ouvrir avec la + mauvaise version y écrit avant d'échouer » — et rend 2 sans rien + faire. La bascule est la seule suite possible, autant la proposer. + + On DEMANDE au lieu de basculer d'office : `make switch_odoo_16` change + le checkout entier, et quelqu'un peut être en train d'y travailler. + """ + if switch: + version, cible = switch + print() + print( + status.paint( + f"⚠ {t('the checkout is on Odoo')}" + f" {quality.checkout_version()}.0," + f" {t('this database is on')} {version}.0", + "warn", + True, + ) + ) + print( + f" {t('opening it with the wrong version writes before it fails.')}" + ) + print() + if not ask_yes(f" {t('run')} « make {cible} » {t('first?')}"): + print(f" {t('left as is — the test will refuse and return 2.')}") + else: + code, _tourne = run_in_terminal(f"make {cible}", wait=False) + if code: + print(status.paint(f" ✗ make {cible} → {code}", "fail", True)) + if not ask_yes(f" {t('run the test anyway?')}", defaut=False): + return None, False + return run_in_terminal(command, wait=wait, capture=capture) + + +def run_in_terminal(command, wait=True, capture=None): """Rendre le terminal au test, puis le reprendre. (code, a_tourné). L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et @@ -556,6 +795,21 @@ def run_in_terminal(command, wait=True): print() print(f"▶ {' '.join(argv)}") print("─" * 72) + if capture: + # `tee` garde une copie SANS rien cacher : la sortie continue + # d'aller au terminal en direct. Le prix est la couleur — les + # outils la coupent quand stdout n'est plus un terminal — et + # c'est un bon prix pour un journal qu'on relira dans l'écran. + # `pipefail` pour que le code rendu soit celui de la commande, + # et non celui de `tee`, qui réussit toujours. + os.makedirs(os.path.dirname(capture), exist_ok=True) + argv = [ + "bash", + "-o", + "pipefail", + "-c", + "%s 2>&1 | tee %s" % (shlex.join(argv), shlex.quote(capture)), + ] try: code = subprocess.run(argv, cwd=REPO_ROOT).returncode except OSError as exc: @@ -585,6 +839,11 @@ def build_app(lst_snapshot): ("m", "toggle_missing", t("Missing files")), ("d", "cycle_detail", t("Details")), ("r,enter", "run_selected", t("Run")), + # La table garde les flèches et pgup/pgdn pour ses lignes ; + # ces deux-là ne lui appartiennent pas et restent donc + # disponibles pour le panneau. + ("ctrl+u", "pane_up", t("Pane up")), + ("ctrl+d", "pane_down", t("Pane down")), ] def __init__(self, lst_snapshot): @@ -674,9 +933,17 @@ def build_app(lst_snapshot): self.bell() return with self.suspend(): - run_in_terminal(commande) + run_with_switch( + commande, row.get("switch"), capture=row.get("capture") + ) self.refresh() + def action_pane_up(self): + self.query_one("#pane", VerticalScroll).scroll_page_up() + + def action_pane_down(self): + self.query_one("#pane", VerticalScroll).scroll_page_down() + def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index 651b84b..ebda974 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -96,6 +96,7 @@ except Exception: # pragma: no cover - repli si i18n indisponible from script.analyse import check_migration_quality as quality # noqa: E402 from script.analyse import lib_analyse # noqa: E402 +from script.todo import migration_status as status # noqa: E402 COULEURS = { "broken": "\033[31m", @@ -285,7 +286,36 @@ def verdicts(database, path=None): return tous, quality.failures(tous) -def verdicts_block(database, colour=True, path=None): +def extrait_du_journal(dct, event, colour=True, avant=6): + """Ce que le journal d'étape montre autour de ce verdict. + + La commande seule ne dit pas POURQUOI. Le journal, lui, garde ce + qu'Odoo écrivait au moment du test — et c'est tout ce qu'on a : la + sortie de l'outil n'y est pas, parce qu'elle passe par le terminal, + qu'un tube ferait renoncer aux outils en plein écran. Le pilote le + documente à l'endroit où il l'écrit. + + Silencieux quand il n'y a rien à montrer : une ligne « pas de + journal » par verdict noierait les quatre qui comptent. + """ + chemin = status.step_log_path(dct, event.get("step")) + if not chemin or avant <= 0: + return [] + try: + with open(chemin, "r", encoding="utf-8", errors="replace") as handle: + brut = handle.read().splitlines() + except OSError: + return [] + extrait, _rang = quality.event_excerpt(brut, event, avant=avant) + if not extrait: + return [] + lignes = [paint(f" {chemin}", "dim", colour)] + for ligne in extrait: + lignes.append(paint(f" {ligne[:150]}", "dim", colour)) + return lignes + + +def verdicts_block(database, colour=True, path=None, lignes_avant=6): """La section « Verdicts », ou rien du tout s'il n'y en a pas. Silencieuse quand le fichier n'existe pas : devant la sauvegarde d'un @@ -310,8 +340,10 @@ def verdicts_block(database, colour=True, path=None): colour, ) ) + dct = quality.read_progression(chemin) for event in ratés: - palier = quality.event_step(event) + version = quality.version_of(quality.event_database(event), dct) + palier = str(version) if version else quality.event_step(event) lignes.append( paint( f"❌ {palier.rjust(6)} {event['name']}", @@ -322,6 +354,7 @@ def verdicts_block(database, colour=True, path=None): lignes.append( paint(f" {event['detail'][:120]}", "dim", colour) ) + lignes.extend(extrait_du_journal(dct, event, colour, lignes_avant)) lignes.append("") lignes.append( paint( @@ -331,6 +364,15 @@ def verdicts_block(database, colour=True, path=None): colour, ) ) + if ratés and lignes_avant > 0: + lignes.append( + paint( + f" {t('the tool output is not in the step log: it goes')}" + f" {t('to the terminal and dies with it.')}", + "dim", + colour, + ) + ) return lignes diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 2bc1f0c..8d6ef65 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3499,8 +3499,8 @@ TRANSLATIONS = { "en": "vendors document two, not more.", }, "Measured at level 4: 36x slower, then a frozen kernel.": { - "fr": "Mesuré au niveau 4 : 36x plus lent, puis noyau gelé.", - "en": "Measured at level 4: 36x slower, then a frozen kernel.", + "fr": "Mesuré au niveau 4 : 36x plus lent, et 12 vCPU gelaient.", + "en": "Measured at level 4: 36x slower, and 12 vCPU froze.", }, "vCPU capped to": { "fr": "vCPU borné à", @@ -6978,6 +6978,95 @@ TRANSLATIONS = { "en": "Migration quality, step by step", }, # --- Écran de qualité : Verdicts, Validation, Revue --- + # --- Journal d'étape et bascule de version --- + "last run": { + "fr": "dernière exécution", + "en": "last run", + }, + "never run from here yet": { + "fr": "jamais lancée depuis ici", + "en": "never run from here yet", + }, + "Pane up": { + "fr": "Panneau ↑", + "en": "Pane up", + }, + "Pane down": { + "fr": "Panneau ↓", + "en": "Pane down", + }, + "the tool output is not in the step log: it goes": { + "fr": "la sortie de l'outil n'est pas dans le journal d'étape : elle part", + "en": "the tool output is not in the step log: it goes", + }, + "to the terminal and dies with it.": { + "fr": "sur le terminal et meurt avec lui.", + "en": "to the terminal and dies with it.", + }, + "r offers to run": { + "fr": "r propose de lancer", + "en": "r offers to run", + }, + "first": { + "fr": "d'abord", + "en": "first", + }, + "step log": { + "fr": "journal de l'étape", + "en": "step log", + }, + "no log file for this step": { + "fr": "aucun journal pour cette étape", + "en": "no log file for this step", + }, + "lines in all": { + "fr": "lignes en tout", + "en": "lines in all", + }, + "this verdict is not in it": { + "fr": "ce verdict n'y figure pas", + "en": "this verdict is not in it", + }, + "the tool output is not in there: it goes to the": { + "fr": "la sortie de l'outil n'y est pas : elle part sur le", + "en": "the tool output is not in there: it goes to the", + }, + "terminal, which a pipe would make full-screen tools": { + "fr": "terminal, qu'un tube ferait renoncer aux outils en", + "en": "terminal, which a pipe would make full-screen tools", + }, + "give up. What precedes is what Odoo was writing.": { + "fr": "plein écran. Ce qui précède est ce qu'Odoo écrivait.", + "en": "give up. What precedes is what Odoo was writing.", + }, + "the checkout is on Odoo": { + "fr": "le checkout est sur Odoo", + "en": "the checkout is on Odoo", + }, + "this database is on": { + "fr": "cette base est en", + "en": "this database is on", + }, + "opening it with the wrong version writes before it fails.": { + "fr": "l'ouvrir avec la mauvaise version y écrit avant d'échouer.", + "en": "opening it with the wrong version writes before it fails.", + }, + "run": { + "fr": "lancer", + "en": "run", + }, + "first?": { + "fr": "d'abord ?", + "en": "first?", + }, + "left as is — the test will refuse and return 2.": { + "fr": "laissé tel quel — le test refusera et rendra 2.", + "en": "left as is — the test will refuse and return 2.", + }, + "run the test anyway?": { + "fr": "lancer le test quand même ?", + "en": "run the test anyway?", + }, # --- Trous dans les manifestes Google Repo --- # --- Type de vue « tree » resté dans un module --- "Is any module still on a pre-18 view type?": { diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index af71a94..fce2a73 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1832,12 +1832,6 @@ class TestTheThreeExtraSections(Base): lst = qtui.extra_rows([], {}) self.assertIn("verdict-none", [r["kind"] for r in lst]) - def test_every_check_passing_is_stated_not_left_blank(self): - lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) - ok = [r for r in lst if r["kind"] == "verdict-ok"] - self.assertEqual(1, len(ok)) - self.assertIn("1", ok[0]["label"]) - def test_a_failed_verdict_names_its_step_in_the_label(self): # « smoke_public_url » quatre fois de suite ne dit pas lequel a # échoué, et c'est la seule chose qu'on veut savoir. @@ -1852,17 +1846,9 @@ class TestTheThreeExtraSections(Base): self.assertEqual("▶", ligne["detail"]) self.assertTrue(ligne["command"]) - def test_the_header_counts_the_failures(self): - lst = qtui.extra_rows([], {"lst_event": [evenement(), evenement()]}) - self.assertEqual("2", self.premier_entete(lst)["detail"]) - def premier_entete(self, lst): return [r for r in lst if r["kind"] == "header"][0] - def test_a_clean_run_shows_a_tick_not_a_zero(self): - lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) - self.assertEqual("✅", self.premier_entete(lst)["detail"]) - def test_review_steps_target_the_last_database(self): presents = [ {"database": "base_upgrade_17", "exists": True}, @@ -1902,6 +1888,398 @@ class TestTheThreeExtraSections(Base): self.assertEqual("", ligne["command"], ligne["question"]) +class TestEveryVerdictIsListed(Base): + """N'afficher que les échecs répondait à une autre question. + + Devant une base migrée on se demande « qu'a-t-on vérifié », pas + seulement « qu'est-ce qui a raté ». Et c'est la seule façon de voir + qu'un échec du palier 14 a été RATTRAPÉ au 17. + """ + + def journal(self, *statuts): + lst = [] + for rang, statut in enumerate(statuts): + version = 13 + rang + lst.append( + evenement( + status=statut, + detail=( + "./script/odoo/migration/smoke_public_url.py" + " -d base_upgrade_%d" % version + ), + ) + ) + return { + "lst_event": lst, + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def test_a_passing_verdict_gets_its_own_line(self): + lst = qtui.extra_rows([], self.journal(0, 0)) + lignes = [r for r in lst if r["kind"] == "verdict"] + self.assertEqual(2, len(lignes)) + for ligne in lignes: + self.assertIn("✅", ligne["label"]) + + def test_a_failure_and_a_success_sit_side_by_side(self): + lst = qtui.extra_rows([], self.journal(1, 0)) + icones = [ + "❌" if "❌" in r["label"] else "✅" + for r in lst + if r["kind"] == "verdict" + ] + self.assertEqual(["❌", "✅"], icones) + + def test_the_header_counts_failures_over_the_total(self): + lst = qtui.extra_rows([], self.journal(1, 0, 0)) + entete = [r for r in lst if r["kind"] == "header"][0] + self.assertEqual("1/3", entete["detail"]) + + def test_an_all_green_run_says_how_many(self): + lst = qtui.extra_rows([], self.journal(0, 0, 0)) + entete = [r for r in lst if r["kind"] == "header"][0] + self.assertIn("3", entete["detail"]) + self.assertIn("✅", entete["detail"]) + + def test_the_starting_database_gets_its_real_version(self): + # Elle ne porte pas « _upgrade_ » dans son nom : le palier + # retombait alors sur le compteur du pilote et affichait « 2 ». + dct = { + "lst_event": [ + evenement( + status=0, + step="2 - Update all addons", + detail="./script/odoo/migration/smoke_public_url.py -d base", + ) + ], + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + ligne = [ + r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict" + ][0] + self.assertIn("12", ligne["label"]) + self.assertNotIn(" 2 ", ligne["label"]) + + def test_a_dialogue_answer_is_still_not_a_verdict(self): + # Les entrées `command` à 1 sont les réponses du pilote ; les + # lister ferait sept faux échecs par migration. + dct = self.journal(0) + dct["lst_event"].append( + evenement( + kind="command", name="./odoo_bin.sh db --clone", status=1 + ) + ) + lignes = [ + r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict" + ] + self.assertEqual(1, len(lignes)) + + +class TestTheStepLogInThePanel(Base): + """Ce que le journal contient vraiment, dit sans détour.""" + + def setUp(self): + super().setUp() + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + + def journal(self, lignes): + chemin = os.path.join(self.dossier, "etape.log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write("\n".join(lignes) + "\n") + return chemin + + def ligne_verdict(self, chemin, **champs): + return { + "kind": "verdict", + "label": "", + "event": evenement(**champs), + "log": chemin, + "command": "script/odoo/migration/smoke_public_url.py -d x", + } + + def test_the_passage_around_the_verdict_is_shown(self): + chemin = self.journal( + [ + "2026-08-26 03:19:00,000 INFO odoo: avant", + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.846406] -> 1", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + "2026-08-26 03:20:00,000 INFO odoo: après", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn("INFO odoo: avant", texte) + self.assertIn("[test] smoke_public_url -> 1", texte) + self.assertNotIn("INFO odoo: après", texte) + + def test_it_says_the_tool_output_is_absent(self): + # Sans cela on cherche un fichier plus complet qui n'existe pas : + # ce qui passe par run_on_terminal n'a pas de sortie capturable. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn( + qtui.t("the tool output is not in there: it goes to the"), texte + ) + + def test_the_path_and_the_size_are_documented(self): + chemin = self.journal(["a"] * 40) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn(chemin, texte) + self.assertIn("40", texte) + + def test_no_log_at_all_says_so(self): + ligne = self.ligne_verdict(None) + ligne["log"] = None + texte = qtui.extra_pane(ligne) + self.assertIn(qtui.t("no log file for this step"), texte) + + def test_a_verdict_absent_from_the_log_says_so(self): + chemin = self.journal(["rien à voir", "vraiment rien"]) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn(qtui.t("this verdict is not in it"), texte) + + def test_the_tool_is_read_from_the_command_not_from_the_name(self): + # Le nom du test se retrouve dans d'AUTRES lignes du journal — une + # cible make qui le mentionne, par exemple. Chercher le nom au + # lieu du script y accroche la mauvaise commande, et l'extrait + # montre alors un passage qui n'a rien à voir. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.000000] $ ./script/odoo/migration/" + "database_cleanup.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:50.000000] [test] database_cleanup -> 0", + "[2026-08-26 03:19:52.000000] $ make database_cleanup_all", + "[2026-08-26 03:19:55.000000] -> 0", + ] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, + evenement( + name="database_cleanup", + at="2026-08-26 03:19:56.000000", + detail="./script/odoo/migration/database_cleanup.py" + " -d test_neutralize_upgrade_14", + ), + ) + self.assertEqual(0, rang) + + def test_a_verdict_launched_by_no_script_matches_nothing(self): + # Sans « .py » dans la commande on ne sait pas quoi chercher ; + # deviner accrocherait la première ligne venue. + chemin = self.journal( + ["[2026-08-26 03:19:44.000000] $ make quelque_chose"] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, evenement(name="quelque_chose", detail="make quelque_chose") + ) + self.assertIsNone(rang) + + def test_a_replayed_migration_shows_the_matching_run(self): + # Le même test apparaît plusieurs fois : la dernière occurrence + # peut appartenir à une exécution qui n'est pas celle qu'on + # regarde. On retient celle qui précède l'horodatage du verdict. + chemin = self.journal( + [ + "[2026-08-23 00:43:59.331479] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-23 00:44:19.776150] [test] smoke_public_url -> 1", + "premiere execution finie", + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, evenement(at="2026-08-23 00:44:19.776150") + ) + self.assertEqual(0, rang) + + +class TestKeepingWhatARunWrote(Base): + """Ce que la migration ne pouvait pas garder, l'écran le peut. + + Le pilote lance par `run_on_terminal`, qui n'a pas de sortie + capturable — un tube y ferait renoncer les pleins écrans. Mais ce que + l'on lance SOI-MÊME depuis l'écran, on peut en garder une copie, et + la relire sans relancer. + """ + + def setUp(self): + super().setUp() + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + + def fichier(self, contenu): + chemin = os.path.join(self.dossier, "run.log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(contenu) + return chemin + + def test_one_file_per_screen_line_not_per_run(self): + # Deux exécutions de la même étape se remplacent : on veut « ce + # que ça donne maintenant », pas un historique à trier. + self.assertEqual( + qtui.run_log_path("review_residue"), + qtui.run_log_path("review_residue"), + ) + self.assertNotEqual( + qtui.run_log_path("review_residue"), + qtui.run_log_path("review_state"), + ) + + def test_a_key_that_could_escape_the_directory_cannot(self): + chemin = os.path.realpath(qtui.run_log_path("../../etc/passwd")) + dossier = os.path.realpath( + os.path.join(qtui.REPO_ROOT, qtui.REVIEW_LOG_DIR) + ) + self.assertEqual(dossier, os.path.dirname(chemin)) + self.assertNotIn(os.sep, os.path.basename(chemin)) + + def test_no_key_means_no_file(self): + self.assertIsNone(qtui.run_log_path("")) + + def test_a_missing_file_reads_as_nothing(self): + lst, total = qtui.read_run_log( + os.path.join(self.dossier, "jamais_ecrit.log") + ) + self.assertEqual(([], 0), (lst, total)) + + def test_the_total_is_told_even_when_the_tail_is_kept(self): + # Montrer la fin SANS dire qu'on en cache se lit « il manque des + # lignes ». + chemin = self.fichier("\n".join("l%d" % i for i in range(50))) + lst, total = qtui.read_run_log(chemin, limite=10) + self.assertEqual(10, len(lst)) + self.assertEqual(50, total) + + def test_the_whole_output_is_shown_not_a_sample(self): + # Le panneau DÉFILE : amputer obligerait à relancer dans un + # terminal pour lire la suite. + chemin = self.fichier("\n".join("ligne %d" % i for i in range(300))) + row = { + "kind": "review", + "question": "Does Odoo load the database?", + "command": "x", + "capture": chemin, + } + texte = qtui.extra_pane(row) + self.assertIn("ligne 0", texte) + self.assertIn("ligne 299", texte) + + def test_a_line_never_run_says_so(self): + row = { + "kind": "review", + "question": "Does Odoo load the database?", + "command": "x", + "capture": os.path.join(self.dossier, "absent.log"), + } + self.assertIn(qtui.t("never run from here yet"), qtui.extra_pane(row)) + + def test_a_line_with_nothing_to_run_promises_no_log(self): + row = { + "kind": "review", + "question": "Did the migration reach the end?", + "command": "", + "capture": os.path.join(self.dossier, "absent.log"), + } + self.assertNotIn( + qtui.t("never run from here yet"), qtui.extra_pane(row) + ) + + def test_the_repl_step_is_not_captured(self): + # Le passer par un tube lui ferait perdre son invite. + lignes = qtui.rows([snapshot(odoo="18.0")], {}) + shell = [ + row + for row in lignes + if row["kind"] == "review" + and "shell" in (row.get("command") or "") + ] + self.assertTrue(shell) + self.assertIsNone(shell[0].get("capture")) + + def test_every_other_runnable_step_is_captured(self): + lignes = qtui.rows([snapshot(odoo="18.0")], {}) + lancables = [ + row + for row in lignes + if row["kind"] == "review" and row.get("command") + ] + self.assertTrue(lancables) + sans = [r for r in lancables if not r.get("capture")] + self.assertEqual(1, len(sans), [r.get("command") for r in sans]) + + +class TestSwitchingTheCheckoutFirst(Base): + """Lancer un outil sur une base d'un autre palier ÉCRIT dedans.""" + + def dct(self): + return { + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def test_the_same_version_needs_no_switch(self): + courante = quality.checkout_version() + self.assertIsNone( + qtui.switch_needed("base_upgrade_%d" % courante, self.dct()) + ) + + def test_another_tier_names_its_make_target(self): + courante = quality.checkout_version() + autre = 16 if courante != 16 else 15 + besoin = qtui.switch_needed("base_upgrade_%d" % autre, self.dct()) + self.assertEqual((autre, "switch_odoo_%d" % autre), besoin) + + def test_a_database_outside_the_chain_asks_for_nothing(self): + # On ne sait pas à quel palier elle est : proposer une bascule au + # hasard basculerait le checkout pour rien. + self.assertIsNone(qtui.switch_needed("une_autre_base", self.dct())) + + def test_the_panel_warns_before_the_key_not_after(self): + courante = quality.checkout_version() + autre = 16 if courante != 16 else 15 + ligne = { + "kind": "verdict", + "label": "", + "event": evenement(), + "log": None, + "command": "script/odoo/migration/smoke_public_url.py -d x", + "switch": (autre, "switch_odoo_%d" % autre), + } + texte = qtui.extra_pane(ligne) + self.assertIn("switch_odoo_%d" % autre, texte) + self.assertLess( + texte.index("switch_odoo_%d" % autre), + texte.index(qtui.t("press r to run it again")), + ) + + class TestTheVerdictPanel(Base): def panneau(self, **champs): lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]}) diff --git a/test/test_monitoring.py b/test/test_monitoring.py index c7375b7..cd952ff 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -450,6 +450,135 @@ def verdict(**champs): return brut +class TestTheStepLogInTheReport(unittest.TestCase): + """La commande seule ne dit pas POURQUOI.""" + + def setUp(self): + import json + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.json = json + self.progression = os.path.join(self.dossier, "progression.json") + # `step_log_path` cherche sous private/odoo/migration//step_log + self.ancien = residue.status.PATH_MIGRATION_PRIVATE + residue.status.PATH_MIGRATION_PRIVATE = os.path.join( + self.dossier, "private" + ) + self.addCleanup( + setattr, + residue.status, + "PATH_MIGRATION_PRIVATE", + self.ancien, + ) + + def ecrire_progression(self, evenements): + with io.open(self.progression, "w", encoding="utf-8") as handle: + self.json.dump( + { + "lst_event": evenements, + "config_database_name": "test_neutralize", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + }, + handle, + ) + return self.progression + + def ecrire_journal(self, step, lignes): + dossier = os.path.join( + residue.status.PATH_MIGRATION_PRIVATE, + "test_neutralize", + "step_log", + ) + os.makedirs(dossier, exist_ok=True) + chemin = os.path.join(dossier, residue.status.step_slug(step) + ".log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write("\n".join(lignes) + "\n") + return chemin + + def bloc(self, **kw): + return "\n".join( + residue.verdicts_block( + "test_neutralize_upgrade_14", False, self.progression, **kw + ) + ) + + def test_the_passage_around_the_failure_is_shown(self): + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "odoo: ce qui precedait", + "[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + "odoo: ce qui suivait", + ], + ) + texte = self.bloc() + self.assertIn("odoo: ce qui precedait", texte) + self.assertIn("[test] smoke_public_url -> 1", texte) + self.assertNotIn("odoo: ce qui suivait", texte) + + def test_the_log_path_is_named(self): + self.ecrire_progression([verdict()]) + chemin = self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + self.assertIn(chemin, self.bloc()) + + def test_no_step_log_stays_silent(self): + # Une ligne « pas de journal » par verdict noierait ceux qui + # comptent. + self.ecrire_progression([verdict()]) + texte = self.bloc() + self.assertIn("smoke_public_url", texte) + self.assertNotIn("step_log", texte) + + def test_asking_for_none_shows_none(self): + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + self.assertNotIn("[test]", self.bloc(lignes_avant=0)) + + def test_it_says_the_tool_output_is_elsewhere(self): + # Sans cela on cherche dans le journal une sortie qui n'y a + # jamais été écrite. + self.ecrire_progression([verdict()]) + self.assertIn( + residue.t("the tool output is not in the step log: it goes"), + self.bloc(), + ) + + def test_an_all_green_run_does_not_carry_that_warning(self): + self.ecrire_progression([verdict(status=0)]) + self.assertNotIn( + residue.t("the tool output is not in the step log: it goes"), + self.bloc(), + ) + + def test_the_step_shown_is_the_odoo_version(self): + # « 4.1.I » est le compteur du pilote ; la migration en est au 14. + self.ecrire_progression([verdict()]) + texte = self.bloc() + self.assertIn("14 smoke_public_url", texte) + + class TestTheControlsThemselves(unittest.TestCase): """Un contrôle retiré ne doit pas survivre dans les tests. @@ -464,7 +593,8 @@ class TestTheControlsThemselves(unittest.TestCase): import re connues = {c["key"] for c in residue.CONTROLES} - source = io.open(__file__, encoding="utf-8").read() + with io.open(__file__, encoding="utf-8") as handle: + source = handle.read() nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source)) nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source)) self.assertTrue(nommees)