This is an automated email from the ASF dual-hosted git repository.

rebenitez1802 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 97329bb1cf1 fix(reports): detect perceptually blank screenshots 
(#44034)
97329bb1cf1 is described below

commit 97329bb1cf17ffaa6fd59befa70284c72c51c79d
Author: Mafi <[email protected]>
AuthorDate: Wed Sep 9 23:18:20 2026 +1000

    fix(reports): detect perceptually blank screenshots (#44034)
---
 docs/admin_docs/configuration/alerts-reports.mdx |  10 +
 superset/utils/screenshot_utils.py               | 268 +++++++++++++++++++----
 superset/utils/webdriver.py                      | 169 +++++++++++---
 tests/unit_tests/commands/report/execute_test.py |  37 ++++
 tests/unit_tests/utils/test_screenshot_utils.py  | 261 +++++++++++++++++++---
 tests/unit_tests/utils/webdriver_test.py         | 205 ++++++++++++++---
 6 files changed, 823 insertions(+), 127 deletions(-)

diff --git a/docs/admin_docs/configuration/alerts-reports.mdx 
b/docs/admin_docs/configuration/alerts-reports.mdx
index a65dfea77f5..32cad433ca5 100644
--- a/docs/admin_docs/configuration/alerts-reports.mdx
+++ b/docs/admin_docs/configuration/alerts-reports.mdx
@@ -476,6 +476,16 @@ to determine which hosts are internal.
 
 There are many reasons that reports might not be working. Try these steps to 
check for specific issues.
 
+### Blank PDF or PNG captures
+
+Playwright report captures are checked for blank image content as well as chart
+readiness. Captures detected as blank where chart content is expected are 
retried
+up to three total attempts, within the execution deadline. If they remain 
blank,
+the report fails instead of delivering the blank or partial attachment. This 
also
+applies to alerts that attach reports. Check worker logs for
+`ScreenshotBlankCaptureError` and `report_capture_validation` when 
investigating
+these failures.
+
 ### Confirm feature flag is enabled and you have sufficient permissions
 
 If you don't see "Alerts & Reports" under the _Manage_ section of the Settings 
dropdown in the Superset UI, you need to enable the `ALERT_REPORTS` feature 
flag (see above). Enable another feature flag and check to see that it took 
effect, to verify that your config file is getting loaded.
diff --git a/superset/utils/screenshot_utils.py 
b/superset/utils/screenshot_utils.py
index f1996561eb3..b48814b2403 100644
--- a/superset/utils/screenshot_utils.py
+++ b/superset/utils/screenshot_utils.py
@@ -19,11 +19,14 @@ from __future__ import annotations
 
 import io
 import logging
+import math
 import time
+from dataclasses import dataclass
 from typing import TYPE_CHECKING
 
 from celery import current_task
-from PIL import Image, UnidentifiedImageError
+from celery.exceptions import SoftTimeLimitExceeded
+from PIL import Image, ImageChops, ImageStat, UnidentifiedImageError
 
 from superset.utils.report_execution import (
     ReportExecutionBudgetExceededError,
@@ -45,6 +48,13 @@ EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS = 5.0
 TILED_SCREENSHOT_CAPTURE_TIMEOUT_SECONDS = 120
 TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS = 3
 TILED_SCREENSHOT_BLANK_DOMINANT_PIXEL_RATIO = 0.995
+SCREENSHOT_BLANK_MIN_LUMINANCE = 240
+SCREENSHOT_BLANK_MIN_MEAN_LUMINANCE = 250.0
+SCREENSHOT_BLANK_MAX_LUMINANCE_STDDEV = 8.0
+SCREENSHOT_BLANK_MAX_ENTROPY = 1.5
+SCREENSHOT_BLANK_MIN_EDGE_DIFFERENCE = 8
+SCREENSHOT_BLANK_MIN_STRUCTURAL_EDGE_RATIO = 0.02
+SCREENSHOT_BLANK_SAMPLE_SIZES = (256, 1024)
 
 # Runtime task-budget policy shared with the approach introduced in #42118.
 # Celery exposes the effective per-task hard/soft limits only on the running
@@ -130,32 +140,124 @@ class ScreenshotCaptureTimeoutError(RuntimeError):
     """Raised when Chromium repeatedly times out while capturing a tile."""
 
 
-def is_screenshot_nearly_uniform(screenshot: bytes) -> tuple[bool, float]:
-    """Return whether one color occupies nearly all sampled screenshot 
pixels."""
+class ScreenshotBlankCaptureError(RuntimeError):
+    """Raised when Chromium repeatedly returns a perceptually blank capture."""
+
+
+@dataclass(frozen=True)
+class ScreenshotBlanknessMetrics:
+    """Metrics used to decide whether a screenshot is perceptually blank."""
+
+    is_blank: bool
+    dominant_pixel_ratio: float
+    near_white_pixel_ratio: float
+    mean_luminance: float
+    luminance_stddev: float
+    entropy: float
+    structural_edge_ratio: float
+
+
+def get_screenshot_blankness_metrics(screenshot: bytes) -> 
ScreenshotBlanknessMetrics:
+    """Measure exact-color and perceptual blankness on a sampled screenshot."""
 
     try:
         with Image.open(io.BytesIO(screenshot)) as image:
-            sample = image.convert("RGB")
-            sample.thumbnail((256, 256))
-            colors = sample.getcolors(maxcolors=256)
-            if not colors:
-                return False, 0.0
-            dominant_pixels = max(count for count, _color in colors)
-            dominant_ratio = dominant_pixels / (sample.width * sample.height)
-            return (
-                dominant_ratio >= TILED_SCREENSHOT_BLANK_DOMINANT_PIXEL_RATIO,
-                dominant_ratio,
+            sample_metrics: list[ScreenshotBlanknessMetrics] = []
+            for sample_size in SCREENSHOT_BLANK_SAMPLE_SIZES:
+                sample = image.convert("RGB")
+                sample.thumbnail((sample_size, sample_size))
+                pixel_count = sample.width * sample.height
+                colors = sample.getcolors(maxcolors=pixel_count) or []
+                dominant_pixels = max(
+                    (count for count, _color in colors),
+                    default=0,
+                )
+                dominant_ratio = dominant_pixels / pixel_count
+
+                grayscale = sample.convert("L")
+                histogram = grayscale.histogram()
+                near_white_ratio = (
+                    sum(histogram[SCREENSHOT_BLANK_MIN_LUMINANCE:]) / 
pixel_count
+                )
+                statistics = ImageStat.Stat(grayscale)
+                mean_luminance = float(statistics.mean[0])
+                luminance_stddev = float(statistics.stddev[0])
+                entropy = -sum(
+                    (count / pixel_count) * math.log2(count / pixel_count)
+                    for count in histogram
+                    if count
+                )
+                horizontal_edges = ImageChops.difference(
+                    grayscale, ImageChops.offset(grayscale, 1, 0)
+                )
+                vertical_edges = ImageChops.difference(
+                    grayscale, ImageChops.offset(grayscale, 0, 1)
+                )
+                edge_histogram = ImageChops.lighter(
+                    horizontal_edges, vertical_edges
+                ).histogram()
+                structural_edge_ratio = (
+                    sum(edge_histogram[SCREENSHOT_BLANK_MIN_EDGE_DIFFERENCE:])
+                    / pixel_count
+                )
+                low_information = (
+                    luminance_stddev <= SCREENSHOT_BLANK_MAX_LUMINANCE_STDDEV
+                    and entropy <= SCREENSHOT_BLANK_MAX_ENTROPY
+                    and structural_edge_ratio
+                    <= SCREENSHOT_BLANK_MIN_STRUCTURAL_EDGE_RATIO
+                )
+                perceptually_blank = low_information and (
+                    mean_luminance >= SCREENSHOT_BLANK_MIN_MEAN_LUMINANCE
+                    or dominant_ratio >= 
TILED_SCREENSHOT_BLANK_DOMINANT_PIXEL_RATIO
+                )
+                sample_metrics.append(
+                    ScreenshotBlanknessMetrics(
+                        is_blank=perceptually_blank,
+                        dominant_pixel_ratio=dominant_ratio,
+                        near_white_pixel_ratio=near_white_ratio,
+                        mean_luminance=mean_luminance,
+                        luminance_stddev=luminance_stddev,
+                        entropy=entropy,
+                        structural_edge_ratio=structural_edge_ratio,
+                    )
+                )
+
+            metrics = sample_metrics[-1]
+            return ScreenshotBlanknessMetrics(
+                is_blank=all(sample.is_blank for sample in sample_metrics),
+                dominant_pixel_ratio=metrics.dominant_pixel_ratio,
+                near_white_pixel_ratio=metrics.near_white_pixel_ratio,
+                mean_luminance=metrics.mean_luminance,
+                luminance_stddev=metrics.luminance_stddev,
+                entropy=metrics.entropy,
+                structural_edge_ratio=metrics.structural_edge_ratio,
             )
     except (OSError, UnidentifiedImageError):
         # Combining the tiles remains responsible for rejecting corrupt image
         # bytes. This check only identifies valid images with blank pixels.
-        return False, 0.0
+        return ScreenshotBlanknessMetrics(False, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
+
+
+def is_screenshot_nearly_uniform(screenshot: bytes) -> tuple[bool, float]:
+    """Return the blank decision and dominant-color ratio for compatibility."""
+
+    metrics = get_screenshot_blankness_metrics(screenshot)
+    return metrics.is_blank, metrics.dominant_pixel_ratio
 
 
 try:
-    from playwright.sync_api import TimeoutError as PlaywrightTimeout
+    from playwright.sync_api import (
+        Error as PlaywrightError,
+        TimeoutError as PlaywrightTimeout,
+    )
 except ImportError:
-    PlaywrightTimeout = Exception
+
+    class PlaywrightError(Exception):  # type: ignore[no-redef]
+        """Fallback Playwright error that excludes unrelated exceptions."""
+
+    class PlaywrightTimeout(PlaywrightError):  # type: ignore[no-redef]  # 
noqa: N818
+        """Fallback matching Playwright's timeout error hierarchy."""
+
 
 if TYPE_CHECKING:
     try:
@@ -441,6 +543,23 @@ CHART_CONTAINER_STATE_JS = f"""
 }}
 """
 
+CHART_CONTAINER_HAS_RENDERED_CONTENT_JS = f"""
+() => {{
+    const chart = document.querySelector('.chart-container');
+    return chart !== null
+        && chart.querySelector('{CHART_ERROR_OR_EMPTY_SELECTOR}') === null
+        && chart.querySelector('{SLICE_CONTAINER_SELECTOR}') !== null;
+}}
+"""
+
+REPORT_HAS_RENDERED_CHART_HOLDERS_JS = f"""
+() => Array.from(document.querySelectorAll('{CHART_HOLDER_SELECTOR}')).some(
+    holder => holder.querySelector(
+        '{CHART_ERROR_OR_EMPTY_SELECTOR}'
+    ) === null && holder.querySelector('{SLICE_CONTAINER_SELECTOR}') !== null
+)
+"""
+
 CHART_CONTAINER_SELECTOR = ".chart-container"
 
 # `.slice_container` (superset-frontend/src/components/Chart/Chart.tsx) is
@@ -636,14 +755,7 @@ def take_tiled_screenshot(  # noqa: C901
         log_context = report_execution_context.log_context
     context_suffix = f" [{log_context}]" if log_context else ""
     # Set right before re-raising the per-tile readiness timeout below, and
-    # checked in the except block at the bottom of this function. Deciding
-    # whether to propagate via `isinstance(e, PlaywrightTimeout)` would be
-    # unreliable: when the playwright package isn't installed,
-    # `PlaywrightTimeout` is aliased to the bare `Exception` class (see the
-    # try/except ImportError above this function), which would make *any*
-    # exception -- not just our own deliberate readiness-timeout raise --
-    # match `except PlaywrightTimeout` and incorrectly propagate instead of
-    # degrading to `None` like every other unexpected error in this function.
+    # checked in the except block at the bottom of this function.
     readiness_timeout = False
     if screenshot_started_at is None:
         screenshot_started_at = time.monotonic()
@@ -771,6 +883,7 @@ def take_tiled_screenshot(  # noqa: C901
 
         screenshot_tiles: list[bytes] = []
         blank_tile_retries = 0
+        contentful_tiles_captured = 0
 
         def _raise_if_budget_exhausted() -> None:
             elapsed, remaining = _deadline_values()
@@ -1082,20 +1195,30 @@ def take_tiled_screenshot(  # noqa: C901
                         ) from ex
                 else:
                     capture_elapsed = time.monotonic() - capture_started_at
-                    is_uniform, dominant_ratio = 
is_screenshot_nearly_uniform(candidate)
-                    is_blank = is_uniform and (
+                    blankness = get_screenshot_blankness_metrics(candidate)
+                    is_blank = blankness.is_blank and (
                         contentful_chart_holders > 0 or holder_count_failed
                     )
-                    logger.debug(
-                        "Captured tile %s/%s attempt %s/%s in %.2fs "
-                        "(contentful_chart_holders=%s 
dominant_pixel_ratio=%.5f)%s",
+                    logger.info(
+                        "report_capture_validation capture=tile tile=%s/%s "
+                        "attempt=%s/%s capture_elapsed_seconds=%.2f "
+                        "contentful_chart_holders=%s is_blank=%s "
+                        "dominant_pixel_ratio=%.5f near_white_pixel_ratio=%.5f 
"
+                        "mean_luminance=%.2f luminance_stddev=%.2f 
entropy=%.3f "
+                        "structural_edge_ratio=%.5f%s",
                         i + 1,
                         num_tiles,
                         capture_attempt,
                         TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
                         capture_elapsed,
                         contentful_chart_holders,
-                        dominant_ratio,
+                        is_blank,
+                        blankness.dominant_pixel_ratio,
+                        blankness.near_white_pixel_ratio,
+                        blankness.mean_luminance,
+                        blankness.luminance_stddev,
+                        blankness.entropy,
+                        blankness.structural_edge_ratio,
                         context_suffix,
                     )
                     if not is_blank:
@@ -1105,32 +1228,63 @@ def take_tiled_screenshot(  # noqa: C901
                     logger.warning(
                         "report_capture_blank_tile tile=%s/%s attempt=%s/%s "
                         "capture_elapsed_seconds=%.2f 
contentful_chart_holders=%s "
-                        "dominant_pixel_ratio=%.5f%s",
+                        "dominant_pixel_ratio=%.5f near_white_pixel_ratio=%.5f 
"
+                        "mean_luminance=%.2f luminance_stddev=%.2f 
entropy=%.3f "
+                        "structural_edge_ratio=%.5f%s",
                         i + 1,
                         num_tiles,
                         capture_attempt,
                         TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
                         capture_elapsed,
                         contentful_chart_holders,
-                        dominant_ratio,
+                        blankness.dominant_pixel_ratio,
+                        blankness.near_white_pixel_ratio,
+                        blankness.mean_luminance,
+                        blankness.luminance_stddev,
+                        blankness.entropy,
+                        blankness.structural_edge_ratio,
                         context_suffix,
                     )
                     if capture_attempt == 
TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS:
+                        if report_execution_context:
+                            # Exhausted retries must never turn a rejected 
report
+                            # tile into accepted content. Only thumbnails may
+                            # retain a blank capture below.
+                            raise ScreenshotBlankCaptureError(
+                                "Chromium returned a blank tile "
+                                f"{i + 1}/{num_tiles} after {capture_attempt} 
attempts"
+                            )
                         tile_screenshot = candidate
                         logger.warning(
-                            "report_capture_uniform_tile_retained tile=%s/%s "
+                            "report_capture_blank_tile_retained tile=%s/%s "
                             "attempts=%s contentful_chart_holders=%s "
-                            "dominant_pixel_ratio=%.5f%s",
+                            "dominant_pixel_ratio=%.5f 
near_white_pixel_ratio=%.5f "
+                            "mean_luminance=%.2f luminance_stddev=%.2f 
entropy=%.3f "
+                            "structural_edge_ratio=%.5f%s",
                             i + 1,
                             num_tiles,
                             capture_attempt,
                             contentful_chart_holders,
-                            dominant_ratio,
+                            blankness.dominant_pixel_ratio,
+                            blankness.near_white_pixel_ratio,
+                            blankness.mean_luminance,
+                            blankness.luminance_stddev,
+                            blankness.entropy,
+                            blankness.structural_edge_ratio,
                             context_suffix,
                         )
                         break
 
                 _raise_if_budget_exhausted()
+                repaint_timeout = _timeout_seconds(
+                    "screenshot_repaint",
+                    requested_seconds=5.0,
+                    reserve_seconds=(
+                        report_execution_context.post_capture_reserve_seconds
+                        if report_execution_context
+                        else 0.0
+                    ),
+                )
                 try:
                     page.bring_to_front()
                     page.evaluate(
@@ -1143,20 +1297,11 @@ def take_tiled_screenshot(  # noqa: C901
                             }));
                         }"""
                     )
-                    repaint_timeout = _timeout_seconds(
-                        "screenshot_repaint",
-                        requested_seconds=5.0,
-                        reserve_seconds=(
-                            
report_execution_context.post_capture_reserve_seconds
-                            if report_execution_context
-                            else 0.0
-                        ),
-                    )
                     page.wait_for_function(
                         "() => window.__supersetRepaintComplete === true",
                         timeout=repaint_timeout * 1000,
                     )
-                except Exception:  # noqa: BLE001
+                except PlaywrightError:
                     logger.warning(
                         "report_capture_repaint_timeout tile=%s/%s 
attempt=%s/%s%s",
                         i + 1,
@@ -1169,6 +1314,8 @@ def take_tiled_screenshot(  # noqa: C901
 
             assert tile_screenshot is not None
             screenshot_tiles.append(tile_screenshot)
+            if contentful_chart_holders > 0:
+                contentful_tiles_captured += 1
 
             logger.debug(
                 "Captured tile %s/%s with clip %s%s",
@@ -1224,6 +1371,31 @@ def take_tiled_screenshot(  # noqa: C901
             log_context=log_context,
         )
 
+        if report_execution_context and contentful_tiles_captured:
+            combined_blankness = 
get_screenshot_blankness_metrics(combined_screenshot)
+            logger.info(
+                "report_capture_validation capture=combined "
+                "contentful_tiles=%s is_blank=%s "
+                "dominant_pixel_ratio=%.5f near_white_pixel_ratio=%.5f "
+                "mean_luminance=%.2f luminance_stddev=%.2f entropy=%.3f "
+                "structural_edge_ratio=%.5f%s",
+                contentful_tiles_captured,
+                combined_blankness.is_blank,
+                combined_blankness.dominant_pixel_ratio,
+                combined_blankness.near_white_pixel_ratio,
+                combined_blankness.mean_luminance,
+                combined_blankness.luminance_stddev,
+                combined_blankness.entropy,
+                combined_blankness.structural_edge_ratio,
+                context_suffix,
+            )
+            if combined_blankness.is_blank:
+                logger.warning(
+                    "report_capture_blank_combined_retained 
contentful_tiles=%s%s",
+                    contentful_tiles_captured,
+                    context_suffix,
+                )
+
         return combined_screenshot
 
     except (ReportExecutionBudgetExceededError, 
TiledScreenshotBudgetExceededError):
@@ -1242,13 +1414,15 @@ def take_tiled_screenshot(  # noqa: C901
             context_suffix,
         )
         raise
-    except ScreenshotCaptureTimeoutError:
-        # Preserve the explicit capture-timeout reason for report execution
+    except (ScreenshotBlankCaptureError, ScreenshotCaptureTimeoutError):
+        # Preserve the explicit blank-capture or timeout reason for report 
execution
         # history instead of degrading it to an anonymous None screenshot.
         logger.exception("Tiled screenshot capture rejected%s", context_suffix)
         if report_execution_context:
             raise
         return None
+    except SoftTimeLimitExceeded:
+        raise
     except Exception as e:
         if readiness_timeout:
             # Let the per-tile readiness timeout propagate so the caller
diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py
index 337ab698258..9a5f32e75a8 100644
--- a/superset/utils/webdriver.py
+++ b/superset/utils/webdriver.py
@@ -31,6 +31,7 @@ from superset.utils.report_execution import (
     ReportExecutionContext,
 )
 from superset.utils.screenshot_utils import (
+    CHART_CONTAINER_HAS_RENDERED_CONTENT_JS,
     CHART_CONTAINER_READY_JS,
     CHART_CONTAINER_STATE_JS,
     CHART_HOLDERS_READY_JS,
@@ -39,10 +40,14 @@ from superset.utils.screenshot_utils import (
     FIND_ALL_UNREADY_CHART_HOLDERS_JS,
     FIND_CHART_HOLDER_STATES_JS,
     FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS,
+    get_screenshot_blankness_metrics,
     REPORT_ALL_CHART_HOLDERS_READY_JS,
+    REPORT_HAS_RENDERED_CHART_HOLDERS_JS,
     resolve_screenshot_task_budget_seconds,
+    ScreenshotBlankCaptureError,
     ScreenshotTaskBudgetExceededError,
     take_tiled_screenshot,
+    TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
 )
 
 WindowSize = tuple[int, int]
@@ -75,8 +80,13 @@ except ImportError:
 
     # Define dummy classes when playwright is not available
     BrowserContext = Any
-    PlaywrightError = Exception
-    PlaywrightTimeout = Exception
+
+    class PlaywrightError(Exception):  # type: ignore[no-redef]
+        """Fallback Playwright error that does not swallow unrelated 
failures."""
+
+    class PlaywrightTimeout(PlaywrightError):  # type: ignore[no-redef]  # 
noqa: N818
+        """Fallback matching Playwright's timeout error hierarchy."""
+
     Locator = Any
     Page = Any
     sync_playwright = None
@@ -240,6 +250,131 @@ class WebDriverPlaywright(WebDriverProxy):
         else:
             return element.screenshot(**timeout_kwargs)
 
+    @staticmethod
+    def _get_validated_screenshot(
+        page: Page,
+        element: Locator,
+        element_name: str,
+        log_context: str | None,
+        report_execution_context: ReportExecutionContext | None,
+    ) -> bytes:
+        """Capture a standard screenshot and reject blank report output."""
+
+        context_suffix = f" [{log_context}]" if log_context else ""
+        for attempt in range(1, TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS + 1):
+            capture_timeout = (
+                report_execution_context.deadline.timeout_seconds(
+                    "screenshot_capture",
+                    reserve_seconds=(
+                        report_execution_context.post_capture_reserve_seconds
+                    ),
+                )
+                if report_execution_context
+                else None
+            )
+            capture_started_at = time.monotonic()
+            image = WebDriverPlaywright._get_screenshot(
+                page,
+                element,
+                element_name,
+                timeout_seconds=capture_timeout,
+            )
+            capture_elapsed = time.monotonic() - capture_started_at
+            if report_execution_context is None:
+                return image
+
+            blankness = get_screenshot_blankness_metrics(image)
+            try:
+                has_rendered_content = bool(
+                    page.evaluate(
+                        CHART_CONTAINER_HAS_RENDERED_CONTENT_JS
+                        if element_name == "chart-container"
+                        else REPORT_HAS_RENDERED_CHART_HOLDERS_JS
+                    )
+                )
+            except PlaywrightError:
+                has_rendered_content = False
+                logger.warning(
+                    "report_capture_content_state_failed capture=standard%s",
+                    context_suffix,
+                    exc_info=True,
+                )
+            is_blank = has_rendered_content and blankness.is_blank
+            logger.info(
+                "report_capture_validation capture=standard attempt=%s/%s "
+                "capture_elapsed_seconds=%.2f has_rendered_content=%s 
is_blank=%s "
+                "dominant_pixel_ratio=%.5f near_white_pixel_ratio=%.5f "
+                "mean_luminance=%.2f luminance_stddev=%.2f entropy=%.3f "
+                "structural_edge_ratio=%.5f%s",
+                attempt,
+                TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
+                capture_elapsed,
+                has_rendered_content,
+                is_blank,
+                blankness.dominant_pixel_ratio,
+                blankness.near_white_pixel_ratio,
+                blankness.mean_luminance,
+                blankness.luminance_stddev,
+                blankness.entropy,
+                blankness.structural_edge_ratio,
+                context_suffix,
+            )
+            if not is_blank:
+                return image
+
+            logger.warning(
+                "report_capture_blank_standard attempt=%s/%s "
+                "capture_elapsed_seconds=%.2f dominant_pixel_ratio=%.5f "
+                "near_white_pixel_ratio=%.5f mean_luminance=%.2f "
+                "luminance_stddev=%.2f entropy=%.3f 
structural_edge_ratio=%.5f%s",
+                attempt,
+                TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
+                capture_elapsed,
+                blankness.dominant_pixel_ratio,
+                blankness.near_white_pixel_ratio,
+                blankness.mean_luminance,
+                blankness.luminance_stddev,
+                blankness.entropy,
+                blankness.structural_edge_ratio,
+                context_suffix,
+            )
+            if attempt == TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS:
+                raise ScreenshotBlankCaptureError(
+                    "Chromium returned a blank standard screenshot "
+                    f"after {attempt} attempts"
+                )
+            repaint_timeout = 
report_execution_context.deadline.timeout_seconds(
+                "screenshot_repaint",
+                requested_seconds=5.0,
+                
reserve_seconds=report_execution_context.post_capture_reserve_seconds,
+            )
+            try:
+                page.bring_to_front()
+                page.evaluate(
+                    """() => {
+                        window.scrollBy(0, 1);
+                        window.scrollBy(0, -1);
+                        window.__supersetRepaintComplete = false;
+                        requestAnimationFrame(() => requestAnimationFrame(() 
=> {
+                            window.__supersetRepaintComplete = true;
+                        }));
+                    }"""
+                )
+                page.wait_for_function(
+                    "() => window.__supersetRepaintComplete === true",
+                    timeout=repaint_timeout * 1000,
+                )
+            except PlaywrightError:
+                logger.warning(
+                    "report_capture_repaint_timeout capture=standard 
attempt=%s/%s%s",
+                    attempt,
+                    TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
+                    context_suffix,
+                    exc_info=True,
+                )
+
+        raise AssertionError("standard screenshot retry loop did not return or 
raise")
+
     @staticmethod
     def _expand_scrollable_content(
         page: Page,
@@ -948,21 +1083,12 @@ class WebDriverPlaywright(WebDriverProxy):
                             user.username if user else "None",
                             context_suffix,
                         )
-                        capture_timeout = (
-                            report_execution_context.deadline.timeout_seconds(
-                                "screenshot_capture",
-                                reserve_seconds=(
-                                    
report_execution_context.post_capture_reserve_seconds
-                                ),
-                            )
-                            if report_execution_context
-                            else None
-                        )
-                        img = WebDriverPlaywright._get_screenshot(
+                        img = WebDriverPlaywright._get_validated_screenshot(
                             page,
                             element,
                             element_name,
-                            timeout_seconds=capture_timeout,
+                            log_context,
+                            report_execution_context,
                         )
                         logger.debug(
                             "Screenshot result: %d bytes for url: %s%s",
@@ -1020,21 +1146,12 @@ class WebDriverPlaywright(WebDriverProxy):
                         user.username if user else "None",
                         context_suffix,
                     )
-                    capture_timeout = (
-                        report_execution_context.deadline.timeout_seconds(
-                            "screenshot_capture",
-                            reserve_seconds=(
-                                
report_execution_context.post_capture_reserve_seconds
-                            ),
-                        )
-                        if report_execution_context
-                        else None
-                    )
-                    img = WebDriverPlaywright._get_screenshot(
+                    img = WebDriverPlaywright._get_validated_screenshot(
                         page,
                         element,
                         element_name,
-                        timeout_seconds=capture_timeout,
+                        log_context,
+                        report_execution_context,
                     )
                     logger.debug(
                         "Screenshot result: %d bytes for url: %s%s",
diff --git a/tests/unit_tests/commands/report/execute_test.py 
b/tests/unit_tests/commands/report/execute_test.py
index 5bf2da33dd8..15d14decec1 100644
--- a/tests/unit_tests/commands/report/execute_test.py
+++ b/tests/unit_tests/commands/report/execute_test.py
@@ -1774,6 +1774,43 @@ def 
test_screenshot_soft_timeout_distinguishes_reports_from_alert_attachments(
         state._get_screenshots()
 
 
[email protected]("has_chart", [True, False])
+def test_blank_capture_prevents_pdf_generation_and_delivery(
+    app: SupersetApp, mocker: MockerFixture, has_chart: bool
+) -> None:
+    """A rejected capture must abort the PDF before any recipient delivery."""
+    from superset.utils.screenshot_utils import ScreenshotBlankCaptureError
+
+    state = _make_notification_state(
+        mocker, report_format=ReportDataFormat.PDF, has_chart=has_chart
+    )
+    state._report_schedule.custom_width = None
+    state._report_schedule.custom_height = None
+    mocker.patch(
+        "superset.commands.report.execute.resolve_executor_user",
+        return_value=(mocker.Mock(), "executor"),
+    )
+    mocker.patch.object(state, "get_dashboard_urls", 
return_value=["/dashboard/1"])
+    screenshot_class = "ChartScreenshot" if has_chart else 
"DashboardScreenshot"
+    screenshot = mocker.patch(
+        f"superset.commands.report.execute.{screenshot_class}"
+    ).return_value
+    capture_error = ScreenshotBlankCaptureError("blank capture after 3 
attempts")
+    screenshot.get_screenshot.side_effect = capture_error
+    build_pdf = mocker.patch(
+        "superset.commands.report.execute.build_pdf_from_screenshots"
+    )
+    deliver = mocker.patch.object(state, "_send")
+
+    with pytest.raises(ReportScheduleScreenshotFailedError) as exc:
+        state.send()
+
+    assert exc.value.__cause__ is capture_error
+    screenshot.get_screenshot.assert_called_once()
+    build_pdf.assert_not_called()
+    deliver.assert_not_called()
+
+
 def test_executor_not_found_error_message_without_username() -> None:
     """
     When no username is available, the message falls back to ``(unknown)``
diff --git a/tests/unit_tests/utils/test_screenshot_utils.py 
b/tests/unit_tests/utils/test_screenshot_utils.py
index f14f9dc6110..34beab9ac58 100644
--- a/tests/unit_tests/utils/test_screenshot_utils.py
+++ b/tests/unit_tests/utils/test_screenshot_utils.py
@@ -20,7 +20,7 @@ from unittest.mock import MagicMock, patch
 from uuid import UUID
 
 import pytest
-from PIL import Image, UnidentifiedImageError
+from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
 
 from superset.utils.report_execution import (
     ReportExecutionContext,
@@ -29,9 +29,11 @@ from superset.utils.report_execution import (
 from superset.utils.screenshot_utils import (
     combine_screenshot_tiles,
     CONTENTFUL_CHART_HOLDERS_IN_CLIP_JS,
+    get_screenshot_blankness_metrics,
     is_screenshot_nearly_uniform,
     resolve_screenshot_task_budget_seconds,
     SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS,
+    ScreenshotBlankCaptureError,
     ScreenshotCaptureTimeoutError,
     ScreenshotTaskBudgetExceededError,
     SCROLL_SETTLE_TIMEOUT_MS,
@@ -48,6 +50,16 @@ def _png(width: int, height: int, color: str) -> bytes:
     return output.getvalue()
 
 
+def _two_tone_blank(width: int = 100, height: int = 100) -> bytes:
+    image = Image.new("RGB", (width, height), "white")
+    for y in range(int(height * 0.85), height):
+        for x in range(width):
+            image.putpixel((x, y), (245, 245, 245))
+    output = io.BytesIO()
+    image.save(output, format="PNG")
+    return output.getvalue()
+
+
 class TestScreenshotBlankDetection:
     def test_uniform_png_is_blank(self):
         is_blank, dominant_ratio = is_screenshot_nearly_uniform(_png(100, 100, 
"white"))
@@ -68,6 +80,116 @@ class TestScreenshotBlankDetection:
         assert is_blank is False
         assert dominant_ratio < 0.995
 
+    def test_two_tone_near_white_png_is_blank(self):
+        is_blank, dominant_ratio = 
is_screenshot_nearly_uniform(_two_tone_blank())
+
+        assert is_blank is True
+        assert dominant_ratio == 0.85
+
+    def test_two_tone_gray_below_near_white_cutoff_is_blank(self):
+        image = Image.new("RGB", (100, 100), "white")
+        for y in range(85, 100):
+            for x in range(100):
+                image.putpixel((x, y), (239, 239, 239))
+        output = io.BytesIO()
+        image.save(output, format="PNG")
+
+        is_blank, dominant_ratio = 
is_screenshot_nearly_uniform(output.getvalue())
+
+        assert is_blank is True
+        assert dominant_ratio == 0.85
+
+    def test_sparse_readable_text_is_not_blank(self):
+        image = Image.new("RGB", (800, 1000), "white")
+        label = Image.new("RGB", (60, 14), "white")
+        ImageDraw.Draw(label).text(
+            (0, 0), "No data", fill="black", font=ImageFont.load_default()
+        )
+        label = label.resize((240, 56), Image.Resampling.NEAREST)
+        image.paste(label, (20, 20))
+        output = io.BytesIO()
+        image.save(output, format="PNG")
+
+        is_blank, _dominant_ratio = 
is_screenshot_nearly_uniform(output.getvalue())
+
+        assert is_blank is False
+
+    def test_tall_sparse_report_with_content_is_not_blank(self):
+        image = Image.new("RGB", (800, 4000), "white")
+        draw = ImageDraw.Draw(image)
+        draw.rectangle((20, 20, 780, 220), outline="black", width=3)
+        for y in range(60, 220, 40):
+            draw.line((20, y, 780, y), fill="black", width=2)
+        output = io.BytesIO()
+        image.save(output, format="PNG")
+
+        is_blank, _dominant_ratio = 
is_screenshot_nearly_uniform(output.getvalue())
+
+        assert is_blank is False
+
+    def test_high_density_light_thin_line_chart_is_not_blank(self):
+        image = Image.new("RGB", (3000, 1200), "white")
+        draw = ImageDraw.Draw(image)
+        for y in (200, 500, 800, 1050):
+            draw.line((180, y, 2850, y), fill=(225, 225, 225), width=2)
+        draw.line((180, 100, 180, 1050), fill=(170, 170, 170), width=3)
+        draw.line((180, 1050, 2850, 1050), fill=(170, 170, 170), width=3)
+        points = [
+            (250, 900),
+            (700, 720),
+            (1200, 790),
+            (1750, 480),
+            (2250, 560),
+            (2800, 250),
+        ]
+        draw.line(points, fill=(145, 180, 210), width=3)
+        for x, y in points:
+            draw.ellipse((x - 5, y - 5, x + 5, y + 5), fill=(145, 180, 210))
+        output = io.BytesIO()
+        image.save(output, format="PNG")
+
+        metrics = get_screenshot_blankness_metrics(output.getvalue())
+
+        assert metrics.is_blank is False
+        assert metrics.structural_edge_ratio >= 0.02
+
+    def test_high_density_sparse_light_table_is_not_blank(self):
+        image = Image.new("RGB", (3000, 1200), "white")
+        draw = ImageDraw.Draw(image)
+        columns = (150, 900, 1700, 2850)
+        rows = (150, 350, 550, 750)
+        for y in rows:
+            draw.line((columns[0], y, columns[-1], y), fill=(210, 210, 210), 
width=2)
+        for x in columns:
+            draw.line((x, rows[0], x, rows[-1]), fill=(210, 210, 210), width=2)
+        for row in range(3):
+            for column in range(3):
+                x = columns[column] + 35
+                y = rows[row] + 55
+                draw.rectangle(
+                    (x, y, x + 180 + column * 80, y + 9), fill=(155, 155, 155)
+                )
+        output = io.BytesIO()
+        image.save(output, format="PNG")
+
+        metrics = get_screenshot_blankness_metrics(output.getvalue())
+
+        assert metrics.is_blank is False
+        assert metrics.structural_edge_ratio >= 0.02
+
+    def test_sparse_png_with_dark_content_is_not_blank(self):
+        image = Image.new("RGB", (100, 100), "white")
+        for x in range(20, 80):
+            for y in range(20, 24):
+                image.putpixel((x, y), (50, 50, 50))
+        output = io.BytesIO()
+        image.save(output, format="PNG")
+
+        is_blank, dominant_ratio = 
is_screenshot_nearly_uniform(output.getvalue())
+
+        assert is_blank is False
+        assert dominant_ratio == 0.976
+
     def test_non_image_bytes_are_left_for_combination_validation(self):
         assert is_screenshot_nearly_uniform(b"not an image") == (False, 0.0)
 
@@ -320,7 +442,7 @@ class TestTakeTiledScreenshot:
         )
         assert retry_log.args[1] == 1
 
-    def test_repeated_uniform_tiles_are_retained_with_warning(self, mock_page):
+    def test_repeated_blank_tiles_fail_closed_for_reports(self, mock_page):
         element_info = {"height": 2000, "top": 0, "left": 0, "width": 800}
 
         def evaluate(script, _arg=None):
@@ -336,11 +458,48 @@ class TestTakeTiledScreenshot:
         mock_page.screenshot.return_value = _png(800, 1000, "white")
 
         with (
-            patch("superset.utils.screenshot_utils.logger") as mock_logger,
+            patch("superset.utils.screenshot_utils.logger"),
             patch(
-                "superset.utils.screenshot_utils.combine_screenshot_tiles",
-                return_value=b"combined",
+                "superset.utils.screenshot_utils.combine_screenshot_tiles"
             ) as mock_combine,
+            pytest.raises(
+                ScreenshotBlankCaptureError,
+                match="blank tile 1/2 after 3 attempts",
+            ),
+        ):
+            take_tiled_screenshot(
+                mock_page,
+                "dashboard",
+                tile_height=1000,
+                report_execution_context=_report_context(),
+            )
+
+        assert mock_page.screenshot.call_count == 3
+        mock_combine.assert_not_called()
+
+    def test_blank_combined_image_is_advisory_after_contentful_tiles_pass(
+        self, mock_page
+    ):
+        element_info = {"height": 1000, "top": 0, "left": 0, "width": 800}
+
+        def evaluate(script, _arg=None):
+            if "scrollWidth" in script:
+                return element_info
+            if script == CONTENTFUL_CHART_HOLDERS_IN_CLIP_JS:
+                return {"total": 1, "contentful": 1}
+            if "requestAnimationFrame" in script or "window.scrollTo" in 
script:
+                return None
+            return [{"chartId": "7", "state": "rendered"}]
+
+        mock_page.evaluate.side_effect = evaluate
+        mock_page.screenshot.return_value = self._create_chart_like_tile()
+
+        with (
+            patch(
+                "superset.utils.screenshot_utils.combine_screenshot_tiles",
+                return_value=_two_tone_blank(800, 1000),
+            ),
+            patch("superset.utils.screenshot_utils.logger") as mock_logger,
         ):
             result = take_tiled_screenshot(
                 mock_page,
@@ -349,17 +508,44 @@ class TestTakeTiledScreenshot:
                 report_execution_context=_report_context(),
             )
 
-        assert result == b"combined"
-        assert mock_page.screenshot.call_count == 6
-        assert len(mock_combine.call_args.args[0]) == 2
-        retained_warnings = [
-            call
+        assert result == _two_tone_blank(800, 1000)
+        assert any(
+            call.args[0].startswith("report_capture_blank_combined_retained")
+            and call.args[1] == 1
             for call in mock_logger.warning.call_args_list
-            if call.args[0].startswith("report_capture_uniform_tile_retained")
-        ]
-        assert len(retained_warnings) == 2
+        )
+
+    def test_blank_combined_image_is_allowed_for_terminal_empty_states(self, 
mock_page):
+        element_info = {"height": 1000, "top": 0, "left": 0, "width": 800}
+        blank = _two_tone_blank(800, 1000)
+
+        def evaluate(script, _arg=None):
+            if "scrollWidth" in script:
+                return element_info
+            if script == CONTENTFUL_CHART_HOLDERS_IN_CLIP_JS:
+                return {"total": 1, "contentful": 0}
+            if "window.scrollTo" in script:
+                return None
+            return [{"chartId": "7", "state": "empty"}]
+
+        mock_page.evaluate.side_effect = evaluate
+        mock_page.screenshot.return_value = blank
+
+        with patch(
+            "superset.utils.screenshot_utils.combine_screenshot_tiles",
+            return_value=blank,
+        ):
+            result = take_tiled_screenshot(
+                mock_page,
+                "dashboard",
+                tile_height=1000,
+                report_execution_context=_report_context(),
+            )
+
+        assert result == blank
+        assert mock_page.screenshot.call_count == 1
 
-    def test_single_uniform_content_tile_is_retained(self, mock_page):
+    def test_single_uniform_content_tile_fails_closed_for_reports(self, 
mock_page):
         element_info = {"height": 1000, "top": 0, "left": 0, "width": 800}
         uniform_tile = _png(800, 1000, "navy")
 
@@ -375,24 +561,21 @@ class TestTakeTiledScreenshot:
         mock_page.evaluate.side_effect = evaluate
         mock_page.screenshot.return_value = uniform_tile
 
-        with patch(
-            "superset.utils.screenshot_utils.combine_screenshot_tiles",
-            return_value=b"combined",
-        ) as mock_combine:
-            result = take_tiled_screenshot(
+        with (
+            patch(
+                "superset.utils.screenshot_utils.combine_screenshot_tiles"
+            ) as mock_combine,
+            pytest.raises(ScreenshotBlankCaptureError),
+        ):
+            take_tiled_screenshot(
                 mock_page,
                 "dashboard",
                 tile_height=2000,
                 report_execution_context=_report_context(),
             )
 
-        assert result == b"combined"
         assert mock_page.screenshot.call_count == 3
-        mock_combine.assert_called_once_with(
-            [uniform_tile],
-            allow_partial_fallback=False,
-            log_context=_report_context().log_context,
-        )
+        mock_combine.assert_not_called()
 
     def test_uniform_tile_without_visible_charts_is_allowed(self, mock_page):
         element_info = {"height": 1000, "top": 0, "left": 0, "width": 800}
@@ -536,7 +719,7 @@ class TestTakeTiledScreenshot:
             for call in mock_logger.warning.call_args_list
         )
         assert any(
-            call.args[0].startswith("report_capture_uniform_tile_retained")
+            call.args[0].startswith("report_capture_blank_tile_retained")
             for call in mock_logger.warning.call_args_list
         )
 
@@ -586,6 +769,32 @@ class TestTakeTiledScreenshot:
         )
         assert mock_page.screenshot.call_count == 2
 
+    def test_repaint_preserves_celery_soft_timeout(self, mock_page):
+        from celery.exceptions import SoftTimeLimitExceeded
+
+        element_info = {"height": 1000, "top": 0, "left": 0, "width": 800}
+
+        def evaluate(script, _arg=None):
+            if "scrollWidth" in script:
+                return element_info
+            if script == CONTENTFUL_CHART_HOLDERS_IN_CLIP_JS:
+                return {"total": 1, "contentful": 1}
+            if "requestAnimationFrame" in script or "window.scrollTo" in 
script:
+                return None
+            return [{"chartId": "7", "state": "rendered"}]
+
+        mock_page.evaluate.side_effect = evaluate
+        mock_page.screenshot.return_value = _png(800, 1000, "white")
+        mock_page.wait_for_function.side_effect = [None, None, 
SoftTimeLimitExceeded()]
+
+        with pytest.raises(SoftTimeLimitExceeded):
+            take_tiled_screenshot(
+                mock_page,
+                "dashboard",
+                tile_height=2000,
+                report_execution_context=_report_context(),
+            )
+
     def test_repeated_capture_timeout_preserves_thumbnail_contract(self, 
mock_page):
         from superset.utils.screenshot_utils import PlaywrightTimeout
 
diff --git a/tests/unit_tests/utils/webdriver_test.py 
b/tests/unit_tests/utils/webdriver_test.py
index a1759a12ec4..e031f847d58 100644
--- a/tests/unit_tests/utils/webdriver_test.py
+++ b/tests/unit_tests/utils/webdriver_test.py
@@ -15,15 +15,20 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import io
+from dataclasses import replace
 from unittest.mock import ANY, MagicMock, patch
 from uuid import UUID
 
 import pytest
+from celery.exceptions import SoftTimeLimitExceeded
+from PIL import Image, ImageDraw
 
 from superset.utils.report_execution import (
     ReportExecutionContext,
     ReportExecutionDeadline,
 )
+from superset.utils.screenshot_utils import ScreenshotBlankCaptureError
 from superset.utils.webdriver import (
     check_playwright_availability,
     PLAYWRIGHT_AVAILABLE,
@@ -57,6 +62,13 @@ def _report_context(
     )
 
 
+def _png(color: str) -> bytes:
+    image = Image.new("RGB", (100, 100), color)
+    output = io.BytesIO()
+    image.save(output, format="PNG")
+    return output.getvalue()
+
+
 @pytest.fixture()
 def mock_app():
     """Mock Flask app with webdriver configuration."""
@@ -92,6 +104,131 @@ class TestPlaywrightAvailabilityCheck:
         mock_sync_playwright.assert_not_called()
 
 
+class TestStandardScreenshotValidation:
+    def test_blank_capture_retries_then_accepts_content(self):
+        page = MagicMock()
+        element = MagicMock()
+        valid = Image.new("RGB", (100, 100), "white")
+        for x in range(20, 80):
+            for y in range(20, 80):
+                valid.putpixel((x, y), (50, 50, 50))
+        output = io.BytesIO()
+        valid.save(output, format="PNG")
+        page.screenshot.side_effect = [_png("white"), output.getvalue()]
+
+        result = WebDriverPlaywright._get_validated_screenshot(
+            page,
+            element,
+            "standalone",
+            "execution_id=test",
+            _report_context(),
+        )
+
+        assert result == output.getvalue()
+        assert page.screenshot.call_count == 2
+        page.bring_to_front.assert_called_once_with()
+        page.wait_for_function.assert_called_once_with(
+            "() => window.__supersetRepaintComplete === true",
+            timeout=5000,
+        )
+
+    def test_repeated_blank_capture_fails_closed(self):
+        page = MagicMock()
+        element = MagicMock()
+        page.screenshot.return_value = _png("white")
+
+        with pytest.raises(
+            ScreenshotBlankCaptureError,
+            match="blank standard screenshot after 3 attempts",
+        ):
+            WebDriverPlaywright._get_validated_screenshot(
+                page,
+                element,
+                "standalone",
+                "execution_id=test",
+                _report_context(),
+            )
+
+        assert page.screenshot.call_count == 3
+
+    def test_blank_empty_state_capture_is_allowed(self):
+        page = MagicMock()
+        element = MagicMock()
+        page.screenshot.return_value = _png("white")
+        page.evaluate.return_value = False
+
+        result = WebDriverPlaywright._get_validated_screenshot(
+            page,
+            element,
+            "standalone",
+            "execution_id=test",
+            _report_context(),
+        )
+
+        assert result == _png("white")
+        page.screenshot.assert_called_once()
+        page.bring_to_front.assert_not_called()
+
+    def test_repaint_timeout_is_bounded_and_retry_continues(self):
+        from superset.utils.webdriver import PlaywrightTimeout
+
+        page = MagicMock()
+        element = MagicMock()
+        valid = Image.new("RGB", (100, 100), "white")
+        ImageDraw.Draw(valid).rectangle((20, 20, 80, 80), fill="black")
+        output = io.BytesIO()
+        valid.save(output, format="PNG")
+        page.screenshot.side_effect = [_png("white"), output.getvalue()]
+        page.evaluate.return_value = True
+        page.wait_for_function.side_effect = PlaywrightTimeout("stalled 
repaint")
+
+        result = WebDriverPlaywright._get_validated_screenshot(
+            page,
+            element,
+            "standalone",
+            "execution_id=test",
+            _report_context(),
+        )
+
+        assert result == output.getvalue()
+        page.wait_for_function.assert_called_once_with(
+            "() => window.__supersetRepaintComplete === true",
+            timeout=5000,
+        )
+
+    def test_repaint_preserves_celery_soft_timeout(self):
+        page = MagicMock()
+        element = MagicMock()
+        page.screenshot.return_value = _png("white")
+        page.evaluate.return_value = True
+        page.wait_for_function.side_effect = SoftTimeLimitExceeded()
+
+        with pytest.raises(SoftTimeLimitExceeded):
+            WebDriverPlaywright._get_validated_screenshot(
+                page,
+                element,
+                "standalone",
+                "execution_id=test",
+                _report_context(),
+            )
+
+    def test_non_report_capture_preserves_existing_behavior(self):
+        page = MagicMock()
+        element = MagicMock()
+        page.screenshot.return_value = _png("white")
+
+        result = WebDriverPlaywright._get_validated_screenshot(
+            page,
+            element,
+            "standalone",
+            None,
+            None,
+        )
+
+        assert result == _png("white")
+        page.screenshot.assert_called_once_with(full_page=True)
+
+
 class TestWebDriverPlaywrightFallback:
     """Test WebDriverPlaywright fallback behavior when unavailable."""
 
@@ -380,18 +517,17 @@ class TestWebDriverPlaywrightErrorHandling:
         mock_page = MagicMock()
         report_execution_context = _report_context()
         # Only 2s left for this phase after other phases' reserves.
-        report_execution_context = report_execution_context.__class__(
-            **{
-                **report_execution_context.__dict__,
-                "deadline": report_execution_context.deadline.__class__(
-                    total_seconds=2
-                    + report_execution_context.capture_reserve_seconds
-                    + report_execution_context.delivery_reserve_seconds
-                    + report_execution_context.cleanup_reserve_seconds,
-                    started_at=0,
-                    _clock=lambda: 0,
-                ),
-            }
+        report_execution_context = replace(
+            report_execution_context,
+            deadline=replace(
+                report_execution_context.deadline,
+                total_seconds=2
+                + report_execution_context.capture_reserve_seconds
+                + report_execution_context.delivery_reserve_seconds
+                + report_execution_context.cleanup_reserve_seconds,
+                started_at=0,
+                _clock=lambda: 0,
+            ),
         )
 
         WebDriverPlaywright._expand_scrollable_content(
@@ -594,9 +730,7 @@ class TestWebDriverPlaywrightErrorHandling:
                         "http://example.com";, "test-element", mock_user
                     )
 
-        # The exact injected instance must propagate — guards against the
-        # fallback alias (PlaywrightTimeout = Exception when playwright is
-        # not installed) accepting unrelated exceptions.
+        # The exact injected instance must propagate.
         assert exc_info.value is timeout
         mock_logger.exception.assert_any_call(
             "Timed out requesting url %s%s", "http://example.com";, ""
@@ -641,7 +775,7 @@ class TestWebDriverPlaywrightErrorHandling:
         mock_chart_container.wait_for.return_value = None
         mock_page.wait_for_timeout.return_value = None
 
-        def evaluate_side_effect(script):
+        def evaluate_side_effect(script, *_args):
             if script == 
'document.querySelectorAll(".chart-container").length':
                 return 1
             if "const target = document.querySelector" in script:
@@ -733,7 +867,7 @@ class TestWebDriverPlaywrightErrorHandling:
         mock_page.wait_for_timeout.return_value = None
         mock_take_tiled.return_value = b"tiled_screenshot"
 
-        def evaluate_side_effect(script):
+        def evaluate_side_effect(script, *_args):
             if script == 
'document.querySelectorAll(".chart-container").length':
                 return 25  # chart_count >= threshold
             if "const target = document.querySelector" in script:
@@ -816,7 +950,7 @@ class TestWebDriverPlaywrightErrorHandling:
         mock_page.locator.side_effect = locator_side_effect
         mock_take_tiled.return_value = b"tiled_screenshot"
 
-        def evaluate_side_effect(script):
+        def evaluate_side_effect(script, *_args):
             if script == 
'document.querySelectorAll(".chart-container").length':
                 return 52  # mounted containers
             if "const target = document.querySelector" in script:
@@ -970,7 +1104,7 @@ class TestWebDriverPlaywrightErrorHandling:
         # it must never be reached by the failure path under test.
         mock_page.screenshot.return_value = b"fallback_screenshot"
 
-        def evaluate_side_effect(script):
+        def evaluate_side_effect(script, *_args):
             if "querySelectorAll" in script:
                 return 25  # chart_count >= threshold
             if "const target" in script:
@@ -1001,8 +1135,6 @@ class TestWebDriverPlaywrightErrorHandling:
                 mock_auth.return_value = mock_context
 
                 driver = WebDriverPlaywright("chrome")
-                # match= keeps this assertion meaningful even when playwright
-                # is not installed and PlaywrightTimeout aliases bare 
Exception.
                 with pytest.raises(
                     PlaywrightTimeout, match="Tiled screenshot failed for url"
                 ):
@@ -1299,6 +1431,8 @@ class TestWebDriverPlaywrightChartReadiness:
         self, mock_app, mock_logger, mock_browser_manager
     ):
         """Elapsed setup time is removed from the task-derived safe budget."""
+        from itertools import chain, repeat
+
         mock_user = MagicMock()
         mock_user.username = "test_user"
         mock_app.config = {
@@ -1315,7 +1449,7 @@ class TestWebDriverPlaywrightChartReadiness:
             ),
             patch(
                 "superset.utils.webdriver.time.monotonic",
-                side_effect=[100.0, 110.0],
+                side_effect=chain([100.0], repeat(110.0)),
             ),
         ):
             WebDriverPlaywright("chrome").get_screenshot(
@@ -1746,8 +1880,16 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
         mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
 
         # Small dashboard: 3 charts, 1000px height — below both thresholds.
-        # First item is consumed by the pre-capture scrollable-content 
expansion.
-        mock_page.evaluate.side_effect = [None, 3, 1000, [], []]
+        def evaluate_side_effect(script, *_args):
+            if script == 
'document.querySelectorAll(".chart-container").length':
+                return 3
+            if "const target = document.querySelector" in script:
+                return 1000
+            if "dashboard-component-chart-holder" in script:
+                return []
+            return None
+
+        mock_page.evaluate.side_effect = evaluate_side_effect
 
         call_order: list[str] = []
 
@@ -1789,8 +1931,17 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
             "SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
         }
         mock_context, mock_page = self._make_pw_mocks(mock_browser_manager)
-        # First item is consumed by the pre-capture scrollable-content 
expansion.
-        mock_page.evaluate.side_effect = [None, 25, 500, [], []]
+
+        def evaluate_side_effect(script, *_args):
+            if script == 
'document.querySelectorAll(".chart-container").length':
+                return 25
+            if "const target = document.querySelector" in script:
+                return 500
+            if "dashboard-component-chart-holder" in script:
+                return []
+            return None
+
+        mock_page.evaluate.side_effect = evaluate_side_effect
 
         with patch.object(WebDriverPlaywright, "auth", 
return_value=mock_context):
             result = WebDriverPlaywright("chrome").get_screenshot(
@@ -1885,8 +2036,6 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
         mock_page.screenshot.return_value = b"fallback"
 
         with patch.object(WebDriverPlaywright, "auth", 
return_value=mock_context):
-            # match= keeps this assertion meaningful even when playwright
-            # is not installed and PlaywrightTimeout aliases bare Exception.
             with pytest.raises(
                 PlaywrightTimeout, match="Tiled screenshot failed for url"
             ):

Reply via email to