mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
1597 lines
65 KiB
Python
1597 lines
65 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.
|
|
|
|
from unittest.mock import ANY, MagicMock, patch
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
|
|
from superset.utils.report_execution import (
|
|
ReportExecutionContext,
|
|
ReportExecutionDeadline,
|
|
)
|
|
from superset.utils.webdriver import (
|
|
check_playwright_availability,
|
|
PLAYWRIGHT_AVAILABLE,
|
|
PLAYWRIGHT_INSTALL_MESSAGE,
|
|
WebDriverPlaywright,
|
|
)
|
|
|
|
|
|
def _report_context(
|
|
*,
|
|
dashboard_id: int | None = 805,
|
|
chart_id: int | None = None,
|
|
expected_chart_count: int = 52,
|
|
) -> ReportExecutionContext:
|
|
"""Return a deterministic scheduled-report context."""
|
|
|
|
return ReportExecutionContext(
|
|
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
|
|
report_schedule_id=11,
|
|
dashboard_id=dashboard_id,
|
|
chart_id=chart_id,
|
|
expected_chart_count=expected_chart_count,
|
|
deadline=ReportExecutionDeadline(
|
|
total_seconds=900,
|
|
started_at=0,
|
|
_clock=lambda: 0,
|
|
),
|
|
capture_reserve_seconds=60,
|
|
delivery_reserve_seconds=120,
|
|
cleanup_reserve_seconds=30,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def mock_app():
|
|
"""Mock Flask app with webdriver configuration."""
|
|
app = MagicMock()
|
|
app.config = {
|
|
"WEBDRIVER_OPTION_ARGS": [],
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 10,
|
|
}
|
|
return app
|
|
|
|
|
|
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_returns_true_when_module_importable(
|
|
self, mock_logger, mock_sync_playwright
|
|
):
|
|
"""Test check_playwright_availability returns True when module is importable."""
|
|
result = check_playwright_availability()
|
|
assert result is True
|
|
# Only checks sync_playwright is not None — never launches browser
|
|
mock_sync_playwright.assert_not_called()
|
|
|
|
|
|
class TestWebDriverPlaywrightFallback:
|
|
"""Test WebDriverPlaywright fallback behavior when unavailable."""
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", False)
|
|
@patch("superset.utils.webdriver.logger")
|
|
def test_get_screenshot_raises_when_unavailable(self, mock_logger, mock_app):
|
|
"""Test get_screenshot raises RuntimeError when Playwright is unavailable."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
|
|
driver = WebDriverPlaywright("chrome")
|
|
with pytest.raises(RuntimeError, match="Playwright is required"):
|
|
driver.get_screenshot("http://example.com", "test-element", mock_user)
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_get_screenshot_works_when_available(self, mock_app, mock_browser_manager):
|
|
"""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,
|
|
}
|
|
|
|
# Setup playwright mocks
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.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._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
def test_get_screenshot_handles_playwright_timeout(
|
|
self, mock_logger, mock_browser_manager
|
|
):
|
|
"""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_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.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("timeout")
|
|
|
|
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_SELENIUM_ANIMATION_WAIT": 1,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 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
|
|
)
|
|
|
|
# page.goto() timeout is caught and logged without aborting; execution
|
|
# continues to the element waits, which succeed here, so a screenshot
|
|
# is taken and returned (not None).
|
|
assert result is not 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%s", ""
|
|
)
|
|
|
|
@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._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_uses_wait_for_function_to_detect_spinners(
|
|
self, mock_app, mock_browser_manager
|
|
):
|
|
"""wait_for_function polls for chart-holder readiness, not snapshotting."""
|
|
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": 0,
|
|
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 0,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_TILED_ENABLED": False,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 60,
|
|
}
|
|
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.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"screenshot"
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
driver = WebDriverPlaywright("chrome")
|
|
driver.get_screenshot("http://example.com", "test-element", mock_user)
|
|
|
|
mock_page.wait_for_function.assert_called_once()
|
|
call_args, call_kwargs = mock_page.wait_for_function.call_args
|
|
js = call_args[0]
|
|
# The old absence-of-`.loading` predicate passed vacuously when a
|
|
# chart holder hadn't mounted anything yet; the fix requires a
|
|
# positive terminal-state check instead (same predicate as the tiled
|
|
# path, #42119).
|
|
assert "dashboard-component-chart-holder" in js
|
|
assert ".slice_container" in js
|
|
assert "data-test" not in js
|
|
assert call_kwargs["timeout"] == 60 * 1000
|
|
# Guard against reintroducing the old snapshot-based approach
|
|
loading_locator_calls = [
|
|
c for c in mock_page.locator.call_args_list if c.args == (".loading",)
|
|
]
|
|
assert loading_locator_calls == []
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_spinner_timeout_logs_warning_and_raises(
|
|
self, mock_app, mock_logger, mock_browser_manager
|
|
):
|
|
"""Readiness timeout is logged as a warning, with per-chart diagnostics,
|
|
and re-raised rather than silently capturing."""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
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": 0,
|
|
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 0,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_TILED_ENABLED": False,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 60,
|
|
}
|
|
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.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
|
|
|
|
timeout = PlaywrightTimeout()
|
|
mock_page.wait_for_function.side_effect = timeout
|
|
mock_page.evaluate.return_value = [
|
|
{"chartId": "42", "state": "nothing_mounted"}
|
|
]
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
driver = WebDriverPlaywright("chrome")
|
|
with pytest.raises(PlaywrightTimeout) as exc_info:
|
|
driver.get_screenshot("http://example.com", "test-element", mock_user)
|
|
|
|
assert exc_info.value is timeout
|
|
mock_logger.error.assert_not_called()
|
|
warning_call = mock_logger.warning.call_args
|
|
assert "terminal_reason=readiness_timeout" in warning_call.args[0]
|
|
assert warning_call.args[1] == "http://example.com"
|
|
assert warning_call.args[3] == 1 # mounted holders
|
|
assert warning_call.args[4] == 0 # ready holders
|
|
assert warning_call.args[7] == 60
|
|
assert warning_call.args[9] == [{"chartId": "42", "state": "nothing_mounted"}]
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
def test_get_screenshot_raises_on_element_wait_timeout(
|
|
self, mock_logger, mock_browser_manager
|
|
):
|
|
"""Test that PlaywrightTimeout propagates when waiting for page elements."""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
|
|
# Setup mocks
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.return_value = mock_browser
|
|
mock_browser.new_context.return_value = mock_context
|
|
mock_context.new_page.return_value = mock_page
|
|
|
|
# Keep a reference to the exact instance so we can verify identity below.
|
|
timeout = PlaywrightTimeout()
|
|
mock_page.locator.return_value = mock_element
|
|
mock_element.wait_for.side_effect = timeout
|
|
|
|
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_SELENIUM_ANIMATION_WAIT": 1,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 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")
|
|
with pytest.raises(PlaywrightTimeout) as exc_info:
|
|
driver.get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
# The exact injected instance must propagate — guards against the
|
|
# fallback alias (PlaywrightTimeout = Exception when playwright is
|
|
# not installed) accepting unrelated exceptions.
|
|
assert exc_info.value is timeout
|
|
mock_logger.exception.assert_any_call(
|
|
"Timed out requesting url %s%s", "http://example.com", ""
|
|
)
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
def test_missing_element_for_dashboard_height_falls_back_without_crashing(
|
|
self, mock_logger, mock_browser_manager
|
|
):
|
|
"""Missing dashboard element should not crash height evaluation."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
mock_chart_container = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.return_value = mock_browser
|
|
mock_browser.new_context.return_value = mock_context
|
|
mock_context.new_page.return_value = mock_page
|
|
|
|
def locator_side_effect(selector):
|
|
if selector == ".dashboard":
|
|
return mock_element
|
|
if selector == ".chart-container":
|
|
locator = MagicMock()
|
|
locator.all.return_value = [mock_chart_container]
|
|
return locator
|
|
if selector == ".loading":
|
|
locator = MagicMock()
|
|
locator.all.return_value = []
|
|
return locator
|
|
return MagicMock()
|
|
|
|
mock_page.locator.side_effect = locator_side_effect
|
|
mock_element.wait_for.return_value = None
|
|
mock_element.screenshot.return_value = b"fake_screenshot"
|
|
mock_chart_container.wait_for.return_value = None
|
|
mock_page.wait_for_timeout.return_value = None
|
|
|
|
def evaluate_side_effect(script):
|
|
if script == 'document.querySelectorAll(".chart-container").length':
|
|
return 1
|
|
if "const target = document.querySelector" in script:
|
|
return 0
|
|
if "dashboard-component-chart-holder" in script:
|
|
return []
|
|
return None
|
|
|
|
mock_page.evaluate.side_effect = evaluate_side_effect
|
|
|
|
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_SELENIUM_ANIMATION_WAIT": 1,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 10,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_TILED_ENABLED": True,
|
|
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
|
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
|
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
|
}
|
|
|
|
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
|
|
mock_auth.return_value = mock_context
|
|
|
|
driver = WebDriverPlaywright("chrome")
|
|
result = driver.get_screenshot(
|
|
"http://example.com", "dashboard", mock_user
|
|
)
|
|
|
|
assert result == b"fake_screenshot"
|
|
# chart_count (1) is well below the tiling threshold (20), so this is
|
|
# the benign/expected case and must not be logged as a WARNING.
|
|
mock_logger.debug.assert_any_call(
|
|
"Could not determine dashboard height for element %s "
|
|
"at url %s (%s chart containers found); %s%s",
|
|
"dashboard",
|
|
"http://example.com",
|
|
1,
|
|
"falling back to standard screenshot behavior",
|
|
"",
|
|
)
|
|
assert not any(
|
|
call.args and "Could not determine dashboard height" in call.args[0]
|
|
for call in mock_logger.warning.call_args_list
|
|
)
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.take_tiled_screenshot")
|
|
def test_unknown_height_does_not_veto_tiling_for_large_dashboard(
|
|
self, mock_take_tiled, mock_logger, mock_browser_manager
|
|
):
|
|
"""
|
|
A large dashboard (by chart_count) whose height can't be measured
|
|
must still attempt tiling instead of being silently downgraded to a
|
|
standard screenshot, since below-the-fold charts may not have
|
|
rendered without the scroll-driven tiling pass.
|
|
"""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
mock_chart_container = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.return_value = mock_browser
|
|
mock_browser.new_context.return_value = mock_context
|
|
mock_context.new_page.return_value = mock_page
|
|
|
|
def locator_side_effect(selector):
|
|
if selector == ".chart-container":
|
|
locator = MagicMock()
|
|
locator.all.return_value = [mock_chart_container]
|
|
return locator
|
|
return mock_element
|
|
|
|
mock_page.locator.side_effect = locator_side_effect
|
|
mock_element.wait_for.return_value = None
|
|
mock_chart_container.wait_for.return_value = None
|
|
mock_page.wait_for_timeout.return_value = None
|
|
mock_take_tiled.return_value = b"tiled_screenshot"
|
|
|
|
def evaluate_side_effect(script):
|
|
if script == 'document.querySelectorAll(".chart-container").length':
|
|
return 25 # chart_count >= threshold
|
|
if "const target = document.querySelector" in script:
|
|
return 0 # height could not be determined
|
|
return None
|
|
|
|
mock_page.evaluate.side_effect = evaluate_side_effect
|
|
|
|
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_SELENIUM_ANIMATION_WAIT": 1,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 10,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_TILED_ENABLED": True,
|
|
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
|
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
|
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
|
}
|
|
|
|
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
|
|
mock_auth.return_value = mock_context
|
|
|
|
driver = WebDriverPlaywright("chrome")
|
|
result = driver.get_screenshot(
|
|
"http://example.com", "dashboard", mock_user
|
|
)
|
|
|
|
assert result == b"tiled_screenshot"
|
|
mock_take_tiled.assert_called_once()
|
|
mock_logger.warning.assert_any_call(
|
|
"Could not determine dashboard height for element %s "
|
|
"at url %s (%s chart containers found); %s%s",
|
|
"dashboard",
|
|
"http://example.com",
|
|
25,
|
|
"attempting tiled screenshot anyway",
|
|
"",
|
|
)
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
def test_chart_container_timeout_logs_warning_with_progress_and_raises(
|
|
self, mock_logger, mock_browser_manager
|
|
):
|
|
"""
|
|
Timing out while waiting for `.chart-container` elements to draw must
|
|
be logged as a WARNING (matching the other locate-wait timeouts in
|
|
this method, and the customer-side-slowness convention established
|
|
for these Playwright timeouts) with rendered/total progress, and must
|
|
still fail the screenshot by re-raising.
|
|
"""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.return_value = mock_browser
|
|
mock_browser.new_context.return_value = mock_context
|
|
mock_context.new_page.return_value = mock_page
|
|
|
|
timeout = PlaywrightTimeout()
|
|
rendered_ok = MagicMock()
|
|
rendered_ok.wait_for.return_value = None
|
|
never_renders = MagicMock()
|
|
never_renders.wait_for.side_effect = timeout
|
|
|
|
def locator_side_effect(selector):
|
|
if selector == ".chart-container":
|
|
locator = MagicMock()
|
|
locator.all.return_value = [rendered_ok, never_renders]
|
|
return locator
|
|
return mock_element
|
|
|
|
mock_page.locator.side_effect = locator_side_effect
|
|
mock_element.wait_for.return_value = None
|
|
|
|
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_SELENIUM_ANIMATION_WAIT": 1,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 10,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_TILED_ENABLED": False,
|
|
}
|
|
|
|
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
|
|
mock_auth.return_value = mock_context
|
|
|
|
driver = WebDriverPlaywright("chrome")
|
|
with pytest.raises(PlaywrightTimeout) as exc_info:
|
|
driver.get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
assert exc_info.value is timeout
|
|
mock_logger.warning.assert_any_call(
|
|
"Timed out waiting for chart containers to draw at url %s "
|
|
"(%s of %s chart containers rendered before the timeout)%s",
|
|
"http://example.com",
|
|
1,
|
|
2,
|
|
"",
|
|
exc_info=True,
|
|
)
|
|
mock_logger.exception.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.take_tiled_screenshot")
|
|
def test_tiled_screenshot_failure_raises_without_fallback(
|
|
self, mock_take_tiled, mock_logger, mock_browser_manager
|
|
) -> None:
|
|
"""When take_tiled_screenshot returns None, fail loudly instead of
|
|
falling back to an unguarded standard screenshot."""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.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.wait_for.return_value = None
|
|
# page.screenshot is used by _get_screenshot for the "standalone" element;
|
|
# it must never be reached by the failure path under test.
|
|
mock_page.screenshot.return_value = b"fallback_screenshot"
|
|
|
|
def evaluate_side_effect(script):
|
|
if "querySelectorAll" in script:
|
|
return 25 # chart_count >= threshold
|
|
if "const target" in script:
|
|
return 6000 # dashboard_height > height_threshold and > tile_height
|
|
return None
|
|
|
|
mock_page.evaluate.side_effect = evaluate_side_effect
|
|
mock_take_tiled.return_value = None # tiled screenshot returns None
|
|
|
|
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": 0,
|
|
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 0,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 10,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_TILED_ENABLED": True,
|
|
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
|
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
|
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
|
}
|
|
|
|
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
|
|
mock_auth.return_value = mock_context
|
|
|
|
driver = WebDriverPlaywright("chrome")
|
|
# match= keeps this assertion meaningful even when playwright
|
|
# is not installed and PlaywrightTimeout aliases bare Exception.
|
|
with pytest.raises(
|
|
PlaywrightTimeout, match="Tiled screenshot failed for url"
|
|
):
|
|
driver.get_screenshot(
|
|
"http://example.com",
|
|
"standalone",
|
|
mock_user,
|
|
report_execution_context=_report_context(),
|
|
)
|
|
|
|
mock_take_tiled.assert_called_once()
|
|
mock_page.screenshot.assert_not_called()
|
|
mock_element.screenshot.assert_not_called()
|
|
assert any(
|
|
"no safe fallback exists" in call.args[0]
|
|
for call in mock_logger.warning.call_args_list
|
|
)
|
|
|
|
|
|
class TestWebDriverPlaywrightChartReadiness:
|
|
"""Regression tests for the non-tiled vacuous-pass fix.
|
|
|
|
The readiness predicate itself (`CHART_HOLDERS_READY_JS` /
|
|
`FIND_UNREADY_CHART_HOLDERS_JS` in screenshot_utils.py) is exercised
|
|
directly in test_screenshot_utils.py; these tests confirm the standard
|
|
(non-tiled) `get_screenshot` path wires up to that *same* shared
|
|
predicate instead of the old absence-of-`.loading` check, which passed
|
|
vacuously for a chart holder that hadn't mounted anything yet.
|
|
"""
|
|
|
|
_base_config = {
|
|
"WEBDRIVER_OPTION_ARGS": [],
|
|
"WEBDRIVER_WINDOW": {"pixel_density": 1},
|
|
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
|
|
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
|
|
"SCREENSHOT_SELENIUM_HEADSTART": 0,
|
|
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 0,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_TILED_ENABLED": False,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 5,
|
|
}
|
|
|
|
def _make_pw_mocks(self, mock_browser_manager):
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.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"screenshot"
|
|
return mock_context, mock_page
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_chart_holder_with_nothing_mounted_does_not_satisfy_wait(
|
|
self, mock_app, mock_browser_manager
|
|
):
|
|
"""A chart holder present in the DOM but with nothing mounted yet (no
|
|
spinner, no rendered content -- e.g. the gap between page-load
|
|
completing and React/query bootstrap) must not satisfy the readiness
|
|
wait. The old `.loading`-absence check passed immediately in this
|
|
case, producing a silently blank screenshot.
|
|
"""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {**self._base_config}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
|
|
def fake_wait_for_function(js, timeout=None):
|
|
# Confirm the predicate sent is the shared terminal-state check
|
|
# (not the old absence-of-`.loading` check), then simulate it
|
|
# correctly reporting "not ready" for a chart holder that hasn't
|
|
# mounted anything.
|
|
assert "dashboard-component-chart-holder" in js
|
|
raise PlaywrightTimeout("Timeout waiting for chart holders")
|
|
|
|
mock_page.wait_for_function.side_effect = fake_wait_for_function
|
|
mock_page.evaluate.return_value = [{"chartId": "7", "state": "nothing_mounted"}]
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
driver = WebDriverPlaywright("chrome")
|
|
with pytest.raises(PlaywrightTimeout):
|
|
driver.get_screenshot("http://example.com", "test-element", mock_user)
|
|
|
|
# No screenshot should be captured -- fail loudly instead of
|
|
# silently returning a blank image.
|
|
mock_page.screenshot.assert_not_called()
|
|
mock_page.locator.return_value.screenshot.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_all_chart_holders_ready_passes(self, mock_app, mock_browser_manager):
|
|
"""All chart holders rendered or errored -> wait passes, screenshot taken."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {**self._base_config}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
# mock_page.wait_for_function is a no-op MagicMock by default, i.e.
|
|
# the readiness predicate is satisfied immediately.
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
driver = WebDriverPlaywright("chrome")
|
|
result = driver.get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
assert result == b"screenshot"
|
|
# Readiness diagnostics are emitted before polling so a task killed by
|
|
# an outer limit still leaves useful state in the logs.
|
|
assert mock_page.evaluate.call_count == 2
|
|
assert all(
|
|
"state: 'rendered'" in call.args[0]
|
|
for call in mock_page.evaluate.call_args_list
|
|
)
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_chart_capture_uses_positive_terminal_state_predicate(
|
|
self, mock_app, mock_browser_manager
|
|
):
|
|
"""Chart captures require a terminal marker and no loading marker."""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_app.config = {**self._base_config}
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
timeout = PlaywrightTimeout("chart not ready")
|
|
mock_page.wait_for_function.side_effect = timeout
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
with pytest.raises(PlaywrightTimeout):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com",
|
|
"chart-container",
|
|
MagicMock(),
|
|
report_execution_context=_report_context(
|
|
dashboard_id=None,
|
|
chart_id=7,
|
|
expected_chart_count=1,
|
|
),
|
|
)
|
|
|
|
predicate = mock_page.wait_for_function.call_args.args[0]
|
|
assert "document.querySelector('.chart-container')" in predicate
|
|
assert ".slice_container" in predicate
|
|
assert ".loading" in predicate
|
|
assert '[role="alert"]' in predicate
|
|
assert ".ant-empty" in predicate
|
|
assert ".missing-chart-container" in predicate
|
|
mock_page.locator.return_value.screenshot.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_standalone_zero_holders_remain_not_ready_and_skip_capture(
|
|
self, mock_app, mock_logger, mock_browser_manager
|
|
):
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_app.config = {**self._base_config}
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
mock_page.evaluate.return_value = []
|
|
mock_page.wait_for_function.side_effect = PlaywrightTimeout("zero holders")
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
with pytest.raises(PlaywrightTimeout):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com",
|
|
"standalone",
|
|
MagicMock(),
|
|
report_execution_context=_report_context(),
|
|
)
|
|
|
|
assert any(
|
|
"report_readiness_waiting_for_mount" in call.args[0]
|
|
for call in mock_logger.info.call_args_list
|
|
)
|
|
assert (
|
|
"terminal_reason=readiness_timeout" in mock_logger.warning.call_args.args[0]
|
|
)
|
|
mock_page.screenshot.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_thumbnail_zero_holders_preserves_existing_capture_behavior(
|
|
self,
|
|
mock_app,
|
|
mock_browser_manager,
|
|
):
|
|
mock_app.config = {**self._base_config}
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
mock_page.evaluate.return_value = []
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
result = WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com",
|
|
"standalone",
|
|
MagicMock(),
|
|
)
|
|
|
|
predicate = mock_page.wait_for_function.call_args.args[0]
|
|
assert "holders.length > 0" not in predicate
|
|
assert result == mock_page.screenshot.return_value
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_readiness_check_scoped_to_viewport_visible_holders(
|
|
self, mock_app, mock_browser_manager
|
|
):
|
|
"""The non-tiled readiness check only requires viewport-intersecting
|
|
chart holders to be ready, mirroring the tiled path's reasoning
|
|
(#42119). `get_screenshot`'s standard (non-tiled) branch never
|
|
resizes the browser viewport to the full dashboard height before
|
|
capturing -- only the tiled branch's `set_viewport_size` call does
|
|
that -- so a below-the-fold chart holder is a
|
|
DashboardVirtualization placeholder that hasn't mounted anything
|
|
real yet by design and must not block this wait.
|
|
"""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {**self._base_config}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
driver = WebDriverPlaywright("chrome")
|
|
driver.get_screenshot("http://example.com", "test-element", mock_user)
|
|
|
|
js = mock_page.wait_for_function.call_args[0][0]
|
|
# The predicate skips any chart holder whose bounding rect doesn't
|
|
# intersect the current viewport -- an off-screen/below-fold holder
|
|
# is excluded from the readiness requirement rather than blocking it.
|
|
assert "getBoundingClientRect" in js
|
|
assert "window.innerHeight" in js
|
|
# set_viewport_size is only ever called on the tiled branch (to
|
|
# resize to tile_height); confirming it's untouched here is what
|
|
# makes the viewport-scoped predicate necessary for this branch.
|
|
mock_page.set_viewport_size.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_log_context_threaded_into_readiness_wait(
|
|
self, mock_app, mock_logger, mock_browser_manager
|
|
):
|
|
"""log_context (e.g. report execution id) is threaded through the
|
|
non-tiled readiness wait for correlation, matching #42119's
|
|
convention for the tiled path."""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {**self._base_config}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
mock_page.wait_for_function.side_effect = PlaywrightTimeout("timed out")
|
|
mock_page.evaluate.return_value = [{"chartId": "7", "state": "nothing_mounted"}]
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
driver = WebDriverPlaywright("chrome")
|
|
with pytest.raises(PlaywrightTimeout):
|
|
driver.get_screenshot(
|
|
"http://example.com",
|
|
"test-element",
|
|
mock_user,
|
|
log_context="execution_id=abc-123",
|
|
)
|
|
|
|
assert mock_logger.warning.call_args.args[8] == " [execution_id=abc-123]"
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_wait_is_capped_to_remaining_runtime_task_budget(
|
|
self, mock_app, mock_logger, mock_browser_manager
|
|
):
|
|
"""Elapsed setup time is removed from the task-derived safe budget."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {
|
|
**self._base_config,
|
|
"SCREENSHOT_LOAD_WAIT": 600,
|
|
}
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
|
|
with (
|
|
patch.object(WebDriverPlaywright, "auth", return_value=mock_context),
|
|
patch(
|
|
"superset.utils.webdriver.resolve_screenshot_task_budget_seconds",
|
|
return_value=240,
|
|
),
|
|
patch(
|
|
"superset.utils.webdriver.time.monotonic",
|
|
side_effect=[100.0, 110.0],
|
|
),
|
|
):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
assert mock_page.wait_for_function.call_args.kwargs["timeout"] == 230_000
|
|
|
|
def test_report_readiness_uses_shared_deadline_and_phase_reserves(self):
|
|
from uuid import UUID
|
|
|
|
from superset.utils.report_execution import (
|
|
ReportExecutionContext,
|
|
ReportExecutionDeadline,
|
|
)
|
|
|
|
page = MagicMock()
|
|
page.evaluate.return_value = [{"chartId": "7", "state": "rendered"}]
|
|
deadline = ReportExecutionDeadline(
|
|
total_seconds=900,
|
|
started_at=0,
|
|
_clock=lambda: 100,
|
|
)
|
|
report_context = ReportExecutionContext(
|
|
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
|
|
report_schedule_id=11,
|
|
dashboard_id=805,
|
|
expected_chart_count=52,
|
|
deadline=deadline,
|
|
capture_reserve_seconds=60,
|
|
delivery_reserve_seconds=120,
|
|
cleanup_reserve_seconds=30,
|
|
)
|
|
|
|
WebDriverPlaywright._wait_for_charts_ready(
|
|
page,
|
|
"http://example.com/dashboard/805",
|
|
5,
|
|
"standalone",
|
|
report_execution_context=report_context,
|
|
)
|
|
|
|
assert page.wait_for_function.call_args.kwargs["timeout"] == 590_000
|
|
|
|
def test_report_readiness_budget_exhaustion_skips_poll_and_capture(self):
|
|
from uuid import UUID
|
|
|
|
from superset.utils.report_execution import (
|
|
ReportExecutionBudgetExceededError,
|
|
ReportExecutionContext,
|
|
ReportExecutionDeadline,
|
|
)
|
|
|
|
page = MagicMock()
|
|
page.evaluate.return_value = []
|
|
deadline = ReportExecutionDeadline(
|
|
total_seconds=900,
|
|
started_at=0,
|
|
_clock=lambda: 700,
|
|
)
|
|
report_context = ReportExecutionContext(
|
|
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
|
|
report_schedule_id=11,
|
|
dashboard_id=805,
|
|
expected_chart_count=52,
|
|
deadline=deadline,
|
|
capture_reserve_seconds=60,
|
|
delivery_reserve_seconds=120,
|
|
cleanup_reserve_seconds=30,
|
|
)
|
|
|
|
with pytest.raises(ReportExecutionBudgetExceededError):
|
|
WebDriverPlaywright._wait_for_charts_ready(
|
|
page,
|
|
"http://example.com/dashboard/805",
|
|
600,
|
|
"standalone",
|
|
report_execution_context=report_context,
|
|
)
|
|
|
|
page.wait_for_function.assert_not_called()
|
|
page.screenshot.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.logger")
|
|
def test_chart_capture_ready_logs_container_state_not_holder_counts(
|
|
self, mock_logger
|
|
):
|
|
"""Chart pages have no dashboard grid holders, so the ready line must
|
|
report the `.chart-container` state instead of vacuous zero counters
|
|
(which read as "no charts" in customer logs)."""
|
|
from superset.utils.screenshot_utils import CHART_CONTAINER_STATE_JS
|
|
|
|
page = MagicMock()
|
|
page.wait_for_function.return_value = None
|
|
page.evaluate.side_effect = lambda script: (
|
|
"terminal" if script == CHART_CONTAINER_STATE_JS else []
|
|
)
|
|
|
|
with patch(
|
|
"superset.utils.webdriver.resolve_screenshot_task_budget_seconds",
|
|
return_value=None,
|
|
):
|
|
WebDriverPlaywright._wait_for_charts_ready(
|
|
page,
|
|
"http://example.com",
|
|
10,
|
|
"chart-container",
|
|
log_context="capture_kind=alert execution_id=abc-123",
|
|
)
|
|
|
|
ready_call = next(
|
|
call
|
|
for call in mock_logger.info.call_args_list
|
|
if call.args and call.args[0].startswith("report_readiness_ready")
|
|
)
|
|
assert "target=chart-container" in ready_call.args[0]
|
|
assert "mounted_holders" not in ready_call.args[0]
|
|
assert "terminal" in ready_call.args
|
|
assert " [capture_kind=alert execution_id=abc-123]" in ready_call.args
|
|
|
|
@patch("superset.utils.webdriver.logger")
|
|
def test_chart_capture_timeout_logs_container_state(self, mock_logger):
|
|
from superset.utils.screenshot_utils import CHART_CONTAINER_STATE_JS
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
page = MagicMock()
|
|
page.wait_for_function.side_effect = PlaywrightTimeout()
|
|
page.evaluate.side_effect = lambda script: (
|
|
"loading" if script == CHART_CONTAINER_STATE_JS else []
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"superset.utils.webdriver.resolve_screenshot_task_budget_seconds",
|
|
return_value=None,
|
|
),
|
|
pytest.raises(PlaywrightTimeout),
|
|
):
|
|
WebDriverPlaywright._wait_for_charts_ready(
|
|
page,
|
|
"http://example.com",
|
|
10,
|
|
"chart-container",
|
|
)
|
|
|
|
terminal_call = next(
|
|
call
|
|
for call in mock_logger.warning.call_args_list
|
|
if call.args and call.args[0].startswith("report_readiness_terminal")
|
|
)
|
|
assert "target=chart-container" in terminal_call.args[0]
|
|
assert "terminal_reason=readiness_timeout" in terminal_call.args[0]
|
|
assert "mounted_holders" not in terminal_call.args[0]
|
|
assert "loading" in terminal_call.args
|
|
|
|
def test_zero_load_wait_without_task_budget_preserves_playwright_no_timeout(self):
|
|
page = MagicMock()
|
|
page.evaluate.return_value = []
|
|
|
|
with patch(
|
|
"superset.utils.webdriver.resolve_screenshot_task_budget_seconds",
|
|
return_value=None,
|
|
):
|
|
WebDriverPlaywright._wait_for_charts_ready(
|
|
page,
|
|
"http://example.com",
|
|
0,
|
|
"chart-container",
|
|
)
|
|
|
|
page.wait_for_function.assert_called_once()
|
|
assert page.wait_for_function.call_args.kwargs["timeout"] == 0
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_exhausted_task_budget_raises_before_capture(
|
|
self, mock_app, mock_logger, mock_browser_manager
|
|
):
|
|
"""An exhausted budget fails loudly while cleanup time remains."""
|
|
from superset.utils.webdriver import ScreenshotTaskBudgetExceededError
|
|
|
|
mock_user = MagicMock()
|
|
mock_app.config = {**self._base_config, "SCREENSHOT_LOAD_WAIT": 600}
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
diagnostics = [{"chartId": "17", "state": "nothing_mounted"}]
|
|
mock_page.evaluate.return_value = diagnostics
|
|
|
|
with (
|
|
patch.object(WebDriverPlaywright, "auth", return_value=mock_context),
|
|
patch(
|
|
"superset.utils.webdriver.resolve_screenshot_task_budget_seconds",
|
|
return_value=240,
|
|
),
|
|
patch(
|
|
"superset.utils.webdriver.time.monotonic",
|
|
side_effect=[100.0, 341.0],
|
|
),
|
|
):
|
|
with pytest.raises(ScreenshotTaskBudgetExceededError):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
mock_page.wait_for_function.assert_not_called()
|
|
mock_page.locator.return_value.screenshot.assert_not_called()
|
|
mock_context.close.assert_called_once()
|
|
warning_args = mock_logger.warning.call_args.args
|
|
assert "budget exhausted" in warning_args[0]
|
|
assert warning_args[5] == diagnostics
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.logger")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_unready_diagnostics_logged_early_and_at_failure(
|
|
self, mock_app, mock_logger, mock_browser_manager
|
|
):
|
|
"""Unready IDs/states are logged before polling and on timeout."""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {**self._base_config}
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
diagnostics = [{"chartId": "17", "state": "waiting_on_database"}]
|
|
mock_page.evaluate.return_value = diagnostics
|
|
mock_page.wait_for_function.side_effect = PlaywrightTimeout("timed out")
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
driver = WebDriverPlaywright("chrome")
|
|
with pytest.raises(PlaywrightTimeout):
|
|
driver.get_screenshot("http://example.com", "test-element", mock_user)
|
|
|
|
mock_logger.info.assert_any_call(
|
|
"Chart holders not ready before polling at url %s%s: %s",
|
|
"http://example.com",
|
|
"",
|
|
diagnostics,
|
|
)
|
|
failure_args = mock_logger.warning.call_args.args
|
|
assert failure_args[9] == diagnostics
|
|
assert failure_args[10] == diagnostics
|
|
mock_page.locator.return_value.screenshot.assert_not_called()
|
|
|
|
|
|
class TestWebDriverPlaywrightAnimationWaitOrder:
|
|
"""Animation wait must run after the spinner wait, not before."""
|
|
|
|
_base_config = {
|
|
"WEBDRIVER_OPTION_ARGS": [],
|
|
"WEBDRIVER_WINDOW": {"pixel_density": 1},
|
|
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
|
|
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
|
|
"SCREENSHOT_SELENIUM_HEADSTART": 0,
|
|
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 2,
|
|
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
|
"SCREENSHOT_LOCATE_WAIT": 10,
|
|
"SCREENSHOT_LOAD_WAIT": 30,
|
|
}
|
|
|
|
def _make_pw_mocks(self, mock_browser_manager):
|
|
mock_browser = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_element = MagicMock()
|
|
|
|
mock_browser_manager.get_browser.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"screenshot"
|
|
return mock_context, mock_page
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_animation_wait_after_spinner_wait_tiled_disabled(
|
|
self, mock_app, mock_browser_manager
|
|
):
|
|
"""Non-tiled path: animation wait runs after spinner wait_for_function."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {**self._base_config, "SCREENSHOT_TILED_ENABLED": False}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
|
|
call_order: list[str] = []
|
|
|
|
def record_wait_for_function(*args, **kwargs):
|
|
call_order.append("spinner_wait")
|
|
|
|
def record_wait_for_timeout(ms):
|
|
if ms == 2 * 1000:
|
|
call_order.append("animation_wait")
|
|
|
|
mock_page.wait_for_function.side_effect = record_wait_for_function
|
|
mock_page.wait_for_timeout.side_effect = record_wait_for_timeout
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
assert "spinner_wait" in call_order
|
|
assert "animation_wait" in call_order
|
|
spinner_idx = call_order.index("spinner_wait")
|
|
anim_idx = call_order.index("animation_wait")
|
|
assert spinner_idx < anim_idx, (
|
|
"spinner wait must precede animation wait in non-tiled path"
|
|
)
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_animation_wait_after_spinner_wait_tiled_enabled_small_dashboard(
|
|
self, mock_app, mock_browser_manager
|
|
):
|
|
"""Non-tiled path (tiled on, small dashboard): animation after spinner."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {
|
|
**self._base_config,
|
|
"SCREENSHOT_TILED_ENABLED": True,
|
|
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
|
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
|
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
|
}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
|
|
# Small dashboard: 3 charts, 1000px height — below both thresholds
|
|
mock_page.evaluate.side_effect = [3, 1000, [], []]
|
|
|
|
call_order: list[str] = []
|
|
|
|
def record_wait_for_function(*args, **kwargs):
|
|
call_order.append("spinner_wait")
|
|
|
|
def record_wait_for_timeout(ms):
|
|
if ms == 2 * 1000:
|
|
call_order.append("animation_wait")
|
|
|
|
mock_page.wait_for_function.side_effect = record_wait_for_function
|
|
mock_page.wait_for_timeout.side_effect = record_wait_for_timeout
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
assert "spinner_wait" in call_order
|
|
assert "animation_wait" in call_order
|
|
assert call_order.index("spinner_wait") < call_order.index("animation_wait")
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.take_tiled_screenshot")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_chart_threshold_does_not_tile_short_dashboard(
|
|
self, mock_app, mock_take_tiled, mock_browser_manager
|
|
):
|
|
"""Preserve the historical height guard for reports and thumbnails."""
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {
|
|
**self._base_config,
|
|
"SCREENSHOT_TILED_ENABLED": True,
|
|
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
|
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
|
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
|
}
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
mock_page.evaluate.side_effect = [25, 500, [], []]
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
result = WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
assert result == b"screenshot"
|
|
mock_take_tiled.assert_not_called()
|
|
mock_page.set_viewport_size.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.take_tiled_screenshot")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_tiled_path_passes_animation_wait_per_tile_no_global_wait(
|
|
self, mock_app, mock_take_tiled, mock_browser_manager
|
|
):
|
|
"""Tiled path delegates animation_wait to take_tiled_screenshot; no global."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {
|
|
**self._base_config,
|
|
"SCREENSHOT_TILED_ENABLED": True,
|
|
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
|
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
|
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
|
}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
|
|
# Large dashboard: 25 charts, 6000px height
|
|
mock_page.evaluate.side_effect = [25, 6000]
|
|
mock_take_tiled.return_value = b"tiled_screenshot"
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
result = WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "standalone", mock_user
|
|
)
|
|
|
|
assert result == b"tiled_screenshot"
|
|
mock_take_tiled.assert_called_once_with(
|
|
mock_page,
|
|
"standalone",
|
|
600,
|
|
load_wait=30,
|
|
animation_wait=2,
|
|
log_context=None,
|
|
report_execution_context=None,
|
|
url="http://example.com",
|
|
screenshot_started_at=ANY,
|
|
)
|
|
# The only wait_for_timeout call should be the 0ms headstart; no global
|
|
# animation wait should be issued (handled per-tile by take_tiled_screenshot)
|
|
animation_waits = [
|
|
call[0][0]
|
|
for call in mock_page.wait_for_timeout.call_args_list
|
|
if call[0][0] == 2 * 1000
|
|
]
|
|
assert animation_waits == [], (
|
|
"No global 2s animation wait_for_timeout should fire on the tiled path"
|
|
)
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.take_tiled_screenshot")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_tiled_empty_bytes_raises_without_fallback(
|
|
self, mock_app, mock_take_tiled, mock_browser_manager
|
|
):
|
|
"""Tiled failure raises when take_tiled_screenshot returns b"" (not None),
|
|
instead of silently falling through to an unguarded raw capture."""
|
|
from superset.utils.webdriver import PlaywrightTimeout
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {
|
|
**self._base_config,
|
|
"SCREENSHOT_TILED_ENABLED": True,
|
|
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
|
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
|
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
|
}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
mock_page.evaluate.side_effect = [25, 6000]
|
|
# Empty bytes — falsy but not None; was silently passed through before the fix
|
|
mock_take_tiled.return_value = b""
|
|
# _get_screenshot("standalone") calls page.screenshot(full_page=True); it
|
|
# must never be reached by the failure path under test.
|
|
mock_page.screenshot.return_value = b"fallback"
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
# match= keeps this assertion meaningful even when playwright
|
|
# is not installed and PlaywrightTimeout aliases bare Exception.
|
|
with pytest.raises(
|
|
PlaywrightTimeout, match="Tiled screenshot failed for url"
|
|
):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "standalone", mock_user
|
|
)
|
|
|
|
# Tiled path was taken (take_tiled_screenshot was called)
|
|
mock_take_tiled.assert_called_once()
|
|
# Standard screenshot must never be called as a fallback
|
|
mock_page.screenshot.assert_not_called()
|
|
|
|
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
|
@patch("superset.utils.webdriver._browser_manager")
|
|
@patch("superset.utils.webdriver.app")
|
|
def test_animation_wait_skipped_when_zero(self, mock_app, mock_browser_manager):
|
|
"""No extra wait_for_timeout call when SCREENSHOT_SELENIUM_ANIMATION_WAIT=0."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test_user"
|
|
mock_app.config = {
|
|
**self._base_config,
|
|
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 0,
|
|
"SCREENSHOT_TILED_ENABLED": False,
|
|
}
|
|
|
|
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
|
|
|
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
|
WebDriverPlaywright("chrome").get_screenshot(
|
|
"http://example.com", "test-element", mock_user
|
|
)
|
|
|
|
# Only headstart (0ms) should be called; no animation wait call
|
|
timeout_values = [
|
|
call[0][0] for call in mock_page.wait_for_timeout.call_args_list
|
|
]
|
|
assert timeout_values == [0], (
|
|
f"Expected only [0] (headstart), got {timeout_values}"
|
|
)
|