diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py index 746a784b93f..c43b2c1d31e 100644 --- a/superset/utils/webdriver.py +++ b/superset/utils/webdriver.py @@ -21,7 +21,7 @@ import logging from abc import ABC, abstractmethod from enum import Enum from time import sleep -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING from flask import current_app as app from packaging import version @@ -302,47 +302,99 @@ class WebDriverPlaywright(WebDriverProxy): class WebDriverSelenium(WebDriverProxy): + def _create_firefox_driver( + self, pixel_density: float + ) -> tuple[type[WebDriver], type[Service], dict[str, Any]]: + """Create Firefox driver configuration.""" + options = firefox.options.Options() + profile = FirefoxProfile() + profile.set_preference("layout.css.devPixelsPerPx", str(pixel_density)) + options.profile = profile + return ( + firefox.webdriver.WebDriver, + firefox.service.Service, + {"options": options}, + ) + + def _create_chrome_driver( + self, pixel_density: float + ) -> tuple[type[WebDriver], type[Service], dict[str, Any]]: + """Create Chrome driver configuration.""" + options = chrome.options.Options() + options.add_argument(f"--force-device-scale-factor={pixel_density}") + options.add_argument(f"--window-size={self._window[0]},{self._window[1]}") + return ( + chrome.webdriver.WebDriver, + chrome.service.Service, + {"options": options}, + ) + + def _normalize_timeout_values(self, config: dict[str, Any]) -> dict[str, Any]: + """Convert timeout values to float for urllib3 2.x compatibility.""" + timeout_keys = [ + "timeout", + "connect_timeout", + "socket_timeout", + "read_timeout", + "page_load_timeout", + "implicit_wait", + "command_executor_timeout", + "connection_timeout", + ] + + for key, value in config.items(): + if any(timeout_key in key.lower() for timeout_key in timeout_keys): + if value is None or value == "None" or value == "null": + config[key] = None + else: + try: + config[key] = float(value) + except (ValueError, TypeError): + config[key] = None + logger.warning( + f"Invalid timeout value for {key}: {value}, setting to None" + ) + return config + def create(self) -> WebDriver: pixel_density = app.config["WEBDRIVER_WINDOW"].get("pixel_density", 1) + + # Get driver class and initial kwargs based on driver type if self._driver_type == "firefox": - driver_class: type[WebDriver] = firefox.webdriver.WebDriver - service_class: type[Service] = firefox.service.Service - options = firefox.options.Options() - profile = FirefoxProfile() - profile.set_preference("layout.css.devPixelsPerPx", str(pixel_density)) - options.profile = profile - kwargs = {"options": options} + driver_class, service_class, kwargs = self._create_firefox_driver( + pixel_density + ) elif self._driver_type == "chrome": - driver_class = chrome.webdriver.WebDriver - service_class = chrome.service.Service - options = chrome.options.Options() - options.add_argument(f"--force-device-scale-factor={pixel_density}") - options.add_argument(f"--window-size={self._window[0]},{self._window[1]}") - kwargs = {"options": options} + driver_class, service_class, kwargs = self._create_chrome_driver( + pixel_density + ) else: raise Exception( # pylint: disable=broad-exception-raised f"Webdriver name ({self._driver_type}) not supported" ) - # Prepare args for the webdriver init + # Add additional arguments from config + options = kwargs["options"] for arg in list(app.config["WEBDRIVER_OPTION_ARGS"]): options.add_argument(arg) - # Add additional configured webdriver options - webdriver_conf = dict(app.config["WEBDRIVER_CONFIGURATION"]) + # Fix timeout values for urllib3 2.x compatibility + webdriver_config = app.config["WEBDRIVER_CONFIGURATION"].copy() + webdriver_config = self._normalize_timeout_values(webdriver_config) + kwargs.update(webdriver_config) # Set the binary location if provided # We need to pop it from the dict due to selenium_version < 4.10.0 - options.binary_location = webdriver_conf.pop("binary_location", "") + options.binary_location = webdriver_config.pop("binary_location", "") if version.parse(selenium_version) < version.parse("4.10.0"): - kwargs |= webdriver_conf + kwargs |= webdriver_config else: driver_opts = dict( - webdriver_conf.get("options", {"capabilities": {}, "preferences": {}}) + webdriver_config.get("options", {"capabilities": {}, "preferences": {}}) ) driver_srv = dict( - webdriver_conf.get( + webdriver_config.get( "service", { "log_output": "/dev/null", diff --git a/tests/unit_tests/utils/webdriver_test.py b/tests/unit_tests/utils/webdriver_test.py new file mode 100644 index 00000000000..12b457bdb52 --- /dev/null +++ b/tests/unit_tests/utils/webdriver_test.py @@ -0,0 +1,263 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from unittest.mock import MagicMock, patch + +import pytest + +from superset.utils.webdriver import WebDriverSelenium + + +@pytest.fixture +def mock_app(): + """Mock Flask app with webdriver configuration.""" + app = MagicMock() + app.config = { + "WEBDRIVER_TYPE": "chrome", + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_CONFIGURATION": {}, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + } + return app + + +class TestWebDriverSelenium: + """Test WebDriverSelenium timeout handling for urllib3 2.x compatibility.""" + + @patch("superset.utils.webdriver.app") + @patch("superset.utils.webdriver.firefox") + @patch("superset.utils.webdriver.chrome") + def test_timeout_conversion_to_float( + self, mock_chrome, mock_firefox, mock_app_patch, mock_app + ): + """Test that timeout values are properly converted to float.""" + # Set up app mock to be used throughout + mock_app_patch.config = { + "WEBDRIVER_TYPE": "chrome", + "WEBDRIVER_OPTION_ARGS": [], + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "WEBDRIVER_WINDOW": {}, + "WEBDRIVER_CONFIGURATION": { + "timeout": "30", + "connect_timeout": "10.5", + "socket_timeout": 20, + "read_timeout": "15.0", + "command_executor_timeout": "25", + }, + } + + mock_driver_class = MagicMock() + mock_chrome.webdriver.WebDriver = mock_driver_class + mock_chrome.service.Service = MagicMock() + mock_options = MagicMock() + mock_options.add_argument = MagicMock() + mock_chrome.options.Options = MagicMock(return_value=mock_options) + + driver = WebDriverSelenium(driver_type="chrome") + driver.create() + + # Check that the driver was called with float timeout values + mock_driver_class.assert_called_once() + call_kwargs = mock_driver_class.call_args.kwargs + assert call_kwargs["timeout"] == 30.0 + assert call_kwargs["connect_timeout"] == 10.5 + assert call_kwargs["socket_timeout"] == 20.0 + assert call_kwargs["read_timeout"] == 15.0 + assert call_kwargs["command_executor_timeout"] == 25.0 + + @patch("superset.utils.webdriver.app") + @patch("superset.utils.webdriver.chrome") + def test_timeout_none_handling(self, mock_chrome, mock_app_patch, mock_app): + """Test that None, 'None', and 'null' timeout values are set to None.""" + mock_app_patch.config = { + "WEBDRIVER_TYPE": "chrome", + "WEBDRIVER_OPTION_ARGS": [], + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "WEBDRIVER_WINDOW": {}, + "WEBDRIVER_CONFIGURATION": { + "timeout": None, + "connect_timeout": "None", + "socket_timeout": "null", + }, + } + + mock_driver_class = MagicMock() + mock_chrome.webdriver.WebDriver = mock_driver_class + mock_chrome.service.Service = MagicMock() + mock_options = MagicMock() + mock_options.add_argument = MagicMock() + mock_chrome.options.Options = MagicMock(return_value=mock_options) + + driver = WebDriverSelenium(driver_type="chrome") + driver.create() + + # Check that None values are preserved + mock_driver_class.assert_called_once() + call_kwargs = mock_driver_class.call_args.kwargs + assert call_kwargs["timeout"] is None + assert call_kwargs["connect_timeout"] is None + assert call_kwargs["socket_timeout"] is None + + @patch("superset.utils.webdriver.app") + @patch("superset.utils.webdriver.chrome") + @patch("superset.utils.webdriver.logger") + def test_invalid_timeout_warning( + self, mock_logger, mock_chrome, mock_app_patch, mock_app + ): + """Test that invalid timeout values log warnings and are set to None.""" + mock_app_patch.config = { + "WEBDRIVER_TYPE": "chrome", + "WEBDRIVER_OPTION_ARGS": [], + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "WEBDRIVER_WINDOW": {}, + "WEBDRIVER_CONFIGURATION": { + "timeout": "invalid", + "connect_timeout": "not_a_number", + "Page_Load_Timeout": "abc123", # Test case-insensitive matching + }, + } + + mock_driver_class = MagicMock() + mock_chrome.webdriver.WebDriver = mock_driver_class + mock_chrome.service.Service = MagicMock() + mock_options = MagicMock() + mock_options.add_argument = MagicMock() + mock_chrome.options.Options = MagicMock(return_value=mock_options) + + driver = WebDriverSelenium(driver_type="chrome") + driver.create() + + # Check that invalid values are set to None + mock_driver_class.assert_called_once() + call_kwargs = mock_driver_class.call_args.kwargs + assert call_kwargs["timeout"] is None + assert call_kwargs["connect_timeout"] is None + assert call_kwargs["Page_Load_Timeout"] is None + + # Check that warnings were logged + assert mock_logger.warning.call_count == 3 + mock_logger.warning.assert_any_call( + "Invalid timeout value for timeout: invalid, setting to None" + ) + mock_logger.warning.assert_any_call( + "Invalid timeout value for connect_timeout: not_a_number, setting to None" + ) + mock_logger.warning.assert_any_call( + "Invalid timeout value for Page_Load_Timeout: abc123, setting to None" + ) + + @patch("superset.utils.webdriver.app") + @patch("superset.utils.webdriver.chrome") + def test_non_timeout_config_preserved(self, mock_chrome, mock_app_patch, mock_app): + """Test that non-timeout configuration values are preserved.""" + mock_app_patch.config = { + "WEBDRIVER_TYPE": "chrome", + "WEBDRIVER_OPTION_ARGS": [], + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "WEBDRIVER_WINDOW": {}, + "WEBDRIVER_CONFIGURATION": { + "timeout": "30", + "some_other_option": "value", + "another_option": 123, + "boolean_option": True, + }, + } + + mock_driver_class = MagicMock() + mock_chrome.webdriver.WebDriver = mock_driver_class + mock_chrome.service.Service = MagicMock() + mock_options = MagicMock() + mock_options.add_argument = MagicMock() + mock_chrome.options.Options = MagicMock(return_value=mock_options) + + driver = WebDriverSelenium(driver_type="chrome") + driver.create() + + # Check that all config values are passed through + mock_driver_class.assert_called_once() + call_kwargs = mock_driver_class.call_args.kwargs + assert call_kwargs["timeout"] == 30.0 + assert call_kwargs["some_other_option"] == "value" + assert call_kwargs["another_option"] == 123 + assert call_kwargs["boolean_option"] is True + + @patch("superset.utils.webdriver.app") + @patch("superset.utils.webdriver.chrome") + def test_timeout_key_case_insensitive(self, mock_chrome, mock_app_patch, mock_app): + """Test that timeout detection is case-insensitive.""" + mock_app_patch.config = { + "WEBDRIVER_TYPE": "chrome", + "WEBDRIVER_OPTION_ARGS": [], + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "WEBDRIVER_WINDOW": {}, + "WEBDRIVER_CONFIGURATION": { + "TIMEOUT": "10", + "Connect_Timeout": "20", + "SOCKET_TIMEOUT": "30", + "connection_timeout_ms": "5000", # Contains 'connection_timeout' + }, + } + + mock_driver_class = MagicMock() + mock_chrome.webdriver.WebDriver = mock_driver_class + mock_chrome.service.Service = MagicMock() + mock_options = MagicMock() + mock_options.add_argument = MagicMock() + mock_chrome.options.Options = MagicMock(return_value=mock_options) + + driver = WebDriverSelenium(driver_type="chrome") + driver.create() + + # Check that all timeout values are converted to float + mock_driver_class.assert_called_once() + call_kwargs = mock_driver_class.call_args.kwargs + assert call_kwargs["TIMEOUT"] == 10.0 + assert call_kwargs["Connect_Timeout"] == 20.0 + assert call_kwargs["SOCKET_TIMEOUT"] == 30.0 + assert call_kwargs["connection_timeout_ms"] == 5000.0 + + @patch("superset.utils.webdriver.app") + @patch("superset.utils.webdriver.chrome") + def test_empty_webdriver_config(self, mock_chrome, mock_app_patch, mock_app): + """Test handling of empty webdriver configuration.""" + mock_app_patch.config = { + "WEBDRIVER_TYPE": "chrome", + "WEBDRIVER_OPTION_ARGS": [], + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "WEBDRIVER_WINDOW": {}, + "WEBDRIVER_CONFIGURATION": {}, + } + + mock_driver_class = MagicMock() + mock_chrome.webdriver.WebDriver = mock_driver_class + mock_chrome.service.Service = MagicMock() + mock_options = MagicMock() + mock_options.add_argument = MagicMock() + mock_chrome.options.Options = MagicMock(return_value=mock_options) + + driver = WebDriverSelenium(driver_type="chrome") + driver.create() + + # Should create driver without errors + mock_driver_class.assert_called_once()