forked from HAL9000/cleveragents-core
31472b5413
Add Behave feature/step pairs that exercise uncovered branches across handlers, LSP, CLI, and service layers to reach the coverage gate. ISSUES CLOSED: #1232
515 lines
17 KiB
Python
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}'"
|
|
)
|