Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion superset/utils/screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
77 changes: 77 additions & 0 deletions superset/utils/webdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there may be a lighter way to do this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to check if the browser binary exists first, only launches if that fails

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
Expand Down Expand Up @@ -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)
Expand Down
151 changes: 150 additions & 1 deletion tests/unit_tests/utils/screenshot_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@

# 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

from superset.utils.hashing import md5_sha_from_dict
from superset.utils.screenshots import (
BaseScreenshot,
ChartScreenshot,
DashboardScreenshot,
ScreenshotCachePayload,
ScreenshotCachePayloadType,
)
Expand Down Expand Up @@ -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
Loading
Loading