4ff075e0da
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / quality (pull_request) Successful in 32s
CI / typecheck (pull_request) Successful in 56s
CI / security (pull_request) Successful in 57s
CI / lint (pull_request) Successful in 3m19s
CI / integration_tests (pull_request) Successful in 3m44s
CI / unit_tests (pull_request) Successful in 6m39s
CI / docker (pull_request) Successful in 1m9s
CI / e2e_tests (pull_request) Successful in 9m38s
CI / coverage (pull_request) Successful in 11m19s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Failing after 1h13m38s
Replace local-mode stubs with real LSP protocol support: - StdioTransport: subprocess management with JSON-RPC framing - LspClient: LSP protocol (initialize/shutdown/diagnostics/completions) - LspLifecycleManager: reference-counted instances, health checks, crash restart - LspRuntime: registry-based server lookup, auto-restart on crash - LspToolAdapter: runtime-delegating handlers with local-mode fallback - LanguageDiscovery: 4-layer detection (extension, shebang, UKO, project) - activate_bindings/deactivate_bindings: actor compiler LSP binding wiring Tests: 27 Behave scenarios, 6 Robot integration tests, 250 existing pass. ISSUES CLOSED: #826
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""Step definitions for the functional LSP runtime feature tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import MagicMock, create_autospec
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.lsp.client import LspClient
|
|
from cleveragents.lsp.discovery import LanguageDiscovery
|
|
from cleveragents.lsp.errors import LspNotAvailableError, LspServerNotFoundError
|
|
from cleveragents.lsp.lifecycle import LspLifecycleManager
|
|
from cleveragents.lsp.models import LspCapability, LspServerConfig
|
|
from cleveragents.lsp.runtime import LspRuntime
|
|
from cleveragents.lsp.tool_adapter import LspToolAdapter
|
|
from cleveragents.lsp.transport import StdioTransport
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# StdioTransport steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to create a StdioTransport with empty command")
|
|
def step_transport_empty_command(context: Context) -> None:
|
|
try:
|
|
StdioTransport(command="")
|
|
context.transport_error = None
|
|
except ValueError as exc:
|
|
context.transport_error = exc
|
|
|
|
|
|
@then("a ValueError should be raised for transport")
|
|
def step_transport_value_error(context: Context) -> None:
|
|
assert context.transport_error is not None
|
|
assert isinstance(context.transport_error, ValueError)
|
|
|
|
|
|
@given('I create a StdioTransport for "{cmd}"')
|
|
def step_create_transport(context: Context, cmd: str) -> None:
|
|
context.transport = StdioTransport(command=cmd)
|
|
|
|
|
|
@then("the transport should not be alive")
|
|
def step_transport_not_alive(context: Context) -> None:
|
|
assert not context.transport.is_alive
|
|
|
|
|
|
@when("I start the transport")
|
|
def step_start_transport(context: Context) -> None:
|
|
context.transport.start()
|
|
|
|
|
|
@then("the transport should be alive")
|
|
def step_transport_alive(context: Context) -> None:
|
|
assert context.transport.is_alive
|
|
|
|
|
|
@when("I stop the transport")
|
|
def step_stop_transport(context: Context) -> None:
|
|
context.transport.stop()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LspClient steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create an LspClient with a mock transport")
|
|
def step_create_client_mock(context: Context) -> None:
|
|
mock_transport = create_autospec(StdioTransport, instance=True)
|
|
context.lsp_client = LspClient(mock_transport, server_name="test/mock")
|
|
|
|
|
|
@then("the client should not be initialized")
|
|
def step_client_not_initialized(context: Context) -> None:
|
|
assert not context.lsp_client.is_initialized
|
|
|
|
|
|
@when("I get two request IDs from the client")
|
|
def step_get_request_ids(context: Context) -> None:
|
|
context.id1 = context.lsp_client._next_id()
|
|
context.id2 = context.lsp_client._next_id()
|
|
|
|
|
|
@then("the IDs should be sequential")
|
|
def step_ids_sequential(context: Context) -> None:
|
|
assert context.id2 == context.id1 + 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LspLifecycleManager steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create an LspLifecycleManager")
|
|
def step_create_lifecycle(context: Context) -> None:
|
|
context.lifecycle_mgr = LspLifecycleManager()
|
|
|
|
|
|
@then("the manager should have no running servers")
|
|
def step_lifecycle_empty(context: Context) -> None:
|
|
assert len(context.lifecycle_mgr.list_running()) == 0
|
|
|
|
|
|
@when('I try to stop server "{name}" on the manager')
|
|
def step_lifecycle_stop_unknown(context: Context, name: str) -> None:
|
|
try:
|
|
context.lifecycle_mgr.stop_server(name)
|
|
context.lifecycle_error = None
|
|
except LspServerNotFoundError as exc:
|
|
context.lifecycle_error = exc
|
|
|
|
|
|
@then("an LspServerNotFoundError should be raised for lifecycle")
|
|
def step_lifecycle_not_found(context: Context) -> None:
|
|
assert context.lifecycle_error is not None
|
|
assert isinstance(context.lifecycle_error, LspServerNotFoundError)
|
|
|
|
|
|
@then('health check for "{name}" should return false')
|
|
def step_health_false(context: Context, name: str) -> None:
|
|
assert not context.lifecycle_mgr.health_check(name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LspRuntime steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create a functional LspRuntime")
|
|
def step_create_runtime(context: Context) -> None:
|
|
context.func_runtime = LspRuntime()
|
|
context.runtime_error = None
|
|
|
|
|
|
@when('I try to start server with empty name and workspace "{ws}"')
|
|
def step_runtime_empty_name(context: Context, ws: str) -> None:
|
|
try:
|
|
context.func_runtime.start_server("", ws)
|
|
context.runtime_error = None
|
|
except ValueError as exc:
|
|
context.runtime_error = exc
|
|
|
|
|
|
@when('I try to start server "{name}" with empty workspace')
|
|
def step_runtime_empty_workspace(context: Context, name: str) -> None:
|
|
try:
|
|
context.func_runtime.start_server(name, "")
|
|
context.runtime_error = None
|
|
except ValueError as exc:
|
|
context.runtime_error = exc
|
|
|
|
|
|
@when('I try to start unregistered server "{name}"')
|
|
def step_runtime_unregistered(context: Context, name: str) -> None:
|
|
try:
|
|
context.func_runtime.start_server(name, "/tmp")
|
|
context.runtime_error = None
|
|
except LspServerNotFoundError as exc:
|
|
context.runtime_error = exc
|
|
|
|
|
|
@then("a ValueError should be raised for runtime")
|
|
def step_runtime_value_error(context: Context) -> None:
|
|
assert context.runtime_error is not None
|
|
assert isinstance(context.runtime_error, ValueError)
|
|
|
|
|
|
@then("an LspServerNotFoundError should be raised for runtime")
|
|
def step_runtime_not_found(context: Context) -> None:
|
|
assert context.runtime_error is not None
|
|
assert isinstance(context.runtime_error, LspServerNotFoundError)
|
|
|
|
|
|
@when("I try to get diagnostics with empty name")
|
|
def step_runtime_diag_empty(context: Context) -> None:
|
|
try:
|
|
context.func_runtime.get_diagnostics("", "/tmp/test.py")
|
|
context.runtime_error = None
|
|
except ValueError as exc:
|
|
context.runtime_error = exc
|
|
|
|
|
|
@when("I try to get completions with line 0")
|
|
def step_runtime_completions_line0(context: Context) -> None:
|
|
try:
|
|
context.func_runtime.get_completions("local/test", "/tmp/test.py", 0, 1)
|
|
context.runtime_error = None
|
|
except ValueError as exc:
|
|
context.runtime_error = exc
|
|
|
|
|
|
@when("I try to get completions with column 0")
|
|
def step_runtime_completions_col0(context: Context) -> None:
|
|
try:
|
|
context.func_runtime.get_completions("local/test", "/tmp/test.py", 1, 0)
|
|
context.runtime_error = None
|
|
except ValueError as exc:
|
|
context.runtime_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LanguageDiscovery steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create a LanguageDiscovery instance")
|
|
def step_create_discovery(context: Context) -> None:
|
|
context.discovery = LanguageDiscovery()
|
|
|
|
|
|
@given('I create a LanguageDiscovery with project language "{lang}"')
|
|
def step_create_discovery_project(context: Context, lang: str) -> None:
|
|
context.discovery = LanguageDiscovery(project_languages=[lang])
|
|
|
|
|
|
@then('language for "{filename}" should be "{expected}"')
|
|
def step_check_language(context: Context, filename: str, expected: str) -> None:
|
|
result = context.discovery.detect_file_language(filename)
|
|
assert result == expected, f"Expected '{expected}', got '{result}'"
|
|
|
|
|
|
@when('I detect language for "{filename}"')
|
|
def step_detect_language(context: Context, filename: str) -> None:
|
|
context.detected_lang = context.discovery.detect_file_language(filename)
|
|
|
|
|
|
@when('I invalidate "{filename}" from discovery cache')
|
|
def step_invalidate_cache(context: Context, filename: str) -> None:
|
|
context.discovery.invalidate(filename)
|
|
|
|
|
|
@then('the cache should not contain "{filename}"')
|
|
def step_cache_not_contains(context: Context, filename: str) -> None:
|
|
assert filename not in context.discovery._cache
|
|
|
|
|
|
@given('I create a temp file with shebang "{shebang}"')
|
|
def step_create_shebang_file(context: Context, shebang: str) -> None:
|
|
fd, path = tempfile.mkstemp(suffix="")
|
|
with os.fdopen(fd, "w") as f:
|
|
f.write(f"{shebang}\nprint('hello')\n")
|
|
context.temp_shebang_file = path
|
|
|
|
|
|
@then('the temp file language should be "{lang}"')
|
|
def step_temp_file_lang(context: Context, lang: str) -> None:
|
|
result = context.discovery.detect_file_language(context.temp_shebang_file)
|
|
assert result == lang, f"Expected '{lang}', got '{result}'"
|
|
os.unlink(context.temp_shebang_file)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LspToolAdapter steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create an LspToolAdapter with a functional runtime")
|
|
def step_adapter_with_runtime(context: Context) -> None:
|
|
context.adapter_runtime = LspRuntime()
|
|
context.tool_adapter = LspToolAdapter(runtime=context.adapter_runtime)
|
|
|
|
|
|
@given("I create an LspToolAdapter without runtime")
|
|
def step_adapter_no_runtime(context: Context) -> None:
|
|
context.tool_adapter = LspToolAdapter()
|
|
|
|
|
|
@given("I have an LSP server config with diagnostics capability")
|
|
def step_server_config_diag(context: Context) -> None:
|
|
context.adapter_config = LspServerConfig(
|
|
name="local/test-server",
|
|
command="echo",
|
|
languages=["python"],
|
|
capabilities=[LspCapability.DIAGNOSTICS],
|
|
)
|
|
|
|
|
|
@when("I generate functional tool specs from the adapter")
|
|
def step_generate_specs(context: Context) -> None:
|
|
context.generated_specs = context.tool_adapter.generate_tool_specs(
|
|
context.adapter_config,
|
|
)
|
|
|
|
|
|
@then("the tool specs should have runtime handlers")
|
|
def step_specs_runtime_handlers(context: Context) -> None:
|
|
assert len(context.generated_specs) > 0
|
|
spec = context.generated_specs[0]
|
|
assert spec["name"] == "local/test-server/diagnostics"
|
|
# Runtime handler should not raise LspNotAvailableError
|
|
# (it would raise LspServerNotFoundError instead since server not started)
|
|
handler = spec["handler"]
|
|
try:
|
|
handler(file_path="/tmp/test.py")
|
|
except LspNotAvailableError as exc:
|
|
raise AssertionError(
|
|
"Expected runtime handler, got local-mode handler"
|
|
) from exc
|
|
except Exception:
|
|
pass # Expected: server not started
|
|
|
|
|
|
@then("the tool specs should have local-mode handlers")
|
|
def step_specs_local_handlers(context: Context) -> None:
|
|
assert len(context.generated_specs) > 0
|
|
spec = context.generated_specs[0]
|
|
handler = spec["handler"]
|
|
try:
|
|
handler(file_path="/tmp/test.py")
|
|
raise AssertionError("Expected LspNotAvailableError")
|
|
except LspNotAvailableError:
|
|
pass # Expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Binding activation steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I activate bindings with empty list")
|
|
def step_activate_empty(context: Context) -> None:
|
|
context.activated = context.func_runtime.activate_bindings([], "/tmp")
|
|
|
|
|
|
@then("the activated servers list should be empty")
|
|
def step_activated_empty(context: Context) -> None:
|
|
assert context.activated == []
|
|
|
|
|
|
@when("I activate bindings with unregistered server binding")
|
|
def step_activate_unregistered(context: Context) -> None:
|
|
mock_binding = MagicMock()
|
|
mock_binding.lsp_server_name = "local/nonexistent"
|
|
mock_binding.node_name = "test_node"
|
|
context.activated = context.func_runtime.activate_bindings(
|
|
[mock_binding],
|
|
"/tmp",
|
|
)
|