[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed in the database whose code no longer ships with the target version. None of it stops the module loading — it stops the day someone opens the app. Neutralizing installs a `test` / `test` login carrying the SYSTEM user's groups, and it survives the module's uninstall. So the tool checks for that user rather than trusting a flag, and it rides the server the public pass already started: booting Odoo is what costs minutes, not requests. Measured on a real 18.0 database of 25 apps, which found four defects of mine: web_search_read changed signature in 17, a bare 404 hides an unregistered model, embedded sub-views are not the parent's fields, and an empty psql result meant two different things. --- FR --- [ADD] migration : ouvrir chaque application avec l'utilisateur test 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 module installé en base dont le code n'accompagne plus la version cible. Rien de cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli. La neutralisation pose un compte `test` / `test` portant les groupes du superutilisateur, et il survit à la désinstallation du module. L'outil vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur déjà démarré : c'est le démarrage qui coûte, pas les requêtes. Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre défauts de mon fait. Assisted-by: Claude Opus 5
This commit is contained in:
parent
95a6ba558e
commit
d8794f0e10
5 changed files with 1502 additions and 8 deletions
765
script/odoo/migration/smoke_internal_ui.py
Executable file
765
script/odoo/migration/smoke_internal_ui.py
Executable file
|
|
@ -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
|
||||
`<field name="product_id"/>` 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 `<field>`.
|
||||
|
||||
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())
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
498
test/test_smoke_internal_ui.py
Normal file
498
test/test_smoke_internal_ui.py
Normal file
|
|
@ -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": (
|
||||
"<form>"
|
||||
'<field name="partner_id"/>'
|
||||
'<field name="line_ids">'
|
||||
'<list><field name="product_id"/>'
|
||||
'<field name="price_unit"/></list>'
|
||||
"</field>"
|
||||
'<field name="amount_total"/>'
|
||||
"</form>"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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": '<list><field name="partner_id.name"/></list>'}}
|
||||
self.assertEqual(ui.arch_fields(views), [])
|
||||
|
||||
def test_an_unreadable_arch_yields_nothing_rather_than_nonsense(self):
|
||||
views = {"a": {"arch": "<form><field name='x'"}}
|
||||
self.assertEqual(ui.arch_fields(views), [])
|
||||
|
||||
def test_it_finds_arch_wherever_the_version_put_it(self):
|
||||
# `views` en 16+, `fields_views` avant : chercher à plat évite de
|
||||
# tenir deux chemins en dur qui dérivent l'un de l'autre.
|
||||
ancien = {
|
||||
"fields_views": {
|
||||
"list": {"arch": '<list><field name="x"/></list>'}
|
||||
}
|
||||
}
|
||||
self.assertEqual(ui.arch_fields(ancien), ["x"])
|
||||
|
||||
|
||||
class TestTheThreeStatesOfTheTestUser(unittest.TestCase):
|
||||
"""« je ne sais pas » n'est pas « il n'y en a pas ».
|
||||
|
||||
Vécu : un « id » ambigu dans MON SQL faisait rendre une liste vide, et
|
||||
l'outil annonçait tranquillement que la base n'avait pas été
|
||||
neutralisée. Le back-office n'était pas testé, et rien ne le disait.
|
||||
"""
|
||||
|
||||
def test_a_row_saying_one_is_present(self):
|
||||
self.assertEqual(
|
||||
ui.user_state("db", run_psql=lambda d, s: [["1"]]), "present"
|
||||
)
|
||||
|
||||
def test_a_row_saying_zero_is_absent(self):
|
||||
self.assertEqual(
|
||||
ui.user_state("db", run_psql=lambda d, s: [["0"]]), "absent"
|
||||
)
|
||||
|
||||
def test_no_row_at_all_is_unknown(self):
|
||||
self.assertEqual(
|
||||
ui.user_state("db", run_psql=lambda d, s: []), "unknown"
|
||||
)
|
||||
|
||||
def test_a_psql_that_raises_is_unknown(self):
|
||||
def explose(database, sql):
|
||||
raise OSError("psql absent")
|
||||
|
||||
self.assertEqual(ui.user_state("db", run_psql=explose), "unknown")
|
||||
|
||||
def test_it_counts_rather_than_selects(self):
|
||||
# Un COUNT rend TOUJOURS une ligne quand la requête aboutit : c'est
|
||||
# ce qui sépare « aucun utilisateur » de « requête refusée ».
|
||||
vu = {}
|
||||
ui.user_state(
|
||||
"db", run_psql=lambda d, s: vu.setdefault("sql", s) and []
|
||||
)
|
||||
self.assertIn("count(", vu["sql"].lower())
|
||||
|
||||
|
||||
class TestTellingRealBreakageFromNoise(unittest.TestCase):
|
||||
def test_a_bare_404_means_the_model_is_not_registered(self):
|
||||
# Le message d'Odoo — « 404 Not Found: The requested URL... » — ne
|
||||
# dit rien. Ce qu'il faut savoir : le module est installé dans la
|
||||
# base et son code manque à cette version.
|
||||
self.assertTrue(
|
||||
ui.model_is_unregistered({"name": "werkzeug.exceptions.NotFound"})
|
||||
)
|
||||
|
||||
def test_an_access_error_is_not_that(self):
|
||||
self.assertFalse(
|
||||
ui.model_is_unregistered({"name": "odoo.exceptions.AccessError"})
|
||||
)
|
||||
|
||||
def test_a_missing_method_is_recognised(self):
|
||||
self.assertTrue(
|
||||
ui._is_missing_method(
|
||||
{
|
||||
"name": "builtins.AttributeError",
|
||||
"message": "object has no attribute 'load_views'",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_a_real_failure_is_not_mistaken_for_one(self):
|
||||
# Retomber sur l'ancien nom de méthode après une vraie panne
|
||||
# masquerait la panne derrière un « méthode inconnue » sans rapport.
|
||||
self.assertFalse(
|
||||
ui._is_missing_method(
|
||||
{"name": "odoo.exceptions.AccessError", "message": "refusé"}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestNotEvaluatingWhatComesFromTheDatabase(unittest.TestCase):
|
||||
"""Un domaine d'action est parfois du Python. On ne l'exécute pas."""
|
||||
|
||||
def test_a_literal_domain_is_used(self):
|
||||
self.assertEqual(
|
||||
ui.literal("[('a','=',1)]", []), ([("a", "=", 1)], False)
|
||||
)
|
||||
|
||||
def test_an_expression_falls_back_and_SAYS_so(self):
|
||||
# `uid`, `context_today` : seul le navigateur sait les évaluer. Le
|
||||
# second membre du couple est ce qui permet de le dire au rapport
|
||||
# au lieu de faire passer une approximation pour une mesure.
|
||||
valeur, approx = ui.literal("[('user_id','=',uid)]", [])
|
||||
self.assertEqual(valeur, [])
|
||||
self.assertTrue(approx)
|
||||
|
||||
def test_an_empty_domain_is_not_an_approximation(self):
|
||||
self.assertEqual(ui.literal("[]", []), ([], False))
|
||||
|
||||
|
||||
class TestWhatIsWorthOpening(unittest.TestCase):
|
||||
def test_a_window_action_is_split(self):
|
||||
self.assertEqual(
|
||||
ui.split_action("ir.actions.act_window,42"),
|
||||
("ir.actions.act_window", 42),
|
||||
)
|
||||
|
||||
def test_a_server_action_is_recognised_and_left_alone(self):
|
||||
session = object()
|
||||
app = {"id": 1, "name": "X"}
|
||||
menu = {"id": 2, "name": "Y", "action": "ir.actions.server,9"}
|
||||
# Une action serveur ÉCRIT : l'exécuter pour « voir si ça marche »
|
||||
# ferait justement ce qu'un test ne doit pas faire.
|
||||
result = ui.check_entry(session, app, menu)
|
||||
self.assertEqual(result["kind"], "ir.actions.server")
|
||||
self.assertIsNone(result["error"])
|
||||
self.assertIsNone(result["model"])
|
||||
|
||||
def test_qweb_is_not_a_page_to_open(self):
|
||||
self.assertEqual(
|
||||
ui.view_pairs("list,qweb,form"), [[False, "list"], [False, "form"]]
|
||||
)
|
||||
|
||||
def test_no_view_mode_still_gives_something(self):
|
||||
self.assertTrue(ui.view_pairs(None))
|
||||
|
||||
|
||||
class TestLoadingTheFirstPage(unittest.TestCase):
|
||||
"""L'appel qui charge les enregistrements, et sa forme exacte.
|
||||
|
||||
C'EST le défaut qui a coûté le plus cher : `web_search_read` a changé
|
||||
de signature en 17 — `fields`, une liste, est devenu `specification`,
|
||||
un dictionnaire. Mesuré sur une base 18.0 : seize applications sur
|
||||
vingt-deux échouaient sur « unexpected keyword argument 'fields' »,
|
||||
c'est-à-dire sur MON appel et non sur la base. `search_read` n'a pas
|
||||
bougé depuis la 12.
|
||||
"""
|
||||
|
||||
def call(self, lst_field=None):
|
||||
session = ui.Session("http://x")
|
||||
vu = {}
|
||||
|
||||
def faux(model, method, args, kwargs=None):
|
||||
vu.update(
|
||||
model=model, method=method, args=args, kwargs=kwargs or {}
|
||||
)
|
||||
return [], None
|
||||
|
||||
session.call_kw = faux
|
||||
session.first_page("sale.order", [], {}, 20, lst_field=lst_field)
|
||||
return vu
|
||||
|
||||
def test_it_uses_the_call_that_never_changed(self):
|
||||
self.assertEqual(self.call(["name"])["method"], "search_read")
|
||||
|
||||
def test_the_fields_go_where_search_read_expects_them(self):
|
||||
vu = self.call(["name", "partner_id"])
|
||||
self.assertEqual(vu["args"], [[], ["name", "partner_id"]])
|
||||
self.assertEqual(vu["kwargs"]["limit"], 20)
|
||||
|
||||
def test_without_fields_it_still_loads_something(self):
|
||||
self.assertEqual(self.call()["args"][1], ["display_name"])
|
||||
|
||||
def test_the_page_reads_the_columns_it_shows(self):
|
||||
# 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 la migration se manifeste.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(ui.check_entry)
|
||||
self.assertIn("arch_fields(views)", source)
|
||||
self.assertIn("lst_field=lst_read", source)
|
||||
|
||||
def test_a_field_the_model_lacks_is_never_requested(self):
|
||||
# Le demander ferait échouer la lecture par NOTRE faute, et l'on
|
||||
# perdrait le vrai signal derrière une erreur qu'on a causée.
|
||||
source = __import__("inspect").getsource(ui.check_entry)
|
||||
self.assertIn("if name in lst_known", source)
|
||||
|
||||
|
||||
class TestTheViewsAreRenderedServerSide(unittest.TestCase):
|
||||
def test_it_tries_the_modern_name_then_the_old_one(self):
|
||||
# get_views depuis la 16, load_views avant, et la 18 n'a plus que le
|
||||
# premier : une seule des deux ne couvrirait pas 12→18.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(ui.Session.views_of)
|
||||
self.assertIn("get_views", source)
|
||||
self.assertIn("load_views", source)
|
||||
self.assertLess(source.index("get_views"), source.index("load_views"))
|
||||
|
||||
def test_the_resolved_name_is_remembered(self):
|
||||
# Chercher à chaque application doublerait le nombre de requêtes.
|
||||
session = ui.Session("http://x")
|
||||
appels = []
|
||||
|
||||
def faux(model, method, args, kwargs=None):
|
||||
appels.append(method)
|
||||
if method == "get_views":
|
||||
return None, {
|
||||
"name": "builtins.AttributeError",
|
||||
"message": "has no attribute",
|
||||
}
|
||||
return {"views": {}}, None
|
||||
|
||||
session.call_kw = faux
|
||||
session.views_of("m", [[False, "list"]])
|
||||
session.views_of("m", [[False, "list"]])
|
||||
self.assertEqual(appels, ["get_views", "load_views", "load_views"])
|
||||
|
||||
|
||||
class TestTheReport(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from script.todo import todo_i18n
|
||||
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def entry(self, **kw):
|
||||
item = {
|
||||
"app": "Sales",
|
||||
"menu": "Quotations",
|
||||
"model": "sale.order",
|
||||
"kind": "ir.actions.act_window",
|
||||
"error": None,
|
||||
"stage": None,
|
||||
"domain_ignored": False,
|
||||
"unknown_fields": [],
|
||||
"fields_read": 3,
|
||||
}
|
||||
item.update(kw)
|
||||
return item
|
||||
|
||||
def test_all_clean_says_so(self):
|
||||
text = ui.render([self.entry()], [])
|
||||
self.assertIn("1/1", text)
|
||||
self.assertIn("opened its first page", text)
|
||||
|
||||
def test_a_registry_failure_explains_itself(self):
|
||||
# Le 404 nu est illisible : le rapport doit dire ce qu'il signifie.
|
||||
item = self.entry(
|
||||
stage="registry",
|
||||
error={"name": "werkzeug.exceptions.NotFound", "message": "404"},
|
||||
)
|
||||
text = ui.render([item], [item])
|
||||
self.assertIn("addons path", text)
|
||||
self.assertNotIn("The requested URL", text)
|
||||
|
||||
def test_ghost_fields_are_reported_without_being_a_failure(self):
|
||||
# Une vue qui nomme un champ disparu n'empêche pas toujours la page
|
||||
# de s'ouvrir. Le taire laisserait passer la cause d'une panne à
|
||||
# venir ; le compter comme un échec ferait crier au loup.
|
||||
item = self.entry(unknown_fields=["grant_offer_id"])
|
||||
text = ui.render([item], [])
|
||||
self.assertIn("grant_offer_id", text)
|
||||
self.assertIn("1/1", text)
|
||||
|
||||
def test_an_approximate_domain_is_admitted(self):
|
||||
text = ui.render([self.entry(domain_ignored=True)], [])
|
||||
self.assertIn("empty domain", text)
|
||||
|
||||
def test_the_report_says_the_public_test_cannot_see_this(self):
|
||||
item = self.entry(
|
||||
stage="records", error={"name": "KeyError", "message": "x"}
|
||||
)
|
||||
text = ui.render([item], [item])
|
||||
self.assertIn(
|
||||
"back-office", text.lower().replace("back office", "back-office")
|
||||
)
|
||||
|
||||
|
||||
class TestItRidesTheServerAlreadyRunning(unittest.TestCase):
|
||||
"""Le démarrage d'Odoo coûte des minutes ; les requêtes, non."""
|
||||
|
||||
def test_the_public_tool_runs_it_in_the_same_session(self):
|
||||
import inspect
|
||||
|
||||
import smoke_public_url as public
|
||||
|
||||
source = inspect.getsource(public.run)
|
||||
debut = source.index("check_urls(")
|
||||
arret = source.index("stop_server(server)")
|
||||
self.assertLess(debut, source.index("internal_phase("))
|
||||
self.assertLess(source.index("internal_phase("), arret)
|
||||
|
||||
def test_an_unknown_state_is_not_reported_as_healthy(self):
|
||||
import smoke_public_url as public
|
||||
|
||||
rapport = public.internal_phase("http://x", "db", enabled=True)
|
||||
self.assertIsNotNone(rapport)
|
||||
|
||||
def test_disabling_it_returns_nothing(self):
|
||||
import smoke_public_url as public
|
||||
|
||||
self.assertIsNone(
|
||||
public.internal_phase("http://x", "db", enabled=False)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue