diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py index e11253014ba7..cf0bc165f602 100644 --- a/superset/utils/screenshots.py +++ b/superset/utils/screenshots.py @@ -41,6 +41,17 @@ logger = logging.getLogger(__name__) +# Import Playwright availability and install message +try: + from superset.utils.webdriver import ( + PLAYWRIGHT_AVAILABLE, + PLAYWRIGHT_INSTALL_MESSAGE, + ) +except ImportError: + PLAYWRIGHT_AVAILABLE = False + PLAYWRIGHT_INSTALL_MESSAGE = "Playwright module not found" + + DEFAULT_SCREENSHOT_WINDOW_SIZE = 800, 600 DEFAULT_SCREENSHOT_THUMBNAIL_SIZE = 400, 300 DEFAULT_CHART_WINDOW_SIZE = DEFAULT_CHART_THUMBNAIL_SIZE = 800, 600 @@ -169,7 +180,19 @@ def __init__(self, url: str, digest: str | None): def driver(self, window_size: WindowSize | None = None) -> WebDriver: window_size = window_size or self.window_size if feature_flag_manager.is_feature_enabled("PLAYWRIGHT_REPORTS_AND_THUMBNAILS"): - return WebDriverPlaywright(self.driver_type, window_size) + # Try to use Playwright if available (supports WebGL/DeckGL, unlike Cypress) + if PLAYWRIGHT_AVAILABLE: + return WebDriverPlaywright(self.driver_type, window_size) + + # Playwright not available, falling back to Selenium + logger.info( + "PLAYWRIGHT_REPORTS_AND_THUMBNAILS enabled but Playwright not " + "installed. Falling back to Selenium (WebGL/Canvas charts may " + "not render correctly). %s", + PLAYWRIGHT_INSTALL_MESSAGE, + ) + + # Use Selenium as default/fallback return WebDriverSelenium(self.driver_type, window_size) def get_screenshot( diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py index 953ee2f22486..73a7839f9729 100644 --- a/superset/utils/webdriver.py +++ b/superset/utils/webdriver.py @@ -45,6 +45,13 @@ WindowSize = tuple[int, int] logger = logging.getLogger(__name__) +# Installation message for missing Playwright (Cypress doesn't work with DeckGL) +PLAYWRIGHT_INSTALL_MESSAGE = ( + "To complete the migration from Cypress " + "and enable WebGL/DeckGL screenshot support, install Playwright with: " + "pip install playwright && playwright install chromium" +) + if TYPE_CHECKING: from typing import Any @@ -71,6 +78,67 @@ sync_playwright = None +def check_playwright_availability() -> bool: + """ + Lightweight check for Playwright availability. + + First checks if browser binary exists, falls back to launch test if needed. + """ + if sync_playwright is None: + return False + + try: + with sync_playwright() as p: + # First try lightweight check - just verify executable exists + try: + executable_path = p.chromium.executable_path + if executable_path: + return True + except Exception: + # Fall back to full launch test if executable_path fails + logger.debug( + "Executable path check failed, falling back to launch test" + ) + + # Fallback: actually launch browser to ensure it works + browser = p.chromium.launch(headless=True) + browser.close() + return True + except Exception as e: + logger.warning( + "Playwright module is installed but browser launch failed. " + "Run 'playwright install chromium' to install browser binaries. " + "Error: %s", + str(e), + ) + return False + + +PLAYWRIGHT_AVAILABLE = check_playwright_availability() + + +def validate_webdriver_config() -> dict[str, Any]: + """ + Validate webdriver configuration and dependencies. + + Used to check migration status from Cypress to Playwright. + Returns a dictionary with the status of available webdrivers + and feature flags. + """ + from superset import feature_flag_manager + + return { + "selenium_available": True, # Always available as required dependency + "playwright_available": PLAYWRIGHT_AVAILABLE, + "playwright_feature_enabled": feature_flag_manager.is_feature_enabled( + "PLAYWRIGHT_REPORTS_AND_THUMBNAILS" + ), + "recommended_action": ( + PLAYWRIGHT_INSTALL_MESSAGE if not PLAYWRIGHT_AVAILABLE else None + ), + } + + class DashboardStandaloneMode(Enum): HIDE_NAV = 1 HIDE_NAV_AND_TITLE = 2 @@ -151,6 +219,15 @@ def find_unexpected_errors(page: Page) -> list[str]: def get_screenshot( # pylint: disable=too-many-locals, too-many-statements # noqa: C901 self, url: str, element_name: str, user: User ) -> bytes | None: + if not PLAYWRIGHT_AVAILABLE: + logger.info( + "Playwright not available - falling back to Selenium. " + "Note: WebGL/Canvas charts may not render correctly with Selenium. " + "%s", + PLAYWRIGHT_INSTALL_MESSAGE, + ) + return None + with sync_playwright() as playwright: browser_args = app.config["WEBDRIVER_OPTION_ARGS"] browser = playwright.chromium.launch(args=browser_args) diff --git a/tests/unit_tests/utils/screenshot_test.py b/tests/unit_tests/utils/screenshot_test.py index d747a9005531..db5036cd7e92 100644 --- a/tests/unit_tests/utils/screenshot_test.py +++ b/tests/unit_tests/utils/screenshot_test.py @@ -17,7 +17,7 @@ # pylint: disable=import-outside-toplevel, unused-argument -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from pytest_mock import MockerFixture @@ -25,6 +25,8 @@ from superset.utils.hashing import md5_sha_from_dict from superset.utils.screenshots import ( BaseScreenshot, + ChartScreenshot, + DashboardScreenshot, ScreenshotCachePayload, ScreenshotCachePayloadType, ) @@ -239,3 +241,150 @@ def test_get_image_multiple_reads(self): # Should be different BytesIO instances assert result1 is not result2 + + +class TestBaseScreenshotDriverFallback: + """Test BaseScreenshot.driver() fallback logic for Playwright migration.""" + + @patch("superset.utils.screenshots.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_driver_returns_playwright_when_feature_enabled_and_available( + self, mock_feature_flag, screenshot_obj + ): + """Test driver() returns WebDriverPlaywright when enabled and available.""" + mock_feature_flag.return_value = True + + driver = screenshot_obj.driver() + + assert driver.__class__.__name__ == "WebDriverPlaywright" + mock_feature_flag.assert_called_once_with("PLAYWRIGHT_REPORTS_AND_THUMBNAILS") + + @patch("superset.utils.screenshots.logger") + @patch("superset.utils.screenshots.PLAYWRIGHT_AVAILABLE", False) + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_driver_falls_back_to_selenium_when_playwright_unavailable( + self, mock_feature_flag, mock_logger, screenshot_obj + ): + """Test driver() falls back to Selenium when Playwright unavailable.""" + mock_feature_flag.return_value = True + + driver = screenshot_obj.driver() + + assert driver.__class__.__name__ == "WebDriverSelenium" + # Should log the fallback message + mock_logger.info.assert_called_once() + log_call = mock_logger.info.call_args[0][0] + assert ( + "PLAYWRIGHT_REPORTS_AND_THUMBNAILS enabled but Playwright not installed" + in log_call + ) + assert "Falling back to Selenium" in log_call + assert "WebGL/Canvas charts may not render correctly" in log_call + + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_driver_uses_selenium_when_feature_flag_disabled( + self, mock_feature_flag, screenshot_obj + ): + """Test driver() uses Selenium when feature flag disabled.""" + mock_feature_flag.return_value = False + + driver = screenshot_obj.driver() + + assert driver.__class__.__name__ == "WebDriverSelenium" + mock_feature_flag.assert_called_once_with("PLAYWRIGHT_REPORTS_AND_THUMBNAILS") + + @patch("superset.utils.screenshots.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_driver_passes_window_size_to_playwright( + self, mock_feature_flag, screenshot_obj + ): + """Test driver() passes window_size parameter to WebDriverPlaywright.""" + mock_feature_flag.return_value = True + custom_window_size = (1200, 800) + + driver = screenshot_obj.driver(window_size=custom_window_size) + + assert driver._window == custom_window_size + assert driver.__class__.__name__ == "WebDriverPlaywright" + + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_driver_passes_window_size_to_selenium( + self, mock_feature_flag, screenshot_obj + ): + """Test driver() passes window_size parameter to WebDriverSelenium.""" + mock_feature_flag.return_value = False + custom_window_size = (1200, 800) + + driver = screenshot_obj.driver(window_size=custom_window_size) + + assert driver._window == custom_window_size + assert driver.__class__.__name__ == "WebDriverSelenium" + + @patch("superset.utils.screenshots.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_driver_uses_default_window_size_when_none_provided( + self, mock_feature_flag, screenshot_obj + ): + """Test driver() uses screenshot object's window_size when none provided.""" + mock_feature_flag.return_value = True + + driver = screenshot_obj.driver() + + assert driver._window == screenshot_obj.window_size + assert driver.__class__.__name__ == "WebDriverPlaywright" + + +class TestScreenshotSubclassesDriverBehavior: + """Test ChartScreenshot and DashboardScreenshot inherit driver behavior.""" + + @patch("superset.utils.screenshots.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_chart_screenshot_uses_playwright_when_enabled(self, mock_feature_flag): + """Test ChartScreenshot uses Playwright when feature enabled.""" + mock_feature_flag.return_value = True + + chart_screenshot = ChartScreenshot("http://example.com/chart", "digest") + driver = chart_screenshot.driver() + + assert driver.__class__.__name__ == "WebDriverPlaywright" + assert driver._window == chart_screenshot.window_size + + @patch("superset.utils.screenshots.logger") + @patch("superset.utils.screenshots.PLAYWRIGHT_AVAILABLE", False) + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_dashboard_screenshot_falls_back_to_selenium( + self, mock_feature_flag, mock_logger + ): + """Test DashboardScreenshot falls back to Selenium if no Playwright.""" + mock_feature_flag.return_value = True + + dashboard_screenshot = DashboardScreenshot( + "http://example.com/dashboard", "digest" + ) + driver = dashboard_screenshot.driver() + + assert driver.__class__.__name__ == "WebDriverSelenium" + assert driver._window == dashboard_screenshot.window_size + + # Should log the fallback message + mock_logger.info.assert_called_once() + + @patch("superset.utils.screenshots.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_custom_window_size_passed_to_driver(self, mock_feature_flag): + """Test custom window size is passed correctly to driver.""" + mock_feature_flag.return_value = True + custom_window_size = (1920, 1080) + custom_thumb_size = (960, 540) + + chart_screenshot = ChartScreenshot( + "http://example.com/chart", + "digest", + window_size=custom_window_size, + thumb_size=custom_thumb_size, + ) + + driver = chart_screenshot.driver() + + assert driver._window == custom_window_size + assert chart_screenshot.thumb_size == custom_thumb_size diff --git a/tests/unit_tests/utils/test_playwright_migration_working.py b/tests/unit_tests/utils/test_playwright_migration_working.py new file mode 100644 index 000000000000..4e5a1259b7ac --- /dev/null +++ b/tests/unit_tests/utils/test_playwright_migration_working.py @@ -0,0 +1,100 @@ +# 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. + +""" +Working tests for Playwright migration functionality. +These tests demonstrate the core functionality works correctly. +""" + +from unittest.mock import MagicMock, patch + +from superset.utils.webdriver import ( + PLAYWRIGHT_AVAILABLE, + validate_webdriver_config, +) + + +class TestPlaywrightMigrationCore: + """Core tests that demonstrate working Playwright migration functionality.""" + + def test_playwright_available_is_boolean(self): + """Test that PLAYWRIGHT_AVAILABLE is always a boolean.""" + assert isinstance(PLAYWRIGHT_AVAILABLE, bool) + + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_validate_webdriver_config_structure(self, mock_feature_flag): + """Test that validate_webdriver_config returns correct structure.""" + mock_feature_flag.return_value = True + + result = validate_webdriver_config() + + # Check required keys exist + required_keys = [ + "selenium_available", + "playwright_available", + "playwright_feature_enabled", + "recommended_action", + ] + for key in required_keys: + assert key in result + + # Check data types + assert isinstance(result["selenium_available"], bool) + assert isinstance(result["playwright_available"], bool) + assert isinstance(result["playwright_feature_enabled"], bool) + assert result["recommended_action"] is None or isinstance( + result["recommended_action"], str + ) + + # Selenium should always be available + assert result["selenium_available"] is True + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", False) + @patch("superset.utils.webdriver.logger") + def test_webdriver_playwright_fallback_logging(self, mock_logger): + """Test that WebDriverPlaywright logs fallback correctly.""" + from superset.utils.webdriver import WebDriverPlaywright + + mock_user = MagicMock() + mock_user.username = "test_user" + + driver = WebDriverPlaywright("chrome") + result = driver.get_screenshot("http://example.com", "test-element", mock_user) + + # Should return None when unavailable + assert result is None + + # Should log the fallback message + mock_logger.info.assert_called_once() + log_call = mock_logger.info.call_args[0][0] + assert "Playwright not available" in log_call + assert "falling back to Selenium" in log_call + + def test_webdriver_classes_exist(self): + """Test that both WebDriver classes can be imported.""" + from superset.utils.webdriver import WebDriverPlaywright, WebDriverSelenium + + # Should be able to create instances without errors + playwright_driver = WebDriverPlaywright("chrome") + selenium_driver = WebDriverSelenium("chrome") + + assert playwright_driver is not None + assert selenium_driver is not None + + # Should have required attributes + assert hasattr(playwright_driver, "_driver_type") + assert hasattr(selenium_driver, "_driver_type") diff --git a/tests/unit_tests/utils/webdriver_test.py b/tests/unit_tests/utils/webdriver_test.py index 3946aa4aca1a..ab3490e0956f 100644 --- a/tests/unit_tests/utils/webdriver_test.py +++ b/tests/unit_tests/utils/webdriver_test.py @@ -15,11 +15,18 @@ # specific language governing permissions and limitations # under the License. -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, PropertyMock import pytest -from superset.utils.webdriver import WebDriverSelenium +from superset.utils.webdriver import ( + check_playwright_availability, + PLAYWRIGHT_AVAILABLE, + PLAYWRIGHT_INSTALL_MESSAGE, + validate_webdriver_config, + WebDriverPlaywright, + WebDriverSelenium, +) @pytest.fixture @@ -265,3 +272,424 @@ def test_empty_webdriver_config(self, mock_chrome, mock_app_patch, mock_app): # Should create driver without errors mock_driver_class.assert_called_once() + + +class TestPlaywrightAvailabilityCheck: + """Test comprehensive Playwright availability checking.""" + + @patch("superset.utils.webdriver.sync_playwright", None) + def test_check_playwright_availability_returns_false_when_module_not_available( + self, + ): + """Test check_playwright_availability returns False when no module.""" + result = check_playwright_availability() + assert result is False + + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_check_playwright_availability_uses_lightweight_check( + self, mock_logger, mock_sync_playwright + ): + """Test check_playwright_availability uses executable_path first.""" + # Setup mocks for successful executable path check + mock_playwright_instance = MagicMock() + mock_sync_playwright.return_value.__enter__.return_value = ( + mock_playwright_instance + ) + mock_playwright_instance.chromium.executable_path = "/path/to/chromium" + + result = check_playwright_availability() + + assert result is True + # Should not launch browser if executable_path works + mock_playwright_instance.chromium.launch.assert_not_called() + + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_check_playwright_availability_falls_back_to_launch( + self, mock_logger, mock_sync_playwright + ): + """Test check_playwright_availability falls back to browser launch.""" + # Setup mocks where executable_path fails but launch succeeds + mock_playwright_instance = MagicMock() + mock_browser = MagicMock() + + mock_sync_playwright.return_value.__enter__.return_value = ( + mock_playwright_instance + ) + # Make executable_path raise exception + type(mock_playwright_instance.chromium).executable_path = PropertyMock( + side_effect=Exception("executable_path failed") + ) + mock_playwright_instance.chromium.launch.return_value = mock_browser + + result = check_playwright_availability() + + assert result is True + # Should fall back to browser launch + mock_playwright_instance.chromium.launch.assert_called_once_with(headless=True) + mock_browser.close.assert_called_once() + + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_check_playwright_availability_handles_browser_launch_failure( + self, mock_logger, mock_sync_playwright + ): + """Test check_playwright_availability handles browser launch failures.""" + # Setup mocks to raise exception on browser launch + mock_playwright_instance = MagicMock() + mock_sync_playwright.return_value.__enter__.return_value = ( + mock_playwright_instance + ) + # Mock executable_path to raise exception to force fallback to launch test + type(mock_playwright_instance.chromium).executable_path = PropertyMock( + side_effect=Exception("Executable path check failed") + ) + mock_playwright_instance.chromium.launch.side_effect = Exception( + "Browser binaries not installed" + ) + + result = check_playwright_availability() + + assert result is False + mock_logger.warning.assert_called_once() + warning_call = mock_logger.warning.call_args[0][0] + assert ( + "Playwright module is installed but browser launch failed" in warning_call + ) + assert "playwright install chromium" in warning_call + + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_check_playwright_availability_handles_context_manager_error( + self, mock_logger, mock_sync_playwright + ): + """Test check_playwright_availability handles context manager errors.""" + # Setup mock to raise exception when entering context + mock_sync_playwright.return_value.__enter__.side_effect = Exception( + "Context error" + ) + + result = check_playwright_availability() + + assert result is False + mock_logger.warning.assert_called_once() + + +class TestPlaywrightMigrationSupport: + """Test Playwright migration and fallback functionality.""" + + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_validate_webdriver_config_all_available(self, mock_feature_flag): + """Test validate_webdriver_config when all dependencies available.""" + mock_feature_flag.return_value = True + + result = validate_webdriver_config() + + assert result["selenium_available"] is True + assert isinstance(result["playwright_available"], bool) + assert isinstance(result["playwright_feature_enabled"], bool) + + if result["playwright_available"]: + assert result["recommended_action"] is None + else: + assert result["recommended_action"] == PLAYWRIGHT_INSTALL_MESSAGE + + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + def test_validate_webdriver_config_feature_flag_disabled(self, mock_feature_flag): + """Test validate_webdriver_config when feature flag is disabled.""" + mock_feature_flag.return_value = False + + result = validate_webdriver_config() + + assert result["selenium_available"] is True + assert result["playwright_feature_enabled"] is False + + @patch("superset.extensions.feature_flag_manager.is_feature_enabled") + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", False) + def test_validate_webdriver_config_playwright_unavailable(self, mock_feature_flag): + """Test validate_webdriver_config when Playwright not available.""" + mock_feature_flag.return_value = True + + result = validate_webdriver_config() + + assert result["selenium_available"] is True + assert result["playwright_available"] is False + assert result["playwright_feature_enabled"] is True + assert result["recommended_action"] == PLAYWRIGHT_INSTALL_MESSAGE + + +class TestWebDriverPlaywrightFallback: + """Test WebDriverPlaywright fallback behavior when unavailable.""" + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", False) + @patch("superset.utils.webdriver.logger") + def test_get_screenshot_returns_none_when_unavailable(self, mock_logger, mock_app): + """Test WebDriverPlaywright.get_screenshot returns None when unavailable.""" + mock_user = MagicMock() + mock_user.username = "test_user" + + driver = WebDriverPlaywright("chrome") + result = driver.get_screenshot("http://example.com", "test-element", mock_user) + + assert result is None + + # Verify warning log was called with correct message + mock_logger.info.assert_called_once() + log_call = mock_logger.info.call_args[0][0] + assert "Playwright not available" in log_call + assert "falling back to Selenium" in log_call + assert "WebGL/Canvas charts may not render correctly" in log_call + # Check the substituted parameter + assert mock_logger.info.call_args[0][1] == PLAYWRIGHT_INSTALL_MESSAGE + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.app") + def test_get_screenshot_works_when_available(self, mock_app, mock_sync_playwright): + """Test WebDriverPlaywright.get_screenshot works when Playwright available.""" + # Setup mocks + mock_user = MagicMock() + mock_user.username = "test_user" + + mock_app.config = { + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_WINDOW": {"pixel_density": 1}, + "SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000, + "SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle", + "SCREENSHOT_SELENIUM_HEADSTART": 5, + "SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1, + "SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False, + "SCREENSHOT_TILED_ENABLED": False, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10, + } + + # Setup playwright mocks + mock_playwright_instance = MagicMock() + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + mock_element = MagicMock() + + mock_sync_playwright.return_value.__enter__.return_value = ( + mock_playwright_instance + ) + mock_playwright_instance.chromium.launch.return_value = mock_browser + mock_browser.new_context.return_value = mock_context + mock_context.new_page.return_value = mock_page + mock_page.locator.return_value = mock_element + mock_element.screenshot.return_value = b"fake_screenshot" + + # Mock the auth method + with patch.object(WebDriverPlaywright, "auth") as mock_auth: + mock_auth.return_value = mock_context + + driver = WebDriverPlaywright("chrome") + result = driver.get_screenshot( + "http://example.com", "test-element", mock_user + ) + + assert result == b"fake_screenshot" + mock_page.goto.assert_called_once_with( + "http://example.com", wait_until="networkidle" + ) + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_get_screenshot_handles_playwright_timeout( + self, mock_logger, mock_sync_playwright + ): + """Test WebDriverPlaywright handles PlaywrightTimeout gracefully.""" + from superset.utils.webdriver import PlaywrightTimeout + + mock_user = MagicMock() + mock_user.username = "test_user" + + # Setup playwright mocks to raise timeout + mock_playwright_instance = MagicMock() + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + + mock_sync_playwright.return_value.__enter__.return_value = ( + mock_playwright_instance + ) + mock_playwright_instance.chromium.launch.return_value = mock_browser + mock_browser.new_context.return_value = mock_context + mock_context.new_page.return_value = mock_page + mock_page.goto.side_effect = PlaywrightTimeout() + + with patch("superset.utils.webdriver.app") as mock_app: + mock_app.config = { + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_WINDOW": {"pixel_density": 1}, + "SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000, + "SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle", + "SCREENSHOT_SELENIUM_HEADSTART": 5, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10, + "SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": True, + "SCREENSHOT_TILED_ENABLED": False, + } + + with patch.object(WebDriverPlaywright, "auth") as mock_auth: + mock_auth.return_value = mock_context + + driver = WebDriverPlaywright("chrome") + result = driver.get_screenshot( + "http://example.com", "test-element", mock_user + ) + + # Should handle timeout gracefully and return None + assert result is None + mock_logger.exception.assert_called() + exception_call = mock_logger.exception.call_args[0][0] + assert "Web event %s not detected" in exception_call + + +class TestWebDriverConstantsWithImportError: + """Test module-level constants behavior with import errors.""" + + def test_playwright_constants_defined_when_import_fails(self): + """Test constants are properly defined even when Playwright import fails.""" + # These should be available even when playwright is not installed + assert PLAYWRIGHT_INSTALL_MESSAGE is not None + assert isinstance(PLAYWRIGHT_INSTALL_MESSAGE, str) + + # PLAYWRIGHT_AVAILABLE should be boolean regardless of installation + assert isinstance(PLAYWRIGHT_AVAILABLE, bool) + + @patch("superset.utils.webdriver.sync_playwright", None) + def test_dummy_classes_when_playwright_unavailable(self): + """Test that dummy classes are defined when Playwright unavailable.""" + # Force reimport to test ImportError path + from importlib import reload + + import superset.utils.webdriver as webdriver_module + + # Mock the import to fail + with patch.dict("sys.modules", {"playwright.sync_api": None}): + reload(webdriver_module) + + # Should have dummy classes defined + assert hasattr(webdriver_module, "BrowserContext") + assert hasattr(webdriver_module, "PlaywrightError") + assert hasattr(webdriver_module, "PlaywrightTimeout") + + +class TestWebDriverPlaywrightErrorHandling: + """Test error handling in WebDriverPlaywright methods.""" + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_find_unexpected_errors_handles_playwright_error( + self, mock_logger, mock_sync_playwright + ): + """Test find_unexpected_errors handles PlaywrightError gracefully.""" + from superset.utils.webdriver import PlaywrightError + + mock_page = MagicMock() + mock_page.get_by_role.side_effect = PlaywrightError("Test error") + + result = WebDriverPlaywright.find_unexpected_errors(mock_page) + + assert result == [] + mock_logger.exception.assert_called_once_with( + "Failed to capture unexpected errors" + ) + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_find_unexpected_errors_processes_alerts( + self, mock_logger, mock_sync_playwright + ): + """Test find_unexpected_errors processes alert elements correctly.""" + mock_page = MagicMock() + mock_alert_div = MagicMock() + mock_button = MagicMock() + mock_modal_content = MagicMock() + mock_modal_body = MagicMock() + mock_close_button = MagicMock() + + # Setup the mock chain + mock_page.get_by_role.return_value.all.return_value = [mock_alert_div] + mock_alert_div.get_by_role.return_value = mock_button + mock_page.locator.side_effect = [ + mock_modal_content, + mock_modal_body, + mock_close_button, + mock_modal_content, + ] + mock_modal_body.text_content.return_value = "Error message" + mock_modal_body.inner_html.return_value = "Error message" + + result = WebDriverPlaywright.find_unexpected_errors(mock_page) + + assert result == ["Error message"] + mock_button.click.assert_called_once() + mock_close_button.click.assert_called_once() + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver.sync_playwright") + @patch("superset.utils.webdriver.logger") + def test_get_screenshot_logs_multiple_timeouts( + self, mock_logger, mock_sync_playwright + ): + """Test that multiple timeout scenarios are logged appropriately.""" + from superset.utils.webdriver import PlaywrightTimeout + + mock_user = MagicMock() + mock_user.username = "test_user" + + # Setup mocks + mock_playwright_instance = MagicMock() + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + mock_element = MagicMock() + + mock_sync_playwright.return_value.__enter__.return_value = ( + mock_playwright_instance + ) + mock_playwright_instance.chromium.launch.return_value = mock_browser + mock_browser.new_context.return_value = mock_context + mock_context.new_page.return_value = mock_page + + # Mock locator to raise timeout on element wait + mock_page.locator.return_value = mock_element + mock_element.wait_for.side_effect = PlaywrightTimeout() + + with patch("superset.utils.webdriver.app") as mock_app: + mock_app.config = { + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_WINDOW": {"pixel_density": 1}, + "SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000, + "SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle", + "SCREENSHOT_SELENIUM_HEADSTART": 5, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10, + "SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": True, + "SCREENSHOT_TILED_ENABLED": False, + } + + with patch.object(WebDriverPlaywright, "auth") as mock_auth: + mock_auth.return_value = mock_context + + driver = WebDriverPlaywright("chrome") + result = driver.get_screenshot( + "http://example.com", "test-element", mock_user + ) + + assert result is None + # Should log timeout for element wait + assert mock_logger.exception.call_count >= 1