Compare commits

...
Author SHA1 Message Date
Elizabeth ThompsonandClaude Opus 4.8 439942729c fix(reports): mount off-screen dashboard charts before standalone capture
Scheduled reports could deliver a dashboard screenshot with blank regions
where off-screen ("virtualized") charts should be. The non-tiled standalone
capture path waits only for viewport-visible chart holders to reach a
terminal state (#42624), then takes a full_page screenshot that includes
below-the-fold content. DashboardVirtualization only mounts holders that
intersect the window viewport, so any holder that never scrolls into view is
declared ready yet captured blank -- with no timeout, since the gate believes
it is done.

Grow the viewport to the full dashboard height before the readiness wait so
every holder intersects the viewport and virtualization mounts it; the
existing readiness gate then requires them all to reach a terminal state
before capture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 23:02:26 +00:00
3 changed files with 252 additions and 13 deletions
+13
View File
@@ -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
View File
@@ -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,
+109 -3
View File
@@ -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")