From 44c40774f07e3aa7d79bc0a80254b921f98be2cd Mon Sep 17 00:00:00 2001 From: David Brochart Date: Mon, 25 Mar 2024 09:09:37 +0100 Subject: [PATCH] Test on Trio as well --- pyproject.toml | 7 ++-- tests/conftest.py | 45 +++++++++++++++---------- tests/test_asgi.py | 71 +++++++++++++++++----------------------- tests/test_pycrdt_yjs.py | 59 +++++++++++++++++---------------- tests/test_ystore.py | 5 ++- tests/utils.py | 50 ++++++++++++++++++++++++++++ tests/yjs_client_0.js | 3 +- tests/yjs_client_1.js | 3 +- 8 files changed, 149 insertions(+), 94 deletions(-) create mode 100644 tests/utils.py diff --git a/pyproject.toml b/pyproject.toml index cc18acb..ebc5bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,9 +38,10 @@ test = [ "mypy", "pre-commit", "pytest", - "pytest-asyncio", - "websockets >=10.0", - "uvicorn", + "httpx-ws >=0.5.2", + "hypercorn >=0.16.0", + "trio >=0.25.0", + "sniffio", ] docs = [ "mkdocs", diff --git a/tests/conftest.py b/tests/conftest.py index d7cddd9..7f5f799 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,14 @@ -import subprocess +from functools import partial +from socket import socket import pytest +from anyio import Event, create_task_group +from hypercorn import Config from pycrdt import Array, Doc -from websockets import serve +from sniffio import current_async_library +from utils import ensure_server_running -from pycrdt_websocket import WebsocketServer +from pycrdt_websocket import ASGIServer, WebsocketServer class TestYDoc: @@ -23,32 +27,39 @@ def update(self): @pytest.fixture -async def yws_server(request): +async def yws_server(request, unused_tcp_port): try: kwargs = request.param - except Exception: + except AttributeError: kwargs = {} websocket_server = WebsocketServer(**kwargs) + app = ASGIServer(websocket_server) + config = Config() + config.bind = [f"localhost:{unused_tcp_port}"] + shutdown_event = Event() + if current_async_library() == "trio": + from hypercorn.trio import serve + else: + from hypercorn.asyncio import serve try: - async with websocket_server, serve(websocket_server.serve, "127.0.0.1", 1234): - yield websocket_server + async with create_task_group() as tg, websocket_server: + tg.start_soon( + partial(serve, app, config, shutdown_trigger=shutdown_event.wait, mode="asgi") + ) + await ensure_server_running("localhost", unused_tcp_port) + yield unused_tcp_port + shutdown_event.set() except Exception: pass -@pytest.fixture -def yjs_client(request): - client_id = request.param - p = subprocess.Popen(["node", f"tests/yjs_client_{client_id}.js"]) - yield p - p.kill() - - @pytest.fixture def test_ydoc(): return TestYDoc() @pytest.fixture -def anyio_backend(): - return "asyncio" +def unused_tcp_port() -> int: + with socket() as sock: + sock.bind(("localhost", 0)) + return sock.getsockname()[1] diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 0936364..901a7cb 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -1,43 +1,32 @@ import pytest -import uvicorn -from anyio import create_task_group, sleep +from anyio import sleep +from httpx_ws import aconnect_ws from pycrdt import Doc, Map -from websockets import connect - -from pycrdt_websocket import ASGIServer, WebsocketProvider, WebsocketServer - -websocket_server = WebsocketServer(auto_clean_rooms=False) -app = ASGIServer(websocket_server) - - -@pytest.mark.anyio -async def test_asgi(unused_tcp_port): - # server - config = uvicorn.Config("test_asgi:app", port=unused_tcp_port, log_level="info") - server = uvicorn.Server(config) - async with create_task_group() as tg, websocket_server: - tg.start_soon(server.serve) - while not server.started: - await sleep(0) - - # clients - # client 1 - ydoc1 = Doc() - ydoc1["map"] = ymap1 = Map() - ymap1["key"] = "value" - async with connect( - f"ws://localhost:{unused_tcp_port}/my-roomname" - ) as websocket1, WebsocketProvider(ydoc1, websocket1): - await sleep(0.1) - - # client 2 - ydoc2 = Doc() - async with connect( - f"ws://localhost:{unused_tcp_port}/my-roomname" - ) as websocket2, WebsocketProvider(ydoc2, websocket2): - await sleep(0.1) - - ydoc2["map"] = ymap2 = Map() - assert str(ymap2) == '{"key":"value"}' - - tg.cancel_scope.cancel() +from utils import Websocket + +from pycrdt_websocket import WebsocketProvider + +pytestmark = pytest.mark.anyio + + +@pytest.mark.parametrize("yws_server", [{"auto_clean_rooms": False}], indirect=True) +async def test_asgi(yws_server): + port = yws_server + # client 1 + ydoc1 = Doc() + ydoc1["map"] = ymap1 = Map() + ymap1["key"] = "value" + async with aconnect_ws( + f"http://localhost:{port}/my-roomname" + ) as websocket1, WebsocketProvider(ydoc1, Websocket(websocket1, "my-roomname")): + await sleep(0.1) + + # client 2 + ydoc2 = Doc() + async with aconnect_ws( + f"http://localhost:{port}/my-roomname" + ) as websocket2, WebsocketProvider(ydoc2, Websocket(websocket2, "my-roomname")): + await sleep(0.1) + + ydoc2["map"] = ymap2 = Map() + assert str(ymap2) == '{"key":"value"}' diff --git a/tests/test_pycrdt_yjs.py b/tests/test_pycrdt_yjs.py index bd0e8c3..8950b53 100644 --- a/tests/test_pycrdt_yjs.py +++ b/tests/test_pycrdt_yjs.py @@ -4,11 +4,14 @@ import pytest from anyio import Event, fail_after +from httpx_ws import aconnect_ws from pycrdt import Array, Doc, Map -from websockets import connect +from utils import Websocket, yjs_client from pycrdt_websocket import WebsocketProvider +pytestmark = pytest.mark.anyio + class Change: def __init__(self, event, timeout, ydata, sid, key): @@ -38,32 +41,32 @@ def watch(ydata, key: str | None = None, timeout: float = 1.0): return Change(change_event, timeout, ydata, sid, key) -@pytest.mark.anyio -@pytest.mark.parametrize("yjs_client", "0", indirect=True) -async def test_pycrdt_yjs_0(yws_server, yjs_client): - ydoc = Doc() - async with connect("ws://127.0.0.1:1234/my-roomname") as websocket, WebsocketProvider( - ydoc, websocket - ): - ydoc["map"] = ymap = Map() - for v_in in range(10): - ymap["in"] = float(v_in) - v_out = await watch(ymap, "out").wait() - assert v_out == v_in + 1.0 +async def test_pycrdt_yjs_0(yws_server): + port = yws_server + with yjs_client(0, port): + ydoc = Doc() + async with aconnect_ws( + f"http://localhost:{port}/my-roomname" + ) as websocket, WebsocketProvider(ydoc, Websocket(websocket, "my-roomname")): + ydoc["map"] = ymap = Map() + for v_in in range(10): + ymap["in"] = float(v_in) + v_out = await watch(ymap, "out").wait() + assert v_out == v_in + 1.0 -@pytest.mark.anyio -@pytest.mark.parametrize("yjs_client", "1", indirect=True) -async def test_pycrdt_yjs_1(yws_server, yjs_client): - ydoc = Doc() - ydoc["cells"] = ycells = Array() - ydoc["state"] = ystate = Map() - ycells_change = watch(ycells) - ystate_change = watch(ystate) - async with connect("ws://127.0.0.1:1234/my-roomname") as websocket, WebsocketProvider( - ydoc, websocket - ): - await ycells_change.wait() - await ystate_change.wait() - assert ycells.to_py() == [{"metadata": {"foo": "bar"}, "source": "1 + 2"}] - assert ystate.to_py() == {"state": {"dirty": False}} +async def test_pycrdt_yjs_1(yws_server): + port = yws_server + with yjs_client(1, port): + ydoc = Doc() + ydoc["cells"] = ycells = Array() + ydoc["state"] = ystate = Map() + ycells_change = watch(ycells) + ystate_change = watch(ystate) + async with aconnect_ws( + f"http://localhost:{port}/my-roomname" + ) as websocket, WebsocketProvider(ydoc, Websocket(websocket, "my-roomname")): + await ycells_change.wait() + await ystate_change.wait() + assert ycells.to_py() == [{"metadata": {"foo": "bar"}, "source": "1 + 2"}] + assert ystate.to_py() == {"state": {"dirty": False}} diff --git a/tests/test_ystore.py b/tests/test_ystore.py index d374e3a..f94c190 100644 --- a/tests/test_ystore.py +++ b/tests/test_ystore.py @@ -9,6 +9,8 @@ from pycrdt_websocket.ystore import SQLiteYStore, TempFileYStore +pytestmark = pytest.mark.anyio + class MetadataCallback: def __init__(self): @@ -37,7 +39,6 @@ def __init__(self, *args, delete_db=False, **kwargs): super().__init__(*args, **kwargs) -@pytest.mark.anyio @pytest.mark.parametrize("YStore", (MyTempFileYStore, MySQLiteYStore)) async def test_ystore(YStore): store_name = "my_store" @@ -62,7 +63,6 @@ async def test_ystore(YStore): await ystore.stop() -@pytest.mark.anyio async def test_document_ttl_sqlite_ystore(test_ydoc): store_name = "my_store" ystore = MySQLiteYStore(store_name, delete_db=True) @@ -91,7 +91,6 @@ async def test_document_ttl_sqlite_ystore(test_ydoc): await ystore.stop() -@pytest.mark.anyio @pytest.mark.parametrize("YStore", (MyTempFileYStore, MySQLiteYStore)) async def test_version(YStore, caplog): store_name = "my_store" diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..3815efb --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,50 @@ +import subprocess +from contextlib import contextmanager + +from anyio import Lock, connect_tcp + + +class Websocket: + def __init__(self, websocket, path: str): + self._websocket = websocket + self._path = path + self._send_lock = Lock() + + @property + def path(self) -> str: + return self._path + + def __aiter__(self): + return self + + async def __anext__(self) -> bytes: + try: + message = await self.recv() + except Exception: + raise StopAsyncIteration() + return message + + async def send(self, message: bytes): + async with self._send_lock: + await self._websocket.send_bytes(message) + + async def recv(self) -> bytes: + b = await self._websocket.receive_bytes() + return bytes(b) + + +@contextmanager +def yjs_client(client_id: int, port: int): + p = subprocess.Popen(["node", f"tests/yjs_client_{client_id}.js", str(port)]) + yield p + p.kill() + + +async def ensure_server_running(host: str, port: int) -> None: + while True: + try: + await connect_tcp(host, port) + except OSError: + pass + else: + break diff --git a/tests/yjs_client_0.js b/tests/yjs_client_0.js index 92c87c0..33285c5 100644 --- a/tests/yjs_client_0.js +++ b/tests/yjs_client_0.js @@ -2,6 +2,7 @@ const Y = require('yjs') const WebsocketProvider = require('y-websocket').WebsocketProvider const ws = require('ws') +const port = process.argv[2] const ydoc = new Y.Doc() const ymap = ydoc.getMap('map') @@ -18,7 +19,7 @@ ymap.observe(event => { }) const wsProvider = new WebsocketProvider( - 'ws://127.0.0.1:1234', 'my-roomname', + `ws://127.0.0.1:${port}`, 'my-roomname', ydoc, { WebSocketPolyfill: ws } ) diff --git a/tests/yjs_client_1.js b/tests/yjs_client_1.js index cb743ea..038c4d5 100644 --- a/tests/yjs_client_1.js +++ b/tests/yjs_client_1.js @@ -2,10 +2,11 @@ const Y = require('yjs') const WebsocketProvider = require('y-websocket').WebsocketProvider const ws = require('ws') +const port = process.argv[2] const ydoc = new Y.Doc() const wsProvider = new WebsocketProvider( - 'ws://127.0.0.1:1234', 'my-roomname', + `ws://127.0.0.1:${port}`, 'my-roomname', ydoc, { WebSocketPolyfill: ws } )