# #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="ok") 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