Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion superset/utils/screenshot_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
# Time to wait after scrolling for content to settle and load (in milliseconds)
SCROLL_SETTLE_TIMEOUT_MS = 1000

try:
from playwright.sync_api import TimeoutError as PlaywrightTimeout
except ImportError:
PlaywrightTimeout = Exception

if TYPE_CHECKING:
try:
from playwright.sync_api import Page
Expand Down Expand Up @@ -80,7 +85,10 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:


def take_tiled_screenshot(
page: "Page", element_name: str, tile_height: int
page: "Page",
element_name: str,
tile_height: int,
load_wait: int = 60,
) -> bytes | None:
"""
Take a tiled screenshot of a large dashboard by scrolling and capturing sections.
Expand All @@ -89,6 +97,7 @@ def take_tiled_screenshot(
page: Playwright page object
element_name: CSS class name of the element to screenshot
tile_height: Height of each tile in pixels
load_wait: Seconds to wait for charts to load per tile (default 60)

Returns:
Combined screenshot bytes or None if failed
Expand Down Expand Up @@ -139,6 +148,31 @@ def take_tiled_screenshot(
)
# Wait for scroll to settle and content to load
page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
# Wait for any loading spinners visible in the current viewport to clear.
# Only check viewport-visible spinners to avoid blocking on
# virtualization placeholders rendered for off-screen charts.
try:
page.wait_for_function(
"""() => {
const els = document.querySelectorAll('.loading');
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight && r.bottom > 0) {
return false;
}
}
return true;
}""",
timeout=load_wait * 1000,
)
except PlaywrightTimeout:
logger.warning(
"Timed out waiting for visible spinners to clear on tile %s/%s "
"(load_wait=%ss)",
i + 1,
num_tiles,
load_wait,
)

# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * tile_height
Expand Down
23 changes: 20 additions & 3 deletions superset/utils/webdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,24 @@ def get_screenshot( # pylint: disable=too-many-locals, too-many-statements # n
"Wait for loading element of charts to be gone at url: %s", url
)
page.wait_for_function(
"() => document.querySelectorAll('.loading').length === 0",
"""() => {
const els = document.querySelectorAll('.loading');
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight && r.bottom > 0) {
return false;
}
}
return true;
}""",
Comment on lines +304 to +313
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantic duplication in loading visibility logic

The updated loading element visibility check logic duplicates the same JavaScript code already present in take_tiled_screenshot within screenshot_utils.py. This creates maintenance risk if the logic needs updates, as changes would need to be synced across both files. Consider refactoring to a shared utility.

Code Review Run #19541f


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

timeout=self._screenshot_load_wait * 1000,
)
except PlaywrightTimeout:
logger.warning(
"Timed out waiting for charts to load at url %s", url
"Timed out waiting for charts to load at url %s "
"(SCREENSHOT_LOAD_WAIT=%ss)",
url,
self._screenshot_load_wait,
)
raise

Expand Down Expand Up @@ -368,7 +380,12 @@ def get_screenshot( # pylint: disable=too-many-locals, too-many-statements # n
page.set_viewport_size(
{"height": tile_height, "width": viewport_width}
)
img = take_tiled_screenshot(page, element_name, tile_height)
img = take_tiled_screenshot(
page,
element_name,
tile_height,
load_wait=self._screenshot_load_wait,
)
if img is None:
logger.warning(
(
Expand Down
51 changes: 51 additions & 0 deletions tests/unit_tests/utils/test_screenshot_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,54 @@ def test_reset_scroll_position(self, mock_page):
# Each wait should use the scroll settle timeout constant
for call in mock_page.wait_for_timeout.call_args_list:
assert call[0][0] == SCROLL_SETTLE_TIMEOUT_MS

def test_per_tile_spinner_wait_uses_viewport_check(self, mock_page):
"""wait_for_function polls viewport-visible spinners after each scroll."""
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=30
)

# 3 tiles → 3 wait_for_function calls, one per tile
assert mock_page.wait_for_function.call_count == 3

# Each call uses viewport-scoped JS and the load_wait timeout
for call in mock_page.wait_for_function.call_args_list:
js = call[0][0]
assert "getBoundingClientRect" in js
assert "window.innerHeight" in js
assert call[1]["timeout"] == 30 * 1000

def test_per_tile_spinner_timeout_logs_warning_and_continues(self, mock_page):
"""A per-tile spinner timeout logs a warning but still takes the screenshot."""
from superset.utils.screenshot_utils import PlaywrightTimeout

timeout = PlaywrightTimeout()
mock_page.wait_for_function.side_effect = timeout

with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=30
)

# Screenshot should still proceed (non-fatal)
assert result is not None
# Warning logged for each tile that timed out
assert mock_logger.warning.call_count == 3
mock_logger.warning.assert_any_call(
"Timed out waiting for visible spinners to clear on tile %s/%s "
"(load_wait=%ss)",
1,
3,
30,
)

def test_load_wait_default_is_sixty_seconds(self):
"""load_wait defaults to 60 to match SCREENSHOT_LOAD_WAIT config default."""
import inspect

from superset.utils.screenshot_utils import take_tiled_screenshot

sig = inspect.signature(take_tiled_screenshot)
assert sig.parameters["load_wait"].default == 60
14 changes: 12 additions & 2 deletions tests/unit_tests/utils/webdriver_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,16 @@ def test_uses_wait_for_function_to_detect_spinners(
driver.get_screenshot("http://example.com", "test-element", mock_user)

mock_page.wait_for_function.assert_called_once_with(
"() => document.querySelectorAll('.loading').length === 0",
"""() => {
const els = document.querySelectorAll('.loading');
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight && r.bottom > 0) {
return false;
}
}
return true;
}""",
timeout=60 * 1000,
)
# Guard against reintroducing the old snapshot-based approach
Expand Down Expand Up @@ -744,8 +753,9 @@ def test_spinner_timeout_logs_warning_and_raises(

assert exc_info.value is timeout
mock_logger.warning.assert_any_call(
"Timed out waiting for charts to load at url %s",
"Timed out waiting for charts to load at url %s (SCREENSHOT_LOAD_WAIT=%ss)",
"http://example.com",
60,
)

@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
Expand Down
Loading