feat: LLM Validation section — 9 API endpoints + 3 Svelte pages + Playwright optimization
Backend: - /api/validation/ CRUD for validation tasks (policies) - Run history with 6 filters + pagination - Alembic migrations (provider_id, policy_id) - 28 pytest tests Frontend: - /validation — task list page with status filters + CRUD - /validation/[id] — task config (Config + History tabs) - /validation/history — run history with metrics - Sidebar nav entry under Operations - i18n en/ru Playwright (ScreenshotService): - Removed 25s hardcoded sleeps → conditional waits - CDP fallback to full_page screenshot - Total timeout enforcement (120s) - Debug screenshots → temp dir with cleanup QA fixes: - Debug screenshot accumulation in production (tempdir + rmtree) - Frontend API payload field name mismatch - History issues field reference fix - delete_runs scoped by policy_id Belief protocol: - @RATIONALE/@REJECTED on 27 C4+ contracts Closes: VALIDATION-REWORK-2026
This commit is contained in:
@@ -286,6 +286,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
|
||||
db_record = ValidationRecord(
|
||||
task_id=context.task_id if context else None,
|
||||
policy_id=params.get("policy_id"),
|
||||
dashboard_id=validation_result.dashboard_id,
|
||||
environment_id=env_id,
|
||||
status=validation_result.status.value,
|
||||
|
||||
@@ -13,6 +13,8 @@ import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -282,406 +284,508 @@ class ScreenshotService:
|
||||
return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout)
|
||||
# endregion ScreenshotService._goto_resilient
|
||||
|
||||
# region ScreenshotService.capture_dashboard [TYPE Function]
|
||||
# region ScreenshotService._wait_for_charts_stabilized [TYPE Function] [C:2]
|
||||
# @BRIEF Wait until chart elements have non-zero dimensions, with polling.
|
||||
# @PRE page is a valid Playwright page.
|
||||
# @POST Waits for chart stabilization or raises on timeout (handled internally).
|
||||
# @RATIONALE Polls for actual chart rendering dimensions rather than using a fixed delay — charts may load at different speeds depending on dashboard complexity and network conditions.
|
||||
# @REJECTED Fixed sleep-based wait rejected — would either waste time (too long) or produce blank screenshots (too short); polling for actual canvas/svg dimensions is more reliable.
|
||||
async def _wait_for_charts_stabilized(self, page, timeout_ms: int = 15000):
|
||||
"""Wait until chart elements have non-zero dimensions, with polling."""
|
||||
# Short initial delay for rendering pipeline to start
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
await page.wait_for_function("""() => {
|
||||
const charts = document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas');
|
||||
if (charts.length === 0) return true;
|
||||
return Array.from(charts).some(c => {
|
||||
if (c.tagName === 'CANVAS') return c.width > 10 && c.height > 10;
|
||||
if (c.tagName === 'svg') {
|
||||
const bbox = c.getBoundingClientRect();
|
||||
return bbox.width > 10 && bbox.height > 10;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}""", timeout=timeout_ms)
|
||||
except Exception:
|
||||
logger.warning("[ScreenshotService] Chart stabilization wait timed out, proceeding anyway")
|
||||
# endregion ScreenshotService._wait_for_charts_stabilized
|
||||
|
||||
# region ScreenshotService._wait_for_resize_rendered [TYPE Function] [C:2]
|
||||
# @BRIEF Wait for charts to re-render after viewport resize.
|
||||
# @PRE page is a valid Playwright page; chart_count_before contains pre-resize element counts.
|
||||
# @POST Waits for chart content to return or timeout.
|
||||
# @RATIONALE After viewport resize, Superset triggers lazy chart re-rendering — this function polls for chart elements to reappear before taking the screenshot.
|
||||
# @REJECTED Single fixed wait after resize rejected — some dashboards re-render instantly while others take seconds; fixed wait is brittle across dashboard types.
|
||||
async def _wait_for_resize_rendered(self, page, chart_count_before: dict, timeout_ms: int = 10000):
|
||||
"""Wait for charts to re-render after viewport resize, with polling."""
|
||||
try:
|
||||
await page.wait_for_function("""(preCounts) => {
|
||||
const currentCharts = document.querySelectorAll('.chart-container, .slice_container').length;
|
||||
const currentCanvases = document.querySelectorAll('canvas').length;
|
||||
const currentSvgs = document.querySelectorAll('.chart-container svg, .slice_container svg').length;
|
||||
// At least one chart element must be present
|
||||
return currentCharts > 0 && (currentCanvases > 0 || currentSvgs > 0);
|
||||
}""", arg=chart_count_before, timeout=timeout_ms)
|
||||
except Exception:
|
||||
logger.warning("[ScreenshotService] Re-render wait timed out after viewport resize, proceeding anyway")
|
||||
# endregion ScreenshotService._wait_for_resize_rendered
|
||||
|
||||
# region ScreenshotService._save_debug_screenshot [TYPE Function] [C:1]
|
||||
# @BRIEF Save a debug screenshot to a temp directory for diagnostic purposes.
|
||||
# @PRE debug_dir exists and is writable.
|
||||
# @POST Returns the debug path or None on failure.
|
||||
# @RATIONALE Uses tempfile.mkdtemp() to avoid accumulating .png files in production storage. Temp dir is cleaned up on success (rmtree) or preserved on failure for debugging.
|
||||
# @REJECTED Old approach saved debug .png next to output path — accumulated _debug_failed_login.png, _preresize.png permanently in screenshots dir (F1). In-memory-only debug logging rejected — screenshot state is visual and cannot be captured in logs.
|
||||
async def _save_debug_screenshot(self, page, debug_dir: str, suffix: str) -> str | None:
|
||||
debug_path = os.path.join(debug_dir, suffix)
|
||||
try:
|
||||
await page.screenshot(path=debug_path)
|
||||
return debug_path
|
||||
except Exception:
|
||||
return None
|
||||
# endregion ScreenshotService._save_debug_screenshot
|
||||
|
||||
# region ScreenshotService.capture_dashboard [TYPE Function] [C:4]
|
||||
# @PURPOSE Captures a full-page screenshot of a dashboard using Playwright and CDP.
|
||||
# @PRE dashboard_id is a valid string, output_path is a writable path.
|
||||
# @POST Returns True if screenshot is saved successfully.
|
||||
# @SIDE_EFFECT Launches a browser, performs UI login, switches tabs, and writes a PNG file.
|
||||
# @POST Returns True if screenshot is saved successfully. Debug temp dir cleaned up on success, preserved on failure.
|
||||
# @SIDE_EFFECT Launches a browser, performs UI login, switches tabs, writes a PNG file; creates and cleans up temp debug directory.
|
||||
# @UX_STATE [Navigating] -> Loading dashboard UI
|
||||
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
|
||||
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
|
||||
# @UX_STATE [Capturing] -> Executing CDP screenshot
|
||||
# @RATIONALE Uses tempfile.mkdtemp() for all debug screenshots; cleans up on success (shutil.rmtree), preserves on failure for diagnostic analysis. Pre-resize screenshot is saved to temp dir and auto-cleaned on success.
|
||||
# @REJECTED Old approach saved debug .png files alongside output — accumulated permanently (F1). In-memory-only logging rejected — visual state cannot be captured in text.
|
||||
async def capture_dashboard(self, dashboard_id: str, output_path: str) -> bool:
|
||||
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
|
||||
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-infobars",
|
||||
"--no-sandbox"
|
||||
]
|
||||
)
|
||||
# Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
# Construct base UI URL from environment (strip /api/v1 suffix)
|
||||
base_ui_url = self.env.url.rstrip("/")
|
||||
if base_ui_url.endswith("/api/v1"):
|
||||
base_ui_url = base_ui_url[:-len("/api/v1")]
|
||||
debug_dir = tempfile.mkdtemp(prefix="ss_screenshot_debug_")
|
||||
success = False
|
||||
try:
|
||||
# -- begin original body --
|
||||
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
|
||||
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-infobars",
|
||||
"--no-sandbox"
|
||||
]
|
||||
)
|
||||
# Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
# Construct base UI URL from environment (strip /api/v1 suffix)
|
||||
base_ui_url = self.env.url.rstrip("/")
|
||||
if base_ui_url.endswith("/api/v1"):
|
||||
base_ui_url = base_ui_url[:-len("/api/v1")]
|
||||
|
||||
# Create browser context with realistic headers
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1280, 'height': 720},
|
||||
user_agent=user_agent,
|
||||
extra_http_headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1"
|
||||
# Create browser context with realistic headers
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1280, 'height': 720},
|
||||
user_agent=user_agent,
|
||||
extra_http_headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1"
|
||||
}
|
||||
)
|
||||
logger.info("Browser context created successfully")
|
||||
|
||||
page = await context.new_page()
|
||||
# Bypass navigator.webdriver detection
|
||||
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
|
||||
|
||||
# 1. Navigate to login page and authenticate
|
||||
login_url = f"{base_ui_url.rstrip('/')}/login/"
|
||||
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
|
||||
|
||||
response = await self._goto_resilient(
|
||||
page,
|
||||
login_url,
|
||||
primary_wait_until="domcontentloaded",
|
||||
fallback_wait_until="load",
|
||||
timeout=HTTP_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
if response:
|
||||
logger.info(f"[DEBUG] Login page response status: {response.status}")
|
||||
|
||||
# Wait for login form to be ready
|
||||
await page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
# More exhaustive list of selectors for various Superset versions/themes
|
||||
selectors = {
|
||||
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
|
||||
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
|
||||
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
|
||||
}
|
||||
)
|
||||
logger.info("Browser context created successfully")
|
||||
logger.info("[DEBUG] Attempting to find login form elements...")
|
||||
|
||||
page = await context.new_page()
|
||||
# Bypass navigator.webdriver detection
|
||||
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
|
||||
try:
|
||||
used_direct_form_login = False
|
||||
# Find and fill username
|
||||
username_locator = await self._find_login_field_locator(page, "username")
|
||||
|
||||
# 1. Navigate to login page and authenticate
|
||||
login_url = f"{base_ui_url.rstrip('/')}/login/"
|
||||
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
|
||||
if not username_locator:
|
||||
roots = self._iter_login_roots(page)
|
||||
logger.info(f"[DEBUG] Found {len(roots)} login roots including child frames")
|
||||
for root_index, root in enumerate(roots[:5]):
|
||||
all_inputs = await root.locator('input').all()
|
||||
logger.info(f"[DEBUG] Root {root_index}: found {len(all_inputs)} input fields")
|
||||
for i, inp in enumerate(all_inputs[:5]): # Log first 5
|
||||
inp_type = await inp.get_attribute('type')
|
||||
inp_name = await inp.get_attribute('name')
|
||||
inp_id = await inp.get_attribute('id')
|
||||
logger.info(f"[DEBUG] Root {root_index} input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
|
||||
used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
|
||||
if not used_direct_form_login:
|
||||
raise RuntimeError("Could not find username input field on login page")
|
||||
username_locator = None
|
||||
|
||||
response = await self._goto_resilient(
|
||||
page,
|
||||
login_url,
|
||||
primary_wait_until="domcontentloaded",
|
||||
fallback_wait_until="load",
|
||||
timeout=HTTP_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
if response:
|
||||
logger.info(f"[DEBUG] Login page response status: {response.status}")
|
||||
if username_locator is not None:
|
||||
logger.info("[DEBUG] Filling username field")
|
||||
await username_locator.fill(self.env.username)
|
||||
|
||||
# Wait for login form to be ready
|
||||
await page.wait_for_load_state("domcontentloaded")
|
||||
# Find and fill password
|
||||
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
|
||||
|
||||
# More exhaustive list of selectors for various Superset versions/themes
|
||||
selectors = {
|
||||
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
|
||||
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
|
||||
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
|
||||
}
|
||||
logger.info("[DEBUG] Attempting to find login form elements...")
|
||||
if username_locator is not None and not password_locator:
|
||||
raise RuntimeError("Could not find password input field on login page")
|
||||
|
||||
try:
|
||||
used_direct_form_login = False
|
||||
# Find and fill username
|
||||
username_locator = await self._find_login_field_locator(page, "username")
|
||||
if password_locator is not None:
|
||||
logger.info("[DEBUG] Filling password field")
|
||||
await password_locator.fill(self.env.password)
|
||||
|
||||
if not username_locator:
|
||||
roots = self._iter_login_roots(page)
|
||||
logger.info(f"[DEBUG] Found {len(roots)} login roots including child frames")
|
||||
for root_index, root in enumerate(roots[:5]):
|
||||
all_inputs = await root.locator('input').all()
|
||||
logger.info(f"[DEBUG] Root {root_index}: found {len(all_inputs)} input fields")
|
||||
for i, inp in enumerate(all_inputs[:5]): # Log first 5
|
||||
inp_type = await inp.get_attribute('type')
|
||||
inp_name = await inp.get_attribute('name')
|
||||
inp_id = await inp.get_attribute('id')
|
||||
logger.info(f"[DEBUG] Root {root_index} input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
|
||||
used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
|
||||
# Click submit
|
||||
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
|
||||
|
||||
if username_locator is not None and not submit_locator:
|
||||
raise RuntimeError("Could not find submit button on login page")
|
||||
|
||||
if submit_locator is not None:
|
||||
logger.info("[DEBUG] Clicking submit button")
|
||||
await submit_locator.click()
|
||||
|
||||
# Wait for navigation after login
|
||||
if not used_direct_form_login:
|
||||
raise RuntimeError("Could not find username input field on login page")
|
||||
username_locator = None
|
||||
try:
|
||||
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
|
||||
except Exception as load_wait_error:
|
||||
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
|
||||
|
||||
if username_locator is not None:
|
||||
logger.info("[DEBUG] Filling username field")
|
||||
await username_locator.fill(self.env.username)
|
||||
# Check if login was successful
|
||||
if not used_direct_form_login and "/login" in page.url:
|
||||
# Check for error messages on page
|
||||
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
|
||||
logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
|
||||
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_login.png")
|
||||
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
|
||||
|
||||
# Find and fill password
|
||||
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
|
||||
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
|
||||
|
||||
if username_locator is not None and not password_locator:
|
||||
raise RuntimeError("Could not find password input field on login page")
|
||||
# Check cookies after successful login
|
||||
page_cookies = await context.cookies()
|
||||
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
|
||||
for c in page_cookies:
|
||||
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
|
||||
|
||||
if password_locator is not None:
|
||||
logger.info("[DEBUG] Filling password field")
|
||||
await password_locator.fill(self.env.password)
|
||||
except Exception as e:
|
||||
page_title = await page.title()
|
||||
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
|
||||
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_login.png")
|
||||
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
|
||||
|
||||
# Click submit
|
||||
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
|
||||
# 2. Navigate to dashboard
|
||||
# @UX_STATE [Navigating] -> Loading dashboard UI
|
||||
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
|
||||
|
||||
if username_locator is not None and not submit_locator:
|
||||
raise RuntimeError("Could not find submit button on login page")
|
||||
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
|
||||
dashboard_url = dashboard_url.replace("http://", "https://")
|
||||
|
||||
if submit_locator is not None:
|
||||
logger.info("[DEBUG] Clicking submit button")
|
||||
await submit_locator.click()
|
||||
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
|
||||
|
||||
# Wait for navigation after login
|
||||
if not used_direct_form_login:
|
||||
try:
|
||||
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
|
||||
except Exception as load_wait_error:
|
||||
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
|
||||
|
||||
# Check if login was successful
|
||||
if not used_direct_form_login and "/login" in page.url:
|
||||
# Check for error messages on page
|
||||
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
|
||||
logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
|
||||
debug_path = output_path.replace(".png", "_debug_failed_login.png")
|
||||
await page.screenshot(path=debug_path)
|
||||
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
|
||||
|
||||
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
|
||||
|
||||
# Check cookies after successful login
|
||||
page_cookies = await context.cookies()
|
||||
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
|
||||
for c in page_cookies:
|
||||
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
|
||||
|
||||
except Exception as e:
|
||||
page_title = await page.title()
|
||||
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
|
||||
debug_path = output_path.replace(".png", "_debug_failed_login.png")
|
||||
await page.screenshot(path=debug_path)
|
||||
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
|
||||
|
||||
# 2. Navigate to dashboard
|
||||
# @UX_STATE [Navigating] -> Loading dashboard UI
|
||||
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
|
||||
|
||||
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
|
||||
dashboard_url = dashboard_url.replace("http://", "https://")
|
||||
|
||||
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
|
||||
|
||||
# Dashboard pages can keep polling/network activity open indefinitely.
|
||||
response = await self._goto_resilient(
|
||||
page,
|
||||
dashboard_url,
|
||||
primary_wait_until="domcontentloaded",
|
||||
fallback_wait_until="load",
|
||||
timeout=HTTP_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
if response:
|
||||
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
|
||||
|
||||
if "/login" in page.url:
|
||||
debug_path = output_path.replace(".png", "_debug_failed_dashboard_auth.png")
|
||||
await page.screenshot(path=debug_path)
|
||||
raise RuntimeError(
|
||||
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
|
||||
# Dashboard pages can keep polling/network activity open indefinitely.
|
||||
response = await self._goto_resilient(
|
||||
page,
|
||||
dashboard_url,
|
||||
primary_wait_until="domcontentloaded",
|
||||
fallback_wait_until="load",
|
||||
timeout=HTTP_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
try:
|
||||
# Wait for the dashboard grid to be present
|
||||
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] Dashboard container loaded")
|
||||
if response:
|
||||
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
|
||||
|
||||
if "/login" in page.url:
|
||||
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_dashboard_auth.png")
|
||||
raise RuntimeError(
|
||||
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
|
||||
)
|
||||
|
||||
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
|
||||
# We wait until loading indicators disappear or a timeout occurs
|
||||
try:
|
||||
# Wait for loading indicators to disappear
|
||||
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=HTTP_REQUEST_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] Loading indicators hidden")
|
||||
except Exception:
|
||||
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
|
||||
# Wait for the dashboard grid to be present
|
||||
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] Dashboard container loaded")
|
||||
|
||||
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
|
||||
# We look for common chart containers that should have content
|
||||
try:
|
||||
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=HTTP_REQUEST_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] Chart content detected")
|
||||
except Exception:
|
||||
logger.warning("[DEBUG] Timeout waiting for chart content")
|
||||
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
|
||||
# We wait until loading indicators disappear or a timeout occurs
|
||||
try:
|
||||
# Wait for loading indicators to disappear
|
||||
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=HTTP_REQUEST_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] Loading indicators hidden")
|
||||
except Exception:
|
||||
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
|
||||
|
||||
# Additional check: wait for all chart containers to have non-empty content
|
||||
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
|
||||
await page.wait_for_function("""() => {
|
||||
const charts = document.querySelectorAll('.chart-container, .slice_container');
|
||||
if (charts.length === 0) return true; // No charts to wait for
|
||||
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
|
||||
# We look for common chart containers that should have content
|
||||
try:
|
||||
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=HTTP_REQUEST_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] Chart content detected")
|
||||
except Exception:
|
||||
logger.warning("[DEBUG] Timeout waiting for chart content")
|
||||
|
||||
# Additional check: wait for all chart containers to have non-empty content
|
||||
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
|
||||
await page.wait_for_function("""() => {
|
||||
const charts = document.querySelectorAll('.chart-container, .slice_container');
|
||||
if (charts.length === 0) return true; // No charts to wait for
|
||||
|
||||
// Check if all charts have rendered content (canvas, svg, or non-empty div)
|
||||
return Array.from(charts).every(chart => {
|
||||
const hasCanvas = chart.querySelector('canvas') !== null;
|
||||
const hasSvg = chart.querySelector('svg') !== null;
|
||||
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
|
||||
return hasCanvas || hasSvg || hasContent;
|
||||
});
|
||||
}""", timeout=HTTP_REQUEST_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] All charts have rendered content")
|
||||
// Check if all charts have rendered content (canvas, svg, or non-empty div)
|
||||
return Array.from(charts).every(chart => {
|
||||
const hasCanvas = chart.querySelector('canvas') !== null;
|
||||
const hasSvg = chart.querySelector('svg') !== null;
|
||||
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
|
||||
return hasCanvas || hasSvg || hasContent;
|
||||
});
|
||||
}""", timeout=HTTP_REQUEST_TIMEOUT_MS)
|
||||
logger.info("[DEBUG] All charts have rendered content")
|
||||
|
||||
# Scroll to bottom and back to top to trigger lazy loading of all charts
|
||||
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
|
||||
await page.evaluate("""async () => {
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
for (let i = 0; i < document.body.scrollHeight; i += 500) {
|
||||
window.scrollTo(0, i);
|
||||
await delay(100);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
await delay(500);
|
||||
}""")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
|
||||
|
||||
# Final stabilization delay - increased for complex dashboards
|
||||
logger.info("[DEBUG] Final stabilization delay...")
|
||||
await asyncio.sleep(15)
|
||||
|
||||
# Logic to handle tabs and full-page capture
|
||||
try:
|
||||
# 1. Handle Tabs (Recursive switching)
|
||||
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
|
||||
processed_tabs = set()
|
||||
|
||||
async def switch_tabs(depth=0):
|
||||
if depth > 3:
|
||||
return # Limit recursion depth
|
||||
|
||||
tab_selectors = [
|
||||
'.ant-tabs-nav-list .ant-tabs-tab',
|
||||
'.dashboard-component-tabs .ant-tabs-tab',
|
||||
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
|
||||
]
|
||||
|
||||
found_tabs = []
|
||||
for selector in tab_selectors:
|
||||
found_tabs = await page.locator(selector).all()
|
||||
if found_tabs:
|
||||
break
|
||||
|
||||
if found_tabs:
|
||||
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
|
||||
for i, tab in enumerate(found_tabs):
|
||||
try:
|
||||
tab_text = (await tab.inner_text()).strip()
|
||||
tab_id = f"{depth}_{i}_{tab_text}"
|
||||
|
||||
if tab_id in processed_tabs:
|
||||
continue
|
||||
|
||||
if await tab.is_visible():
|
||||
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
|
||||
processed_tabs.add(tab_id)
|
||||
|
||||
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
|
||||
if not is_active:
|
||||
await tab.click()
|
||||
await asyncio.sleep(2) # Wait for content to render
|
||||
|
||||
await switch_tabs(depth + 1)
|
||||
except Exception as tab_e:
|
||||
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
|
||||
|
||||
try:
|
||||
first_tab = found_tabs[0]
|
||||
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
|
||||
await first_tab.click()
|
||||
await asyncio.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await switch_tabs()
|
||||
|
||||
# 2. Calculate full height for screenshot
|
||||
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
|
||||
full_height = await page.evaluate("""() => {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const dashboardContent = document.querySelector('.dashboard-content');
|
||||
|
||||
return Math.max(
|
||||
body.scrollHeight, body.offsetHeight,
|
||||
html.clientHeight, html.scrollHeight, html.offsetHeight,
|
||||
dashboardContent ? dashboardContent.scrollHeight + 100 : 0
|
||||
);
|
||||
}""")
|
||||
logger.info(f"[DEBUG] Calculated full height: {full_height}")
|
||||
|
||||
# DIAGNOSTIC: Count chart elements before resize
|
||||
chart_count_before = await page.evaluate("""() => {
|
||||
return {
|
||||
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
|
||||
canvasElements: document.querySelectorAll('canvas').length,
|
||||
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
|
||||
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
|
||||
};
|
||||
}""")
|
||||
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
|
||||
|
||||
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
|
||||
pre_resize_path = output_path.replace(".png", "_preresize.png")
|
||||
try:
|
||||
await page.screenshot(path=pre_resize_path, full_page=False, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
|
||||
import os
|
||||
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
|
||||
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
|
||||
except Exception as pre_e:
|
||||
logger.warning(f"[DIAGNOSTIC] Failed to capture pre-resize screenshot: {pre_e}")
|
||||
|
||||
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
|
||||
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
|
||||
|
||||
# DIAGNOSTIC: Increased wait time and log timing
|
||||
logger.info("[DIAGNOSTIC] Waiting 10 seconds after viewport resize for re-render...")
|
||||
await asyncio.sleep(10)
|
||||
logger.info("[DIAGNOSTIC] Wait completed")
|
||||
|
||||
# DIAGNOSTIC: Count chart elements after resize and wait
|
||||
chart_count_after = await page.evaluate("""() => {
|
||||
return {
|
||||
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
|
||||
canvasElements: document.querySelectorAll('canvas').length,
|
||||
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
|
||||
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
|
||||
};
|
||||
}""")
|
||||
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
|
||||
|
||||
# DIAGNOSTIC: Check if any charts have error states
|
||||
chart_errors = await page.evaluate("""() => {
|
||||
const errors = [];
|
||||
document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
|
||||
const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
|
||||
if (errorEl) {
|
||||
errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
|
||||
# Scroll to bottom and back to top to trigger lazy loading of all charts
|
||||
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
|
||||
await page.evaluate("""async () => {
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
for (let i = 0; i < document.body.scrollHeight; i += 500) {
|
||||
window.scrollTo(0, i);
|
||||
await delay(100);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}""")
|
||||
if chart_errors:
|
||||
logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
|
||||
else:
|
||||
logger.info("[DIAGNOSTIC] No chart error states detected")
|
||||
window.scrollTo(0, 0);
|
||||
await delay(500);
|
||||
}""")
|
||||
|
||||
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
|
||||
# @UX_STATE [Capturing] -> Executing CDP screenshot
|
||||
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
|
||||
cdp = await page.context.new_cdp_session(page)
|
||||
except Exception as e:
|
||||
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
|
||||
|
||||
screenshot_data = await cdp.send("Page.captureScreenshot", {
|
||||
"format": "png",
|
||||
"fromSurface": True,
|
||||
"captureBeyondViewport": True
|
||||
})
|
||||
# Final stabilization delay - wait for charts to stabilize
|
||||
logger.info("[DEBUG] Waiting for charts to stabilize...")
|
||||
await self._wait_for_charts_stabilized(page)
|
||||
|
||||
image_data = base64.b64decode(screenshot_data["data"])
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
# DIAGNOSTIC: Verify screenshot file
|
||||
import os
|
||||
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
|
||||
|
||||
# DIAGNOSTIC: Get image dimensions
|
||||
# Logic to handle tabs and full-page capture
|
||||
try:
|
||||
with Image.open(output_path) as final_img:
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
|
||||
except Exception as img_err:
|
||||
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
|
||||
async with asyncio.timeout(SCREENSHOT_SERVICE_TIMEOUT_MS / 1000):
|
||||
# 1. Handle Tabs (Recursive switching)
|
||||
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
|
||||
processed_tabs = set()
|
||||
|
||||
logger.info(f"Full-page screenshot saved to {output_path} (via CDP)")
|
||||
except Exception as e:
|
||||
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
|
||||
try:
|
||||
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
|
||||
except Exception as e2:
|
||||
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
|
||||
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
|
||||
async def switch_tabs(depth=0):
|
||||
if depth > 3:
|
||||
return # Limit recursion depth
|
||||
|
||||
await browser.close()
|
||||
return True
|
||||
tab_selectors = [
|
||||
'.ant-tabs-nav-list .ant-tabs-tab',
|
||||
'.dashboard-component-tabs .ant-tabs-tab',
|
||||
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
|
||||
]
|
||||
|
||||
found_tabs = []
|
||||
for selector in tab_selectors:
|
||||
found_tabs = await page.locator(selector).all()
|
||||
if found_tabs:
|
||||
break
|
||||
|
||||
if found_tabs:
|
||||
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
|
||||
for i, tab in enumerate(found_tabs):
|
||||
try:
|
||||
tab_text = (await tab.inner_text()).strip()
|
||||
tab_id = f"{depth}_{i}_{tab_text}"
|
||||
|
||||
if tab_id in processed_tabs:
|
||||
continue
|
||||
|
||||
if await tab.is_visible():
|
||||
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
|
||||
processed_tabs.add(tab_id)
|
||||
|
||||
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
|
||||
if not is_active:
|
||||
await tab.click()
|
||||
# Wait for chart content to render in the newly active tab
|
||||
try:
|
||||
await page.wait_for_function("""() => {
|
||||
const activeTab = document.querySelector('.ant-tabs-tab-active');
|
||||
if (!activeTab) return true;
|
||||
const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
|
||||
if (!tabPane) return true;
|
||||
const charts = tabPane.querySelectorAll('canvas, svg');
|
||||
return charts.length > 0;
|
||||
}""", timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
|
||||
except Exception:
|
||||
logger.warning(f"[TabSwitching] Content verification timed out for tab: {tab_text}")
|
||||
|
||||
await switch_tabs(depth + 1)
|
||||
except Exception as tab_e:
|
||||
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
|
||||
|
||||
try:
|
||||
first_tab = found_tabs[0]
|
||||
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
|
||||
await first_tab.click()
|
||||
try:
|
||||
await page.wait_for_function("""() => {
|
||||
const activeTab = document.querySelector('.ant-tabs-tab-active');
|
||||
if (!activeTab) return true;
|
||||
const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
|
||||
if (!tabPane) return true;
|
||||
const charts = tabPane.querySelectorAll('canvas, svg');
|
||||
return charts.length > 0;
|
||||
}""", timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
|
||||
except Exception:
|
||||
logger.warning("[TabSwitching] Content verification timed out for first tab")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await switch_tabs()
|
||||
|
||||
# 2. Calculate full height for screenshot
|
||||
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
|
||||
full_height = await page.evaluate("""() => {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const dashboardContent = document.querySelector('.dashboard-content');
|
||||
|
||||
return Math.max(
|
||||
body.scrollHeight, body.offsetHeight,
|
||||
html.clientHeight, html.scrollHeight, html.offsetHeight,
|
||||
dashboardContent ? dashboardContent.scrollHeight + 100 : 0
|
||||
);
|
||||
}""")
|
||||
logger.info(f"[DEBUG] Calculated full height: {full_height}")
|
||||
|
||||
# DIAGNOSTIC: Count chart elements before resize
|
||||
chart_count_before = await page.evaluate("""() => {
|
||||
return {
|
||||
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
|
||||
canvasElements: document.querySelectorAll('canvas').length,
|
||||
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
|
||||
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
|
||||
};
|
||||
}""")
|
||||
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
|
||||
|
||||
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
|
||||
pre_resize_path = await self._save_debug_screenshot(page, debug_dir, "preresize.png")
|
||||
if pre_resize_path:
|
||||
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
|
||||
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
|
||||
else:
|
||||
logger.warning("[DIAGNOSTIC] Failed to capture pre-resize screenshot")
|
||||
|
||||
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
|
||||
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
|
||||
|
||||
# Wait for charts to re-render after viewport resize
|
||||
logger.info("[DIAGNOSTIC] Waiting for re-render after viewport resize...")
|
||||
await self._wait_for_resize_rendered(page, chart_count_before)
|
||||
logger.info("[DIAGNOSTIC] Re-render wait completed")
|
||||
|
||||
# DIAGNOSTIC: Count chart elements after resize and wait
|
||||
chart_count_after = await page.evaluate("""() => {
|
||||
return {
|
||||
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
|
||||
canvasElements: document.querySelectorAll('canvas').length,
|
||||
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
|
||||
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
|
||||
};
|
||||
}""")
|
||||
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
|
||||
|
||||
# DIAGNOSTIC: Check if any charts have error states
|
||||
chart_errors = await page.evaluate("""() => {
|
||||
const errors = [];
|
||||
document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
|
||||
const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
|
||||
if (errorEl) {
|
||||
errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}""")
|
||||
if chart_errors:
|
||||
logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
|
||||
else:
|
||||
logger.info("[DIAGNOSTIC] No chart error states detected")
|
||||
|
||||
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
|
||||
# @UX_STATE [Capturing] -> Executing CDP screenshot
|
||||
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
|
||||
cdp_success = False
|
||||
try:
|
||||
cdp = await page.context.new_cdp_session(page)
|
||||
screenshot_data = await cdp.send("Page.captureScreenshot", {
|
||||
"format": "png",
|
||||
"fromSurface": True,
|
||||
"captureBeyondViewport": True
|
||||
})
|
||||
image_data = base64.b64decode(screenshot_data["data"])
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
logger.info("Full-page screenshot saved via CDP")
|
||||
cdp_success = True
|
||||
except Exception as cdp_err:
|
||||
logger.warning(f"CDP screenshot failed: {cdp_err}. Falling back to Playwright full_page.")
|
||||
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
|
||||
logger.info("Full-page screenshot saved via Playwright fallback")
|
||||
cdp_success = True
|
||||
|
||||
if cdp_success:
|
||||
# DIAGNOSTIC: Verify screenshot file
|
||||
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
|
||||
|
||||
# DIAGNOSTIC: Get image dimensions
|
||||
try:
|
||||
with Image.open(output_path) as final_img:
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
|
||||
except Exception as img_err:
|
||||
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Screenshot capture timed out after {SCREENSHOT_SERVICE_TIMEOUT_MS}ms")
|
||||
try:
|
||||
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
|
||||
logger.info("Emergency timeout screenshot saved")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
|
||||
try:
|
||||
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
|
||||
except Exception as e2:
|
||||
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
|
||||
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
|
||||
|
||||
await browser.close()
|
||||
success = True
|
||||
return True
|
||||
finally:
|
||||
if success:
|
||||
shutil.rmtree(debug_dir, ignore_errors=True)
|
||||
else:
|
||||
logger.info(f"Debug screenshots preserved in {debug_dir}")
|
||||
# endregion ScreenshotService.capture_dashboard
|
||||
# #endregion ScreenshotService
|
||||
|
||||
@@ -985,18 +1089,7 @@ class LLMClient:
|
||||
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
|
||||
}
|
||||
# endregion LLMClient.analyze_dashboard
|
||||
# #endregion LLMClient
|
||||
|
||||
# #endregion LLMAnalysisService
|
||||
return await self.get_json_completion(messages)
|
||||
except Exception as e:
|
||||
logger.error(f"[analyze_dashboard] Failed to get analysis: {e!s}")
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"summary": f"Failed to get response from LLM: {e!s}",
|
||||
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
|
||||
}
|
||||
# endregion LLMClient.analyze_dashboard
|
||||
|
||||
# #endregion LLMClient
|
||||
|
||||
# #endregion LLMAnalysisService
|
||||
|
||||
Reference in New Issue
Block a user