mirror of
https://github.com/apache/superset.git
synced 2026-07-18 04:35:40 +00:00
Compare commits
2 Commits
dependabot
...
poc/per-ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec96a39da | ||
|
|
bfc1debb6a |
@@ -60,6 +60,7 @@ export enum FeatureFlag {
|
||||
GranularExportControls = 'GRANULAR_EXPORT_CONTROLS',
|
||||
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
Matrixify = 'MATRIXIFY',
|
||||
PerChartDashboardReports = 'PER_CHART_DASHBOARD_REPORTS',
|
||||
ScheduledQueries = 'SCHEDULED_QUERIES',
|
||||
SemanticLayers = 'SEMANTIC_LAYERS',
|
||||
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',
|
||||
|
||||
@@ -679,6 +679,54 @@ test('removes ignore cache checkbox when chart is selected', async () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders single column checkbox when dashboard is selected', async () => {
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
await screen.findByText(/test dashboard/i);
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /arrange dashboard charts in a single column/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('removes single column checkbox when chart is selected', async () => {
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
await screen.findByText(/test dashboard/i);
|
||||
const contentTypeSelector = screen.getByRole('combobox', {
|
||||
name: /select content type/i,
|
||||
});
|
||||
await comboboxSelect(
|
||||
contentTypeSelector,
|
||||
'Chart',
|
||||
() => screen.getAllByText(/select chart/i)[0],
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('checkbox', {
|
||||
name: /arrange dashboard charts in a single column/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('toggles single column checkbox', async () => {
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
await screen.findByText(/test dashboard/i);
|
||||
const checkbox = screen.getByRole('checkbox', {
|
||||
name: /arrange dashboard charts in a single column/i,
|
||||
});
|
||||
expect(checkbox).not.toBeChecked();
|
||||
await userEvent.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('open chart button opens explore with slice_id', async () => {
|
||||
// Render with an existing alert that has a chart selected
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
|
||||
@@ -694,7 +742,9 @@ test('open chart button opens explore with slice_id', async () => {
|
||||
});
|
||||
expect(openChartButton).toBeInTheDocument();
|
||||
|
||||
const navSpy = jest.spyOn(navigationUtils, 'navigateTo').mockImplementation(() => null);
|
||||
const navSpy = jest
|
||||
.spyOn(navigationUtils, 'navigateTo')
|
||||
.mockImplementation(() => null);
|
||||
try {
|
||||
await userEvent.click(openChartButton);
|
||||
expect(navSpy).toHaveBeenCalledWith(
|
||||
@@ -721,7 +771,9 @@ test('open dashboard button opens dashboard url', async () => {
|
||||
});
|
||||
expect(openDashButton).toBeInTheDocument();
|
||||
|
||||
const navSpy = jest.spyOn(navigationUtils, 'navigateTo').mockImplementation(() => null);
|
||||
const navSpy = jest
|
||||
.spyOn(navigationUtils, 'navigateTo')
|
||||
.mockImplementation(() => null);
|
||||
try {
|
||||
await userEvent.click(openDashButton);
|
||||
expect(navSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -628,6 +628,9 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
isFeatureEnabled(FeatureFlag.AlertsAttachReports) || isReport;
|
||||
const tabsEnabled = isFeatureEnabled(FeatureFlag.AlertReportTabs);
|
||||
const filtersEnabled = isFeatureEnabled(FeatureFlag.AlertReportsFilter);
|
||||
const perChartEnabled = isFeatureEnabled(
|
||||
FeatureFlag.PerChartDashboardReports,
|
||||
);
|
||||
|
||||
const [notificationAddState, setNotificationAddState] =
|
||||
useState<NotificationAddStatus>('active');
|
||||
@@ -861,6 +864,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
setCurrentAlert(currentAlertData => {
|
||||
const dashboardState = currentAlertData?.extra?.dashboard;
|
||||
const extra = {
|
||||
...currentAlertData?.extra,
|
||||
dashboard: {
|
||||
...dashboardState,
|
||||
anchor: value,
|
||||
@@ -1488,6 +1492,17 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
setForceScreenshot(e.target.checked);
|
||||
};
|
||||
|
||||
const onPerChartDashboardChange = (e: CheckboxChangeEvent) => {
|
||||
const { checked } = e.target;
|
||||
setCurrentAlert(currentAlertData => ({
|
||||
...currentAlertData,
|
||||
extra: {
|
||||
...currentAlertData?.extra,
|
||||
per_chart_dashboard: checked,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const onChangeDashboardFilter = (idx: number, nativeFilterId: string) => {
|
||||
if (
|
||||
!nativeFilterId ||
|
||||
@@ -2652,6 +2667,30 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
{perChartEnabled &&
|
||||
isScreenshot &&
|
||||
contentType === ContentType.Dashboard && (
|
||||
<div className="inline-container">
|
||||
<Checkbox
|
||||
data-test="per-chart-dashboard"
|
||||
checked={
|
||||
currentAlert?.extra?.per_chart_dashboard || false
|
||||
}
|
||||
onChange={onPerChartDashboardChange}
|
||||
>
|
||||
{t('Arrange dashboard charts in a single column')}
|
||||
</Checkbox>
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'Each chart is captured individually and stacked ' +
|
||||
'vertically in the report. Dashboard filters ' +
|
||||
'are applied to all charts. Charts that fail ' +
|
||||
'to load are skipped instead of blocking the ' +
|
||||
'report.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -110,6 +110,7 @@ export type ExtraNativeFilter = {
|
||||
|
||||
export type Extra = {
|
||||
dashboard?: DashboardState;
|
||||
per_chart_dashboard?: boolean;
|
||||
};
|
||||
|
||||
export type Operator = '<' | '>' | '<=' | '>=' | '==' | '!=' | 'not null';
|
||||
|
||||
@@ -463,9 +463,22 @@ class BaseReportState:
|
||||
)
|
||||
for url in urls
|
||||
]
|
||||
per_chart_reports = feature_flag_manager.is_feature_enabled(
|
||||
"PER_CHART_DASHBOARD_REPORTS"
|
||||
) and bool((self._report_schedule.extra or {}).get("per_chart_dashboard"))
|
||||
try:
|
||||
imges = []
|
||||
for screenshot in screenshots:
|
||||
if per_chart_reports and isinstance(screenshot, DashboardScreenshot):
|
||||
chart_imges = screenshot.get_per_chart_screenshots(user=user)
|
||||
if chart_imges:
|
||||
imges.extend(chart_imges)
|
||||
continue
|
||||
logger.warning(
|
||||
"Per-chart capture returned no charts for %s; "
|
||||
"falling back to full-dashboard screenshot",
|
||||
screenshot.url,
|
||||
)
|
||||
imge = screenshot.get_screenshot(user=user)
|
||||
if imge is None:
|
||||
raise ReportScheduleScreenshotFailedError(
|
||||
|
||||
@@ -732,6 +732,14 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
# When impersonating a user, use the email prefix instead of username
|
||||
# @lifecycle: testing
|
||||
"IMPERSONATE_WITH_EMAIL_PREFIX": False,
|
||||
# Expose a per-report option to capture dashboard reports chart-by-chart
|
||||
# and arrange the images in a single column, instead of one
|
||||
# full-dashboard screenshot. Each chart waits for its own loading
|
||||
# spinner, so one slow chart cannot block or blank the entire report.
|
||||
# Enabled per report via the "Arrange dashboard charts in a single
|
||||
# column" checkbox. Requires PLAYWRIGHT_REPORTS_AND_THUMBNAILS.
|
||||
# @lifecycle: testing
|
||||
"PER_CHART_DASHBOARD_REPORTS": False,
|
||||
# Replace Selenium with Playwright for reports and thumbnails.
|
||||
# Supports deck.gl visualizations. Requires playwright pip package.
|
||||
# @lifecycle: testing
|
||||
|
||||
@@ -19,5 +19,9 @@ from typing import TypedDict
|
||||
from superset.dashboards.permalink.types import DashboardPermalinkState
|
||||
|
||||
|
||||
class ReportScheduleExtra(TypedDict):
|
||||
class ReportScheduleExtra(TypedDict, total=False):
|
||||
dashboard: DashboardPermalinkState
|
||||
# Capture each chart individually and arrange the images in a single
|
||||
# column, instead of one full-dashboard screenshot. Only honored when
|
||||
# the PER_CHART_DASHBOARD_REPORTS feature flag is enabled.
|
||||
per_chart_dashboard: bool
|
||||
|
||||
@@ -232,3 +232,69 @@ def take_tiled_screenshot(
|
||||
except Exception as e:
|
||||
logger.exception("Tiled screenshot failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
CHART_HOLDER_SELECTOR = '[data-test="dashboard-component-chart-holder"]'
|
||||
|
||||
|
||||
def take_per_chart_screenshots(
|
||||
page: "Page",
|
||||
load_wait: int = 60,
|
||||
animation_wait: int = 0,
|
||||
) -> list[bytes]:
|
||||
"""
|
||||
Capture each chart on a dashboard as an individual screenshot.
|
||||
|
||||
Unlike the full-dashboard screenshot, each chart waits only for its own
|
||||
loading spinner, so one slow chart cannot block or blank the entire
|
||||
report. Charts whose spinner never clears within ``load_wait`` are
|
||||
skipped with a warning rather than captured mid-load.
|
||||
|
||||
The page must already be navigated to the dashboard (typically via a
|
||||
permalink URL so tab state and dashboard filters are applied).
|
||||
|
||||
Args:
|
||||
page: Playwright page object, already at the dashboard URL
|
||||
load_wait: Seconds to wait for each chart's spinner to clear
|
||||
animation_wait: Seconds to wait for chart animations after load
|
||||
|
||||
Returns:
|
||||
List of PNG screenshot bytes, one per successfully captured chart,
|
||||
in DOM (layout) order. Empty list if no charts could be captured.
|
||||
"""
|
||||
chart_holders = page.locator(CHART_HOLDER_SELECTOR)
|
||||
chart_count = chart_holders.count()
|
||||
logger.info("Capturing %s charts individually", chart_count)
|
||||
|
||||
screenshots: list[bytes] = []
|
||||
for i in range(chart_count):
|
||||
holder = chart_holders.nth(i)
|
||||
try:
|
||||
holder.scroll_into_view_if_needed()
|
||||
# Trigger lazy loading and let layout settle
|
||||
page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
|
||||
try:
|
||||
# Wait for this chart's own spinner only; spinners in other
|
||||
# charts (still loading elsewhere on the page) don't block.
|
||||
holder.locator(".loading").first.wait_for(
|
||||
state="detached", timeout=load_wait * 1000
|
||||
)
|
||||
except PlaywrightTimeout:
|
||||
logger.warning(
|
||||
"Chart %s/%s did not finish loading within %ss; skipping",
|
||||
i + 1,
|
||||
chart_count,
|
||||
load_wait,
|
||||
)
|
||||
continue
|
||||
if animation_wait > 0:
|
||||
page.wait_for_timeout(animation_wait * 1000)
|
||||
screenshots.append(holder.screenshot())
|
||||
logger.debug("Captured chart %s/%s", i + 1, chart_count)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to capture chart %s/%s; skipping", i + 1, chart_count
|
||||
)
|
||||
|
||||
logger.info("Captured %s/%s charts individually", len(screenshots), chart_count)
|
||||
return screenshots
|
||||
|
||||
@@ -410,6 +410,24 @@ class DashboardScreenshot(BaseScreenshot):
|
||||
self.window_size = window_size or DEFAULT_DASHBOARD_WINDOW_SIZE
|
||||
self.thumb_size = thumb_size or DEFAULT_DASHBOARD_THUMBNAIL_SIZE
|
||||
|
||||
def get_per_chart_screenshots(
|
||||
self, user: User, window_size: WindowSize | None = None
|
||||
) -> list[bytes] | None:
|
||||
"""
|
||||
Capture each chart on the dashboard as an individual screenshot.
|
||||
|
||||
Playwright-only; returns None when the resolved driver is Selenium
|
||||
so callers can fall back to the full-dashboard screenshot.
|
||||
"""
|
||||
driver = self.driver(window_size, user)
|
||||
if not isinstance(driver, WebDriverPlaywright):
|
||||
logger.info(
|
||||
"Per-chart screenshots require Playwright; falling back to "
|
||||
"full-dashboard screenshot"
|
||||
)
|
||||
return None
|
||||
return driver.get_per_chart_screenshots(self.url, self.element, user)
|
||||
|
||||
def get_cache_key(
|
||||
self,
|
||||
window_size: bool | WindowSize | None = None,
|
||||
|
||||
@@ -41,7 +41,10 @@ from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from superset.extensions import machine_auth_provider_factory
|
||||
from superset.utils.retries import retry_call
|
||||
from superset.utils.screenshot_utils import take_tiled_screenshot
|
||||
from superset.utils.screenshot_utils import (
|
||||
take_per_chart_screenshots,
|
||||
take_tiled_screenshot,
|
||||
)
|
||||
|
||||
WindowSize = tuple[int, int]
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -481,6 +484,78 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
context.close()
|
||||
return img
|
||||
|
||||
def get_per_chart_screenshots(
|
||||
self, url: str, element_name: str, user: User | None = None
|
||||
) -> list[bytes] | None:
|
||||
"""
|
||||
Capture each chart on a dashboard as an individual screenshot.
|
||||
|
||||
Navigates to the dashboard once (so permalink tab state and dashboard
|
||||
filters apply to every chart), then captures chart-by-chart. Each
|
||||
chart waits only for its own loading spinner, so one slow chart
|
||||
cannot block or blank the entire report.
|
||||
|
||||
Returns a list of PNG bytes in layout order, or None if Playwright
|
||||
is unavailable or the dashboard itself failed to load.
|
||||
"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
logger.info(
|
||||
"Playwright not available - per-chart screenshots require "
|
||||
"Playwright. %s",
|
||||
PLAYWRIGHT_INSTALL_MESSAGE,
|
||||
)
|
||||
return None
|
||||
|
||||
browser_args = app.config["WEBDRIVER_OPTION_ARGS"]
|
||||
browser = _browser_manager.get_browser(browser_args)
|
||||
pixel_density = app.config["WEBDRIVER_WINDOW"].get("pixel_density", 1)
|
||||
context = browser.new_context(
|
||||
bypass_csp=True,
|
||||
viewport={
|
||||
"height": self._window[1],
|
||||
"width": self._window[0],
|
||||
},
|
||||
device_scale_factor=pixel_density,
|
||||
)
|
||||
context.set_default_timeout(app.config["SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT"])
|
||||
if user:
|
||||
self.auth(user, context)
|
||||
page = context.new_page()
|
||||
try:
|
||||
try:
|
||||
page.goto(
|
||||
url,
|
||||
wait_until=app.config["SCREENSHOT_PLAYWRIGHT_WAIT_EVENT"],
|
||||
)
|
||||
except PlaywrightTimeout:
|
||||
logger.exception(
|
||||
"Web event %s not detected. Page %s might not have been "
|
||||
"fully loaded",
|
||||
app.config["SCREENSHOT_PLAYWRIGHT_WAIT_EVENT"],
|
||||
url,
|
||||
)
|
||||
|
||||
selenium_headstart = app.config["SCREENSHOT_SELENIUM_HEADSTART"]
|
||||
page.wait_for_timeout(selenium_headstart * 1000)
|
||||
try:
|
||||
page.locator(f".{element_name}").wait_for()
|
||||
except PlaywrightTimeout:
|
||||
logger.exception("Timed out requesting url %s", url)
|
||||
return None
|
||||
|
||||
return take_per_chart_screenshots(
|
||||
page,
|
||||
load_wait=self._screenshot_load_wait,
|
||||
animation_wait=app.config["SCREENSHOT_SELENIUM_ANIMATION_WAIT"],
|
||||
)
|
||||
except PlaywrightError:
|
||||
logger.exception(
|
||||
"Encountered an unexpected error when requesting url %s", url
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
context.close()
|
||||
|
||||
|
||||
class WebDriverSelenium(WebDriverProxy):
|
||||
def __init__(
|
||||
|
||||
@@ -54,7 +54,7 @@ from superset.reports.models import (
|
||||
ReportState,
|
||||
)
|
||||
from superset.utils.core import HeaderDataType
|
||||
from superset.utils.screenshots import ChartScreenshot
|
||||
from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot
|
||||
from tests.integration_tests.conftest import with_feature_flags
|
||||
|
||||
|
||||
@@ -1722,3 +1722,107 @@ def test_get_url_for_csv_uses_post_processed_type(
|
||||
f"CSV report URL must use type=post_processed so chart filters "
|
||||
f"(incl. time filters) are applied; got: {url}; see issue #25538"
|
||||
)
|
||||
|
||||
|
||||
def _make_dashboard_report_state(
|
||||
mocker: MockerFixture, per_chart_dashboard: bool = True
|
||||
) -> BaseReportState:
|
||||
"""Build a BaseReportState for a dashboard report with screenshot mocks."""
|
||||
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
|
||||
mock_report_schedule.chart = False
|
||||
mock_report_schedule.dashboard_id = 123
|
||||
mock_report_schedule.dashboard.digest = "digest"
|
||||
mock_report_schedule.custom_width = None
|
||||
mock_report_schedule.custom_height = None
|
||||
mock_report_schedule.extra = (
|
||||
{"per_chart_dashboard": True} if per_chart_dashboard else {}
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.report.execute.get_executor",
|
||||
return_value=(None, "admin"),
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.report.execute.security_manager.find_user",
|
||||
return_value=mocker.Mock(),
|
||||
)
|
||||
mocker.patch.object(
|
||||
BaseReportState,
|
||||
"get_dashboard_urls",
|
||||
return_value=["http://example.com/superset/dashboard/p/abc123/"],
|
||||
)
|
||||
|
||||
class_instance = BaseReportState(
|
||||
mock_report_schedule, "January 1, 2021", "execution_id_example"
|
||||
)
|
||||
class_instance._report_schedule = mock_report_schedule
|
||||
return class_instance
|
||||
|
||||
|
||||
@with_feature_flags(PER_CHART_DASHBOARD_REPORTS=True)
|
||||
def test_get_screenshots_per_chart_mode(mocker: MockerFixture, app) -> None:
|
||||
"""With the flag on, dashboard reports capture each chart individually."""
|
||||
class_instance = _make_dashboard_report_state(mocker)
|
||||
per_chart = mocker.patch.object(
|
||||
DashboardScreenshot,
|
||||
"get_per_chart_screenshots",
|
||||
return_value=[b"chart1", b"chart2"],
|
||||
)
|
||||
full_screenshot = mocker.patch.object(DashboardScreenshot, "get_screenshot")
|
||||
|
||||
result = class_instance._get_screenshots()
|
||||
|
||||
assert result == [b"chart1", b"chart2"]
|
||||
per_chart.assert_called_once()
|
||||
full_screenshot.assert_not_called()
|
||||
|
||||
|
||||
@with_feature_flags(PER_CHART_DASHBOARD_REPORTS=True)
|
||||
def test_get_screenshots_per_chart_mode_falls_back_when_empty(
|
||||
mocker: MockerFixture, app
|
||||
) -> None:
|
||||
"""If per-chart capture yields nothing, fall back to the full screenshot."""
|
||||
class_instance = _make_dashboard_report_state(mocker)
|
||||
mocker.patch.object(
|
||||
DashboardScreenshot, "get_per_chart_screenshots", return_value=[]
|
||||
)
|
||||
full_screenshot = mocker.patch.object(
|
||||
DashboardScreenshot, "get_screenshot", return_value=b"full_dashboard"
|
||||
)
|
||||
|
||||
result = class_instance._get_screenshots()
|
||||
|
||||
assert result == [b"full_dashboard"]
|
||||
full_screenshot.assert_called_once()
|
||||
|
||||
|
||||
@with_feature_flags(PER_CHART_DASHBOARD_REPORTS=False)
|
||||
def test_get_screenshots_per_chart_mode_disabled(mocker: MockerFixture, app) -> None:
|
||||
"""With the flag off, dashboard reports use the full-dashboard screenshot."""
|
||||
class_instance = _make_dashboard_report_state(mocker)
|
||||
per_chart = mocker.patch.object(DashboardScreenshot, "get_per_chart_screenshots")
|
||||
mocker.patch.object(
|
||||
DashboardScreenshot, "get_screenshot", return_value=b"full_dashboard"
|
||||
)
|
||||
|
||||
result = class_instance._get_screenshots()
|
||||
|
||||
assert result == [b"full_dashboard"]
|
||||
per_chart.assert_not_called()
|
||||
|
||||
|
||||
@with_feature_flags(PER_CHART_DASHBOARD_REPORTS=True)
|
||||
def test_get_screenshots_per_chart_report_toggle_off(
|
||||
mocker: MockerFixture, app
|
||||
) -> None:
|
||||
"""The flag alone is not enough; the report's own toggle must be on."""
|
||||
class_instance = _make_dashboard_report_state(mocker, per_chart_dashboard=False)
|
||||
per_chart = mocker.patch.object(DashboardScreenshot, "get_per_chart_screenshots")
|
||||
mocker.patch.object(
|
||||
DashboardScreenshot, "get_screenshot", return_value=b"full_dashboard"
|
||||
)
|
||||
|
||||
result = class_instance._get_screenshots()
|
||||
|
||||
assert result == [b"full_dashboard"]
|
||||
per_chart.assert_not_called()
|
||||
|
||||
@@ -23,7 +23,9 @@ from PIL import Image
|
||||
|
||||
from superset.utils.screenshot_utils import (
|
||||
combine_screenshot_tiles,
|
||||
PlaywrightTimeout,
|
||||
SCROLL_SETTLE_TIMEOUT_MS,
|
||||
take_per_chart_screenshots,
|
||||
take_tiled_screenshot,
|
||||
)
|
||||
|
||||
@@ -397,3 +399,90 @@ class TestTakeTiledScreenshot:
|
||||
|
||||
sig = inspect.signature(take_tiled_screenshot)
|
||||
assert sig.parameters["animation_wait"].default == 0
|
||||
|
||||
|
||||
class TestTakePerChartScreenshots:
|
||||
def _make_page_with_charts(self, chart_screenshots: list[bytes]):
|
||||
"""Create a mock page whose chart holders return the given bytes."""
|
||||
page = MagicMock()
|
||||
holders = MagicMock()
|
||||
holders.count.return_value = len(chart_screenshots)
|
||||
|
||||
holder_mocks = []
|
||||
for shot in chart_screenshots:
|
||||
holder = MagicMock()
|
||||
holder.screenshot.return_value = shot
|
||||
holder_mocks.append(holder)
|
||||
holders.nth.side_effect = lambda i: holder_mocks[i]
|
||||
page.locator.return_value = holders
|
||||
return page, holder_mocks
|
||||
|
||||
def test_captures_all_charts_in_order(self):
|
||||
page, _ = self._make_page_with_charts([b"chart1", b"chart2", b"chart3"])
|
||||
|
||||
result = take_per_chart_screenshots(page, load_wait=10)
|
||||
|
||||
assert result == [b"chart1", b"chart2", b"chart3"]
|
||||
|
||||
def test_uses_chart_holder_selector(self):
|
||||
page, _ = self._make_page_with_charts([b"chart1"])
|
||||
|
||||
take_per_chart_screenshots(page, load_wait=10)
|
||||
|
||||
page.locator.assert_called_once_with(
|
||||
'[data-test="dashboard-component-chart-holder"]'
|
||||
)
|
||||
|
||||
def test_no_charts_returns_empty_list(self):
|
||||
page, _ = self._make_page_with_charts([])
|
||||
|
||||
result = take_per_chart_screenshots(page, load_wait=10)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_spinner_timeout_skips_chart_and_continues(self):
|
||||
page, holders = self._make_page_with_charts([b"chart1", b"chart2"])
|
||||
# First chart's spinner never clears
|
||||
holders[0].locator.return_value.first.wait_for.side_effect = PlaywrightTimeout(
|
||||
"spinner stuck"
|
||||
)
|
||||
|
||||
result = take_per_chart_screenshots(page, load_wait=10)
|
||||
|
||||
assert result == [b"chart2"]
|
||||
holders[0].screenshot.assert_not_called()
|
||||
|
||||
def test_screenshot_failure_skips_chart_and_continues(self):
|
||||
page, holders = self._make_page_with_charts([b"chart1", b"chart2"])
|
||||
holders[0].screenshot.side_effect = Exception("capture failed")
|
||||
|
||||
result = take_per_chart_screenshots(page, load_wait=10)
|
||||
|
||||
assert result == [b"chart2"]
|
||||
|
||||
def test_waits_for_each_charts_own_spinner(self):
|
||||
page, holders = self._make_page_with_charts([b"chart1"])
|
||||
|
||||
take_per_chart_screenshots(page, load_wait=42)
|
||||
|
||||
holders[0].locator.assert_called_once_with(".loading")
|
||||
holders[0].locator.return_value.first.wait_for.assert_called_once_with(
|
||||
state="detached", timeout=42000
|
||||
)
|
||||
|
||||
def test_scrolls_each_chart_into_view(self):
|
||||
page, holders = self._make_page_with_charts([b"chart1", b"chart2"])
|
||||
|
||||
take_per_chart_screenshots(page, load_wait=10)
|
||||
|
||||
for holder in holders:
|
||||
holder.scroll_into_view_if_needed.assert_called_once()
|
||||
|
||||
def test_animation_wait_applied_after_spinner_clears(self):
|
||||
page, _ = self._make_page_with_charts([b"chart1"])
|
||||
|
||||
take_per_chart_screenshots(page, load_wait=10, animation_wait=3)
|
||||
|
||||
# Scroll settle wait plus animation wait
|
||||
page.wait_for_timeout.assert_any_call(SCROLL_SETTLE_TIMEOUT_MS)
|
||||
page.wait_for_timeout.assert_any_call(3000)
|
||||
|
||||
Reference in New Issue
Block a user