[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
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
"""L'URL qui a échoué n'est pas toujours celle qu'on a demandée.
|
|
|
|
|
|
|
|
|
|
Sur un site Odoo, chaque page traverse deux ou trois redirections —
|
|
|
|
|
mesuré, 146 pour 55 pages, entre la langue et le slug canonique. Quand
|
|
|
|
|
la DERNIÈRE rend 500, l'outil nommait la première : on allait vérifier
|
|
|
|
|
une page parfaitement saine et l'on concluait que le test se trompait.
|
|
|
|
|
|
|
|
|
|
Et un dépassement de délai ne rend PAS 500 : `fetch` rend 0, que le
|
|
|
|
|
rapport écrit « aucune réponse ». Confondre les deux enverrait chercher
|
|
|
|
|
une lenteur là où le serveur a répondu par une erreur.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import http.server
|
|
|
|
|
import io
|
|
|
|
|
import os
|
|
|
|
|
import socketserver
|
|
|
|
|
import sys
|
|
|
|
|
import threading
|
|
|
|
|
import unittest
|
|
|
|
|
|
|
|
|
|
sys.path.append(
|
|
|
|
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from script.odoo.migration import smoke_public_url as smoke # noqa: E402
|
|
|
|
|
from script.todo import todo_i18n # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Chaine(http.server.BaseHTTPRequestHandler):
|
|
|
|
|
"""/depart → 303 → /milieu → 303 → /fin, qui décide."""
|
|
|
|
|
|
|
|
|
|
fin_status = 500
|
|
|
|
|
|
|
|
|
|
def do_GET(self):
|
|
|
|
|
if self.path == "/depart":
|
|
|
|
|
self.send_response(303)
|
|
|
|
|
self.send_header("Location", "/milieu")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
elif self.path == "/milieu":
|
|
|
|
|
self.send_response(303)
|
|
|
|
|
self.send_header("Location", "/fin")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
elif self.path == "/direct":
|
|
|
|
|
self.send_response(500)
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(b"boum")
|
|
|
|
|
else:
|
|
|
|
|
self.send_response(self.fin_status)
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(b"boum")
|
|
|
|
|
|
|
|
|
|
def log_message(self, *args):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFetchFollowsTheChain(unittest.TestCase):
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.srv = socketserver.TCPServer(("127.0.0.1", 0), Chaine)
|
|
|
|
|
cls.port = cls.srv.server_address[1]
|
|
|
|
|
cls.fil = threading.Thread(target=cls.srv.serve_forever, daemon=True)
|
|
|
|
|
cls.fil.start()
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def tearDownClass(cls):
|
|
|
|
|
cls.srv.shutdown()
|
|
|
|
|
cls.srv.server_close()
|
|
|
|
|
|
|
|
|
|
def url(self, chemin):
|
|
|
|
|
return f"http://127.0.0.1:{self.port}{chemin}"
|
|
|
|
|
|
|
|
|
|
def test_it_reports_the_url_that_actually_failed(self):
|
|
|
|
|
# LE point : la 500 est au bout de la chaîne, pas au départ.
|
|
|
|
|
statut, _corps, finale = smoke.fetch(self.url("/depart"), timeout=5)
|
|
|
|
|
self.assertEqual(statut, 500)
|
|
|
|
|
self.assertTrue(finale.endswith("/fin"), finale)
|
|
|
|
|
|
|
|
|
|
def test_without_a_redirect_both_are_the_same(self):
|
|
|
|
|
statut, _corps, finale = smoke.fetch(self.url("/direct"), timeout=5)
|
|
|
|
|
self.assertEqual(statut, 500)
|
|
|
|
|
self.assertEqual(finale, self.url("/direct"))
|
|
|
|
|
|
|
|
|
|
def test_a_success_also_carries_its_final_url(self):
|
|
|
|
|
Chaine.fin_status = 200
|
|
|
|
|
try:
|
|
|
|
|
statut, _corps, finale = smoke.fetch(
|
|
|
|
|
self.url("/depart"), timeout=5
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(statut, 200)
|
|
|
|
|
self.assertTrue(finale.endswith("/fin"), finale)
|
|
|
|
|
finally:
|
|
|
|
|
Chaine.fin_status = 500
|
|
|
|
|
|
|
|
|
|
def test_a_dead_host_is_zero_NOT_five_hundred(self):
|
|
|
|
|
# C'est ce qui distingue « le serveur a répondu par une erreur »
|
|
|
|
|
# de « il n'a pas répondu ». Les confondre envoie chercher une
|
|
|
|
|
# lenteur là où il y a une exception.
|
|
|
|
|
statut, corps, finale = smoke.fetch(
|
|
|
|
|
"http://127.0.0.1:1/jamais", timeout=1
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(statut, 0)
|
|
|
|
|
self.assertEqual(corps, "")
|
|
|
|
|
self.assertEqual(finale, "http://127.0.0.1:1/jamais")
|
|
|
|
|
|
|
|
|
|
def test_check_urls_keeps_the_final_url(self):
|
|
|
|
|
echecs = smoke.check_urls([self.url("/depart")], timeout=5)
|
|
|
|
|
self.assertEqual(len(echecs), 1)
|
|
|
|
|
url, statut, parents, finale = echecs[0]
|
|
|
|
|
self.assertEqual(url, self.url("/depart"))
|
|
|
|
|
self.assertEqual(statut, 500)
|
|
|
|
|
self.assertEqual(parents, [])
|
|
|
|
|
self.assertTrue(finale.endswith("/fin"))
|
|
|
|
|
|
|
|
|
|
def test_a_page_that_answers_is_not_a_failure(self):
|
|
|
|
|
Chaine.fin_status = 200
|
|
|
|
|
try:
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
smoke.check_urls([self.url("/depart")], timeout=5), []
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
Chaine.fin_status = 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheReport(unittest.TestCase):
|
|
|
|
|
def test_it_names_the_final_url_when_it_differs(self):
|
|
|
|
|
texte = smoke.render(
|
|
|
|
|
["a", "b"], [("http://x/depart", 500, [], "http://x/fin")]
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("http://x/depart", texte)
|
|
|
|
|
self.assertIn(todo_i18n.t("failed at"), texte)
|
|
|
|
|
self.assertIn("http://x/fin", texte)
|
|
|
|
|
|
|
|
|
|
def test_it_stays_quiet_when_they_are_the_same(self):
|
|
|
|
|
# Répéter la même URL sur deux lignes n'apprend rien et allonge
|
|
|
|
|
# un rapport qui peut compter trente-quatre entrées.
|
|
|
|
|
texte = smoke.render(
|
|
|
|
|
["a"], [("http://x/page", 500, [], "http://x/page")]
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn(todo_i18n.t("failed at"), texte)
|
|
|
|
|
|
|
|
|
|
def test_no_answer_is_worded_apart_from_a_status(self):
|
|
|
|
|
texte = smoke.render(
|
|
|
|
|
["a"], [("http://x/page", 0, [], "http://x/page")]
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(todo_i18n.t("no answer"), texte)
|
|
|
|
|
self.assertNotIn("[500]", texte)
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 04:07:19 -04:00
|
|
|
class TestNothingUnpacksTheFailureTupleBlindly(unittest.TestCase):
|
|
|
|
|
"""Ajouter un champ au tuple d'échec a cassé une migration en cours.
|
|
|
|
|
|
|
|
|
|
Le tuple est passé de trois à quatre éléments et deux sites
|
|
|
|
|
dépaquetaient encore trois — `too many values to unpack`, en plein
|
|
|
|
|
milieu, APRÈS la réinitialisation d'une copie COW. Le commentaire
|
|
|
|
|
« TOUJOURS quatre éléments » ne protège de rien : il faut ne pas
|
|
|
|
|
dépaqueter quand on ne veut qu'un champ.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
CHEMIN = os.path.join(
|
|
|
|
|
os.path.dirname(__file__),
|
|
|
|
|
"..",
|
|
|
|
|
"script",
|
|
|
|
|
"odoo",
|
|
|
|
|
"migration",
|
|
|
|
|
"smoke_public_url.py",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def source(self):
|
|
|
|
|
with io.open(self.CHEMIN, encoding="utf-8") as handle:
|
|
|
|
|
return handle.read()
|
|
|
|
|
|
|
|
|
|
def test_no_three_element_unpack_survives(self):
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
motif = re.compile(
|
|
|
|
|
r"for\s+[a-z_]+,\s*[a-z_]+,\s*[a-z_]+\s+in\s+lst_failure"
|
|
|
|
|
)
|
|
|
|
|
trouves = motif.findall(self.source())
|
|
|
|
|
self.assertEqual(trouves, [], f"dépaquetage à trois : {trouves}")
|
|
|
|
|
|
|
|
|
|
def test_taking_only_the_url_uses_an_index(self):
|
|
|
|
|
# Indexer survit au prochain champ ajouté ; dépaqueter non.
|
|
|
|
|
self.assertIn("[echec[0] for echec in lst_failure]", self.source())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRecheckingAfterAReset(unittest.TestCase):
|
|
|
|
|
"""La passe qui a cassé, exercée pour de vrai.
|
|
|
|
|
|
|
|
|
|
Elle ne tournait sous aucun test : c'est pourquoi le dépaquetage à
|
|
|
|
|
trois y a survécu à la suite complète, aux mutations, et n'est
|
|
|
|
|
tombé qu'en production.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.vrais = {
|
|
|
|
|
nom: getattr(smoke, nom)
|
|
|
|
|
for nom in (
|
|
|
|
|
"start_server",
|
|
|
|
|
"wait_ready",
|
|
|
|
|
"check_urls",
|
|
|
|
|
"internal_needs_retry",
|
|
|
|
|
"stop_server",
|
|
|
|
|
)
|
|
|
|
|
if hasattr(smoke, nom)
|
|
|
|
|
}
|
|
|
|
|
self.vus = []
|
|
|
|
|
|
|
|
|
|
class FauxServeur:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.arrete = False
|
|
|
|
|
|
|
|
|
|
smoke.start_server = lambda *a, **k: FauxServeur()
|
|
|
|
|
smoke.wait_ready = lambda *a, **k: True
|
|
|
|
|
smoke.internal_needs_retry = lambda rapport: False
|
|
|
|
|
if hasattr(smoke, "stop_server"):
|
|
|
|
|
smoke.stop_server = lambda *a, **k: None
|
|
|
|
|
|
|
|
|
|
def faux_check(lst_url, timeout=30):
|
|
|
|
|
self.vus.append(list(lst_url))
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
smoke.check_urls = faux_check
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
for nom, valeur in self.vrais.items():
|
|
|
|
|
setattr(smoke, nom, valeur)
|
|
|
|
|
|
|
|
|
|
def test_it_rechecks_exactly_the_urls_that_had_failed(self):
|
|
|
|
|
echecs = [
|
|
|
|
|
("http://h/contactus", 500, ["2837"], "http://h/en/contactus"),
|
|
|
|
|
("http://h/blog", 500, [], "http://h/blog"),
|
|
|
|
|
]
|
|
|
|
|
smoke.recheck_after_reset(
|
|
|
|
|
"db",
|
|
|
|
|
8169,
|
|
|
|
|
"./config.conf",
|
|
|
|
|
"http://h",
|
|
|
|
|
None,
|
|
|
|
|
echecs,
|
|
|
|
|
{"failures": []},
|
|
|
|
|
internal=False,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(self.vus, [["http://h/contactus", "http://h/blog"]])
|
|
|
|
|
|
|
|
|
|
def test_it_rechecks_the_REQUESTED_url_not_the_final_one(self):
|
|
|
|
|
# On revérifie ce que le sitemap publie : c'est cette adresse-là
|
|
|
|
|
# que les visiteurs demandent.
|
|
|
|
|
echecs = [("http://h/a", 500, [], "http://h/z")]
|
|
|
|
|
smoke.recheck_after_reset(
|
|
|
|
|
"db",
|
|
|
|
|
8169,
|
|
|
|
|
"./config.conf",
|
|
|
|
|
"http://h",
|
|
|
|
|
None,
|
|
|
|
|
echecs,
|
|
|
|
|
{"failures": []},
|
|
|
|
|
internal=False,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(self.vus, [["http://h/a"]])
|
|
|
|
|
|
|
|
|
|
def test_an_empty_failure_list_rechecks_nothing(self):
|
|
|
|
|
smoke.recheck_after_reset(
|
|
|
|
|
"db",
|
|
|
|
|
8169,
|
|
|
|
|
"./config.conf",
|
|
|
|
|
"http://h",
|
|
|
|
|
None,
|
|
|
|
|
[],
|
|
|
|
|
{"failures": []},
|
|
|
|
|
internal=False,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(self.vus, [[]])
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
class TestTheLogSurvives(unittest.TestCase):
|
|
|
|
|
def test_the_previous_run_is_kept(self):
|
|
|
|
|
# Le journal était ouvert en « w » : relancer le test effaçait la
|
|
|
|
|
# trace de l'échec qu'on venait de voir.
|
|
|
|
|
with io.open(
|
|
|
|
|
os.path.join(
|
|
|
|
|
os.path.dirname(__file__),
|
|
|
|
|
"..",
|
|
|
|
|
"script",
|
|
|
|
|
"odoo",
|
|
|
|
|
"migration",
|
|
|
|
|
"smoke_public_url.py",
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
) as handle:
|
|
|
|
|
src = handle.read()
|
|
|
|
|
debut = src.index("def start_server")
|
|
|
|
|
fin = src.index("subprocess.Popen", debut)
|
|
|
|
|
self.assertIn("os.replace(log_path, log_path", src[debut:fin])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|