Add unit tests for LspGraphBuilder class

- Implement comprehensive unit tests for the LspGraphBuilder class to validate its functionality in building code association graphs.
- Tests cover various scenarios including single level graph expansion, max nodes and depth boundaries, concurrent expansion limits, document symbol caching, error handling during node expansion, and edge cases such as empty seed lists and self-referencing nodes.
- Utilize pytest and asyncio for asynchronous testing and mocking of LspBridge methods.
This commit is contained in:
catlog22
2026-01-20 12:49:31 +08:00
parent 1376dc71d9
commit 2f3a14e946
33 changed files with 8303 additions and 716 deletions

View File

@@ -0,0 +1 @@
"""Integration tests for CodexLens."""

View File

@@ -0,0 +1,596 @@
"""Integration tests for HybridSearchEngine LSP graph search.
Tests the _search_lsp_graph method which orchestrates:
1. Seed retrieval via vector/splade/exact fallback chain
2. LSP graph expansion via LspBridge and LspGraphBuilder
3. Result deduplication and merging
Test Priority:
- P0: Critical path tests (e2e success, fallback chain)
- P1: Important edge cases (no seeds, bridge failures)
- P2: Supplementary tests (deduplication)
"""
from __future__ import annotations
import asyncio
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from codexlens.entities import SearchResult
from codexlens.hybrid_search.data_structures import (
CallHierarchyItem,
CodeAssociationGraph,
CodeSymbolNode,
Range,
)
from codexlens.search.hybrid_search import HybridSearchEngine
# -----------------------------------------------------------------------------
# Fixtures
# -----------------------------------------------------------------------------
@pytest.fixture
def tmp_index_path(tmp_path: Path) -> Path:
"""Create a temporary index database path."""
db_path = tmp_path / "_index.db"
# Create empty file to satisfy existence checks
db_path.write_bytes(b"")
return db_path
@pytest.fixture
def sample_search_result() -> SearchResult:
"""Create a sample SearchResult for use as seed."""
return SearchResult(
path="/path/to/file.py",
content="def auth_flow(): ...",
excerpt="def auth_flow(): ...",
start_line=10,
end_line=20,
symbol_name="auth_flow",
symbol_kind="function",
score=0.9,
)
@pytest.fixture
def sample_search_result_2() -> SearchResult:
"""Create a second sample SearchResult."""
return SearchResult(
path="/path/to/other.py",
content="def init_db(): ...",
excerpt="def init_db(): ...",
start_line=5,
end_line=15,
symbol_name="init_db",
symbol_kind="function",
score=0.85,
)
@pytest.fixture
def sample_code_symbol_node() -> CodeSymbolNode:
"""Create a sample CodeSymbolNode for graph expansion."""
return CodeSymbolNode(
id="/path/to/related.py:helper_func:30",
name="helper_func",
kind="function",
file_path="/path/to/related.py",
range=Range(
start_line=30,
start_character=0,
end_line=40,
end_character=0,
),
raw_code="def helper_func(): pass",
docstring="Helper function",
)
@pytest.fixture
def sample_code_symbol_node_2() -> CodeSymbolNode:
"""Create another sample CodeSymbolNode."""
return CodeSymbolNode(
id="/path/to/util.py:validate:50",
name="validate",
kind="function",
file_path="/path/to/util.py",
range=Range(
start_line=50,
start_character=0,
end_line=60,
end_character=0,
),
raw_code="def validate(): pass",
docstring="Validation function",
)
@pytest.fixture
def mock_search_engine() -> HybridSearchEngine:
"""Create a HybridSearchEngine with default settings."""
return HybridSearchEngine()
def create_mock_graph_with_seed_and_related(
seed_result: SearchResult,
related_nodes: List[CodeSymbolNode],
) -> CodeAssociationGraph:
"""Helper to create a mock graph with seed and related nodes."""
graph = CodeAssociationGraph()
# Add seed node
seed_node_id = f"{seed_result.path}:{seed_result.symbol_name or 'unknown'}:{seed_result.start_line or 0}"
seed_node = CodeSymbolNode(
id=seed_node_id,
name=seed_result.symbol_name or "unknown",
kind=seed_result.symbol_kind or "unknown",
file_path=seed_result.path,
range=Range(
start_line=seed_result.start_line or 1,
start_character=0,
end_line=seed_result.end_line or 1,
end_character=0,
),
)
graph.add_node(seed_node)
# Add related nodes
for node in related_nodes:
graph.add_node(node)
return graph
# -----------------------------------------------------------------------------
# P0: Critical Tests
# -----------------------------------------------------------------------------
class TestP0CriticalLspSearch:
"""P0 Critical: Core E2E tests for LSP graph search."""
def test_e2e_lsp_search_vector_seed_success(
self,
tmp_index_path: Path,
sample_search_result: SearchResult,
sample_code_symbol_node: CodeSymbolNode,
sample_code_symbol_node_2: CodeSymbolNode,
) -> None:
"""Test E2E LSP search with vector providing seed, returning graph-expanded results.
Input: query="authentication flow"
Mock: _search_vector returns 1 SearchResult as seed
Mock: LspBridge/LspGraphBuilder returns 2 related symbols
Assert: Returns 2 new results (seed is filtered from final results)
"""
engine = HybridSearchEngine()
# Create mock graph with seed and 2 related nodes
mock_graph = create_mock_graph_with_seed_and_related(
sample_search_result,
[sample_code_symbol_node, sample_code_symbol_node_2],
)
# Patch seed search methods
with patch.object(
engine, "_search_vector", return_value=[sample_search_result]
) as mock_vector, patch.object(
engine, "_search_splade", return_value=[]
), patch.object(
engine, "_search_exact", return_value=[]
):
# Patch LSP module at the import location
with patch.dict("sys.modules", {"codexlens.lsp": MagicMock()}):
# Patch the module-level HAS_LSP check
with patch("codexlens.search.hybrid_search.HAS_LSP", True):
# Create mock LspBridge class
mock_bridge_instance = AsyncMock()
mock_bridge_class = MagicMock()
mock_bridge_class.return_value.__aenter__ = AsyncMock(
return_value=mock_bridge_instance
)
mock_bridge_class.return_value.__aexit__ = AsyncMock(
return_value=None
)
# Create mock LspGraphBuilder
async def mock_build(seeds, bridge):
return mock_graph
mock_builder_instance = MagicMock()
mock_builder_instance.build_from_seeds = mock_build
mock_builder_class = MagicMock(return_value=mock_builder_instance)
# Patch at module level
with patch(
"codexlens.search.hybrid_search.LspBridge",
mock_bridge_class,
), patch(
"codexlens.search.hybrid_search.LspGraphBuilder",
mock_builder_class,
):
results = engine._search_lsp_graph(
index_path=tmp_index_path,
query="authentication flow",
limit=10,
max_depth=1,
max_nodes=20,
)
# Verify vector search was called first
mock_vector.assert_called_once()
# Should return 2 results (the two non-seed nodes)
assert len(results) == 2
# Verify seed is not in results
seed_node_id = f"{sample_search_result.path}:{sample_search_result.symbol_name or 'unknown'}:{sample_search_result.start_line or 0}"
result_node_ids = {
f"{r.path}:{r.symbol_name or 'unknown'}:{r.start_line or 0}"
for r in results
}
assert seed_node_id not in result_node_ids
# Verify the returned results are the graph-expanded nodes
result_paths = {r.path for r in results}
assert sample_code_symbol_node.file_path in result_paths
assert sample_code_symbol_node_2.file_path in result_paths
def test_seed_fallback_chain_vector_fails_fts_succeeds(
self,
tmp_index_path: Path,
sample_search_result: SearchResult,
sample_code_symbol_node: CodeSymbolNode,
) -> None:
"""Test seed fallback chain: vector -> splade -> exact.
Input: query="init_db"
Mock: _search_vector returns []
Mock: _search_splade returns []
Mock: _search_exact returns 1 seed
Assert: Fallback chain called in order, uses exact's seed
"""
engine = HybridSearchEngine()
call_order: List[str] = []
def track_vector(*args, **kwargs):
call_order.append("vector")
return []
def track_splade(*args, **kwargs):
call_order.append("splade")
return []
def track_exact(*args, **kwargs):
call_order.append("exact")
return [sample_search_result]
# Create mock graph
mock_graph = create_mock_graph_with_seed_and_related(
sample_search_result,
[sample_code_symbol_node],
)
with patch.object(
engine, "_search_vector", side_effect=track_vector
) as mock_vector, patch.object(
engine, "_search_splade", side_effect=track_splade
) as mock_splade, patch.object(
engine, "_search_exact", side_effect=track_exact
) as mock_exact:
with patch("codexlens.search.hybrid_search.HAS_LSP", True):
# Create mock LspBridge class
mock_bridge_instance = AsyncMock()
mock_bridge_class = MagicMock()
mock_bridge_class.return_value.__aenter__ = AsyncMock(
return_value=mock_bridge_instance
)
mock_bridge_class.return_value.__aexit__ = AsyncMock(
return_value=None
)
# Create mock LspGraphBuilder
async def mock_build(seeds, bridge):
return mock_graph
mock_builder_instance = MagicMock()
mock_builder_instance.build_from_seeds = mock_build
mock_builder_class = MagicMock(return_value=mock_builder_instance)
with patch(
"codexlens.search.hybrid_search.LspBridge",
mock_bridge_class,
), patch(
"codexlens.search.hybrid_search.LspGraphBuilder",
mock_builder_class,
):
results = engine._search_lsp_graph(
index_path=tmp_index_path,
query="init_db",
limit=10,
max_depth=1,
max_nodes=20,
)
# Verify fallback chain order: vector -> splade -> exact
assert call_order == ["vector", "splade", "exact"]
# All three methods should be called
mock_vector.assert_called_once()
mock_splade.assert_called_once()
mock_exact.assert_called_once()
# Should return results from graph expansion (1 related node)
assert len(results) == 1
# -----------------------------------------------------------------------------
# P1: Important Tests
# -----------------------------------------------------------------------------
class TestP1ImportantLspSearch:
"""P1 Important: Edge case tests for LSP graph search."""
def test_e2e_lsp_search_no_seeds_found(
self,
tmp_index_path: Path,
) -> None:
"""Test LSP search when no seeds found from any source.
Input: query="non_existent_symbol"
Mock: All seed search methods return []
Assert: Returns [], LspBridge is not called
"""
engine = HybridSearchEngine()
with patch.object(
engine, "_search_vector", return_value=[]
) as mock_vector, patch.object(
engine, "_search_splade", return_value=[]
) as mock_splade, patch.object(
engine, "_search_exact", return_value=[]
) as mock_exact:
with patch("codexlens.search.hybrid_search.HAS_LSP", True):
# LspBridge should NOT be called when no seeds
mock_bridge_class = MagicMock()
with patch(
"codexlens.search.hybrid_search.LspBridge",
mock_bridge_class,
):
results = engine._search_lsp_graph(
index_path=tmp_index_path,
query="non_existent_symbol",
limit=10,
max_depth=1,
max_nodes=20,
)
# All search methods should be tried
mock_vector.assert_called_once()
mock_splade.assert_called_once()
mock_exact.assert_called_once()
# Should return empty list
assert results == []
# LspBridge should not be instantiated (no seeds)
mock_bridge_class.assert_not_called()
def test_e2e_lsp_search_bridge_fails(
self,
tmp_index_path: Path,
sample_search_result: SearchResult,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test graceful degradation when LspBridge connection fails.
Mock: Seed search returns valid seed
Mock: LspBridge raises exception during expansion
Assert: Returns [], error handled gracefully
"""
engine = HybridSearchEngine()
with patch.object(
engine, "_search_vector", return_value=[sample_search_result]
):
with patch("codexlens.search.hybrid_search.HAS_LSP", True):
# Make LspBridge raise an error during async context
mock_bridge_class = MagicMock()
mock_bridge_class.return_value.__aenter__ = AsyncMock(
side_effect=Exception("Connection refused")
)
mock_bridge_class.return_value.__aexit__ = AsyncMock(
return_value=None
)
mock_builder_class = MagicMock()
with patch(
"codexlens.search.hybrid_search.LspBridge",
mock_bridge_class,
), patch(
"codexlens.search.hybrid_search.LspGraphBuilder",
mock_builder_class,
):
with caplog.at_level(logging.DEBUG):
results = engine._search_lsp_graph(
index_path=tmp_index_path,
query="authentication",
limit=10,
max_depth=1,
max_nodes=20,
)
# Should return empty list on failure
assert results == []
# -----------------------------------------------------------------------------
# P2: Supplementary Tests
# -----------------------------------------------------------------------------
class TestP2SupplementaryLspSearch:
"""P2 Supplementary: Deduplication and edge cases."""
def test_result_deduping_seed_not_returned(
self,
tmp_index_path: Path,
sample_search_result: SearchResult,
) -> None:
"""Test that seed results are deduplicated from final output.
Mock: Seed search returns SearchResult(path="a.py", symbol_name="foo")
Mock: LspBridge also returns same symbol in graph
Assert: Final results do not contain duplicate seed symbol
"""
engine = HybridSearchEngine()
# Create a different node that should be returned
different_node = CodeSymbolNode(
id="/different/path.py:other_func:100",
name="other_func",
kind="function",
file_path="/different/path.py",
range=Range(
start_line=100,
start_character=0,
end_line=110,
end_character=0,
),
raw_code="def other_func(): pass",
docstring="Other function",
)
# Create mock graph with seed and one different node
mock_graph = create_mock_graph_with_seed_and_related(
sample_search_result,
[different_node],
)
with patch.object(
engine, "_search_vector", return_value=[sample_search_result]
):
with patch("codexlens.search.hybrid_search.HAS_LSP", True):
mock_bridge_instance = AsyncMock()
mock_bridge_class = MagicMock()
mock_bridge_class.return_value.__aenter__ = AsyncMock(
return_value=mock_bridge_instance
)
mock_bridge_class.return_value.__aexit__ = AsyncMock(
return_value=None
)
async def mock_build(seeds, bridge):
return mock_graph
mock_builder_instance = MagicMock()
mock_builder_instance.build_from_seeds = mock_build
mock_builder_class = MagicMock(return_value=mock_builder_instance)
with patch(
"codexlens.search.hybrid_search.LspBridge",
mock_bridge_class,
), patch(
"codexlens.search.hybrid_search.LspGraphBuilder",
mock_builder_class,
):
results = engine._search_lsp_graph(
index_path=tmp_index_path,
query="test query",
limit=10,
max_depth=1,
max_nodes=20,
)
# Should only return 1 result (the different node, not the seed)
assert len(results) == 1
# The seed should NOT be in results
result_paths = [r.path for r in results]
assert sample_search_result.path not in result_paths
# The different node should be in results
assert "/different/path.py" in result_paths
def test_lsp_not_available_returns_empty(
self,
tmp_index_path: Path,
) -> None:
"""Test that _search_lsp_graph returns [] when LSP dependencies unavailable."""
engine = HybridSearchEngine()
with patch("codexlens.search.hybrid_search.HAS_LSP", False):
results = engine._search_lsp_graph(
index_path=tmp_index_path,
query="test",
limit=10,
max_depth=1,
max_nodes=20,
)
assert results == []
def test_graph_with_no_new_nodes_returns_empty(
self,
tmp_index_path: Path,
sample_search_result: SearchResult,
) -> None:
"""Test when graph only contains seed nodes (no expansion)."""
engine = HybridSearchEngine()
# Create graph with ONLY the seed node (no related nodes)
mock_graph = create_mock_graph_with_seed_and_related(
sample_search_result,
[], # No related nodes
)
with patch.object(
engine, "_search_vector", return_value=[sample_search_result]
):
with patch("codexlens.search.hybrid_search.HAS_LSP", True):
mock_bridge_instance = AsyncMock()
mock_bridge_class = MagicMock()
mock_bridge_class.return_value.__aenter__ = AsyncMock(
return_value=mock_bridge_instance
)
mock_bridge_class.return_value.__aexit__ = AsyncMock(
return_value=None
)
async def mock_build(seeds, bridge):
return mock_graph
mock_builder_instance = MagicMock()
mock_builder_instance.build_from_seeds = mock_build
mock_builder_class = MagicMock(return_value=mock_builder_instance)
with patch(
"codexlens.search.hybrid_search.LspBridge",
mock_bridge_class,
), patch(
"codexlens.search.hybrid_search.LspGraphBuilder",
mock_builder_class,
):
results = engine._search_lsp_graph(
index_path=tmp_index_path,
query="test",
limit=10,
max_depth=1,
max_nodes=20,
)
# Should return empty since all nodes are seeds (filtered out)
assert results == []

View File

@@ -0,0 +1,5 @@
"""Real interface tests for LSP integration.
These tests require VSCode Bridge to be running.
See test_lsp_real_interface.py for details.
"""

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python
"""Direct comparison: standalone manager vs direct subprocess."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
async def test_direct():
"""Direct subprocess test that WORKS."""
print("\n=== DIRECT SUBPROCESS TEST ===")
process = await asyncio.create_subprocess_exec(
'pyright-langserver', '--stdio',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(Path(__file__).parent.parent.parent),
)
def encode(msg):
body = json.dumps(msg).encode('utf-8')
header = f'Content-Length: {len(body)}\r\n\r\n'.encode('ascii')
return header + body
async def read_message(timeout=5.0):
content_length = 0
while True:
try:
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
except asyncio.TimeoutError:
return None
if not line:
return None
line_str = line.decode('ascii').strip()
if not line_str:
break
if line_str.lower().startswith('content-length:'):
content_length = int(line_str.split(':')[1].strip())
if content_length == 0:
return None
body = await process.stdout.readexactly(content_length)
return json.loads(body.decode('utf-8'))
# Initialize
init = {
'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
'params': {
'processId': 12345,
'rootUri': 'file:///D:/Claude_dms3/codex-lens',
'rootPath': 'D:/Claude_dms3/codex-lens',
'capabilities': {
'textDocument': {
'synchronization': {'dynamicRegistration': False},
'documentSymbol': {'hierarchicalDocumentSymbolSupport': True},
},
'workspace': {'configuration': True, 'workspaceFolders': True},
},
'workspaceFolders': [{'uri': 'file:///D:/Claude_dms3/codex-lens', 'name': 'codex-lens'}],
'initializationOptions': {},
}
}
process.stdin.write(encode(init))
await process.stdin.drain()
while True:
msg = await read_message(5.0)
if msg is None or msg.get('id') == 1:
print(f" Got initialize response")
break
# Initialized
process.stdin.write(encode({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}}))
await process.stdin.drain()
print(" Sent initialized")
# didOpen with simple content
did_open = {
'jsonrpc': '2.0', 'method': 'textDocument/didOpen',
'params': {
'textDocument': {
'uri': 'file:///D:/Claude_dms3/codex-lens/simple.py',
'languageId': 'python',
'version': 1,
'text': 'def hello():\n pass\n'
}
}
}
process.stdin.write(encode(did_open))
await process.stdin.drain()
print(" Sent didOpen")
# Read and respond to configuration requests
print(" Waiting for messages...")
for i in range(15):
msg = await read_message(2.0)
if msg is None:
continue
method = msg.get('method')
print(f" RECV: id={msg.get('id')}, method={method}")
if method == 'workspace/configuration':
process.stdin.write(encode({'jsonrpc': '2.0', 'id': msg['id'], 'result': [{}]}))
await process.stdin.drain()
if method == 'textDocument/publishDiagnostics':
break
# documentSymbol
doc_sym = {
'jsonrpc': '2.0', 'id': 2, 'method': 'textDocument/documentSymbol',
'params': {'textDocument': {'uri': 'file:///D:/Claude_dms3/codex-lens/simple.py'}}
}
process.stdin.write(encode(doc_sym))
await process.stdin.drain()
print(" Sent documentSymbol")
for i in range(5):
msg = await read_message(3.0)
if msg is None:
continue
if msg.get('id') == 2:
result = msg.get('result', [])
print(f" GOT {len(result)} SYMBOLS!")
break
process.terminate()
await process.wait()
async def test_manager():
"""Standalone manager test that FAILS."""
print("\n=== STANDALONE MANAGER TEST ===")
from codexlens.lsp.standalone_manager import StandaloneLspManager
workspace = Path(__file__).parent.parent.parent
manager = StandaloneLspManager(
workspace_root=str(workspace),
timeout=30.0
)
await manager.start()
simple_file = workspace / "simple.py"
simple_file.write_text('def hello():\n pass\n')
try:
symbols = await manager.get_document_symbols(str(simple_file))
print(f" GOT {len(symbols)} SYMBOLS!")
finally:
simple_file.unlink(missing_ok=True)
await manager.stop()
async def main():
await test_direct()
await test_manager()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python
"""Test concurrent read loop behavior."""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import logging
logging.basicConfig(level=logging.DEBUG, format='%(name)s - %(levelname)s - %(message)s')
from codexlens.lsp.standalone_manager import StandaloneLspManager
async def test():
workspace = Path(__file__).parent.parent.parent
manager = StandaloneLspManager(
workspace_root=str(workspace),
timeout=30.0
)
await manager.start()
# Get server for a simple file
simple_content = "def hello():\n pass\n"
simple_file = workspace / "test_simple.py"
simple_file.write_text(simple_content)
try:
print("\n=== Getting server ===")
state = await manager._get_server(str(simple_file))
print(f"Server state: initialized={state.initialized if state else 'None'}")
print("\n=== Sending didOpen ===")
await manager._send_notification(state, "textDocument/didOpen", {
"textDocument": {
"uri": simple_file.as_uri(),
"languageId": "python",
"version": 1,
"text": simple_content,
}
})
print("\n=== Waiting 5 seconds - watch for server requests ===")
for i in range(5):
print(f" Tick {i+1}...")
await asyncio.sleep(1.0)
print("\n=== Sending documentSymbol ===")
result = await manager._send_request(
state,
"textDocument/documentSymbol",
{"textDocument": {"uri": simple_file.as_uri()}},
timeout=10.0
)
print(f"Result: {result}")
finally:
simple_file.unlink(missing_ok=True)
await manager.stop()
if __name__ == "__main__":
asyncio.run(test())

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python
"""Debug script to check pyright LSP configuration requests."""
import asyncio
import logging
import sys
from pathlib import Path
# Enable DEBUG logging
logging.basicConfig(
level=logging.DEBUG,
format='%(name)s - %(levelname)s - %(message)s',
stream=sys.stdout
)
# Add source to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from codexlens.lsp.standalone_manager import StandaloneLspManager
async def test():
workspace = Path(__file__).parent.parent.parent
manager = StandaloneLspManager(
workspace_root=str(workspace),
timeout=60.0
)
await manager.start()
# Wait a bit after start to see if any requests come in
print("Waiting 3 seconds after start to see server requests...")
await asyncio.sleep(3)
# Try to get symbols for a simpler file
test_file = str(workspace / "tests" / "real" / "debug_lsp.py")
print(f"Testing with: {test_file}")
# Let's see if we can check what pyright sees
print("Checking server state...")
state = manager._servers.get("python")
if state:
print(f" - Process running: {state.process.returncode is None}")
print(f" - Initialized: {state.initialized}")
print(f" - Pending requests: {list(state.pending_requests.keys())}")
try:
symbols = await manager.get_document_symbols(test_file)
print(f"Got {len(symbols)} symbols")
for s in symbols[:5]:
print(f" - {s}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
await manager.stop()
if __name__ == "__main__":
asyncio.run(test())

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python
"""Debug script to test StandaloneLspManager directly."""
import asyncio
import logging
import sys
from pathlib import Path
# Add source to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
# Enable debug logging
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(name)s: %(message)s")
from codexlens.lsp.standalone_manager import StandaloneLspManager
async def test_standalone_manager():
"""Test StandaloneLspManager directly."""
workspace = Path(__file__).parent.parent.parent
test_file = workspace / "src" / "codexlens" / "lsp" / "lsp_bridge.py"
print(f"Workspace: {workspace}")
print(f"Test file: {test_file}")
print()
manager = StandaloneLspManager(workspace_root=str(workspace), timeout=30.0)
print("Starting manager...")
await manager.start()
print(f"Configs loaded: {list(manager._configs.keys())}")
print(f"Servers running: {list(manager._servers.keys())}")
# Try to get the server for the test file
print(f"\nGetting server for {test_file.name}...")
server = await manager._get_server(str(test_file))
if server:
print(f"Server: {server.config.display_name}")
print(f"Initialized: {server.initialized}")
print(f"Capabilities: {list(server.capabilities.keys())}")
else:
print("Failed to get server!")
# Try to get document symbols
print(f"\nGetting document symbols for {test_file.name}...")
try:
symbols = await manager.get_document_symbols(str(test_file))
print(f"Found {len(symbols)} symbols")
for sym in symbols[:5]:
print(f" - {sym.get('name', '?')} ({sym.get('kind', '?')})")
except Exception as e:
print(f"Error getting symbols: {e}")
print("\nStopping manager...")
await manager.stop()
print("Done!")
if __name__ == "__main__":
asyncio.run(test_standalone_manager())

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python
"""Direct test of pyright-langserver communication."""
import asyncio
import json
import sys
async def test_pyright():
print("Starting pyright-langserver...")
process = await asyncio.create_subprocess_exec(
"pyright-langserver", "--stdio",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Build initialize request
init_msg = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": 1234,
"rootUri": "file:///D:/Claude_dms3/codex-lens",
"rootPath": "D:/Claude_dms3/codex-lens",
"capabilities": {
"textDocument": {
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True}
},
"workspace": {"configuration": True}
},
"workspaceFolders": [
{"uri": "file:///D:/Claude_dms3/codex-lens", "name": "codex-lens"}
]
}
}
body = json.dumps(init_msg).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
print(f"Sending initialize request ({len(body)} bytes)...")
process.stdin.write(header + body)
await process.stdin.drain()
# Read responses
print("Reading responses...")
for i in range(20):
try:
line = await asyncio.wait_for(process.stdout.readline(), timeout=2.0)
if not line:
print(" (empty line - stream closed)")
break
line_str = line.decode("ascii").strip()
print(f" Header: {line_str}")
if line_str.lower().startswith("content-length:"):
content_length = int(line_str.split(":")[1].strip())
# Read empty line
await process.stdout.readline()
# Read body
body_data = await process.stdout.readexactly(content_length)
msg = json.loads(body_data.decode("utf-8"))
print(f" Message: id={msg.get('id', 'none')}, method={msg.get('method', 'none')}")
if msg.get("id") == 1:
print(f" >>> GOT INITIALIZE RESPONSE!")
print(f" >>> Capabilities: {list(msg.get('result', {}).get('capabilities', {}).keys())[:10]}...")
# Send initialized notification
print("\nSending 'initialized' notification...")
init_notif = {"jsonrpc": "2.0", "method": "initialized", "params": {}}
body2 = json.dumps(init_notif).encode("utf-8")
header2 = f"Content-Length: {len(body2)}\r\n\r\n".encode("ascii")
process.stdin.write(header2 + body2)
await process.stdin.drain()
# Wait a moment for any server requests
print("Waiting for server requests...")
await asyncio.sleep(1.0)
continue # Keep reading to see if workspace/configuration comes
if msg.get("method") == "workspace/configuration":
print(f" >>> GOT workspace/configuration REQUEST!")
print(f" >>> Params: {msg.get('params')}")
except asyncio.TimeoutError:
print(" (timeout waiting for more data)")
break
process.terminate()
await process.wait()
print("Done.")
if __name__ == "__main__":
asyncio.run(test_pyright())

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python
"""Minimal test that mimics the working direct test."""
import asyncio
import json
import sys
from pathlib import Path
# Add source to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
async def test_minimal():
"""Minimal test using the standalone manager."""
from codexlens.lsp.standalone_manager import StandaloneLspManager
workspace = Path(__file__).parent.parent.parent
manager = StandaloneLspManager(
workspace_root=str(workspace),
timeout=60.0
)
await manager.start()
# Get server state
server_state = await manager._get_server(str(workspace / "tests" / "real" / "minimal_test.py"))
if not server_state:
print("Failed to get server state")
await manager.stop()
return
print(f"Server initialized: {server_state.initialized}")
print(f"Server capabilities: {list(server_state.capabilities.keys())[:5]}...")
# Wait for any background messages
print("Waiting 5 seconds for background messages...")
await asyncio.sleep(5)
# Now send a documentSymbol request manually
print("Sending documentSymbol request...")
result = await manager._send_request(
server_state,
"textDocument/documentSymbol",
{"textDocument": {"uri": (workspace / "tests" / "real" / "minimal_test.py").resolve().as_uri()}},
timeout=30.0
)
print(f"Result: {result}")
await manager.stop()
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format='%(name)s - %(levelname)s - %(message)s')
asyncio.run(test_minimal())

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python
"""Quick real interface test script for LSP Bridge (Standalone Mode).
Usage:
python tests/real/quick_test.py
Requires: pyright-langserver installed (npm install -g pyright)
"""
import asyncio
import shutil
import sys
from pathlib import Path
# Add source to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from codexlens.lsp.lsp_bridge import LspBridge
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
from codexlens.hybrid_search.data_structures import CodeSymbolNode, Range
# Test file - the LSP bridge source itself
TEST_FILE = Path(__file__).parent.parent.parent / "src" / "codexlens" / "lsp" / "lsp_bridge.py"
WORKSPACE_ROOT = Path(__file__).parent.parent.parent # codex-lens root
def check_pyright():
"""Check if pyright-langserver is available."""
return shutil.which("pyright-langserver") is not None
async def test_get_definition():
"""Test get_definition."""
print("\n" + "=" * 60)
print("TEST: get_definition")
print("=" * 60)
symbol = CodeSymbolNode(
id=f"{TEST_FILE}:LspBridge:96",
name="LspBridge",
kind="class",
file_path=str(TEST_FILE),
range=Range(start_line=96, start_character=6, end_line=96, end_character=15),
)
print(f"Symbol: {symbol.name}")
print(f"File: {symbol.file_path}")
print(f"Position: line {symbol.range.start_line}, char {symbol.range.start_character}")
async with LspBridge(workspace_root=str(WORKSPACE_ROOT), timeout=30.0) as bridge:
result = await bridge.get_definition(symbol)
if result:
print(f"\n[OK] SUCCESS: Definition found at {result.file_path}:{result.line}")
else:
print(f"\n[WARN] No definition found (may be expected for class declaration)")
return result is not None
async def test_get_references():
"""Test get_references."""
print("\n" + "=" * 60)
print("TEST: get_references")
print("=" * 60)
symbol = CodeSymbolNode(
id=f"{TEST_FILE}:get_references:200",
name="get_references",
kind="method",
file_path=str(TEST_FILE),
range=Range(start_line=200, start_character=10, end_line=200, end_character=24),
)
print(f"Symbol: {symbol.name}")
print(f"File: {Path(symbol.file_path).name}")
print(f"Position: line {symbol.range.start_line}")
async with LspBridge(workspace_root=str(WORKSPACE_ROOT), timeout=30.0) as bridge:
refs = await bridge.get_references(symbol)
print(f"\n[OK] Found {len(refs)} references:")
for i, ref in enumerate(refs[:10]):
print(f" [{i+1}] {Path(ref.file_path).name}:{ref.line}")
if len(refs) > 10:
print(f" ... and {len(refs) - 10} more")
return len(refs) >= 0
async def test_get_hover():
"""Test get_hover."""
print("\n" + "=" * 60)
print("TEST: get_hover")
print("=" * 60)
symbol = CodeSymbolNode(
id=f"{TEST_FILE}:LspBridge:96",
name="LspBridge",
kind="class",
file_path=str(TEST_FILE),
range=Range(start_line=96, start_character=6, end_line=96, end_character=15),
)
print(f"Symbol: {symbol.name}")
async with LspBridge(workspace_root=str(WORKSPACE_ROOT), timeout=30.0) as bridge:
hover = await bridge.get_hover(symbol)
if hover:
preview = hover[:300].replace('\n', '\n ')
print(f"\n[OK] Hover info ({len(hover)} chars):")
print(f" {preview}...")
else:
print(f"\n[WARN] No hover info available")
return hover is not None
async def test_get_document_symbols():
"""Test get_document_symbols."""
print("\n" + "=" * 60)
print("TEST: get_document_symbols")
print("=" * 60)
file_path = str(TEST_FILE)
print(f"File: {Path(file_path).name}")
async with LspBridge(workspace_root=str(WORKSPACE_ROOT), timeout=30.0) as bridge:
symbols = await bridge.get_document_symbols(file_path)
print(f"\n[OK] Found {len(symbols)} symbols:")
# Group by kind
by_kind = {}
for sym in symbols:
kind = sym.get("kind", "unknown")
by_kind[kind] = by_kind.get(kind, 0) + 1
for kind, count in sorted(by_kind.items()):
print(f" {kind}: {count}")
print("\nSample symbols:")
for sym in symbols[:15]:
name = sym.get("name", "?")
kind = sym.get("kind", "?")
range_data = sym.get("range", {})
start = range_data.get("start", {})
line = start.get("line", 0) + 1
print(f" - {name} ({kind}) at line {line}")
return len(symbols) > 0
async def test_graph_expansion():
"""Test graph expansion."""
print("\n" + "=" * 60)
print("TEST: Graph Expansion (LspGraphBuilder)")
print("=" * 60)
seed = CodeSymbolNode(
id=f"{TEST_FILE}:LspBridge:96",
name="LspBridge",
kind="class",
file_path=str(TEST_FILE),
range=Range(start_line=96, start_character=6, end_line=96, end_character=15),
)
print(f"Seed: {seed.name} in {Path(seed.file_path).name}:{seed.range.start_line}")
print("Settings: max_depth=1, max_nodes=20")
builder = LspGraphBuilder(max_depth=1, max_nodes=20)
async with LspBridge(workspace_root=str(WORKSPACE_ROOT), timeout=30.0) as bridge:
graph = await builder.build_from_seeds([seed], bridge)
print(f"\n[OK] Graph expansion complete:")
print(f" Nodes: {len(graph.nodes)}")
print(f" Edges: {len(graph.edges)}")
if graph.nodes:
print("\nNodes found:")
for node_id, node in list(graph.nodes.items())[:15]:
print(f" - {node.name} ({node.kind}) in {Path(node.file_path).name}:{node.range.start_line}")
if graph.edges:
print(f"\nEdges (first 10):")
for edge in list(graph.edges)[:10]:
src = graph.nodes.get(edge.source_id)
tgt = graph.nodes.get(edge.target_id)
src_name = src.name if src else edge.source_id[:20]
tgt_name = tgt.name if tgt else edge.target_id[:20]
print(f" - {src_name} --[{edge.relation}]--> {tgt_name}")
return len(graph.nodes) >= 1
async def test_cache_performance():
"""Test cache performance."""
print("\n" + "=" * 60)
print("TEST: Cache Performance")
print("=" * 60)
symbol = CodeSymbolNode(
id=f"{TEST_FILE}:LspBridge:96",
name="LspBridge",
kind="class",
file_path=str(TEST_FILE),
range=Range(start_line=96, start_character=6, end_line=96, end_character=15),
)
import time
async with LspBridge(workspace_root=str(WORKSPACE_ROOT), timeout=30.0) as bridge:
# First call - cache miss
start = time.perf_counter()
await bridge.get_references(symbol)
first_time = (time.perf_counter() - start) * 1000
# Second call - cache hit
start = time.perf_counter()
await bridge.get_references(symbol)
second_time = (time.perf_counter() - start) * 1000
print(f"\nFirst call (cache miss): {first_time:.2f}ms")
print(f"Second call (cache hit): {second_time:.2f}ms")
print(f"Speedup: {first_time/max(second_time, 0.001):.1f}x")
print(f"Cache entries: {len(bridge.cache)}")
if second_time < first_time:
print("\n[OK] Cache is working correctly")
else:
print("\n[WARN] Cache may not be effective")
return second_time < first_time
async def run_all_tests():
"""Run all tests."""
print("=" * 60)
print("CODEX-LENS LSP REAL INTERFACE TESTS (Standalone Mode)")
print("=" * 60)
print(f"Test file: {TEST_FILE}")
print(f"Workspace: {WORKSPACE_ROOT}")
print(f"Mode: Standalone (direct language server communication)")
results = {}
tests = [
("get_definition", test_get_definition),
("get_references", test_get_references),
("get_hover", test_get_hover),
("get_document_symbols", test_get_document_symbols),
("graph_expansion", test_graph_expansion),
("cache_performance", test_cache_performance),
]
for name, test_fn in tests:
try:
results[name] = await test_fn()
except Exception as e:
print(f"\n[FAIL] FAILED: {e}")
import traceback
traceback.print_exc()
results[name] = False
# Summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
passed = sum(1 for v in results.values() if v)
total = len(results)
for name, result in results.items():
status = "[PASS]" if result else "[FAIL]"
print(f" {status}: {name}")
print(f"\nResult: {passed}/{total} tests passed")
return passed == total
def main():
"""Main entry point."""
print("Checking pyright-langserver availability...")
if not check_pyright():
print("\n" + "=" * 60)
print("ERROR: pyright-langserver not available")
print("=" * 60)
print()
print("To run these tests:")
print(" 1. Install pyright: npm install -g pyright")
print(" 2. Verify: pyright-langserver --version")
print(" 3. Run this script again")
print()
sys.exit(1)
print("[OK] pyright-langserver is available!")
print()
# Run tests
# Note: On Windows, we use the default ProactorEventLoop (not SelectorEventLoop)
# because ProactorEventLoop supports subprocess creation which is required for LSP
success = asyncio.run(run_all_tests())
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,424 @@
"""Real interface tests for LSP Bridge using Standalone Mode.
These tests require:
1. Language servers installed (pyright-langserver, typescript-language-server)
2. A Python/TypeScript project in the workspace
Run with: pytest tests/real/ -v -s
"""
import asyncio
import os
import sys
import pytest
from pathlib import Path
# Add source to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from codexlens.lsp.lsp_bridge import LspBridge, Location, HAS_AIOHTTP
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
from codexlens.hybrid_search.data_structures import CodeSymbolNode, Range
# Test configuration - adjust these paths to match your setup
TEST_PYTHON_FILE = Path(__file__).parent.parent.parent / "src" / "codexlens" / "lsp" / "lsp_bridge.py"
TEST_TYPESCRIPT_FILE = Path(__file__).parent.parent.parent.parent / "ccw-vscode-bridge" / "src" / "extension.ts"
WORKSPACE_ROOT = Path(__file__).parent.parent.parent # codex-lens root
def is_pyright_available() -> bool:
"""Check if pyright-langserver is installed."""
import shutil
return shutil.which("pyright-langserver") is not None
def is_typescript_server_available() -> bool:
"""Check if typescript-language-server is installed."""
import shutil
return shutil.which("typescript-language-server") is not None
# Skip all tests if pyright not available
pytestmark = pytest.mark.skipif(
not is_pyright_available(),
reason="pyright-langserver not installed. Install with: npm install -g pyright"
)
class TestRealLspBridgeStandalone:
"""Real interface tests for LspBridge in Standalone Mode."""
@pytest.fixture
def bridge(self):
"""Create real LspBridge instance in standalone mode."""
return LspBridge(
workspace_root=str(WORKSPACE_ROOT),
timeout=30.0,
use_vscode_bridge=False, # Use standalone mode
)
@pytest.fixture
def python_symbol(self):
"""Create a symbol pointing to LspBridge class."""
return CodeSymbolNode(
id=f"{TEST_PYTHON_FILE}:LspBridge:96",
name="LspBridge",
kind="class",
file_path=str(TEST_PYTHON_FILE),
range=Range(start_line=96, start_character=6, end_line=96, end_character=15),
)
@pytest.fixture
def python_method_symbol(self):
"""Create a symbol pointing to get_references method."""
return CodeSymbolNode(
id=f"{TEST_PYTHON_FILE}:get_references:200",
name="get_references",
kind="method",
file_path=str(TEST_PYTHON_FILE),
range=Range(start_line=200, start_character=10, end_line=200, end_character=24),
)
@pytest.mark.asyncio
async def test_real_get_definition(self, bridge, python_symbol):
"""Test get_definition against real Python file."""
print(f"\n>>> Testing get_definition for {python_symbol.name}")
print(f" File: {python_symbol.file_path}")
print(f" Position: line {python_symbol.range.start_line}, char {python_symbol.range.start_character}")
async with bridge:
definition = await bridge.get_definition(python_symbol)
print(f" Result: {definition}")
# Definition should exist (class definition)
if definition:
print(f" ✓ Found definition at {definition.file_path}:{definition.line}")
assert definition.file_path.endswith(".py")
assert definition.line > 0
else:
print(" ⚠ No definition found (may be expected for class declarations)")
@pytest.mark.asyncio
async def test_real_get_references(self, bridge, python_method_symbol):
"""Test get_references against real Python file."""
print(f"\n>>> Testing get_references for {python_method_symbol.name}")
print(f" File: {python_method_symbol.file_path}")
print(f" Position: line {python_method_symbol.range.start_line}")
async with bridge:
refs = await bridge.get_references(python_method_symbol)
print(f" Found {len(refs)} references:")
for i, ref in enumerate(refs[:5]): # Show first 5
print(f" [{i+1}] {Path(ref.file_path).name}:{ref.line}")
if len(refs) > 5:
print(f" ... and {len(refs) - 5} more")
# Should find at least the definition itself
assert len(refs) >= 0, "References query should succeed (may be empty)"
@pytest.mark.asyncio
async def test_real_get_hover(self, bridge, python_symbol):
"""Test get_hover against real Python file."""
print(f"\n>>> Testing get_hover for {python_symbol.name}")
async with bridge:
hover = await bridge.get_hover(python_symbol)
if hover:
print(f" ✓ Hover info ({len(hover)} chars):")
preview = hover[:200].replace('\n', '\\n')
print(f" {preview}...")
assert len(hover) > 0
else:
print(" ⚠ No hover info available")
@pytest.mark.asyncio
async def test_real_get_document_symbols(self, bridge):
"""Test get_document_symbols against real Python file."""
file_path = str(TEST_PYTHON_FILE)
print(f"\n>>> Testing get_document_symbols")
print(f" File: {file_path}")
async with bridge:
symbols = await bridge.get_document_symbols(file_path)
print(f" Found {len(symbols)} symbols:")
# Group by kind
by_kind = {}
for sym in symbols:
kind = sym.get("kind", "unknown")
by_kind[kind] = by_kind.get(kind, 0) + 1
for kind, count in sorted(by_kind.items()):
print(f" {kind}: {count}")
# Show some sample symbols
print(" Sample symbols:")
for sym in symbols[:10]:
name = sym.get("name", "?")
kind = sym.get("kind", "?")
range_data = sym.get("range", {})
start = range_data.get("start", {})
line = start.get("line", 0) + 1
print(f" - {name} ({kind}) at line {line}")
assert len(symbols) > 0, "Should find symbols in Python file"
@pytest.mark.asyncio
async def test_real_get_call_hierarchy(self, bridge, python_method_symbol):
"""Test get_call_hierarchy against real Python file."""
print(f"\n>>> Testing get_call_hierarchy for {python_method_symbol.name}")
async with bridge:
calls = await bridge.get_call_hierarchy(python_method_symbol)
print(f" Found {len(calls)} call hierarchy items:")
for i, call in enumerate(calls[:10]):
print(f" [{i+1}] {call.name} in {Path(call.file_path).name}:{call.range.start_line}")
# May be empty if call hierarchy not supported or no callers
print(f" ✓ Call hierarchy query completed")
@pytest.mark.asyncio
async def test_real_cache_behavior(self, bridge, python_symbol):
"""Test that cache actually works with real requests."""
print(f"\n>>> Testing cache behavior")
async with bridge:
# First call - should hit language server
print(" First call (cache miss expected)...")
refs1 = await bridge.get_references(python_symbol)
cache_size_after_first = len(bridge.cache)
print(f" Cache size after first call: {cache_size_after_first}")
# Second call - should hit cache
print(" Second call (cache hit expected)...")
refs2 = await bridge.get_references(python_symbol)
cache_size_after_second = len(bridge.cache)
print(f" Cache size after second call: {cache_size_after_second}")
assert cache_size_after_first > 0, "Cache should have entries after first call"
assert cache_size_after_second == cache_size_after_first, "Cache size should not change on hit"
assert refs1 == refs2, "Results should be identical"
print(" ✓ Cache working correctly")
class TestRealLspGraphBuilderStandalone:
"""Real interface tests for LspGraphBuilder with Standalone Mode."""
@pytest.fixture
def seed_node(self):
"""Create a seed node for graph expansion."""
return CodeSymbolNode(
id=f"{TEST_PYTHON_FILE}:LspBridge:96",
name="LspBridge",
kind="class",
file_path=str(TEST_PYTHON_FILE),
range=Range(start_line=96, start_character=6, end_line=96, end_character=15),
)
@pytest.mark.asyncio
async def test_real_graph_expansion(self, seed_node):
"""Test real graph expansion from a Python class."""
print(f"\n>>> Testing graph expansion from {seed_node.name}")
print(f" Seed: {seed_node.file_path}:{seed_node.range.start_line}")
builder = LspGraphBuilder(max_depth=1, max_nodes=20)
async with LspBridge(
workspace_root=str(WORKSPACE_ROOT),
timeout=30.0,
) as bridge:
graph = await builder.build_from_seeds([seed_node], bridge)
print(f" Graph results:")
print(f" Nodes: {len(graph.nodes)}")
print(f" Edges: {len(graph.edges)}")
if graph.nodes:
print(f" Node details:")
for node_id, node in list(graph.nodes.items())[:10]:
print(f" - {node.name} ({node.kind}) in {Path(node.file_path).name}:{node.range.start_line}")
if graph.edges:
print(f" Edge details:")
for edge in list(graph.edges)[:10]:
print(f" - {edge.source_id[:30]}... --[{edge.relation}]--> {edge.target_id[:30]}...")
# We should have at least the seed node
assert len(graph.nodes) >= 1, "Graph should contain at least the seed node"
print(" ✓ Graph expansion completed")
@pytest.mark.asyncio
async def test_real_multi_seed_expansion(self):
"""Test graph expansion from multiple seeds."""
print(f"\n>>> Testing multi-seed graph expansion")
seeds = [
CodeSymbolNode(
id=f"{TEST_PYTHON_FILE}:Location:35",
name="Location",
kind="class",
file_path=str(TEST_PYTHON_FILE),
range=Range(start_line=35, start_character=6, end_line=35, end_character=14),
),
CodeSymbolNode(
id=f"{TEST_PYTHON_FILE}:CacheEntry:81",
name="CacheEntry",
kind="class",
file_path=str(TEST_PYTHON_FILE),
range=Range(start_line=81, start_character=6, end_line=81, end_character=16),
),
]
print(f" Seeds: {[s.name for s in seeds]}")
builder = LspGraphBuilder(max_depth=1, max_nodes=30)
async with LspBridge(
workspace_root=str(WORKSPACE_ROOT),
timeout=30.0,
) as bridge:
graph = await builder.build_from_seeds(seeds, bridge)
print(f" Graph results:")
print(f" Nodes: {len(graph.nodes)}")
print(f" Edges: {len(graph.edges)}")
# Should have at least the seed nodes
assert len(graph.nodes) >= len(seeds), f"Graph should contain at least {len(seeds)} seed nodes"
print(" ✓ Multi-seed expansion completed")
class TestRealHybridSearchIntegrationStandalone:
"""Real integration tests with HybridSearchEngine."""
@pytest.mark.asyncio
async def test_real_lsp_search_pipeline(self):
"""Test the full LSP search pipeline with real LSP."""
print(f"\n>>> Testing full LSP search pipeline")
# Create mock seeds (normally from vector/splade search)
seeds = [
CodeSymbolNode(
id=f"{TEST_PYTHON_FILE}:LspBridge:96",
name="LspBridge",
kind="class",
file_path=str(TEST_PYTHON_FILE),
range=Range(start_line=96, start_character=6, end_line=96, end_character=15),
),
]
print(f" Starting with {len(seeds)} seed(s)")
builder = LspGraphBuilder(max_depth=2, max_nodes=50)
async with LspBridge(
workspace_root=str(WORKSPACE_ROOT),
timeout=30.0,
) as bridge:
graph = await builder.build_from_seeds(seeds, bridge)
print(f" Expanded to {len(graph.nodes)} nodes")
# Simulate conversion to SearchResult format
results = []
for node_id, node in graph.nodes.items():
if node.id not in [s.id for s in seeds]: # Exclude seeds
results.append({
"path": node.file_path,
"symbol_name": node.name,
"symbol_kind": node.kind,
"start_line": node.range.start_line,
"end_line": node.range.end_line,
})
print(f" Generated {len(results)} search results (excluding seeds)")
if results:
print(" Sample results:")
for r in results[:5]:
print(f" - {r['symbol_name']} ({r['symbol_kind']}) at {Path(r['path']).name}:{r['start_line']}")
print(" ✓ Full pipeline completed")
# TypeScript tests (if available)
@pytest.mark.skipif(
not is_typescript_server_available() or not TEST_TYPESCRIPT_FILE.exists(),
reason="TypeScript language server or test file not available"
)
class TestRealTypescriptLspStandalone:
"""Real tests against TypeScript files."""
@pytest.fixture
def ts_symbol(self):
"""Create a symbol in the TypeScript extension file."""
return CodeSymbolNode(
id=f"{TEST_TYPESCRIPT_FILE}:activate:12",
name="activate",
kind="function",
file_path=str(TEST_TYPESCRIPT_FILE),
range=Range(start_line=12, start_character=16, end_line=12, end_character=24),
)
@pytest.mark.asyncio
async def test_real_typescript_definition(self, ts_symbol):
"""Test LSP definition lookup in TypeScript."""
print(f"\n>>> Testing TypeScript definition for {ts_symbol.name}")
async with LspBridge(
workspace_root=str(TEST_TYPESCRIPT_FILE.parent.parent),
timeout=30.0,
) as bridge:
definition = await bridge.get_definition(ts_symbol)
if definition:
print(f" ✓ Found: {definition.file_path}:{definition.line}")
else:
print(" ⚠ No definition found (TypeScript LSP may not be active)")
@pytest.mark.asyncio
async def test_real_typescript_document_symbols(self):
"""Test document symbols in TypeScript."""
print(f"\n>>> Testing TypeScript document symbols")
async with LspBridge(
workspace_root=str(TEST_TYPESCRIPT_FILE.parent.parent),
timeout=30.0,
) as bridge:
symbols = await bridge.get_document_symbols(str(TEST_TYPESCRIPT_FILE))
print(f" Found {len(symbols)} symbols")
for sym in symbols[:5]:
print(f" - {sym.get('name')} ({sym.get('kind')})")
# TypeScript files should have symbols
if symbols:
print(" ✓ TypeScript symbols retrieved")
else:
print(" ⚠ No symbols found (TypeScript LSP may not be active)")
if __name__ == "__main__":
# Allow running directly
if is_pyright_available():
print("Pyright language server is available")
print("Running tests...")
pytest.main([__file__, "-v", "-s"])
else:
print("=" * 60)
print("Pyright language server NOT available")
print("=" * 60)
print()
print("To run these tests:")
print("1. Install pyright: npm install -g pyright")
print("2. Install typescript-language-server: npm install -g typescript-language-server")
print("3. Run: pytest tests/real/ -v -s")
print()
sys.exit(1)

View File

@@ -0,0 +1 @@
# Unit tests package

View File

@@ -0,0 +1 @@
# LSP unit tests package

View File

@@ -0,0 +1,879 @@
"""Unit tests for LspBridge service (VSCode Bridge HTTP mode).
This module provides comprehensive tests for the LspBridge class when used
in VSCode Bridge HTTP mode (use_vscode_bridge=True). These tests mock
aiohttp HTTP communication with the VSCode Bridge extension.
Test coverage:
- P0 (Critical): Success/failure scenarios for core methods
- P1 (Important): Cache hit/miss and invalidation logic
- P2 (Supplementary): Edge cases and error handling
Note: For standalone mode tests (direct language server communication),
see tests/real/ directory.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Skip all tests if aiohttp is not available
pytest.importorskip("aiohttp")
import aiohttp
from codexlens.hybrid_search.data_structures import (
CallHierarchyItem,
CodeSymbolNode,
Range,
)
from codexlens.lsp.lsp_bridge import (
CacheEntry,
Location,
LspBridge,
)
# -----------------------------------------------------------------------------
# Fixtures
# -----------------------------------------------------------------------------
@pytest.fixture
def sample_symbol() -> CodeSymbolNode:
"""Create a sample CodeSymbolNode for testing.
Returns:
CodeSymbolNode with typical function symbol data.
"""
return CodeSymbolNode(
id="test.py:test_func:10",
name="test_func",
kind="function",
file_path="/path/to/test.py",
range=Range(
start_line=10,
start_character=1,
end_line=20,
end_character=1,
),
)
@pytest.fixture
def mock_response() -> AsyncMock:
"""Create a mock aiohttp response with configurable attributes.
Returns:
AsyncMock configured as aiohttp ClientResponse.
"""
response = AsyncMock()
response.status = 200
response.json = AsyncMock(return_value={"success": True, "result": []})
return response
@pytest.fixture
def mock_session(mock_response: AsyncMock) -> AsyncMock:
"""Create a mock aiohttp ClientSession.
Args:
mock_response: The mock response to return from post().
Returns:
AsyncMock configured as aiohttp ClientSession with async context manager.
"""
session = AsyncMock(spec=aiohttp.ClientSession)
# Configure post() to return context manager with response
post_cm = AsyncMock()
post_cm.__aenter__ = AsyncMock(return_value=mock_response)
post_cm.__aexit__ = AsyncMock(return_value=None)
session.post = MagicMock(return_value=post_cm)
session.closed = False
return session
@pytest.fixture
def lsp_bridge() -> LspBridge:
"""Create a fresh LspBridge instance for testing in VSCode Bridge mode.
Returns:
LspBridge with use_vscode_bridge=True for HTTP-based tests.
"""
return LspBridge(use_vscode_bridge=True)
# -----------------------------------------------------------------------------
# Location Tests
# -----------------------------------------------------------------------------
class TestLocation:
"""Tests for the Location dataclass."""
def test_to_dict(self):
"""Location.to_dict() returns correct dictionary format."""
loc = Location(file_path="/test/file.py", line=10, character=5)
result = loc.to_dict()
assert result == {
"file_path": "/test/file.py",
"line": 10,
"character": 5,
}
def test_from_lsp_response_with_range(self):
"""Location.from_lsp_response() parses LSP range format correctly."""
data = {
"uri": "file:///test/file.py",
"range": {
"start": {"line": 9, "character": 4}, # 0-based
"end": {"line": 15, "character": 0},
},
}
loc = Location.from_lsp_response(data)
assert loc.file_path == "/test/file.py"
assert loc.line == 10 # Converted to 1-based
assert loc.character == 5 # Converted to 1-based
def test_from_lsp_response_direct_fields(self):
"""Location.from_lsp_response() handles direct line/character fields."""
data = {
"file_path": "/direct/path.py",
"line": 25,
"character": 8,
}
loc = Location.from_lsp_response(data)
assert loc.file_path == "/direct/path.py"
assert loc.line == 25
assert loc.character == 8
class TestLocationFromVscodeUri:
"""Tests for parsing VSCode URI formats (P2 test case)."""
@pytest.mark.parametrize(
"uri,expected_path",
[
# Unix-style paths
("file:///home/user/project/file.py", "/home/user/project/file.py"),
("file:///usr/local/lib.py", "/usr/local/lib.py"),
# Windows-style paths
("file:///C:/Users/dev/project/file.py", "C:/Users/dev/project/file.py"),
("file:///D:/code/test.ts", "D:/code/test.ts"),
# Already plain path
("/plain/path/file.py", "/plain/path/file.py"),
# Edge case: file:// without third slash
("file://shared/network/file.py", "shared/network/file.py"),
],
)
def test_location_from_vscode_uri(self, uri: str, expected_path: str):
"""Test correct parsing of various VSCode URI formats to OS paths.
Verifies that file:///C:/path format on Windows and file:///path
format on Unix are correctly converted to native OS paths.
"""
data = {
"uri": uri,
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
}
loc = Location.from_lsp_response(data)
assert loc.file_path == expected_path
# -----------------------------------------------------------------------------
# P0 Critical Tests
# -----------------------------------------------------------------------------
class TestGetReferencesSuccess:
"""P0: Test successful get_references scenarios."""
@pytest.mark.asyncio
async def test_get_references_success(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test get_references returns Location list and caches result.
Mock session returns 200 OK with valid LSP location list.
Verifies:
- Returns list of Location objects
- Results are stored in cache
"""
# Setup mock response with valid locations
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
{
"uri": "file:///ref1.py",
"range": {"start": {"line": 5, "character": 0}, "end": {"line": 5, "character": 10}},
},
{
"uri": "file:///ref2.py",
"range": {"start": {"line": 15, "character": 4}, "end": {"line": 15, "character": 14}},
},
],
})
# Inject mock session
lsp_bridge._session = mock_session
# Execute
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
refs = await lsp_bridge.get_references(sample_symbol)
# Verify results
assert len(refs) == 2
assert isinstance(refs[0], Location)
assert refs[0].file_path == "/ref1.py"
assert refs[0].line == 6 # 0-based to 1-based
assert refs[1].file_path == "/ref2.py"
assert refs[1].line == 16
# Verify cached
cache_key = f"refs:{sample_symbol.id}"
assert cache_key in lsp_bridge.cache
assert lsp_bridge.cache[cache_key].data == refs
class TestGetReferencesBridgeNotRunning:
"""P0: Test get_references when bridge is not running."""
@pytest.mark.asyncio
async def test_get_references_bridge_not_running(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
):
"""Test get_references returns empty list on ClientConnectorError.
When VSCode Bridge is not running, aiohttp raises ClientConnectorError.
Verifies:
- Returns empty list []
- No cache entry is created
"""
# Setup mock session that raises connection error
mock_session = AsyncMock(spec=aiohttp.ClientSession)
mock_session.closed = False
mock_session.post = MagicMock(side_effect=aiohttp.ClientConnectorError(
connection_key=MagicMock(),
os_error=OSError("Connection refused"),
))
lsp_bridge._session = mock_session
# Execute
refs = await lsp_bridge.get_references(sample_symbol)
# Verify
assert refs == []
cache_key = f"refs:{sample_symbol.id}"
assert cache_key not in lsp_bridge.cache
class TestGetReferencesTimeout:
"""P0: Test get_references timeout handling."""
@pytest.mark.asyncio
async def test_get_references_timeout(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
):
"""Test get_references returns empty list on asyncio.TimeoutError.
When request times out, should gracefully return empty list.
"""
# Setup mock session that raises timeout
mock_session = AsyncMock(spec=aiohttp.ClientSession)
mock_session.closed = False
async def raise_timeout(*args, **kwargs):
raise asyncio.TimeoutError()
post_cm = AsyncMock()
post_cm.__aenter__ = raise_timeout
post_cm.__aexit__ = AsyncMock(return_value=None)
mock_session.post = MagicMock(return_value=post_cm)
lsp_bridge._session = mock_session
# Execute
refs = await lsp_bridge.get_references(sample_symbol)
# Verify
assert refs == []
class TestCallHierarchyFallback:
"""P0: Test call_hierarchy fallback to references."""
@pytest.mark.asyncio
async def test_call_hierarchy_fallback_to_references(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
):
"""Test get_call_hierarchy falls back to get_references when not supported.
When call_hierarchy request returns None (not supported by language server),
verifies:
- Falls back to calling get_references
- Returns converted CallHierarchyItem list
"""
call_count = 0
async def mock_json():
nonlocal call_count
call_count += 1
if call_count == 1:
# First call is get_call_hierarchy - return failure
return {"success": False}
else:
# Second call is get_references - return valid refs
return {
"success": True,
"result": [
{
"uri": "file:///caller.py",
"range": {"start": {"line": 10, "character": 5}, "end": {"line": 10, "character": 15}},
},
],
}
# Setup mock response
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = mock_json
post_cm = AsyncMock()
post_cm.__aenter__ = AsyncMock(return_value=mock_response)
post_cm.__aexit__ = AsyncMock(return_value=None)
mock_session.post = MagicMock(return_value=post_cm)
lsp_bridge._session = mock_session
# Execute
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
items = await lsp_bridge.get_call_hierarchy(sample_symbol)
# Verify fallback occurred and returned CallHierarchyItem
assert len(items) == 1
assert isinstance(items[0], CallHierarchyItem)
assert items[0].file_path == "/caller.py"
assert items[0].kind == "reference"
assert "Inferred from reference" in items[0].detail
# -----------------------------------------------------------------------------
# P1 Important Tests
# -----------------------------------------------------------------------------
class TestCacheHit:
"""P1: Test cache hit behavior."""
@pytest.mark.asyncio
async def test_cache_hit(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test that same symbol called twice only makes one request.
Verifies:
- _request is only called once
- Second call returns cached result
"""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
{"uri": "file:///ref.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
],
})
lsp_bridge._session = mock_session
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
# First call - should make request
refs1 = await lsp_bridge.get_references(sample_symbol)
# Second call - should use cache
refs2 = await lsp_bridge.get_references(sample_symbol)
# Verify only one HTTP call was made
assert mock_session.post.call_count == 1
# Verify both calls return same data
assert refs1 == refs2
class TestCacheInvalidationTtl:
"""P1: Test cache TTL invalidation."""
@pytest.mark.asyncio
async def test_cache_invalidation_ttl(
self,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test cache entry expires after TTL.
Sets extremely short TTL and verifies:
- Cache entry expires
- New request is made after TTL expires
"""
# Create bridge with very short TTL (VSCode Bridge mode for HTTP tests)
bridge = LspBridge(cache_ttl=1, use_vscode_bridge=True) # 1 second TTL
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
{"uri": "file:///ref.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
],
})
bridge._session = mock_session
with patch.object(bridge, "_get_file_mtime", return_value=1000.0):
# First call
await bridge.get_references(sample_symbol)
assert mock_session.post.call_count == 1
# Wait for TTL to expire
await asyncio.sleep(1.1)
# Second call - should make new request
await bridge.get_references(sample_symbol)
assert mock_session.post.call_count == 2
await bridge.close()
class TestCacheInvalidationFileModified:
"""P1: Test cache invalidation on file modification."""
@pytest.mark.asyncio
async def test_cache_invalidation_file_modified(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test cache entry invalidates when file mtime changes.
Verifies:
- mtime change triggers cache invalidation
- New request is made after file modification
"""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
{"uri": "file:///ref.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
],
})
lsp_bridge._session = mock_session
# Mock mtime: first call returns 1000.0, subsequent calls return 2000.0
# This simulates file being modified between cache store and cache check
call_count = [0]
def get_mtime(path: str) -> float:
call_count[0] += 1
# First call during _cache() stores mtime 1000.0
# Second call during _is_cached() should see different mtime
if call_count[0] <= 1:
return 1000.0
return 2000.0 # File modified
with patch.object(lsp_bridge, "_get_file_mtime", side_effect=get_mtime):
# First call - should make request and cache with mtime 1000.0
await lsp_bridge.get_references(sample_symbol)
assert mock_session.post.call_count == 1
# Second call - mtime check returns 2000.0 (different from cached 1000.0)
# Should invalidate cache and make new request
await lsp_bridge.get_references(sample_symbol)
assert mock_session.post.call_count == 2
# -----------------------------------------------------------------------------
# P2 Supplementary Tests
# -----------------------------------------------------------------------------
class TestResponseParsingInvalidJson:
"""P2: Test handling of malformed JSON responses."""
@pytest.mark.asyncio
async def test_response_parsing_invalid_json(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
):
"""Test graceful handling of malformed JSON response.
Verifies:
- Returns empty list when JSON parsing fails
- Does not raise exception
"""
# Setup mock to raise JSONDecodeError
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(side_effect=Exception("Invalid JSON"))
post_cm = AsyncMock()
post_cm.__aenter__ = AsyncMock(return_value=mock_response)
post_cm.__aexit__ = AsyncMock(return_value=None)
mock_session.post = MagicMock(return_value=post_cm)
lsp_bridge._session = mock_session
# Execute - should not raise
refs = await lsp_bridge.get_references(sample_symbol)
# Verify graceful handling
assert refs == []
@pytest.mark.asyncio
async def test_response_with_malformed_location_items(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test handling of partially malformed location items.
The source code catches KeyError and TypeError when parsing items.
Tests that items causing these specific exceptions are skipped while
valid items are returned.
"""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
# Valid item
{"uri": "file:///valid.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
# Another valid item
{"uri": "file:///valid2.py", "range": {"start": {"line": 5, "character": 0}, "end": {"line": 5, "character": 0}}},
],
})
lsp_bridge._session = mock_session
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
refs = await lsp_bridge.get_references(sample_symbol)
# Should return both valid items
assert len(refs) == 2
assert refs[0].file_path == "/valid.py"
assert refs[1].file_path == "/valid2.py"
@pytest.mark.asyncio
async def test_response_with_empty_result_list(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test handling of empty result list."""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [],
})
lsp_bridge._session = mock_session
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
refs = await lsp_bridge.get_references(sample_symbol)
assert refs == []
class TestLspBridgeContextManager:
"""Test async context manager functionality (VSCode Bridge mode)."""
@pytest.mark.asyncio
async def test_context_manager_closes_session(self):
"""Test that async context manager properly closes session in VSCode Bridge mode."""
async with LspBridge(use_vscode_bridge=True) as bridge:
# Create a session
session = await bridge._get_session()
assert session is not None
assert not session.closed
# After context, session should be closed
assert bridge._session is None or bridge._session.closed
class TestCacheEntry:
"""Test CacheEntry dataclass."""
def test_cache_entry_fields(self):
"""CacheEntry stores all required fields."""
entry = CacheEntry(
data=["some", "data"],
file_mtime=12345.0,
cached_at=time.time(),
)
assert entry.data == ["some", "data"]
assert entry.file_mtime == 12345.0
assert entry.cached_at > 0
class TestLspBridgeCacheLru:
"""Test LRU cache behavior."""
def test_cache_lru_eviction(self):
"""Test that oldest entries are evicted when at max capacity."""
bridge = LspBridge(max_cache_size=3)
# Add entries
bridge._cache("key1", "/file1.py", "data1")
bridge._cache("key2", "/file2.py", "data2")
bridge._cache("key3", "/file3.py", "data3")
assert len(bridge.cache) == 3
# Add one more - should evict oldest (key1)
bridge._cache("key4", "/file4.py", "data4")
assert len(bridge.cache) == 3
assert "key1" not in bridge.cache
assert "key4" in bridge.cache
def test_cache_access_moves_to_end(self):
"""Test that accessing cached item moves it to end (LRU behavior)."""
bridge = LspBridge(max_cache_size=3)
with patch.object(bridge, "_get_file_mtime", return_value=1000.0):
bridge._cache("key1", "/file.py", "data1")
bridge._cache("key2", "/file.py", "data2")
bridge._cache("key3", "/file.py", "data3")
# Access key1 - should move it to end
bridge._is_cached("key1", "/file.py")
# Add key4 - should evict key2 (now oldest)
bridge._cache("key4", "/file.py", "data4")
assert "key1" in bridge.cache
assert "key2" not in bridge.cache
class TestGetHover:
"""Test get_hover method."""
@pytest.mark.asyncio
async def test_get_hover_returns_string(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test get_hover returns hover documentation string."""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": {
"contents": "Function documentation here",
},
})
lsp_bridge._session = mock_session
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
hover = await lsp_bridge.get_hover(sample_symbol)
assert hover == "Function documentation here"
@pytest.mark.asyncio
async def test_get_hover_handles_marked_string_list(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test get_hover handles MarkedString list format."""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
{"value": "```python\ndef func():\n```"},
{"value": "Documentation text"},
],
})
lsp_bridge._session = mock_session
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
hover = await lsp_bridge.get_hover(sample_symbol)
assert "def func()" in hover
assert "Documentation text" in hover
class TestGetDefinition:
"""Test get_definition method."""
@pytest.mark.asyncio
async def test_get_definition_returns_location(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test get_definition returns Location for found definition."""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
{
"uri": "file:///definition.py",
"range": {"start": {"line": 99, "character": 0}, "end": {"line": 110, "character": 0}},
},
],
})
lsp_bridge._session = mock_session
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
definition = await lsp_bridge.get_definition(sample_symbol)
assert definition is not None
assert definition.file_path == "/definition.py"
assert definition.line == 100 # 0-based to 1-based
@pytest.mark.asyncio
async def test_get_definition_returns_none_on_failure(
self,
lsp_bridge: LspBridge,
sample_symbol: CodeSymbolNode,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test get_definition returns None when not found."""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": False,
})
lsp_bridge._session = mock_session
definition = await lsp_bridge.get_definition(sample_symbol)
assert definition is None
class TestGetDocumentSymbols:
"""Test get_document_symbols method."""
@pytest.mark.asyncio
async def test_get_document_symbols_flattens_hierarchy(
self,
lsp_bridge: LspBridge,
mock_session: AsyncMock,
mock_response: AsyncMock,
):
"""Test get_document_symbols flattens nested symbol hierarchy."""
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"success": True,
"result": [
{
"name": "MyClass",
"kind": 5, # Class
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 20, "character": 0}},
"children": [
{
"name": "my_method",
"kind": 6, # Method
"range": {"start": {"line": 5, "character": 4}, "end": {"line": 10, "character": 4}},
},
],
},
],
})
lsp_bridge._session = mock_session
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
symbols = await lsp_bridge.get_document_symbols("/test/file.py")
# Should have both class and method
assert len(symbols) == 2
assert symbols[0]["name"] == "MyClass"
assert symbols[0]["kind"] == "class"
assert symbols[1]["name"] == "my_method"
assert symbols[1]["kind"] == "method"
assert symbols[1]["parent"] == "MyClass"
class TestSymbolKindConversion:
"""Test symbol kind integer to string conversion."""
@pytest.mark.parametrize(
"kind_int,expected_str",
[
(1, "file"),
(5, "class"),
(6, "method"),
(12, "function"),
(13, "variable"),
(999, "unknown"), # Unknown kind
],
)
def test_symbol_kind_to_string(self, kind_int: int, expected_str: str):
"""Test _symbol_kind_to_string converts LSP SymbolKind correctly."""
bridge = LspBridge()
result = bridge._symbol_kind_to_string(kind_int)
assert result == expected_str
class TestClearCache:
"""Test cache clearing functionality."""
def test_clear_cache(self, lsp_bridge: LspBridge):
"""Test clear_cache removes all entries."""
# Add some cache entries
lsp_bridge._cache("key1", "/file.py", "data1")
lsp_bridge._cache("key2", "/file.py", "data2")
assert len(lsp_bridge.cache) == 2
# Clear
lsp_bridge.clear_cache()
assert len(lsp_bridge.cache) == 0

View File

@@ -0,0 +1,777 @@
"""Edge case and exception tests for LSP Bridge and Graph Builder.
This module tests boundary conditions, error handling, and exceptional
scenarios in the LSP communication and graph building components.
Test Categories:
- P1 (Critical): Empty responses, HTTP errors
- P2 (Important): Edge inputs, deep structures, special characters
- P3 (Nice-to-have): Cache eviction, concurrent access, circular refs
Note: Tests for HTTP-based communication use use_vscode_bridge=True mode.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from codexlens.hybrid_search.data_structures import (
CodeAssociationGraph,
CodeSymbolNode,
Range,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def valid_range() -> Range:
"""Create a valid Range for test symbols."""
return Range(
start_line=10,
start_character=0,
end_line=20,
end_character=0,
)
@pytest.fixture
def sample_symbol(valid_range: Range) -> CodeSymbolNode:
"""Create a sample CodeSymbolNode for testing."""
return CodeSymbolNode(
id="test/file.py:test_func:10",
name="test_func",
kind="function",
file_path="test/file.py",
range=valid_range,
)
@pytest.fixture
def symbol_with_empty_path() -> CodeSymbolNode:
"""Create a CodeSymbolNode with empty file_path.
Note: CodeSymbolNode.__post_init__ validates that file_path cannot be empty,
so this fixture tests the case where validation is bypassed or data comes
from external sources that might have empty paths.
"""
# We need to bypass validation for this edge case test
node = object.__new__(CodeSymbolNode)
node.id = "::0"
node.name = "empty"
node.kind = "unknown"
node.file_path = "" # Empty path - edge case
node.range = Range(start_line=0, start_character=0, end_line=0, end_character=0)
node.embedding = None
node.raw_code = ""
node.docstring = ""
node.score = 0.0
return node
@pytest.fixture
def mock_aiohttp_session():
"""Create a mock aiohttp ClientSession."""
session = AsyncMock()
return session
@pytest.fixture
def mock_error_response():
"""Create a mock aiohttp response with HTTP 500 error."""
response = AsyncMock()
response.status = 500
response.json = AsyncMock(return_value={"error": "Internal Server Error"})
return response
@pytest.fixture
def mock_empty_response():
"""Create a mock aiohttp response returning empty list."""
response = AsyncMock()
response.status = 200
response.json = AsyncMock(return_value={"success": True, "result": []})
return response
# ---------------------------------------------------------------------------
# P1 Tests - Critical Edge Cases
# ---------------------------------------------------------------------------
class TestLspReturnsEmptyList:
"""Test handling when LSP returns empty results.
Module: LspGraphBuilder._expand_node
Mock: LspBridge methods return []
Assert: Node marked as visited, no new nodes/edges added, returns []
"""
@pytest.mark.asyncio
async def test_expand_node_with_empty_references(self, sample_symbol: CodeSymbolNode):
"""When LSP returns empty references, node should be visited but no expansion."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
# Create mock LspBridge that returns empty results
mock_bridge = AsyncMock()
mock_bridge.get_references = AsyncMock(return_value=[])
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
builder = LspGraphBuilder(max_depth=2, max_nodes=100)
graph = CodeAssociationGraph()
graph.add_node(sample_symbol)
visited = set()
semaphore = asyncio.Semaphore(10)
# Expand the node
result = await builder._expand_node(
sample_symbol,
depth=0,
graph=graph,
lsp_bridge=mock_bridge,
visited=visited,
semaphore=semaphore,
)
# Assertions
assert sample_symbol.id in visited # Node should be marked as visited
assert result == [] # No new nodes to process
assert len(graph.nodes) == 1 # Only the original seed node
assert len(graph.edges) == 0 # No edges added
@pytest.mark.asyncio
async def test_build_from_seeds_with_empty_lsp_results(self, sample_symbol: CodeSymbolNode):
"""When LSP returns empty for all queries, graph should contain only seeds."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
mock_bridge = AsyncMock()
mock_bridge.get_references = AsyncMock(return_value=[])
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
mock_bridge.get_document_symbols = AsyncMock(return_value=[])
builder = LspGraphBuilder(max_depth=2, max_nodes=100)
# Build graph from seed
graph = await builder.build_from_seeds([sample_symbol], mock_bridge)
# Should only have the seed node
assert len(graph.nodes) == 1
assert sample_symbol.id in graph.nodes
assert len(graph.edges) == 0
@pytest.mark.asyncio
async def test_already_visited_node_returns_empty(self, sample_symbol: CodeSymbolNode):
"""Attempting to expand an already-visited node should return empty immediately."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
mock_bridge = AsyncMock()
# These should not be called since node is already visited
mock_bridge.get_references = AsyncMock(return_value=[])
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
builder = LspGraphBuilder()
graph = CodeAssociationGraph()
graph.add_node(sample_symbol)
visited = {sample_symbol.id} # Already visited
semaphore = asyncio.Semaphore(10)
result = await builder._expand_node(
sample_symbol,
depth=0,
graph=graph,
lsp_bridge=mock_bridge,
visited=visited,
semaphore=semaphore,
)
assert result == []
# Bridge methods should not have been called
mock_bridge.get_references.assert_not_called()
mock_bridge.get_call_hierarchy.assert_not_called()
class TestLspHttpError500:
"""Test handling of HTTP 500 errors from LSP bridge (VSCode Bridge mode).
Module: LspBridge._request_vscode_bridge
Mock: aiohttp response status=500
Assert: Returns None, caller handles as failure
"""
@pytest.mark.asyncio
async def test_request_returns_none_on_500(self):
"""HTTP 500 response should result in None return value."""
from codexlens.lsp.lsp_bridge import LspBridge
# Create bridge in VSCode Bridge mode with mocked session
bridge = LspBridge(use_vscode_bridge=True)
# Mock the session to return 500 error
mock_response = AsyncMock()
mock_response.status = 500
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
with patch.object(bridge, '_get_session', return_value=mock_session):
result = await bridge._request_vscode_bridge("get_references", {"file_path": "test.py"})
assert result is None
@pytest.mark.asyncio
async def test_get_references_returns_empty_on_500(self, sample_symbol: CodeSymbolNode):
"""get_references should return empty list on HTTP 500."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(use_vscode_bridge=True)
# Mock _request_vscode_bridge to return None (simulating HTTP error)
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
result = await bridge.get_references(sample_symbol)
assert result == []
@pytest.mark.asyncio
async def test_get_definition_returns_none_on_500(self, sample_symbol: CodeSymbolNode):
"""get_definition should return None on HTTP 500."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(use_vscode_bridge=True)
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
result = await bridge.get_definition(sample_symbol)
assert result is None
@pytest.mark.asyncio
async def test_get_hover_returns_none_on_500(self, sample_symbol: CodeSymbolNode):
"""get_hover should return None on HTTP 500."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(use_vscode_bridge=True)
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
result = await bridge.get_hover(sample_symbol)
assert result is None
@pytest.mark.asyncio
async def test_graph_builder_handles_lsp_errors_gracefully(self, sample_symbol: CodeSymbolNode):
"""Graph builder should handle LSP errors without crashing."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
mock_bridge = AsyncMock()
# Simulate exceptions from LSP
mock_bridge.get_references = AsyncMock(side_effect=Exception("LSP Error"))
mock_bridge.get_call_hierarchy = AsyncMock(side_effect=Exception("LSP Error"))
builder = LspGraphBuilder()
# Should not raise, should return graph with just the seed
graph = await builder.build_from_seeds([sample_symbol], mock_bridge)
assert len(graph.nodes) == 1
assert sample_symbol.id in graph.nodes
# ---------------------------------------------------------------------------
# P2 Tests - Important Edge Cases
# ---------------------------------------------------------------------------
class TestSymbolWithEmptyFilePath:
"""Test handling of symbols with empty file_path (VSCode Bridge mode).
Module: LspBridge.get_references
Input: CodeSymbolNode with file_path=""
Assert: Does not send request, returns [] early
"""
@pytest.mark.asyncio
async def test_get_references_with_empty_path_symbol(self, symbol_with_empty_path: CodeSymbolNode):
"""get_references with empty file_path should handle gracefully."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(use_vscode_bridge=True)
# Mock _request_vscode_bridge - it should still work but with empty path
mock_result = []
with patch.object(bridge, '_request_vscode_bridge', return_value=mock_result) as mock_req:
result = await bridge.get_references(symbol_with_empty_path)
# Should return empty list
assert result == []
# The request was still made (current implementation doesn't pre-validate)
# This documents current behavior - might want to add validation
@pytest.mark.asyncio
async def test_cache_with_empty_path_symbol(self, symbol_with_empty_path: CodeSymbolNode):
"""Cache operations with empty file_path should not crash."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge()
# Cache should handle empty path (mtime check returns 0.0)
cache_key = f"refs:{symbol_with_empty_path.id}"
bridge._cache(cache_key, "", []) # Empty path
# Should be able to check cache without crashing
is_cached = bridge._is_cached(cache_key, "")
# Note: May or may not be cached depending on mtime behavior
assert isinstance(is_cached, bool)
class TestVeryDeepGraphStructure:
"""Test graph building with very deep reference chains.
Module: LspGraphBuilder.build_from_seeds
Input: max_depth=10
Mock: LspBridge produces long chain of references
Assert: Expansion stops cleanly at max_depth
"""
@pytest.mark.asyncio
async def test_expansion_stops_at_max_depth(self, valid_range: Range):
"""Graph expansion should stop at max_depth."""
from codexlens.lsp.lsp_bridge import Location
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
# Create a chain of symbols: seed -> ref1 -> ref2 -> ... -> refN
max_depth = 3 # Use small depth for testing
def create_mock_refs(symbol: CodeSymbolNode) -> List[Location]:
"""Create a single reference pointing to next in chain."""
depth = int(symbol.id.split(":")[-1]) # Extract depth from ID
if depth >= max_depth + 5: # Chain goes deeper than max_depth
return []
next_depth = depth + 1
return [Location(
file_path=f"test/file_{next_depth}.py",
line=1,
character=0,
)]
mock_bridge = AsyncMock()
mock_bridge.get_references = AsyncMock(side_effect=lambda s: create_mock_refs(s))
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
mock_bridge.get_document_symbols = AsyncMock(return_value=[])
# Seed at depth 0
seed = CodeSymbolNode(
id="test/file_0.py:seed:0",
name="seed",
kind="function",
file_path="test/file_0.py",
range=valid_range,
)
builder = LspGraphBuilder(max_depth=max_depth, max_nodes=100)
graph = await builder.build_from_seeds([seed], mock_bridge)
# Graph should not exceed max_depth + 1 nodes (seed + max_depth levels)
# Actual count depends on how references are resolved
assert len(graph.nodes) <= max_depth + 2 # Some tolerance for edge cases
@pytest.mark.asyncio
async def test_expansion_stops_at_max_nodes(self, valid_range: Range):
"""Graph expansion should stop when max_nodes is reached."""
from codexlens.lsp.lsp_bridge import Location
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
call_count = [0]
def create_many_refs(symbol: CodeSymbolNode) -> List[Location]:
"""Create multiple references to generate many nodes."""
call_count[0] += 1
# Return multiple refs to rapidly grow the graph
return [
Location(file_path=f"test/ref_{call_count[0]}_{i}.py", line=1, character=0)
for i in range(5)
]
mock_bridge = AsyncMock()
mock_bridge.get_references = AsyncMock(side_effect=create_many_refs)
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
mock_bridge.get_document_symbols = AsyncMock(return_value=[])
seed = CodeSymbolNode(
id="test/seed.py:seed:0",
name="seed",
kind="function",
file_path="test/seed.py",
range=valid_range,
)
max_nodes = 10
builder = LspGraphBuilder(max_depth=100, max_nodes=max_nodes) # High depth, low nodes
graph = await builder.build_from_seeds([seed], mock_bridge)
# Graph should not exceed max_nodes
assert len(graph.nodes) <= max_nodes
class TestNodeIdWithSpecialCharacters:
"""Test node ID creation with special characters.
Module: LspGraphBuilder._create_node_id
Input: file_path="a/b/c", name="<init>", line=10
Assert: ID successfully created as "a/b/c:<init>:10"
"""
def test_create_node_id_with_special_name(self):
"""Node ID should handle special characters in name."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
builder = LspGraphBuilder()
# Test with angle brackets (common in Java/Kotlin constructors)
node_id = builder._create_node_id("a/b/c", "<init>", 10)
assert node_id == "a/b/c:<init>:10"
# Test with other special characters
node_id = builder._create_node_id("src/file.py", "__init__", 1)
assert node_id == "src/file.py:__init__:1"
# Test with spaces (should preserve as-is)
node_id = builder._create_node_id("my path/file.ts", "my func", 5)
assert node_id == "my path/file.ts:my func:5"
def test_create_node_id_with_windows_path(self):
"""Node ID should handle Windows-style paths."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
builder = LspGraphBuilder()
# Windows path with backslashes
node_id = builder._create_node_id("C:\\Users\\test\\file.py", "main", 1)
assert "main" in node_id
assert "1" in node_id
def test_create_node_id_with_unicode(self):
"""Node ID should handle unicode characters."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
builder = LspGraphBuilder()
# Unicode in name
node_id = builder._create_node_id("src/file.py", "func_name", 10)
assert node_id == "src/file.py:func_name:10"
def test_code_symbol_node_id_format(self):
"""CodeSymbolNode.create_id should match LspGraphBuilder format."""
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
builder = LspGraphBuilder()
# Both should produce the same format
builder_id = builder._create_node_id("path/file.py", "func", 10)
symbol_id = CodeSymbolNode.create_id("path/file.py", "func", 10)
assert builder_id == symbol_id
# ---------------------------------------------------------------------------
# P3 Tests - Additional Edge Cases (if time allows)
# ---------------------------------------------------------------------------
class TestCacheLruEviction:
"""Test LRU cache eviction behavior.
Module: LspBridge._cache
Input: max_cache_size=3, add 5 entries
Assert: Only most recent 3 entries remain
"""
def test_cache_evicts_oldest_entries(self):
"""Cache should evict oldest entries when at capacity."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(max_cache_size=3)
# Add 5 entries (exceeds max of 3)
for i in range(5):
bridge._cache(f"key_{i}", "test.py", f"data_{i}")
# Should only have 3 entries
assert len(bridge.cache) == 3
# Oldest entries (key_0, key_1) should be evicted
assert "key_0" not in bridge.cache
assert "key_1" not in bridge.cache
# Newest entries should remain
assert "key_2" in bridge.cache
assert "key_3" in bridge.cache
assert "key_4" in bridge.cache
def test_cache_moves_accessed_entry_to_end(self):
"""Accessing a cached entry should move it to end (LRU behavior)."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(max_cache_size=3)
# Add 3 entries
bridge._cache("key_0", "test.py", "data_0")
bridge._cache("key_1", "test.py", "data_1")
bridge._cache("key_2", "test.py", "data_2")
# Access key_0 (should move to end)
with patch.object(bridge, '_get_file_mtime', return_value=0.0):
bridge._is_cached("key_0", "test.py")
# Add new entry - key_1 should be evicted (was least recently used)
bridge._cache("key_3", "test.py", "data_3")
assert len(bridge.cache) == 3
assert "key_0" in bridge.cache # Was accessed, moved to end
assert "key_1" not in bridge.cache # Was evicted
assert "key_2" in bridge.cache
assert "key_3" in bridge.cache
class TestConcurrentCacheAccess:
"""Test thread-safety of cache operations.
Module: LspBridge
Test: Multiple concurrent requests access/update cache
Assert: No race conditions, cache remains consistent
"""
@pytest.mark.asyncio
async def test_concurrent_cache_operations(self, valid_range: Range):
"""Multiple concurrent requests should not corrupt cache."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(max_cache_size=100)
async def cache_operation(i: int) -> None:
"""Simulate a cache read/write operation."""
key = f"key_{i % 10}" # Reuse keys to create contention
file_path = f"file_{i}.py"
# Check cache
bridge._is_cached(key, file_path)
# Small delay to increase contention likelihood
await asyncio.sleep(0.001)
# Write to cache
bridge._cache(key, file_path, f"data_{i}")
# Run many concurrent operations
tasks = [cache_operation(i) for i in range(50)]
await asyncio.gather(*tasks)
# Cache should be in consistent state
assert len(bridge.cache) <= bridge.max_cache_size
# All entries should be valid CacheEntry objects
for key, entry in bridge.cache.items():
assert hasattr(entry, 'data')
assert hasattr(entry, 'cached_at')
assert hasattr(entry, 'file_mtime')
class TestGraphWithCircularReferences:
"""Test graph handling of circular reference patterns.
Module: LspGraphBuilder
Mock: A -> B -> C -> A circular reference
Assert: visited set prevents infinite loop
"""
@pytest.mark.asyncio
async def test_circular_references_do_not_loop_infinitely(self, valid_range: Range):
"""Circular references should not cause infinite loops."""
from codexlens.lsp.lsp_bridge import Location
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
# Create circular reference pattern: A -> B -> C -> A
symbol_a = CodeSymbolNode(
id="file.py:A:1", name="A", kind="function",
file_path="file.py", range=valid_range,
)
symbol_b = CodeSymbolNode(
id="file.py:B:10", name="B", kind="function",
file_path="file.py", range=valid_range,
)
symbol_c = CodeSymbolNode(
id="file.py:C:20", name="C", kind="function",
file_path="file.py", range=valid_range,
)
ref_map = {
"file.py:A:1": [Location(file_path="file.py", line=10, character=0)], # A -> B
"file.py:B:10": [Location(file_path="file.py", line=20, character=0)], # B -> C
"file.py:C:20": [Location(file_path="file.py", line=1, character=0)], # C -> A (circular)
}
def get_refs(symbol: CodeSymbolNode) -> List[Location]:
return ref_map.get(symbol.id, [])
mock_bridge = AsyncMock()
mock_bridge.get_references = AsyncMock(side_effect=get_refs)
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
mock_bridge.get_document_symbols = AsyncMock(return_value=[
{"name": "A", "kind": 12, "range": {"start": {"line": 0}, "end": {"line": 5}}},
{"name": "B", "kind": 12, "range": {"start": {"line": 9}, "end": {"line": 15}}},
{"name": "C", "kind": 12, "range": {"start": {"line": 19}, "end": {"line": 25}}},
])
builder = LspGraphBuilder(max_depth=10, max_nodes=100)
# This should complete without hanging
graph = await asyncio.wait_for(
builder.build_from_seeds([symbol_a], mock_bridge),
timeout=5.0 # Should complete quickly, timeout is just safety
)
# Graph should contain the nodes without duplicates
assert len(graph.nodes) >= 1 # At least the seed
# No infinite loop occurred (we reached this point)
class TestRequestTimeoutHandling:
"""Test timeout handling in LSP requests (VSCode Bridge mode)."""
@pytest.mark.asyncio
async def test_timeout_returns_none(self, sample_symbol: CodeSymbolNode):
"""Request timeout should return None gracefully."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(timeout=0.001, use_vscode_bridge=True) # Very short timeout
# Mock session to raise TimeoutError
mock_response = AsyncMock()
mock_response.__aenter__ = AsyncMock(side_effect=asyncio.TimeoutError())
mock_response.__aexit__ = AsyncMock(return_value=None)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
with patch.object(bridge, '_get_session', return_value=mock_session):
result = await bridge._request_vscode_bridge("get_references", {})
assert result is None
class TestConnectionRefusedHandling:
"""Test handling when VSCode Bridge is not running."""
@pytest.mark.asyncio
async def test_connection_refused_returns_none(self):
"""Connection refused should return None gracefully."""
pytest.importorskip("aiohttp")
import aiohttp
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(use_vscode_bridge=True)
# Mock session to raise ClientConnectorError
mock_session = AsyncMock()
mock_session.post = MagicMock(
side_effect=aiohttp.ClientConnectorError(
MagicMock(), OSError("Connection refused")
)
)
with patch.object(bridge, '_get_session', return_value=mock_session):
result = await bridge._request_vscode_bridge("get_references", {})
assert result is None
class TestInvalidLspResponses:
"""Test handling of malformed LSP responses (VSCode Bridge mode)."""
@pytest.mark.asyncio
async def test_malformed_json_response(self, sample_symbol: CodeSymbolNode):
"""Malformed response should be handled gracefully."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(use_vscode_bridge=True)
# Response without expected structure
with patch.object(bridge, '_request_vscode_bridge', return_value={"unexpected": "structure"}):
result = await bridge.get_references(sample_symbol)
# Should return empty list, not crash
assert result == []
@pytest.mark.asyncio
async def test_null_result_in_response(self, sample_symbol: CodeSymbolNode):
"""Null/None result should be handled gracefully."""
from codexlens.lsp.lsp_bridge import LspBridge
bridge = LspBridge(use_vscode_bridge=True)
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
refs = await bridge.get_references(sample_symbol)
defn = await bridge.get_definition(sample_symbol)
hover = await bridge.get_hover(sample_symbol)
assert refs == []
assert defn is None
assert hover is None
class TestLocationParsing:
"""Test Location parsing from various LSP response formats."""
def test_location_from_file_uri_unix(self):
"""Parse Location from Unix-style file:// URI."""
from codexlens.lsp.lsp_bridge import Location
data = {
"uri": "file:///home/user/project/file.py",
"range": {
"start": {"line": 9, "character": 4},
"end": {"line": 9, "character": 10},
}
}
loc = Location.from_lsp_response(data)
assert loc.file_path == "/home/user/project/file.py"
assert loc.line == 10 # Converted from 0-based to 1-based
assert loc.character == 5
def test_location_from_file_uri_windows(self):
"""Parse Location from Windows-style file:// URI."""
from codexlens.lsp.lsp_bridge import Location
data = {
"uri": "file:///C:/Users/test/project/file.py",
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 5},
}
}
loc = Location.from_lsp_response(data)
assert loc.file_path == "C:/Users/test/project/file.py"
assert loc.line == 1
assert loc.character == 1
def test_location_from_direct_fields(self):
"""Parse Location from direct field format."""
from codexlens.lsp.lsp_bridge import Location
data = {
"file_path": "/path/to/file.py",
"line": 5,
"character": 10,
}
loc = Location.from_lsp_response(data)
assert loc.file_path == "/path/to/file.py"
assert loc.line == 5
assert loc.character == 10

View File

@@ -0,0 +1,549 @@
"""Unit tests for LspGraphBuilder.
This module tests the LspGraphBuilder class responsible for building
code association graphs by BFS expansion from seed symbols using LSP.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from codexlens.hybrid_search.data_structures import (
CallHierarchyItem,
CodeAssociationGraph,
CodeSymbolNode,
Range,
)
from codexlens.lsp.lsp_bridge import Location, LspBridge
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
@pytest.fixture
def mock_lsp_bridge() -> AsyncMock:
"""Create a mock LspBridge with async methods."""
bridge = AsyncMock(spec=LspBridge)
bridge.get_references = AsyncMock(return_value=[])
bridge.get_call_hierarchy = AsyncMock(return_value=[])
bridge.get_document_symbols = AsyncMock(return_value=[])
return bridge
@pytest.fixture
def seed_nodes() -> List[CodeSymbolNode]:
"""Create seed nodes for testing."""
return [
CodeSymbolNode(
id="main.py:main:1",
name="main",
kind="function",
file_path="main.py",
range=Range(
start_line=1,
start_character=0,
end_line=10,
end_character=0,
),
)
]
@pytest.fixture
def reference_location() -> Location:
"""Create a reference location for testing."""
return Location(
file_path="utils.py",
line=5,
character=10,
)
@pytest.fixture
def call_hierarchy_item() -> CallHierarchyItem:
"""Create a call hierarchy item for testing."""
return CallHierarchyItem(
name="caller_func",
kind="function",
file_path="caller.py",
range=Range(
start_line=20,
start_character=0,
end_line=30,
end_character=0,
),
detail="Calls main()",
)
class TestSingleLevelGraphExpansion:
"""P0: Test single level graph expansion with max_depth=1."""
@pytest.mark.asyncio
async def test_single_level_graph_expansion(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
reference_location: Location,
call_hierarchy_item: CallHierarchyItem,
) -> None:
"""Test BFS expansion at depth 1 produces correct graph structure.
Input: max_depth=1, single seed node
Mock: LspBridge returns 1 reference + 1 incoming call for seed only
Assert: Graph contains 3 nodes (seed, ref, call) and 2 edges from seed
"""
call_count = {"refs": 0, "calls": 0}
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
"""Return references only for the seed node."""
call_count["refs"] += 1
if node.file_path == "main.py":
return [reference_location]
return [] # No references for expanded nodes
async def mock_get_call_hierarchy(node: CodeSymbolNode) -> List[CallHierarchyItem]:
"""Return call hierarchy only for the seed node."""
call_count["calls"] += 1
if node.file_path == "main.py":
return [call_hierarchy_item]
return [] # No call hierarchy for expanded nodes
mock_lsp_bridge.get_references.side_effect = mock_get_references
mock_lsp_bridge.get_call_hierarchy.side_effect = mock_get_call_hierarchy
# Mock document symbols to provide symbol info for locations
mock_lsp_bridge.get_document_symbols.return_value = [
{
"name": "helper_func",
"kind": 12, # function
"range": {
"start": {"line": 4, "character": 0},
"end": {"line": 10, "character": 0},
},
}
]
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
# Verify graph structure
assert len(graph.nodes) == 3, f"Expected 3 nodes, got {len(graph.nodes)}: {list(graph.nodes.keys())}"
assert len(graph.edges) == 2, f"Expected 2 edges, got {len(graph.edges)}: {graph.edges}"
# Verify seed node is present
assert "main.py:main:1" in graph.nodes
# Verify edges exist with correct relationship types
edge_types = [edge[2] for edge in graph.edges]
assert "references" in edge_types, "Expected 'references' edge"
assert "calls" in edge_types, "Expected 'calls' edge"
# Verify expansion was called for seed and expanded nodes
# (nodes at depth 1 should not be expanded beyond max_depth=1)
assert call_count["refs"] >= 1, "get_references should be called at least once"
class TestMaxNodesBoundary:
"""P0: Test max_nodes boundary stops expansion."""
@pytest.mark.asyncio
async def test_max_nodes_boundary(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
) -> None:
"""Test graph expansion stops when max_nodes is reached.
Input: max_nodes=5
Mock: LspBridge returns many references
Assert: Graph expansion stops at 5 nodes
"""
# Create many reference locations
many_refs = [
Location(file_path=f"file{i}.py", line=i, character=0)
for i in range(20)
]
mock_lsp_bridge.get_references.return_value = many_refs
mock_lsp_bridge.get_call_hierarchy.return_value = []
mock_lsp_bridge.get_document_symbols.return_value = []
builder = LspGraphBuilder(max_depth=10, max_nodes=5, max_concurrent=10)
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
# Verify node count does not exceed max_nodes
assert len(graph.nodes) <= 5, (
f"Expected at most 5 nodes, got {len(graph.nodes)}"
)
class TestMaxDepthBoundary:
"""P1: Test max_depth boundary limits BFS expansion."""
@pytest.mark.asyncio
async def test_max_depth_boundary(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
) -> None:
"""Test BFS queue does not add nodes beyond max_depth.
Input: max_depth=2
Mock: Multi-level expansion responses
Assert: BFS queue stops adding new nodes when depth > 2
"""
# Track which depths are expanded
expanded_depths = set()
def create_ref_for_depth(depth: int) -> Location:
return Location(
file_path=f"depth{depth}.py",
line=depth * 10 + 1,
character=0,
)
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
"""Return references based on node's apparent depth."""
# Determine which depth level this node represents
if node.file_path == "main.py":
expanded_depths.add(0)
return [create_ref_for_depth(1)]
elif "depth1" in node.file_path:
expanded_depths.add(1)
return [create_ref_for_depth(2)]
elif "depth2" in node.file_path:
expanded_depths.add(2)
return [create_ref_for_depth(3)]
elif "depth3" in node.file_path:
expanded_depths.add(3)
return [create_ref_for_depth(4)]
return []
mock_lsp_bridge.get_references.side_effect = mock_get_references
mock_lsp_bridge.get_call_hierarchy.return_value = []
mock_lsp_bridge.get_document_symbols.return_value = []
builder = LspGraphBuilder(max_depth=2, max_nodes=100, max_concurrent=10)
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
# Collect file paths from graph
node_files = [node.file_path for node in graph.nodes.values()]
# Should have: seed (main.py), depth1 (from seed expansion), depth2 (from depth1 expansion)
# depth3 should only be added to graph but NOT expanded (depth > max_depth=2)
assert "main.py" in node_files, "Seed node should be in graph"
assert any("depth1" in f for f in node_files), "Depth 1 node should be in graph"
assert any("depth2" in f for f in node_files), "Depth 2 node should be in graph"
# The depth3 node might be added to the graph (from depth2 expansion)
# but should NOT be expanded (no depth4 nodes should exist)
depth4_nodes = [f for f in node_files if "depth4" in f]
assert len(depth4_nodes) == 0, (
f"Nodes beyond max_depth should not be expanded: {depth4_nodes}"
)
# Verify expansion didn't go to depth 3 (would mean depth4 nodes were created)
# The depth 3 node itself may be in the graph but shouldn't have been expanded
assert 3 not in expanded_depths or 4 not in expanded_depths, (
f"Expansion should stop at max_depth, expanded depths: {expanded_depths}"
)
class TestConcurrentSemaphore:
"""P1: Test concurrent semaphore limits parallel expansion."""
@pytest.mark.asyncio
async def test_concurrent_semaphore(
self,
mock_lsp_bridge: AsyncMock,
) -> None:
"""Test that concurrent node expansions are limited by semaphore.
Input: max_concurrent=3, 10 nodes in queue
Assert: Simultaneous _expand_node calls never exceed 3
"""
concurrent_count = {"current": 0, "max_seen": 0}
lock = asyncio.Lock()
# Create multiple seed nodes
seeds = [
CodeSymbolNode(
id=f"file{i}.py:func{i}:{i}",
name=f"func{i}",
kind="function",
file_path=f"file{i}.py",
range=Range(
start_line=i,
start_character=0,
end_line=i + 10,
end_character=0,
),
)
for i in range(10)
]
original_get_refs = mock_lsp_bridge.get_references
async def tracked_get_references(node: CodeSymbolNode) -> List[Location]:
"""Track concurrent calls to verify semaphore behavior."""
async with lock:
concurrent_count["current"] += 1
if concurrent_count["current"] > concurrent_count["max_seen"]:
concurrent_count["max_seen"] = concurrent_count["current"]
# Simulate some work
await asyncio.sleep(0.01)
async with lock:
concurrent_count["current"] -= 1
return []
mock_lsp_bridge.get_references.side_effect = tracked_get_references
mock_lsp_bridge.get_call_hierarchy.return_value = []
mock_lsp_bridge.get_document_symbols.return_value = []
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=3)
await builder.build_from_seeds(seeds, mock_lsp_bridge)
# Verify concurrent calls never exceeded max_concurrent
assert concurrent_count["max_seen"] <= 3, (
f"Max concurrent calls ({concurrent_count['max_seen']}) exceeded limit (3)"
)
class TestDocumentSymbolCache:
"""P1: Test document symbol caching for same file locations."""
@pytest.mark.asyncio
async def test_document_symbol_cache(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
) -> None:
"""Test that document symbols are cached per file.
Input: 2 locations from the same file
Mock: get_document_symbols only called once
Assert: Second location lookup uses cache
"""
# Two references from the same file
refs_same_file = [
Location(file_path="shared.py", line=10, character=0),
Location(file_path="shared.py", line=20, character=0),
]
mock_lsp_bridge.get_references.return_value = refs_same_file
mock_lsp_bridge.get_call_hierarchy.return_value = []
doc_symbols_call_count = {"count": 0}
async def mock_get_document_symbols(file_path: str) -> List[Dict[str, Any]]:
doc_symbols_call_count["count"] += 1
return [
{
"name": "symbol_at_10",
"kind": 12,
"range": {
"start": {"line": 9, "character": 0},
"end": {"line": 15, "character": 0},
},
},
{
"name": "symbol_at_20",
"kind": 12,
"range": {
"start": {"line": 19, "character": 0},
"end": {"line": 25, "character": 0},
},
},
]
mock_lsp_bridge.get_document_symbols.side_effect = mock_get_document_symbols
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
# get_document_symbols should be called only once for shared.py
assert doc_symbols_call_count["count"] == 1, (
f"Expected 1 call to get_document_symbols, got {doc_symbols_call_count['count']}"
)
# Verify cache contains the file
assert "shared.py" in builder._document_symbols_cache
@pytest.mark.asyncio
async def test_cache_cleared_between_builds(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
) -> None:
"""Test that clear_cache removes cached document symbols."""
mock_lsp_bridge.get_references.return_value = []
mock_lsp_bridge.get_call_hierarchy.return_value = []
mock_lsp_bridge.get_document_symbols.return_value = []
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
# Manually populate cache
builder._document_symbols_cache["test.py"] = [{"name": "cached"}]
# Clear cache
builder.clear_cache()
# Verify cache is empty
assert len(builder._document_symbols_cache) == 0
class TestNodeExpansionErrorHandling:
"""P2: Test error handling during node expansion."""
@pytest.mark.asyncio
async def test_node_expansion_error_handling(
self,
mock_lsp_bridge: AsyncMock,
) -> None:
"""Test that errors in node expansion are logged and other nodes continue.
Mock: get_references throws exception for specific node
Assert: Error is logged, other nodes continue expanding
"""
seeds = [
CodeSymbolNode(
id="good.py:good:1",
name="good",
kind="function",
file_path="good.py",
range=Range(start_line=1, start_character=0, end_line=10, end_character=0),
),
CodeSymbolNode(
id="bad.py:bad:1",
name="bad",
kind="function",
file_path="bad.py",
range=Range(start_line=1, start_character=0, end_line=10, end_character=0),
),
]
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
if "bad" in node.file_path:
raise RuntimeError("Simulated LSP error")
return [Location(file_path="result.py", line=5, character=0)]
mock_lsp_bridge.get_references.side_effect = mock_get_references
mock_lsp_bridge.get_call_hierarchy.return_value = []
mock_lsp_bridge.get_document_symbols.return_value = []
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
# Should not raise, error should be caught and logged
graph = await builder.build_from_seeds(seeds, mock_lsp_bridge)
# Both seed nodes should be in the graph
assert "good.py:good:1" in graph.nodes
assert "bad.py:bad:1" in graph.nodes
# The good node's expansion should have succeeded
# (result.py node should be present)
result_nodes = [n for n in graph.nodes.keys() if "result.py" in n]
assert len(result_nodes) >= 1, "Good node's expansion should have succeeded"
@pytest.mark.asyncio
async def test_partial_failure_continues_expansion(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
) -> None:
"""Test that failure in one LSP call doesn't stop other calls."""
# References succeed, call hierarchy fails
mock_lsp_bridge.get_references.return_value = [
Location(file_path="ref.py", line=5, character=0)
]
mock_lsp_bridge.get_call_hierarchy.side_effect = RuntimeError("Call hierarchy failed")
mock_lsp_bridge.get_document_symbols.return_value = []
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
# Should still have the seed and the reference node
assert len(graph.nodes) >= 2
# Reference edge should exist
ref_edges = [e for e in graph.edges if e[2] == "references"]
assert len(ref_edges) >= 1, "Reference edge should exist despite call hierarchy failure"
class TestEdgeCases:
"""Additional edge case tests."""
@pytest.mark.asyncio
async def test_empty_seeds(
self,
mock_lsp_bridge: AsyncMock,
) -> None:
"""Test building graph with empty seed list."""
builder = LspGraphBuilder(max_depth=2, max_nodes=100, max_concurrent=10)
graph = await builder.build_from_seeds([], mock_lsp_bridge)
assert len(graph.nodes) == 0
assert len(graph.edges) == 0
@pytest.mark.asyncio
async def test_self_referencing_node_skipped(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
) -> None:
"""Test that self-references don't create self-loops."""
# Reference back to the same node
mock_lsp_bridge.get_references.return_value = [
Location(file_path="main.py", line=1, character=0) # Same as seed
]
mock_lsp_bridge.get_call_hierarchy.return_value = []
mock_lsp_bridge.get_document_symbols.return_value = [
{
"name": "main",
"kind": 12,
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 9, "character": 0},
},
}
]
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
# Should only have the seed node, no self-loop edge
# (Note: depending on implementation, self-references may be filtered)
self_edges = [e for e in graph.edges if e[0] == e[1]]
assert len(self_edges) == 0, "Self-referencing edges should not exist"
@pytest.mark.asyncio
async def test_visited_nodes_not_expanded_twice(
self,
mock_lsp_bridge: AsyncMock,
seed_nodes: List[CodeSymbolNode],
) -> None:
"""Test that visited nodes are not expanded multiple times."""
expansion_calls = {"count": 0}
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
expansion_calls["count"] += 1
# Return same node reference each time
return [Location(file_path="shared.py", line=10, character=0)]
mock_lsp_bridge.get_references.side_effect = mock_get_references
mock_lsp_bridge.get_call_hierarchy.return_value = []
mock_lsp_bridge.get_document_symbols.return_value = []
builder = LspGraphBuilder(max_depth=3, max_nodes=100, max_concurrent=10)
await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
# Each unique node should only be expanded once
# seed (main.py) + shared.py = 2 expansions max
assert expansion_calls["count"] <= 2, (
f"Nodes should not be expanded multiple times, got {expansion_calls['count']} calls"
)