mirror of
https://github.com/apache/superset.git
synced 2026-08-21 07:31:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439942729c |
@@ -241,6 +241,19 @@ CHART_CONTAINER_READY_JS = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
# Full scrollable height of the rendered document. Used to grow the browser
|
||||
# viewport to span the whole dashboard before a standalone (non-tiled) capture
|
||||
# so every chart holder intersects the viewport and DashboardVirtualization
|
||||
# mounts it, instead of leaving below-the-fold holders as blank placeholders
|
||||
# (the virtualized-holder blank-report failure mode -- the readiness gate built
|
||||
# on UNREADY_CHART_HOLDERS_JS_BODY above deliberately ignores off-screen holders
|
||||
# to avoid deadlocking on lazy charts, #42624).
|
||||
DASHBOARD_CONTENT_HEIGHT_JS = (
|
||||
"() => Math.max("
|
||||
"document.body ? document.body.scrollHeight : 0, "
|
||||
"document.documentElement ? document.documentElement.scrollHeight : 0)"
|
||||
)
|
||||
|
||||
# Diagnostic companion to CHART_CONTAINER_READY_JS: reports why a chart
|
||||
# capture is (or is not) ready. Chart pages have no dashboard grid holders,
|
||||
# so the holder-count diagnostics read as vacuous zeros there.
|
||||
|
||||
+130
-10
@@ -49,6 +49,7 @@ from superset.utils.screenshot_utils import (
|
||||
CHART_CONTAINER_READY_JS,
|
||||
CHART_CONTAINER_STATE_JS,
|
||||
CHART_HOLDERS_READY_JS,
|
||||
DASHBOARD_CONTENT_HEIGHT_JS,
|
||||
FIND_CHART_HOLDER_STATES_JS,
|
||||
REPORT_CHART_HOLDERS_READY_JS,
|
||||
resolve_screenshot_task_budget_seconds,
|
||||
@@ -66,6 +67,13 @@ PLAYWRIGHT_INSTALL_MESSAGE = (
|
||||
"pip install playwright && playwright install chromium"
|
||||
)
|
||||
|
||||
# Upper bound on the viewport height the standalone (non-tiled) capture grows
|
||||
# to when mounting off-screen chart holders. A full_page screenshot already
|
||||
# rasterizes the whole document, so this is a memory guard for pathologically
|
||||
# tall dashboards, not a new capability limit; taller dashboards should enable
|
||||
# SCREENSHOT_TILED_ENABLED, which captures tile-by-tile instead.
|
||||
MAX_STANDALONE_CAPTURE_VIEWPORT_HEIGHT = 30000
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
@@ -304,6 +312,93 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
else:
|
||||
return element.screenshot(**timeout_kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _mount_offscreen_chart_holders(
|
||||
page: Page,
|
||||
url: str,
|
||||
element_name: str,
|
||||
viewport_width: int,
|
||||
viewport_height: int,
|
||||
log_context: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Grow the viewport to the full dashboard height so every chart holder is
|
||||
pulled into the viewport and DashboardVirtualization mounts it before a
|
||||
standalone (non-tiled) ``full_page`` capture.
|
||||
|
||||
DashboardVirtualization only mounts chart holders whose bounding rect
|
||||
intersects the window viewport; off-screen holders render an empty
|
||||
placeholder. The standalone capture takes a ``full_page`` screenshot
|
||||
that includes below-the-fold content, while its readiness gate
|
||||
deliberately ignores off-screen ("virtualized") holders to avoid
|
||||
deadlocking on lazy charts (#42624). Together those produce a silent
|
||||
failure: a below-the-fold holder that never mounts is declared ready
|
||||
and then captured blank.
|
||||
|
||||
Superset also disables virtualization for automation browsers via
|
||||
``navigator.webdriver`` (``isCurrentUserBot``), but that heuristic is
|
||||
fragile -- any deployment where ``navigator.webdriver`` is falsy
|
||||
(custom browser args, anti-automation flags) re-enables virtualization
|
||||
and reintroduces blank reports. Expanding the viewport mounts every
|
||||
holder regardless, so the readiness gate then requires them all to
|
||||
reach a terminal state before capture.
|
||||
|
||||
Best-effort: this only mounts charts, so any failure is swallowed and
|
||||
the readiness wait still runs against the configured viewport.
|
||||
"""
|
||||
if element_name != "standalone":
|
||||
# Chart captures (`chart-container`) have a single target and no
|
||||
# dashboard grid to virtualize; other elements are not full-page
|
||||
# dashboard captures.
|
||||
return
|
||||
context_suffix = f" [{log_context}]" if log_context else ""
|
||||
try:
|
||||
content_height = page.evaluate(DASHBOARD_CONTENT_HEIGHT_JS)
|
||||
if not isinstance(content_height, (int, float)) or content_height <= 0:
|
||||
return
|
||||
target_height = min(
|
||||
max(int(content_height), int(viewport_height)),
|
||||
MAX_STANDALONE_CAPTURE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
if target_height <= viewport_height:
|
||||
# The whole dashboard already fits in the viewport; nothing is
|
||||
# off-screen, so there is nothing to mount.
|
||||
return
|
||||
page.set_viewport_size({"width": viewport_width, "height": target_height})
|
||||
# Resizing recomputes IntersectionObserver intersections and mounts
|
||||
# the newly in-view holders; scroll back to the top so the
|
||||
# full_page capture starts there against a stable scroll position.
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
logger.info(
|
||||
"report_capture_viewport_expanded url=%s content_height=%s "
|
||||
"viewport_height=%s target_height=%s%s",
|
||||
url,
|
||||
int(content_height),
|
||||
viewport_height,
|
||||
target_height,
|
||||
context_suffix,
|
||||
)
|
||||
if content_height > MAX_STANDALONE_CAPTURE_VIEWPORT_HEIGHT:
|
||||
logger.warning(
|
||||
"Dashboard content height %spx exceeds the standalone "
|
||||
"capture viewport cap of %spx at url %s%s; holders below "
|
||||
"the cap may remain virtualized and render blank. Enable "
|
||||
"SCREENSHOT_TILED_ENABLED for very tall dashboards.",
|
||||
int(content_height),
|
||||
MAX_STANDALONE_CAPTURE_VIEWPORT_HEIGHT,
|
||||
url,
|
||||
context_suffix,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to expand the viewport to mount off-screen chart "
|
||||
"holders before the standalone capture at url %s%s; proceeding "
|
||||
"with the configured viewport",
|
||||
url,
|
||||
context_suffix,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_charts_ready( # noqa: C901
|
||||
page: Page,
|
||||
@@ -329,10 +424,13 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
timeout, warning, or error anywhere.
|
||||
|
||||
Scoped to viewport-intersecting chart holders only, same as the tiled
|
||||
path: this method's caller never resizes the browser viewport to the
|
||||
full dashboard height before capturing, so DashboardVirtualization
|
||||
placeholders below the fold haven't mounted anything real yet by
|
||||
design and must not block this wait.
|
||||
path. For standalone dashboard captures the caller first grows the
|
||||
viewport to span the whole dashboard (see
|
||||
``_mount_offscreen_chart_holders``) so DashboardVirtualization mounts
|
||||
every holder and they all become viewport-intersecting; a holder that
|
||||
still stays below the fold afterwards (e.g. a dashboard taller than the
|
||||
viewport cap) is treated as an unmounted virtualization placeholder and
|
||||
must not block this wait.
|
||||
"""
|
||||
task_budget: float | None
|
||||
remaining_budget: float | None
|
||||
@@ -881,9 +979,20 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
url,
|
||||
context_suffix,
|
||||
)
|
||||
# Standard screenshot captures the full element including
|
||||
# below-the-fold content, so wait for all viewport-visible
|
||||
# chart holders to reach a terminal state.
|
||||
# Standard screenshot captures the full element
|
||||
# (full_page for dashboards), including below-the-fold
|
||||
# content. Grow the viewport to the whole dashboard
|
||||
# first so DashboardVirtualization mounts every
|
||||
# off-screen chart holder, then wait for all holders to
|
||||
# reach a terminal state.
|
||||
WebDriverPlaywright._mount_offscreen_chart_holders(
|
||||
page,
|
||||
url,
|
||||
element_name,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
log_context=log_context,
|
||||
)
|
||||
WebDriverPlaywright._wait_for_charts_ready(
|
||||
page,
|
||||
url,
|
||||
@@ -945,9 +1054,20 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
url,
|
||||
context_suffix,
|
||||
)
|
||||
# Standard screenshot captures the full element including
|
||||
# below-the-fold content, so wait for all viewport-visible
|
||||
# chart holders to reach a terminal state.
|
||||
# Standard screenshot captures the full element (full_page
|
||||
# for dashboards), including below-the-fold content. Grow
|
||||
# the viewport to the whole dashboard first so
|
||||
# DashboardVirtualization mounts every off-screen chart
|
||||
# holder, then wait for all holders to reach a terminal
|
||||
# state.
|
||||
WebDriverPlaywright._mount_offscreen_chart_holders(
|
||||
page,
|
||||
url,
|
||||
element_name,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
log_context=log_context,
|
||||
)
|
||||
WebDriverPlaywright._wait_for_charts_ready(
|
||||
page,
|
||||
url,
|
||||
|
||||
@@ -1555,11 +1555,117 @@ class TestWebDriverPlaywrightChartReadiness:
|
||||
# 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.
|
||||
# The non-tiled branch resizes the viewport only for a "standalone"
|
||||
# full-page dashboard capture (to mount off-screen holders); this test
|
||||
# uses a non-standalone element, so no resize happens and the
|
||||
# viewport-scoped predicate is what keeps below-the-fold holders from
|
||||
# blocking the wait.
|
||||
mock_page.set_viewport_size.assert_not_called()
|
||||
|
||||
def test_mount_offscreen_holders_resizes_tall_standalone_dashboard(self):
|
||||
"""A dashboard taller than the viewport is grown to its full height so
|
||||
DashboardVirtualization mounts every off-screen ("virtualized") chart
|
||||
holder before the standalone full_page capture."""
|
||||
page = MagicMock()
|
||||
page.evaluate.return_value = 4000
|
||||
WebDriverPlaywright._mount_offscreen_chart_holders(
|
||||
page, "http://example.com/dashboard/21", "standalone", 800, 600
|
||||
)
|
||||
page.set_viewport_size.assert_called_once_with({"width": 800, "height": 4000})
|
||||
|
||||
def test_mount_offscreen_holders_skips_when_dashboard_fits_viewport(self):
|
||||
"""Nothing is off-screen when the dashboard already fits the viewport,
|
||||
so the viewport is left untouched."""
|
||||
page = MagicMock()
|
||||
page.evaluate.return_value = 500
|
||||
WebDriverPlaywright._mount_offscreen_chart_holders(
|
||||
page, "http://example.com/dashboard/21", "standalone", 800, 600
|
||||
)
|
||||
page.set_viewport_size.assert_not_called()
|
||||
|
||||
def test_mount_offscreen_holders_scoped_to_standalone(self):
|
||||
"""Chart captures have no dashboard grid to virtualize, so the helper
|
||||
no-ops (it never even measures the page) for non-standalone elements."""
|
||||
page = MagicMock()
|
||||
WebDriverPlaywright._mount_offscreen_chart_holders(
|
||||
page, "http://example.com", "chart-container", 800, 600
|
||||
)
|
||||
page.evaluate.assert_not_called()
|
||||
page.set_viewport_size.assert_not_called()
|
||||
|
||||
@patch("superset.utils.webdriver.logger")
|
||||
def test_mount_offscreen_holders_caps_height_and_warns(self, mock_logger):
|
||||
"""A pathologically tall dashboard is capped at the memory guard and a
|
||||
warning recommends enabling tiled screenshots."""
|
||||
from superset.utils.webdriver import MAX_STANDALONE_CAPTURE_VIEWPORT_HEIGHT
|
||||
|
||||
page = MagicMock()
|
||||
page.evaluate.return_value = MAX_STANDALONE_CAPTURE_VIEWPORT_HEIGHT + 50000
|
||||
WebDriverPlaywright._mount_offscreen_chart_holders(
|
||||
page, "http://example.com/dashboard/21", "standalone", 800, 600
|
||||
)
|
||||
page.set_viewport_size.assert_called_once_with(
|
||||
{"width": 800, "height": MAX_STANDALONE_CAPTURE_VIEWPORT_HEIGHT}
|
||||
)
|
||||
assert any(
|
||||
"exceeds the standalone" in call.args[0]
|
||||
for call in mock_logger.warning.call_args_list
|
||||
)
|
||||
|
||||
@patch("superset.utils.webdriver.logger")
|
||||
def test_mount_offscreen_holders_swallows_errors(self, mock_logger):
|
||||
"""Mounting is best-effort: a failure must never abort the capture."""
|
||||
page = MagicMock()
|
||||
page.evaluate.side_effect = RuntimeError("boom")
|
||||
# Must not raise.
|
||||
WebDriverPlaywright._mount_offscreen_chart_holders(
|
||||
page, "http://example.com/dashboard/21", "standalone", 800, 600
|
||||
)
|
||||
page.set_viewport_size.assert_not_called()
|
||||
assert mock_logger.warning.called
|
||||
|
||||
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
||||
@patch("superset.utils.webdriver._browser_manager")
|
||||
@patch("superset.utils.webdriver.app")
|
||||
def test_standalone_report_expands_viewport_before_readiness(
|
||||
self, mock_app, mock_browser_manager
|
||||
):
|
||||
"""Regression for blank scheduled reports: the non-tiled standalone
|
||||
report path grows the viewport to the whole dashboard (mounting
|
||||
off-screen holders) *before* the readiness wait, so virtualized
|
||||
below-the-fold charts are required to render rather than captured
|
||||
blank."""
|
||||
from superset.utils.screenshot_utils import DASHBOARD_CONTENT_HEIGHT_JS
|
||||
|
||||
mock_app.config = {**self._base_config}
|
||||
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
|
||||
|
||||
def fake_eval(script, *args, **kwargs):
|
||||
if script == DASHBOARD_CONTENT_HEIGHT_JS:
|
||||
return 4000
|
||||
if "scrollTo" in script:
|
||||
return None
|
||||
return [{"chartId": "7", "state": "rendered"}]
|
||||
|
||||
mock_page.evaluate.side_effect = fake_eval
|
||||
|
||||
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
||||
result = WebDriverPlaywright("chrome").get_screenshot(
|
||||
"http://example.com/dashboard/21",
|
||||
"standalone",
|
||||
MagicMock(),
|
||||
report_execution_context=_report_context(),
|
||||
)
|
||||
|
||||
mock_page.set_viewport_size.assert_called_once_with(
|
||||
{"width": 800, "height": 4000}
|
||||
)
|
||||
call_names = [c[0] for c in mock_page.mock_calls]
|
||||
assert call_names.index("set_viewport_size") < call_names.index(
|
||||
"wait_for_function"
|
||||
)
|
||||
assert result == mock_page.screenshot.return_value
|
||||
|
||||
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
||||
@patch("superset.utils.webdriver._browser_manager")
|
||||
@patch("superset.utils.webdriver.logger")
|
||||
|
||||
Reference in New Issue
Block a user