Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 29 additions & 18 deletions tests/server.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""
A simple test server for integration tests.

Only understands stdio.
Uses the asyncio module and mypy types, so you'll need a modern Python.
Can do JSON-RPC with stdio or TCP sockets as the transport.

To make this server reply to requests, send the $test/setResponse notification.

Expand All @@ -18,7 +17,6 @@
Tests can await this request to make sure that they receive notification before code
resumes (since response to request will arrive after requested notification).

TODO: Untested on Windows.
"""
from __future__ import annotations

Expand All @@ -38,7 +36,7 @@
import uuid

__package__ = "server"
__version__ = "1.0.0"
__version__ = "2.0.0"


if sys.version_info[:2] < (3, 6):
Expand Down Expand Up @@ -484,24 +482,36 @@ def do_blocking_drain() -> None:
# END: https://stackoverflow.com/a/52702646/990142


async def main(tcp_port: int | None = None) -> bool:
async def main(tcp_port: int | None = None, mode: str | None = None) -> bool:
Copy link
Copy Markdown
Member

@rchl rchl Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you set type for mode to Literal['client', 'server'] | None so that it self-documents allowed values?

if tcp_port is not None:

class ClientConnectedCallback:
if mode is None or mode == "server":
print("running in TCP server mode", file=sys.stderr)

def __init__(self) -> None:
self.received_shutdown = False
class ClientConnectedCallback:

async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
session = Session(reader, writer)
self.received_shutdown = await session.run_forever()
def __init__(self) -> None:
self.received_shutdown = False

async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
session = Session(reader, writer)
self.received_shutdown = await session.run_forever()

callback = ClientConnectedCallback()
server = await asyncio.start_server(callback, port=tcp_port)
# NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. But,
# it's good to have this botched logic here to make sure that servers shutdown in the integration tests.
await server.serve_forever()
return callback.received_shutdown

if mode is not None and mode == "client":
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if mode is not None and mode == "client":
if mode == "client":

print("running in TCP client mode", file=sys.stderr)
reader, writer = await asyncio.open_connection(host=None, port=tcp_port)
session = Session(reader, writer)
return await session.run_forever()

return False

callback = ClientConnectedCallback()
server = await asyncio.start_server(callback, port=tcp_port)
# NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received.
# But, it's good to have this botched logic here to make sure that servers shutdown in the integration tests.
await server.serve_forever()
return callback.received_shutdown
reader, writer = await stdio()
session = Session(reader, writer)
return await session.run_forever()
Expand All @@ -511,6 +521,7 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri
parser = ArgumentParser(prog=__package__, description=__doc__)
parser.add_argument("-v", "--version", action="store_true", help="print version and exit")
parser.add_argument("-p", "--tcp-port", type=int)
parser.add_argument("--mode", help="one of 'client' or 'server'", default="server")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can validate the argument early and then also doesn't really need a redundant help:

Suggested change
parser.add_argument("--mode", help="one of 'client' or 'server'", default="server")
parser.add_argument("--mode", help="one of 'client' or 'server'", default="server", choices=["client", "server"])

args = parser.parse_args()
if args.version:
print(__package__, __version__)
Expand All @@ -519,7 +530,7 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri
asyncio.set_event_loop(loop)
shutdown_received = False
try:
shutdown_received = loop.run_until_complete(main(args.tcp_port))
shutdown_received = loop.run_until_complete(main(args.tcp_port, args.mode))
except KeyboardInterrupt:
pass
loop.run_until_complete(loop.shutdown_asyncgens())
Expand Down
35 changes: 27 additions & 8 deletions tests/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,42 @@ def result(self) -> Any:
return self.__result


def make_stdio_test_config() -> ClientConfig:
return ClientConfig(
name="TEST",
def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make init_options optional since it's not always needed?

Suggested change
def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig:
def make_stdio_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig:

"""Start the fake language server in STDIO mode."""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking but the function doesn't really start the server. It just creates a config for starting a server.

config = ClientConfig(
name=name,
command=["python3", join("$packages", "LSP", "tests", "server.py")],
selector="text.plain",
enabled=True,
)
config.initialization_options.assign(init_options)
return config
Comment on lines +46 to +53
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not pass init_options to the constructor and save lines?

Suggested change
config = ClientConfig(
name=name,
command=["python3", join("$packages", "LSP", "tests", "server.py")],
selector="text.plain",
enabled=True,
)
config.initialization_options.assign(init_options)
return config
return ClientConfig(
name=name,
command=["python3", join("$packages", "LSP", "tests", "server.py")],
selector="text.plain",
initialization_options=DottedDict(init_options),
enabled=True,
)

Same below.



def make_tcp_test_config() -> ClientConfig:
return ClientConfig(
name="TEST",
command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port"],
def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig:
"""Start the fake server in TCP mode, and make it act as the TCP server, awaiting a single client connection."""
config = ClientConfig(
name=name,
command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=server"],
selector="text.plain",
tcp_port=0, # select a free one for me
enabled=True,
)
config.initialization_options.assign(init_options)
return config


def make_tcp_client_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig:
"""Start the fake server in TCP mode, and make it act as the TCP client, where it connects to the LSP plugin."""
config = ClientConfig(
name=name,
command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=client"],
selector="text.plain",
tcp_port=-1, # select a free one for me
enabled=True,
)
config.initialization_options.assign(init_options)
return config


def add_config(config: ClientConfig) -> None:
Expand All @@ -82,7 +101,7 @@ def expand(s: str, w: sublime.Window) -> str:
class TextDocumentTestCase(DeferrableTestCase):
@classmethod
def get_stdio_test_config(cls) -> ClientConfig:
return make_stdio_test_config()
return make_stdio_test_config("TEST", {})

@classmethod
def setUpClass(cls) -> Generator:
Expand Down
34 changes: 29 additions & 5 deletions tests/test_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from .setup import close_test_view
from .setup import expand
from .setup import make_stdio_test_config
from .setup import make_tcp_client_test_config
from .setup import make_tcp_server_test_config
from .setup import remove_config
from .setup import TIMEOUT_TIME
from .setup import YieldPromise
Expand Down Expand Up @@ -50,16 +52,17 @@ def setUp(self) -> Generator:
self.assertTrue(self.window)
self.session1 = None
self.session2 = None
self.config1 = make_stdio_test_config()
self.config1.initialization_options.assign(initialization_options)
self.config2 = make_stdio_test_config()
self.config2.initialization_options.assign(initialization_options)
self.config2.name = "TEST-2"
self.session3 = None
self.config1 = make_stdio_test_config("TEST-1", initialization_options)
self.config2 = make_tcp_client_test_config("TEST-2", initialization_options)
self.config3 = make_tcp_server_test_config("TEST-3", initialization_options)
self.wm = windows.lookup(self.window)
add_config(self.config1)
add_config(self.config2)
add_config(self.config3)
self.wm.get_config_manager().all[self.config1.name] = self.config1
self.wm.get_config_manager().all[self.config2.name] = self.config2
self.wm.get_config_manager().all[self.config3.name] = self.config3

def test_sends_did_open_to_multiple_sessions(self) -> Generator:
filename = expand(join("$packages", "LSP", "tests", "testfile.txt"), self.window)
Expand All @@ -76,14 +79,21 @@ def test_sends_did_open_to_multiple_sessions(self) -> Generator:
yield {
"condition": lambda: self.wm.get_session(self.config2.name, self.view.file_name()) is not None,
"timeout": TIMEOUT_TIME}
yield {
"condition": lambda: self.wm.get_session(self.config3.name, self.view.file_name()) is not None,
"timeout": TIMEOUT_TIME}
self.session1 = self.wm.get_session(self.config1.name, self.view.file_name())
self.session2 = self.wm.get_session(self.config2.name, self.view.file_name())
self.session3 = self.wm.get_session(self.config3.name, self.view.file_name())
self.assertIsNotNone(self.session1)
self.assertIsNotNone(self.session2)
self.assertIsNotNone(self.session3)
self.assertEqual(self.session1.config.name, self.config1.name)
self.assertEqual(self.session2.config.name, self.config2.name)
self.assertEqual(self.session3.config.name, self.config3.name)
yield {"condition": lambda: self.session1.state == ClientStates.READY, "timeout": TIMEOUT_TIME}
yield {"condition": lambda: self.session2.state == ClientStates.READY, "timeout": TIMEOUT_TIME}
yield {"condition": lambda: self.session3.state == ClientStates.READY, "timeout": TIMEOUT_TIME}
yield from self.await_message("initialize")
yield from self.await_message("initialized")
yield from self.await_message("textDocument/didOpen")
Expand All @@ -103,6 +113,13 @@ def doCleanups(self) -> Generator:
if self.session2:
sublime.set_timeout_async(self.session2.end_async)
yield lambda: self.session2.state == ClientStates.STOPPING
if self.session3:
sublime.set_timeout_async(self.session3.end_async)
yield lambda: self.session3.state == ClientStates.STOPPING
try:
remove_config(self.config3)
except ValueError:
pass
try:
remove_config(self.config2)
except ValueError:
Expand All @@ -111,24 +128,31 @@ def doCleanups(self) -> Generator:
remove_config(self.config1)
except ValueError:
pass
self.wm.get_config_manager().all.pop(self.config3.name, None)
self.wm.get_config_manager().all.pop(self.config2.name, None)
self.wm.get_config_manager().all.pop(self.config1.name, None)
yield from super().doCleanups()

def await_message(self, method: str) -> Generator:
promise1 = YieldPromise()
promise2 = YieldPromise()
promise3 = YieldPromise()

def handler1(params: Any) -> None:
promise1.fulfill(params)

def handler2(params: Any) -> None:
promise2.fulfill(params)

def handler3(params: Any) -> None:
promise3.fulfill(params)

def error_handler(params: Any) -> None:
debug("Got error:", params, "awaiting timeout :(")

self.session1.send_request(Request("$test/getReceived", {"method": method}), handler1, error_handler)
self.session2.send_request(Request("$test/getReceived", {"method": method}), handler2, error_handler)
self.session3.send_request(Request("$test/getReceived", {"method": method}), handler3, error_handler)
yield {"condition": promise1, "timeout": TIMEOUT_TIME}
yield {"condition": promise2, "timeout": TIMEOUT_TIME}
yield {"condition": promise3, "timeout": TIMEOUT_TIME}
2 changes: 1 addition & 1 deletion tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ def test_format_diagnostic_for_html(self) -> None:
diagnostic2.pop("relatedInformation")
self.assertIn("relatedInformation", diagnostic1)
self.assertNotIn("relatedInformation", diagnostic2)
client_config = make_stdio_test_config()
client_config = make_stdio_test_config("TEST", {})
# They should result in the same minihtml.
self.assertEqual(
format_diagnostic_for_html(self.view, client_config, diagnostic1, [], '#ffffff', "/foo/bar"),
Expand Down