diff --git a/script/odoo/migration/smoke_internal_ui.py b/script/odoo/migration/smoke_internal_ui.py
new file mode 100755
index 0000000..4d659ca
--- /dev/null
+++ b/script/odoo/migration/smoke_internal_ui.py
@@ -0,0 +1,765 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Log in as `test` and open the first page of every Odoo app.
+
+Why this exists
+---------------
+The public smoke test opens what a visitor can reach. It says nothing about
+the BACK OFFICE, which is where a migration does most of its damage: a field
+dropped from a model but still named in a form view, a widget renamed between
+two versions, a stored compute that no longer resolves. None of it stops the
+module loading — it stops the day someone opens the app.
+
+Why the `test` user
+-------------------
+Neutralizing a database installs `user_test`, which copies the SYSTEM user's
+groups onto a `test` / `test` login (see its `post_init_hook`). The module is
+uninstalled right after, but the user survives — its uninstall hook only
+warns. So a neutralized database, and only a neutralized one, can be browsed
+without knowing anyone's real password. The tool checks for that user rather
+than trusting a flag: a resumed migration may have skipped neutralization,
+and a promise in a progression file is not a login.
+
+What is actually exercised
+--------------------------
+For each app — a root menu — its first page, meaning the first menu below it
+that carries an action, which is what the web client opens on a click. Two
+calls per app, both server-side:
+
+- ``get_views`` (12→15: ``load_views``) renders EVERY view arch of the
+ action. This is where a migration's damage surfaces, and it surfaces as an
+ exception rather than an empty screen.
+- ``web_search_read`` (12: ``search_read``) loads the first page of records.
+ A view can render on an empty model and still explode on real data.
+
+Exit codes: 0 every app answered, 1 some failed, 2 the tool failed.
+"""
+
+import argparse
+import ast
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+DEFAULT_LOGIN = "test"
+DEFAULT_PASSWORD = "test"
+DEFAULT_RECORD_LIMIT = 20
+
+# Deux façons d'écrire le même champ caché selon la version du gabarit :
+# l'ordre des attributs n'est pas garanti, et un seul motif en manquait la
+# moitié — la connexion échouait alors sans rien dire d'utile.
+RE_CSRF = re.compile(
+ r"""name=["']csrf_token["']\s+value=["']([^"']+)["']"""
+ r"""|value=["']([^"']+)["']\s+name=["']csrf_token["']"""
+)
+RE_DB_FIELD = re.compile(r"""name=["']db["']""")
+
+
+def user_state(database, login=DEFAULT_LOGIN, run_psql=None):
+ """« present », « absent » ou « unknown ». La nuance n'est pas cosmétique.
+
+ On INTERROGE plutôt que de croire un drapeau : une migration reprise a
+ pu sauter la neutralisation, et `state_1_neutralize_database` dirait
+ quand même « fait ».
+
+ Mais `run_psql` rend une liste vide DANS LES DEUX CAS — aucune ligne, ou
+ requête refusée. Mesuré ici même : un « id » ambigu passait pour « pas
+ d'utilisateur test », et l'outil annonçait tranquillement que la base
+ n'avait pas été neutralisée. Un COUNT distingue les deux : il rend
+ toujours une ligne quand la requête aboutit.
+ """
+ if run_psql is None:
+ from smoke_public_url import run_psql
+ sql = (
+ "SELECT count(*) FROM res_users" f" WHERE login = '{login}' AND active"
+ )
+ try:
+ rows = run_psql(database, sql)
+ except Exception:
+ return "unknown"
+ if not rows:
+ return "unknown"
+ try:
+ return "present" if int(rows[0][0]) else "absent"
+ except (TypeError, ValueError, IndexError):
+ return "unknown"
+
+
+def user_exists(database, login=DEFAULT_LOGIN, run_psql=None):
+ """Raccourci : seul « present » vaut oui."""
+ return user_state(database, login, run_psql=run_psql) == "present"
+
+
+class Session:
+ """Un client HTTP qui garde son cookie de session.
+
+ `urllib` sans gestionnaire de cookies renvoie chaque appel comme un
+ inconnu : on se connecte, puis on interroge en anonyme sans qu'aucune
+ erreur ne le signale — juste des résultats vides.
+ """
+
+ def __init__(self, base_url, timeout=60):
+ import http.cookiejar
+
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+ self.jar = http.cookiejar.CookieJar()
+ self.opener = urllib.request.build_opener(
+ urllib.request.HTTPCookieProcessor(self.jar)
+ )
+ self.uid = None
+ # Le nom des méthodes a changé en cours de route (get_views en 16,
+ # load_views avant). On retient celui qui a répondu : chercher à
+ # chaque appel doublerait le nombre de requêtes.
+ self._views_method = None
+ self._read_method = None
+
+ def open(self, path, data=None, headers=None):
+ url = self.base_url + path
+ request = urllib.request.Request(url, data=data, headers=headers or {})
+ try:
+ with self.opener.open(request, timeout=self.timeout) as answer:
+ return answer.getcode(), answer.read().decode(
+ "utf-8", errors="replace"
+ )
+ except urllib.error.HTTPError as exc:
+ return exc.code, exc.read().decode("utf-8", errors="replace")
+ except Exception as exc:
+ return 0, str(exc)
+
+ def rpc(self, path, params):
+ """Un appel JSON-RPC. Rend (résultat, erreur) — jamais une exception.
+
+ Une erreur Odoo arrive avec le statut 200 et un objet `error` dans
+ le corps : la traiter comme un succès ferait passer une vue cassée
+ pour une vue vide.
+ """
+ payload = json.dumps(
+ {
+ "jsonrpc": "2.0",
+ "method": "call",
+ "params": params,
+ "id": 1,
+ }
+ ).encode("utf-8")
+ status, body = self.open(
+ path, data=payload, headers={"Content-Type": "application/json"}
+ )
+ if not status:
+ return None, {"name": "transport", "message": body[:200]}
+ try:
+ answer = json.loads(body)
+ except ValueError:
+ return None, {
+ "name": f"HTTP {status}",
+ "message": body.strip()[:200],
+ }
+ if "error" in answer:
+ error = answer["error"] or {}
+ data = error.get("data") or {}
+ return None, {
+ "name": data.get("name") or error.get("message") or "error",
+ "message": (
+ data.get("message") or error.get("message") or ""
+ ).strip(),
+ "debug": data.get("debug") or "",
+ }
+ return answer.get("result"), None
+
+ def call_kw(self, model, method, args, kwargs=None):
+ return self.rpc(
+ "/web/dataset/call_kw",
+ {
+ "model": model,
+ "method": method,
+ "args": args,
+ "kwargs": kwargs or {},
+ },
+ )
+
+ def log_in(self, database, login, password):
+ """Se connecter par le formulaire, comme le ferait un navigateur.
+
+ Le jeton CSRF n'est pas une formalité : sans lui Odoo rend la page
+ de connexion avec un statut 200, et l'on croirait être entré.
+ """
+ status, body = self.open("/web/login")
+ if not status:
+ return False, t("The server did not serve the login page.")
+ match = RE_CSRF.search(body)
+ fields = {
+ "login": login,
+ "password": password,
+ "redirect": "",
+ }
+ if match:
+ fields["csrf_token"] = match.group(1) or match.group(2)
+ # Le champ `db` n'existe que si le serveur en propose plusieurs.
+ # L'envoyer toujours n'est pas anodin : certaines versions le
+ # refusent quand la liste des bases est masquée.
+ if RE_DB_FIELD.search(body):
+ fields["db"] = database
+ self.open(
+ "/web/login",
+ data=urllib.parse.urlencode(fields).encode("utf-8"),
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ # On ne LIT pas la redirection pour juger : on demande au serveur
+ # qui il croit que nous sommes.
+ info, error = self.rpc("/web/session/get_session_info", {})
+ if error:
+ return False, f"{error['name']} : {error['message']}"[:200]
+ uid = (info or {}).get("uid")
+ if not uid:
+ return False, t("Wrong login or password for user")
+ self.uid = uid
+ return True, ""
+
+ def views_of(self, model, lst_view, context=None):
+ """Faire rendre les arch côté serveur. C'est LE test.
+
+ `get_views` depuis la 16, `load_views` avant, et la 18 n'a plus que
+ le premier. On essaie le moderne, on retombe sur l'ancien.
+ """
+ kwargs = {"views": lst_view, "options": {}, "context": context or {}}
+ lst_try = (
+ [self._views_method]
+ if self._views_method
+ else ["get_views", "load_views"]
+ )
+ last = None
+ for method in lst_try:
+ result, error = self.call_kw(model, method, [], dict(kwargs))
+ if error is None:
+ self._views_method = method
+ return result, None
+ if not _is_missing_method(error):
+ return None, error
+ last = error
+ return None, last
+
+ def first_page(self, model, domain, context, limit, lst_field=None):
+ """Charger la première page d'enregistrements.
+
+ `search_read` et non `web_search_read` : ce dernier a changé de
+ signature en cours de route — `fields` (une liste) est devenu
+ `specification` (un dictionnaire) en 17. Mesuré sur une base 18 :
+ seize applications sur vingt-cinq échouaient sur
+ « unexpected keyword argument 'fields' », c'est-à-dire sur MON
+ appel, pas sur la base. `search_read` n'a pas bougé depuis la 12.
+
+ Une vue peut se rendre sur un modèle vide et exploser sur de
+ vraies données : un champ calculé qui ne résout plus ne se voit
+ qu'une fois qu'il y a quelque chose à calculer.
+ """
+ return self.call_kw(
+ model,
+ "search_read",
+ [domain, lst_field or ["display_name"]],
+ {"limit": limit, "context": context or {}},
+ )
+
+ def known_fields(self, model, context=None):
+ """Les champs que le modèle porte VRAIMENT, d'après le serveur."""
+ result, error = self.call_kw(
+ model, "fields_get", [[], ["type"]], {"context": context or {}}
+ )
+ if error:
+ return None, error
+ return sorted(result or {}), None
+
+
+def arch_fields(views):
+ """Les champs que la première page affiche, et RIEN de plus.
+
+ Analyse XML et non expression régulière, pour une raison mesurée : une
+ vue formulaire embarque les sous-vues de ses one2many, et un
+ `` qui s'y trouve appartient à la LIGNE de
+ facture, pas à la facture. Le motif à plat les ramassait aussi et
+ rapportait neuf applications « nommant un champ que le modèle n'a
+ plus » — toutes fausses. On ne descend donc jamais dans un ``.
+
+ Lire `display_name` seul ne prouverait presque rien : ce sont les
+ colonnes de la liste qui font travailler l'ORM, et c'est là qu'un champ
+ calculé cassé par une migration se manifeste.
+ """
+ import xml.etree.ElementTree as ET
+
+ lst_arch = []
+
+ def descendre(noeud):
+ if isinstance(noeud, dict):
+ arch = noeud.get("arch")
+ if isinstance(arch, str):
+ lst_arch.append(arch)
+ for valeur in noeud.values():
+ descendre(valeur)
+ elif isinstance(noeud, list):
+ for valeur in noeud:
+ descendre(valeur)
+
+ descendre(views)
+
+ lst_name = []
+
+ def parcourir(element):
+ for enfant in element:
+ if enfant.tag == "field":
+ nom = enfant.get("name")
+ # Un champ pointé vise un sous-modèle : le demander au
+ # modèle principal serait une erreur de NOTRE fait.
+ if nom and "." not in nom and nom not in lst_name:
+ lst_name.append(nom)
+ # On s'arrête là : ce qui est SOUS un champ appartient au
+ # modèle de ce champ, pas au nôtre.
+ continue
+ parcourir(enfant)
+
+ for arch in lst_arch:
+ try:
+ racine = ET.fromstring(arch)
+ except ET.ParseError:
+ # Un arch illisible n'est pas une liste de champs vide : c'est
+ # une inconnue. La taire vaut mieux que d'inventer.
+ continue
+ if racine.tag == "field":
+ nom = racine.get("name")
+ if nom and "." not in nom and nom not in lst_name:
+ lst_name.append(nom)
+ else:
+ parcourir(racine)
+ return lst_name
+
+
+def model_is_unregistered(error):
+ """L'erreur dit-elle « ce modèle n'existe pas dans ce registre » ?
+
+ Odoo rend un 404 nu quand `call_kw` vise un modèle absent du registre :
+ « 404 Not Found: The requested URL was not found ». C'est illisible, et
+ pourtant c'est la trouvaille la plus nette d'une migration — le module
+ est installé dans la base, mais son code n'est plus dans l'addons path
+ de la version cible. Mesuré : cinq applications sur vingt-cinq.
+ """
+ return "notfound" in (error.get("name") or "").lower().replace(".", "")
+
+
+def _is_missing_method(error):
+ """L'erreur dit-elle « cette méthode n'existe pas » ?
+
+ Il faut la distinguer d'une vraie panne : retomber sur l'ancien nom
+ après une AccessError masquerait l'AccessError derrière un
+ « méthode inconnue » qui n'a rien à voir.
+ """
+ texte = f"{error.get('name', '')} {error.get('message', '')}".lower()
+ return (
+ "not exist" in texte
+ or "unknown method" in texte
+ or "attributeerror" in texte
+ or "has no attribute" in texte
+ )
+
+
+def menu_rows(session):
+ """Les menus que CET utilisateur voit, dans l'ordre où il les voit.
+
+ Par RPC et non par SQL : `ir.ui.menu` filtre selon les droits et rend
+ les noms traduits. Lire la table donnerait des menus inaccessibles et,
+ depuis la 16, un nom en jsonb qu'il faudrait décoder à la main.
+ """
+ return session.call_kw(
+ "ir.ui.menu",
+ "search_read",
+ [[], ["id", "name", "parent_id", "sequence", "action"]],
+ {"context": {"lang": "en_US"}},
+ )
+
+
+def apps(lst_menu):
+ """(application, menu de sa première page) pour chaque application.
+
+ La première page d'une application est ce que le client web ouvre au
+ clic : le premier menu, en descendant, qui porte une action. Une
+ application qui n'en a aucune n'est pas une erreur — elle n'a rien à
+ ouvrir, et le dire évite de la chercher.
+ """
+ par_parent = {}
+ for menu in lst_menu:
+ parent = menu.get("parent_id")
+ cle = parent[0] if isinstance(parent, (list, tuple)) else None
+ par_parent.setdefault(cle, []).append(menu)
+ for lst in par_parent.values():
+ lst.sort(key=lambda m: (m.get("sequence") or 0, m.get("id") or 0))
+
+ def descendre(menu, vus):
+ if menu["id"] in vus:
+ return None
+ vus.add(menu["id"])
+ if menu.get("action"):
+ return menu
+ for enfant in par_parent.get(menu["id"], []):
+ trouve = descendre(enfant, vus)
+ if trouve:
+ return trouve
+ return None
+
+ resultat = []
+ for racine in par_parent.get(None, []):
+ resultat.append((racine, descendre(racine, set())))
+ return resultat
+
+
+def actionable(lst_menu):
+ """TOUS les menus portant une action, pour le balayage complet."""
+ return [menu for menu in lst_menu if menu.get("action")]
+
+
+def split_action(reference):
+ """« ir.actions.act_window,42 » -> (« ir.actions.act_window », 42)."""
+ if not reference or "," not in str(reference):
+ return None, None
+ model, _sep, ident = str(reference).partition(",")
+ try:
+ return model.strip(), int(ident)
+ except ValueError:
+ return model.strip(), None
+
+
+def literal(value, fallback):
+ """Un domaine ou un contexte, s'il est LITTÉRAL. Sinon le repli.
+
+ Ils contiennent parfois des expressions Python — `uid`, `context_today`
+ — que seul le client sait évaluer. Les évaluer ici serait exécuter du
+ code venu de la base ; les refuser tout court retirerait la moitié des
+ applications du balayage. On teste donc avec un domaine vide, et on le
+ DIT dans le rapport.
+ """
+ if not value or value in ("[]", "{}"):
+ return fallback, False
+ try:
+ return ast.literal_eval(value), False
+ except (ValueError, SyntaxError):
+ return fallback, True
+
+
+def view_pairs(view_mode):
+ """« list,form » -> [[False, « list »], [False, « form »]].
+
+ `qweb` est écarté : son rendu demande un enregistrement précis et un
+ contexte de rapport, pas une ouverture de menu.
+ """
+ lst = []
+ for mode in (view_mode or "list,form").split(","):
+ mode = mode.strip()
+ if mode and mode != "qweb":
+ lst.append([False, mode])
+ return lst or [[False, "form"]]
+
+
+def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT):
+ """Ouvrir une entrée de menu. Rend un dict décrivant ce qui s'est passé."""
+ resultat = {
+ "app": app["name"],
+ "menu": menu["name"],
+ "action": menu.get("action"),
+ "model": None,
+ "kind": None,
+ "error": None,
+ "stage": None,
+ "domain_ignored": False,
+ "unknown_fields": [],
+ "fields_read": 0,
+ }
+ model_action, ident = split_action(menu.get("action"))
+ resultat["kind"] = model_action
+ if model_action != "ir.actions.act_window" or not ident:
+ # Une action client n'a pas d'arch à rendre, une action serveur
+ # ÉCRIT. Ni l'une ni l'autre ne se teste en ouvrant une page.
+ return resultat
+ fields = ["res_model", "view_mode", "domain", "context", "limit"]
+ lst_action, error = session.call_kw(
+ "ir.actions.act_window", "read", [[ident], fields]
+ )
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = "action"
+ return resultat
+ if not lst_action:
+ return resultat
+ action = lst_action[0]
+ model = action.get("res_model")
+ resultat["model"] = model
+ if not model:
+ return resultat
+ domain, brut_domaine = literal(action.get("domain"), [])
+ context, brut_contexte = literal(action.get("context"), {})
+ if not isinstance(domain, list):
+ domain, brut_domaine = [], True
+ if not isinstance(context, dict):
+ context, brut_contexte = {}, True
+ resultat["domain_ignored"] = brut_domaine or brut_contexte
+
+ views, error = session.views_of(
+ model, view_pairs(action.get("view_mode")), context
+ )
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = (
+ "registry" if model_is_unregistered(error) else "views"
+ )
+ return resultat
+
+ # Les champs de la page, et ceux que le modèle porte vraiment. L'écart
+ # entre les deux EST le dégât d'une migration : une vue qui nomme un
+ # champ disparu. Certaines versions le laissent passer au rendu et
+ # n'échouent qu'à la lecture — autant le nommer tout de suite.
+ lst_arch = arch_fields(views)
+ lst_known, error = session.known_fields(model, context)
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = "fields"
+ return resultat
+ resultat["unknown_fields"] = [
+ name for name in lst_arch if name not in lst_known
+ ]
+ lst_read = [name for name in lst_arch if name in lst_known]
+ resultat["fields_read"] = len(lst_read)
+
+ _rows, error = session.first_page(
+ model,
+ domain,
+ context,
+ min(limit, action.get("limit") or limit),
+ lst_field=lst_read or ["display_name"],
+ )
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = "records"
+ return resultat
+
+
+def crawl(session, limit=DEFAULT_RECORD_LIMIT, every_menu=False):
+ """Parcourir les applications. Rend (entrées visitées, échecs)."""
+ lst_menu, error = menu_rows(session)
+ if error:
+ raise RuntimeError(
+ f"{t('Could not read the menus')} : {error['name']}"
+ f" {error['message']}"[:300]
+ )
+ if every_menu:
+ par_id = {menu["id"]: menu for menu in lst_menu}
+ lst_entry = [
+ (_root_of(menu, par_id), menu) for menu in actionable(lst_menu)
+ ]
+ else:
+ lst_entry = [
+ (app, first) for app, first in apps(lst_menu) if first is not None
+ ]
+ lst_result = [
+ check_entry(session, app, menu, limit=limit) for app, menu in lst_entry
+ ]
+ return lst_result, [item for item in lst_result if item["error"]]
+
+
+def _root_of(menu, par_id):
+ """Remonter jusqu'à l'application, pour dire d'où vient le menu."""
+ vus = set()
+ courant = menu
+ while True:
+ parent = courant.get("parent_id")
+ cle = parent[0] if isinstance(parent, (list, tuple)) else None
+ if cle is None or cle in vus or cle not in par_id:
+ return courant
+ vus.add(cle)
+ courant = par_id[cle]
+
+
+def render(lst_result, lst_failure):
+ """Le rapport. Ce qui a ouvert, et ce qui a refusé de s'ouvrir."""
+ lignes = []
+ lignes.append(
+ f"\n✨ {t('Apps opened as the test user')} :"
+ f" {len(lst_result) - len(lst_failure)}/{len(lst_result)}"
+ )
+ saute = [
+ item
+ for item in lst_result
+ if not item["error"] and item["kind"] != "ir.actions.act_window"
+ ]
+ if saute:
+ lignes.append(
+ f" {len(saute)} {t('not openable this way (client or server')}"
+ f" {t('action): nothing to render.')}"
+ )
+ approx = [item for item in lst_result if item["domain_ignored"]]
+ if approx:
+ lignes.append(
+ f" {len(approx)} {t('opened with an empty domain: theirs')}"
+ f" {t('needs the browser to evaluate it.')}"
+ )
+ fantomes = [item for item in lst_result if item.get("unknown_fields")]
+ if fantomes:
+ lignes.append(
+ f"\n⚠️ {t('Views naming a field the model no longer has')} :"
+ )
+ for item in fantomes:
+ lignes.append(
+ f" · {item['app']} [{item['model']}] :"
+ f" {', '.join(item['unknown_fields'][:8])}"
+ )
+ if not lst_failure:
+ lignes.append(f"✅ {t('Every app opened its first page.')}")
+ return "\n".join(lignes)
+ lignes.append(f"\n❌ {t('Apps that failed to open')} :")
+ ETAPE = {
+ "action": t("reading the action"),
+ "views": t("rendering the views"),
+ "fields": t("listing the model fields"),
+ "records": t("loading the first records"),
+ "registry": t("model absent from the running registry"),
+ }
+ for item in lst_failure:
+ error = item["error"]
+ lignes.append(
+ f" · {item['app']} → {item['menu']}"
+ f" [{item['model'] or '-'}]"
+ )
+ lignes.append(
+ f" {ETAPE.get(item['stage'], item['stage'])} :"
+ f" {error['name']}"
+ )
+ if item["stage"] == "registry":
+ # Le 404 nu d'Odoo ne dit rien d'utile ; ce qu'il faut savoir,
+ # c'est que le code du module manque à CETTE version.
+ lignes.append(
+ f" {t('installed in the database, but its module code')}"
+ f" {t('is not in this addons path.')}"
+ )
+ else:
+ message = (error.get("message") or "").strip().splitlines()
+ if message:
+ lignes.append(f" {message[0][:160]}")
+ lignes.append(
+ f"\nℹ {t('These are back-office failures: the public smoke test')}"
+ f" {t('cannot see them.')}"
+ )
+ return "\n".join(lignes)
+
+
+def run(base_url, database, login, password, limit, every_menu=False):
+ """Se connecter puis parcourir. Lève RuntimeError si l'on ne peut pas."""
+ session = Session(base_url)
+ ok, raison = session.log_in(database, login, password)
+ if not ok:
+ raise RuntimeError(f"{t('Could not log in as')} '{login}' : {raison}")
+ return crawl(session, limit=limit, every_menu=every_menu)
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Log in as the neutralization test user and open the first page"
+ " of every Odoo app, reporting what fails."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-c", "--config", default="./config.conf")
+ parser.add_argument("-p", "--port", type=int, default=None)
+ parser.add_argument("--login", default=DEFAULT_LOGIN)
+ parser.add_argument("--password", default=DEFAULT_PASSWORD)
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=DEFAULT_RECORD_LIMIT,
+ help="how many records to load per app",
+ )
+ parser.add_argument(
+ "--all-menus",
+ action="store_true",
+ help="open every menu with an action, not just each app's first page",
+ )
+ parser.add_argument(
+ "--boot-timeout",
+ type=int,
+ default=180,
+ help="how long to wait for the server to answer",
+ )
+ config = parser.parse_args(argv)
+
+ from smoke_public_url import (
+ DEFAULT_PORT,
+ port_is_taken,
+ start_server,
+ stop_server,
+ wait_ready,
+ )
+
+ port = config.port or DEFAULT_PORT
+ etat = user_state(config.database, config.login)
+ if etat == "unknown":
+ # Ne PAS dire « pas neutralisée » : on ne le sait pas. Le dire
+ # ferait croire que le back-office va bien alors qu'on n'a rien vu.
+ print(
+ f"❌ {t('Could not tell whether the')} '{config.login}'"
+ f" {t('user exists in')} '{config.database}'."
+ )
+ return 2
+ if etat == "absent":
+ print(
+ f"ℹ️ {t('No')} '{config.login}' {t('user in')}"
+ f" '{config.database}' :"
+ f" {t('the database was not neutralized, nothing to browse.')}"
+ )
+ return 0
+ if port_is_taken(port):
+ print(
+ f"❌ {t('Something already listens on port')} {port} :"
+ f" {t('it would be tested instead of this database.')}"
+ )
+ return 2
+ base_url = f"http://127.0.0.1:{port}"
+ print(
+ f"⧖ {t('Starting Odoo on')} '{config.database}'"
+ f" ({t('port')} {port})…"
+ )
+ server = start_server(config.database, port, config.config)
+ try:
+ if not wait_ready(base_url, timeout=config.boot_timeout):
+ print(f"❌ {t('The server never answered on')} {base_url}")
+ return 2
+ lst_result, lst_failure = run(
+ base_url,
+ config.database,
+ config.login,
+ config.password,
+ config.limit,
+ every_menu=config.all_menus,
+ )
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+ finally:
+ stop_server(server)
+ print(render(lst_result, lst_failure))
+ return 1 if lst_failure else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py
index dd6023e..f0806c5 100755
--- a/script/odoo/migration/smoke_public_url.py
+++ b/script/odoo/migration/smoke_public_url.py
@@ -430,6 +430,65 @@ def prompt(database, lst_failure, lst_key, ask=None):
return lst_chosen
+def render_internal(internal):
+ """Afficher le rapport du back-office. Rend True s'il a échoué.
+
+ Ne rien afficher quand la base n'a pas été neutralisée serait laisser
+ croire que le back-office a été testé et qu'il va bien.
+ """
+ if internal is None:
+ return False
+ if "skipped" in internal:
+ print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}")
+ return False
+ import smoke_internal_ui
+
+ print(smoke_internal_ui.render(internal["results"], internal["failures"]))
+ return bool(internal["failures"])
+
+
+def internal_phase(
+ base_url,
+ database,
+ enabled=True,
+ login="test",
+ password="test",
+ limit=20,
+ every_menu=False,
+):
+ """Le back-office, si la base a été neutralisée. Rend None sinon.
+
+ La condition n'est pas un drapeau mais l'utilisateur lui-même : la
+ neutralisation pose un compte `test` avec les groupes du
+ superutilisateur, et c'est le seul moyen d'entrer sans connaître le mot
+ de passe de quelqu'un. Une migration reprise a pu sauter l'étape, et le
+ fichier de progression dirait quand même « fait ».
+
+ Un échec ICI ne doit pas emporter le test public : il est mesuré, il
+ est rapporté, mais le rapport des URL publiques a sa propre valeur.
+ """
+ if not enabled:
+ return None
+ try:
+ import smoke_internal_ui
+ except ImportError:
+ return None
+ etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql)
+ if etat == "absent":
+ return {"skipped": t("no test user: the database was not neutralized")}
+ if etat != "present":
+ # « je ne sais pas » n'est pas « tout va bien » : le dire autrement
+ # ferait passer un back-office jamais ouvert pour un back-office sain.
+ return {"skipped": t("could not tell whether the test user exists")}
+ try:
+ lst_result, lst_failure = smoke_internal_ui.run(
+ base_url, database, login, password, limit, every_menu=every_menu
+ )
+ except RuntimeError as exc:
+ return {"skipped": str(exc)}
+ return {"results": lst_result, "failures": lst_failure}
+
+
def run(
database,
port,
@@ -440,6 +499,11 @@ def run(
interactive=False,
auto_apply=False,
ask=input,
+ internal=True,
+ internal_login="test",
+ internal_password="test",
+ internal_limit=20,
+ every_menu=False,
):
"""Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
@@ -472,6 +536,18 @@ def run(
if limit:
lst_url = lst_url[:limit]
lst_failure = check_urls(lst_url, timeout=timeout)
+ # ICI, pendant que le serveur tourne : le démarrage d'Odoo est ce
+ # qui coûte des minutes, pas les requêtes. Un deuxième outil avec
+ # son propre serveur doublerait l'attente pour rien.
+ internal_report = internal_phase(
+ base_url,
+ database,
+ enabled=internal,
+ login=internal_login,
+ password=internal_password,
+ limit=internal_limit,
+ every_menu=every_menu,
+ )
finally:
stop_server(server)
@@ -486,7 +562,7 @@ def run(
if key not in lst_key:
lst_key.append(key)
if not lst_failure or not (interactive or auto_apply):
- return lst_url, lst_failure, lst_key, None
+ return lst_url, lst_failure, lst_key, None, internal_report
print(render(lst_url, lst_failure, lst_key))
if auto_apply:
@@ -499,7 +575,7 @@ def run(
else:
lst_done = prompt(database, lst_failure, lst_key, ask=ask)
if not lst_done:
- return lst_url, lst_failure, lst_key, None
+ return lst_url, lst_failure, lst_key, None, internal_report
server = start_server(database, port, config_path, log_path=log_path)
try:
@@ -512,7 +588,7 @@ def run(
)
finally:
stop_server(server)
- return lst_url, lst_failure, lst_key, lst_again
+ return lst_url, lst_failure, lst_key, lst_again, internal_report
def stop_server(server):
@@ -582,6 +658,24 @@ def main(argv=None):
default=180,
help="how long to wait for the server to answer",
)
+ parser.add_argument(
+ "--no-internal",
+ action="store_true",
+ help="skip the back-office pass done as the neutralization test user",
+ )
+ parser.add_argument("--login", default="test")
+ parser.add_argument("--password", default="test")
+ parser.add_argument(
+ "--record-limit",
+ type=int,
+ default=20,
+ help="how many records each app loads on its first page",
+ )
+ parser.add_argument(
+ "--all-menus",
+ action="store_true",
+ help="open every menu with an action, not just each app's first page",
+ )
config = parser.parse_args(argv)
print(
@@ -590,7 +684,7 @@ def main(argv=None):
)
interactive = not config.report_only and can_ask()
try:
- lst_url, lst_failure, lst_key, lst_again = run(
+ lst_url, lst_failure, lst_key, lst_again, internal = run(
config.database,
config.port,
config.config,
@@ -599,20 +693,26 @@ def main(argv=None):
boot=config.boot_timeout,
interactive=interactive,
auto_apply=config.apply,
+ internal=not config.no_internal,
+ internal_login=config.login,
+ internal_password=config.password,
+ internal_limit=config.record_limit,
+ every_menu=config.all_menus,
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
+ internal_failed = render_internal(internal)
if lst_again is None:
print(render(lst_url, lst_failure, lst_key))
- return 1 if lst_failure else 0
+ return 1 if (lst_failure or internal_failed) else 0
# Après correction on ne redit pas le diagnostic : on dit ce qu'il RESTE.
print(
f"\n↻ {t('Re-checked the')} {len(lst_failure)}"
f" {t('failing URL(s) after the reset')} :"
)
print(render([url for url, _s, _p in lst_failure], lst_again, None))
- return 1 if lst_again else 0
+ return 1 if (lst_again or internal_failed) else 0
if __name__ == "__main__":
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index ead6173..623e8da 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -5286,6 +5286,130 @@ TRANSLATIONS = {
"fr": "garder",
"en": "keep",
},
+ "The server did not serve the login page.": {
+ "fr": "Le serveur n'a pas servi la page de connexion.",
+ "en": "The server did not serve the login page.",
+ },
+ "Wrong login or password for user": {
+ "fr": "Identifiant ou mot de passe refusé pour l'utilisateur",
+ "en": "Wrong login or password for user",
+ },
+ "Could not read the menus": {
+ "fr": "Impossible de lire les menus",
+ "en": "Could not read the menus",
+ },
+ "Apps opened as the test user": {
+ "fr": "Applications ouvertes avec l'utilisateur test",
+ "en": "Apps opened as the test user",
+ },
+ "not openable this way (client or server": {
+ "fr": "non ouvrables ainsi (action client ou",
+ "en": "not openable this way (client or server",
+ },
+ "action): nothing to render.": {
+ "fr": "serveur) : rien à rendre.",
+ "en": "action): nothing to render.",
+ },
+ "opened with an empty domain: theirs": {
+ "fr": "ouvertes avec un domaine vide : le leur",
+ "en": "opened with an empty domain: theirs",
+ },
+ "needs the browser to evaluate it.": {
+ "fr": "demande le navigateur pour être évalué.",
+ "en": "needs the browser to evaluate it.",
+ },
+ "Every app opened its first page.": {
+ "fr": "Chaque application a ouvert sa première page.",
+ "en": "Every app opened its first page.",
+ },
+ "Apps that failed to open": {
+ "fr": "Applications qui n'ont pas pu s'ouvrir",
+ "en": "Apps that failed to open",
+ },
+ "reading the action": {
+ "fr": "lecture de l'action",
+ "en": "reading the action",
+ },
+ "rendering the views": {
+ "fr": "rendu des vues",
+ "en": "rendering the views",
+ },
+ "loading the first records": {
+ "fr": "chargement des premiers enregistrements",
+ "en": "loading the first records",
+ },
+ "These are back-office failures: the public smoke test": {
+ "fr": "Ce sont des échecs du back-office : le test de fumée public",
+ "en": "These are back-office failures: the public smoke test",
+ },
+ "cannot see them.": {
+ "fr": "ne peut pas les voir.",
+ "en": "cannot see them.",
+ },
+ "Could not log in as": {
+ "fr": "Connexion impossible en tant que",
+ "en": "Could not log in as",
+ },
+ "No": {
+ "fr": "Aucun",
+ "en": "No",
+ },
+ "user in": {
+ "fr": "utilisateur dans",
+ "en": "user in",
+ },
+ "the database was not neutralized, nothing to browse.": {
+ "fr": "la base n'a pas été neutralisée, rien à parcourir.",
+ "en": "the database was not neutralized, nothing to browse.",
+ },
+ "Back office not browsed": {
+ "fr": "Back-office non parcouru",
+ "en": "Back office not browsed",
+ },
+ "no test user: the database was not neutralized": {
+ "fr": "pas d'utilisateur test : la base n'a pas été neutralisée",
+ "en": "no test user: the database was not neutralized",
+ },
+ "Could not tell whether the": {
+ "fr": "Impossible de savoir si l'utilisateur",
+ "en": "Could not tell whether the",
+ },
+ "user exists in": {
+ "fr": "existe dans",
+ "en": "user exists in",
+ },
+ "could not tell whether the test user exists": {
+ "fr": "impossible de savoir si l'utilisateur test existe",
+ "en": "could not tell whether the test user exists",
+ },
+ "listing the model fields": {
+ "fr": "inventaire des champs du modèle",
+ "en": "listing the model fields",
+ },
+ "model absent from the running registry": {
+ "fr": "modèle absent du registre en cours",
+ "en": "model absent from the running registry",
+ },
+ "installed in the database, but its module code": {
+ "fr": "installé dans la base, mais le code de son module",
+ "en": "installed in the database, but its module code",
+ },
+ "is not in this addons path.": {
+ "fr": "n'est pas dans cet addons path.",
+ "en": "is not in this addons path.",
+ },
+ "Views naming a field the model no longer has": {
+ "fr": "Vues nommant un champ que le modèle n'a plus",
+ "en": "Views naming a field the model no longer has",
+ },
+ "Public pages, then the back office as the test user": {
+ "fr": "Les pages publiques, puis le back-office avec l'utilisateur test",
+ "en": "Public pages, then the back office as the test user",
+ },
+ "if the database was neutralized.": {
+ "fr": "si la base a été neutralisée.",
+ "en": "if the database was neutralized.",
+ },
"Clean the database before testing the pages?": {
"fr": "Nettoyer la base avant de tester les pages ?",
"en": "Clean the database before testing the pages?",
diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py
index d4e693a..d475978 100755
--- a/script/todo/todo_upgrade.py
+++ b/script/todo/todo_upgrade.py
@@ -3017,8 +3017,11 @@ class TodoUpgrade:
de départ, les mêmes 500 après le palier passent pour un dégât de la
migration, et l'on cherche du mauvais côté.
- « non » par défaut : cela démarre un serveur et peut prendre quelques
- minutes sur un gros site.
+ Le serveur ainsi démarré sert AUSSI au back-office : si la base a
+ été neutralisée, l'outil se connecte avec l'utilisateur `test` que
+ la neutralisation y a posé et ouvre la première page de chaque
+ application. Un deuxième outil avec son propre serveur doublerait
+ l'attente — le démarrage d'Odoo est ce qui coûte, pas les requêtes.
"""
if baseline:
print(
@@ -3037,6 +3040,10 @@ class TodoUpgrade:
)
if answer != "y":
return
+ print(
+ f" {t('Public pages, then the back office as the test user')}"
+ f" {t('if the database was neutralized.')}"
+ )
self.run_on_terminal(
f"{PYTHON_BIN}"
" ./script/odoo/migration/smoke_public_url.py"
diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py
new file mode 100644
index 0000000..3f4c187
--- /dev/null
+++ b/test/test_smoke_internal_ui.py
@@ -0,0 +1,498 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ouvrir le back-office comme quelqu'un qui s'y connecte.
+
+Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit RIEN du
+back-office, où une migration fait pourtant l'essentiel de ses dégâts : un
+champ retiré du modèle mais toujours nommé dans un formulaire, un modèle
+dont le code n'accompagne plus la version cible. Rien de tout cela n'arrête
+le chargement des modules ; cela arrête le jour où quelqu'un ouvre l'appli.
+
+Ce que ces tests verrouillent est ce que l'exécution réelle a corrigé — et
+chaque point ci-dessous a d'abord été un vrai défaut, mesuré sur une base
+18.0 de 25 applications :
+
+- `web_search_read` a changé de signature en 17 (`fields` est devenu
+ `specification`). Seize applications sur vingt-deux échouaient sur MON
+ appel. `search_read` n'a pas bougé depuis la 12 ;
+- un modèle absent du registre rend un « 404 Not Found » nu, illisible,
+ alors que c'est la trouvaille la plus nette d'une migration ;
+- lire les champs d'un arch à l'expression régulière ramasse ceux des
+ SOUS-VUES : neuf applications rapportées « nommant un champ absent »,
+ toutes fausses, parce que les lignes d'une facture ne sont pas des
+ champs de la facture ;
+- `run_psql` rend une liste vide aussi bien pour « aucune ligne » que pour
+ « requête refusée » : un SQL fautif faisait dire « base non neutralisée ».
+"""
+
+import os
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
+
+import smoke_internal_ui as ui # noqa: E402
+
+
+class TestFindingTheFirstPageOfEachApp(unittest.TestCase):
+ """Ce que le client web ouvre au clic : le premier menu avec une action."""
+
+ MENUS = [
+ {
+ "id": 1,
+ "name": "Ventes",
+ "parent_id": False,
+ "sequence": 10,
+ "action": False,
+ },
+ {
+ "id": 2,
+ "name": "Commandes",
+ "parent_id": [1, "Ventes"],
+ "sequence": 5,
+ "action": False,
+ },
+ {
+ "id": 3,
+ "name": "Devis",
+ "parent_id": [2, "Commandes"],
+ "sequence": 1,
+ "action": "ir.actions.act_window,42",
+ },
+ {
+ "id": 4,
+ "name": "Clients",
+ "parent_id": [1, "Ventes"],
+ "sequence": 9,
+ "action": "ir.actions.act_window,43",
+ },
+ {
+ "id": 5,
+ "name": "Réglages",
+ "parent_id": False,
+ "sequence": 99,
+ "action": "ir.actions.act_window,7",
+ },
+ {
+ "id": 6,
+ "name": "Vide",
+ "parent_id": False,
+ "sequence": 50,
+ "action": False,
+ },
+ ]
+
+ def test_it_descends_to_the_first_actionable_menu(self):
+ trouve = dict(
+ (app["name"], first["name"] if first else None)
+ for app, first in ui.apps(self.MENUS)
+ )
+ self.assertEqual(trouve["Ventes"], "Devis")
+
+ def test_sequence_decides_not_the_id(self):
+ # « Commandes » (5) passe avant « Clients » (9) : l'ordre affiché
+ # est celui que l'utilisateur voit, pas l'ordre d'insertion.
+ trouve = dict(
+ (app["name"], first["name"] if first else None)
+ for app, first in ui.apps(self.MENUS)
+ )
+ self.assertNotEqual(trouve["Ventes"], "Clients")
+
+ def test_an_app_carrying_its_own_action_is_its_own_first_page(self):
+ trouve = dict(
+ (app["name"], first["name"] if first else None)
+ for app, first in ui.apps(self.MENUS)
+ )
+ self.assertEqual(trouve["Réglages"], "Réglages")
+
+ def test_an_app_without_any_action_is_not_an_error(self):
+ # Elle n'a rien à ouvrir. La compter comme un échec ferait chercher
+ # un dégât là où il n'y a qu'un menu de regroupement.
+ trouve = dict(
+ (app["name"], first["name"] if first else None)
+ for app, first in ui.apps(self.MENUS)
+ )
+ self.assertIsNone(trouve["Vide"])
+
+ def test_a_menu_loop_does_not_hang(self):
+ # Une base migrée porte parfois des données incohérentes ; un
+ # parcours naïf tournerait indéfiniment sans rien dire.
+ boucle = [
+ {
+ "id": 1,
+ "name": "A",
+ "parent_id": False,
+ "sequence": 1,
+ "action": False,
+ },
+ {
+ "id": 2,
+ "name": "B",
+ "parent_id": [1, "A"],
+ "sequence": 1,
+ "action": False,
+ },
+ {
+ "id": 3,
+ "name": "C",
+ "parent_id": [2, "B"],
+ "sequence": 1,
+ "action": False,
+ },
+ ]
+ boucle[0]["parent_id"] = [3, "C"]
+ self.assertIsInstance(ui.apps(boucle), list)
+
+
+class TestReadingTheFieldsOfAPage(unittest.TestCase):
+ """Les champs de la page, et RIEN de ce qui appartient aux sous-vues."""
+
+ FORM = {
+ "views": {
+ "form": {
+ "arch": (
+ ""
+ )
+ }
+ }
+ }
+
+ def test_the_top_level_fields_are_taken(self):
+ self.assertEqual(
+ ui.arch_fields(self.FORM),
+ ["partner_id", "line_ids", "amount_total"],
+ )
+
+ def test_the_fields_of_an_embedded_view_are_NOT(self):
+ # Le défaut mesuré : `product_id` et `price_unit` sont des champs de
+ # la LIGNE de facture. Les attribuer à la facture faisait rapporter
+ # neuf applications cassées qui allaient parfaitement bien.
+ lst = ui.arch_fields(self.FORM)
+ self.assertNotIn("product_id", lst)
+ self.assertNotIn("price_unit", lst)
+
+ def test_a_dotted_name_is_left_alone(self):
+ views = {"a": {"arch": '
'}}
+ self.assertEqual(ui.arch_fields(views), [])
+
+ def test_an_unreadable_arch_yields_nothing_rather_than_nonsense(self):
+ views = {"a": {"arch": "