This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 335a2a78ea8 fix(dashboard): expand clipped scrollable content before
screenshot/PDF export (#43979)
335a2a78ea8 is described below
commit 335a2a78ea822b98b48c1cdd830e3f7693870bd9
Author: Evan Rusackas <[email protected]>
AuthorDate: Tue Sep 8 16:46:34 2026 -0700
fix(dashboard): expand clipped scrollable content before screenshot/PDF
export (#43979)
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
superset/utils/screenshot_utils.py | 97 +++++++++++++
superset/utils/webdriver.py | 68 ++++++++++
tests/unit_tests/utils/test_screenshot_utils.py | 48 +++++++
tests/unit_tests/utils/webdriver_test.py | 172 ++++++++++++++++++++++--
4 files changed, 374 insertions(+), 11 deletions(-)
diff --git a/superset/utils/screenshot_utils.py
b/superset/utils/screenshot_utils.py
index 3dc1ab33dba..f1996561eb3 100644
--- a/superset/utils/screenshot_utils.py
+++ b/superset/utils/screenshot_utils.py
@@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
# Time to wait after scrolling for content to settle and load (in milliseconds)
SCROLL_SETTLE_TIMEOUT_MS = 1000
+# Ceiling for un-clipping scrollable chart content (ag-Grid stabilization
+# polling) before a screenshot, absent a report deadline to bound it against.
+EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS = 5.0
+
# Chromium can occasionally return a valid but uniformly blank PNG for an
# off-screen clip. Retry after forcing a compositor frame, but keep each CDP
# capture bounded so a wedged compositor cannot consume the report deadline.
@@ -437,6 +441,99 @@ CHART_CONTAINER_STATE_JS = f"""
}}
"""
+CHART_CONTAINER_SELECTOR = ".chart-container"
+
+# `.slice_container` (superset-frontend/src/components/Chart/Chart.tsx) is
+# the one ancestor every chart type shares, directly inside `.chart-container`,
+# with an explicit pixel height matching the dashboard tile. A locator-bounded
+# capture (`element.screenshot()`, used for single-chart exports) clips to
+# `.chart-container`'s own bounding box, which only has a `min-height` --
+# so it stays exactly `.slice_container`'s fixed height unless that fixed
+# height is lifted too. Un-clipping a scrollable *descendant* (the ag-Grid
+# host, a table's own scroll body) is not enough on its own: the descendant
+# can grow, but its ancestor's box does not, and the extra content just
+# overflows the ancestor unseen by a bounding-box screenshot (#38090).
+SLICE_CONTAINER_SELECTOR_FOR_EXPANSION = ".slice_container"
+
+# Legacy/other chart-table implementations that scroll via an inline style
+# rather than a stable class name (e.g. plugin-chart-table's sticky body,
+#
`superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx`)
+# aren't reachable by a fixed class-selector list, so this catches any
+# descendant of a chart that is *actually* clipping its own content
+# (scrollHeight > clientHeight) rather than guessing at class names that may
+# not exist in every plugin version. `.ant-table-body` is kept alongside it
+# for a real Ant Design `<Table>` if one ever renders inside a chart.
+GENERIC_SCROLLABLE_DESCENDANT_SELECTOR = (
+ f'{CHART_CONTAINER_SELECTOR} [style*="overflow"], '
+ f"{CHART_CONTAINER_SELECTOR} .ant-table-body"
+)
+
+# ag-Grid virtualizes rows for performance, so a plain height/overflow reset
+# would still leave off-screen rows unrendered. `domLayout: "print"` is
+# ag-Grid's own "render every row into the DOM" mode -- the same mode the
+# client-side "download as image" export switches to via the GridApi that
+# ThemedAgGridReact (superset-ui-core) stashes on the grid's host element
+# specifically so screenshot/export code can reach it. The grid's own host
+# element and its immediate parent (the ag-Grid table plugin's container,
+# which sets an explicit pixel height via inline style -- see
+# `plugin-chart-ag-grid-table/src/AgGridTable/index.tsx`) are reset for the
+# same ancestor-box reason as `.slice_container` above.
+#
+# `page.screenshot(full_page=True)` already expands the outer dashboard
+# scroll to include every below-the-fold chart (#31158); it has no effect on
+# a chart's own internal scroll container, which is what this JS unrolls
+# in-place before the page is captured.
+EXPAND_SCROLLABLE_CONTENT_JS = f"""
+async (maxWaitMs) => {{
+ const agGrids = Array.from(
+ document.querySelectorAll('{AG_GRID_HOST_SELECTOR}')
+ );
+ await Promise.all(agGrids.map(async (grid) => {{
+ const api = grid._agGridApi;
+ if (!api) {{ return; }}
+ api.setGridOption('domLayout', 'print');
+ if (api.resetRowHeights) {{ api.resetRowHeights(); }}
+ grid.style.height = 'auto';
+ if (grid.parentElement) {{ grid.parentElement.style.height = 'auto'; }}
+ // ag-Grid's autoHeight rows batch-measure asynchronously, so this
+ // polls for a stable scrollHeight instead of a fixed sleep. Five
+ // consecutive unchanged 100ms polls is a deliberate match for the
+ // client-side export's own
+ // waitForStableScrollHeight(agRootWrapper, 5000, 5)
(downloadAsImage.tsx):
+ // always paid in full even when nothing is still settling, so both
+ // paths trust the measurement after the same wait rather than
+ // racing a batch that hasn't finished yet.
+ let lastHeight = grid.scrollHeight;
+ let stableCount = 0;
+ const deadline = Date.now() + maxWaitMs;
+ while (Date.now() < deadline && stableCount < 5) {{
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const height = grid.scrollHeight;
+ if (height === lastHeight) {{
+ stableCount += 1;
+ }} else {{
+ stableCount = 0;
+ lastHeight = height;
+ }}
+ }}
+ }}));
+
+
document.querySelectorAll('{SLICE_CONTAINER_SELECTOR_FOR_EXPANSION}').forEach(
+ (el) => {{ el.style.height = 'auto'; }}
+ );
+
+
document.querySelectorAll('{GENERIC_SCROLLABLE_DESCENDANT_SELECTOR}').forEach(
+ (el) => {{
+ if (el.scrollHeight > el.clientHeight) {{
+ el.style.overflow = 'visible';
+ el.style.height = 'auto';
+ el.style.maxHeight = 'none';
+ }}
+ }}
+ );
+}}
+"""
+
def combine_screenshot_tiles(
screenshot_tiles: list[bytes],
diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py
index e7242dffc9f..337ab698258 100644
--- a/superset/utils/webdriver.py
+++ b/superset/utils/webdriver.py
@@ -34,6 +34,8 @@ from superset.utils.screenshot_utils import (
CHART_CONTAINER_READY_JS,
CHART_CONTAINER_STATE_JS,
CHART_HOLDERS_READY_JS,
+ EXPAND_SCROLLABLE_CONTENT_JS,
+ EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS,
FIND_ALL_UNREADY_CHART_HOLDERS_JS,
FIND_CHART_HOLDER_STATES_JS,
FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS,
@@ -238,6 +240,43 @@ class WebDriverPlaywright(WebDriverProxy):
else:
return element.screenshot(**timeout_kwargs)
+ @staticmethod
+ def _expand_scrollable_content(
+ page: Page,
+ log_context: str | None = None,
+ report_execution_context: ReportExecutionContext | None = None,
+ ) -> None:
+ """
+ Un-clip chart content that is fully present in the DOM but visually
+ cropped by a fixed height + internal scrollbar (e.g. a table taller
+ than the space its dashboard tile gives it) before the page is
+ captured.
+
+ The ag-Grid branch of this step polls for a stable row count, so it
+ is bounded by the report's own deadline the same way every other
+ wait in this method is, rather than an unconditional fixed sleep.
+
+ Best-effort: a failure here should not abort the screenshot, since a
+ clipped-but-present capture beats none at all.
+ """
+ max_wait_seconds = (
+ report_execution_context.deadline.timeout_seconds(
+ "scrollable_content_expansion",
+ requested_seconds=EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS,
+
reserve_seconds=report_execution_context.readiness_reserve_seconds,
+ )
+ if report_execution_context
+ else EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS
+ )
+ try:
+ page.evaluate(EXPAND_SCROLLABLE_CONTENT_JS, max_wait_seconds *
1000)
+ except PlaywrightError:
+ logger.warning(
+ "Failed to expand scrollable chart content before
screenshot%s",
+ f" [{log_context}]" if log_context else "",
+ exc_info=True,
+ )
+
@staticmethod
def _wait_for_charts_ready( # noqa: C901
page: Page,
@@ -712,6 +751,19 @@ class WebDriverPlaywright(WebDriverProxy):
unexpected_errors,
context_suffix,
)
+ # Un-clip scrollable/virtualized chart content (dense tables
+ # taller than their dashboard tile) before measuring height,
+ # so the tiling decision below sees the full content when
+ # possible. A chart whose ag-Grid hasn't fired GridReady yet
+ # at this point is re-expanded below, after readiness --
+ # `.chart-container` elements attaching (waited on above) is
+ # not the same as ag-Grid finishing its own internal init.
+ WebDriverPlaywright._expand_scrollable_content(
+ page,
+ log_context=log_context,
+ report_execution_context=report_execution_context,
+ )
+
# Detect large dashboards and use tiled screenshots if enabled
tiled_enabled = app.config.get("SCREENSHOT_TILED_ENABLED",
False)
@@ -865,6 +917,14 @@ class WebDriverPlaywright(WebDriverProxy):
screenshot_started_at=screenshot_started_at,
report_execution_context=report_execution_context,
)
+ # Re-run now that readiness has confirmed every chart
+ # actually rendered: a grid whose GridReady hadn't
+ # fired yet at the earlier call above is expanded here.
+ WebDriverPlaywright._expand_scrollable_content(
+ page,
+ log_context=log_context,
+ report_execution_context=report_execution_context,
+ )
if selenium_animation_wait > 0:
if report_execution_context:
selenium_animation_wait = min(
@@ -929,6 +989,14 @@ class WebDriverPlaywright(WebDriverProxy):
screenshot_started_at=screenshot_started_at,
report_execution_context=report_execution_context,
)
+ # Re-run now that readiness has confirmed every chart
+ # actually rendered: a grid whose GridReady hadn't fired
+ # yet at the earlier call above is expanded here.
+ WebDriverPlaywright._expand_scrollable_content(
+ page,
+ log_context=log_context,
+ report_execution_context=report_execution_context,
+ )
if selenium_animation_wait > 0:
if report_execution_context:
selenium_animation_wait = min(
diff --git a/tests/unit_tests/utils/test_screenshot_utils.py
b/tests/unit_tests/utils/test_screenshot_utils.py
index 044f65eed83..f14f9dc6110 100644
--- a/tests/unit_tests/utils/test_screenshot_utils.py
+++ b/tests/unit_tests/utils/test_screenshot_utils.py
@@ -1610,3 +1610,51 @@ def
test_ag_grid_no_rows_overlay_is_a_terminal_empty_state() -> None:
CHART_CONTAINER_READY_JS,
):
assert ".ag-overlay-no-rows-wrapper:not(.ag-hidden)" in predicate
+
+
+def test_expand_scrollable_content_js_unrolls_ag_grid_and_css_scroll() -> None:
+ """The pre-capture DOM-expansion script must reach both flavors of
+ clipped table content: ag-Grid's row virtualization (needs its own API
+ to force a full render) and plain CSS overflow/height clipping (the
+ content already exists in the DOM and just needs the constraint lifted).
+ It must also lift the fixed heights on the shared `.chart-container` /
+ `.slice_container` / ag-Grid-wrapper ancestor chain, not just the
+ scrollable descendant itself -- a bounding-box capture
+ (`element.screenshot()`, used for single-chart exports) clips to the
+ ancestor's own box, which does not grow just because a descendant's
+ content does (#38090, reviewed by @aminghadersohi on #43979)."""
+ from superset.utils.screenshot_utils import (
+ AG_GRID_HOST_SELECTOR,
+ CHART_CONTAINER_SELECTOR,
+ EXPAND_SCROLLABLE_CONTENT_JS,
+ GENERIC_SCROLLABLE_DESCENDANT_SELECTOR,
+ SLICE_CONTAINER_SELECTOR_FOR_EXPANSION,
+ )
+
+ assert AG_GRID_HOST_SELECTOR in EXPAND_SCROLLABLE_CONTENT_JS
+ assert "setGridOption('domLayout', 'print')" in
EXPAND_SCROLLABLE_CONTENT_JS
+ assert "grid._agGridApi" in EXPAND_SCROLLABLE_CONTENT_JS
+ # The grid's own host and its immediate parent (the ag-Grid table
+ # plugin's fixed-pixel-height wrapper) both get their height lifted --
+ # not just the descendant content inside the grid.
+ assert "grid.style.height = 'auto'" in EXPAND_SCROLLABLE_CONTENT_JS
+ assert "grid.parentElement.style.height = 'auto'" in
EXPAND_SCROLLABLE_CONTENT_JS
+
+ assert SLICE_CONTAINER_SELECTOR_FOR_EXPANSION == ".slice_container"
+ assert SLICE_CONTAINER_SELECTOR_FOR_EXPANSION in
EXPAND_SCROLLABLE_CONTENT_JS
+
+ assert CHART_CONTAINER_SELECTOR == ".chart-container"
+ assert GENERIC_SCROLLABLE_DESCENDANT_SELECTOR == (
+ '.chart-container [style*="overflow"], .chart-container
.ant-table-body'
+ )
+ assert GENERIC_SCROLLABLE_DESCENDANT_SELECTOR in
EXPAND_SCROLLABLE_CONTENT_JS
+ # Gated on actually clipping, so a non-scrollable match (e.g. a sticky
+ # table header with no overflow of its own) is left untouched.
+ assert "el.scrollHeight > el.clientHeight" in EXPAND_SCROLLABLE_CONTENT_JS
+ assert "overflow = 'visible'" in EXPAND_SCROLLABLE_CONTENT_JS
+ assert "maxHeight = 'none'" in EXPAND_SCROLLABLE_CONTENT_JS
+
+ # Takes an explicit wait budget rather than hardcoding one, so it can be
+ # bounded by a report's remaining deadline (see webdriver_test.py).
+ assert "async (maxWaitMs) =>" in EXPAND_SCROLLABLE_CONTENT_JS
+ assert "Date.now() + maxWaitMs" in EXPAND_SCROLLABLE_CONTENT_JS
diff --git a/tests/unit_tests/utils/webdriver_test.py
b/tests/unit_tests/utils/webdriver_test.py
index d2b3885c93d..a1759a12ec4 100644
--- a/tests/unit_tests/utils/webdriver_test.py
+++ b/tests/unit_tests/utils/webdriver_test.py
@@ -154,6 +154,84 @@ class TestWebDriverPlaywrightFallback:
"http://example.com", wait_until="networkidle"
)
+ @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
+ @patch("superset.utils.webdriver._browser_manager")
+ @patch("superset.utils.webdriver.app")
+ def test_get_screenshot_expands_scrollable_content_before_capture(
+ self, mock_app, mock_browser_manager
+ ):
+ """A dense table taller than its dashboard tile renders fully in the
+ DOM but is visually clipped by a fixed height + internal scrollbar.
+ `get_screenshot` must un-clip that content (ag-Grid print layout /
+ CSS overflow reset, see EXPAND_SCROLLABLE_CONTENT_JS) before taking
+ the screenshot, and must do so *before* the capture call so the
+ expanded layout is what actually gets captured (#38090). It runs
+ twice: once before the tiling decision, and again after chart
+ readiness confirms every ag-Grid has actually mounted its API
+ (@aminghadersohi's review on #43979 -- the earlier call can miss a
+ grid whose GridReady hasn't fired yet)."""
+ from superset.utils.screenshot_utils import
EXPAND_SCROLLABLE_CONTENT_JS
+
+ 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": 10,
+ }
+
+ 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
+
+ capture_order: list[str] = []
+
+ def evaluate_side_effect(script, *args, **kwargs):
+ if script == EXPAND_SCROLLABLE_CONTENT_JS:
+ capture_order.append("expand")
+ return None
+ # FIND_CHART_HOLDER_STATES_JS (readiness diagnostics) expects an
+ # iterable of holder states; every other call in this (non-tiled,
+ # non-report) path is only ever logged, not branched on.
+ return []
+
+ mock_page.evaluate.side_effect = evaluate_side_effect
+ mock_page.screenshot.side_effect = lambda **k: (
+ capture_order.append("capture") or b"fake_screenshot"
+ )
+
+ with patch.object(WebDriverPlaywright, "auth",
return_value=mock_context):
+ driver = WebDriverPlaywright("chrome")
+ result = driver.get_screenshot(
+ "http://example.com", "standalone", mock_user
+ )
+
+ assert result == b"fake_screenshot"
+ expand_calls = [
+ call
+ for call in mock_page.evaluate.call_args_list
+ if call.args[0] == EXPAND_SCROLLABLE_CONTENT_JS
+ ]
+ assert len(expand_calls) == 2
+ # A concrete millisecond budget is passed as the second arg (bounded
+ # by the report deadline when one exists; a fixed default otherwise).
+ assert all(isinstance(call.args[1], float) for call in expand_calls)
+ assert capture_order == ["expand", "expand", "capture"]
+
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
@@ -259,6 +337,70 @@ class TestWebDriverPlaywrightErrorHandling:
"Failed to capture unexpected errors%s", ""
)
+ @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
+ @patch("superset.utils.webdriver.logger")
+ def test_expand_scrollable_content_swallows_playwright_error(self,
mock_logger):
+ """A failure while un-clipping scrollable content must not abort the
+ screenshot -- a clipped-but-present capture beats none at all."""
+ from superset.utils.webdriver import PlaywrightError
+
+ mock_page = MagicMock()
+ mock_page.evaluate.side_effect = PlaywrightError("boom")
+
+ WebDriverPlaywright._expand_scrollable_content(
+ mock_page, log_context="execution_id=abc-123"
+ )
+
+ mock_logger.warning.assert_called_once()
+ warning_args = mock_logger.warning.call_args.args
+ assert "Failed to expand scrollable chart content" in warning_args[0]
+ assert warning_args[1] == " [execution_id=abc-123]"
+
+ def
test_expand_scrollable_content_defaults_wait_without_report_context(self):
+ """Absent a report deadline to bound it against, the ag-Grid
+ stabilization poll gets the fixed default ceiling."""
+ from superset.utils.screenshot_utils import (
+ EXPAND_SCROLLABLE_CONTENT_JS,
+ EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS,
+ )
+
+ mock_page = MagicMock()
+
+ WebDriverPlaywright._expand_scrollable_content(mock_page)
+
+ mock_page.evaluate.assert_called_once_with(
+ EXPAND_SCROLLABLE_CONTENT_JS,
+ EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS * 1000,
+ )
+
+ def test_expand_scrollable_content_bounds_wait_to_report_deadline(self):
+ """This step must respect the report's remaining budget like every
+ other wait in the capture path, rather than an unconditional fixed
+ sleep (Thread A / @aminghadersohi's review on #43979)."""
+ mock_page = MagicMock()
+ report_execution_context = _report_context()
+ # Only 2s left for this phase after other phases' reserves.
+ report_execution_context = report_execution_context.__class__(
+ **{
+ **report_execution_context.__dict__,
+ "deadline": report_execution_context.deadline.__class__(
+ total_seconds=2
+ + report_execution_context.capture_reserve_seconds
+ + report_execution_context.delivery_reserve_seconds
+ + report_execution_context.cleanup_reserve_seconds,
+ started_at=0,
+ _clock=lambda: 0,
+ ),
+ }
+ )
+
+ WebDriverPlaywright._expand_scrollable_content(
+ mock_page, report_execution_context=report_execution_context
+ )
+
+ max_wait_ms = mock_page.evaluate.call_args.args[1]
+ assert max_wait_ms == pytest.approx(2000)
+
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver.sync_playwright")
@patch("superset.utils.webdriver.logger")
@@ -979,12 +1121,16 @@ class TestWebDriverPlaywrightChartReadiness:
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]
+ # an outer limit still leaves useful state in the logs. Two additional
+ # evaluate() calls expand scrollable content before capture: once
+ # before the tiling decision, once after readiness (#38090).
+ readiness_calls = [
+ call
for call in mock_page.evaluate.call_args_list
- )
+ if "state: 'rendered'" in call.args[0]
+ ]
+ assert len(readiness_calls) == 2
+ assert mock_page.evaluate.call_count == 4
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@@ -1599,8 +1745,9 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
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, [], []]
+ # Small dashboard: 3 charts, 1000px height — below both thresholds.
+ # First item is consumed by the pre-capture scrollable-content
expansion.
+ mock_page.evaluate.side_effect = [None, 3, 1000, [], []]
call_order: list[str] = []
@@ -1642,7 +1789,8 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
}
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
- mock_page.evaluate.side_effect = [25, 500, [], []]
+ # First item is consumed by the pre-capture scrollable-content
expansion.
+ mock_page.evaluate.side_effect = [None, 25, 500, [], []]
with patch.object(WebDriverPlaywright, "auth",
return_value=mock_context):
result = WebDriverPlaywright("chrome").get_screenshot(
@@ -1673,8 +1821,9 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
- # Large dashboard: 25 charts, 6000px height
- mock_page.evaluate.side_effect = [25, 6000]
+ # Large dashboard: 25 charts, 6000px height. First item is consumed
+ # by the pre-capture scrollable-content expansion.
+ mock_page.evaluate.side_effect = [None, 25, 6000]
mock_take_tiled.return_value = b"tiled_screenshot"
with patch.object(WebDriverPlaywright, "auth",
return_value=mock_context):
@@ -1727,7 +1876,8 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
}
mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
- mock_page.evaluate.side_effect = [25, 6000]
+ # First item is consumed by the pre-capture scrollable-content
expansion.
+ mock_page.evaluate.side_effect = [None, 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