- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
434 lines
21 KiB
Python
434 lines
21 KiB
Python
# #region Test.LLMAnalysisService.Gaps [C:3] [TYPE Module] [SEMANTICS test,llm,analysis,coverage,gaps]
|
|
# @BRIEF Final coverage gaps for service.py: login-locator fallback across roots, falsy parsed-context
|
|
# flags, recursive tab dedup, CDP screenshot success paths, provider-error normalization (5xx),
|
|
# rate-limit retry-delay body parsing, chunk permanent-error abort, unknown permanent failure
|
|
# propagation, and chart-data non-mapping responses.
|
|
# @RELATION BINDS_TO -> [Plugin.Service.LLMAnalysisService]
|
|
# @TEST_EDGE: login_locator_fallback_across_roots -> username/password absent in root1, found in root2
|
|
# @TEST_EDGE: parsed_context_falsy_flags -> empty native_filters/activeTabs skip URL query append
|
|
# @TEST_EDGE: chunks_recursive_tab_dedup -> revisited depth re-encounters processed tab ids and skips
|
|
# @TEST_EDGE: chunks_cdp_success_tab -> CDP screenshot writes tab file (no Playwright fallback)
|
|
# @TEST_EDGE: chunks_cdp_success_fullpage -> CDP screenshot writes full-page file
|
|
# @TEST_EDGE: normalize_status_5xx -> generic 5xx status maps to ProviderTransportFailure
|
|
# @TEST_EDGE: rate_limit_body_not_dict -> non-dict body falls back to default 5s delay
|
|
# @TEST_EDGE: rate_limit_details_without_retry_info -> non-matching details keep looping
|
|
# @TEST_EDGE: chunk_permanent_error_aborts -> non-retryable chunk failure re-raised (not UNKNOWN)
|
|
# @TEST_EDGE: unknown_permanent_failure_propagates -> custom non-retryable ProviderFailure escapes
|
|
# @TEST_EDGE: chart_data_non_mapping_result -> response neither dict nor list -> row_count 0
|
|
import asyncio
|
|
import base64
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from src.plugins.llm_analysis.exceptions import (
|
|
ProviderAuthenticationFailure,
|
|
ProviderFailure,
|
|
ProviderTransportFailure,
|
|
)
|
|
from src.plugins.llm_analysis.models import LLMProviderType
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# _find_login_field_locator — fallback across multiple login roots
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestFindLoginFieldLocatorFallback:
|
|
"""Verify _find_login_field_locator falls through roots when a field is absent."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_username_not_found_first_root_then_found(self):
|
|
"""Username absent in root1, found in root2."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
root1, root2 = MagicMock(), MagicMock()
|
|
username_loc = MagicMock()
|
|
|
|
with patch.object(svc, '_iter_login_roots', return_value=[root1, root2]):
|
|
with patch.object(svc, '_find_first_visible_locator', AsyncMock(side_effect=[None, username_loc])):
|
|
result = await svc._find_login_field_locator(root1, "username")
|
|
assert result is username_loc
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_password_not_found_first_root_then_found(self):
|
|
"""Password absent in root1, found in root2."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
root1, root2 = MagicMock(), MagicMock()
|
|
password_loc = MagicMock()
|
|
|
|
with patch.object(svc, '_iter_login_roots', return_value=[root1, root2]):
|
|
with patch.object(svc, '_find_first_visible_locator', AsyncMock(side_effect=[None, password_loc])):
|
|
result = await svc._find_login_field_locator(root1, "password")
|
|
assert result is password_loc
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_field_name_returns_none(self):
|
|
"""Field name that is neither username nor password returns None."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
root1, root2 = MagicMock(), MagicMock()
|
|
|
|
with patch.object(svc, '_iter_login_roots', return_value=[root1, root2]):
|
|
with patch.object(svc, '_find_first_visible_locator', AsyncMock(return_value=None)):
|
|
result = await svc._find_login_field_locator(root1, "email")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_username_absent_everywhere_returns_none(self):
|
|
"""Username never found in any root returns None."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
svc = ScreenshotService(MagicMock())
|
|
root1 = MagicMock()
|
|
|
|
with patch.object(svc, '_iter_login_roots', return_value=[root1]):
|
|
with patch.object(svc, '_find_first_visible_locator', AsyncMock(return_value=None)):
|
|
result = await svc._find_login_field_locator(root1, "username")
|
|
assert result is None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# _launch_and_login — falsy parsed-context flags
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestLaunchAndLoginFalsyParsedContext:
|
|
"""Verify _launch_and_login with parsed_context whose flag values are empty."""
|
|
|
|
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_empty_flags_skip_query_append(self):
|
|
"""Empty native_filters/activeTabs values are not appended to dashboard URL."""
|
|
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)):
|
|
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
|
|
browser, context, page = await svc._launch_and_login(
|
|
mock_playwright, "42", parsed_context={"native_filters": "", "activeTabs": ""},
|
|
)
|
|
assert page is not None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# capture_dashboard_chunks — recursive tab dedup + CDP success paths
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestCaptureDashboardChunksGaps:
|
|
"""Verify capture_dashboard_chunks dedup recursion and CDP success paths."""
|
|
|
|
def _make_env(self):
|
|
env = MagicMock()
|
|
env.url = "https://superset.example.com"
|
|
env.username = "admin"
|
|
env.password = "pass"
|
|
return env
|
|
|
|
def _make_cdp_page(self):
|
|
"""Page whose context exposes a working CDP session."""
|
|
mock_page = MagicMock()
|
|
mock_page.set_viewport_size = AsyncMock()
|
|
mock_page.screenshot = AsyncMock()
|
|
mock_page.wait_for_function = AsyncMock()
|
|
mock_page.url = "https://example.com/dashboard/42/"
|
|
mock_page.frames = []
|
|
|
|
mock_cdp = MagicMock()
|
|
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
|
|
mock_context = MagicMock()
|
|
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
|
|
mock_page.context = mock_context
|
|
|
|
mock_browser = MagicMock()
|
|
mock_browser.close = AsyncMock()
|
|
return mock_page, mock_context, mock_browser
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revisited_depth_skips_processed_tabs(self):
|
|
"""Same tab ids re-encountered at a revisited depth are skipped (line 615)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, mock_context, mock_browser = self._make_cdp_page()
|
|
|
|
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="ant-tabs-tab")
|
|
tab_mock.click = AsyncMock()
|
|
|
|
# Always find the same two tabs at every depth → recursion revisits depths
|
|
mock_page.locator = MagicMock()
|
|
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock, 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)
|
|
# Depth-0 ids 0_0/0_1 processed; revisited depths hit the dedup branch
|
|
assert len(results) >= 2
|
|
for r in results:
|
|
assert os.path.exists(r["path"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_tabs_cdp_success_writes_fullpage(self):
|
|
"""No tabs → full-page chunk written via CDP (no Playwright fallback)."""
|
|
from src.plugins.llm_analysis.service import ScreenshotService
|
|
|
|
svc = ScreenshotService(self._make_env())
|
|
mock_page, mock_context, mock_browser = self._make_cdp_page()
|
|
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 tempfile.TemporaryDirectory() as tmp:
|
|
results = await svc.capture_dashboard_chunks("42", tmp)
|
|
assert len(results) == 1
|
|
assert results[0]["tab_name"] == "full"
|
|
assert os.path.exists(results[0]["path"])
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# LLMClient — error normalization and rate-limit body parsing
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestNormalizeProviderError5xx:
|
|
"""Verify _normalize_provider_error maps generic 5xx status to transport failure."""
|
|
|
|
def test_status_code_500_plus(self):
|
|
"""Exception carrying status_code >= 500 maps to ProviderTransportFailure."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
class _ServerError(Exception):
|
|
status_code = 502
|
|
|
|
result = LLMClient._normalize_provider_error(_ServerError("bad gateway"), provider_id="gpt-4o")
|
|
assert isinstance(result, ProviderTransportFailure)
|
|
assert result.status_code == 502
|
|
assert result.retryable is True
|
|
|
|
def test_status_code_404_unknown_maps_to_transport(self):
|
|
"""Non-mapped status (404) falls through to default transport failure."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
class _NotFoundError(Exception):
|
|
status_code = 404
|
|
|
|
result = LLMClient._normalize_provider_error(_NotFoundError("missing"))
|
|
assert isinstance(result, ProviderTransportFailure)
|
|
|
|
|
|
class TestGetJsonCompletionRateLimitBodyParse:
|
|
"""Verify get_json_completion rate-limit retry-delay parsing edge branches."""
|
|
|
|
def _make_client_with_sequence(self, first_errors):
|
|
"""Build a real LLMClient whose first completions calls raise, then succeed."""
|
|
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_success = MagicMock()
|
|
mock_success.choices = [MagicMock()]
|
|
mock_success.choices[0].message.content = '{"status": "PASS"}'
|
|
|
|
mock_client_instance.chat.completions.create = AsyncMock(
|
|
side_effect=[*first_errors, 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):
|
|
with patch.object(asyncio, 'sleep', AsyncMock()):
|
|
return real
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_body_not_dict(self):
|
|
"""Rate-limit error with non-dict body falls back to default delay."""
|
|
from openai import RateLimitError
|
|
|
|
rate_err = RateLimitError("rate_limit", response=MagicMock(), body="not a dict")
|
|
real = self._make_client_with_sequence([rate_err])
|
|
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_details_without_retry_info(self):
|
|
"""Details without matching RetryInfo keep the loop going until a match."""
|
|
from openai import RateLimitError
|
|
|
|
rate_err = RateLimitError(
|
|
"rate_limit",
|
|
response=MagicMock(),
|
|
body={
|
|
"error": {
|
|
"details": [
|
|
{"@type": "type.googleapis.com/google.rpc.SomeOther", "retryDelay": "3s"},
|
|
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "2s"},
|
|
],
|
|
}
|
|
},
|
|
)
|
|
real = self._make_client_with_sequence([rate_err])
|
|
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_no_retry_info_anywhere(self):
|
|
"""Details with no RetryInfo at all keep the default delay."""
|
|
from openai import RateLimitError
|
|
|
|
rate_err = RateLimitError(
|
|
"rate_limit",
|
|
response=MagicMock(),
|
|
body={"error": {"details": [{"@type": "type.googleapis.com/google.rpc.SomeOther", "retryDelay": "3s"}]}},
|
|
)
|
|
real = self._make_client_with_sequence([rate_err])
|
|
|
|
result = await real.get_json_completion([{"role": "user", "content": "test"}])
|
|
assert result["status"] == "PASS"
|
|
|
|
|
|
class TestAnalyzeDashboardMultimodalPermanentError:
|
|
"""Verify analyze_dashboard_multimodal aborts on permanent provider errors."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chunk_permanent_error_aborts(self):
|
|
"""A non-retryable chunk failure re-raises instead of merging 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}
|
|
# Assign the real static function so the MagicMock client classifies errors correctly
|
|
client._normalize_provider_error = LLMClient._normalize_provider_error
|
|
auth_err = ProviderAuthenticationFailure("bad key", provider_id="gpt-4o", status_code=401)
|
|
client._call_llm_for_images = AsyncMock(side_effect=[
|
|
auth_err,
|
|
{"status": "PASS", "summary": "OK", "issues": []},
|
|
{"status": "WARN", "summary": "Issues", "issues": []},
|
|
])
|
|
|
|
with pytest.raises(ProviderAuthenticationFailure):
|
|
await LLMClient.analyze_dashboard_multimodal(client, paths, ["log"], max_images=1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_permanent_failure_propagates(self):
|
|
"""A custom non-retryable ProviderFailure escapes to the caller."""
|
|
from src.plugins.llm_analysis.service import LLMClient
|
|
|
|
class _PermanentProviderFailure(ProviderFailure):
|
|
retryable = False
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
p = os.path.join(tmp, "t.png")
|
|
Image.new("RGB", (10, 10)).save(p, "PNG")
|
|
|
|
client = MagicMock()
|
|
client._optimize_images.return_value = ["a"]
|
|
client._estimate_payload_size.return_value = {"exceeds_limit": False, "pct_of_limit": 10}
|
|
# Assign the real static function so the MagicMock client classifies errors correctly
|
|
client._normalize_provider_error = LLMClient._normalize_provider_error
|
|
client._call_llm_for_images = AsyncMock(side_effect=_PermanentProviderFailure("permanent"))
|
|
|
|
with pytest.raises(_PermanentProviderFailure):
|
|
await LLMClient.analyze_dashboard_multimodal(client, [p], ["log"], max_images=1)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# DatasetHealthChecker — non-mapping chart data response
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class TestCheckChartDataNonMappingResult:
|
|
"""Verify check_chart_data with a response that is neither dict nor list."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_mapping_response_row_count_zero(self):
|
|
"""String response → executed with row_count 0."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.network.request.return_value = "plain string"
|
|
|
|
checker = DatasetHealthChecker(mock_client)
|
|
result = await checker.check_chart_data(1, {"viz_type": "table"})
|
|
assert result["executed"] is True
|
|
assert result["row_count"] == 0
|
|
assert result["error"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_none_response_row_count_zero(self):
|
|
"""None response → executed with row_count 0."""
|
|
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.network.request.return_value = None
|
|
|
|
checker = DatasetHealthChecker(mock_client)
|
|
result = await checker.check_chart_data(1, {"viz_type": "table"})
|
|
assert result["executed"] is True
|
|
assert result["row_count"] == 0
|
|
|
|
|
|
# #endregion Test.LLMAnalysisService.Gaps
|