[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
"""Une migration peut réussir et servir quand même des 500.
|
|
|
|
|
|
|
|
|
|
Mesuré sur une vraie migration 12 → 13 : tous les modules chargés, aucune
|
|
|
|
|
erreur au journal, et pourtant deux pages publiques sur trente-trois
|
|
|
|
|
répondaient 500 — un billet de blogue et /contactus. Rien ne les distinguait
|
|
|
|
|
avant de les demander.
|
|
|
|
|
|
|
|
|
|
La liste vient du sitemap, celle qu'Odoo publie pour les moteurs de
|
|
|
|
|
recherche. Elle ne se lit PAS depuis « odoo-bin shell » : enumerate_pages()
|
|
|
|
|
réclame http.root.get_db_router(request.db) et lève « object unbound » sans
|
|
|
|
|
requête réelle. D'où le serveur démarré, interrogé, arrêté.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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_public_url as smoke # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheUrlAreBroughtHome(unittest.TestCase):
|
|
|
|
|
"""Le sitemap porte le domaine du site ; on teste une base locale."""
|
|
|
|
|
|
|
|
|
|
def test_the_host_is_replaced(self):
|
|
|
|
|
# Garder le domaine ferait interroger la PRODUCTION depuis une
|
|
|
|
|
# migration. C'est le genre d'erreur qui ne se voit qu'après.
|
|
|
|
|
url = smoke.local_url(
|
|
|
|
|
"http://127.0.0.1:8169", "https://technolibre.ca/blog/x-3/post/y-5"
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(url, "http://127.0.0.1:8169/blog/x-3/post/y-5")
|
|
|
|
|
|
|
|
|
|
def test_a_query_string_survives(self):
|
|
|
|
|
url = smoke.local_url("http://127.0.0.1:8169", "https://a.ca/p?x=1")
|
|
|
|
|
self.assertEqual(url, "http://127.0.0.1:8169/p?x=1")
|
|
|
|
|
|
|
|
|
|
def test_a_bare_domain_becomes_the_root(self):
|
|
|
|
|
url = smoke.local_url("http://127.0.0.1:8169", "https://a.ca")
|
|
|
|
|
self.assertEqual(url, "http://127.0.0.1:8169/")
|
|
|
|
|
|
|
|
|
|
def test_a_trailing_slash_on_the_base_does_not_double(self):
|
|
|
|
|
url = smoke.local_url("http://127.0.0.1:8169/", "https://a.ca/p")
|
|
|
|
|
self.assertEqual(url, "http://127.0.0.1:8169/p")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReadingTheSitemap(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.answers = {}
|
|
|
|
|
self.original = smoke.fetch
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
|
|
|
|
|
# `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale.
|
|
|
|
|
# Un faux resté à deux casse chaque appelant sur un « not enough
|
|
|
|
|
# values to unpack » qui n'apprend rien de la panne réelle.
|
|
|
|
|
def faux(url, timeout=30):
|
|
|
|
|
statut, corps = self.answers.get(url, (404, ""))
|
|
|
|
|
return statut, corps, url
|
|
|
|
|
|
|
|
|
|
smoke.fetch = faux
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
self.addCleanup(setattr, smoke, "fetch", self.original)
|
|
|
|
|
|
|
|
|
|
def test_a_plain_sitemap(self):
|
|
|
|
|
self.answers["http://h/sitemap.xml"] = (
|
|
|
|
|
200,
|
|
|
|
|
"<urlset><loc>https://a.ca/x</loc><loc>https://a.ca/y</loc></urlset>",
|
|
|
|
|
)
|
|
|
|
|
lst_url, status = smoke.sitemap_urls("http://h")
|
|
|
|
|
self.assertEqual(status, 200)
|
|
|
|
|
self.assertEqual(lst_url, ["http://h/x", "http://h/y"])
|
|
|
|
|
|
|
|
|
|
def test_an_index_is_followed(self):
|
|
|
|
|
# Odoo découpe le sitemap au-delà d'un certain nombre de pages : ne
|
|
|
|
|
# pas descendre d'un cran ferait tester deux fichiers XML au lieu du
|
|
|
|
|
# site, et conclure « tout va bien ».
|
|
|
|
|
self.answers["http://h/sitemap.xml"] = (
|
|
|
|
|
200,
|
|
|
|
|
"<sitemapindex><loc>https://a.ca/sitemap-1-1.xml</loc>"
|
|
|
|
|
"</sitemapindex>",
|
|
|
|
|
)
|
|
|
|
|
self.answers["http://h/sitemap-1-1.xml"] = (
|
|
|
|
|
200,
|
|
|
|
|
"<urlset><loc>https://a.ca/deep</loc></urlset>",
|
|
|
|
|
)
|
|
|
|
|
lst_url, _status = smoke.sitemap_urls("http://h")
|
|
|
|
|
self.assertEqual(lst_url, ["http://h/deep"])
|
|
|
|
|
|
|
|
|
|
def test_duplicates_are_dropped(self):
|
|
|
|
|
self.answers["http://h/sitemap.xml"] = (
|
|
|
|
|
200,
|
|
|
|
|
"<urlset><loc>https://a.ca/x</loc><loc>https://a.ca/x</loc></urlset>",
|
|
|
|
|
)
|
|
|
|
|
lst_url, _status = smoke.sitemap_urls("http://h")
|
|
|
|
|
self.assertEqual(lst_url, ["http://h/x"])
|
|
|
|
|
|
|
|
|
|
def test_an_unreachable_sitemap_is_reported_not_taken_as_empty(self):
|
|
|
|
|
# Sans cela, un sitemap injoignable se lirait comme « aucune page à
|
|
|
|
|
# tester », c'est-à-dire comme un succès.
|
|
|
|
|
lst_url, status = smoke.sitemap_urls("http://h")
|
|
|
|
|
self.assertEqual(lst_url, [])
|
|
|
|
|
self.assertEqual(status, 404)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhatCountsAsAFailure(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.answers = {}
|
|
|
|
|
self.original = smoke.fetch
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
|
|
|
|
|
# `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale.
|
|
|
|
|
# Un faux resté à deux casse chaque appelant sur un « not enough
|
|
|
|
|
# values to unpack » qui n'apprend rien de la panne réelle.
|
|
|
|
|
def faux(url, timeout=30):
|
|
|
|
|
statut, corps = self.answers.get(url, (200, ""))
|
|
|
|
|
return statut, corps, url
|
|
|
|
|
|
|
|
|
|
smoke.fetch = faux
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
self.addCleanup(setattr, smoke, "fetch", self.original)
|
|
|
|
|
|
|
|
|
|
def test_a_500_fails(self):
|
|
|
|
|
self.answers["http://h/bad"] = (500, "")
|
|
|
|
|
self.assertEqual(
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
smoke.check_urls(["http://h/bad"]),
|
|
|
|
|
[("http://h/bad", 500, [], "http://h/bad")],
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_404_fails_too(self):
|
|
|
|
|
# Le sitemap est la liste PUBLIQUE : une page qui y figure et répond
|
|
|
|
|
# 404 est aussi fausse qu'un 500, juste plus discrète.
|
|
|
|
|
self.answers["http://h/gone"] = (404, "")
|
|
|
|
|
self.assertEqual(len(smoke.check_urls(["http://h/gone"])), 1)
|
|
|
|
|
|
|
|
|
|
def test_no_answer_at_all_fails(self):
|
|
|
|
|
self.answers["http://h/dead"] = (0, "")
|
|
|
|
|
self.assertEqual(
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
smoke.check_urls(["http://h/dead"]),
|
|
|
|
|
[("http://h/dead", 0, [], "http://h/dead")],
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_200_passes(self):
|
|
|
|
|
self.assertEqual(smoke.check_urls(["http://h/ok"]), [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_all_green_says_how_many(self):
|
|
|
|
|
text = smoke.render(["a", "b"], [])
|
|
|
|
|
self.assertIn("2", text)
|
|
|
|
|
self.assertIn("✅", text)
|
|
|
|
|
|
|
|
|
|
def test_a_failure_shows_the_status_and_the_url(self):
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
text = smoke.render(
|
|
|
|
|
["a"], [("http://h/blog/x", 500, [], "http://h/blog/x")]
|
|
|
|
|
)
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
self.assertIn("500", text)
|
|
|
|
|
self.assertIn("http://h/blog/x", text)
|
|
|
|
|
|
|
|
|
|
def test_an_empty_sitemap_is_not_a_success(self):
|
|
|
|
|
# « rien testé » ne doit pas se lire « rien de cassé ».
|
|
|
|
|
text = smoke.render([], [])
|
|
|
|
|
self.assertIn("⚠️", text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheServerIsAlwaysStopped(unittest.TestCase):
|
|
|
|
|
"""Un serveur oublié tient le port et fait échouer le palier suivant."""
|
|
|
|
|
|
|
|
|
|
def test_it_is_terminated_even_when_the_sitemap_fails(self):
|
|
|
|
|
stopped = []
|
|
|
|
|
|
|
|
|
|
class FakeServer:
|
|
|
|
|
def terminate(self):
|
|
|
|
|
stopped.append("terminate")
|
|
|
|
|
|
|
|
|
|
def wait(self, timeout=None):
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
def kill(self):
|
|
|
|
|
stopped.append("kill")
|
|
|
|
|
|
|
|
|
|
original_start = smoke.start_server
|
|
|
|
|
original_wait = smoke.wait_ready
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
original_stop = smoke.stop_server
|
|
|
|
|
original_port = smoke.port_is_taken
|
|
|
|
|
smoke.start_server = (
|
|
|
|
|
lambda db, port, cfg="./config.conf", log_path=None: FakeServer()
|
|
|
|
|
)
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
smoke.wait_ready = lambda base, timeout=180, sleep=2: False
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
smoke.stop_server = lambda server: stopped.append("terminate")
|
|
|
|
|
smoke.port_is_taken = lambda port, host="127.0.0.1": False
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
self.addCleanup(setattr, smoke, "start_server", original_start)
|
|
|
|
|
self.addCleanup(setattr, smoke, "wait_ready", original_wait)
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
self.addCleanup(setattr, smoke, "stop_server", original_stop)
|
|
|
|
|
self.addCleanup(setattr, smoke, "port_is_taken", original_port)
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
with self.assertRaises(RuntimeError):
|
|
|
|
|
smoke.run("db", 8169, "./config.conf")
|
|
|
|
|
self.assertEqual(stopped, ["terminate"])
|
|
|
|
|
|
|
|
|
|
def test_a_distinct_port_by_default(self):
|
|
|
|
|
# 8069 est souvent pris par l'instance de travail : le lui voler
|
|
|
|
|
# ferait échouer le test pour une raison sans rapport.
|
|
|
|
|
self.assertNotEqual(smoke.DEFAULT_PORT, 8069)
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
class TestTheCulpritViewsAreNamed(unittest.TestCase):
|
|
|
|
|
"""Détecter sans nommer laisse relever des ids dans une trace à la main."""
|
|
|
|
|
|
|
|
|
|
def test_the_parent_is_read_from_the_error_context(self):
|
|
|
|
|
# C'est le PARENT le coupable : la copie figée dans laquelle
|
|
|
|
|
# l'enfant ne trouve plus son xpath.
|
|
|
|
|
line = "[view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281]"
|
|
|
|
|
self.assertEqual(smoke.RE_CONTEXT.findall(line), [("3282", "3281")])
|
|
|
|
|
|
|
|
|
|
def test_the_context_line_is_not_translated(self):
|
|
|
|
|
# La phrase qui précède l'est, celle-ci non : la lire marche donc
|
|
|
|
|
# sur un système français comme anglais.
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
smoke.RE_CONTEXT.findall("[view_id: 1, parent_id: 2]"),
|
|
|
|
|
[("1", "2")],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_late_context_is_still_attached(self):
|
|
|
|
|
# Odoo vide son tampon à l'arrêt : le journal se lit APRÈS, et rien
|
|
|
|
|
# ne doit dépendre du moment où la ligne est apparue.
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
lst_failure = [("http://h/a", 500, [], "http://h/a")]
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
|
|
|
|
|
rebuilt = smoke.attach_missing_parents(lst_failure, log)
|
[FIX] migration: reset the copy that is actually stale, and say when a key misses
Answering « all » reset one copy of two and reported success. Two
defects behind it, both mine.
A requested key matching no finding did nothing, silently: the tool
returned early on « nothing drifted » and never looked. Detection is
differential — it only sees a copy whose CHILD breaks — so a stale copy
without children escapes it. A key can now be reset outside detection,
an unknown one is named, and the exit code is 2.
And the culprit is not always the parent. On /contactus the parent was
identical to its module view; the CHILD held the stale arch. Both are
proposed now, parent first — it is the more common case.
Measured on the migration: 33 of 33 public URLs answer.
--- FR ---
[FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet
Répondre « toutes » réinitialisait une copie sur deux et annonçait un
succès. Deux défauts derrière, tous deux à moi.
Une clé demandée ne correspondant à aucun constat ne faisait rien, en
silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher.
La détection est différentielle — elle ne voit qu'une copie dont un
ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé
peut désormais être réinitialisée hors détection, une clé inconnue est
nommée, et le code de sortie vaut 2.
Et le coupable n'est pas toujours le parent. Sur /contactus, le parent
était identique à sa vue module ; c'est l'ENFANT qui portait l'arch
périmée. Les deux sont proposés, le parent d'abord — le cas le plus
fréquent.
Mesuré sur la migration : 33 URL publiques sur 33 répondent.
Assisted-by: Claude Opus 5
2026-08-17 04:49:32 -04:00
|
|
|
self.assertIn("2841", rebuilt[0][2])
|
|
|
|
|
|
|
|
|
|
def test_both_the_parent_and_the_child_are_proposed(self):
|
|
|
|
|
# LE défaut mesuré sur /contactus : le parent (3281) était IDENTIQUE
|
|
|
|
|
# à sa vue module, et c'est l'enfant (3282) qui portait l'arch
|
|
|
|
|
# périmée. Ne nommer que le parent envoyait réinitialiser une copie
|
|
|
|
|
# qui allait déjà bien, et la page restait en 500.
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
lst_failure = [("http://h/contactus", 500, [], "http://h/contactus")]
|
[FIX] migration: reset the copy that is actually stale, and say when a key misses
Answering « all » reset one copy of two and reported success. Two
defects behind it, both mine.
A requested key matching no finding did nothing, silently: the tool
returned early on « nothing drifted » and never looked. Detection is
differential — it only sees a copy whose CHILD breaks — so a stale copy
without children escapes it. A key can now be reset outside detection,
an unknown one is named, and the exit code is 2.
And the culprit is not always the parent. On /contactus the parent was
identical to its module view; the CHILD held the stale arch. Both are
proposed now, parent first — it is the more common case.
Measured on the migration: 33 of 33 public URLs answer.
--- FR ---
[FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet
Répondre « toutes » réinitialisait une copie sur deux et annonçait un
succès. Deux défauts derrière, tous deux à moi.
Une clé demandée ne correspondant à aucun constat ne faisait rien, en
silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher.
La détection est différentielle — elle ne voit qu'une copie dont un
ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé
peut désormais être réinitialisée hors détection, une clé inconnue est
nommée, et le code de sortie vaut 2.
Et le coupable n'est pas toujours le parent. Sur /contactus, le parent
était identique à sa vue module ; c'est l'ENFANT qui portait l'arch
périmée. Les deux sont proposés, le parent d'abord — le cas le plus
fréquent.
Mesuré sur la migration : 33 URL publiques sur 33 répondent.
Assisted-by: Claude Opus 5
2026-08-17 04:49:32 -04:00
|
|
|
log = ["[view_id: 3282, model: n/a, parent_id: 3281]"]
|
|
|
|
|
rebuilt = smoke.attach_missing_parents(lst_failure, log)
|
|
|
|
|
self.assertEqual(rebuilt[0][2], ["3281", "3282"])
|
|
|
|
|
|
|
|
|
|
def test_the_parent_comes_first(self):
|
|
|
|
|
# C'est le cas le plus fréquent — le blogue — donc en tête de liste.
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
lst_failure = [("http://h/a", 500, [], "http://h/a")]
|
[FIX] migration: reset the copy that is actually stale, and say when a key misses
Answering « all » reset one copy of two and reported success. Two
defects behind it, both mine.
A requested key matching no finding did nothing, silently: the tool
returned early on « nothing drifted » and never looked. Detection is
differential — it only sees a copy whose CHILD breaks — so a stale copy
without children escapes it. A key can now be reset outside detection,
an unknown one is named, and the exit code is 2.
And the culprit is not always the parent. On /contactus the parent was
identical to its module view; the CHILD held the stale arch. Both are
proposed now, parent first — it is the more common case.
Measured on the migration: 33 of 33 public URLs answer.
--- FR ---
[FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet
Répondre « toutes » réinitialisait une copie sur deux et annonçait un
succès. Deux défauts derrière, tous deux à moi.
Une clé demandée ne correspondant à aucun constat ne faisait rien, en
silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher.
La détection est différentielle — elle ne voit qu'une copie dont un
ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé
peut désormais être réinitialisée hors détection, une clé inconnue est
nommée, et le code de sortie vaut 2.
Et le coupable n'est pas toujours le parent. Sur /contactus, le parent
était identique à sa vue module ; c'est l'ENFANT qui portait l'arch
périmée. Les deux sont proposés, le parent d'abord — le cas le plus
fréquent.
Mesuré sur la migration : 33 URL publiques sur 33 répondent.
Assisted-by: Claude Opus 5
2026-08-17 04:49:32 -04:00
|
|
|
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
|
|
|
|
|
rebuilt = smoke.attach_missing_parents(lst_failure, log)
|
|
|
|
|
self.assertEqual(rebuilt[0][2][0], "2841")
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
|
[FIX] migration: reset the copy that is actually stale, and say when a key misses
Answering « all » reset one copy of two and reported success. Two
defects behind it, both mine.
A requested key matching no finding did nothing, silently: the tool
returned early on « nothing drifted » and never looked. Detection is
differential — it only sees a copy whose CHILD breaks — so a stale copy
without children escapes it. A key can now be reset outside detection,
an unknown one is named, and the exit code is 2.
And the culprit is not always the parent. On /contactus the parent was
identical to its module view; the CHILD held the stale arch. Both are
proposed now, parent first — it is the more common case.
Measured on the migration: 33 of 33 public URLs answer.
--- FR ---
[FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet
Répondre « toutes » réinitialisait une copie sur deux et annonçait un
succès. Deux défauts derrière, tous deux à moi.
Une clé demandée ne correspondant à aucun constat ne faisait rien, en
silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher.
La détection est différentielle — elle ne voit qu'une copie dont un
ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé
peut désormais être réinitialisée hors détection, une clé inconnue est
nommée, et le code de sortie vaut 2.
Et le coupable n'est pas toujours le parent. Sur /contactus, le parent
était identique à sa vue module ; c'est l'ENFANT qui portait l'arch
périmée. Les deux sont proposés, le parent d'abord — le cas le plus
fréquent.
Mesuré sur la migration : 33 URL publiques sur 33 répondent.
Assisted-by: Claude Opus 5
2026-08-17 04:49:32 -04:00
|
|
|
def test_an_already_attributed_id_is_not_duplicated(self):
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
lst_failure = [("http://h/a", 500, ["2841"], "http://h/a")]
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
|
|
|
|
|
rebuilt = smoke.attach_missing_parents(lst_failure, log)
|
[FIX] migration: reset the copy that is actually stale, and say when a key misses
Answering « all » reset one copy of two and reported success. Two
defects behind it, both mine.
A requested key matching no finding did nothing, silently: the tool
returned early on « nothing drifted » and never looked. Detection is
differential — it only sees a copy whose CHILD breaks — so a stale copy
without children escapes it. A key can now be reset outside detection,
an unknown one is named, and the exit code is 2.
And the culprit is not always the parent. On /contactus the parent was
identical to its module view; the CHILD held the stale arch. Both are
proposed now, parent first — it is the more common case.
Measured on the migration: 33 of 33 public URLs answer.
--- FR ---
[FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet
Répondre « toutes » réinitialisait une copie sur deux et annonçait un
succès. Deux défauts derrière, tous deux à moi.
Une clé demandée ne correspondant à aucun constat ne faisait rien, en
silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher.
La détection est différentielle — elle ne voit qu'une copie dont un
ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé
peut désormais être réinitialisée hors détection, une clé inconnue est
nommée, et le code de sortie vaut 2.
Et le coupable n'est pas toujours le parent. Sur /contactus, le parent
était identique à sa vue module ; c'est l'ENFANT qui portait l'arch
périmée. Les deux sont proposés, le parent d'abord — le cas le plus
fréquent.
Mesuré sur la migration : 33 URL publiques sur 33 répondent.
Assisted-by: Claude Opus 5
2026-08-17 04:49:32 -04:00
|
|
|
self.assertEqual(rebuilt[0][2].count("2841"), 1)
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
|
|
|
|
|
|
[FIX] migration: name the template when the failure comes from rendering
A QWebException carries no [view_id … parent_id …] block — it names the
template. The tool only read the inheritance context, so it answered
« no parent view named » on the one failure that hit everything.
Measured at bump 17: a frozen copy of website.submenu still called
submenu.clean_url(), renamed _clean_url() in that version. Every page
renders the menu, so 34 of 37 public URLs returned 500, and nothing
pointed at the view. Resetting that one copy brought all 37 back.
Only keys that actually have a COW copy are proposed: naming one
without would send a reset against a module view, which does nothing.
--- FR ---
[FIX] migration : nommer le gabarit quand l'échec vient du rendu
Une QWebException ne porte pas de bloc [view_id … parent_id …] : elle
nomme le gabarit. L'outil ne lisait que le contexte d'héritage, et
répondait donc « aucune vue parente nommée » sur la seule panne qui
touchait tout.
Mesuré au palier 17 : une copie figée de website.submenu appelait
encore submenu.clean_url(), renommée _clean_url() par la version. Toute
page affiche le menu, donc 34 URL publiques sur 37 rendaient 500, sans
que rien ne désigne la vue. Réinitialiser cette seule copie les a
toutes ramenées.
Seules les clés ayant vraiment une copie COW sont proposées : en nommer
une sans copie enverrait réinitialiser une vue module, donc ne rien
faire.
Assisted-by: Claude Opus 5
2026-08-17 07:59:28 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
class TestTheServerIsKilledForReal(unittest.TestCase):
|
|
|
|
|
"""« ./run.sh » est un script bash : il ne transmet pas les signaux."""
|
|
|
|
|
|
|
|
|
|
def test_it_starts_its_own_process_group(self):
|
|
|
|
|
# Sans cela, terminate() tue l'enveloppe et laisse odoo-bin vivant.
|
|
|
|
|
# Mesuré : six serveurs orphelins, un par essai, et les essais
|
|
|
|
|
# suivants interrogeaient sans le savoir celui d'avant.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(smoke.start_server)
|
|
|
|
|
self.assertIn("start_new_session=True", source)
|
|
|
|
|
|
|
|
|
|
def test_it_kills_the_group_not_just_the_wrapper(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(smoke.stop_server)
|
|
|
|
|
self.assertIn("killpg", source)
|
|
|
|
|
|
|
|
|
|
def test_a_taken_port_is_refused_not_tested(self):
|
|
|
|
|
# Un serveur resté d'un essai précédent répond « prêt » : on
|
|
|
|
|
# testerait sa base à lui en croyant tester la sienne.
|
|
|
|
|
original = smoke.port_is_taken
|
|
|
|
|
smoke.port_is_taken = lambda port, host="127.0.0.1": True
|
|
|
|
|
self.addCleanup(setattr, smoke, "port_is_taken", original)
|
|
|
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
|
|
|
smoke.run("db", 8169, "./config.conf")
|
|
|
|
|
self.assertIn("8169", str(caught.exception))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOfferingTheFix(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"
|
|
|
|
|
self.applied = []
|
|
|
|
|
original = smoke.apply_reset
|
|
|
|
|
smoke.apply_reset = lambda db, keys: (
|
|
|
|
|
self.applied.append(keys),
|
|
|
|
|
(0, "ok"),
|
|
|
|
|
)[1]
|
|
|
|
|
self.addCleanup(setattr, smoke, "apply_reset", original)
|
|
|
|
|
|
|
|
|
|
def run_prompt(self, answer, lst_key=None):
|
|
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
if lst_key is None:
|
|
|
|
|
lst_key = ["website_blog.blog_post_complete", "website_form.x"]
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
done = smoke.prompt(
|
|
|
|
|
"db",
|
[FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.
urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.
A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.
--- FR ---
Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.
urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.
Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.
Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
|
|
|
[("http://h/a", 500, ["2841"], "http://h/a")],
|
[ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-16 06:18:45 -04:00
|
|
|
lst_key,
|
|
|
|
|
ask=lambda prompt: answer,
|
|
|
|
|
)
|
|
|
|
|
return done, out.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_every_key_is_numbered(self):
|
|
|
|
|
_done, text = self.run_prompt("")
|
|
|
|
|
self.assertIn("[1] website_blog.blog_post_complete", text)
|
|
|
|
|
self.assertIn("[a]", text)
|
|
|
|
|
|
|
|
|
|
def test_a_number_applies_only_that_one(self):
|
|
|
|
|
done, _text = self.run_prompt("1")
|
|
|
|
|
self.assertEqual(done, ["website_blog.blog_post_complete"])
|
|
|
|
|
self.assertEqual(self.applied, [["website_blog.blog_post_complete"]])
|
|
|
|
|
|
|
|
|
|
def test_a_applies_them_all(self):
|
|
|
|
|
done, _text = self.run_prompt("a")
|
|
|
|
|
self.assertEqual(len(done), 2)
|
|
|
|
|
|
|
|
|
|
def test_empty_applies_nothing(self):
|
|
|
|
|
done, text = self.run_prompt("")
|
|
|
|
|
self.assertEqual(done, [])
|
|
|
|
|
self.assertEqual(self.applied, [])
|
|
|
|
|
self.assertIn("Kept", text)
|
|
|
|
|
|
|
|
|
|
def test_an_unknown_answer_applies_nothing(self):
|
|
|
|
|
done, text = self.run_prompt("9")
|
|
|
|
|
self.assertEqual(self.applied, [])
|
|
|
|
|
self.assertIn("Unknown choice", text)
|
|
|
|
|
|
|
|
|
|
def test_no_key_found_says_so(self):
|
|
|
|
|
done, text = self.run_prompt("a", lst_key=[])
|
|
|
|
|
self.assertEqual(done, [])
|
|
|
|
|
self.assertIn("nothing to offer", text)
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
class TestTheMigrationOffersIt(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 run_prompt(self, answer):
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
upgrade.dct_progression = {}
|
|
|
|
|
upgrade.lst_command_executed = []
|
|
|
|
|
upgrade.write_config = lambda: None
|
|
|
|
|
lst_cmd = []
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
# Le doublon HONORE le défaut, comme le vrai `ask_gate`.
|
|
|
|
|
upgrade.ask_gate = lambda prompt, default="": answer or default
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
upgrade.prompt_smoke_public_url("db_upgrade_13")
|
|
|
|
|
return lst_cmd
|
|
|
|
|
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
def test_the_default_runs_it(self):
|
|
|
|
|
# Une page cassée qu'on ne demande pas reste cassée. Le coût est
|
|
|
|
|
# quelques minutes de serveur ; le prix de l'ignorer est de
|
|
|
|
|
# découvrir le 500 six paliers plus loin.
|
|
|
|
|
lst_cmd = self.run_prompt("")
|
|
|
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
|
|
|
self.assertIn("smoke_public_url.py", lst_cmd[0])
|
|
|
|
|
|
|
|
|
|
def test_saying_no_still_skips_it(self):
|
|
|
|
|
self.assertEqual(self.run_prompt("n"), [])
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
|
|
|
|
|
def test_yes_runs_it_on_the_upgraded_database(self):
|
|
|
|
|
lst_cmd = self.run_prompt("y")
|
|
|
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
|
|
|
self.assertIn("smoke_public_url.py", lst_cmd[0])
|
|
|
|
|
self.assertIn("-d db_upgrade_13", lst_cmd[0])
|
|
|
|
|
|
2026-08-17 05:13:13 -04:00
|
|
|
def test_it_is_also_asked_before_the_first_bump(self):
|
|
|
|
|
# LA mesure de départ : sans elle, une page qui rendait déjà 500
|
|
|
|
|
# avant la migration se lit comme un dégât du palier, et l'on
|
|
|
|
|
# cherche des heures du mauvais côté.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
self.assertEqual(source.count("prompt_smoke_public_url"), 2)
|
|
|
|
|
premier = source.index("prompt_smoke_public_url")
|
|
|
|
|
self.assertLess(
|
|
|
|
|
premier, source.index("4 - Upgrade version with OpenUpgrade")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_baseline_runs_on_the_database_before_the_bump(self):
|
|
|
|
|
# Sur la base d'AVANT, pas sur une base de palier qui n'existe pas
|
|
|
|
|
# encore : la mesurer après ne dirait plus d'où vient la panne.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
premier = source.index("prompt_smoke_public_url")
|
|
|
|
|
fenetre = source[premier : premier + 90]
|
|
|
|
|
self.assertIn("database_name", fenetre)
|
|
|
|
|
self.assertNotIn("database_name_upgrade", fenetre)
|
|
|
|
|
self.assertIn("baseline=True", fenetre)
|
|
|
|
|
|
|
|
|
|
def test_the_baseline_says_what_it_measures(self):
|
|
|
|
|
# Un même écran à deux moments différents : sans un mot, on croit
|
|
|
|
|
# que la migration a déjà eu lieu.
|
|
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
from script.todo import todo_i18n
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
)
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
upgrade.dct_progression = {}
|
|
|
|
|
upgrade.lst_command_executed = []
|
|
|
|
|
upgrade.write_config = lambda: None
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
upgrade.run_captured = lambda cmd: 0
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
upgrade.ask_gate = lambda prompt, default="": "n"
|
2026-08-17 05:13:13 -04:00
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
upgrade.prompt_smoke_public_url("db", baseline=True)
|
|
|
|
|
self.assertIn("Before starting", out.getvalue())
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
upgrade.prompt_smoke_public_url("db")
|
|
|
|
|
self.assertNotIn("Before starting", out.getvalue())
|
|
|
|
|
|
[ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-16 05:50:00 -04:00
|
|
|
def test_it_is_asked_before_the_selenium_prompt(self):
|
|
|
|
|
# Après, la question arriverait une fois le navigateur ouvert : on
|
|
|
|
|
# aurait déjà cherché à la main ce que le test nomme.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
self.assertLess(
|
|
|
|
|
source.index("prompt_smoke_public_url"),
|
|
|
|
|
source.index("Open the server with Selenium"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|