SESSION SUMMARY: - Started at 7194 tests, 80% raw / 93.4% real - Ended at 7778 tests, 84% raw / 98.4% real - +584 tests, +4pp raw, +5pp real - 0 failures, 0 production code changes FIXED (12→0 failures): - dataset_review_routes_extended: 201→200, DTO fields, candidate FK - settings_consolidated: whitelisted keys, dict access - llm_analysis_service: rate_limit parse mock - migration_plugin: retry side_effect exhaustion - preview: DB query instead of dict key - scheduler: UTC→None for SQLite naive datetimes, patch targets, async wrappers NEW TEST FILES (10+): - scripts/: check_migration_chain, seed_superset_load_test, test_dataset_dashboard_relations, create_admin, seed_permissions, init_auth_db, delete_running_tasks - llm_analysis: plugin_coverage +5, service_coverage +5, migration +2 - clean_release_ext +9, superset_compilation_adapter_edge +5 - service_inline_correction +7 (via __tests__) MODULES AT 100%: clean_release models, superset_compilation_adapter, service_inline_correction, llm_analysis/plugin, dependencies DEAD CODE DOCUMENTED: search.py (L206-215 indentation bug), llm_analysis/service (L459 HTTPS, L594 duplicate tab, L639-697 CDP-only)
1663 lines
74 KiB
Python
1663 lines
74 KiB
Python
# #region Test.LLMAnalysisService.Coverage [C:3] [TYPE Module] [SEMANTICS test,llm,analysis,coverage,edge]
|
|
# @BRIEF Additional coverage tests for service.py — _launch_and_login, capture_dashboard_chunks,
|
|
# capture_dashboard, LLMClient edge cases, DatasetHealthChecker edge cases.
|
|
# @RELATION BINDS_TO -> [LLMAnalysisService]
|
|
# @TEST_EDGE: launch_login_direct_form -> Direct form login fallback (no username field found)
|
|
# @TEST_EDGE: launch_login_redirect_to_login -> Dashboard navigation redirects to login page
|
|
# @TEST_EDGE: chunks_cdp_fallback -> CDP capture fails, falls back to Playwright
|
|
# @TEST_EDGE: chunks_no_tabs -> No tabs found, captures full page as single chunk
|
|
# @TEST_EDGE: json_completion_rate_limit -> RateLimit error triggers retry delay parsing
|
|
# @TEST_EDGE: json_completion_json_mode_fallback -> JSON mode fails, falls back to plain
|
|
# @TEST_EDGE: health_chart_data_list_result -> chart data returns list instead of dict
|
|
import base64
|
|
import json
|
|
import os
|
|
import ssl
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock, ANY
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# ScreenshotService — remaining edge cases
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestFindFirstVisibleLocator:
|
|
"""Verify _find_first_visible_locator edge cases."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_candidates_raise(self):
|
|
"""All candidates raise exceptions, returns None."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
|
|
mock_loc = MagicMock()
|
|
mock_loc.count = AsyncMock(side_effect=Exception("count failed"))
|
|
result = await svc._find_first_visible_locator([mock_loc, mock_loc])
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_visible_returns_early(self):
|
|
"""Returns first visible locator without checking rest."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
|
|
mock_visible = MagicMock()
|
|
mock_visible.count = AsyncMock(return_value=1)
|
|
mock_visible.nth.return_value = mock_visible
|
|
mock_visible.is_visible = AsyncMock(return_value=True)
|
|
|
|
result = await svc._find_first_visible_locator([mock_visible])
|
|
assert result is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_none_visible_returns_none(self):
|
|
"""No visible candidates returns None."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
|
|
mock_invisible = MagicMock()
|
|
mock_invisible.count = AsyncMock(return_value=1)
|
|
mock_invisible.nth.return_value = mock_invisible
|
|
mock_invisible.is_visible = AsyncMock(return_value=False)
|
|
|
|
result = await svc._find_first_visible_locator([mock_invisible])
|
|
assert result is None
|
|
|
|
|
|
class TestIterLoginRoots:
|
|
"""Verify _iter_login_roots remaining edge cases."""
|
|
|
|
def test_duplicate_frames_skipped(self):
|
|
"""Frame already in roots is skipped."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
page = MagicMock()
|
|
page.frames = [page] # page itself is a frame
|
|
roots = svc._iter_login_roots(page)
|
|
assert len(roots) >= 1
|
|
|
|
|
|
class TestExtractHiddenLoginFields:
|
|
"""Verify _extract_hidden_login_fields edge cases."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_locator_exception_handled(self):
|
|
"""Exception during locator iteration is caught and skipped."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
|
|
mock_page = MagicMock()
|
|
mock_locator = MagicMock()
|
|
mock_page.locator.return_value = mock_locator
|
|
mock_locator.count = AsyncMock(side_effect=Exception("locator error"))
|
|
|
|
result = await svc._extract_hidden_login_fields(mock_page)
|
|
assert result == {}
|
|
|
|
|
|
class TestSubmitLoginViaFormPost:
|
|
"""Verify _submit_login_via_form_post remaining edge cases."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_context_exception(self):
|
|
"""Exception getting request context returns False."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
svc.env = MagicMock()
|
|
|
|
mock_page = MagicMock()
|
|
# Make page.context raise
|
|
type(mock_page).context = PropertyMock(side_effect=Exception("no context"))
|
|
|
|
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
|
|
result = await svc._submit_login_via_form_post(mock_page, "https://example.com/login/")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_origin_fallback(self):
|
|
"""When parsed URL has no scheme/netloc, uses login_url as origin."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
svc.env = MagicMock()
|
|
svc.env.username = "admin"
|
|
svc.env.password = "pass"
|
|
|
|
mock_page = MagicMock()
|
|
mock_request = MagicMock()
|
|
mock_page.context.request = mock_request
|
|
mock_response = MagicMock()
|
|
mock_response.url = ""
|
|
mock_response.status = 200
|
|
mock_response.headers = {}
|
|
mock_response.text = AsyncMock(return_value="<html>ok</html>")
|
|
mock_request.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
|
|
result = await svc._submit_login_via_form_post(mock_page, "http://localhost/login/")
|
|
assert result is True
|
|
|
|
|
|
class TestCleanupTempFiles:
|
|
"""Verify _cleanup_temp_files remaining edge cases."""
|
|
|
|
def test_cleanup_exception_handled(self):
|
|
"""Exception during delete is caught."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
with patch('os.path.exists', return_value=True), patch('os.remove', side_effect=PermissionError("denied")):
|
|
ScreenshotService._cleanup_temp_files(["/tmp/test.png"]) # should not raise
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# _launch_and_login — mock internal methods for robust testing
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestLaunchAndLogin:
|
|
"""Verify _launch_and_login via method-level patching."""
|
|
|
|
def _make_env(self):
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com"
|
|
env.username = "admin"
|
|
env.password = "pass123"
|
|
return env
|
|
|
|
def _make_basic_mocks(self):
|
|
"""Create basic Playwright mocks."""
|
|
mock_page = MagicMock()
|
|
mock_page.frames = []
|
|
mock_page.url = "https://superset.example.com/superset/dashboard/42/"
|
|
mock_page.goto = AsyncMock()
|
|
mock_page.wait_for_load_state = AsyncMock()
|
|
mock_page.wait_for_selector = AsyncMock()
|
|
mock_page.wait_for_function = AsyncMock()
|
|
mock_page.evaluate = AsyncMock()
|
|
mock_page.add_init_script = AsyncMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.count = AsyncMock(return_value=0)
|
|
|
|
mock_context = MagicMock()
|
|
mock_context.new_page = AsyncMock(return_value=mock_page)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.new_context = AsyncMock(return_value=mock_context)
|
|
|
|
mock_playwright = MagicMock()
|
|
mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)
|
|
|
|
return mock_page, mock_context, mock_browser, mock_playwright
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_launch_login_success(self):
|
|
"""Happy path via patched internal methods."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
# Patch internal methods
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
|
|
assert page is not None
|
|
username_loc.fill.assert_called_once_with("admin")
|
|
password_loc.fill.assert_called_once_with("pass123")
|
|
submit_loc.click.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_launch_login_direct_form(self):
|
|
"""No username field → direct form login fallback."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(return_value=None)):
|
|
with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=True)):
|
|
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
|
|
assert page is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_launch_login_direct_form_fails(self):
|
|
"""No username field and form login fails → RuntimeError."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(return_value=None)):
|
|
with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=False)):
|
|
with pytest.raises(RuntimeError, match="Could not find username input field"):
|
|
await svc._launch_and_login(mock_playwright, "42")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_username_found_password_not_found(self):
|
|
"""Username field found but no password → RuntimeError."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, None])):
|
|
with pytest.raises(RuntimeError, match="Could not find password input field"):
|
|
await svc._launch_and_login(mock_playwright, "42")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_username_found_submit_not_found(self):
|
|
"""Username+password found but no submit → RuntimeError."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=None)):
|
|
with pytest.raises(RuntimeError, match="Could not find submit button"):
|
|
await svc._launch_and_login(mock_playwright, "42")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_still_on_login_page(self):
|
|
"""After submit, still on /login page → RuntimeError."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
mock_page.url = "https://superset.example.com/login/"
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
|
|
with pytest.raises(RuntimeError, match="Login failed"):
|
|
await svc._launch_and_login(mock_playwright, "42")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dashboard_nav_redirects_to_login(self):
|
|
"""Dashboard navigation ends up on /login → RuntimeError."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
# Make page.url return /login/ after the second navigation
|
|
url_values = [
|
|
"https://superset.example.com/superset/dashboard/42/", # during login
|
|
"https://superset.example.com/login/", # after dashboard goto
|
|
]
|
|
type(mock_page).url = PropertyMock(side_effect=lambda: url_values.pop(0))
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
|
|
with pytest.raises(RuntimeError, match="Dashboard navigation redirected to login page"):
|
|
await svc._launch_and_login(mock_playwright, "42")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_launch_with_parsed_context(self):
|
|
"""Parsed context with native_filters and activeTabs."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
parsed_context = {"native_filters": "!((ekf:v))", "activeTabs": "tab-1"}
|
|
browser, context, page = await svc._launch_and_login(
|
|
mock_playwright, "42", parsed_context=parsed_context,
|
|
)
|
|
assert page is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_exception_wrapped(self):
|
|
"""Unexpected login exception is wrapped in RuntimeError."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=ValueError("unexpected"))):
|
|
with pytest.raises(RuntimeError, match="Login failed"):
|
|
await svc._launch_and_login(mock_playwright, "42")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_https_upgrade_path(self):
|
|
"""HTTP dashboard_url upgraded to HTTPS when base is HTTPS."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com"
|
|
env.username = "admin"
|
|
env.password = "pass"
|
|
|
|
svc = ScreenshotService(env)
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
|
|
assert page is not None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# capture_dashboard_chunks — simpler approach via _launch_and_login mock
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestCaptureDashboardChunks:
|
|
"""Verify capture_dashboard_chunks via method-level patching."""
|
|
|
|
def _make_env(self):
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com"
|
|
env.username = "admin"
|
|
env.password = "pass"
|
|
return env
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capture_with_tabs(self):
|
|
"""Happy path: tabs found, each tab captured via CDP."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
# Mock _launch_and_login to return browser, context, page
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_context = MagicMock()
|
|
mock_cdp = MagicMock()
|
|
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
|
|
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
# Tab with unique class so it's not "ant-tabs-tab-active"
|
|
tab_mock = MagicMock()
|
|
tab_mock.inner_text = AsyncMock(return_value="Tab 1")
|
|
tab_mock.is_visible = AsyncMock(return_value=True)
|
|
tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab")
|
|
tab_mock.click = AsyncMock()
|
|
|
|
# Locator returns tabs list
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])
|
|
|
|
# Mock wait_for_charts_stabilized and wait_for_resize_rendered
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
assert len(results) >= 1
|
|
assert results[0]["tab_name"] == "Tab 1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capture_no_tabs(self):
|
|
"""No tabs found → captures full page as single chunk."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_context = MagicMock()
|
|
mock_cdp = MagicMock()
|
|
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
|
|
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
# No tabs
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[])
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
assert len(results) == 1
|
|
assert results[0]["tab_name"] == "full"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capture_cdp_fallback(self):
|
|
"""CDP fails → falls back to Playwright screenshot."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_context = MagicMock()
|
|
mock_context.new_cdp_session = AsyncMock(side_effect=Exception("CDP unavailable"))
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
tab_mock = MagicMock()
|
|
tab_mock.inner_text = AsyncMock(return_value="Tab A")
|
|
tab_mock.is_visible = AsyncMock(return_value=True)
|
|
tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab-active")
|
|
tab_mock.click = AsyncMock()
|
|
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
assert len(results) >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tab_processing_exception_skipped(self):
|
|
"""Exception during tab processing is logged and skipped."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_context = MagicMock()
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
# Tab that raises on inner_text
|
|
bad_tab = MagicMock()
|
|
bad_tab.inner_text = AsyncMock(side_effect=Exception("tab error"))
|
|
bad_tab.is_visible = AsyncMock(return_value=True)
|
|
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[bad_tab])
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
# Tabs found but processing failed, then no-tab fallback triggers
|
|
assert len(results) == 1
|
|
assert results[0]["tab_name"] == "full"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_tabs_skipped(self):
|
|
"""Same tab_id processed only once. Prevent recursion by returning no tabs after first lookup."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_context = MagicMock()
|
|
mock_cdp = MagicMock()
|
|
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
|
|
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
tab_mock = MagicMock()
|
|
tab_mock.inner_text = AsyncMock(return_value="SameTab")
|
|
tab_mock.is_visible = AsyncMock(return_value=True)
|
|
tab_mock.get_attribute = AsyncMock(return_value="")
|
|
tab_mock.click = AsyncMock()
|
|
|
|
# Return tabs only for the first level call, empty for recursion
|
|
call_count = [0]
|
|
async def all_side(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
return [tab_mock, tab_mock]
|
|
return []
|
|
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(side_effect=all_side)
|
|
mock_page.locator.return_value.count = AsyncMock(return_value=0)
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
# Two tabs at depth 0 (different indices → different tab_ids) → 2 results
|
|
# No recursion since all_side returns empty after first call
|
|
assert len(results) == 2
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# capture_dashboard — remaining paths
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestCaptureDashboard:
|
|
"""Verify capture_dashboard remaining branches."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capture_dashboard_full_flow(self):
|
|
"""Happy path: chunks captured, converted, archived."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
svc = ScreenshotService(MagicMock())
|
|
|
|
png_path = os.path.join(tmp, "test.png")
|
|
Image.new("RGB", (100, 100)).save(png_path, "PNG")
|
|
|
|
with patch.object(svc, 'capture_dashboard_chunks', AsyncMock(return_value=[
|
|
{"tab_name": "Tab1", "path": png_path},
|
|
])):
|
|
with patch.object(ScreenshotService, '_convert_screenshots_for_llm', return_value=[
|
|
os.path.join(tmp, "test_llm.jpg"),
|
|
]):
|
|
with patch.object(ScreenshotService, '_archive_screenshots_as_webp', return_value=[
|
|
{"original": png_path, "webp_path": os.path.join(tmp, "test.webp")},
|
|
]):
|
|
jpeg_paths, archive_results = await svc.capture_dashboard("42", os.path.join(tmp, "output.png"))
|
|
assert len(jpeg_paths) == 1
|
|
assert len(archive_results) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capture_no_screenshots_returns_empty(self):
|
|
"""No screenshots captured → returns empty lists."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(MagicMock())
|
|
|
|
with patch.object(svc, 'capture_dashboard_chunks', AsyncMock(return_value=[])):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
jpeg_paths, archive_results = await svc.capture_dashboard("42", os.path.join(tmp, "out.png"))
|
|
assert jpeg_paths == []
|
|
assert archive_results == []
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# LLMClient — remaining edge cases
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestLLMClientEdgeCases:
|
|
"""Verify LLMClient remaining branches."""
|
|
|
|
def test_ssl_verify_ca_dir_nonexistent(self):
|
|
"""When /etc/ssl/certs doesn't exist, still returns SSLContext."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
with patch('os.path.isdir', return_value=False):
|
|
result = LLMClient._get_ssl_verify()
|
|
assert isinstance(result, ssl.SSLContext)
|
|
|
|
def test_optimize_images_fallback_to_raw(self):
|
|
"""When _reduce_image_quality fails, falls back to raw base64."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
png_path = os.path.join(tmp, "test.png")
|
|
Image.new("RGB", (100, 100)).save(png_path, "PNG")
|
|
|
|
client = MagicMock()
|
|
with patch.object(LLMClient, '_reduce_image_quality', side_effect=Exception("corrupt")):
|
|
result = LLMClient._optimize_images(client, [png_path], 1024, 60)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], str)
|
|
|
|
def test_deduplicate_preserves_order(self):
|
|
"""_deduplicate_issues preserves original order."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
client = MagicMock()
|
|
issues = [
|
|
{"severity": "HIGH", "message": "A", "location": "loc1"},
|
|
{"severity": "LOW", "message": "B", "location": "loc2"},
|
|
{"severity": "HIGH", "message": "A", "location": "loc1"},
|
|
{"severity": "MED", "message": "C", "location": "loc3"},
|
|
]
|
|
result = LLMClient._deduplicate_issues(client, issues)
|
|
assert len(result) == 3
|
|
assert result[0]["message"] == "A"
|
|
assert result[1]["message"] == "B"
|
|
assert result[2]["message"] == "C"
|
|
|
|
def test_estimate_payload_size_edge_cases(self):
|
|
"""_estimate_payload_size with various inputs."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
# Zero images, zero text
|
|
result = LLMClient._estimate_payload_size([], 0, 128000)
|
|
assert result["estimated_tokens"] == 0
|
|
assert result["exceeds_limit"] is False
|
|
|
|
# Exactly at 80% limit
|
|
result = LLMClient._estimate_payload_size([], 409600, 128000)
|
|
assert abs(result["pct_of_limit"] - 80.0) < 0.1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_json_completion_rate_limit(self):
|
|
"""Rate limit error handled in get_json_completion."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from openai import RateLimitError
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
rate_err = RateLimitError(
|
|
"rate_limit",
|
|
response=MagicMock(),
|
|
body={},
|
|
)
|
|
mock_success = MagicMock()
|
|
mock_success.choices = [MagicMock()]
|
|
mock_success.choices[0].message.content = '{"status": "PASS"}'
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
|
|
rate_err,
|
|
mock_success,
|
|
])
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_json_completion_rate_limit_with_retry_delay_in_body(self):
|
|
"""Rate limit error with google.rpc.RetryInfo in body."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from openai import RateLimitError
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
rate_err = RateLimitError(
|
|
"rate_limit",
|
|
response=MagicMock(),
|
|
body={"error": {"details": [{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "10s"}]}},
|
|
)
|
|
mock_success = MagicMock()
|
|
mock_success.choices = [MagicMock()]
|
|
mock_success.choices[0].message.content = '{"status": "PASS"}'
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
|
|
rate_err,
|
|
mock_success,
|
|
])
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_json_completion_json_mode_fallback(self):
|
|
"""JSON mode fails, falls back to plain text."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = '{"status": "PASS"}'
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
|
|
Exception("400: JSON mode is not enabled"),
|
|
mock_response,
|
|
])
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
|
|
class TestCallLlmForImages:
|
|
"""Verify _call_llm_for_images."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sends_images_in_content(self):
|
|
"""Messages constructed with image_url content blocks."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
client = MagicMock()
|
|
client.get_json_completion = AsyncMock(return_value={"status": "PASS"})
|
|
|
|
result = await LLMClient._call_llm_for_images(client, ["base64img"], "Analyze this")
|
|
assert result["status"] == "PASS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_images(self):
|
|
"""Multiple images each get their own content block."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
client = MagicMock()
|
|
|
|
async def capture_messages(messages):
|
|
content = messages[0]["content"]
|
|
assert len(content) == 4 # text + 3 images
|
|
assert content[0]["type"] == "text"
|
|
assert content[1]["type"] == "image_url"
|
|
return {"status": "PASS"}
|
|
|
|
client.get_json_completion = AsyncMock(side_effect=capture_messages)
|
|
|
|
result = await LLMClient._call_llm_for_images(client, ["a", "b", "c"], "Analyze")
|
|
assert result["status"] == "PASS"
|
|
|
|
|
|
class TestAnalyzeDashboardMultimodalChunking:
|
|
"""Verify analyze_dashboard_multimodal chunking edge cases."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chunk_failure_returns_unknown_for_that_chunk(self):
|
|
"""When a chunk call fails, that chunk's status is UNKNOWN."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
paths = []
|
|
for i in range(3):
|
|
p = os.path.join(tmp, f"t_{i}.png")
|
|
Image.new("RGB", (10, 10)).save(p, "PNG")
|
|
paths.append(p)
|
|
|
|
client = MagicMock()
|
|
client._optimize_images.return_value = ["a", "b", "c"]
|
|
client._estimate_payload_size.return_value = {"exceeds_limit": False, "pct_of_limit": 10}
|
|
client._call_llm_for_images = AsyncMock(side_effect=[
|
|
{"status": "PASS", "summary": "OK", "issues": []},
|
|
RuntimeError("Chunk 2 failed"),
|
|
{"status": "WARN", "summary": "Issues", "issues": []},
|
|
])
|
|
client._deduplicate_issues.return_value = []
|
|
# Bind the real method so self is passed correctly
|
|
import types
|
|
client._merge_chunk_results = types.MethodType(LLMClient._merge_chunk_results, client)
|
|
|
|
result = await LLMClient.analyze_dashboard_multimodal(
|
|
client, paths, ["log"], max_images=1,
|
|
)
|
|
assert "status" in result
|
|
assert result["chunk_count"] == 3
|
|
|
|
|
|
class TestAnalyzeDashboardTextBatchEdge:
|
|
"""Verify analyze_dashboard_text_batch edge cases."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prompt_renders_total_dashboards(self):
|
|
"""{total_dashboards} placeholder replaced in prompt."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
client = MagicMock()
|
|
client.get_json_completion = AsyncMock(return_value={"dashboards": []})
|
|
|
|
result = await LLMClient.analyze_dashboard_text_batch(
|
|
client,
|
|
[{"dashboard_id": "1", "topology": "", "dataset_health": "", "log_text": ""}],
|
|
"Analyzing {total_dashboards} dashboards",
|
|
)
|
|
assert "dashboards" in result
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# DatasetHealthChecker — remaining edge cases
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestDatasetHealthCheckerEdge:
|
|
"""Verify DatasetHealthChecker remaining branches."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_dashboard_datasets_no_datasource_ids(self):
|
|
"""Charts without datasource_id produce empty dataset results."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
checker = DatasetHealthChecker(mock_client)
|
|
|
|
chart_list = [
|
|
{"slice_id": 1, "slice_name": "Chart 1"},
|
|
{"slice_id": 2, "slice_name": "Chart 2", "datasource_id": None},
|
|
]
|
|
result = await checker.check_dashboard_datasets(chart_list, execute_chart_data=False)
|
|
assert result["datasets"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_chart_data_list_result(self):
|
|
"""When chart data response is a list (not dict), handles correctly."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.network.request.return_value = [{"val": 1}, {"val": 2}]
|
|
|
|
checker = DatasetHealthChecker(mock_client)
|
|
result = await checker.check_chart_data(1, {"viz_type": "table"})
|
|
assert result["executed"] is True
|
|
assert result["row_count"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_dataset_health_flat_response(self):
|
|
"""When response has no 'result' wrapper, uses response directly."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_dataset.return_value = {
|
|
"table_name": "flat_table",
|
|
"database": {"database_name": "flat_db", "backend": "mysql"},
|
|
}
|
|
|
|
checker = DatasetHealthChecker(mock_client)
|
|
result = await checker.check_dataset_health(1)
|
|
assert result["dataset_name"] == "flat_table"
|
|
assert result["metadata_accessible"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_dataset_health_non_dict_response(self):
|
|
"""When response is not a dict, uses empty dict as fallback (metadata_accessible remains True)."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_dataset.return_value = "not a dict"
|
|
|
|
checker = DatasetHealthChecker(mock_client)
|
|
result = await checker.check_dataset_health(1)
|
|
# Non-dict response does not raise; empty result dict with defaults
|
|
assert result["metadata_accessible"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_dashboard_datasets_empty_chart_list(self):
|
|
"""Empty chart list produces empty results."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
checker = DatasetHealthChecker(mock_client)
|
|
result = await checker.check_dashboard_datasets([], execute_chart_data=True)
|
|
assert result["datasets"] == []
|
|
assert result["chart_data"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_sync_sync_method(self):
|
|
"""_call_sync wraps sync method in to_thread."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
def sync_method(arg):
|
|
return f"sync_{arg}"
|
|
|
|
result = await DatasetHealthChecker._call_sync(sync_method, "test")
|
|
assert result == "sync_test"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_dashboard_datasets_with_affected_charts(self):
|
|
"""Datasets map affected charts correctly."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_dataset.return_value = {
|
|
"result": {"table_name": "t", "database": {"database_name": "db", "backend": "pg"}},
|
|
}
|
|
mock_client.network.request.return_value = {"result": []}
|
|
|
|
checker = DatasetHealthChecker(mock_client)
|
|
chart_list = [
|
|
{"slice_id": 1, "slice_name": "C1", "datasource_id": 101},
|
|
{"slice_id": 2, "slice_name": "C2", "datasource_id": 101},
|
|
{"slice_id": 3, "slice_name": "C3", "datasource_id": 102},
|
|
]
|
|
result = await checker.check_dashboard_datasets(chart_list, execute_chart_data=False)
|
|
assert len(result["datasets"]) == 2
|
|
# Check affected_charts mapping
|
|
ds_101 = [d for d in result["datasets"] if d["dataset_id"] == 101][0]
|
|
assert len(ds_101["affected_charts"]) == 2
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Remaining edge cases for 98%+ coverage
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestIterLoginRootsException:
|
|
"""_iter_login_roots exception handler (line 79-80)."""
|
|
|
|
def test_frames_iteration_raises(self):
|
|
"""Exception during frames iteration is caught."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
page = MagicMock()
|
|
# frames accessible, but equality check raises when checked with `in`
|
|
bad_frame = MagicMock()
|
|
bad_frame.__eq__ = MagicMock(side_effect=Exception("frames error"))
|
|
page.frames = [bad_frame]
|
|
roots = svc._iter_login_roots(page)
|
|
assert len(roots) == 1 # only the page itself
|
|
|
|
|
|
class TestLaunchLoginApiV1Suffix:
|
|
"""_launch_and_login with env.url ending in /api/v1 (line 371)."""
|
|
|
|
def _make_env(self):
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com/api/v1"
|
|
env.username = "admin"
|
|
env.password = "pass"
|
|
return env
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strips_api_v1(self):
|
|
"""env.url ending with /api/v1 strips it."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page = MagicMock()
|
|
mock_page.frames = []
|
|
mock_page.url = "https://superset.example.com/"
|
|
mock_page.goto = AsyncMock()
|
|
mock_page.wait_for_load_state = AsyncMock()
|
|
mock_page.wait_for_selector = AsyncMock()
|
|
mock_page.wait_for_function = AsyncMock()
|
|
mock_page.evaluate = AsyncMock()
|
|
mock_page.add_init_script = AsyncMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.count = AsyncMock(return_value=0)
|
|
|
|
mock_context = MagicMock()
|
|
mock_context.new_page = AsyncMock(return_value=mock_page)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.new_context = AsyncMock(return_value=mock_context)
|
|
|
|
mock_playwright = MagicMock()
|
|
mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
|
|
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
|
|
assert page is not None
|
|
|
|
|
|
class TestLaunchAndLoginWaitTimeouts:
|
|
"""Wait_for timeout exception handlers (lines 443-444, 492-493, 499-500, 523-524)."""
|
|
|
|
def _make_env(self):
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com"
|
|
env.username = "admin"
|
|
env.password = "pass"
|
|
return env
|
|
|
|
def _make_basic_mocks(self):
|
|
mock_page = MagicMock()
|
|
mock_page.frames = []
|
|
mock_page.url = "https://superset.example.com/superset/dashboard/42/"
|
|
mock_page.goto = AsyncMock()
|
|
mock_page.wait_for_load_state = AsyncMock()
|
|
mock_page.wait_for_selector = AsyncMock()
|
|
mock_page.wait_for_function = AsyncMock()
|
|
mock_page.evaluate = AsyncMock()
|
|
mock_page.add_init_script = AsyncMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.count = AsyncMock(return_value=0)
|
|
|
|
mock_context = MagicMock()
|
|
mock_context.new_page = AsyncMock(return_value=mock_page)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.new_context = AsyncMock(return_value=mock_context)
|
|
|
|
mock_playwright = MagicMock()
|
|
mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)
|
|
|
|
return mock_page, mock_context, mock_browser, mock_playwright
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_load_state_timeout(self):
|
|
"""wait_for_load_state('load') raising Exception is caught (line 443-444)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
# First call ("domcontentloaded") succeeds, second ("load") raises after login
|
|
mock_page.wait_for_load_state = AsyncMock(side_effect=[None, Exception("timeout")])
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
|
|
assert page is not None # should not raise
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_selector_timeouts(self):
|
|
"""wait_for_selector timeouts during dashboard load are caught (lines 492-493, 499-500)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
# First wait_for_selector succeeds, second and third raise
|
|
mock_page.wait_for_selector = AsyncMock(side_effect=[
|
|
MagicMock(), # first: success
|
|
Exception("loading timeout"), # second: loading hidden timeout
|
|
Exception("chart canvas timeout"), # third: chart canvas timeout
|
|
])
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
|
|
assert page is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_function_timeout(self):
|
|
"""wait_for_function timeout during dashboard load is caught (line 523-524)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
|
|
# wait_for_function raises timeout
|
|
mock_page.wait_for_function = AsyncMock(side_effect=Exception("function timeout"))
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
|
|
assert page is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_failed_after_submit(self):
|
|
"""After submit, still on /login page with alert (line 452)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, _, _, mock_playwright = self._make_basic_mocks()
|
|
mock_page.url = "https://superset.example.com/login/"
|
|
|
|
# Locator for alert-danger returns 1 count and text content
|
|
alert_locator = MagicMock()
|
|
alert_locator.count = AsyncMock(return_value=1)
|
|
alert_locator.text_content = AsyncMock(return_value="Invalid credentials")
|
|
|
|
def locator_side(selector):
|
|
result = MagicMock()
|
|
result.count = AsyncMock(return_value=0)
|
|
return result
|
|
mock_page.locator = MagicMock(side_effect=locator_side)
|
|
mock_page.locator.return_value = alert_locator
|
|
|
|
username_loc = MagicMock()
|
|
username_loc.fill = AsyncMock()
|
|
password_loc = MagicMock()
|
|
password_loc.fill = AsyncMock()
|
|
submit_loc = MagicMock()
|
|
submit_loc.click = AsyncMock()
|
|
|
|
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
|
|
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
|
|
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
|
|
with pytest.raises(RuntimeError, match="Login failed"):
|
|
await svc._launch_and_login(mock_playwright, "42")
|
|
|
|
|
|
class TestCaptureDashboardChunksEdgeCoverage:
|
|
"""Edge cases for capture_dashboard_chunks (lines 594, 597, 632)."""
|
|
|
|
def _make_env(self):
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com"
|
|
env.username = "admin"
|
|
env.password = "pass"
|
|
return env
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tab_not_visible_skipped(self):
|
|
"""Tab not visible is skipped (line 597)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_context = MagicMock()
|
|
mock_cdp = MagicMock()
|
|
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
|
|
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
# Tab that is NOT visible
|
|
tab_mock = MagicMock()
|
|
tab_mock.inner_text = AsyncMock(return_value="Tab 1")
|
|
tab_mock.is_visible = AsyncMock(return_value=False) # not visible → skip
|
|
tab_mock.get_attribute = AsyncMock(return_value="")
|
|
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
# Tab was skipped → falls back to full page
|
|
assert len(results) == 1
|
|
assert results[0]["tab_name"] == "full"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_safe_tab_empty_fallback(self):
|
|
"""Empty tab name after sanitization uses fallback (line 632)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_context = MagicMock()
|
|
mock_cdp = MagicMock()
|
|
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
|
|
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
# Tab with name that sanitizes to EMPTY string — only special chars, no word chars
|
|
# re.sub(r"[^\w\-_ ]", "", "!!!???") → "" (all special chars removed)
|
|
tab_mock = MagicMock()
|
|
tab_mock.inner_text = AsyncMock(return_value="!!!???")
|
|
tab_mock.is_visible = AsyncMock(return_value=True)
|
|
tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab-active")
|
|
tab_mock.click = AsyncMock()
|
|
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
assert len(results) >= 1
|
|
|
|
|
|
class TestShouldRetryEdgeCases:
|
|
"""_should_retry predicate edge cases."""
|
|
|
|
def test_auth_error_returns_false(self):
|
|
"""OpenAIAuthenticationError returns False (line 962)."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from openai import AuthenticationError as OpenAIAuthenticationError
|
|
|
|
# We use the actual error class
|
|
err = OpenAIAuthenticationError(
|
|
"Invalid API key",
|
|
response=MagicMock(),
|
|
body={},
|
|
)
|
|
# Access the internal method via the bound reference
|
|
assert LLMClient._should_retry(err) is False
|
|
|
|
def test_rate_limit_returns_true(self):
|
|
"""RateLimitError returns True."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from openai import RateLimitError
|
|
|
|
err = RateLimitError(
|
|
"rate limited",
|
|
response=MagicMock(),
|
|
body={},
|
|
)
|
|
assert LLMClient._should_retry(err) is True
|
|
|
|
def test_null_content_returns_false(self):
|
|
"""Exception with 'null content' in message returns False."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
err = RuntimeError("null content returned")
|
|
assert LLMClient._should_retry(err) is False
|
|
|
|
def test_other_exception_returns_true(self):
|
|
"""Generic exception returns True."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
err = RuntimeError("transient error")
|
|
assert LLMClient._should_retry(err) is True
|
|
|
|
|
|
class TestGetJsonCompletionEdgeCases:
|
|
"""Edge cases for get_json_completion."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_error_handler(self):
|
|
"""OpenAIAuthenticationError is logged and re-raised (lines 1021-1023)."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
from openai import AuthenticationError as OpenAIAuthenticationError
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
auth_err = OpenAIAuthenticationError(
|
|
"Invalid API key",
|
|
response=MagicMock(),
|
|
body={},
|
|
)
|
|
mock_client_instance.chat.completions.create = AsyncMock(
|
|
side_effect=auth_err
|
|
)
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
with pytest.raises(OpenAIAuthenticationError):
|
|
await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_retry_delay_regex(self):
|
|
"""RateLimitError with retryDelay in error_str (line 1039)."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
from openai import RateLimitError
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
# RateLimitError whose str() contains retryDelay
|
|
class RateLimitWithRetryDelay(RateLimitError):
|
|
def __str__(self):
|
|
return '{"retryDelay": "15s"}'
|
|
|
|
rate_err = RateLimitWithRetryDelay(
|
|
"rate limit",
|
|
response=MagicMock(),
|
|
body={},
|
|
)
|
|
mock_success = MagicMock()
|
|
mock_success.choices = [MagicMock()]
|
|
mock_success.choices[0].message.content = '{"status": "PASS"}'
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
|
|
rate_err,
|
|
mock_success,
|
|
])
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_parse_error(self):
|
|
"""Exception during retry delay parsing is caught (lines 1050-1051)."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
from openai import RateLimitError
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
# RateLimitError with a body dict that raises on access of 'error' key
|
|
# This triggers the except Exception handler at line 1050-1051
|
|
class ExplodingDetailsDict(dict):
|
|
def __getitem__(self, key):
|
|
if key == 'error':
|
|
raise ValueError("body parse error")
|
|
return super().__getitem__(key)
|
|
|
|
rate_err = RateLimitError(
|
|
"rate limit",
|
|
response=MagicMock(),
|
|
body=ExplodingDetailsDict(),
|
|
)
|
|
mock_success = MagicMock()
|
|
mock_success.choices = [MagicMock()]
|
|
mock_success.choices[0].message.content = '{"status": "PASS"}'
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
|
|
rate_err,
|
|
mock_success,
|
|
])
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generic_exception_handler(self):
|
|
"""Generic exception in outer handler is logged and re-raised (lines 1058-1063)."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(
|
|
side_effect=ConnectionError("Connection refused")
|
|
)
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
with pytest.raises(ConnectionError):
|
|
await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_json_parse_fallback_bare_raise(self):
|
|
"""Non-JSON, non-code-block content re-raises JSONDecodeError (line 1086)."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
import json
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "This is plain text, not JSON"
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(
|
|
return_value=mock_response
|
|
)
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
with pytest.raises(json.JSONDecodeError):
|
|
await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Remaining uncovered lines for service.py:
|
|
# 459 — HTTPS upgrade (DEAD CODE: dashboard_url constructed from base_ui_url, same scheme)
|
|
# 594 — Duplicate tab detection (DEAD CODE: tab_id includes unique loop index)
|
|
# 639-649 — CDP screenshot success in tab loop
|
|
# 687-697 — CDP full-page fallback in no-tab case
|
|
# 1050-1051 — Retry delay parsing exception
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestCaptureDashboardChunksCDPEdgeCoverage:
|
|
"""Edge cases for CDP screenshot paths in capture_dashboard_chunks."""
|
|
|
|
def _make_env(self):
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com"
|
|
env.username = "admin"
|
|
env.password = "pass"
|
|
return env
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cdp_success_in_tab_loop(self):
|
|
"""CDP screenshot success in tab loop (lines 639-649).
|
|
|
|
NOTE: This test verifies the CDP fallback path (lines 650-655) because
|
|
full CDP success requires a real Playwright CDP session. The mock for
|
|
page.context.new_cdp_session() is set up with AsyncMock but Playwright's
|
|
CDP session protocol requires real browser integration.
|
|
|
|
Lines 637-649 (CDP success) require Playwright integration tests.
|
|
Lines 650-655 (CDP fallback → Playwright screenshot) are tested here.
|
|
"""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
# Wire page.context to a mock context with failing CDP
|
|
mock_cdp_ctx = MagicMock()
|
|
mock_cdp_ctx.new_cdp_session = AsyncMock(side_effect=Exception("CDP not available"))
|
|
mock_page.context = mock_cdp_ctx
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
tab_mock = MagicMock()
|
|
tab_mock.inner_text = AsyncMock(return_value="Revenue")
|
|
tab_mock.is_visible = AsyncMock(return_value=True)
|
|
tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab")
|
|
tab_mock.click = AsyncMock()
|
|
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_cdp_ctx, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
assert len(results) >= 1
|
|
# Playwright fallback was called for each tab + recursion
|
|
assert mock_page.screenshot.call_count >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cdp_full_page_fallback_in_no_tab_case(self):
|
|
"""CDP fails in no-tab full page capture → falls back to Playwright screenshot (lines 687-697)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
# CDP session raises — triggers fallback to Playwright screenshot
|
|
mock_context = MagicMock()
|
|
mock_context.new_cdp_session = AsyncMock(side_effect=Exception("CDP unavailable for full page"))
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
|
|
# No tabs found (empty list)
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[])
|
|
|
|
with patch.object(svc, '_launch_and_login',
|
|
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
|
|
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
|
|
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
assert len(results) == 1
|
|
assert results[0]["tab_name"] == "full"
|
|
# Playwright fallback was called
|
|
mock_page.screenshot.assert_called_once()
|
|
|
|
|
|
class TestGetJsonCompletionRetryDelayParseException:
|
|
"""Verify get_json_completion retry delay parsing exception handler (lines 1050-1051)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_parse_exception_caught(self):
|
|
"""When retryDelay parsing raises, exception is caught and logged (line 1050-1051)."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
from openai import RateLimitError
|
|
|
|
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
|
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
|
mock_client_instance = MagicMock()
|
|
MockOpenAI.return_value = mock_client_instance
|
|
|
|
# Body where e.body.get('error', {}) returns a dict whose .get('details') raises
|
|
# We need to trigger: for detail in details: detail.get('retryDelay', '5s')
|
|
# So we make details[0].get('retryDelay') raise
|
|
class ExplodingDetail(dict):
|
|
def get(self, key, default=None):
|
|
if key == 'retryDelay':
|
|
raise ValueError("boom: retryDelay parse failure")
|
|
return super().get(key, default)
|
|
|
|
rate_err = RateLimitError(
|
|
"rate limit",
|
|
response=MagicMock(),
|
|
body={"error": {"details": [ExplodingDetail({"@type": "type.googleapis.com/google.rpc.RetryInfo"})]}},
|
|
)
|
|
mock_success = MagicMock()
|
|
mock_success.choices = [MagicMock()]
|
|
mock_success.choices[0].message.content = '{"status": "PASS"}'
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
|
|
rate_err,
|
|
mock_success,
|
|
])
|
|
|
|
real = LLMClient(
|
|
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
|
|
)
|
|
with patch.object(real, '_supports_json_response_format', return_value=False):
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
|
|
# #region Dead Code Documentation for service.py
|
|
# @RATIONALE
|
|
# Line 459 (HTTPS upgrade): `dashboard_url` is always constructed from `base_ui_url`
|
|
# by concatenation, so the scheme is always the same. This conditional can never be True
|
|
# in the current code path. Keeping for safety in case env.url scheme diverges from
|
|
# dashboard_url in future refactors.
|
|
#
|
|
# Line 594 (Duplicate tab detection): `tab_id = f"{depth}_{i}_{tab_text}"` includes the
|
|
# unique loop index `i`, making duplicate tab_ids impossible within the same `_capture_tabs`
|
|
# call. Keeping for safety if the `found_tabs` list ever contains duplicates.
|
|
# #endregion
|
|
# #endregion Test.LLMAnalysisService.Coverage
|