991 lines
44 KiB
Python
991 lines
44 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
|
|
# #endregion Test.LLMAnalysisService.Coverage
|