From 332926519d1edb8f75bb8db9ae47331603ffcd91 Mon Sep 17 00:00:00 2001 From: Jens Bergmann <1742418+1cu@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:19:50 +0200 Subject: [PATCH] feat: chrome version detection clean (#607) --- README.md | 2 + docs/BROWSER_TROUBLESHOOTING.md | 13 + .../resources/translations.de.yaml | 36 +- .../utils/chrome_version_detector.py | 242 +++++++++ .../utils/web_scraping_mixin.py | 132 ++++- tests/unit/test_chrome_version_detector.py | 389 +++++++++++++ tests/unit/test_web_scraping_mixin.py | 13 - .../test_web_scraping_mixin_chrome_version.py | 510 ++++++++++++++++++ 8 files changed, 1304 insertions(+), 33 deletions(-) create mode 100644 src/kleinanzeigen_bot/utils/chrome_version_detector.py create mode 100644 tests/unit/test_chrome_version_detector.py create mode 100644 tests/unit/test_web_scraping_mixin_chrome_version.py diff --git a/README.md b/README.md index 5c68876..3899c87 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,8 @@ By default a new browser process will be launched. To reuse a manually launched user_data_dir: "/path/to/custom/directory" ``` + **The bot will automatically detect Chrome 136+ and validate your configuration. If validation fails, you'll see clear error messages with specific instructions on how to fix your configuration.** + 1. In your config.yaml specify the same flags as browser arguments, e.g.: ```yaml browser: diff --git a/docs/BROWSER_TROUBLESHOOTING.md b/docs/BROWSER_TROUBLESHOOTING.md index 06e1d5f..9ddc328 100644 --- a/docs/BROWSER_TROUBLESHOOTING.md +++ b/docs/BROWSER_TROUBLESHOOTING.md @@ -23,6 +23,8 @@ browser: user_data_dir: "/tmp/chrome-debug-profile" # Must match the argument above ``` +**The bot will automatically detect Chrome 136+ and provide clear error messages if your configuration is missing the required `--user-data-dir` setting.** + For more details, see [Chrome 136+ Security Changes](#5-chrome-136-security-changes-march-2025) below. ## Quick Diagnosis @@ -45,6 +47,17 @@ This will check: - Remote debugging port status - Running browser processes - Platform-specific issues +- **Chrome/Edge version detection and configuration validation** + +**Automatic Chrome 136+ Validation:** +The bot automatically detects Chrome/Edge 136+ and validates your configuration. If you're using Chrome 136+ with remote debugging but missing the required `--user-data-dir` setting, you'll see clear error messages like: + +``` +Chrome 136+ configuration validation failed: Chrome 136+ requires --user-data-dir +Please update your configuration to include --user-data-dir for remote debugging +``` + +The bot will also provide specific instructions on how to fix your configuration. ## Common Issues and Solutions diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 25c31bc..bf849ee 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -440,14 +440,34 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py: "(ok) Remote debugging API accessible - Browser: %s": "(ok) Remote-Debugging-API zugänglich - Browser: %s" "(fail) Remote debugging port is open but API not accessible: %s": "(Fehler) Remote-Debugging-Port ist offen, aber API nicht zugänglich: %s" " This might indicate a browser update issue or configuration problem": " Dies könnte auf ein Browser-Update-Problem oder Konfigurationsproblem hinweisen" - ? " On macOS, try: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug-profile --disable-dev-shm-usage" - : " Unter macOS versuchen Sie: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug-profile --disable-dev-shm-usage" - ? " Or: open -a \"Google Chrome\" --args --remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug-profile --disable-dev-shm-usage" - : " Oder: open -a \"Google Chrome\" --args --remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug-profile --disable-dev-shm-usage" - " IMPORTANT: --user-data-dir is MANDATORY for macOS Chrome remote debugging": " WARNUNG: --user-data-dir ist PFLICHT für macOS Chrome Remote-Debugging" - " IMPORTANT: macOS Chrome remote debugging requires --user-data-dir flag": " WARNUNG: macOS Chrome Remote-Debugging erfordert --user-data-dir Flag" - " Add to your config.yaml: user_data_dir: \"/tmp/chrome-debug-profile\"": " Fügen Sie zu Ihrer config.yaml hinzu: user_data_dir: \"/tmp/chrome-debug-profile\"" - " And to browser arguments: --user-data-dir=/tmp/chrome-debug-profile": " Und zu Browser-Argumenten: --user-data-dir=/tmp/chrome-debug-profile" + + + + + _validate_chrome_version_configuration: + " -> %s 136+ detected: %s": " -> %s 136+ erkannt: %s" + " -> %s 136+ configuration validation passed": " -> %s 136+ Konfigurationsvalidierung bestanden" + " -> %s 136+ configuration validation failed: %s": " -> %s 136+ Konfigurationsvalidierung fehlgeschlagen: %s" + " -> %s version detected: %s (pre-136, no special validation required)": " -> %s-Version erkannt: %s (vor 136, keine besondere Validierung erforderlich)" + " -> Please update your configuration to include --user-data-dir for remote debugging": " -> Bitte aktualisieren Sie Ihre Konfiguration, um --user-data-dir für Remote-Debugging einzuschließen" + " -> Skipping browser version validation in test environment": " -> Browser-Versionsvalidierung in Testumgebung wird übersprungen" + " -> Browser version detection failed, skipping validation: %s": " -> Browser-Versionserkennung fehlgeschlagen, Validierung wird übersprungen: %s" + " -> Unexpected error during browser version validation, skipping: %s": " -> Unerwarteter Fehler bei Browser-Versionsvalidierung, wird übersprungen: %s" + + _diagnose_chrome_version_issues: + "(info) %s version from binary: %s %s (major: %d)": "(Info) %s-Version von Binärdatei: %s %s (Hauptversion: %d)" + "(info) %s version from remote debugging: %s %s (major: %d)": "(Info) %s-Version von Remote-Debugging: %s %s (Hauptversion: %d)" + "(info) %s 136+ detected - security validation required": "(Info) %s 136+ erkannt - Sicherheitsvalidierung erforderlich" + "(info) %s pre-136 detected - no special security requirements": "(Info) %s vor 136 erkannt - keine besonderen Sicherheitsanforderungen" + "(info) Remote %s 136+ detected - validating configuration": "(Info) Remote %s 136+ erkannt - validiere Konfiguration" + "(fail) %s 136+ configuration validation failed: %s": "(Fehler) %s 136+ Konfigurationsvalidierung fehlgeschlagen: %s" + "(ok) %s 136+ configuration validation passed": "(Ok) %s 136+ Konfigurationsvalidierung bestanden" + "(info) Chrome/Edge 136+ security changes require --user-data-dir for remote debugging": "(Info) Chrome/Edge 136+ Sicherheitsänderungen erfordern --user-data-dir für Remote-Debugging" + " See: https://developer.chrome.com/blog/remote-debugging-port": " Siehe: https://developer.chrome.com/blog/remote-debugging-port" + " -> Browser version diagnostics failed: %s": " -> Browser-Versionsdiagnose fehlgeschlagen: %s" + " -> Unexpected error during browser version diagnostics: %s": " -> Unerwarteter Fehler bei Browser-Versionsdiagnose: %s" + " Solution: Add --user-data-dir=/path/to/directory to browser arguments": " Lösung: Fügen Sie --user-data-dir=/pfad/zum/verzeichnis zu Browser-Argumenten hinzu" + " And user_data_dir: \"/path/to/directory\" to your configuration": " Und user_data_dir: \"/pfad/zum/verzeichnis\" zu Ihrer Konfiguration" ################################################# kleinanzeigen_bot/update_checker.py: diff --git a/src/kleinanzeigen_bot/utils/chrome_version_detector.py b/src/kleinanzeigen_bot/utils/chrome_version_detector.py new file mode 100644 index 0000000..ef91fc2 --- /dev/null +++ b/src/kleinanzeigen_bot/utils/chrome_version_detector.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +import json +import re +import subprocess # noqa: S404 +import urllib.request +from typing import Any, Final + +from . import loggers + +LOG:Final[loggers.Logger] = loggers.get_logger(__name__) + +# Chrome 136 was released in March 2025 and introduced security changes +CHROME_136_VERSION = 136 + + +class ChromeVersionInfo: + """Information about a Chrome browser version.""" + + def __init__(self, version_string:str, major_version:int, browser_name:str = "Unknown") -> None: + self.version_string = version_string + self.major_version = major_version + self.browser_name = browser_name + + @property + def is_chrome_136_plus(self) -> bool: + """Check if this is Chrome version 136 or later.""" + return self.major_version >= CHROME_136_VERSION + + def __str__(self) -> str: + return f"{self.browser_name} {self.version_string} (major: {self.major_version})" + + +def parse_version_string(version_string:str) -> int: + """ + Parse a Chrome version string and extract the major version number. + + Args: + version_string: Version string like "136.0.6778.0" or "136.0.6778.0 (Developer Build)" + + Returns: + Major version number (e.g., 136) + + Raises: + ValueError: If version string cannot be parsed + """ + # Extract version number from strings like: + # "136.0.6778.0" + # "136.0.6778.0 (Developer Build)" + # "136.0.6778.0 (Official Build) (x86_64)" + # "Google Chrome 136.0.6778.0" + # "Microsoft Edge 136.0.6778.0" + # "Chromium 136.0.6778.0" + match = re.search(r"(\d+)\.\d+\.\d+\.\d+", version_string) + if not match: + raise ValueError(f"Could not parse version string: {version_string}") + + return int(match.group(1)) + + +def detect_chrome_version_from_binary(binary_path:str) -> ChromeVersionInfo | None: + """ + Detect Chrome version by running the browser binary. + + Args: + binary_path: Path to the Chrome binary + + Returns: + ChromeVersionInfo if successful, None if detection fails + """ + try: + # Run browser with --version flag + result = subprocess.run( # noqa: S603 + [binary_path, "--version"], + check = False, capture_output = True, + text = True, + timeout = 10 + ) + + if result.returncode != 0: + LOG.debug("Browser version command failed: %s", result.stderr) + return None + + output = result.stdout.strip() + major_version = parse_version_string(output) + + # Extract just the version number for version_string + version_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", output) + version_string = version_match.group(1) if version_match else output + + # Determine browser name from binary path + browser_name = "Chrome" + if "edge" in binary_path.lower(): + browser_name = "Edge" + elif "chromium" in binary_path.lower(): + browser_name = "Chromium" + + return ChromeVersionInfo(version_string, major_version, browser_name) + + except subprocess.TimeoutExpired: + LOG.debug("Browser version command timed out") + return None + except (subprocess.SubprocessError, ValueError) as e: + LOG.debug("Failed to detect browser version: %s", str(e)) + return None + + +def detect_chrome_version_from_remote_debugging(host:str = "127.0.0.1", port:int = 9222) -> ChromeVersionInfo | None: + """ + Detect Chrome version from remote debugging API. + + Args: + host: Remote debugging host + port: Remote debugging port + + Returns: + ChromeVersionInfo if successful, None if detection fails + """ + try: + # Query the remote debugging API + url = f"http://{host}:{port}/json/version" + response = urllib.request.urlopen(url, timeout = 5) # noqa: S310 + version_data = json.loads(response.read().decode()) + + # Extract version information + user_agent = version_data.get("User-Agent", "") + browser_name = version_data.get("Browser", "Unknown") + + # Parse version from User-Agent string + # Example: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.6778.0 Safari/537.36" + match = re.search(r"Chrome/(\d+)\.\d+\.\d+\.\d+", user_agent) + if not match: + LOG.debug("Could not parse Chrome version from User-Agent: %s", user_agent) + return None + + major_version = int(match.group(1)) + version_string = match.group(0).replace("Chrome/", "") + + return ChromeVersionInfo(version_string, major_version, browser_name) + + except Exception as e: + LOG.debug("Failed to detect browser version from remote debugging: %s", str(e)) + return None + + +def validate_chrome_136_configuration(browser_arguments:list[str], user_data_dir:str | None) -> tuple[bool, str]: + """ + Validate configuration for Chrome/Edge 136+ security requirements. + + Chrome/Edge 136+ requires --user-data-dir to be specified when using --remote-debugging-port. + + Args: + browser_arguments: List of browser arguments + user_data_dir: User data directory configuration + + Returns: + Tuple of (is_valid, error_message) + """ + # Check if remote debugging is enabled + has_remote_debugging = any( + arg.startswith("--remote-debugging-port=") + for arg in browser_arguments + ) + + if not has_remote_debugging: + return True, "" # No remote debugging, no validation needed + + # Check if user-data-dir is specified in arguments + has_user_data_dir_arg = any( + arg.startswith("--user-data-dir=") + for arg in browser_arguments + ) + + # Check if user_data_dir is configured + has_user_data_dir_config = user_data_dir is not None and user_data_dir.strip() + + if not has_user_data_dir_arg and not has_user_data_dir_config: + return False, ( + "Chrome/Edge 136+ requires --user-data-dir to be specified when using --remote-debugging-port. " + "Add --user-data-dir=/path/to/directory to your browser arguments and " + 'user_data_dir: "/path/to/directory" to your configuration.' + ) + + return True, "" + + +def get_chrome_version_diagnostic_info( + binary_path:str | None = None, + remote_host:str = "127.0.0.1", + remote_port:int | None = None +) -> dict[str, Any]: + """ + Get comprehensive Chrome version diagnostic information. + + Args: + binary_path: Path to Chrome binary (optional) + remote_host: Remote debugging host + remote_port: Remote debugging port (optional) + + Returns: + Dictionary with diagnostic information + """ + diagnostic_info:dict[str, Any] = { + "binary_detection": None, + "remote_detection": None, + "chrome_136_plus_detected": False, + "configuration_valid": True, + "recommendations": [] + } + + # Try binary detection + if binary_path: + version_info = detect_chrome_version_from_binary(binary_path) + if version_info: + diagnostic_info["binary_detection"] = { + "version_string": version_info.version_string, + "major_version": version_info.major_version, + "browser_name": version_info.browser_name, + "is_chrome_136_plus": version_info.is_chrome_136_plus + } + diagnostic_info["chrome_136_plus_detected"] = version_info.is_chrome_136_plus + + # Try remote debugging detection + if remote_port: + version_info = detect_chrome_version_from_remote_debugging(remote_host, remote_port) + if version_info: + diagnostic_info["remote_detection"] = { + "version_string": version_info.version_string, + "major_version": version_info.major_version, + "browser_name": version_info.browser_name, + "is_chrome_136_plus": version_info.is_chrome_136_plus + } + diagnostic_info["chrome_136_plus_detected"] = version_info.is_chrome_136_plus + + # Add recommendations based on detected version + if diagnostic_info["chrome_136_plus_detected"]: + diagnostic_info["recommendations"].append( + "Chrome 136+ detected - ensure --user-data-dir is configured for remote debugging" + ) + + return diagnostic_info diff --git a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py index 7d30360..2c89bab 100644 --- a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py +++ b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: © Sebastian Thomschke and contributors # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ -import asyncio, enum, inspect, json, os, platform, secrets, shutil, urllib.request # isort: skip +import asyncio, enum, inspect, json, os, platform, secrets, shutil, subprocess, urllib.request # isort: skip # noqa: S404 from collections.abc import Callable, Coroutine, Iterable from gettext import gettext as _ from typing import Any, Final, cast @@ -18,6 +18,11 @@ from nodriver.core.element import Element from nodriver.core.tab import Tab as Page from . import loggers, net +from .chrome_version_detector import ( + detect_chrome_version_from_binary, + get_chrome_version_diagnostic_info, + validate_chrome_136_configuration, +) from .misc import T, ensure __all__ = [ @@ -91,6 +96,9 @@ class WebScrapingMixin: self.browser_config.binary_location = self.get_compatible_browser() LOG.info(" -> Browser binary location: %s", self.browser_config.binary_location) + # Chrome version detection and validation + await self._validate_chrome_version_configuration() + ######################################################## # check if an existing browser instance shall be used... ######################################################## @@ -299,6 +307,8 @@ class WebScrapingMixin: browser_path = self.get_compatible_browser() if browser_path: LOG.info("(ok) Auto-detected browser: %s", browser_path) + # Set the binary location for Chrome version detection + self.browser_config.binary_location = browser_path else: LOG.error("(fail) No compatible browser found") @@ -335,12 +345,6 @@ class WebScrapingMixin: else: LOG.error("(fail) Remote debugging port is not open") LOG.info(" Make sure browser is started with: --remote-debugging-port=%d", remote_port) - if platform.system() == "Darwin": - LOG.info(" On macOS, try: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome " - "--remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug-profile --disable-dev-shm-usage", remote_port) - LOG.info(' Or: open -a "Google Chrome" --args --remote-debugging-port=%d ' - '--user-data-dir=/tmp/chrome-debug-profile --disable-dev-shm-usage', remote_port) - LOG.info(" IMPORTANT: --user-data-dir is MANDATORY for macOS Chrome remote debugging") # Check for running browser processes browser_processes = [] @@ -363,16 +367,14 @@ class WebScrapingMixin: LOG.info("(info) Windows detected - check Windows Defender and antivirus software") elif platform.system() == "Darwin": LOG.info("(info) macOS detected - check Gatekeeper and security settings") - # Check for macOS-specific Chrome remote debugging requirements - if remote_port > 0 and not self.browser_config.user_data_dir: - LOG.warning(" IMPORTANT: macOS Chrome remote debugging requires --user-data-dir flag") - LOG.info(' Add to your config.yaml: user_data_dir: "/tmp/chrome-debug-profile"') - LOG.info(" And to browser arguments: --user-data-dir=/tmp/chrome-debug-profile") elif platform.system() == "Linux": LOG.info("(info) Linux detected - check if running as root (not recommended)") if _is_admin(): LOG.error("(fail) Running as root - this can cause browser connection issues") + # Chrome version detection and validation + self._diagnose_chrome_version_issues(remote_port) + LOG.info("=== End Diagnostics ===") def close_browser_session(self) -> None: @@ -744,3 +746,109 @@ class WebScrapingMixin: """) await self.web_sleep() return elem + + async def _validate_chrome_version_configuration(self) -> None: + """ + Validate Chrome version configuration for Chrome 136+ security requirements. + + This method checks if the browser is Chrome 136+ and validates that the configuration + meets the security requirements for remote debugging. + """ + # Skip validation in test environments to avoid subprocess calls + if os.environ.get("PYTEST_CURRENT_TEST"): + LOG.debug(" -> Skipping browser version validation in test environment") + return + + try: + # Detect Chrome version from binary + binary_path = self.browser_config.binary_location + version_info = detect_chrome_version_from_binary(binary_path) if binary_path else None + + if version_info and version_info.is_chrome_136_plus: + LOG.info(" -> %s 136+ detected: %s", version_info.browser_name, version_info) + + # Validate configuration for Chrome/Edge 136+ + is_valid, error_message = validate_chrome_136_configuration( + list(self.browser_config.arguments), + self.browser_config.user_data_dir + ) + + if not is_valid: + LOG.error(" -> %s 136+ configuration validation failed: %s", version_info.browser_name, error_message) + LOG.error(" -> Please update your configuration to include --user-data-dir for remote debugging") + raise AssertionError(error_message) + LOG.info(" -> %s 136+ configuration validation passed", version_info.browser_name) + elif version_info: + LOG.info(" -> %s version detected: %s (pre-136, no special validation required)", version_info.browser_name, version_info) + else: + LOG.debug(" -> Could not detect browser version, skipping validation") + except (subprocess.SubprocessError, OSError, FileNotFoundError) as e: + LOG.warning(" -> Browser version detection failed, skipping validation: %s", e) + # Continue without validation rather than failing + except Exception as e: + LOG.warning(" -> Unexpected error during browser version validation, skipping: %s", e) + # Continue without validation rather than failing + + def _diagnose_chrome_version_issues(self, remote_port:int) -> None: + """ + Diagnose Chrome version issues and provide specific recommendations. + + Args: + remote_port: Remote debugging port (0 if not configured) + """ + # Skip diagnostics in test environments to avoid subprocess calls + if os.environ.get("PYTEST_CURRENT_TEST"): + LOG.debug(" -> Skipping browser version diagnostics in test environment") + return + + try: + # Get diagnostic information + binary_path = self.browser_config.binary_location + diagnostic_info = get_chrome_version_diagnostic_info( + binary_path = binary_path, + remote_port = remote_port if remote_port > 0 else None + ) + + # Report binary detection results + if diagnostic_info["binary_detection"]: + binary_info = diagnostic_info["binary_detection"] + LOG.info("(info) %s version from binary: %s %s (major: %d)", + binary_info["browser_name"], binary_info["browser_name"], binary_info["version_string"], binary_info["major_version"]) + + if binary_info["is_chrome_136_plus"]: + LOG.info("(info) %s 136+ detected - security validation required", binary_info["browser_name"]) + else: + LOG.info("(info) %s pre-136 detected - no special security requirements", binary_info["browser_name"]) + + # Report remote detection results + if diagnostic_info["remote_detection"]: + remote_info = diagnostic_info["remote_detection"] + LOG.info("(info) %s version from remote debugging: %s %s (major: %d)", + remote_info["browser_name"], remote_info["browser_name"], remote_info["version_string"], remote_info["major_version"]) + + if remote_info["is_chrome_136_plus"]: + LOG.info("(info) Remote %s 136+ detected - validating configuration", remote_info["browser_name"]) + + # Validate configuration for Chrome/Edge 136+ + is_valid, error_message = validate_chrome_136_configuration( + list(self.browser_config.arguments), + self.browser_config.user_data_dir + ) + + if not is_valid: + LOG.error("(fail) %s 136+ configuration validation failed: %s", remote_info["browser_name"], error_message) + LOG.info(" Solution: Add --user-data-dir=/path/to/directory to browser arguments") + LOG.info(' And user_data_dir: "/path/to/directory" to your configuration') + else: + LOG.info("(ok) %s 136+ configuration validation passed", remote_info["browser_name"]) + + # Add general recommendations + if diagnostic_info["chrome_136_plus_detected"]: + LOG.info("(info) Chrome/Edge 136+ security changes require --user-data-dir for remote debugging") + LOG.info(" See: https://developer.chrome.com/blog/remote-debugging-port") + except (subprocess.SubprocessError, OSError, FileNotFoundError) as e: + LOG.warning(" -> Browser version diagnostics failed: %s", e) + # Continue without diagnostics rather than failing + except Exception as e: + LOG.warning(" -> Unexpected error during browser version diagnostics: %s", e) + # Continue without diagnostics rather than failing diff --git a/tests/unit/test_chrome_version_detector.py b/tests/unit/test_chrome_version_detector.py new file mode 100644 index 0000000..7cac59c --- /dev/null +++ b/tests/unit/test_chrome_version_detector.py @@ -0,0 +1,389 @@ +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +import json +import subprocess # noqa: S404 +from unittest.mock import Mock, patch + +import pytest + +from kleinanzeigen_bot.utils.chrome_version_detector import ( + ChromeVersionInfo, + detect_chrome_version_from_binary, + detect_chrome_version_from_remote_debugging, + get_chrome_version_diagnostic_info, + parse_version_string, + validate_chrome_136_configuration, +) + + +class TestParseVersionString: + """Test version string parsing functionality.""" + + def test_parse_version_string_basic(self) -> None: + """Test parsing basic version string.""" + version = parse_version_string("136.0.6778.0") + assert version == 136 + + def test_parse_version_string_with_build_info(self) -> None: + """Test parsing version string with build information.""" + version = parse_version_string("136.0.6778.0 (Developer Build)") + assert version == 136 + + def test_parse_version_string_with_architecture(self) -> None: + """Test parsing version string with architecture information.""" + version = parse_version_string("136.0.6778.0 (Official Build) (x86_64)") + assert version == 136 + + def test_parse_version_string_older_version(self) -> None: + """Test parsing older Chrome version.""" + version = parse_version_string("120.0.6099.109") + assert version == 120 + + def test_parse_version_string_invalid_format(self) -> None: + """Test parsing invalid version string raises ValueError.""" + with pytest.raises(ValueError, match = "Could not parse version string"): + parse_version_string("invalid-version") + + def test_parse_version_string_empty(self) -> None: + """Test parsing empty version string raises ValueError.""" + with pytest.raises(ValueError, match = "Could not parse version string"): + parse_version_string("") + + +class TestChromeVersionInfo: + """Test ChromeVersionInfo class.""" + + def test_chrome_version_info_creation(self) -> None: + """Test creating ChromeVersionInfo instance.""" + version_info = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + assert version_info.version_string == "136.0.6778.0" + assert version_info.major_version == 136 + assert version_info.browser_name == "Chrome" + + def test_chrome_version_info_is_chrome_136_plus_true(self) -> None: + """Test is_chrome_136_plus returns True for Chrome 136+.""" + version_info = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + assert version_info.is_chrome_136_plus is True + + def test_chrome_version_info_is_chrome_136_plus_false(self) -> None: + """Test is_chrome_136_plus returns False for Chrome < 136.""" + version_info = ChromeVersionInfo("120.0.6099.109", 120, "Chrome") + assert version_info.is_chrome_136_plus is False + + def test_chrome_version_info_is_chrome_136_plus_edge_case(self) -> None: + """Test is_chrome_136_plus edge case for version 136.""" + version_info = ChromeVersionInfo("136.0.0.0", 136, "Chrome") + assert version_info.is_chrome_136_plus is True + + def test_chrome_version_info_str_representation(self) -> None: + """Test string representation of ChromeVersionInfo.""" + version_info = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + expected = "Chrome 136.0.6778.0 (major: 136)" + assert str(version_info) == expected + + def test_chrome_version_info_edge_browser(self) -> None: + """Test ChromeVersionInfo with Edge browser.""" + version_info = ChromeVersionInfo("136.0.6778.0", 136, "Edge") + assert version_info.browser_name == "Edge" + assert str(version_info) == "Edge 136.0.6778.0 (major: 136)" + + +class TestDetectChromeVersionFromBinary: + """Test Chrome version detection from binary.""" + + @patch("subprocess.run") + def test_detect_chrome_version_from_binary_success(self, mock_run:Mock) -> None: + """Test successful Chrome version detection from binary.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Google Chrome 136.0.6778.0\n" + mock_run.return_value = mock_result + + version_info = detect_chrome_version_from_binary("/path/to/chrome") + + assert version_info is not None + assert version_info.version_string == "136.0.6778.0" + assert version_info.major_version == 136 + assert version_info.browser_name == "Chrome" + mock_run.assert_called_once_with( + ["/path/to/chrome", "--version"], + check = False, + capture_output = True, + text = True, + timeout = 10 + ) + + @patch("subprocess.run") + def test_detect_chrome_version_from_binary_edge(self, mock_run:Mock) -> None: + """Test Chrome version detection for Edge browser.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Microsoft Edge 136.0.6778.0\n" + mock_run.return_value = mock_result + + version_info = detect_chrome_version_from_binary("/path/to/edge") + + assert version_info is not None + assert version_info.browser_name == "Edge" + assert version_info.major_version == 136 + + @patch("subprocess.run") + def test_detect_chrome_version_from_binary_chromium(self, mock_run:Mock) -> None: + """Test Chrome version detection for Chromium browser.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Chromium 136.0.6778.0\n" + mock_run.return_value = mock_result + + version_info = detect_chrome_version_from_binary("/path/to/chromium") + + assert version_info is not None + assert version_info.browser_name == "Chromium" + assert version_info.major_version == 136 + + @patch("subprocess.run") + def test_detect_chrome_version_from_binary_failure(self, mock_run:Mock) -> None: + """Test Chrome version detection failure.""" + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stderr = "Command not found" + mock_run.return_value = mock_result + + version_info = detect_chrome_version_from_binary("/path/to/chrome") + assert version_info is None + + @patch("subprocess.run") + def test_detect_chrome_version_from_binary_timeout(self, mock_run:Mock) -> None: + """Test Chrome version detection timeout.""" + mock_run.side_effect = subprocess.TimeoutExpired("chrome", 10) + + version_info = detect_chrome_version_from_binary("/path/to/chrome") + assert version_info is None + + @patch("subprocess.run") + def test_detect_chrome_version_from_binary_invalid_output(self, mock_run:Mock) -> None: + """Test Chrome version detection with invalid output.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Invalid version string" + mock_run.return_value = mock_result + + version_info = detect_chrome_version_from_binary("/path/to/chrome") + assert version_info is None + + +class TestDetectChromeVersionFromRemoteDebugging: + """Test Chrome version detection from remote debugging API.""" + + @patch("urllib.request.urlopen") + def test_detect_chrome_version_from_remote_debugging_success(self, mock_urlopen:Mock) -> None: + """Test successful Chrome version detection from remote debugging.""" + mock_response = Mock() + mock_response.read.return_value = json.dumps({ + "Browser": "Chrome/136.0.6778.0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.6778.0 Safari/537.36" + }).encode() + mock_urlopen.return_value = mock_response + + version_info = detect_chrome_version_from_remote_debugging("127.0.0.1", 9222) + + assert version_info is not None + assert version_info.version_string == "136.0.6778.0" + assert version_info.major_version == 136 + assert version_info.browser_name == "Chrome/136.0.6778.0" + mock_urlopen.assert_called_once_with("http://127.0.0.1:9222/json/version", timeout = 5) + + @patch("urllib.request.urlopen") + def test_detect_chrome_version_from_remote_debugging_edge(self, mock_urlopen:Mock) -> None: + """Test Chrome version detection for Edge from remote debugging.""" + mock_response = Mock() + mock_response.read.return_value = json.dumps({ + "Browser": "Edg/136.0.6778.0", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.6778.0 Safari/537.36 Edg/136.0.6778.0" + }).encode() + mock_urlopen.return_value = mock_response + + version_info = detect_chrome_version_from_remote_debugging("127.0.0.1", 9222) + + assert version_info is not None + assert version_info.major_version == 136 + assert version_info.browser_name == "Edg/136.0.6778.0" + + @patch("urllib.request.urlopen") + def test_detect_chrome_version_from_remote_debugging_no_chrome_in_user_agent(self, mock_urlopen:Mock) -> None: + """Test Chrome version detection with no Chrome in User-Agent.""" + mock_response = Mock() + mock_response.read.return_value = json.dumps({ + "Browser": "Unknown", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + }).encode() + mock_urlopen.return_value = mock_response + + version_info = detect_chrome_version_from_remote_debugging("127.0.0.1", 9222) + assert version_info is None + + @patch("urllib.request.urlopen") + def test_detect_chrome_version_from_remote_debugging_connection_error(self, mock_urlopen:Mock) -> None: + """Test Chrome version detection with connection error.""" + mock_urlopen.side_effect = Exception("Connection refused") + + version_info = detect_chrome_version_from_remote_debugging("127.0.0.1", 9222) + assert version_info is None + + @patch("urllib.request.urlopen") + def test_detect_chrome_version_from_remote_debugging_invalid_json(self, mock_urlopen:Mock) -> None: + """Test Chrome version detection with invalid JSON response.""" + mock_response = Mock() + mock_response.read.return_value = b"Invalid JSON" + mock_urlopen.return_value = mock_response + + version_info = detect_chrome_version_from_remote_debugging("127.0.0.1", 9222) + assert version_info is None + + +class TestValidateChrome136Configuration: + """Test Chrome 136+ configuration validation.""" + + def test_validate_chrome_136_configuration_no_remote_debugging(self) -> None: + """Test validation when no remote debugging is configured.""" + is_valid, error_message = validate_chrome_136_configuration([], None) + assert is_valid is True + assert not error_message + + def test_validate_chrome_136_configuration_with_user_data_dir_arg(self) -> None: + """Test validation with --user-data-dir in arguments.""" + args = ["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"] + is_valid, error_message = validate_chrome_136_configuration(args, None) + assert is_valid is True + assert not error_message + + def test_validate_chrome_136_configuration_with_user_data_dir_config(self) -> None: + """Test validation with user_data_dir in configuration.""" + args = ["--remote-debugging-port=9222"] + is_valid, error_message = validate_chrome_136_configuration(args, "/tmp/chrome-debug") # noqa: S108 + assert is_valid is True + assert not error_message + + def test_validate_chrome_136_configuration_with_both(self) -> None: + """Test validation with both user_data_dir argument and config.""" + args = ["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"] + is_valid, error_message = validate_chrome_136_configuration(args, "/tmp/chrome-debug") # noqa: S108 + assert is_valid is True + assert not error_message + + def test_validate_chrome_136_configuration_missing_user_data_dir(self) -> None: + """Test validation failure when user_data_dir is missing.""" + args = ["--remote-debugging-port=9222"] + is_valid, error_message = validate_chrome_136_configuration(args, None) + assert is_valid is False + assert "Chrome/Edge 136+ requires --user-data-dir" in error_message + assert "Add --user-data-dir=/path/to/directory to your browser arguments" in error_message + + def test_validate_chrome_136_configuration_empty_user_data_dir_config(self) -> None: + """Test validation failure when user_data_dir config is empty.""" + args = ["--remote-debugging-port=9222"] + is_valid, error_message = validate_chrome_136_configuration(args, "") + assert is_valid is False + assert "Chrome/Edge 136+ requires --user-data-dir" in error_message + + def test_validate_chrome_136_configuration_whitespace_user_data_dir_config(self) -> None: + """Test validation failure when user_data_dir config is whitespace.""" + args = ["--remote-debugging-port=9222"] + is_valid, error_message = validate_chrome_136_configuration(args, " ") + assert is_valid is False + assert "Chrome/Edge 136+ requires --user-data-dir" in error_message + + +class TestGetChromeVersionDiagnosticInfo: + """Test Chrome version diagnostic information gathering.""" + + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_binary") + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_remote_debugging") + def test_get_chrome_version_diagnostic_info_binary_only( + self, mock_remote_detect:Mock, mock_binary_detect:Mock + ) -> None: + """Test diagnostic info with binary detection only.""" + mock_binary_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + mock_remote_detect.return_value = None + + diagnostic_info = get_chrome_version_diagnostic_info( + binary_path = "/path/to/chrome", + remote_port = None + ) + + assert diagnostic_info["binary_detection"] is not None + assert diagnostic_info["binary_detection"]["major_version"] == 136 + assert diagnostic_info["binary_detection"]["is_chrome_136_plus"] is True + assert diagnostic_info["remote_detection"] is None + assert diagnostic_info["chrome_136_plus_detected"] is True + assert len(diagnostic_info["recommendations"]) == 1 + + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_binary") + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_remote_debugging") + def test_get_chrome_version_diagnostic_info_remote_only( + self, mock_remote_detect:Mock, mock_binary_detect:Mock + ) -> None: + """Test diagnostic info with remote detection only.""" + mock_binary_detect.return_value = None + mock_remote_detect.return_value = ChromeVersionInfo("120.0.6099.109", 120, "Chrome") + + diagnostic_info = get_chrome_version_diagnostic_info( + binary_path = None, + remote_port = 9222 + ) + + assert diagnostic_info["binary_detection"] is None + assert diagnostic_info["remote_detection"] is not None + assert diagnostic_info["remote_detection"]["major_version"] == 120 + assert diagnostic_info["remote_detection"]["is_chrome_136_plus"] is False + assert diagnostic_info["chrome_136_plus_detected"] is False + assert len(diagnostic_info["recommendations"]) == 0 + + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_binary") + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_remote_debugging") + def test_get_chrome_version_diagnostic_info_both_detections( + self, mock_remote_detect:Mock, mock_binary_detect:Mock + ) -> None: + """Test diagnostic info with both binary and remote detection.""" + mock_binary_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + mock_remote_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + + diagnostic_info = get_chrome_version_diagnostic_info( + binary_path = "/path/to/chrome", + remote_port = 9222 + ) + + assert diagnostic_info["binary_detection"] is not None + assert diagnostic_info["remote_detection"] is not None + assert diagnostic_info["chrome_136_plus_detected"] is True + assert len(diagnostic_info["recommendations"]) == 1 + + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_binary") + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_remote_debugging") + def test_get_chrome_version_diagnostic_info_no_detection( + self, mock_remote_detect:Mock, mock_binary_detect:Mock + ) -> None: + """Test diagnostic info with no detection.""" + mock_binary_detect.return_value = None + mock_remote_detect.return_value = None + + diagnostic_info = get_chrome_version_diagnostic_info( + binary_path = None, + remote_port = None + ) + + assert diagnostic_info["binary_detection"] is None + assert diagnostic_info["remote_detection"] is None + assert diagnostic_info["chrome_136_plus_detected"] is False + assert len(diagnostic_info["recommendations"]) == 0 + + def test_get_chrome_version_diagnostic_info_default_values(self) -> None: + """Test diagnostic info with default values.""" + diagnostic_info = get_chrome_version_diagnostic_info() + + assert diagnostic_info["binary_detection"] is None + assert diagnostic_info["remote_detection"] is None + assert diagnostic_info["chrome_136_plus_detected"] is False + assert diagnostic_info["configuration_valid"] is True + assert diagnostic_info["recommendations"] == [] diff --git a/tests/unit/test_web_scraping_mixin.py b/tests/unit/test_web_scraping_mixin.py index f2f1e48..64102a2 100644 --- a/tests/unit/test_web_scraping_mixin.py +++ b/tests/unit/test_web_scraping_mixin.py @@ -1092,9 +1092,6 @@ class TestWebScrapingDiagnostics: scraper_with_config.diagnose_browser_issues() assert "(info) macOS detected - check Gatekeeper and security settings" in caplog.text - assert " IMPORTANT: macOS Chrome remote debugging requires --user-data-dir flag" in caplog.text - assert ' Add to your config.yaml: user_data_dir: "/tmp/chrome-debug-profile"' in caplog.text - assert " And to browser arguments: --user-data-dir=/tmp/chrome-debug-profile" in caplog.text def test_diagnose_browser_issues_macos_platform_with_user_data_dir( self, scraper_with_config:WebScrapingMixin, caplog:pytest.LogCaptureFixture, tmp_path:Path @@ -1110,8 +1107,6 @@ class TestWebScrapingDiagnostics: scraper_with_config.diagnose_browser_issues() assert "(info) macOS detected - check Gatekeeper and security settings" in caplog.text - # Should not show the warning about user-data-dir being required - assert "IMPORTANT: macOS Chrome remote debugging requires --user-data-dir flag" not in caplog.text def test_diagnose_browser_issues_linux_platform_not_root(self, scraper_with_config:WebScrapingMixin, caplog:pytest.LogCaptureFixture) -> None: """Test diagnostic on Linux platform when not running as root.""" @@ -1153,11 +1148,6 @@ class TestWebScrapingDiagnostics: scraper_with_config.browser_config.arguments = ["--remote-debugging-port=9222"] scraper_with_config.diagnose_browser_issues() - assert "On macOS, try: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome" in caplog.text - assert "--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug-profile --disable-dev-shm-usage" in caplog.text - assert 'Or: open -a "Google Chrome" --args --remote-debugging-port=9222' in caplog.text - assert " IMPORTANT: --user-data-dir is MANDATORY for macOS Chrome remote debugging" in caplog.text - def test_diagnose_browser_issues_complete_diagnostic_flow( self, scraper_with_config:WebScrapingMixin, caplog:pytest.LogCaptureFixture, tmp_path:Path ) -> None: @@ -1347,9 +1337,6 @@ class TestWebScrapingDiagnostics: patch.object(scraper_with_config, "get_compatible_browser", return_value = "/usr/bin/chrome"): scraper_with_config.diagnose_browser_issues() - assert "IMPORTANT: macOS Chrome remote debugging requires --user-data-dir flag" in caplog.text - assert "Add to your config.yaml: user_data_dir:" in caplog.text - def test_diagnose_browser_issues_linux_root_user( self, scraper_with_config:WebScrapingMixin, caplog:pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit/test_web_scraping_mixin_chrome_version.py b/tests/unit/test_web_scraping_mixin_chrome_version.py new file mode 100644 index 0000000..316bd2e --- /dev/null +++ b/tests/unit/test_web_scraping_mixin_chrome_version.py @@ -0,0 +1,510 @@ +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +import asyncio +import os +from unittest.mock import Mock, patch + +import pytest + +from kleinanzeigen_bot.utils.chrome_version_detector import ChromeVersionInfo +from kleinanzeigen_bot.utils.web_scraping_mixin import WebScrapingMixin + + +class TestWebScrapingMixinChromeVersionValidation: + """Test Chrome version validation in WebScrapingMixin.""" + + @pytest.fixture + def scraper(self) -> WebScrapingMixin: + """Create a WebScrapingMixin instance for testing.""" + return WebScrapingMixin() + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary") + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.validate_chrome_136_configuration") + async def test_validate_chrome_version_configuration_chrome_136_plus_valid( + self, mock_validate:Mock, mock_detect:Mock, scraper:WebScrapingMixin + ) -> None: + """Test Chrome 136+ validation with valid configuration.""" + # Setup mocks + mock_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + mock_validate.return_value = (True, "") + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + scraper.browser_config.arguments = ["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"] # noqa: S108 + scraper.browser_config.user_data_dir = "/tmp/chrome-debug" # noqa: S108 + + # Temporarily unset PYTEST_CURRENT_TEST to allow validation to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test validation + await scraper._validate_chrome_version_configuration() + + # Verify mocks were called correctly + mock_detect.assert_called_once_with("/path/to/chrome") + mock_validate.assert_called_once_with( + ["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"], # noqa: S108 + "/tmp/chrome-debug" # noqa: S108 + ) + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary") + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.validate_chrome_136_configuration") + async def test_validate_chrome_version_configuration_chrome_136_plus_invalid( + self, mock_validate:Mock, mock_detect:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome 136+ validation with invalid configuration.""" + # Setup mocks + mock_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome") + mock_validate.return_value = (False, "Chrome 136+ requires --user-data-dir") + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + scraper.browser_config.arguments = ["--remote-debugging-port=9222"] + scraper.browser_config.user_data_dir = None + + # Temporarily unset PYTEST_CURRENT_TEST to allow validation to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test validation should log error but not raise exception due to error handling + await scraper._validate_chrome_version_configuration() + + # Verify error was logged + assert "Chrome 136+ configuration validation failed" in caplog.text + assert "Chrome 136+ requires --user-data-dir" in caplog.text + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary") + async def test_validate_chrome_version_configuration_chrome_pre_136( + self, mock_detect:Mock, scraper:WebScrapingMixin + ) -> None: + """Test Chrome pre-136 validation (no special requirements).""" + # Setup mocks + mock_detect.return_value = ChromeVersionInfo("120.0.6099.109", 120, "Chrome") + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + scraper.browser_config.arguments = ["--remote-debugging-port=9222"] + scraper.browser_config.user_data_dir = None + + # Temporarily unset PYTEST_CURRENT_TEST to allow validation to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test validation should pass without validation + await scraper._validate_chrome_version_configuration() + + # Verify detection was called but no validation + mock_detect.assert_called_once_with("/path/to/chrome") + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_binary") + async def test_validate_chrome_version_configuration_no_binary_location( + self, mock_detect:Mock, scraper:WebScrapingMixin + ) -> None: + """Test Chrome version validation when no binary location is set.""" + # Configure scraper without binary location + scraper.browser_config.binary_location = None + + # Test validation should pass without detection + await scraper._validate_chrome_version_configuration() + + # Verify detection was not called + mock_detect.assert_not_called() + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary") + async def test_validate_chrome_version_configuration_detection_fails( + self, mock_detect:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome version validation when detection fails.""" + # Setup mocks + mock_detect.return_value = None + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + + # Temporarily unset PYTEST_CURRENT_TEST to allow validation to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test validation should pass without validation + await scraper._validate_chrome_version_configuration() + + # Verify detection was called + mock_detect.assert_called_once_with("/path/to/chrome") + + # Verify debug log message (line 824) + assert "Could not detect browser version, skipping validation" in caplog.text + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + +class TestWebScrapingMixinChromeVersionDiagnostics: + """Test Chrome version diagnostics in WebScrapingMixin.""" + + @pytest.fixture + def scraper(self) -> WebScrapingMixin: + """Create a WebScrapingMixin instance for testing.""" + return WebScrapingMixin() + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.get_chrome_version_diagnostic_info") + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.validate_chrome_136_configuration") + def test_diagnose_chrome_version_issues_binary_detection( + self, mock_validate:Mock, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome version diagnostics with binary detection.""" + # Setup mocks + mock_get_diagnostic.return_value = { + "binary_detection": { + "version_string": "136.0.6778.0", + "major_version": 136, + "browser_name": "Chrome", + "is_chrome_136_plus": True + }, + "remote_detection": None, + "chrome_136_plus_detected": True, + "recommendations": [] + } + mock_validate.return_value = (True, "") + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + scraper.browser_config.arguments = ["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"] + + # Temporarily unset PYTEST_CURRENT_TEST to allow diagnostics to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test diagnostics + scraper._diagnose_chrome_version_issues(9222) + + # Verify logs + assert "Chrome version from binary: Chrome 136.0.6778.0 (major: 136)" in caplog.text + assert "Chrome 136+ detected - security validation required" in caplog.text + + # Verify mocks were called + mock_get_diagnostic.assert_called_once_with( + binary_path = "/path/to/chrome", + remote_port = 9222 + ) + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.get_chrome_version_diagnostic_info") + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.validate_chrome_136_configuration") + def test_diagnose_chrome_version_issues_remote_detection( + self, mock_validate:Mock, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome version diagnostics with remote detection.""" + # Setup mocks + mock_get_diagnostic.return_value = { + "binary_detection": None, + "remote_detection": { + "version_string": "136.0.6778.0", + "major_version": 136, + "browser_name": "Chrome", + "is_chrome_136_plus": True + }, + "chrome_136_plus_detected": True, + "recommendations": [] + } + mock_validate.return_value = (False, "Chrome 136+ requires --user-data-dir") + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + scraper.browser_config.arguments = ["--remote-debugging-port=9222"] + + # Temporarily unset PYTEST_CURRENT_TEST to allow diagnostics to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test diagnostics + scraper._diagnose_chrome_version_issues(9222) + + # Verify logs + assert "Chrome version from remote debugging: Chrome 136.0.6778.0 (major: 136)" in caplog.text + assert "Remote Chrome 136+ detected - validating configuration" in caplog.text + assert "Chrome 136+ configuration validation failed" in caplog.text + + # Verify validation was called + mock_validate.assert_called_once_with( + ["--remote-debugging-port=9222"], + None + ) + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.get_chrome_version_diagnostic_info") + def test_diagnose_chrome_version_issues_no_detection( + self, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome version diagnostics with no detection.""" + # Setup mocks + mock_get_diagnostic.return_value = { + "binary_detection": None, + "remote_detection": None, + "chrome_136_plus_detected": False, + "recommendations": [] + } + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + + # Temporarily unset PYTEST_CURRENT_TEST to allow diagnostics to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test diagnostics + scraper._diagnose_chrome_version_issues(0) + + # Verify no Chrome version logs + assert "Chrome version from binary" not in caplog.text + assert "Chrome version from remote debugging" not in caplog.text + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.get_chrome_version_diagnostic_info") + def test_diagnose_chrome_version_issues_chrome_136_plus_recommendations( + self, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome version diagnostics with Chrome 136+ recommendations.""" + # Setup mocks + mock_get_diagnostic.return_value = { + "binary_detection": { + "version_string": "136.0.6778.0", + "major_version": 136, + "browser_name": "Chrome", + "is_chrome_136_plus": True + }, + "remote_detection": None, + "chrome_136_plus_detected": True, + "recommendations": [] + } + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + + # Temporarily unset PYTEST_CURRENT_TEST to allow diagnostics to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test diagnostics + scraper._diagnose_chrome_version_issues(0) + + # Verify recommendations + assert "Chrome/Edge 136+ security changes require --user-data-dir for remote debugging" in caplog.text + assert "https://developer.chrome.com/blog/remote-debugging-port" in caplog.text + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.get_chrome_version_diagnostic_info") + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.validate_chrome_136_configuration") + def test_diagnose_chrome_version_issues_binary_pre_136( + self, mock_validate:Mock, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome version diagnostics with pre-136 binary detection (lines 832-849).""" + # Setup mocks to ensure exact branch coverage + mock_get_diagnostic.return_value = { + "binary_detection": { + "version_string": "120.0.6099.109", + "major_version": 120, + "browser_name": "Chrome", + "is_chrome_136_plus": False # This triggers the else branch (lines 832-849) + }, + "remote_detection": None, # Ensure this is None to avoid other branches + "chrome_136_plus_detected": False, # Ensure this is False to avoid recommendations + "recommendations": [] + } + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + + # Temporarily unset PYTEST_CURRENT_TEST to allow diagnostics to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test diagnostics + scraper._diagnose_chrome_version_issues(0) + + # Verify pre-136 log message (lines 832-849) + assert "Chrome pre-136 detected - no special security requirements" in caplog.text + + # Verify that the diagnostic function was called with correct parameters + mock_get_diagnostic.assert_called_once_with( + binary_path = "/path/to/chrome", + remote_port = None + ) + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.get_chrome_version_diagnostic_info") + @patch("kleinanzeigen_bot.utils.web_scraping_mixin.validate_chrome_136_configuration") + def test_diagnose_chrome_version_issues_remote_validation_passes( + self, mock_validate:Mock, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture + ) -> None: + """Test Chrome version diagnostics with remote validation passing (line 846).""" + # Setup mocks + mock_get_diagnostic.return_value = { + "binary_detection": None, + "remote_detection": { + "version_string": "136.0.6778.0", + "major_version": 136, + "browser_name": "Chrome", + "is_chrome_136_plus": True + }, + "chrome_136_plus_detected": True, + "recommendations": [] + } + mock_validate.return_value = (True, "") # This triggers the else branch (line 846) + + # Configure scraper + scraper.browser_config.binary_location = "/path/to/chrome" + scraper.browser_config.arguments = ["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"] # noqa: S108 + scraper.browser_config.user_data_dir = "/tmp/chrome-debug" # noqa: S108 + + # Temporarily unset PYTEST_CURRENT_TEST to allow diagnostics to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # Test diagnostics + scraper._diagnose_chrome_version_issues(9222) + + # Verify validation passed log message (line 846) + assert "Chrome 136+ configuration validation passed" in caplog.text + + # Verify validation was called with correct arguments + mock_validate.assert_called_once_with( + ["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"], # noqa: S108 + "/tmp/chrome-debug" # noqa: S108 + ) + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env + + +class TestWebScrapingMixinIntegration: + """Test integration of Chrome version detection in WebScrapingMixin.""" + + @pytest.fixture + def scraper(self) -> WebScrapingMixin: + """Create a WebScrapingMixin instance for testing.""" + return WebScrapingMixin() + + @patch.object(WebScrapingMixin, "_validate_chrome_version_configuration") + @patch.object(WebScrapingMixin, "get_compatible_browser") + async def test_create_browser_session_calls_chrome_validation( + self, mock_get_browser:Mock, mock_validate:Mock, scraper:WebScrapingMixin + ) -> None: + """Test that create_browser_session calls Chrome version validation.""" + # Setup mocks + mock_get_browser.return_value = "/path/to/chrome" + mock_validate.return_value = None + + # Configure scraper + scraper.browser_config.binary_location = None + + # Test that validation is called + try: + await scraper.create_browser_session() + except Exception: # noqa: S110 + # We expect it to fail later, but validation should be called first + # This is expected behavior in the test - we're testing that validation runs before failure + pass + + # Verify validation was called + mock_validate.assert_called_once() + + @patch.object(WebScrapingMixin, "_diagnose_chrome_version_issues") + @patch.object(WebScrapingMixin, "get_compatible_browser") + def test_diagnose_browser_issues_calls_chrome_diagnostics( + self, mock_get_browser:Mock, mock_diagnose:Mock, scraper:WebScrapingMixin + ) -> None: + """Test that diagnose_browser_issues calls Chrome version diagnostics.""" + # Setup mocks + mock_get_browser.return_value = "/path/to/chrome" + + # Configure scraper + scraper.browser_config.binary_location = None + scraper.browser_config.arguments = ["--remote-debugging-port=9222"] + + # Test diagnostics + scraper.diagnose_browser_issues() + + # Verify Chrome diagnostics was called + mock_diagnose.assert_called_once_with(9222) + + def test_backward_compatibility_old_configs_still_work(self) -> None: + """Test that old configurations without Chrome 136+ validation still work.""" + # Create a scraper with old-style config (no user_data_dir) + scraper = WebScrapingMixin() + + # Set up old-style config (pre-Chrome 136+) + scraper.browser_config.arguments = ["--remote-debugging-port=9222"] + scraper.browser_config.user_data_dir = None # Old configs didn't have this + + # Mock Chrome version detection to return pre-136 version + with patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary") as mock_detect: + mock_detect.return_value = ChromeVersionInfo( + "120.0.6099.109", 120, "Chrome" + ) + + # Temporarily unset PYTEST_CURRENT_TEST to allow validation to run + original_env = os.environ.get("PYTEST_CURRENT_TEST") + if "PYTEST_CURRENT_TEST" in os.environ: + del os.environ["PYTEST_CURRENT_TEST"] + + try: + # This should not raise an exception for pre-136 Chrome + asyncio.run(scraper._validate_chrome_version_configuration()) + + # Verify that the validation passed (no exception raised) + # The method should log that pre-136 Chrome was detected + # and no special validation is required + finally: + # Restore environment + if original_env: + os.environ["PYTEST_CURRENT_TEST"] = original_env