Skip to content

Commit

Permalink
pytest-lsp: Forward a server's stderr output
Browse files Browse the repository at this point in the history
This should be enough in most cases to get pytest to report the
server's log output on failure.

There may be issues if there are multiple servers running
concurrently/within the same session but we will cross that bridge
if/when it becomes an issue.
  • Loading branch information
alcarney committed Feb 7, 2024
1 parent 7487e05 commit 9cd5089
Show file tree
Hide file tree
Showing 6 changed files with 116 additions and 7 deletions.
1 change: 1 addition & 0 deletions lib/pytest-lsp/changes/143.enhancement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When a test fails `pytest-lsp` will now show the server's `stderr` output (if any)
32 changes: 26 additions & 6 deletions lib/pytest-lsp/pytest_lsp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,33 @@ def __init__(self, *args, configuration: Optional[Dict[str, Any]] = None, **kwar
self._last_log_index = 0
"""Used to keep track of which log messages correspond with which test case."""

self._stderr_forwarder: Optional[asyncio.Task] = None
"""A task that forwards the server's stderr to the test process."""

async def start_io(self, cmd: str, *args, **kwargs):
await super().start_io(cmd, *args, **kwargs)

# Forward the server's stderr to this process' stderr
if self._server and self._server.stderr:
self._stderr_forwarder = asyncio.create_task(forward_stderr(self._server))

async def stop(self):
if self._stderr_forwarder:
self._stderr_forwarder.cancel()

return await super().stop()

async def server_exit(self, server: asyncio.subprocess.Process):
"""Called when the server process exits."""
logger.debug("Server process exited with code: %s", server.returncode)

if self._stop_event.is_set():
return

stderr = ""
if server.stderr is not None:
stderr_bytes = await server.stderr.read()
stderr = stderr_bytes.decode("utf8")

loop = asyncio.get_running_loop()
loop.call_soon(
cancel_all_tasks,
f"Server process exited with return code: {server.returncode}\n{stderr}",
f"Server process exited with return code: {server.returncode}",
)

def report_server_error(
Expand Down Expand Up @@ -259,6 +270,15 @@ async def wait_for_notification(self, method: str):
return await self.protocol.wait_for_notification_async(method)


async def forward_stderr(server: asyncio.subprocess.Process):
if server.stderr is None:
return

# EOF is signalled with an empty bytestring
while (line := await server.stderr.readline()) != b"":
sys.stderr.buffer.write(line)


def cancel_all_tasks(message: str):
"""Called to cancel all awaited tasks."""

Expand Down
21 changes: 21 additions & 0 deletions lib/pytest-lsp/tests/examples/server-stderr/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import sys

from lsprotocol import types
from pygls.server import LanguageServer

server = LanguageServer("server-stderr", "v1")


@server.feature(types.TEXT_DOCUMENT_COMPLETION)
def completion(params: types.CompletionParams):
items = []

for i in range(10):
print(f"Suggesting item {i}", file=sys.stderr)
items.append(types.CompletionItem(label=f"item-{i}"))

return items


if __name__ == "__main__":
server.start_io()
46 changes: 46 additions & 0 deletions lib/pytest-lsp/tests/examples/server-stderr/t_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import sys

from lsprotocol.types import ClientCapabilities
from lsprotocol.types import CompletionList
from lsprotocol.types import CompletionParams
from lsprotocol.types import InitializeParams
from lsprotocol.types import Position
from lsprotocol.types import TextDocumentIdentifier

import pytest_lsp
from pytest_lsp import ClientServerConfig
from pytest_lsp import LanguageClient


@pytest_lsp.fixture(
config=ClientServerConfig(server_command=[sys.executable, "server.py"]),
)
async def client(lsp_client: LanguageClient):
# Setup
params = InitializeParams(capabilities=ClientCapabilities())
await lsp_client.initialize_session(params)

yield

# Teardown
await lsp_client.shutdown_session()


async def test_completions(client: LanguageClient):
results = await client.text_document_completion_async(
params=CompletionParams(
position=Position(line=1, character=0),
text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"),
)
)

assert results is not None

if isinstance(results, CompletionList):
items = results.items
else:
items = results

labels = [item.label for item in items]
assert labels == [f"item-{i}" for i in range(10)]
assert False # Force the test case to fail.
21 changes: 21 additions & 0 deletions lib/pytest-lsp/tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ def test_generic_rpc(pytester: pytest.Pytester):
results.stdout.fnmatch_lines(" *LOG: b=2")


def test_server_stderr_fail(pytester: pytest.Pytester):
"""Ensure that the server's stderr stream is presented on failure."""

setup_test(pytester, "server-stderr")

results = pytester.runpytest()
results.assert_outcomes(failed=1)

results.stdout.fnmatch_lines("-* Captured stderr call -*")
results.stdout.fnmatch_lines("Suggesting item 0")
results.stdout.fnmatch_lines("Suggesting item 1")
results.stdout.fnmatch_lines("Suggesting item 2")
results.stdout.fnmatch_lines("Suggesting item 3")
results.stdout.fnmatch_lines("Suggesting item 4")
results.stdout.fnmatch_lines("Suggesting item 5")
results.stdout.fnmatch_lines("Suggesting item 6")
results.stdout.fnmatch_lines("Suggesting item 7")
results.stdout.fnmatch_lines("Suggesting item 8")
results.stdout.fnmatch_lines("Suggesting item 9")


def test_window_log_message_fail(pytester: pytest.Pytester):
"""Ensure that the initial getting started example fails as expected."""

Expand Down
2 changes: 1 addition & 1 deletion lib/pytest-lsp/tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ async def test_capabilities(client):
else:
message = [
"E*asyncio.exceptions.CancelledError: Server process exited with return code: 1", # noqa: E501
"E*ZeroDivisionError: division by zero",
"ZeroDivisionError: division by zero",
]

results.stdout.fnmatch_lines(message)
Expand Down

0 comments on commit 9cd5089

Please sign in to comment.