[ADD] qualité de migration : verdicts, sources, revue
Le rapport comparait les paliers sans dire si la migration avait réussi, alors que les verdicts dorment déjà dans lst_event du journal de progression : des contrôles en échec y restent sans remonter nulle part. Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de résidus porte la même section sans toucher son code de sortie : un verdict vient du fichier, pas de la base. --- EN --- The report compared the tiers without saying whether the migration had succeeded, while the verdicts already sit in lst_event of the progression file: failed checks stay there and surface nowhere. Three sections are added below the tiers: the verdicts, tied to the ODOO tier and not to the driver counter, which is off by one; where the traces live, since config.conf leaves logfile= empty and Odoo's output dies with the terminal; and the review, six steps runnable with "r". The residue check carries the same section without touching its exit code: a verdict comes from the file, not from the database. Assisted-by: Claude Opus 5 (cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655)
This commit is contained in:
parent
4a90f9face
commit
c95cf2e9d8
6 changed files with 1372 additions and 18 deletions
|
|
@ -87,6 +87,170 @@ def read_progression(path=DEFAULT_PROGRESSION):
|
|||
return {}
|
||||
|
||||
|
||||
# Où la migration laisse une trace, et ce qu'on y trouve. Les DEUX
|
||||
# chemins sont montrés à l'écran même quand le second n'existe pas : sans
|
||||
# `logfile` dans config.conf, Odoo écrit sur le terminal et sa sortie est
|
||||
# perdue à la fermeture. Ne rien dire laisserait chercher un fichier qui
|
||||
# n'a jamais été écrit.
|
||||
JOURNAL_ODOO = "log/odoo.log"
|
||||
MOTIFS_ERREUR = ("CRITICAL", "ERROR", "Traceback")
|
||||
|
||||
|
||||
def read_events(dct):
|
||||
"""Les verdicts que la migration a laissés, dans l'ordre où ils sont venus.
|
||||
|
||||
`command_executed` dit ce qui a été LANCÉ ; `lst_event` dit ce que
|
||||
cela a RENDU. C'est la seule trace persistante d'un échec : la sortie
|
||||
d'Odoo, elle, part sur le terminal et disparaît avec lui.
|
||||
"""
|
||||
lst = []
|
||||
for brut in dct.get("lst_event") or []:
|
||||
if not isinstance(brut, dict):
|
||||
continue
|
||||
try:
|
||||
statut = int(brut.get("status") or 0)
|
||||
except (TypeError, ValueError):
|
||||
statut = 0
|
||||
lst.append(
|
||||
{
|
||||
"at": str(brut.get("at") or ""),
|
||||
"step": str(brut.get("step") or ""),
|
||||
"kind": str(brut.get("kind") or ""),
|
||||
"name": str(brut.get("name") or ""),
|
||||
"status": statut,
|
||||
"detail": str(brut.get("detail") or ""),
|
||||
}
|
||||
)
|
||||
return lst
|
||||
|
||||
|
||||
def failures(events):
|
||||
"""Ceux qui n'ont pas rendu zéro, et seulement ceux-là.
|
||||
|
||||
On garde `kind == "test"` : les entrées `command` à 1 appartiennent au
|
||||
dialogue du pilote — « voulez-vous effacer le module manquant » — et
|
||||
signaler un choix comme un échec ferait ignorer la liste entière.
|
||||
"""
|
||||
return [e for e in events if e["status"] and e["kind"] == "test"]
|
||||
|
||||
|
||||
def event_database(event):
|
||||
"""La base sur laquelle ce verdict portait, lue dans sa commande."""
|
||||
for mot in event.get("detail", "").split():
|
||||
if mot.startswith("test_") or "_upgrade_" in mot:
|
||||
return mot
|
||||
morceaux = event.get("detail", "").split("-d ")
|
||||
if len(morceaux) > 1:
|
||||
return morceaux[1].split()[0]
|
||||
return ""
|
||||
|
||||
|
||||
def event_step(event, database=""):
|
||||
"""Le palier Odoo que ce verdict concernait — « 14 », « 18 ».
|
||||
|
||||
Le nom de la base le porte — « …_upgrade_14 » — et c'est plus sûr que
|
||||
le champ `step`, dont la numérotation (« 4.1.I ») compte les ÉTAPES du
|
||||
pilote et non les versions d'Odoo : elles sont décalées d'un rang.
|
||||
"""
|
||||
base = database or event_database(event)
|
||||
if "_upgrade_" in base:
|
||||
suffixe = base.rsplit("_upgrade_", 1)[1]
|
||||
if suffixe.isdigit():
|
||||
return suffixe
|
||||
return (event.get("step") or "").split(" ")[0][:5]
|
||||
|
||||
|
||||
def log_sources(path=DEFAULT_PROGRESSION, journal=JOURNAL_ODOO):
|
||||
"""[(rôle, chemin, existe, ce qu'on y lit)] — la carte des traces.
|
||||
|
||||
Documenter les chemins EST la fonctionnalité : il a fallu une question
|
||||
pour découvrir que les verdicts vivaient dans `lst_event`, et que la
|
||||
sortie d'Odoo n'était écrite nulle part.
|
||||
"""
|
||||
return [
|
||||
(
|
||||
t("Migration progression"),
|
||||
path,
|
||||
os.path.isfile(path),
|
||||
t("steps, commands and their verdicts (lst_event)"),
|
||||
),
|
||||
(
|
||||
t("Odoo log"),
|
||||
journal,
|
||||
os.path.isfile(journal),
|
||||
t("set logfile= in config.conf, else output is lost"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def scan_log(path=JOURNAL_ODOO, limite=12):
|
||||
"""{motif: compte} et les dernières lignes fautives d'un journal Odoo.
|
||||
|
||||
On lit la QUEUE : un journal de migration pèse des dizaines de
|
||||
mégaoctets et ce qu'on cherche est ce qui a échoué en dernier.
|
||||
"""
|
||||
rapport = {
|
||||
"path": path,
|
||||
"exists": os.path.isfile(path),
|
||||
"counts": {},
|
||||
"lines": [],
|
||||
}
|
||||
if not rapport["exists"]:
|
||||
return rapport
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
handle.seek(0, os.SEEK_END)
|
||||
debut = max(0, handle.tell() - 2_000_000)
|
||||
handle.seek(debut)
|
||||
queue = handle.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return rapport
|
||||
for motif in MOTIFS_ERREUR:
|
||||
rapport["counts"][motif] = queue.count(motif)
|
||||
for ligne in queue.splitlines():
|
||||
if any(motif in ligne for motif in MOTIFS_ERREUR):
|
||||
rapport["lines"].append(ligne.strip()[:200])
|
||||
rapport["lines"] = rapport["lines"][-limite:]
|
||||
return rapport
|
||||
|
||||
|
||||
# La revue : ce qu'on vérifie, dans l'ordre, et ce que chaque étape prouve.
|
||||
# Elle est ÉCRITE ici plutôt que dans une documentation à côté, parce
|
||||
# qu'une liste de contrôle qu'il faut aller chercher n'est pas suivie.
|
||||
REVUE = (
|
||||
(
|
||||
"Did the migration reach the end?",
|
||||
"lst_event, and the six state_4_*_lst flags at 6/6",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Does Odoo load the database?",
|
||||
"./odoo_bin.sh shell -c ./config.conf -d {db} --no-http",
|
||||
"shell",
|
||||
),
|
||||
(
|
||||
"What did the migration leave behind?",
|
||||
"script/analyse/check_migration_residue.py -d {db}",
|
||||
"residue",
|
||||
),
|
||||
(
|
||||
"Is this copy safe to open?",
|
||||
"script/analyse/check_instance_state.py -d {db} --expect copy",
|
||||
"state",
|
||||
),
|
||||
(
|
||||
"Do the public pages still answer?",
|
||||
"script/odoo/migration/smoke_public_url.py -d {db}",
|
||||
"smoke",
|
||||
),
|
||||
(
|
||||
"Is anything left that cleanup could not drop?",
|
||||
"script/odoo/migration/database_cleanup.py -d {db}",
|
||||
"cleanup",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def chain(dct):
|
||||
"""[(version, base)] du départ à l'arrivée, dans l'ordre du parcours.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux
|
|||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.append(
|
||||
|
|
@ -30,6 +31,13 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
return key
|
||||
|
||||
|
||||
# Les commandes se lancent depuis la RACINE : les chemins qu'elles portent
|
||||
# — « script/odoo/migration/… » — y sont relatifs, et les lancer d'ailleurs
|
||||
# les rend introuvables.
|
||||
REPO_ROOT = os.path.normpath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
)
|
||||
|
||||
CSS = """
|
||||
Screen { layout: vertical; }
|
||||
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
|
||||
|
|
@ -39,12 +47,17 @@ Screen { layout: vertical; }
|
|||
"""
|
||||
|
||||
|
||||
def rows(lst_snapshot):
|
||||
"""Un palier par ligne, puis le bilan d'ensemble en dernier.
|
||||
def rows(lst_snapshot, dct=None):
|
||||
"""Un palier par ligne, le bilan, puis les trois sections de revue.
|
||||
|
||||
Le bilan EN DERNIER et non en tête : on descend la liste comme on a
|
||||
vécu la migration, et la question « qu'est-ce qu'il en reste » se pose
|
||||
une fois qu'on a vu le chemin.
|
||||
Le bilan APRÈS les paliers et non en tête : on descend la liste comme
|
||||
on a vécu la migration, et la question « qu'est-ce qu'il en reste » se
|
||||
pose une fois qu'on a vu le chemin. Ce qui suit — verdicts, où lire,
|
||||
quoi vérifier — répond à « et maintenant ».
|
||||
|
||||
`dct` se passe pour éviter la lecture du fichier de progression :
|
||||
autrement la liste dépend de ce qu'une migration a laissé sur CE
|
||||
poste, et deux exécutions ne rendent pas la même chose.
|
||||
"""
|
||||
lst = []
|
||||
presents = [x for x in lst_snapshot if x.get("exists")]
|
||||
|
|
@ -95,9 +108,129 @@ def rows(lst_snapshot):
|
|||
"diff": quality.overall(lst_snapshot),
|
||||
}
|
||||
)
|
||||
lst.extend(extra_rows(presents, dct))
|
||||
return lst
|
||||
|
||||
|
||||
def extra_rows(presents, dct=None):
|
||||
"""Les trois sections sous les paliers : succès, validation, revue.
|
||||
|
||||
Elles vivent dans la MÊME table que les paliers plutôt que dans un
|
||||
second panneau : on descend la colonne une seule fois, et l'ordre dit
|
||||
la démarche — ce que la migration a rendu, où le lire, quoi vérifier
|
||||
ensuite.
|
||||
|
||||
Un séparateur n'est PAS sélectionnable au sens où il n'affiche qu'un
|
||||
titre ; le laisser dans la liste garde l'index de la table aligné sur
|
||||
celui des lignes, ce qu'une liste filtrée perdrait.
|
||||
"""
|
||||
dct = quality.read_progression() if dct is None else dct
|
||||
cible = presents[-1]["database"] if presents else ""
|
||||
lst = []
|
||||
|
||||
# ── Succès ──
|
||||
evenements = quality.read_events(dct)
|
||||
ratés = quality.failures(evenements)
|
||||
lst.append(
|
||||
{
|
||||
"kind": "header",
|
||||
"label": f"── {t('Verdicts')} ──",
|
||||
"detail": str(len(ratés)) if ratés else "✅",
|
||||
}
|
||||
)
|
||||
if not evenements:
|
||||
lst.append(
|
||||
{
|
||||
"kind": "verdict-none",
|
||||
"label": f" {t('no verdict recorded')}",
|
||||
"detail": "",
|
||||
}
|
||||
)
|
||||
for event in ratés:
|
||||
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)
|
||||
lst.append(
|
||||
{
|
||||
"kind": "verdict",
|
||||
"label": f" ❌ {palier:<5} {event['name'][:18]}",
|
||||
"detail": "▶",
|
||||
"event": event,
|
||||
"database": base or cible,
|
||||
"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": "",
|
||||
}
|
||||
)
|
||||
|
||||
# ── Validation ──
|
||||
lst.append(
|
||||
{"kind": "header", "label": f"── {t('Validation')} ──", "detail": ""}
|
||||
)
|
||||
for role, chemin, existe, _quoi in quality.log_sources():
|
||||
lst.append(
|
||||
{
|
||||
"kind": "source",
|
||||
"label": f" {'📄' if existe else '∅'} {role[:24]}",
|
||||
"detail": "",
|
||||
"path": chemin,
|
||||
}
|
||||
)
|
||||
lst.append(
|
||||
{
|
||||
"kind": "logscan",
|
||||
"label": f" 🔎 {t('errors in the log')}",
|
||||
"detail": "",
|
||||
}
|
||||
)
|
||||
|
||||
# ── Revue ──
|
||||
lst.append(
|
||||
{"kind": "header", "label": f"── {t('Review')} ──", "detail": ""}
|
||||
)
|
||||
for rang, (question, commande, clef) in enumerate(quality.REVUE, 1):
|
||||
lst.append(
|
||||
{
|
||||
"kind": "review",
|
||||
"label": f" {rang}. {t(question)[:26]}",
|
||||
"detail": "▶" if clef else "",
|
||||
"question": question,
|
||||
"command": (
|
||||
commande.format(db=cible) if cible and clef else ""
|
||||
),
|
||||
}
|
||||
)
|
||||
return lst
|
||||
|
||||
|
||||
def rerun_command(event, database):
|
||||
"""La commande qui rejoue ce verdict, ou "" si on ne sait pas la refaire.
|
||||
|
||||
On la RECONSTRUIT depuis le nom de l'outil plutôt que de rejouer le
|
||||
`detail` tel quel : celui-ci porte le chemin d'un venv de palier, qui
|
||||
n'est plus celui du checkout courant.
|
||||
"""
|
||||
if not database:
|
||||
return ""
|
||||
outils = {
|
||||
"smoke_public_url": "script/odoo/migration/smoke_public_url.py",
|
||||
"database_cleanup": "script/odoo/migration/database_cleanup.py",
|
||||
"check_hidden_models": "script/odoo/migration/check_hidden_models.py",
|
||||
}
|
||||
chemin = outils.get(event.get("name", ""))
|
||||
if not chemin:
|
||||
return ""
|
||||
return f"{chemin} -d {database}"
|
||||
|
||||
|
||||
def head_text(lst_snapshot):
|
||||
presents = [x for x in lst_snapshot if x.get("exists")]
|
||||
manquants = len(lst_snapshot) - len(presents)
|
||||
|
|
@ -159,6 +292,183 @@ def statistics(etat, precedent):
|
|||
]
|
||||
|
||||
|
||||
EXTRA_KINDS = (
|
||||
"header",
|
||||
"verdict",
|
||||
"verdict-none",
|
||||
"verdict-ok",
|
||||
"source",
|
||||
"logscan",
|
||||
"review",
|
||||
)
|
||||
|
||||
|
||||
def extra_pane(row, colour=False):
|
||||
"""Le panneau des trois sections ajoutées sous les paliers."""
|
||||
genre = row["kind"]
|
||||
if genre == "header":
|
||||
return t("Pick a line below.")
|
||||
if genre == "verdict":
|
||||
return verdict_pane(row, colour)
|
||||
if genre == "verdict-none":
|
||||
return "\n".join(
|
||||
[
|
||||
t("The migration recorded no verdict."),
|
||||
"",
|
||||
t("Verdicts live in lst_event of the progression file,"),
|
||||
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":
|
||||
return logscan_pane(colour)
|
||||
if genre == "review":
|
||||
return review_pane(row, colour)
|
||||
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 {}
|
||||
lignes = [
|
||||
status.paint(f"❌ {event.get('name')}", "fail", 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
|
||||
# migration en est alors au palier 14. Les afficher tous deux sous
|
||||
# le même mot faisait lire « palier 4.1 », qui n'existe pas.
|
||||
f" {t('step'):<10} {quality.event_step(event)}"
|
||||
f" ({event.get('step')})",
|
||||
f" {t('when'):<10} {event.get('at')[:19]}",
|
||||
f" {t('status'):<10} {event.get('status')}",
|
||||
"",
|
||||
f" {t('what it ran')}",
|
||||
f" {event.get('detail', '')[:150]}",
|
||||
"",
|
||||
]
|
||||
sens = {
|
||||
"smoke_public_url": t(
|
||||
"1 means a public page failed — not merely a finding."
|
||||
),
|
||||
"database_cleanup": t(
|
||||
"1 means leftovers remain that it could not drop."
|
||||
),
|
||||
}.get(event.get("name"), "")
|
||||
if sens:
|
||||
lignes.append(f" {sens}")
|
||||
lignes.append("")
|
||||
if row.get("command"):
|
||||
lignes.append(
|
||||
status.paint(
|
||||
f" ▶ {t('press r to run it again')}", "step", colour
|
||||
)
|
||||
)
|
||||
lignes.append(f" {row['command']}")
|
||||
lignes.append("")
|
||||
lignes.append(
|
||||
f" {t('The screen steps aside, the test takes the terminal,')}"
|
||||
)
|
||||
lignes.append(f" {t('and you come back with Enter.')}")
|
||||
else:
|
||||
lignes.append(f" {t('No known way to replay this one.')}")
|
||||
return "\n".join(lignes)
|
||||
|
||||
|
||||
def source_pane(row, colour=False):
|
||||
"""Où la migration laisse ses traces, et ce que chacune contient."""
|
||||
lignes = [status.paint(t("Where the traces live"), "step", colour), ""]
|
||||
for role, chemin, existe, quoi in quality.log_sources():
|
||||
marque = "📄" if existe else "∅"
|
||||
teinte = "ok" if existe else "warn"
|
||||
lignes.append(f" {marque} {status.paint(role, teinte, colour)}")
|
||||
lignes.append(f" {chemin}")
|
||||
lignes.append(f" {quoi}")
|
||||
if not existe:
|
||||
lignes.append(f" {t('missing: nothing was written here')}")
|
||||
lignes.append("")
|
||||
lignes.append(f" {t('To read the verdicts by hand:')}")
|
||||
lignes.append(" python3 - <<'PY'")
|
||||
lignes.append(" import json, io")
|
||||
lignes.append(
|
||||
" d = json.load(io.open("
|
||||
f"'{quality.DEFAULT_PROGRESSION}', encoding='utf-8'))"
|
||||
)
|
||||
lignes.append(" for e in d['lst_event']:")
|
||||
lignes.append(" if e.get('status'):")
|
||||
lignes.append(" print(e['step'], e['name'], e['status'])")
|
||||
lignes.append(" PY")
|
||||
return "\n".join(lignes)
|
||||
|
||||
|
||||
def logscan_pane(colour=False):
|
||||
"""Les erreurs du journal d'Odoo — ou pourquoi il n'y en a pas."""
|
||||
rapport = quality.scan_log()
|
||||
lignes = [status.paint(t("Errors in the Odoo log"), "step", colour), ""]
|
||||
lignes.append(f" {rapport['path']}")
|
||||
if not rapport["exists"]:
|
||||
lignes.append("")
|
||||
lignes.append(
|
||||
status.paint(
|
||||
f" ∅ {t('This file does not exist.')}", "warn", colour
|
||||
)
|
||||
)
|
||||
lignes.append("")
|
||||
lignes.append(
|
||||
f" {t('config.conf has an empty logfile=, so Odoo writes to')}"
|
||||
)
|
||||
lignes.append(
|
||||
f" {t('the terminal and its output dies with it. To keep it:')}"
|
||||
)
|
||||
lignes.append("")
|
||||
lignes.append(
|
||||
f" logfile = {os.path.abspath(quality.JOURNAL_ODOO)}"
|
||||
)
|
||||
return "\n".join(lignes)
|
||||
lignes.append("")
|
||||
for motif, combien in rapport["counts"].items():
|
||||
teinte = "fail" if combien else "ok"
|
||||
lignes.append(
|
||||
f" {status.paint(motif.ljust(10), teinte, colour)} {combien}"
|
||||
)
|
||||
if rapport["lines"]:
|
||||
lignes.append("")
|
||||
lignes.append(f" {t('last offending lines')}")
|
||||
for ligne in rapport["lines"]:
|
||||
lignes.append(f" {ligne}")
|
||||
return "\n".join(lignes)
|
||||
|
||||
|
||||
def review_pane(row, colour=False):
|
||||
"""Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas."""
|
||||
lignes = [
|
||||
status.paint(t(row.get("question", "")), "step", colour),
|
||||
"",
|
||||
]
|
||||
if row.get("command"):
|
||||
lignes.append(f" {row['command']}")
|
||||
lignes.append("")
|
||||
lignes.append(
|
||||
status.paint(f" ▶ {t('press r to run it')}", "step", colour)
|
||||
)
|
||||
else:
|
||||
lignes.append(f" {t('Read it in the Verdicts section above.')}")
|
||||
lignes.append("")
|
||||
lignes.append(f" {t('What none of these can see')}")
|
||||
lignes.append(
|
||||
f" {t('They all read the DATABASE. A module that kept a')}"
|
||||
)
|
||||
lignes.append(
|
||||
f" {t('pre-18 view type, or an image URL Odoo no longer')}"
|
||||
)
|
||||
lignes.append(
|
||||
f" {t('accepts, breaks at runtime on a perfectly sound one.')}"
|
||||
)
|
||||
return "\n".join(lignes)
|
||||
|
||||
|
||||
def pane_text(lst_snapshot, row, colour=False, mode=None):
|
||||
"""Le détail du palier choisi, ou le bilan d'ensemble.
|
||||
|
||||
|
|
@ -168,6 +478,8 @@ def pane_text(lst_snapshot, row, colour=False, mode=None):
|
|||
"""
|
||||
if row is None:
|
||||
return t("Nothing to show yet.")
|
||||
if row["kind"] in EXTRA_KINDS:
|
||||
return extra_pane(row, colour)
|
||||
if row["kind"] == "missing":
|
||||
return f"⚠️ {row['data']['database']} : {t('database not found')}"
|
||||
etat = row["data"]
|
||||
|
|
@ -222,6 +534,41 @@ def mode_label(mode):
|
|||
return t(mode)
|
||||
|
||||
|
||||
def run_in_terminal(command, wait=True):
|
||||
"""Rendre le terminal au test, puis le reprendre. (code, a_tourné).
|
||||
|
||||
L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et
|
||||
écrit des centaines de lignes ; les capturer les cacherait, et les
|
||||
laisser passer par-dessus la TUI la déchirerait. Textual sait se
|
||||
retirer — `suspend()` chez l'appelant — et c'est le seul moment où ce
|
||||
processus peut lancer autre chose sans se battre pour l'affichage.
|
||||
|
||||
L'attente d'Entrée n'est pas une politesse : sans elle, l'écran se
|
||||
reconstruit par-dessus le résultat avant qu'on ait pu le lire.
|
||||
"""
|
||||
if not command:
|
||||
return None, False
|
||||
argv = command.split()
|
||||
if argv[0].endswith(".py"):
|
||||
argv = [sys.executable] + argv
|
||||
print()
|
||||
print(f"▶ {' '.join(argv)}")
|
||||
print("─" * 72)
|
||||
try:
|
||||
code = subprocess.run(argv, cwd=REPO_ROOT).returncode
|
||||
except OSError as exc:
|
||||
print(f"❌ {exc}")
|
||||
code = None
|
||||
print("─" * 72)
|
||||
print(f"↩ {t('exit code:')} {code}")
|
||||
if wait:
|
||||
try:
|
||||
input(t("Press Enter to go back to the screen…"))
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
pass
|
||||
return code, True
|
||||
|
||||
|
||||
def build_app(lst_snapshot):
|
||||
"""Textual est importé ICI : le module reste testable sans lui."""
|
||||
from rich.text import Text
|
||||
|
|
@ -235,6 +582,7 @@ def build_app(lst_snapshot):
|
|||
("q,escape", "quit", t("Quit")),
|
||||
("m", "toggle_missing", t("Missing files")),
|
||||
("d", "cycle_detail", t("Details")),
|
||||
("r,enter", "run_selected", t("Run")),
|
||||
]
|
||||
|
||||
def __init__(self, lst_snapshot):
|
||||
|
|
@ -305,6 +653,28 @@ def build_app(lst_snapshot):
|
|||
self.mode = suite[(suite.index(courant) + 1) % len(suite)]
|
||||
self._show()
|
||||
|
||||
def action_run_selected(self):
|
||||
"""Rejouer le test de la ligne choisie, hors de l'écran.
|
||||
|
||||
`suspend()` rend le terminal pour de bon : le test peut monter
|
||||
son instance Odoo et écrire ce qu'il veut. Rien n'est capturé,
|
||||
donc rien n'est caché.
|
||||
"""
|
||||
row = (
|
||||
self.lst_row[self.index]
|
||||
if self.lst_row and self.index < len(self.lst_row)
|
||||
else None
|
||||
)
|
||||
commande = row.get("command") if row else ""
|
||||
if not commande:
|
||||
# Une ligne sans commande n'est pas une erreur : la plupart
|
||||
# n'en ont pas. Un bip dit « rien ici » sans interrompre.
|
||||
self.bell()
|
||||
return
|
||||
with self.suspend():
|
||||
run_in_terminal(commande)
|
||||
self.refresh()
|
||||
|
||||
def on_data_table_row_highlighted(self, event):
|
||||
if event.data_table.id == "left" and self.lst_row:
|
||||
self.index = event.cursor_row
|
||||
|
|
|
|||
|
|
@ -74,10 +74,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
return key
|
||||
|
||||
|
||||
from script.analyse import check_migration_quality as quality # noqa: E402
|
||||
from script.analyse import lib_analyse # noqa: E402
|
||||
|
||||
COULEURS = {
|
||||
"broken": "\033[31m",
|
||||
"step": "\033[36m",
|
||||
"watch": "\033[33m",
|
||||
"ok": "\033[32m",
|
||||
"dim": "\033[90m",
|
||||
|
|
@ -244,6 +246,83 @@ def judge(resultats):
|
|||
return trouve, illisibles
|
||||
|
||||
|
||||
def famille(nom):
|
||||
"""La base d'origine dont ce nom est un palier.
|
||||
|
||||
« test_neutralize_upgrade_14 » et « …_upgrade_18 » sont deux paliers
|
||||
de la MÊME migration. Interroger la base 18 doit montrer l'échec du
|
||||
palier 14 : c'est le seul endroit où il subsiste.
|
||||
"""
|
||||
return nom.rsplit("_upgrade_", 1)[0] if "_upgrade_" in nom else nom
|
||||
|
||||
|
||||
def verdicts(database, path=None):
|
||||
"""(tous, ratés) pour cette base — lus dans le FICHIER, pas en SQL.
|
||||
|
||||
Un test de fumée qui échoue ne laisse aucune trace en base : rien
|
||||
n'est écrit, rien n'est cassé, la requête suivante répond. Les
|
||||
contrôles ci-dessus sont donc structurellement aveugles à ce type
|
||||
d'échec, et c'était la moitié de ce qu'une migration peut rater.
|
||||
"""
|
||||
dct = quality.read_progression(path or quality.DEFAULT_PROGRESSION)
|
||||
lignee = famille(database)
|
||||
tous = [
|
||||
e
|
||||
for e in quality.read_events(dct)
|
||||
if famille(quality.event_database(e)) == lignee
|
||||
]
|
||||
return tous, quality.failures(tous)
|
||||
|
||||
|
||||
def verdicts_block(database, colour=True, path=None):
|
||||
"""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
|
||||
client, il n'y a jamais eu de migration locale, et annoncer l'absence
|
||||
d'un fichier qu'on n'attendait pas ne renseigne personne.
|
||||
"""
|
||||
chemin = path or quality.DEFAULT_PROGRESSION
|
||||
tous, ratés = verdicts(database, chemin)
|
||||
if not tous:
|
||||
return []
|
||||
lignes = [
|
||||
"",
|
||||
paint(f"🚦 {t('Verdicts the migration recorded')}", "step", colour),
|
||||
]
|
||||
lignes.append(paint(f" {t('recorded in')} {chemin}", "dim", colour))
|
||||
lignes.append("")
|
||||
if not ratés:
|
||||
lignes.append(
|
||||
paint(
|
||||
f"✅ {str(len(tous)).rjust(6)} {t('checks, all passed')}",
|
||||
"ok",
|
||||
colour,
|
||||
)
|
||||
)
|
||||
for event in ratés:
|
||||
palier = quality.event_step(event)
|
||||
lignes.append(
|
||||
paint(
|
||||
f"❌ {palier.rjust(6)} {event['name']}",
|
||||
"broken",
|
||||
colour,
|
||||
)
|
||||
)
|
||||
lignes.append(
|
||||
paint(f" {event['detail'][:120]}", "dim", colour)
|
||||
)
|
||||
lignes.append("")
|
||||
lignes.append(
|
||||
paint(
|
||||
f" {t('These come from the file, not the database:')}"
|
||||
f" {t('the exit code ignores them.')}",
|
||||
"dim",
|
||||
colour,
|
||||
)
|
||||
)
|
||||
return lignes
|
||||
|
||||
|
||||
def render(database, resultats, version=None, colour=True):
|
||||
"""Le rapport lisible. Chaque constat dit quoi lancer pour le réparer."""
|
||||
trouve, illisibles = judge(resultats)
|
||||
|
|
@ -266,7 +345,7 @@ def render(database, resultats, version=None, colour=True):
|
|||
colour,
|
||||
)
|
||||
)
|
||||
return "\n".join(lignes)
|
||||
return "\n".join(lignes + verdicts_block(database, colour)).rstrip()
|
||||
|
||||
for controle, combien in trouve:
|
||||
icone = "❌" if controle["gravity"] == "broken" else "⚠"
|
||||
|
|
@ -295,7 +374,7 @@ def render(database, resultats, version=None, colour=True):
|
|||
)
|
||||
)
|
||||
lignes.append(paint(f" {erreur}", "dim", colour))
|
||||
return "\n".join(lignes).rstrip()
|
||||
return "\n".join(lignes + verdicts_block(database, colour)).rstrip()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
|
|
@ -329,6 +408,7 @@ def main(argv=None):
|
|||
trouve, illisibles = judge(resultats)
|
||||
|
||||
if args.json:
|
||||
tous_verdicts, verdicts_ratés = verdicts(args.database)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
|
|
@ -337,6 +417,11 @@ def main(argv=None):
|
|||
"checks": resultats,
|
||||
"found": [c["key"] for c, _ in trouve],
|
||||
"unreadable": [c["key"] for c, _ in illisibles],
|
||||
"verdicts": tous_verdicts,
|
||||
"verdicts_failed": [
|
||||
{"step": quality.event_step(e), "name": e["name"]}
|
||||
for e in verdicts_ratés
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
|
|
|
|||
|
|
@ -6977,6 +6977,209 @@ TRANSLATIONS = {
|
|||
"fr": "Qualité de la migration, palier par palier",
|
||||
"en": "Migration quality, step by step",
|
||||
},
|
||||
# --- Écran de qualité : Verdicts, Validation, Revue ---
|
||||
"Verdicts the migration recorded": {
|
||||
"fr": "Verdicts enregistrés par la migration",
|
||||
"en": "Verdicts the migration recorded",
|
||||
},
|
||||
"recorded in": {
|
||||
"fr": "consignés dans",
|
||||
"en": "recorded in",
|
||||
},
|
||||
"These come from the file, not the database:": {
|
||||
"fr": "Ils viennent du fichier, non de la base :",
|
||||
"en": "These come from the file, not the database:",
|
||||
},
|
||||
"the exit code ignores them.": {
|
||||
"fr": "le code de sortie ne les compte pas.",
|
||||
"en": "the exit code ignores them.",
|
||||
},
|
||||
# Les six questions de la revue : t() les reçoit par
|
||||
# variable, un scan des littéraux ne les voyait pas.
|
||||
"Did the migration reach the end?": {
|
||||
"fr": "La migration est-elle allée jusqu'au bout ?",
|
||||
"en": "Did the migration reach the end?",
|
||||
},
|
||||
"Does Odoo load the database?": {
|
||||
"fr": "Odoo charge-t-il la base ?",
|
||||
"en": "Does Odoo load the database?",
|
||||
},
|
||||
"What did the migration leave behind?": {
|
||||
"fr": "Qu'est-ce que la migration a laissé derrière ?",
|
||||
"en": "What did the migration leave behind?",
|
||||
},
|
||||
"Is this copy safe to open?": {
|
||||
"fr": "Cette copie est-elle sans danger à ouvrir ?",
|
||||
"en": "Is this copy safe to open?",
|
||||
},
|
||||
"Do the public pages still answer?": {
|
||||
"fr": "Les pages publiques répondent-elles encore ?",
|
||||
"en": "Do the public pages still answer?",
|
||||
},
|
||||
"Is anything left that cleanup could not drop?": {
|
||||
"fr": "Reste-t-il ce que le nettoyage n'a pu supprimer ?",
|
||||
"en": "Is anything left that cleanup could not drop?",
|
||||
},
|
||||
"Verdicts": {
|
||||
"fr": "Verdicts",
|
||||
"en": "Verdicts",
|
||||
},
|
||||
"Validation": {
|
||||
"fr": "Validation",
|
||||
"en": "Validation",
|
||||
},
|
||||
"Review": {
|
||||
"fr": "Revue de la migration",
|
||||
"en": "Review",
|
||||
},
|
||||
"no verdict recorded": {
|
||||
"fr": "aucun verdict enregistré",
|
||||
"en": "no verdict recorded",
|
||||
},
|
||||
"checks, all passed": {
|
||||
"fr": "tests, tous réussis",
|
||||
"en": "checks, all passed",
|
||||
},
|
||||
"step": {
|
||||
"fr": "palier",
|
||||
"en": "step",
|
||||
},
|
||||
"when": {
|
||||
"fr": "quand",
|
||||
"en": "when",
|
||||
},
|
||||
"status": {
|
||||
"fr": "statut",
|
||||
"en": "status",
|
||||
},
|
||||
"what it ran": {
|
||||
"fr": "ce qu'il a lancé",
|
||||
"en": "what it ran",
|
||||
},
|
||||
"1 means a public page failed — not merely a finding.": {
|
||||
"fr": "1 signifie qu'une page publique a échoué — pas un simple constat.",
|
||||
"en": "1 means a public page failed — not merely a finding.",
|
||||
},
|
||||
"1 means leftovers remain that it could not drop.": {
|
||||
"fr": "1 signifie qu'il reste des objets qu'il n'a pu supprimer.",
|
||||
"en": "1 means leftovers remain that it could not drop.",
|
||||
},
|
||||
"press r to run it again": {
|
||||
"fr": "appuie sur r pour le relancer",
|
||||
"en": "press r to run it again",
|
||||
},
|
||||
"The screen steps aside, the test takes the terminal,": {
|
||||
"fr": "L'écran s'efface, le test prend le terminal,",
|
||||
"en": "The screen steps aside, the test takes the terminal,",
|
||||
},
|
||||
"and you come back with Enter.": {
|
||||
"fr": "et tu reviens avec Entrée.",
|
||||
"en": "and you come back with Enter.",
|
||||
},
|
||||
"No known way to replay this one.": {
|
||||
"fr": "Aucun moyen connu de rejouer celui-ci.",
|
||||
"en": "No known way to replay this one.",
|
||||
},
|
||||
"The migration recorded no verdict.": {
|
||||
"fr": "La migration n'a enregistré aucun verdict.",
|
||||
"en": "The migration recorded no verdict.",
|
||||
},
|
||||
"Every recorded check returned zero.": {
|
||||
"fr": "Chaque test enregistré a rendu zéro.",
|
||||
"en": "Every recorded check returned zero.",
|
||||
},
|
||||
"Where the traces live": {
|
||||
"fr": "Où vivent les traces",
|
||||
"en": "Where the traces live",
|
||||
},
|
||||
"missing: nothing was written here": {
|
||||
"fr": "absent : rien n'a été écrit ici",
|
||||
"en": "missing: nothing was written here",
|
||||
},
|
||||
"To read the verdicts by hand:": {
|
||||
"fr": "Pour lire les verdicts à la main :",
|
||||
"en": "To read the verdicts by hand:",
|
||||
},
|
||||
"Verdicts live in lst_event of the progression file,": {
|
||||
"fr": "Les verdicts vivent dans lst_event du fichier de",
|
||||
"en": "Verdicts live in lst_event of the progression file,",
|
||||
},
|
||||
"not in command_executed, which only lists what ran.": {
|
||||
"fr": "progression — command_executed ne liste que l'exécuté.",
|
||||
"en": "not in command_executed, which only lists what ran.",
|
||||
},
|
||||
"Errors in the Odoo log": {
|
||||
"fr": "Erreurs dans le journal d'Odoo",
|
||||
"en": "Errors in the Odoo log",
|
||||
},
|
||||
"This file does not exist.": {
|
||||
"fr": "Ce fichier n'existe pas.",
|
||||
"en": "This file does not exist.",
|
||||
},
|
||||
"config.conf has an empty logfile=, so Odoo writes to": {
|
||||
"fr": "config.conf a un logfile= vide : Odoo écrit dans le",
|
||||
"en": "config.conf has an empty logfile=, so Odoo writes to",
|
||||
},
|
||||
"the terminal and its output dies with it. To keep it:": {
|
||||
"fr": "terminal et sa sortie meurt avec lui. Pour la garder :",
|
||||
"en": "the terminal and its output dies with it. To keep it:",
|
||||
},
|
||||
"last offending lines": {
|
||||
"fr": "dernières lignes fautives",
|
||||
"en": "last offending lines",
|
||||
},
|
||||
"press r to run it": {
|
||||
"fr": "appuie sur r pour le lancer",
|
||||
"en": "press r to run it",
|
||||
},
|
||||
"Read it in the Verdicts section above.": {
|
||||
"fr": "À lire dans la section Verdicts ci-dessus.",
|
||||
"en": "Read it in the Verdicts section above.",
|
||||
},
|
||||
"What none of these can see": {
|
||||
"fr": "Ce qu'aucun d'eux ne voit",
|
||||
"en": "What none of these can see",
|
||||
},
|
||||
"They all read the DATABASE. A module that kept a": {
|
||||
"fr": "Tous lisent la BASE. Un module resté sur un type de",
|
||||
"en": "They all read the DATABASE. A module that kept a",
|
||||
},
|
||||
"pre-18 view type, or an image URL Odoo no longer": {
|
||||
"fr": "vue d'avant 18, ou une URL d'image qu'Odoo",
|
||||
"en": "pre-18 view type, or an image URL Odoo no longer",
|
||||
},
|
||||
"accepts, breaks at runtime on a perfectly sound one.": {
|
||||
"fr": "refuse désormais, casse à l'exécution sur une base saine.",
|
||||
"en": "accepts, breaks at runtime on a perfectly sound one.",
|
||||
},
|
||||
"exit code:": {
|
||||
"fr": "code de sortie :",
|
||||
"en": "exit code:",
|
||||
},
|
||||
"Press Enter to go back to the screen…": {
|
||||
"fr": "Appuie sur Entrée pour revenir à l'écran…",
|
||||
"en": "Press Enter to go back to the screen…",
|
||||
},
|
||||
"Pick a line below.": {
|
||||
"fr": "Choisis une ligne ci-dessous.",
|
||||
"en": "Pick a line below.",
|
||||
},
|
||||
"Migration progression": {
|
||||
"fr": "Progression de la migration",
|
||||
"en": "Migration progression",
|
||||
},
|
||||
"Odoo log": {
|
||||
"fr": "Journal d'Odoo",
|
||||
"en": "Odoo log",
|
||||
},
|
||||
"steps, commands and their verdicts (lst_event)": {
|
||||
"fr": "paliers, commandes et leurs verdicts (lst_event)",
|
||||
"en": "steps, commands and their verdicts (lst_event)",
|
||||
},
|
||||
"set logfile= in config.conf, else output is lost": {
|
||||
"fr": "poser logfile= dans config.conf, sinon la sortie est perdue",
|
||||
"en": "set logfile= in config.conf, else output is lost",
|
||||
},
|
||||
"database not found": {
|
||||
"fr": "base introuvable",
|
||||
"en": "database not found",
|
||||
|
|
|
|||
|
|
@ -1025,26 +1025,71 @@ class TestItNeverWrites(Base):
|
|||
def test_no_odoo_is_started(self):
|
||||
# Six démarrages coûteraient une heure ET écriraient dans les
|
||||
# bases. L'inspection en SQL prend moins d'une demi-seconde.
|
||||
#
|
||||
# La garde porte sur le CODE, fonction par fonction, et non sur le
|
||||
# texte du fichier : la revue CITE « ./odoo_bin.sh shell » comme
|
||||
# étape à faire soi-même, et interdire le mot interdirait de le
|
||||
# nommer. Ce qu'on veut garantir est que rien ici ne le LANCE.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(quality)
|
||||
for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
|
||||
self.assertNotIn(interdit, source, interdit)
|
||||
for nom, objet in vars(quality).items():
|
||||
if not inspect.isfunction(objet):
|
||||
continue
|
||||
if objet.__module__ != quality.__name__:
|
||||
continue
|
||||
source = inspect.getsource(objet)
|
||||
for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
|
||||
self.assertNotIn(interdit, source, f"{nom} → {interdit}")
|
||||
|
||||
def test_the_only_process_it_launches_is_psql(self):
|
||||
# Complément du précédent : une donnée peut nommer un programme,
|
||||
# un `subprocess.run` le lance. Il n'y en a qu'un, et c'est psql.
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
arbre = ast.parse(inspect.getsource(quality))
|
||||
lances = [
|
||||
n
|
||||
for n in ast.walk(arbre)
|
||||
if isinstance(n, ast.Call)
|
||||
and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr == "run"
|
||||
and getattr(n.func.value, "id", "") == "subprocess"
|
||||
]
|
||||
self.assertEqual(1, len(lances))
|
||||
premier = lances[0].args[0]
|
||||
self.assertEqual("psql", premier.elts[0].value)
|
||||
|
||||
|
||||
class TestTheFullScreen(Base):
|
||||
def test_one_row_per_step_plus_the_overall(self):
|
||||
def test_one_row_per_step_then_the_overall(self):
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
lignes = qtui.rows(lst)
|
||||
self.assertEqual(
|
||||
[x["kind"] for x in lignes], ["step", "step", "overall"]
|
||||
)
|
||||
lignes = qtui.rows(lst, {})
|
||||
debut = [x["kind"] for x in lignes][:3]
|
||||
self.assertEqual(debut, ["step", "step", "overall"])
|
||||
|
||||
def test_the_overall_comes_LAST(self):
|
||||
def test_the_overall_closes_the_steps(self):
|
||||
# On descend la liste comme on a vécu la migration ; « qu'en
|
||||
# reste-t-il » se pose une fois le chemin vu.
|
||||
# reste-t-il » se pose une fois le chemin vu. Ce qui suit — les
|
||||
# verdicts, où les lire, quoi vérifier — répond à « et après ».
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall")
|
||||
genres = [x["kind"] for x in qtui.rows(lst, {})]
|
||||
rang = genres.index("overall")
|
||||
self.assertEqual(set(genres[:rang]), {"step"})
|
||||
self.assertNotIn("step", genres[rang:])
|
||||
|
||||
def test_the_review_sections_come_after_the_overall(self):
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
genres = [x["kind"] for x in qtui.rows(lst, {})]
|
||||
apres = set(genres[genres.index("overall") + 1 :])
|
||||
self.assertTrue(apres <= set(qtui.EXTRA_KINDS) | {"header"}, apres)
|
||||
|
||||
def test_the_screen_does_not_depend_on_this_machine(self):
|
||||
# Sans `dct`, la liste refléterait ce qu'une migration a laissé
|
||||
# sur CE poste : deux exécutions ne rendraient pas la même chose.
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
avec = qtui.rows(lst, {"lst_event": []})
|
||||
self.assertNotIn("verdict", [x["kind"] for x in avec])
|
||||
|
||||
def test_a_missing_database_gets_its_own_row(self):
|
||||
lignes = qtui.rows(
|
||||
|
|
@ -1587,5 +1632,358 @@ class TestWhyAnAttachmentWentAway(Base):
|
|||
self.assertNotEqual("ir_attachment", entree.get("table"))
|
||||
|
||||
|
||||
def evenement(**champs):
|
||||
"""Un événement du journal de progression, forme réelle."""
|
||||
brut = {
|
||||
"at": "2026-08-26 03:19:59.846453",
|
||||
"step": "4.1.I - Migrate database",
|
||||
"kind": "test",
|
||||
"name": "smoke_public_url",
|
||||
"status": 1,
|
||||
"detail": ".venv.erplibre/bin/python3"
|
||||
" ./script/odoo/migration/smoke_public_url.py"
|
||||
" -d test_neutralize_upgrade_14 --internal-required",
|
||||
}
|
||||
brut.update(champs)
|
||||
return brut
|
||||
|
||||
|
||||
class TestTheVerdictsAreReadFromTheFile(Base):
|
||||
"""`lst_event` est la SEULE trace persistante d'un échec.
|
||||
|
||||
`command_executed` ne dit que ce qui a été lancé. La sortie d'Odoo,
|
||||
elle, part sur le terminal et meurt avec lui.
|
||||
"""
|
||||
|
||||
def test_a_status_written_as_text_still_counts(self):
|
||||
# Le pilote écrit parfois le code en chaîne ; le comparer à zéro
|
||||
# sans le convertir ferait passer "0" pour un échec.
|
||||
lus = quality.read_events({"lst_event": [evenement(status="1")]})
|
||||
self.assertEqual(1, lus[0]["status"])
|
||||
|
||||
def test_a_zero_written_as_text_is_not_a_failure(self):
|
||||
lus = quality.read_events({"lst_event": [evenement(status="0")]})
|
||||
self.assertEqual([], quality.failures(lus))
|
||||
|
||||
def test_an_unreadable_status_is_read_as_success(self):
|
||||
# Mieux vaut taire un verdict illisible que crier un faux échec.
|
||||
lus = quality.read_events({"lst_event": [evenement(status="oui")]})
|
||||
self.assertEqual(0, lus[0]["status"])
|
||||
|
||||
def test_an_entry_that_is_not_a_record_is_skipped(self):
|
||||
lus = quality.read_events({"lst_event": ["cassé", evenement()]})
|
||||
self.assertEqual(1, len(lus))
|
||||
|
||||
def test_no_list_at_all_reads_as_no_verdict(self):
|
||||
self.assertEqual([], quality.read_events({}))
|
||||
self.assertEqual([], quality.read_events({"lst_event": None}))
|
||||
|
||||
def test_a_dialogue_answer_is_not_a_failed_test(self):
|
||||
# Les entrées `command` à 1 sont les réponses du pilote — « veux-tu
|
||||
# effacer le module manquant ». Les compter comme des échecs
|
||||
# noierait les vrais sous une liste qu'on cesse de lire.
|
||||
lus = quality.read_events(
|
||||
{
|
||||
"lst_event": [
|
||||
evenement(kind="command", name="./odoo_bin.sh db --clone"),
|
||||
evenement(kind="test"),
|
||||
]
|
||||
}
|
||||
)
|
||||
ratés = quality.failures(lus)
|
||||
self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés])
|
||||
|
||||
|
||||
class TestWhichStepAVerdictBelongsTo(Base):
|
||||
def test_the_database_is_read_from_the_command(self):
|
||||
self.assertEqual(
|
||||
"test_neutralize_upgrade_14",
|
||||
quality.event_database(evenement()),
|
||||
)
|
||||
|
||||
def test_a_command_without_database_yields_nothing(self):
|
||||
self.assertEqual("", quality.event_database(evenement(detail="")))
|
||||
|
||||
def test_the_step_comes_from_the_database_not_the_counter(self):
|
||||
# `step` compte les ÉTAPES du pilote — « 4.1.I » — et non les
|
||||
# versions d'Odoo : les deux sont décalées d'un rang. Afficher
|
||||
# « 4.1 » à côté de « palier » nommerait la mauvaise version.
|
||||
self.assertEqual("14", quality.event_step(evenement()))
|
||||
|
||||
def test_without_a_step_database_it_falls_back_to_the_counter(self):
|
||||
seul = evenement(detail="", step="4.1.I - Migrate database")
|
||||
self.assertEqual("4.1.I", quality.event_step(seul))
|
||||
|
||||
|
||||
class TestTheMapOfTraces(Base):
|
||||
"""Documenter les chemins EST la fonctionnalité."""
|
||||
|
||||
def test_both_places_are_named_even_when_one_is_empty(self):
|
||||
sources = quality.log_sources(
|
||||
path="rien.json", journal="rien_non_plus.log"
|
||||
)
|
||||
self.assertEqual(2, len(sources))
|
||||
for _role, _chemin, existe, _quoi in sources:
|
||||
self.assertFalse(existe)
|
||||
|
||||
def test_the_missing_journal_is_shown_not_hidden(self):
|
||||
# Le taire laisserait chercher un fichier qu'Odoo n'a jamais
|
||||
# écrit, faute de `logfile=` dans config.conf.
|
||||
chemins = [c for _r, c, _e, _q in quality.log_sources()]
|
||||
self.assertIn(quality.JOURNAL_ODOO, chemins)
|
||||
|
||||
|
||||
class TestScanningTheOdooLog(Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
import tempfile
|
||||
|
||||
self.dossier = tempfile.mkdtemp()
|
||||
self.addCleanup(__import__("shutil").rmtree, self.dossier)
|
||||
self.journal = os.path.join(self.dossier, "odoo.log")
|
||||
|
||||
def ecrire(self, texte):
|
||||
with io.open(self.journal, "w", encoding="utf-8") as handle:
|
||||
handle.write(texte)
|
||||
|
||||
def test_a_missing_file_is_not_an_error(self):
|
||||
rapport = quality.scan_log(os.path.join(self.dossier, "absent.log"))
|
||||
self.assertFalse(rapport["exists"])
|
||||
self.assertEqual([], rapport["lines"])
|
||||
|
||||
def test_each_pattern_is_counted(self):
|
||||
self.ecrire("INFO ok\nERROR boum\nCRITICAL pire\nTraceback (x)\n")
|
||||
rapport = quality.scan_log(self.journal)
|
||||
for motif in quality.MOTIFS_ERREUR:
|
||||
self.assertEqual(1, rapport["counts"][motif], motif)
|
||||
|
||||
def test_a_clean_log_counts_zero_and_shows_nothing(self):
|
||||
self.ecrire("INFO tout va bien\nINFO encore\n")
|
||||
rapport = quality.scan_log(self.journal)
|
||||
self.assertEqual([], rapport["lines"])
|
||||
self.assertEqual(0, rapport["counts"]["ERROR"])
|
||||
|
||||
def test_only_the_last_lines_are_kept(self):
|
||||
# Un journal de migration pèse des dizaines de mégaoctets ; ce
|
||||
# qu'on cherche est ce qui a échoué en DERNIER.
|
||||
self.ecrire("".join("ERROR %d\n" % i for i in range(50)))
|
||||
rapport = quality.scan_log(self.journal, limite=3)
|
||||
self.assertEqual(3, len(rapport["lines"]))
|
||||
self.assertIn("ERROR 49", rapport["lines"][-1])
|
||||
|
||||
|
||||
class TestTheReviewChecklist(Base):
|
||||
"""Une liste de contrôle qu'il faut aller chercher n'est pas suivie."""
|
||||
|
||||
def test_every_step_names_a_question(self):
|
||||
for question, _commande, _clef in quality.REVUE:
|
||||
self.assertTrue(question.endswith("?"), question)
|
||||
|
||||
def test_a_runnable_step_carries_the_database_placeholder(self):
|
||||
for _q, commande, clef in quality.REVUE:
|
||||
if clef:
|
||||
self.assertIn("{db}", commande, commande)
|
||||
|
||||
def test_the_first_step_has_nothing_to_run(self):
|
||||
# « La migration est-elle allée jusqu'au bout » se lit dans le
|
||||
# fichier ; aucune commande ne le rejoue.
|
||||
self.assertIsNone(quality.REVUE[0][2])
|
||||
|
||||
|
||||
class TestTheThreeExtraSections(Base):
|
||||
"""Elles vivent dans la MÊME table que les paliers."""
|
||||
|
||||
def entetes(self, lst):
|
||||
return [r["label"] for r in lst if r["kind"] == "header"]
|
||||
|
||||
def test_the_sections_come_in_the_order_of_the_work(self):
|
||||
lst = qtui.extra_rows([], {})
|
||||
self.assertEqual(
|
||||
[
|
||||
f"── {qtui.t('Verdicts')} ──",
|
||||
f"── {qtui.t('Validation')} ──",
|
||||
f"── {qtui.t('Review')} ──",
|
||||
],
|
||||
self.entetes(lst),
|
||||
)
|
||||
|
||||
def test_no_verdict_at_all_says_so(self):
|
||||
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.
|
||||
lst = qtui.extra_rows([], {"lst_event": [evenement()]})
|
||||
ligne = [r for r in lst if r["kind"] == "verdict"][0]
|
||||
self.assertIn("14", ligne["label"])
|
||||
self.assertIn("smoke_public_url", ligne["label"])
|
||||
|
||||
def test_a_failed_verdict_offers_a_way_to_replay_it(self):
|
||||
lst = qtui.extra_rows([], {"lst_event": [evenement()]})
|
||||
ligne = [r for r in lst if r["kind"] == "verdict"][0]
|
||||
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},
|
||||
{"database": "base_upgrade_18", "exists": True},
|
||||
]
|
||||
lst = qtui.extra_rows(presents, {})
|
||||
commandes = [
|
||||
r["command"] for r in lst if r["kind"] == "review" and r["command"]
|
||||
]
|
||||
self.assertTrue(commandes)
|
||||
for commande in commandes:
|
||||
self.assertIn("base_upgrade_18", commande)
|
||||
self.assertNotIn("{db}", commande)
|
||||
|
||||
def test_without_a_database_no_review_step_can_be_run(self):
|
||||
lst = qtui.extra_rows([], {})
|
||||
for ligne in [r for r in lst if r["kind"] == "review"]:
|
||||
self.assertEqual("", ligne["command"])
|
||||
|
||||
|
||||
class TestTheVerdictPanel(Base):
|
||||
def panneau(self, **champs):
|
||||
lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]})
|
||||
ligne = [r for r in lst if r["kind"] == "verdict"][0]
|
||||
return qtui.extra_pane(ligne)
|
||||
|
||||
def test_the_step_shown_is_the_odoo_version(self):
|
||||
# « 4.1.I » est la première étape du quatrième bloc du pilote ; la
|
||||
# migration en est alors au palier 14. Afficher le compteur sous
|
||||
# le mot « palier » faisait lire une version qui n'existe pas.
|
||||
texte = self.panneau()
|
||||
self.assertIn(f"{qtui.t('step'):<10} 14", texte)
|
||||
|
||||
def test_the_pilot_counter_is_still_there_in_brackets(self):
|
||||
# Il sert à retrouver l'entrée dans le journal ; le perdre
|
||||
# obligerait à compter les étapes à la main.
|
||||
self.assertIn("(4.1.I - Migrate database)", self.panneau())
|
||||
|
||||
def test_what_a_status_of_one_means_is_spelled_out(self):
|
||||
# « status 1 » ne dit pas si c'est un constat ou une panne.
|
||||
texte = self.panneau()
|
||||
self.assertIn(
|
||||
qtui.t("1 means a public page failed — not merely a finding."),
|
||||
texte,
|
||||
)
|
||||
|
||||
def test_a_tool_we_cannot_replay_says_so(self):
|
||||
texte = self.panneau(name="un_outil_inconnu")
|
||||
self.assertIn(qtui.t("No known way to replay this one."), texte)
|
||||
self.assertNotIn(qtui.t("press r to run it again"), texte)
|
||||
|
||||
|
||||
class TestReplayingAVerdict(Base):
|
||||
def test_a_known_tool_is_rebuilt_from_its_name(self):
|
||||
self.assertEqual(
|
||||
"script/odoo/migration/smoke_public_url.py -d base",
|
||||
qtui.rerun_command(evenement(), "base"),
|
||||
)
|
||||
|
||||
def test_the_recorded_command_is_not_replayed_as_is(self):
|
||||
# Le `detail` porte le venv du PALIER — « .venv.erplibre/bin/python3 »
|
||||
# d'alors — qui n'est plus celui du checkout courant.
|
||||
commande = qtui.rerun_command(evenement(), "base")
|
||||
self.assertNotIn(".venv", commande)
|
||||
self.assertNotIn("--internal-required", commande)
|
||||
|
||||
def test_an_unknown_tool_offers_nothing(self):
|
||||
self.assertEqual(
|
||||
"", qtui.rerun_command(evenement(name="autre_chose"), "base")
|
||||
)
|
||||
|
||||
def test_without_a_database_there_is_nothing_to_run(self):
|
||||
self.assertEqual("", qtui.rerun_command(evenement(), ""))
|
||||
|
||||
|
||||
class TestRunningATestFromTheScreen(Base):
|
||||
"""Le test prend le terminal, puis le rend."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.appels = []
|
||||
|
||||
class FauxRetour:
|
||||
returncode = 0
|
||||
|
||||
def faux_run(argv, cwd=None):
|
||||
self.appels.append((argv, cwd))
|
||||
return FauxRetour()
|
||||
|
||||
faux = type("M", (), {"run": staticmethod(faux_run)})
|
||||
self.addCleanup(setattr, qtui, "subprocess", qtui.subprocess)
|
||||
qtui.subprocess = faux
|
||||
|
||||
def lancer(self, commande, wait=False):
|
||||
tampon = io.StringIO()
|
||||
with redirect_stdout(tampon):
|
||||
resultat = qtui.run_in_terminal(commande, wait=wait)
|
||||
return resultat, tampon.getvalue()
|
||||
|
||||
def test_nothing_to_run_runs_nothing(self):
|
||||
(code, tourné), _sortie = self.lancer("")
|
||||
self.assertIsNone(code)
|
||||
self.assertFalse(tourné)
|
||||
self.assertEqual([], self.appels)
|
||||
|
||||
def test_a_python_tool_runs_with_this_checkout_interpreter(self):
|
||||
# Le lancer avec le python du système le priverait des dépendances
|
||||
# d'ERPLibre : l'outil échouerait à l'import, pas sur la base.
|
||||
self.lancer("script/odoo/migration/smoke_public_url.py -d base")
|
||||
argv, _cwd = self.appels[0]
|
||||
self.assertEqual(sys.executable, argv[0])
|
||||
|
||||
def test_it_runs_from_the_repository_root(self):
|
||||
# Les chemins que la commande porte y sont relatifs ; ailleurs,
|
||||
# ils sont introuvables.
|
||||
self.lancer("script/odoo/migration/smoke_public_url.py -d base")
|
||||
_argv, cwd = self.appels[0]
|
||||
self.assertEqual(qtui.REPO_ROOT, cwd)
|
||||
self.assertTrue(
|
||||
os.path.isdir(os.path.join(cwd, "script", "odoo", "migration"))
|
||||
)
|
||||
|
||||
def test_a_plain_command_keeps_its_own_program(self):
|
||||
self.lancer("./odoo_bin.sh shell -d base")
|
||||
argv, _cwd = self.appels[0]
|
||||
self.assertEqual("./odoo_bin.sh", argv[0])
|
||||
|
||||
def test_the_exit_code_is_shown_not_swallowed(self):
|
||||
(code, _t), sortie = self.lancer("./odoo_bin.sh shell")
|
||||
self.assertEqual(0, code)
|
||||
self.assertIn(qtui.t("exit code:"), sortie)
|
||||
|
||||
def test_a_missing_program_is_reported_not_raised(self):
|
||||
def explose(argv, cwd=None):
|
||||
raise OSError("No such file or directory")
|
||||
|
||||
qtui.subprocess = type("M", (), {"run": staticmethod(explose)})
|
||||
(code, tourné), sortie = self.lancer("absent.sh")
|
||||
self.assertIsNone(code)
|
||||
self.assertTrue(tourné)
|
||||
self.assertIn("No such file", sortie)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas
|
|||
d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
|
@ -434,5 +435,138 @@ class TestTheRepairAsksItsOwnDetector(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
def verdict(**champs):
|
||||
"""Un événement du journal de progression, forme réelle."""
|
||||
brut = {
|
||||
"at": "2026-08-26 03:19:59.846453",
|
||||
"step": "4.1.I - Migrate database",
|
||||
"kind": "test",
|
||||
"name": "smoke_public_url",
|
||||
"status": 1,
|
||||
"detail": ".venv.erplibre/bin/python3"
|
||||
" ./script/odoo/migration/smoke_public_url.py"
|
||||
" -d test_neutralize_upgrade_14 --internal-required",
|
||||
}
|
||||
brut.update(champs)
|
||||
return brut
|
||||
|
||||
|
||||
class TestTheVerdictsSection(unittest.TestCase):
|
||||
"""Ce que les contrôles SQL ne peuvent structurellement pas voir.
|
||||
|
||||
Un test de fumée qui échoue n'écrit rien en base : rien n'est cassé,
|
||||
la requête suivante répond. Aucun contrôle lisant la base ne le
|
||||
retrouvera jamais — et c'était la moitié de ce qu'une migration peut
|
||||
rater.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
self.dossier = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.dossier)
|
||||
self.chemin = os.path.join(self.dossier, "progression.json")
|
||||
self.json = json
|
||||
|
||||
def ecrire(self, evenements):
|
||||
with io.open(self.chemin, "w", encoding="utf-8") as handle:
|
||||
self.json.dump({"lst_event": evenements}, handle)
|
||||
return self.chemin
|
||||
|
||||
def test_two_steps_of_one_migration_are_the_same_lineage(self):
|
||||
# Interroger la base 18 doit montrer l'échec du palier 14 : c'est
|
||||
# le seul endroit où il subsiste.
|
||||
self.assertEqual(
|
||||
residue.famille("test_neutralize_upgrade_14"),
|
||||
residue.famille("test_neutralize_upgrade_18"),
|
||||
)
|
||||
|
||||
def test_a_plain_name_is_its_own_lineage(self):
|
||||
self.assertEqual("copy_chezlepro3", residue.famille("copy_chezlepro3"))
|
||||
|
||||
def test_another_migration_verdicts_are_not_shown(self):
|
||||
# Deux migrations partagent le fichier. Attribuer l'échec de
|
||||
# l'une à l'autre enverrait chercher une panne qui n'existe pas.
|
||||
chemin = self.ecrire([verdict()])
|
||||
_tous, ratés = residue.verdicts("autre_client_upgrade_18", chemin)
|
||||
self.assertEqual([], ratés)
|
||||
|
||||
def test_the_failure_of_an_earlier_step_is_shown(self):
|
||||
chemin = self.ecrire([verdict()])
|
||||
_tous, ratés = residue.verdicts("test_neutralize_upgrade_18", chemin)
|
||||
self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés])
|
||||
|
||||
def test_a_step_is_named_by_its_odoo_version(self):
|
||||
chemin = self.ecrire([verdict()])
|
||||
texte = "\n".join(
|
||||
residue.verdicts_block("test_neutralize_upgrade_18", False, chemin)
|
||||
)
|
||||
self.assertIn("14", texte)
|
||||
self.assertIn("smoke_public_url", texte)
|
||||
|
||||
def test_no_file_says_nothing_at_all(self):
|
||||
# Devant la sauvegarde d'un client, il n'y a jamais eu de
|
||||
# migration locale : annoncer l'absence d'un fichier qu'on
|
||||
# n'attendait pas ne renseigne personne.
|
||||
absent = os.path.join(self.dossier, "jamais_ecrit.json")
|
||||
self.assertEqual([], residue.verdicts_block("base", False, absent))
|
||||
|
||||
def test_all_green_is_stated_not_left_silent(self):
|
||||
chemin = self.ecrire([verdict(status=0), verdict(status=0)])
|
||||
texte = "\n".join(
|
||||
residue.verdicts_block("test_neutralize_upgrade_18", False, chemin)
|
||||
)
|
||||
self.assertIn("2", texte)
|
||||
self.assertIn(residue.t("checks, all passed"), texte)
|
||||
|
||||
def test_the_report_says_these_are_not_from_the_database(self):
|
||||
# Sans cette phrase, un verdict d'il y a quatre paliers se lirait
|
||||
# comme un défaut présent de la base qu'on a sous les yeux.
|
||||
chemin = self.ecrire([verdict()])
|
||||
texte = "\n".join(
|
||||
residue.verdicts_block("test_neutralize_upgrade_18", False, chemin)
|
||||
)
|
||||
self.assertIn(
|
||||
residue.t("These come from the file, not the database:"), texte
|
||||
)
|
||||
|
||||
def test_a_past_verdict_does_not_become_a_finding(self):
|
||||
# Le code de sortie dit « la BASE porte un défaut ». Y compter un
|
||||
# verdict passé rendrait 1 pour toujours, et le pilote traiterait
|
||||
# une base saine comme cassée à chaque appel.
|
||||
vide = {c["key"]: 0 for c in residue.CONTROLES}
|
||||
trouve, _illisibles = residue.judge(vide)
|
||||
self.assertEqual([], trouve)
|
||||
|
||||
def test_the_clean_report_still_carries_the_verdicts(self):
|
||||
# Le cas qui compte : aucun résidu en base, et pourtant un test
|
||||
# de fumée a échoué en chemin. Le rapport « rien trouvé » ne doit
|
||||
# pas être le dernier mot.
|
||||
chemin = self.ecrire([verdict()])
|
||||
ancien = residue.quality.DEFAULT_PROGRESSION
|
||||
residue.quality.DEFAULT_PROGRESSION = chemin
|
||||
self.addCleanup(
|
||||
setattr, residue.quality, "DEFAULT_PROGRESSION", ancien
|
||||
)
|
||||
vide = {c["key"]: 0 for c in residue.CONTROLES}
|
||||
texte = residue.render(
|
||||
"test_neutralize_upgrade_18", vide, colour=False
|
||||
)
|
||||
self.assertIn(residue.t("None of the checks found anything."), texte)
|
||||
self.assertIn("smoke_public_url", texte)
|
||||
|
||||
def test_the_colour_it_asks_for_exists(self):
|
||||
# `paint` retombe sur une chaîne vide suivie d'un RESET quand le
|
||||
# genre est inconnu : cela n'annule rien et ne teinte rien.
|
||||
chemin = self.ecrire([verdict()])
|
||||
for ligne in residue.verdicts_block(
|
||||
"test_neutralize_upgrade_18", True, chemin
|
||||
):
|
||||
if ligne and ligne.endswith(residue.RESET):
|
||||
self.assertNotEqual(residue.RESET, ligne.strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue