Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
30 changes: 29 additions & 1 deletion superset/utils/screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@

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"

# Track if we've already logged the fallback to avoid log spam
_PLAYWRIGHT_FALLBACK_LOGGED = False

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 +182,22 @@ 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)

# Log fallback only once to avoid log spam on repeated operations
global _PLAYWRIGHT_FALLBACK_LOGGED
if not _PLAYWRIGHT_FALLBACK_LOGGED:
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,
)
_PLAYWRIGHT_FALLBACK_LOGGED = True
Copy link
Member

Choose a reason for hiding this comment

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

In a Flask app (multiple threads) or a Celery worker (multiple processes), this may not behave as we would expect:

Each process will have its own memory space, so the log suppression will only work per process, not globally.

In threaded mode, modifying the global without a lock could lead to race conditions (unlikely to matter much for a boolean flag, but still possible).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right about the concurrency issues. Added thread safety with a lock to prevent race conditions in threaded Flask mode. The per-process behavior in Celery is actually fine (one log per worker), but the threading needed fixing.


# Use Selenium as default/fallback
return WebDriverSelenium(self.driver_type, window_size)

def get_screenshot(
Expand Down
72 changes: 70 additions & 2 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,58 @@
sync_playwright = None


# Check Playwright availability at module level
def check_playwright_availability() -> bool:
"""
Comprehensive check for Playwright availability.

Verifies not only that the module is imported, but also that
browser binaries are installed and can be launched.
"""
if sync_playwright is None:
return False

try:
# Try to actually launch a browser to ensure binaries are installed
with sync_playwright() as p:
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 All @@ -87,8 +146,8 @@ class WebDriverProxy(ABC):
def __init__(self, driver_type: str, window: WindowSize | None = None):
self._driver_type = driver_type
self._window: WindowSize = window or (800, 600)
self._screenshot_locate_wait = app.config["SCREENSHOT_LOCATE_WAIT"]
self._screenshot_load_wait = app.config["SCREENSHOT_LOAD_WAIT"]
self._screenshot_locate_wait = app.config.get("SCREENSHOT_LOCATE_WAIT", 10)
self._screenshot_load_wait = app.config.get("SCREENSHOT_LOAD_WAIT", 60)
Copy link
Member

Choose a reason for hiding this comment

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

nit: We used to have a rule to be strict with the config, all keys have defaults configured so we expect app.config["SOME_KEY"] instead of app.config.get unsure if we still have this "rule"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah. yeah, Thanks. Good catch! Since the defaults are configured in config.py, switched to strict app.config["KEY"].


@abstractmethod
def get_screenshot(self, url: str, element_name: str, user: User) -> bytes | None:
Expand Down Expand Up @@ -151,6 +210,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
156 changes: 155 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,155 @@ 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

# Reset the global fallback logging flag to ensure we can test the logging
import superset.utils.screenshots

superset.utils.screenshots._PLAYWRIGHT_FALLBACK_LOGGED = False

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

# Note: May not log if fallback message was already logged globally
# This is expected behavior due to the single-log optimization

@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