23803f14ec
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 4m0s
CI / docker (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 4m48s
CI / coverage (pull_request) Successful in 4m31s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 15s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 3m3s
CI / docker (push) Successful in 38s
CI / coverage (push) Successful in 4m25s
CI / benchmark-publish (push) Successful in 16m28s
CI / benchmark-regression (pull_request) Successful in 28m52s
Added minimal LSP server entrypoint supporting initialize/shutdown/exit handshake over JSON-RPC stdin/stdout transport with Content-Length framing. Unsupported methods return MethodNotFound error with descriptive message. Wired LSP requests through ACP facade in local mode. Added agents lsp serve CLI command with --log-level flag, PID output, and startup banner. Created reference documentation for the stub server. Includes Behave BDD tests for protocol handshake, Robot smoke test, and ASV startup latency benchmark. ISSUES CLOSED: #203
240 lines
8.0 KiB
Python
240 lines
8.0 KiB
Python
"""Helper script for Robot Framework LSP stub smoke tests.
|
|
|
|
Usage:
|
|
python helper_lsp_stub.py <command>
|
|
|
|
Commands:
|
|
initialize Send initialize request and verify response
|
|
shutdown Full shutdown handshake
|
|
unsupported Test unsupported method error
|
|
startup-pid Verify startup PID reporting
|
|
full-lifecycle Complete initialize/shutdown/exit lifecycle
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure src is on the path. Robot helpers are invoked as standalone
|
|
# scripts via ``Run Process`` so the project package is not always
|
|
# importable. This sys.path manipulation is required for that use
|
|
# case. If the project is installed in editable mode this insert is
|
|
# harmless (the installed entry-point takes precedence).
|
|
src_dir = str(Path(__file__).resolve().parents[1] / "src")
|
|
if src_dir not in sys.path:
|
|
sys.path.insert(0, src_dir)
|
|
|
|
from cleveragents.lsp.server import LspServer # noqa: E402
|
|
|
|
# Import the shared response-parsing utility from the test mocks
|
|
# package so that parsing logic is defined in a single place.
|
|
# Same sys.path rationale as above — needed for standalone execution.
|
|
features_dir = str(Path(__file__).resolve().parents[1])
|
|
if features_dir not in sys.path:
|
|
sys.path.insert(0, features_dir)
|
|
|
|
from features.mocks.lsp_transport_mock import parse_lsp_responses # noqa: E402
|
|
|
|
|
|
def _encode_message(msg: dict) -> bytes:
|
|
"""Encode a JSON-RPC message with Content-Length framing."""
|
|
payload = json.dumps(msg).encode("utf-8")
|
|
header = f"Content-Length: {len(payload)}\r\n\r\n".encode()
|
|
return header + payload
|
|
|
|
|
|
def _run_server(messages: list[dict]) -> tuple[int, list[dict]]:
|
|
"""Run LSP server with the given messages and return (exit_code, responses)."""
|
|
raw = b"".join(_encode_message(msg) for msg in messages)
|
|
input_stream = io.BytesIO(raw)
|
|
output_stream = io.BytesIO()
|
|
server = LspServer(input_stream=input_stream, output_stream=output_stream)
|
|
exit_code = server.run()
|
|
output_stream.seek(0)
|
|
responses = parse_lsp_responses(output_stream.read())
|
|
return exit_code, responses
|
|
|
|
|
|
def cmd_initialize() -> None:
|
|
"""Test initialize handshake."""
|
|
_, responses = _run_server(
|
|
[
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {"capabilities": {}},
|
|
},
|
|
{"jsonrpc": "2.0", "method": "exit"},
|
|
]
|
|
)
|
|
assert len(responses) >= 1, f"Expected at least 1 response, got {len(responses)}"
|
|
result = responses[0].get("result", {})
|
|
server_info = result.get("serverInfo", {})
|
|
assert server_info.get("name") == "cleveragents-lsp-stub", (
|
|
f"Unexpected serverInfo: {server_info}"
|
|
)
|
|
assert "capabilities" in result, "Missing capabilities"
|
|
print("lsp-initialize-ok")
|
|
|
|
|
|
def cmd_shutdown() -> None:
|
|
"""Test shutdown handshake."""
|
|
exit_code, responses = _run_server(
|
|
[
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {"capabilities": {}},
|
|
},
|
|
{"jsonrpc": "2.0", "id": 2, "method": "shutdown"},
|
|
{"jsonrpc": "2.0", "method": "exit"},
|
|
]
|
|
)
|
|
assert exit_code == 0, f"Expected exit code 0, got {exit_code}"
|
|
shutdown_resp = None
|
|
for r in responses:
|
|
if r.get("id") == 2:
|
|
shutdown_resp = r
|
|
break
|
|
assert shutdown_resp is not None, "No shutdown response"
|
|
assert shutdown_resp.get("result") is None, "Shutdown result should be null"
|
|
print("lsp-shutdown-ok")
|
|
|
|
|
|
def cmd_unsupported() -> None:
|
|
"""Test unsupported method error after initialize."""
|
|
_, responses = _run_server(
|
|
[
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {"capabilities": {}},
|
|
},
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "textDocument/completion",
|
|
"params": {},
|
|
},
|
|
{"jsonrpc": "2.0", "method": "exit"},
|
|
]
|
|
)
|
|
assert len(responses) >= 2, f"Expected at least 2 responses, got {len(responses)}"
|
|
# Find the completion response (id=2)
|
|
completion_resp = None
|
|
for r in responses:
|
|
if r.get("id") == 2:
|
|
completion_resp = r
|
|
break
|
|
assert completion_resp is not None, "No response for textDocument/completion"
|
|
error = completion_resp.get("error", {})
|
|
assert error.get("code") == -32601, f"Expected -32601, got {error.get('code')}"
|
|
assert "Not implemented" in error.get("message", ""), (
|
|
f"Unexpected error message: {error.get('message')}"
|
|
)
|
|
print("lsp-unsupported-ok")
|
|
|
|
|
|
def cmd_startup_pid() -> None:
|
|
"""Test that server starts and processes messages (PID reported via logging).
|
|
|
|
Validates PID plausibility (> 0) by capturing the structlog
|
|
``lsp.server.starting`` event emitted during ``run()``.
|
|
"""
|
|
import structlog
|
|
|
|
exit_code: int
|
|
responses: list[dict]
|
|
messages = [
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {"capabilities": {}},
|
|
},
|
|
{"jsonrpc": "2.0", "method": "exit"},
|
|
]
|
|
raw = b"".join(_encode_message(msg) for msg in messages)
|
|
input_stream = io.BytesIO(raw)
|
|
output_stream = io.BytesIO()
|
|
server = LspServer(input_stream=input_stream, output_stream=output_stream)
|
|
with structlog.testing.capture_logs() as captured:
|
|
exit_code = server.run()
|
|
output_stream.seek(0)
|
|
responses = parse_lsp_responses(output_stream.read())
|
|
|
|
assert len(responses) >= 1, "Server should produce responses"
|
|
# exit_code is 1 because no shutdown was sent
|
|
assert exit_code == 1, f"Expected exit code 1 (no shutdown), got {exit_code}"
|
|
|
|
# Validate PID plausibility: the server must log its PID as a
|
|
# positive integer in the ``lsp.server.starting`` event.
|
|
pid_entries = [
|
|
e for e in captured if e.get("event") == "lsp.server.starting" and "pid" in e
|
|
]
|
|
assert pid_entries, (
|
|
f"No 'lsp.server.starting' log with PID found in captured logs: {captured}"
|
|
)
|
|
logged_pid = pid_entries[0]["pid"]
|
|
assert isinstance(logged_pid, int), f"PID should be int, got {type(logged_pid)}"
|
|
assert logged_pid > 0, f"PID should be > 0, got {logged_pid}"
|
|
print("lsp-startup-pid-ok")
|
|
|
|
|
|
def _find_response_by_id(responses: list[dict], req_id: int) -> dict:
|
|
"""Find a response by its JSON-RPC id."""
|
|
for r in responses:
|
|
if r.get("id") == req_id:
|
|
return r
|
|
raise AssertionError(f"No response with id={req_id} in {responses}")
|
|
|
|
|
|
def cmd_full_lifecycle() -> None:
|
|
"""Test complete lifecycle."""
|
|
exit_code, responses = _run_server(
|
|
[
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {"capabilities": {}},
|
|
},
|
|
{"jsonrpc": "2.0", "id": 2, "method": "shutdown"},
|
|
{"jsonrpc": "2.0", "method": "exit"},
|
|
]
|
|
)
|
|
assert exit_code == 0, f"Expected exit code 0, got {exit_code}"
|
|
assert len(responses) == 2, f"Expected 2 responses, got {len(responses)}"
|
|
|
|
# Verify initialize response (by id, not index)
|
|
init_resp = _find_response_by_id(responses, 1)
|
|
assert "capabilities" in init_resp.get("result", {})
|
|
|
|
# Verify shutdown response (by id, not index)
|
|
shutdown_resp = _find_response_by_id(responses, 2)
|
|
assert shutdown_resp.get("result") is None
|
|
|
|
print("lsp-full-lifecycle-ok")
|
|
|
|
|
|
COMMANDS = {
|
|
"initialize": cmd_initialize,
|
|
"shutdown": cmd_shutdown,
|
|
"unsupported": cmd_unsupported,
|
|
"startup-pid": cmd_startup_pid,
|
|
"full-lifecycle": cmd_full_lifecycle,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
|
print(f"Usage: {sys.argv[0]} <{'|'.join(COMMANDS)}>", file=sys.stderr)
|
|
sys.exit(1)
|
|
COMMANDS[sys.argv[1]]()
|