diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 1eb914d..3673161 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -74,6 +74,13 @@ RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I) # figée dans laquelle l'enfant ne trouve plus son xpath. RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]") +# « Template: website.submenu ». Une QWebException de RENDU ne porte pas le +# bloc [view_id …] : elle nomme le gabarit. Mesuré au palier 17 — une copie +# figée appelait `submenu.clean_url()`, méthode renommée `_clean_url()` dans +# la version, et 34 URL sur 37 rendaient 500 sans que rien ne désigne la vue +# fautive. +RE_TEMPLATE = re.compile(r"^Template:\s*([\w.]+)\s*$", re.M) + # Un port à part : la migration tourne souvent à côté d'une instance vivante, # et lui voler 8069 ferait échouer le test pour une raison sans rapport. DEFAULT_PORT = 8169 @@ -253,6 +260,29 @@ def check_urls(lst_url, timeout=30): return lst_failure +def template_keys(lst_log, database): + """Les gabarits nommés par une QWebException, s'ils ont une copie COW. + + Nommer une clé sans copie enverrait réinitialiser une vue module — donc + ne rien faire, en silence. On ne propose que ce qui peut l'être. + """ + lst_key = [] + for line in lst_log: + for key in RE_TEMPLATE.findall(line): + if key not in lst_key: + lst_key.append(key) + if not lst_key: + return [] + quoted = ",".join("'" + k.replace("'", "''") + "'" for k in lst_key) + rows = run_psql( + database, + "SELECT DISTINCT key FROM ir_ui_view" + f" WHERE website_id IS NOT NULL AND key IN ({quoted});", + ) + with_copy = {row[0] for row in rows if row and row[0]} + return [key for key in lst_key if key in with_copy] + + def culprit_keys(database, lst_failure): """Les clés des vues parentes mises en cause, sans doublon. @@ -426,9 +456,16 @@ def run( finally: stop_server(server) + lst_key = [] if lst_failure: - lst_failure = attach_missing_parents(lst_failure, read_log(log_path)) - lst_key = culprit_keys(database, lst_failure) + lst_log = read_log(log_path) + lst_failure = attach_missing_parents(lst_failure, lst_log) + lst_key = culprit_keys(database, lst_failure) + # Les deux sources : le contexte d'héritage quand il existe, le nom + # du gabarit quand l'échec vient du rendu. + for key in template_keys(lst_log, database): + 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 diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 425cc76..630d7e5 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -240,6 +240,70 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): self.assertEqual(rebuilt[0][2].count("2841"), 1) +class TestARenderFailureNamesItsTemplate(unittest.TestCase): + """Une QWebException ne porte pas de bloc [view_id …] : elle nomme le + gabarit. + + Mesuré au palier 17 : une copie figée appelait `submenu.clean_url()`, + méthode renommée `_clean_url()` par la version. 34 URL sur 37 rendaient + 500, et l'outil répondait « aucune vue parente nommée » — il ne lisait + que le contexte d'héritage, absent ici. + """ + + def test_the_template_line_is_read(self): + self.assertEqual( + smoke.RE_TEMPLATE.findall("Template: website.submenu"), + ["website.submenu"], + ) + + def test_prose_is_not_mistaken_for_a_key(self): + # « Template: » suivi d'une phrase n'est pas un gabarit. + self.assertEqual( + smoke.RE_TEMPLATE.findall("Template: not a key, just words"), [] + ) + + def test_only_keys_with_a_copy_are_proposed(self): + # Proposer une clé sans copie COW enverrait réinitialiser une vue + # module : la commande tournerait sans rien faire. + seen = {} + original = smoke.run_psql + smoke.run_psql = lambda db, sql: ( + seen.update(sql=sql), + [["website.submenu"]], + )[1] + self.addCleanup(setattr, smoke, "run_psql", original) + got = smoke.template_keys( + ["Template: website.submenu", "Template: website.layout"], "db" + ) + self.assertEqual(got, ["website.submenu"]) + self.assertIn("website_id IS NOT NULL", seen["sql"]) + + def test_no_template_line_asks_nothing_of_the_database(self): + called = [] + original = smoke.run_psql + smoke.run_psql = lambda db, sql: called.append(sql) or [] + self.addCleanup(setattr, smoke, "run_psql", original) + self.assertEqual(smoke.template_keys(["rien ici"], "db"), []) + self.assertEqual(called, []) + + def test_a_quote_in_a_key_is_escaped(self): + seen = {} + original = smoke.run_psql + smoke.run_psql = lambda db, sql: (seen.update(sql=sql), [])[1] + self.addCleanup(setattr, smoke, "run_psql", original) + smoke.template_keys(["Template: a.b"], "db") + self.assertIn("'a.b'", seen["sql"]) + + def test_both_sources_feed_the_proposal(self): + # Le contexte d'héritage quand il existe, le gabarit quand l'échec + # vient du rendu : l'un ne remplace pas l'autre. + import inspect + + source = inspect.getsource(smoke.run) + self.assertIn("culprit_keys(", source) + self.assertIn("template_keys(", source) + + class TestTheServerIsKilledForReal(unittest.TestCase): """« ./run.sh » est un script bash : il ne transmet pas les signaux."""