From 25a8d06727d38e13d56aba0ce6888f26b03fe1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Andreatta?= Date: Tue, 28 Apr 2026 00:29:34 +0200 Subject: [PATCH 1/3] [IMP] browsers: provision specific Chrome version for tours This migrates the Chrome provisioning logic from odev-plugin-ai to odev. When running tours (clic_all or tours tags), odev will now automatically: 1. Download a specific Chrome version (145.0.7632.116) if missing. 2. Use a wrapper script with optimized rendering flags for consistency. Assisted-by: gemini-3-flash --- odev/commands/database/test.py | 9 ++++ odev/common/browsers.py | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 odev/common/browsers.py diff --git a/odev/commands/database/test.py b/odev/commands/database/test.py index b5270b36..c9a88a98 100644 --- a/odev/commands/database/test.py +++ b/odev/commands/database/test.py @@ -1,5 +1,6 @@ """Run unit tests on an empty local Odoo database.""" +import os import re from collections import defaultdict from collections.abc import Mapping, MutableMapping @@ -9,6 +10,7 @@ import requests from odev.common import args, string +from odev.common.browsers import Chrome from odev.common.commands import OdoobinCommand from odev.common.console import TableHeader from odev.common.databases import LocalDatabase @@ -193,6 +195,13 @@ def run(self): """Run the command.""" if not self.args.no_auto_tags: self.apply_auto_tags() + + if "clic_all" in self.test_tags or "tours" in self.test_tags: + chrome = Chrome(self.odev) + chrome_bin = chrome.provision() + wrapper = chrome.get_wrapper(chrome_bin) + os.environ["ODOO_BROWSER_BIN"] = str(wrapper) + self.run_test_database() def cleanup(self): diff --git a/odev/common/browsers.py b/odev/common/browsers.py new file mode 100644 index 00000000..2641ca6b --- /dev/null +++ b/odev/common/browsers.py @@ -0,0 +1,93 @@ +"""Browser provisioning utilities.""" + +import shutil +import subprocess +from pathlib import Path + +from odev.common.logging import logging + + +logger = logging.getLogger(__name__) + + +class Chrome: + """Manages Chrome provisioning and wrapper generation.""" + + VERSION = "145.0.7632.116" + + def __init__(self, odev): + self.odev = odev + self.base_path = self.odev.home_path / "browsers" / "chrome" / self.VERSION + # Puppeteer structure: /chrome/linux-/chrome-linux64/chrome + self.executable = self.base_path / "chrome" / f"linux-{self.VERSION}" / "chrome-linux64" / "chrome" + + def provision(self) -> Path | None: + """Ensure the specific version of Chrome is installed. + + :return: Path to the Chrome executable, or None if provisioning failed. + """ + if not self.executable.exists(): + logger.info(f"Provisioning Chrome {self.VERSION} for tours...") + self.base_path.mkdir(parents=True, exist_ok=True) + npx = shutil.which("npx") + if not npx: + logger.warning("npx not found, skipping Chrome provisioning") + return None + + try: + subprocess.run( # noqa: S603 + [ + npx, + "-y", + "@puppeteer/browsers", + "install", + f"chrome@{self.VERSION}", + "--path", + str(self.base_path), + ], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + logger.warning(f"Failed to provision Chrome {self.VERSION}: {e.stderr.decode()}") + return None + except OSError as e: + logger.warning(f"OS error provisioning Chrome {self.VERSION}: {e}") + return None + + return self.executable if self.executable.exists() else None + + def get_wrapper(self, chrome_bin: Path | None = None) -> Path: + """Create a Chrome wrapper script with consistent rendering flags. + + :param chrome_bin: Optional path to the Chrome binary to use. + :return: Path to the generated wrapper script. + """ + tmp_dir = self.odev.home_path / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + wrapper = tmp_dir / "odoo-chrome-wrapper" + + search_bins = "google-chrome chromium chromium-browser google-chrome-stable" + if chrome_bin: + search_bins = f"{chrome_bin} {search_bins}" + + wrapper.write_text( + "#!/bin/bash\n" + f"for bin in {search_bins}; do\n" + ' real=$(command -v "$bin" 2>/dev/null)\n' + ' if [ -n "$real" ]; then\n' + ' exec "$real" \\\n' + " --font-render-hinting=none \\\n" + " --force-device-scale-factor=1 \\\n" + " --disable-font-subpixel-positioning \\\n" + " --hide-scrollbars \\\n" + " --window-size=1366,768 \\\n" + " --no-sandbox \\\n" + ' "$@"\n' + " fi\n" + "done\n" + 'echo "Chrome not found" >&2\n' + "exit 1\n" + ) + wrapper.chmod(0o755) + return wrapper From d15a7ba97a0772606c3d9ed53fb377b0355617e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Andreatta?= Date: Tue, 28 Apr 2026 00:35:11 +0200 Subject: [PATCH 2/3] [FIX] test: support 'click_all' tag for Chrome provisioning --- odev/commands/database/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odev/commands/database/test.py b/odev/commands/database/test.py index c9a88a98..4ad1cd06 100644 --- a/odev/commands/database/test.py +++ b/odev/commands/database/test.py @@ -196,7 +196,7 @@ def run(self): if not self.args.no_auto_tags: self.apply_auto_tags() - if "clic_all" in self.test_tags or "tours" in self.test_tags: + if any(tag in self.test_tags for tag in ["clic_all", "click_all", "tours"]): chrome = Chrome(self.odev) chrome_bin = chrome.provision() wrapper = chrome.get_wrapper(chrome_bin) From 4a4f1c9da26ad5e5ba6052d05f0b38dc074ed667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Andreatta?= Date: Wed, 6 May 2026 10:26:01 +0200 Subject: [PATCH 3/3] [IMP] browsers: fetch Chrome version dynamically from Runbot Dockerfile --- odev/common/browsers.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/odev/common/browsers.py b/odev/common/browsers.py index 2641ca6b..1255cce6 100644 --- a/odev/common/browsers.py +++ b/odev/common/browsers.py @@ -1,9 +1,13 @@ """Browser provisioning utilities.""" +import re import shutil import subprocess +from functools import lru_cache from pathlib import Path +import requests + from odev.common.logging import logging @@ -14,12 +18,35 @@ class Chrome: """Manages Chrome provisioning and wrapper generation.""" VERSION = "145.0.7632.116" + """Fallback version if Runbot fetching fails.""" + + @classmethod + @lru_cache(maxsize=1) + def fetch_version(cls) -> str: + """Fetch the latest Chrome version used by Runbot. + + :return: The Chrome version string (e.g., "145.0.7632.116"). + """ + url = "https://runbot.odoo.com/runbot/dockerfile/tag/odoo:DockerMaster" + try: + logger.debug(f"Fetching Chrome version from {url}") + response = requests.get(url, timeout=10) + response.raise_for_status() + # Parse version from: # Install chrome with values {"chrome_version": "145.0.7632.116-1"} + match = re.search(r'chrome_version": "([\d\.]+)', response.text) + if match: + return match.group(1) + except requests.RequestException as e: + logger.warning(f"Could not fetch Chrome version from Runbot: {e}") + + return cls.VERSION def __init__(self, odev): self.odev = odev - self.base_path = self.odev.home_path / "browsers" / "chrome" / self.VERSION + self.version = self.fetch_version() + self.base_path = self.odev.home_path / "browsers" / "chrome" / self.version # Puppeteer structure: /chrome/linux-/chrome-linux64/chrome - self.executable = self.base_path / "chrome" / f"linux-{self.VERSION}" / "chrome-linux64" / "chrome" + self.executable = self.base_path / "chrome" / f"linux-{self.version}" / "chrome-linux64" / "chrome" def provision(self) -> Path | None: """Ensure the specific version of Chrome is installed. @@ -27,7 +54,7 @@ def provision(self) -> Path | None: :return: Path to the Chrome executable, or None if provisioning failed. """ if not self.executable.exists(): - logger.info(f"Provisioning Chrome {self.VERSION} for tours...") + logger.info(f"Provisioning Chrome {self.version} for tours...") self.base_path.mkdir(parents=True, exist_ok=True) npx = shutil.which("npx") if not npx: @@ -41,7 +68,7 @@ def provision(self) -> Path | None: "-y", "@puppeteer/browsers", "install", - f"chrome@{self.VERSION}", + f"chrome@{self.version}", "--path", str(self.base_path), ], @@ -49,10 +76,10 @@ def provision(self) -> Path | None: capture_output=True, ) except subprocess.CalledProcessError as e: - logger.warning(f"Failed to provision Chrome {self.VERSION}: {e.stderr.decode()}") + logger.warning(f"Failed to provision Chrome {self.version}: {e.stderr.decode()}") return None except OSError as e: - logger.warning(f"OS error provisioning Chrome {self.VERSION}: {e}") + logger.warning(f"OS error provisioning Chrome {self.version}: {e}") return None return self.executable if self.executable.exists() else None