diff --git a/odev/commands/database/test.py b/odev/commands/database/test.py
index b5270b36..4ad1cd06 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 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)
+ 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..1255cce6
--- /dev/null
+++ b/odev/common/browsers.py
@@ -0,0 +1,120 @@
+"""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
+
+
+logger = logging.getLogger(__name__)
+
+
+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.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"
+
+ 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