Files
temp/features/steps/lsp_transport_coverage_steps.py
freemo 31472b5413 test(coverage): add Behave scenarios for 39 under-covered modules
Add Behave feature/step pairs that exercise uncovered branches across handlers, LSP, CLI, and service layers to reach the coverage gate.

ISSUES CLOSED: #1232
2026-03-31 21:47:12 +00:00

515 lines
17 KiB
Python

"""Step definitions for LSP StdioTransport coverage tests.
Exercises every uncovered branch in ``cleveragents.lsp.transport``:
start() error paths, stop() variations, send_message(), read_message()
header/body parsing, and all error handling paths.
Uses the ``ltcov`` prefix on all steps to avoid AmbiguousStep errors.
"""
from __future__ import annotations
import json
import subprocess
from unittest.mock import MagicMock, PropertyMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.lsp.errors import LspError
from cleveragents.lsp.transport import _MAX_CONTENT_LENGTH, StdioTransport
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_mock_process(
*,
poll_return: int | None = None,
returncode: int = 0,
stdin: object | None = "auto",
stdout: object | None = "auto",
stderr: object | None = "auto",
pid: int = 12345,
) -> MagicMock:
"""Create a ``MagicMock`` resembling ``subprocess.Popen``."""
proc = MagicMock(spec=subprocess.Popen)
proc.pid = pid
proc.poll.return_value = poll_return
proc.returncode = returncode
if stdin == "auto":
proc.stdin = MagicMock()
else:
proc.stdin = stdin
if stdout == "auto":
proc.stdout = MagicMock()
else:
proc.stdout = stdout
if stderr == "auto":
proc.stderr = MagicMock()
else:
proc.stderr = stderr
return proc
def _build_lsp_frame(body_dict: dict) -> bytes:
"""Encode a dict as a Content-Length framed LSP message."""
payload = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
return header + payload
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given('ltcov I create a StdioTransport for command "{cmd}"')
def step_ltcov_create_transport(context: Context, cmd: str) -> None:
context.ltcov_transport = StdioTransport(command=cmd)
context.ltcov_error = None
context.ltcov_result = None
@given("ltcov the transport has a running mock process")
def step_ltcov_running_mock_process(context: Context) -> None:
proc = _make_mock_process(poll_return=None)
context.ltcov_transport._process = proc
@given("ltcov Popen is mocked to raise FileNotFoundError")
def step_ltcov_popen_fnf(context: Context) -> None:
patcher = patch(
"cleveragents.lsp.transport.subprocess.Popen",
side_effect=FileNotFoundError("No such file"),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given('ltcov Popen is mocked to raise OSError with "{msg}"')
def step_ltcov_popen_oserror(context: Context, msg: str) -> None:
patcher = patch(
"cleveragents.lsp.transport.subprocess.Popen",
side_effect=OSError(msg),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process that already exited with code {code:d}")
def step_ltcov_exited_process(context: Context, code: int) -> None:
proc = _make_mock_process(poll_return=code, returncode=code)
context.ltcov_transport._process = proc
@given("ltcov the transport has a mock process that ignores terminate")
def step_ltcov_stubborn_process(context: Context) -> None:
proc = _make_mock_process(poll_return=None, returncode=-9)
# First poll returns None (alive), after kill returns -9
proc.poll.return_value = None
# terminate() does nothing; wait() after terminate times out
proc.wait.side_effect = [
subprocess.TimeoutExpired(cmd="sleep", timeout=0.1),
None, # second wait after kill succeeds
]
# After kill, returncode becomes -9
type(proc).returncode = PropertyMock(return_value=-9)
context.ltcov_transport._process = proc
context.ltcov_mock_process = proc
@given("ltcov the transport has a mock process with no stdin")
def step_ltcov_no_stdin(context: Context) -> None:
proc = _make_mock_process(stdin=None)
context.ltcov_transport._process = proc
@given("ltcov the transport has a mock process with writable stdin")
def step_ltcov_writable_stdin(context: Context) -> None:
mock_stdin = MagicMock()
mock_stdin.write = MagicMock()
mock_stdin.flush = MagicMock()
proc = _make_mock_process()
proc.stdin = mock_stdin
context.ltcov_transport._process = proc
context.ltcov_mock_stdin = mock_stdin
@given("ltcov the transport has a mock process with broken stdin")
def step_ltcov_broken_stdin(context: Context) -> None:
mock_stdin = MagicMock()
mock_stdin.write.side_effect = BrokenPipeError("pipe closed")
proc = _make_mock_process()
proc.stdin = mock_stdin
context.ltcov_transport._process = proc
@given("ltcov the transport has a mock process with no stdout")
def step_ltcov_no_stdout(context: Context) -> None:
proc = _make_mock_process(stdout=None)
context.ltcov_transport._process = proc
@given("ltcov the transport has a mock process with empty stdout")
def step_ltcov_empty_stdout(context: Context) -> None:
mock_stdout = MagicMock()
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
context.ltcov_mock_stdout = mock_stdout
@given("ltcov select is mocked to return not ready")
def step_ltcov_select_not_ready(context: Context) -> None:
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with stdout that returns EOF")
def step_ltcov_stdout_eof(context: Context) -> None:
mock_stdout = MagicMock()
# select says ready, but readline returns empty (EOF)
mock_stdout.readline.return_value = b""
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([mock_stdout], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with a valid JSON-RPC response")
def step_ltcov_valid_response(context: Context) -> None:
body = {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {}}}
payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
header_line = f"Content-Length: {len(payload)}\r\n".encode("ascii")
empty_line = b"\r\n"
mock_stdout = MagicMock()
# readline() returns: header line, then empty line (end of headers)
mock_stdout.readline.side_effect = [header_line, empty_line]
# read() returns the JSON body
mock_stdout.read.return_value = payload
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
# select always says ready
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([mock_stdout], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with invalid content-length header")
def step_ltcov_invalid_cl(context: Context) -> None:
mock_stdout = MagicMock()
# Return a header with non-numeric content-length, then empty line
mock_stdout.readline.side_effect = [
b"Content-Length: not_a_number\r\n",
b"\r\n",
]
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([mock_stdout], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with no content-length header")
def step_ltcov_no_cl(context: Context) -> None:
mock_stdout = MagicMock()
# Return a random header (not Content-Length), then empty line
mock_stdout.readline.side_effect = [
b"X-Custom-Header: something\r\n",
b"\r\n",
]
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([mock_stdout], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with oversized content-length")
def step_ltcov_oversized_cl(context: Context) -> None:
huge_length = _MAX_CONTENT_LENGTH + 1
mock_stdout = MagicMock()
mock_stdout.readline.side_effect = [
f"Content-Length: {huge_length}\r\n".encode("ascii"),
b"\r\n",
]
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([mock_stdout], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with headers but body times out")
def step_ltcov_body_timeout(context: Context) -> None:
mock_stdout = MagicMock()
mock_stdout.readline.side_effect = [
b"Content-Length: 50\r\n",
b"\r\n",
]
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
# First select call returns ready (for headers), second returns not-ready (body timeout)
call_count = [0]
def select_side_effect(rlist, wlist, xlist, timeout=None):
call_count[0] += 1
if call_count[0] <= 2:
# Headers: two readline calls both need select to report ready
return (rlist, [], [])
else:
# Body: timeout
return ([], [], [])
patcher = patch(
"cleveragents.lsp.transport.select.select",
side_effect=select_side_effect,
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with truncated body")
def step_ltcov_truncated_body(context: Context) -> None:
mock_stdout = MagicMock()
mock_stdout.readline.side_effect = [
b"Content-Length: 50\r\n",
b"\r\n",
]
# Server sends fewer bytes than Content-Length claims
mock_stdout.read.return_value = b"short"
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([mock_stdout], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
@given("ltcov the transport has a mock process with invalid JSON body")
def step_ltcov_invalid_json_body(context: Context) -> None:
bad_body = b"this is not valid json!!"
mock_stdout = MagicMock()
mock_stdout.readline.side_effect = [
f"Content-Length: {len(bad_body)}\r\n".encode("ascii"),
b"\r\n",
]
mock_stdout.read.return_value = bad_body
proc = _make_mock_process()
proc.stdout = mock_stdout
context.ltcov_transport._process = proc
patcher = patch(
"cleveragents.lsp.transport.select.select",
return_value=([mock_stdout], [], []),
)
patcher.start()
context.add_cleanup(patcher.stop)
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when("ltcov I try to start the transport")
def step_ltcov_try_start(context: Context) -> None:
try:
context.ltcov_transport.start()
except Exception as exc:
context.ltcov_error = exc
@when("ltcov I stop the transport")
def step_ltcov_stop(context: Context) -> None:
context.ltcov_result = context.ltcov_transport.stop()
@when("ltcov I stop the transport with timeout {timeout:g}")
def step_ltcov_stop_timeout(context: Context, timeout: float) -> None:
context.ltcov_result = context.ltcov_transport.stop(timeout=timeout)
@when("ltcov I try to send a message")
def step_ltcov_try_send(context: Context) -> None:
try:
context.ltcov_transport.send_message(
{"jsonrpc": "2.0", "method": "test", "id": 1}
)
except Exception as exc:
context.ltcov_error = exc
@when("ltcov I send the message {body_json}")
def step_ltcov_send_message(context: Context, body_json: str) -> None:
body = json.loads(body_json)
context.ltcov_transport.send_message(body)
@when("ltcov I try to read a message")
def step_ltcov_try_read(context: Context) -> None:
try:
context.ltcov_result = context.ltcov_transport.read_message()
except Exception as exc:
context.ltcov_error = exc
@when("ltcov I read a message with timeout {timeout:g}")
def step_ltcov_read_with_timeout(context: Context, timeout: float) -> None:
try:
context.ltcov_result = context.ltcov_transport.read_message(timeout=timeout)
except Exception as exc:
context.ltcov_error = exc
@when("ltcov I read a message with default timeout")
def step_ltcov_read_default_timeout(context: Context) -> None:
try:
context.ltcov_result = context.ltcov_transport.read_message(timeout=None)
except Exception as exc:
context.ltcov_error = exc
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then('ltcov the error should be a RuntimeError with message "{fragment}"')
def step_ltcov_runtime_error(context: Context, fragment: str) -> None:
assert context.ltcov_error is not None, "Expected an error but none was raised"
assert isinstance(context.ltcov_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.ltcov_error).__name__}"
)
assert fragment in str(context.ltcov_error), (
f"Expected '{fragment}' in '{context.ltcov_error}'"
)
@then('ltcov the error should be an LspError with message "{fragment}"')
def step_ltcov_lsp_error(context: Context, fragment: str) -> None:
assert context.ltcov_error is not None, "Expected an error but none was raised"
assert isinstance(context.ltcov_error, LspError), (
f"Expected LspError, got {type(context.ltcov_error).__name__}"
)
assert fragment in str(context.ltcov_error), (
f"Expected '{fragment}' in '{context.ltcov_error}'"
)
@then("ltcov the stop result should be None")
def step_ltcov_stop_none(context: Context) -> None:
assert context.ltcov_result is None, f"Expected None, got {context.ltcov_result}"
@then("ltcov the stop result should be {code:d}")
def step_ltcov_stop_code(context: Context, code: int) -> None:
assert context.ltcov_result == code, f"Expected {code}, got {context.ltcov_result}"
@then("ltcov the internal process should be None")
def step_ltcov_process_none(context: Context) -> None:
assert context.ltcov_transport._process is None
@then("ltcov the mock process should have been killed")
def step_ltcov_killed(context: Context) -> None:
context.ltcov_mock_process.kill.assert_called_once()
@then("ltcov the stop result should be an integer")
def step_ltcov_stop_is_int(context: Context) -> None:
assert isinstance(context.ltcov_result, int), (
f"Expected int, got {type(context.ltcov_result).__name__}"
)
@then('ltcov the written bytes should contain "{fragment}"')
def step_ltcov_written_contains(context: Context, fragment: str) -> None:
written_calls = context.ltcov_mock_stdin.write.call_args_list
assert len(written_calls) > 0, "No data was written to stdin"
written_data = b"".join(call.args[0] for call in written_calls)
assert fragment.encode() in written_data, f"Expected '{fragment}' in written data"
@then("ltcov the error should be a BrokenPipeError")
def step_ltcov_broken_pipe(context: Context) -> None:
assert context.ltcov_error is not None, "Expected an error but none was raised"
assert isinstance(context.ltcov_error, BrokenPipeError), (
f"Expected BrokenPipeError, got {type(context.ltcov_error).__name__}"
)
@then("ltcov the read result should be None")
def step_ltcov_read_none(context: Context) -> None:
assert context.ltcov_result is None, f"Expected None, got {context.ltcov_result}"
@then('ltcov the read result should have key "{key}" with value "{value}"')
def step_ltcov_read_key_value(context: Context, key: str, value: str) -> None:
assert context.ltcov_result is not None, "Expected a result but got None"
assert isinstance(context.ltcov_result, dict), (
f"Expected dict, got {type(context.ltcov_result)}"
)
assert key in context.ltcov_result, f"Key '{key}' not in result"
assert context.ltcov_result[key] == value, (
f"Expected '{value}', got '{context.ltcov_result[key]}'"
)
@then('ltcov the error should be a ValueError with message "{fragment}"')
def step_ltcov_value_error(context: Context, fragment: str) -> None:
assert context.ltcov_error is not None, "Expected an error but none was raised"
assert isinstance(context.ltcov_error, ValueError), (
f"Expected ValueError, got {type(context.ltcov_error).__name__}"
)
assert fragment in str(context.ltcov_error), (
f"Expected '{fragment}' in '{context.ltcov_error}'"
)