Files
superset2/tests/unit_tests/utils/test_screenshot_utils.py
Elizabeth Thompson 482bef1507 fix(playwright): Use actual viewport height for tiled screenshot scroll calculations
The configured SCREENSHOT_TILED_VIEWPORT_HEIGHT (default 2000px) was larger than
Playwright's actual browser viewport (~768-1200px), causing scroll increments to
skip content between tiles. Now we fetch the actual viewport height and use
min(configured, actual) for scroll calculations to ensure complete content coverage.

Added regression test to verify tiles properly cover all content when configured
viewport exceeds actual viewport size.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:38:24 -07:00

741 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import io
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
from superset.utils.screenshot_utils import (
combine_screenshot_tiles,
take_tiled_screenshot,
)
class TestCombineScreenshotTiles:
def _create_test_image(self, width: int, height: int, color: str = "red") -> bytes:
"""Helper to create test PNG image bytes."""
img = Image.new("RGB", (width, height), color)
output = io.BytesIO()
img.save(output, format="PNG")
return output.getvalue()
def test_empty_tiles_returns_empty_bytes(self):
"""Test that empty tiles list returns empty bytes."""
result = combine_screenshot_tiles([])
assert result == b""
def test_single_tile_returns_original(self):
"""Test that single tile returns the original image."""
test_image = self._create_test_image(100, 100)
result = combine_screenshot_tiles([test_image])
assert result == test_image
def test_combine_multiple_tiles_vertically(self):
"""Test combining multiple tiles into a single vertical image."""
# Create test images with different colors
tile1 = self._create_test_image(100, 50, "red")
tile2 = self._create_test_image(100, 75, "green")
tile3 = self._create_test_image(100, 25, "blue")
result = combine_screenshot_tiles([tile1, tile2, tile3])
# Verify result is not empty
assert result != b""
# Verify the combined image has correct dimensions
combined_img = Image.open(io.BytesIO(result))
assert combined_img.width == 100 # Max width of all tiles
assert combined_img.height == 150 # Sum of all heights (50 + 75 + 25)
# Verify the image format is PNG
assert combined_img.format == "PNG"
def test_combine_tiles_different_widths(self):
"""Test combining tiles with different widths uses max width."""
tile1 = self._create_test_image(50, 100, "red")
tile2 = self._create_test_image(150, 100, "green")
tile3 = self._create_test_image(100, 100, "blue")
result = combine_screenshot_tiles([tile1, tile2, tile3])
combined_img = Image.open(io.BytesIO(result))
assert combined_img.width == 150 # Max width
assert combined_img.height == 300 # Sum of heights
def test_combine_tiles_handles_pil_error(self):
"""Test that PIL errors are handled gracefully."""
# Create one valid image and one invalid
valid_tile = self._create_test_image(100, 100)
invalid_tile = b"invalid_image_data"
result = combine_screenshot_tiles([valid_tile, invalid_tile])
# Should return the first (valid) tile as fallback
assert result == valid_tile
def test_combine_tiles_logs_exception(self):
"""Test that exceptions are logged properly."""
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
# Create invalid image data that will cause PIL to raise an exception
invalid_tile = b"definitely_not_an_image"
valid_tile = self._create_test_image(100, 100)
result = combine_screenshot_tiles([valid_tile, invalid_tile])
# Should have logged the exception
mock_logger.exception.assert_called_once()
# Should return first tile as fallback
assert result == valid_tile
class TestTakeTiledScreenshot:
@pytest.fixture
def mock_page(self):
"""Create a mock Playwright page object."""
page = MagicMock()
# Mock viewport size
page.viewport_size = {"width": 1024, "height": 768}
# Mock element locator
element = MagicMock()
page.locator.return_value = element
# Mock element info - simulating a 5000px tall dashboard
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 7 tiles (5000px / 768px actual viewport = 6.5, rounded up to 7):
# 1 initial call + 7 scroll + 7 viewport info + 1 reset scroll = 16 calls
page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None,
viewport_info, # Tile 5
None,
viewport_info, # Tile 6
None,
viewport_info, # Tile 7
None, # Final reset scroll call
]
# Mock screenshot method
fake_screenshot = b"fake_screenshot_data"
page.screenshot.return_value = fake_screenshot
return page
def test_successful_tiled_screenshot(self, mock_page):
"""Test successful tiled screenshot generation."""
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
mock_combine.return_value = b"combined_screenshot"
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should return combined screenshot
assert result == b"combined_screenshot"
# Should have called screenshot method multiple times
# (7 tiles for 5000px height with 768px actual viewport)
assert mock_page.screenshot.call_count == 7
# Should have called combine function
mock_combine.assert_called_once()
def test_element_not_found_returns_none(self):
"""Test that missing element returns None."""
mock_page = MagicMock()
element = MagicMock()
element.wait_for.side_effect = Exception("Element not found")
mock_page.locator.return_value = element
result = take_tiled_screenshot(mock_page, "nonexistent", viewport_height=2000)
assert result is None
def test_tile_calculation_logic(self, mock_page):
"""Test that tiles are calculated correctly."""
# Mock dashboard height of 3500px with viewport of 2000px
element_info = {"height": 3500, "top": 100, "left": 50, "width": 800}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 2 tiles (3500px / 2000px = 1.75, rounded up to 2):
# 1 initial call + 2 scroll + 2 viewport info + 1 reset scroll = 6 calls
mock_page.evaluate.side_effect = [
element_info,
None, # First scroll call
viewport_info, # First viewport info call
None, # Second scroll call
viewport_info, # Second viewport info call
None, # Reset scroll call
]
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
mock_combine.return_value = b"combined"
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should take 2 screenshots (3500px / 2000px = 1.75, rounded up to 2)
assert mock_page.screenshot.call_count == 2
def test_scroll_positions_calculated_correctly(self, mock_page):
"""Test that scroll positions are calculated correctly."""
# Override the fixture's side_effect for this specific test
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None,
viewport_info, # Tile 5
None,
viewport_info, # Tile 6
None,
viewport_info, # Tile 7
None, # Reset scroll call
]
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Check scroll positions (dashboard_top = 100, tile_height = 768)
scroll_calls = [
call
for call in mock_page.evaluate.call_args_list
if "scrollTo" in str(call)
]
# Should have scrolled to positions: 100, 868, 1636, 2404, 3172, 3940, 4708
expected_scrolls = [
"window.scrollTo(0, 100)",
"window.scrollTo(0, 868)",
"window.scrollTo(0, 1636)",
"window.scrollTo(0, 2404)",
"window.scrollTo(0, 3172)",
"window.scrollTo(0, 3940)",
"window.scrollTo(0, 4708)",
]
actual_scrolls = [call[0][0] for call in scroll_calls]
assert len(actual_scrolls) == 8 # 7 tile scrolls + 1 reset
for expected in expected_scrolls:
assert expected in actual_scrolls
def test_reset_scroll_position(self, mock_page):
"""Test that scroll position is reset after screenshot."""
# Override the fixture's side_effect for this specific test
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None,
viewport_info, # Tile 5
None,
viewport_info, # Tile 6
None,
viewport_info, # Tile 7
None, # Reset scroll call
]
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Check that final call resets scroll to top
final_call = mock_page.evaluate.call_args_list[-1]
assert "window.scrollTo(0, 0)" in str(final_call)
def test_logs_dashboard_info(self, mock_page):
"""Test that dashboard info is logged."""
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should log dashboard dimensions with lazy logging format
mock_logger.info.assert_any_call(
"Dashboard: %sx%spx at (%s, %s)", 800, 5000, 50, 100
)
# Should log number of tiles with lazy logging format
mock_logger.info.assert_any_call("Taking %s screenshot tiles", 7)
def test_exception_handling_returns_none(self):
"""Test that exceptions are handled and None is returned."""
mock_page = MagicMock()
mock_page.locator.side_effect = Exception("Unexpected error")
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
assert result is None
# The exception object is passed, not the string
call_args = mock_logger.exception.call_args
assert call_args[0][0] == "Tiled screenshot failed: %s"
assert str(call_args[0][1]) == "Unexpected error"
def test_wait_timeouts_between_tiles(self, mock_page):
"""Test that there are appropriate waits between tiles."""
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should have called wait_for_timeout for each tile (7 tiles)
assert mock_page.wait_for_timeout.call_count == 7
# Each wait should be 2000ms (2 seconds)
for call in mock_page.wait_for_timeout.call_args_list:
assert call[0][0] == 2000
def test_screenshot_clip_parameters(self, mock_page):
"""Test that screenshot clipping parameters are correct."""
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Check screenshot calls have correct clip parameters
screenshot_calls = mock_page.screenshot.call_args_list
for call in screenshot_calls:
kwargs = call[1]
assert kwargs["type"] == "png"
assert "clip" in kwargs
clip = kwargs["clip"]
assert clip["x"] == 50
assert clip["y"] == 200
assert clip["width"] == 800
# Height should be min of viewport_height and remaining content
assert clip["height"] <= 600 # Element height from mock
def test_negative_element_position_clipped_to_zero(self):
"""Test that negative element positions are clipped to viewport bounds."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
# Mock element locator
element = MagicMock()
mock_page.locator.return_value = element
# Simulate element scrolled above viewport (negative Y position)
element_info = {"height": 3000, "top": 100, "left": 0, "width": 800}
viewport_info = {
"elementX": 0,
"elementY": -200, # Element is scrolled 200px above viewport
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 4 tiles (3000px / 768px = 3.9, rounded up to 4):
# 1 initial + 4 * (scroll + viewport info) + 1 reset = 10 calls
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None,
viewport_info, # Tile 4
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should complete successfully
assert result is not None
# Check that clip Y was adjusted to 0 (not negative)
screenshot_calls = mock_page.screenshot.call_args_list
for call in screenshot_calls:
clip = call[1]["clip"]
assert clip["y"] >= 0, "Clip Y should never be negative"
assert clip["x"] >= 0, "Clip X should never be negative"
def test_element_extends_beyond_viewport(self):
"""Test clipping when element extends beyond viewport boundaries."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 0, "left": 0, "width": 1200}
# Element is wider than viewport
viewport_info = {
"elementX": 0,
"elementY": 100,
"elementWidth": 1200, # Wider than viewport
"elementHeight": 800,
"viewportWidth": 1024, # Viewport width
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
assert result is not None
# Check that clip width was constrained to viewport
clip = mock_page.screenshot.call_args_list[0][1]["clip"]
assert clip["width"] <= 1024, "Clip width should not exceed viewport"
def test_invalid_clip_dimensions_skipped(self):
"""Test that tiles with invalid dimensions are skipped with a warning."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 4000, "top": 0, "left": 0, "width": 800}
# First tile: valid
valid_viewport_info = {
"elementX": 0,
"elementY": 100,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# Second tile: invalid (negative height after calculation)
invalid_viewport_info = {
"elementX": 0,
"elementY": -1000, # Far above viewport
"elementWidth": 800,
"elementHeight": 100, # Not enough visible height
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 6 tiles (4000px / 768px = 5.2, rounded up to 6):
# 1 initial + 6 * (scroll + viewport info) + 1 reset = 14 calls
mock_page.evaluate.side_effect = [
element_info,
None,
valid_viewport_info, # Tile 1 - valid
None,
invalid_viewport_info, # Tile 2 - invalid, should be skipped
None,
valid_viewport_info, # Tile 3 - valid
None,
valid_viewport_info, # Tile 4 - valid
None,
valid_viewport_info, # Tile 5 - valid
None,
valid_viewport_info, # Tile 6 - valid
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
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", viewport_height=2000
)
# Should complete but with warning
assert result is not None
# Should have logged a warning about skipping tile
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
assert "Skipping tile" in warning_msg
assert "invalid clip dimensions" in warning_msg
# Should have taken 5 screenshots (6 tiles - 1 invalid)
assert mock_page.screenshot.call_count == 5
def test_viewport_bounds_with_offset_element(self):
"""Test proper clipping for element with positive offset from viewport edge."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 500, "left": 200, "width": 600}
# Element starts 200px from left edge
viewport_info = {
"elementX": 200, # Offset from left
"elementY": 150,
"elementWidth": 600,
"elementHeight": 500,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1
None,
viewport_info, # Tile 2
None,
viewport_info, # Tile 3
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
assert result is not None
# Check clip respects element position
clip = mock_page.screenshot.call_args_list[0][1]["clip"]
assert clip["x"] == 200, "Should preserve element X offset"
assert clip["y"] == 150, "Should preserve element Y offset"
assert clip["width"] == 600, "Should use element width"
def test_zero_width_element_skipped(self):
"""Test that elements with zero or negative width are skipped."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 0, "left": 0, "width": 0}
viewport_info = {
"elementX": 0,
"elementY": 100,
"elementWidth": 0, # Zero width
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
# All tiles will be skipped due to zero width
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1 - skipped
None,
viewport_info, # Tile 2 - skipped
None,
viewport_info, # Tile 3 - skipped
None, # Reset scroll
]
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", viewport_height=2000
)
# Should handle gracefully
assert result is not None
# Should have logged warnings about invalid dimensions
# (3 times, once per tile)
assert mock_logger.warning.call_count == 3
for call in mock_logger.warning.call_args_list:
warning_msg = call[0][0]
assert "invalid clip dimensions" in warning_msg
# Should not have taken any screenshots
assert mock_page.screenshot.call_count == 0
def test_element_completely_above_viewport(self):
"""Test element that is completely scrolled above the viewport."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1024, "height": 768}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 2000, "top": 0, "left": 0, "width": 800}
# Element completely above viewport
viewport_info = {
"elementX": 0,
"elementY": -800, # Completely above viewport
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (2000px / 768px = 2.6, rounded up to 3):
# 1 initial + 3 * (scroll + viewport info) + 1 reset = 8 calls
# All tiles will be skipped because element is completely above viewport
mock_page.evaluate.side_effect = [
element_info,
None,
viewport_info, # Tile 1 - skipped
None,
viewport_info, # Tile 2 - skipped
None,
viewport_info, # Tile 3 - skipped
None, # Reset scroll
]
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", viewport_height=2000
)
# Should handle gracefully
assert result is not None
# Should have skipped all 3 tiles with warnings
assert mock_logger.warning.call_count == 3
# Should not have taken screenshots
assert mock_page.screenshot.call_count == 0
def test_scroll_increment_respects_actual_viewport_height(self):
"""When config viewport height > actual viewport, we still cover every tile."""
mock_page = MagicMock()
mock_page.viewport_size = {"width": 1600, "height": 1200}
element = MagicMock()
mock_page.locator.return_value = element
element_info = {"height": 3600, "top": 0, "left": 0, "width": 800}
viewport_info = {
"elementX": 0,
"elementY": 0,
"elementWidth": 800,
"elementHeight": 1200,
"viewportWidth": 1600,
"viewportHeight": 1200,
}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll
viewport_info, # First viewport info
None, # Second scroll
viewport_info, # Second viewport info
None, # Third scroll
viewport_info, # Third viewport info
None, # Reset scroll
]
mock_page.screenshot.return_value = b"screenshot"
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# We expect three tiles (01200, 12002400, 24003600)
# even though config says 2000.
assert mock_page.screenshot.call_count == 3
scroll_calls = [
call
for call in mock_page.evaluate.call_args_list
if "scrollTo" in str(call)
]
actual_scrolls = [call[0][0] for call in scroll_calls]
# Should have scrolled to positions: 0, 1200, 2400, plus final reset to 0
assert len(actual_scrolls) == 4 # 3 tile scrolls + 1 reset
assert actual_scrolls == [
"window.scrollTo(0, 0)",
"window.scrollTo(0, 1200)",
"window.scrollTo(0, 2400)",
"window.scrollTo(0, 0)", # Reset scroll
]