diff --git a/lib/pytest-lsp/changes/143.enhancement.md b/lib/pytest-lsp/changes/143.enhancement.md new file mode 100644 index 0000000..4795a08 --- /dev/null +++ b/lib/pytest-lsp/changes/143.enhancement.md @@ -0,0 +1 @@ +When a test fails `pytest-lsp` will now show the server's `stderr` output (if any) diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index 24d0950..f93b3e3 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -82,6 +82,22 @@ 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) @@ -89,15 +105,10 @@ async def server_exit(self, server: asyncio.subprocess.Process): 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( @@ -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.""" diff --git a/lib/pytest-lsp/tests/examples/server-stderr/server.py b/lib/pytest-lsp/tests/examples/server-stderr/server.py new file mode 100644 index 0000000..c4886e5 --- /dev/null +++ b/lib/pytest-lsp/tests/examples/server-stderr/server.py @@ -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, flush=True) + items.append(types.CompletionItem(label=f"item-{i}")) + + return items + + +if __name__ == "__main__": + server.start_io() diff --git a/lib/pytest-lsp/tests/examples/server-stderr/t_server.py b/lib/pytest-lsp/tests/examples/server-stderr/t_server.py new file mode 100644 index 0000000..239d50c --- /dev/null +++ b/lib/pytest-lsp/tests/examples/server-stderr/t_server.py @@ -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. diff --git a/lib/pytest-lsp/tests/test_examples.py b/lib/pytest-lsp/tests/test_examples.py index ab9243d..e9a0399 100644 --- a/lib/pytest-lsp/tests/test_examples.py +++ b/lib/pytest-lsp/tests/test_examples.py @@ -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.""" diff --git a/lib/pytest-lsp/tests/test_plugin.py b/lib/pytest-lsp/tests/test_plugin.py index 1cb06d5..6934062 100644 --- a/lib/pytest-lsp/tests/test_plugin.py +++ b/lib/pytest-lsp/tests/test_plugin.py @@ -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)