erplibre/test/test_check_stale_scss.py
Mathieu Benoit f6aa2575c3 [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-29 02:11:04 -04:00

439 lines
16 KiB
Python
Executable file

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un SCSS personnalisé est une copie figée, comme une vue COW.
Personnaliser un site écrit du SCSS dans `ir_attachment`. Cette copie est
figée le jour où elle est écrite et continue d'employer les variables de
CETTE version. Un module peut les renommer d'un palier à l'autre : mesuré,
`website/.../primary_variables.scss` déclarait `$o-theme-font-number` en 12.0
et a remplacé tout le mécanisme en 13.0. Une personnalisation de 2020
demandait encore l'ancien nom, et le bundle s'arrêtait dessus.
Ce qui rend un tel détecteur utilisable n'est pas de trouver — c'est de ne
pas crier à tort. Sur le seul fichier mesuré, la première version rapportait
dix noms dont six n'étaient pas des manques : paramètres de mixin, variables
de boucle, arguments nommés d'`@include`. Ces tests portent surtout là-dessus.
"""
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 check_stale_scss as scss # noqa: E402
class TestWhatCountsAsAUse(unittest.TestCase):
def test_a_plain_dereference_is_a_use(self):
self.assertIn("color", scss.used_names("a { b: $color; }"))
def test_a_declaration_is_not_a_use(self):
self.assertNotIn("color", scss.used_names("$color: red;"))
def test_a_named_include_argument_is_not_a_use(self):
# LE faux positif mesuré : « @include o-position-absolute(
# $right: 50%) » se lisait comme l'usage d'un $right inexistant.
source = "@include o-position-absolute($right: 50%, $left: 50%);"
self.assertEqual(scss.used_names(source), set())
def test_a_default_value_is_not_a_use(self):
self.assertNotIn("size", scss.used_names("$size: 3 !default;"))
def test_the_name_is_not_truncated_to_dodge_the_colon(self):
# Une négation en tête de motif fait rétrograder le nom : le premier
# essai rapportait « $botto » au lieu d'écarter « $bottom ».
names = scss.used_names("a { b: $bottom-margin; }")
self.assertEqual(names, {"bottom-margin"})
def test_a_use_after_a_declaration_still_counts(self):
self.assertEqual(scss.used_names("$a: $b;"), {"b"})
class TestWhatTheFileBindsItself(unittest.TestCase):
def test_mixin_parameters_are_bound(self):
source = "@mixin thing($on, $off: 2) { a: $on; b: $off; }"
self.assertEqual(scss.bound_names(source), {"on", "off"})
def test_function_parameters_are_bound(self):
source = "@function f($value) { @return $value; }"
self.assertIn("value", scss.bound_names(source))
def test_each_loop_variables_are_bound(self):
source = "@each $key, $val in $map { a: $key; }"
bound = scss.bound_names(source)
self.assertIn("key", bound)
self.assertIn("val", bound)
def test_for_loop_variables_are_bound(self):
source = "@for $counter from 1 through 3 { a: $counter; }"
self.assertIn("counter", scss.bound_names(source))
class TestTheUrlIsSplitForTheFix(unittest.TestCase):
"""reset_asset veut le fichier ET le bundle ; l'URL porte les deux."""
def test_a_frontend_customization(self):
base, bundle = scss.split_custom_url(
"/website/static/src/scss/website.custom.web.assets_frontend.scss"
)
self.assertEqual(base, "/website/static/src/scss/website.scss")
self.assertEqual(bundle, "web.assets_frontend")
def test_a_common_customization(self):
base, bundle = scss.split_custom_url(
"/website/static/src/scss/options/colors/"
"user_theme_color_palette.custom.web.assets_common.scss"
)
self.assertTrue(base.endswith("user_theme_color_palette.scss"))
self.assertEqual(bundle, "web.assets_common")
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 finding(self, **override):
data = {
"id": 1110,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["o-theme-font-number"],
"custom": "a { b: $o-theme-font-number; }",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": "odoo13.0/a/b.scss",
"module_content": "a { b: 1; }",
"version_dir": "odoo13.0",
"database": "db",
}
data.update(override)
return data
def test_nothing_at_risk_says_so(self):
text = scss.render([], "db", "odoo13.0")
self.assertIn("✅", text)
def test_a_finding_names_the_attachment_and_the_variable(self):
text = scss.render([self.finding()], "db", "odoo13.0")
self.assertIn("1110", text)
self.assertIn("$o-theme-font-number", text)
def test_it_prints_a_command_that_can_be_pasted(self):
# Un diagnostic sans le geste qui répare oblige à rechercher les deux
# arguments de reset_asset au pire moment.
text = scss.render([self.finding()], "mydb", "odoo13.0")
self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text)
self.assertIn("-d mydb", text)
def test_it_warns_before_dropping(self):
text = scss.render([self.finding()], "db", "odoo13.0")
self.assertIn("real customization", text)
class TestTheDiff(unittest.TestCase):
"""Ce que la copie a changé : la seule chose que réinitialiser perd."""
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 finding(self, **override):
data = {
"id": 1110,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["x"],
"custom": "a { b: 2; }",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": "odoo13.0/a/b.scss",
"module_content": "a { b: 1; }",
"version_dir": "odoo13.0",
"database": "db",
}
data.update(override)
return data
def test_it_counts_what_would_be_lost(self):
text = scss.render_diff(self.finding())
self.assertIn("+1/-1", text)
def test_an_identical_copy_says_it_loses_nothing(self):
text = scss.render_diff(self.finding(custom="a { b: 1; }"))
self.assertIn("identical", text)
def test_a_missing_module_file_is_said_not_guessed(self):
# Si la cible ne livre plus le fichier, il n'y a rien sur quoi
# retomber : le taire ferait accepter une réinitialisation vide.
text = scss.render_diff(self.finding(module_path=None))
self.assertIn("no longer ships", text)
class TestThePrompt(unittest.TestCase):
"""Regarder ne doit pas répondre à la question."""
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 finding(self):
return {
"id": 1,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["x"],
"custom": "a { b: 2; }",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": "odoo13.0/a/b.scss",
"module_content": "a { b: 1; }",
"version_dir": "odoo13.0",
"database": "db",
}
def run_prompt(self, answers):
import contextlib
import io
seq = iter(answers)
out = io.StringIO()
with contextlib.redirect_stdout(out):
wrote = scss.prompt(
[self.finding()], "db", ask=lambda prompt: next(seq)
)
return wrote, out.getvalue()
def test_enter_writes_nothing(self):
wrote, text = self.run_prompt([""])
self.assertFalse(wrote)
self.assertEqual(text, "")
def test_v_shows_the_diff_and_asks_again(self):
# Sans le « asks again », montrer vaudrait réponse : on aurait vu le
# diff et perdu la main sur la décision.
wrote, text = self.run_prompt(["v", ""])
self.assertFalse(wrote)
self.assertIn("+1/-1", text)
def test_apply_saves_a_copy_before_writing(self):
# reset_asset SUPPRIME la pièce jointe : sans sauvegarde préalable,
# les lignes personnalisées ne seraient plus nulle part.
import os
import tempfile
previous = os.getcwd()
self.addCleanup(os.chdir, previous)
os.chdir(tempfile.mkdtemp())
seen = {}
original = scss.apply_reset
scss.apply_reset = lambda lst, db, cfg="./config.conf": (
seen.update(called=True),
(0, "ok"),
)[1]
self.addCleanup(setattr, scss, "apply_reset", original)
wrote, text = self.run_prompt(["a"])
self.assertTrue(wrote)
self.assertTrue(seen.get("called"))
import glob
saved = glob.glob("private/odoo/migration/db/scss_backup/*")
self.assertEqual(len(saved), 1)
with open(saved[0]) as handle:
self.assertEqual(handle.read(), "a { b: 2; }")
def test_a_failed_reset_says_nothing_changed(self):
import os
import tempfile
previous = os.getcwd()
self.addCleanup(os.chdir, previous)
os.chdir(tempfile.mkdtemp())
original = scss.apply_reset
scss.apply_reset = lambda lst, db, cfg="./config.conf": (1, "boom")
self.addCleanup(setattr, scss, "apply_reset", original)
wrote, text = self.run_prompt(["a"])
self.assertFalse(wrote)
self.assertIn("nothing was changed", text)
class TestTheMigrationRunsIt(unittest.TestCase):
def test_it_is_checked_at_the_same_place_as_the_cow_views(self):
# Les deux prédictions valent avant le palier, pas après : c'est le
# seul moment où l'on peut encore arbitrer sans avoir tout rejoué.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertIn("check_stale_scss.py", source)
self.assertLess(
source.index("check_stale_scss.py"),
source.index("4 - Upgrade version with OpenUpgrade"),
)
def test_no_call_goes_through_the_piped_executor(self):
# L'outil pose lui-même ses questions : un TUBE les rendrait
# injoignables, sans rien signaler — Python met sa sortie en
# tampon par blocs et l'invite reste invisible.
#
# Les DEUX voies terminal conviennent : `run_on_terminal` laisse
# le vrai terminal, `run_captured` en fabrique un et garde une
# copie. Seul `todo_upgrade_execute` bufferise.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
depart = 0
vus = 0
while True:
rang = source.find("check_stale_scss.py", depart)
if rang < 0:
break
vus += 1
avant = source[:rang]
# L'appel le plus proche EN AMONT : c'est lui qui exécute.
terminal = max(
avant.rfind("run_on_terminal("), avant.rfind("run_captured(")
)
self.assertGreater(
terminal,
avant.rfind("todo_upgrade_execute("),
"l'outil repasse par l'exécuteur à tube",
)
depart = rang + 1
self.assertEqual(2, vus)
class TestTheFixCannotRunTooEarly(unittest.TestCase):
"""Corriger exige la version d'ARRIVÉE, pas celle de départ.
Mesuré sur une vraie migration : la question a été posée avant le palier,
alors que le checkout était encore sur odoo12.0. Répondre « a » a lancé
reset_asset dans un shell Odoo 12, qui ne connaît pas `web_editor.assets`
— KeyError, rien de modifié, et la migration a continué jusqu'à casser au
palier suivant.
Prédire tôt reste juste. C'est corriger tôt qui ne l'est pas.
"""
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_a_version_without_reset_asset_is_refused(self):
import os
import tempfile
root = tempfile.mkdtemp()
old = os.path.join(root, "odoo12.0", "addons", "web_editor", "models")
os.makedirs(old)
with open(os.path.join(old, "assets.py"), "w") as handle:
handle.write("class Assets:\n pass\n")
self.assertFalse(scss.reset_supported(os.path.join(root, "odoo12.0")))
def test_a_version_with_it_is_allowed(self):
import os
import tempfile
root = tempfile.mkdtemp()
new = os.path.join(root, "odoo13.0", "addons", "web_editor", "models")
os.makedirs(new)
with open(os.path.join(new, "assets.py"), "w") as handle:
handle.write("def reset_asset(self, url, bundle):\n pass\n")
self.assertTrue(scss.reset_supported(os.path.join(root, "odoo13.0")))
def test_an_unknown_checkout_does_not_block(self):
# Rien pour trancher : refuser sur une supposition empêcherait de
# corriger là où c'est possible.
self.assertTrue(scss.reset_supported("odoo_no_such_dir_zz"))
def test_the_prompt_hides_the_fix_when_it_cannot_work(self):
# Offrir un choix qui échouera, c'est le faire prendre.
import contextlib
import io
original = scss.reset_supported
scss.reset_supported = lambda odoo_dir=None: False
self.addCleanup(setattr, scss, "reset_supported", original)
finding = {
"id": 1,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["x"],
"custom": "a",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": None,
"module_content": "",
"version_dir": "odoo13.0",
"database": "db",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
wrote = scss.prompt([finding], "db", ask=lambda p: "a")
self.assertFalse(wrote)
self.assertIn("KeyError", out.getvalue())
self.assertNotIn("a = reset", out.getvalue())
class TestTheMigrationAsksAtTheRightMoment(unittest.TestCase):
"""Prédire avant le palier, corriger après — jamais l'inverse."""
def source(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
def test_the_early_call_cannot_offer_the_fix(self):
source = self.source()
premier = source.index("check_stale_scss.py")
fenetre = source[premier : premier + 400]
self.assertIn("--report-only", fenetre)
def test_a_second_call_comes_after_the_bump(self):
# Sans elle, la prédiction n'aurait jamais de suite : on saurait ce
# qui va casser sans jamais pouvoir le réparer.
source = self.source()
self.assertEqual(source.count("check_stale_scss.py"), 2)
second = source.rindex("check_stale_scss.py")
self.assertGreater(second, source.index("state_4_upgrade_odoo_lst"))
def test_the_second_call_targets_the_upgraded_database(self):
source = self.source()
second = source.rindex("check_stale_scss.py")
fenetre = source[second : second + 300]
self.assertIn("database_name_upgrade", fenetre)
self.assertNotIn("--report-only", fenetre)
class TestThePromptStaysOutOfAPipe(unittest.TestCase):
def test_it_only_asks_in_front_of_a_terminal(self):
# Une invite dans un tube bloquerait l'appelant sur une question que
# personne ne voit.
with open(scss.__file__) as handle:
source = handle.read()
self.assertIn("sys.stdin.isatty()", source)
if __name__ == "__main__":
unittest.main()