fix(reports): narrow spinner checks to viewport and tighten exception handling

- Replace global '.loading' count check in webdriver.py with a
  getBoundingClientRect viewport-visibility check to avoid deadlock
  when DashboardVirtualization renders off-screen placeholder spinners
- Narrow except clause in screenshot_utils.py from bare Exception to
  PlaywrightTimeout so non-timeout errors (e.g. browser crash) propagate
- Fix load_wait default from 30 s to 60 s to match SCREENSHOT_LOAD_WAIT
  config default
- Add tests covering per-tile spinner wait, timeout warning, non-timeout
  propagation, and load_wait default value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Elizabeth Thompson
2026-05-05 16:26:51 +00:00
committed by Amin Ghadersohi
parent cb53745d43
commit 4e6ab1ceec
4 changed files with 117 additions and 4 deletions

View File

@@ -28,6 +28,11 @@ logger = logging.getLogger(__name__)
# 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
@@ -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.
@@ -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 30)
Returns:
Combined screenshot bytes or None if failed
@@ -139,6 +148,29 @@ 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",
i + 1,
num_tiles,
)
# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * tile_height

View File

@@ -301,7 +301,16 @@ class WebDriverPlaywright(WebDriverProxy):
"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;
}""",
timeout=self._screenshot_load_wait * 1000,
)
except PlaywrightTimeout:
@@ -368,7 +377,12 @@ class WebDriverPlaywright(WebDriverProxy):
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(
(

View File

@@ -320,3 +320,61 @@ class TestTakeTiledScreenshot:
# 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",
1,
3,
)
def test_per_tile_non_timeout_exceptions_propagate(self, mock_page):
"""Non-timeout exceptions from wait_for_function are not swallowed."""
mock_page.wait_for_function.side_effect = RuntimeError("browser crashed")
with pytest.raises(RuntimeError, match="browser crashed"):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=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

View File

@@ -684,7 +684,16 @@ class TestWebDriverPlaywrightErrorHandling:
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