Compare commits

...

3 Commits

Author SHA1 Message Date
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
Elizabeth Thompson
20c04a4663 fix(playwright): Simplify tiled screenshot clip calculation to fix multi-tile captures
Fixes issue where Playwright tiled screenshots only captured the first frame
and failed to capture subsequent tiles on large dashboards (50+ charts).

**Problem:**
The previous fix prevented errors but used overly complex tile_content_height
calculations that caused incorrect clip dimensions for tiles after the first one.
Customer testing revealed screenshots would not capture content past the first tile.

**Solution:**
Simplified the clip height calculation by:
- Removing tile_content_height from the clip height logic
- Directly calculating visible portion of element in viewport
- Maintaining proper handling for elements scrolled above viewport

After scrolling to position each tile, we now simply capture what's visible
of the element rather than trying to match a calculated content height.

**Testing:**
- All 21 unit tests pass including edge cases
- Negative element positions (scrolled above viewport)
- Elements extending beyond viewport boundaries
- Elements completely out of view

Based on customer feedback from Brandon Sovran who confirmed this approach
successfully captures massive pages.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 16:43:32 -07:00
Elizabeth Thompson
782f5eab16 fix(playwright): Fix tiled screenshot clip bounds validation
Fixes issue where Playwright tiled screenshots fail with "Clipped area is either empty or outside the resulting image" error on large dashboards (50+ charts).

The problem occurred when:
- Dashboard elements scroll above viewport (negative Y coordinates)
- Clip regions extend beyond viewport boundaries
- Element dimensions result in invalid clip calculations

Changes:
- Add viewport dimension tracking to clip calculations
- Clamp coordinates to viewport bounds (prevent negative x/y)
- Calculate visible portions for partially scrolled elements
- Validate clip dimensions before screenshot (skip invalid tiles)
- Add comprehensive test coverage for edge cases

Tested with 21 unit tests including:
- Negative element positions
- Elements beyond viewport bounds
- Invalid clip dimensions
- Zero-width elements
- Elements completely scrolled out of view

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 14:14:51 -07:00
2 changed files with 524 additions and 54 deletions

View File

@@ -120,15 +120,26 @@ def take_tiled_screenshot(
dashboard_top,
)
# Calculate number of tiles needed
num_tiles = max(1, (dashboard_height + viewport_height - 1) // viewport_height)
# Get actual viewport height to ensure we don't skip content
actual_viewport_height = page.viewport_size["height"]
tile_height = min(viewport_height, actual_viewport_height)
logger.info(
"Viewport: configured=%s, actual=%s, using tile_height=%s",
viewport_height,
actual_viewport_height,
tile_height,
)
# Calculate number of tiles needed based on actual tile height
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
logger.info("Taking %s screenshot tiles", num_tiles)
screenshot_tiles = []
for i in range(num_tiles):
# Calculate scroll position to show this tile's content
scroll_y = dashboard_top + (i * viewport_height)
scroll_y = dashboard_top + (i * tile_height)
# Scroll the window to the desired position
page.evaluate(f"window.scrollTo(0, {scroll_y})")
@@ -139,29 +150,65 @@ def take_tiled_screenshot(
# Wait for scroll to settle and content to load
page.wait_for_timeout(2000) # 2 second wait per tile
# Get the current element position after scroll
current_element_box = page.evaluate(f"""() => {{
# Get the current element position after scroll and viewport size
viewport_info = page.evaluate(f"""() => {{
const el = document.querySelector(".{element_name}");
const rect = el.getBoundingClientRect();
return {{
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
elementX: rect.left,
elementY: rect.top,
elementWidth: rect.width,
elementHeight: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
}};
}}""")
# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * viewport_height
remaining_content = dashboard_height - tile_start_in_element
tile_content_height = min(viewport_height, remaining_content)
# Ensure clip coordinates are within viewport bounds
# If element.top is negative, it's scrolled above viewport - start from y=0
clip_y = max(0, viewport_info["elementY"])
# If element.left is negative, start from x=0
clip_x = max(0, viewport_info["elementX"])
# Calculate clip dimensions - capture what's visible of the element
# Handle elements scrolled above viewport: if elementY is negative,
# only the portion from (elementY + elementHeight) is visible
if viewport_info["elementY"] < 0:
# Element extends from above viewport - calculate visible portion
visible_height = (
viewport_info["elementY"] + viewport_info["elementHeight"]
)
clip_height = min(visible_height, viewport_info["viewportHeight"])
else:
# Element is within viewport
clip_height = min(
viewport_info["elementHeight"],
viewport_info["viewportHeight"] - clip_y,
)
clip_width = min(
viewport_info["elementWidth"], viewport_info["viewportWidth"] - clip_x
)
# Validate clip region before taking screenshot
if clip_width <= 0 or clip_height <= 0:
logger.warning(
"Skipping tile %s/%s - invalid clip dimensions: %sx%s at (%s, %s)",
i + 1,
num_tiles,
clip_width,
clip_height,
clip_x,
clip_y,
)
continue
# Clip to capture only the current tile portion of the element
clip = {
"x": current_element_box["x"],
"y": current_element_box["y"],
"width": current_element_box["width"],
"height": min(tile_content_height, current_element_box["height"]),
"x": clip_x,
"y": clip_y,
"width": clip_width,
"height": clip_height,
}
# Take screenshot with clipping to capture only this tile's content

View File

@@ -110,24 +110,42 @@ class TestTakeTiledScreenshot:
"""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}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
viewport_info = {
"elementX": 50,
"elementY": 200,
"elementWidth": 800,
"elementHeight": 600,
"viewportWidth": 1024,
"viewportHeight": 768,
}
# For 3 tiles (5000px / 2000px = 2.5, rounded up to 3):
# 1 initial call + 3 scroll + 3 element box + 1 reset scroll = 8 calls
# 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, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
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
]
@@ -150,8 +168,8 @@ class TestTakeTiledScreenshot:
assert result == b"combined_screenshot"
# Should have called screenshot method multiple times
# (3 tiles for 5000px height)
assert mock_page.screenshot.call_count == 3
# (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()
@@ -171,16 +189,23 @@ class TestTakeTiledScreenshot:
"""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}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
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 element box + 1 reset scroll = 6 calls
# 1 initial call + 2 scroll + 2 viewport info + 1 reset scroll = 6 calls
mock_page.evaluate.side_effect = [
element_info,
None, # First scroll call
element_box, # First element box call
viewport_info, # First viewport info call
None, # Second scroll call
element_box, # Second element box call
viewport_info, # Second viewport info call
None, # Reset scroll call
]
@@ -198,38 +223,57 @@ class TestTakeTiledScreenshot:
"""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}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
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, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
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)
# 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, 2100, 4100
# Should have scrolled to positions: 100, 868, 1636, 2404, 3172, 3940, 4708
expected_scrolls = [
"window.scrollTo(0, 100)",
"window.scrollTo(0, 2100)",
"window.scrollTo(0, 4100)",
"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) == 4 # 3 tile scrolls + 1 reset
assert len(actual_scrolls) == 8 # 7 tile scrolls + 1 reset
for expected in expected_scrolls:
assert expected in actual_scrolls
@@ -237,16 +281,31 @@ class TestTakeTiledScreenshot:
"""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}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
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, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
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
]
@@ -268,7 +327,7 @@ class TestTakeTiledScreenshot:
"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", 3)
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."""
@@ -289,8 +348,8 @@ class TestTakeTiledScreenshot:
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 (3 tiles)
assert mock_page.wait_for_timeout.call_count == 3
# 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:
@@ -315,3 +374,367 @@ class TestTakeTiledScreenshot:
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
]