mirror of
https://github.com/apache/superset.git
synced 2026-04-08 02:45:22 +00:00
391 lines
15 KiB
Python
391 lines
15 KiB
Python
# 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.
|
|
|
|
# pylint: disable=import-outside-toplevel, unused-argument
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from pytest_mock import MockerFixture
|
|
|
|
from superset.utils.hashing import hash_from_dict
|
|
from superset.utils.screenshots import (
|
|
BaseScreenshot,
|
|
ChartScreenshot,
|
|
DashboardScreenshot,
|
|
ScreenshotCachePayload,
|
|
ScreenshotCachePayloadType,
|
|
)
|
|
|
|
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
|
|
|
|
|
|
class MockCache:
|
|
"""A class to manage screenshot cache."""
|
|
|
|
def __init__(self):
|
|
self._cache = None # Store the cached value
|
|
|
|
def set(self, _key, value):
|
|
"""Set the cache with a new value."""
|
|
self._cache = value
|
|
|
|
def get(self, _key):
|
|
"""Get the cached value."""
|
|
return self._cache
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_user():
|
|
"""Fixture to create a mock user."""
|
|
mock_user = MagicMock()
|
|
mock_user.id = 1
|
|
return mock_user
|
|
|
|
|
|
@pytest.fixture
|
|
def screenshot_obj():
|
|
"""Fixture to create a BaseScreenshot object."""
|
|
url = "http://example.com"
|
|
digest = "sample_digest"
|
|
return BaseScreenshot(url, digest)
|
|
|
|
|
|
def test_get_screenshot(mocker: MockerFixture, screenshot_obj):
|
|
"""Get screenshot should return a Bytes object"""
|
|
fake_bytes = b"fake_screenshot_data"
|
|
driver = mocker.patch(BASE_SCREENSHOT_PATH + ".driver")
|
|
driver.return_value.get_screenshot.return_value = fake_bytes
|
|
screenshot_data = screenshot_obj.get_screenshot(mock_user)
|
|
assert screenshot_data == fake_bytes
|
|
|
|
|
|
def test_get_cache_key(app_context, screenshot_obj):
|
|
"""Test get_cache_key method"""
|
|
expected_cache_key = hash_from_dict(
|
|
{
|
|
"thumbnail_type": "",
|
|
"digest": screenshot_obj.digest,
|
|
"type": "thumb",
|
|
"window_size": screenshot_obj.window_size,
|
|
"thumb_size": screenshot_obj.thumb_size,
|
|
}
|
|
)
|
|
cache_key = screenshot_obj.get_cache_key()
|
|
assert cache_key == expected_cache_key
|
|
|
|
|
|
def test_get_from_cache_key(mocker: MockerFixture, screenshot_obj):
|
|
"""get_from_cache_key should always return a ScreenshotCachePayload Object"""
|
|
# backwards compatibility test for retrieving plain bytes
|
|
fake_bytes = b"fake_screenshot_data"
|
|
BaseScreenshot.cache = MockCache()
|
|
BaseScreenshot.cache.set("key", fake_bytes)
|
|
cache_payload = screenshot_obj.get_from_cache_key("key")
|
|
assert isinstance(cache_payload, ScreenshotCachePayload)
|
|
assert cache_payload._image == fake_bytes # pylint: disable=protected-access
|
|
|
|
|
|
class TestComputeAndCache:
|
|
def _setup_compute_and_cache(self, mocker: MockerFixture, screenshot_obj):
|
|
"""Helper method to handle the common setup for the tests."""
|
|
# Patch the methods
|
|
get_from_cache_key = mocker.patch(
|
|
BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None
|
|
)
|
|
get_screenshot = mocker.patch(
|
|
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"new_image_data"
|
|
)
|
|
resize_image = mocker.patch(
|
|
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
|
|
)
|
|
BaseScreenshot.cache = MockCache()
|
|
return {
|
|
"get_from_cache_key": get_from_cache_key,
|
|
"get_screenshot": get_screenshot,
|
|
"resize_image": resize_image,
|
|
}
|
|
|
|
def test_happy_path(self, mocker: MockerFixture, screenshot_obj):
|
|
self._setup_compute_and_cache(mocker, screenshot_obj)
|
|
screenshot_obj.compute_and_cache(force=False)
|
|
cache_payload: ScreenshotCachePayloadType = screenshot_obj.cache.get("key")
|
|
assert cache_payload["status"] == "Updated"
|
|
|
|
def test_screenshot_error(self, mocker: MockerFixture, screenshot_obj):
|
|
mocks = self._setup_compute_and_cache(mocker, screenshot_obj)
|
|
get_screenshot: MagicMock = mocks.get("get_screenshot")
|
|
get_screenshot.side_effect = Exception
|
|
screenshot_obj.compute_and_cache(force=False)
|
|
cache_payload: ScreenshotCachePayloadType = screenshot_obj.cache.get("key")
|
|
assert cache_payload["status"] == "Error"
|
|
|
|
def test_resize_error(self, mocker: MockerFixture, screenshot_obj):
|
|
mocks = self._setup_compute_and_cache(mocker, screenshot_obj)
|
|
resize_image: MagicMock = mocks.get("resize_image")
|
|
resize_image.side_effect = Exception
|
|
screenshot_obj.compute_and_cache(force=False)
|
|
cache_payload: ScreenshotCachePayloadType = screenshot_obj.cache.get("key")
|
|
assert cache_payload["status"] == "Error"
|
|
|
|
def test_skips_if_computing(self, mocker: MockerFixture, screenshot_obj):
|
|
mocks = self._setup_compute_and_cache(mocker, screenshot_obj)
|
|
cached_value = ScreenshotCachePayload()
|
|
cached_value.computing()
|
|
get_from_cache_key = mocks.get("get_from_cache_key")
|
|
get_from_cache_key.return_value = cached_value
|
|
|
|
# Ensure that it skips when thumbnail status is computing
|
|
screenshot_obj.compute_and_cache(force=False)
|
|
get_screenshot = mocks.get("get_screenshot")
|
|
get_screenshot.assert_not_called()
|
|
|
|
# Ensure that it processes when force = True
|
|
screenshot_obj.compute_and_cache(force=True)
|
|
get_screenshot.assert_called_once()
|
|
cache_payload: ScreenshotCachePayloadType = screenshot_obj.cache.get("key")
|
|
assert cache_payload["status"] == "Updated"
|
|
|
|
def test_skips_if_updated(self, mocker: MockerFixture, screenshot_obj):
|
|
mocks = self._setup_compute_and_cache(mocker, screenshot_obj)
|
|
cached_value = ScreenshotCachePayload(image=b"initial_value")
|
|
get_from_cache_key = mocks.get("get_from_cache_key")
|
|
get_from_cache_key.return_value = cached_value
|
|
|
|
# Ensure that it skips when thumbnail status is updated
|
|
window_size = thumb_size = (10, 10)
|
|
screenshot_obj.compute_and_cache(
|
|
force=False, window_size=window_size, thumb_size=thumb_size
|
|
)
|
|
get_screenshot = mocks.get("get_screenshot")
|
|
get_screenshot.assert_not_called()
|
|
|
|
# Ensure that it processes when force = True
|
|
screenshot_obj.compute_and_cache(
|
|
force=True, window_size=window_size, thumb_size=thumb_size
|
|
)
|
|
get_screenshot.assert_called_once()
|
|
cache_payload: ScreenshotCachePayloadType = screenshot_obj.cache.get("key")
|
|
assert cache_payload["image"] != b"initial_value"
|
|
|
|
def test_resize(self, mocker: MockerFixture, screenshot_obj):
|
|
mocks = self._setup_compute_and_cache(mocker, screenshot_obj)
|
|
window_size = thumb_size = (10, 10)
|
|
resize_image: MagicMock = mocks.get("resize_image")
|
|
screenshot_obj.compute_and_cache(
|
|
force=False, window_size=window_size, thumb_size=thumb_size
|
|
)
|
|
resize_image.assert_not_called()
|
|
screenshot_obj.compute_and_cache(
|
|
force=False, window_size=(1, 1), thumb_size=thumb_size
|
|
)
|
|
resize_image.assert_called_once()
|
|
|
|
|
|
class TestScreenshotCachePayloadGetImage:
|
|
"""Test the get_image method behavior including exception handling"""
|
|
|
|
def test_get_image_returns_bytesio_when_image_exists(self):
|
|
"""Test that get_image returns BytesIO object when image data exists"""
|
|
image_data = b"test image data"
|
|
payload = ScreenshotCachePayload(image=image_data)
|
|
|
|
result = payload.get_image()
|
|
|
|
assert result is not None
|
|
assert result.read() == image_data
|
|
|
|
def test_get_image_raises_exception_when_no_image(self):
|
|
"""Test get_image raises ScreenshotImageNotAvailableException when no image"""
|
|
from superset.exceptions import ScreenshotImageNotAvailableException
|
|
|
|
payload = ScreenshotCachePayload() # No image data
|
|
|
|
with pytest.raises(ScreenshotImageNotAvailableException):
|
|
payload.get_image()
|
|
|
|
def test_get_image_raises_exception_when_image_is_none(self):
|
|
"""Test that get_image raises exception when image is explicitly set to None"""
|
|
from superset.exceptions import ScreenshotImageNotAvailableException
|
|
|
|
payload = ScreenshotCachePayload(image=None)
|
|
|
|
with pytest.raises(ScreenshotImageNotAvailableException):
|
|
payload.get_image()
|
|
|
|
def test_get_image_multiple_reads(self):
|
|
"""Test that get_image returns fresh BytesIO each time"""
|
|
image_data = b"test image data"
|
|
payload = ScreenshotCachePayload(image=image_data)
|
|
|
|
result1 = payload.get_image()
|
|
result2 = payload.get_image()
|
|
|
|
# Both should be valid BytesIO objects
|
|
assert result1.read() == image_data
|
|
assert result2.read() == image_data
|
|
|
|
# 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
|