- Use _free_port() instead of hardcoded 19999 (avoids CI port conflicts) - Add timeout=2.0 to ws.close() so close handshake never blocks >2s
208 lines
7.3 KiB
Python
208 lines
7.3 KiB
Python
"""Integration tests: fake LSP client → WebSocket proxy → real pylsp.
|
|
|
|
Tests that a real LSP client (us, acting as the editor) can send LSP
|
|
JSON-RPC through the WebSocket proxy and get valid responses from the
|
|
actual pylsp process running inside the container.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
import aiohttp
|
|
from aiohttp import web
|
|
|
|
from ilsp.server import _build_app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers: LSP framing (Content-Length protocol)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _lsp_frame(body: bytes) -> bytes:
|
|
return b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body
|
|
|
|
|
|
def _lsp_message(**kwargs) -> bytes:
|
|
return _lsp_frame(json.dumps(kwargs).encode())
|
|
|
|
|
|
def _parse_lsp_frames(data: bytes) -> list[dict]:
|
|
"""Extract all complete LSP JSON-RPC messages from a raw byte buffer.
|
|
|
|
pylsp sends multiple headers (Content-Length + Content-Type), so we
|
|
parse each header line individually instead of assuming one header.
|
|
"""
|
|
messages = []
|
|
while b"\r\n\r\n" in data:
|
|
headers_raw, rest = data.split(b"\r\n\r\n", 1)
|
|
length = None
|
|
for line in headers_raw.split(b"\r\n"):
|
|
if line.lower().startswith(b"content-length:"):
|
|
length = int(line.split(b":", 1)[1].strip())
|
|
break
|
|
if length is None or len(rest) < length:
|
|
break
|
|
messages.append(json.loads(rest[:length]))
|
|
data = rest[length:]
|
|
return messages
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures: real pylsp + aiohttp proxy app
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
async def pylsp_port():
|
|
"""Start real pylsp on a free TCP port; yield port; kill on teardown."""
|
|
port = _free_port()
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-m", "pylsp", "--tcp", "--host", "127.0.0.1", "--port", str(port)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
# Wait until pylsp is actually accepting connections
|
|
for _ in range(20):
|
|
await asyncio.sleep(0.1)
|
|
try:
|
|
r, w = await asyncio.open_connection("127.0.0.1", port)
|
|
w.close()
|
|
await w.wait_closed()
|
|
break
|
|
except OSError:
|
|
continue
|
|
yield port
|
|
proc.terminate()
|
|
proc.wait(timeout=5)
|
|
|
|
|
|
@pytest.fixture
|
|
async def proxy_base_url(monkeypatch, pylsp_port):
|
|
"""Start the aiohttp proxy pointing at real pylsp; yield base URL."""
|
|
http_port = _free_port()
|
|
monkeypatch.setattr("ilsp.server.PYTHON_LSP_PORT", pylsp_port)
|
|
monkeypatch.setattr("ilsp.server.BICEP_LSP_PORT", _free_port()) # unused in these tests
|
|
|
|
app = await _build_app()
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, "127.0.0.1", http_port)
|
|
await site.start()
|
|
|
|
yield f"http://127.0.0.1:{http_port}"
|
|
|
|
await runner.cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: fake LSP client talks to real pylsp via WebSocket
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_endpoint(proxy_base_url):
|
|
"""Sanity check: /health returns ok."""
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(f"{proxy_base_url}/health") as resp:
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["status"] == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fake_client_initialize_gets_real_pylsp_capabilities(proxy_base_url):
|
|
"""
|
|
Fake editor client sends LSP initialize through WebSocket proxy.
|
|
Expects a real InitializeResult with pylsp capabilities back.
|
|
"""
|
|
ws_url = proxy_base_url.replace("http://", "ws://") + "/python"
|
|
init_request = _lsp_message(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="initialize",
|
|
params={
|
|
"processId": None,
|
|
"rootUri": None,
|
|
"capabilities": {},
|
|
},
|
|
)
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.ws_connect(ws_url) as ws:
|
|
await ws.send_bytes(init_request)
|
|
|
|
# Collect response bytes — pylsp may send in multiple frames
|
|
buf = b""
|
|
for _ in range(5):
|
|
msg = await asyncio.wait_for(ws.receive(), timeout=5.0)
|
|
if msg.type not in (aiohttp.WSMsgType.BINARY, aiohttp.WSMsgType.TEXT):
|
|
break
|
|
buf += msg.data if msg.type == aiohttp.WSMsgType.BINARY else msg.data.encode()
|
|
if b"\r\n\r\n" in buf:
|
|
break
|
|
|
|
messages = _parse_lsp_frames(buf)
|
|
assert messages, "No LSP response received from pylsp"
|
|
|
|
# Find the InitializeResult (id=1)
|
|
response = next((m for m in messages if m.get("id") == 1), None)
|
|
assert response is not None, f"No response with id=1, got: {messages}"
|
|
assert "result" in response, f"Expected result, got: {response}"
|
|
assert "capabilities" in response["result"], "pylsp did not return capabilities"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fake_client_receives_completion_capabilities(proxy_base_url):
|
|
"""pylsp should advertise completionProvider in its capabilities."""
|
|
ws_url = proxy_base_url.replace("http://", "ws://") + "/python"
|
|
init_request = _lsp_message(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="initialize",
|
|
params={"processId": None, "rootUri": None, "capabilities": {}},
|
|
)
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.ws_connect(ws_url) as ws:
|
|
await ws.send_bytes(init_request)
|
|
|
|
buf = b""
|
|
for _ in range(5):
|
|
msg = await asyncio.wait_for(ws.receive(), timeout=5.0)
|
|
if msg.type not in (aiohttp.WSMsgType.BINARY, aiohttp.WSMsgType.TEXT):
|
|
break
|
|
buf += msg.data if msg.type == aiohttp.WSMsgType.BINARY else msg.data.encode()
|
|
if b"\r\n\r\n" in buf:
|
|
break
|
|
|
|
messages = _parse_lsp_frames(buf)
|
|
response = next((m for m in messages if m.get("id") == 1), None)
|
|
caps = response["result"]["capabilities"]
|
|
assert "completionProvider" in caps, f"No completionProvider in: {caps}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ws_backend_unavailable_closes_cleanly(proxy_base_url, monkeypatch):
|
|
"""If pylsp is unreachable, WebSocket should close gracefully (not crash)."""
|
|
# Use _free_port() to guarantee nothing is listening (avoids CI port 19999 collisions)
|
|
dead_port = _free_port()
|
|
monkeypatch.setattr("ilsp.server.PYTHON_LSP_PORT", dead_port)
|
|
|
|
ws_url = proxy_base_url.replace("http://", "ws://") + "/python"
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.ws_connect(ws_url) as ws:
|
|
msg = await asyncio.wait_for(ws.receive(), timeout=5.0)
|
|
assert msg.type in (
|
|
aiohttp.WSMsgType.CLOSE,
|
|
aiohttp.WSMsgType.CLOSED,
|
|
aiohttp.WSMsgType.ERROR,
|
|
)
|