From 367892ad4a503866ddd254033331c2cb6957ba85 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 21 Oct 2024 13:47:25 +0200 Subject: [PATCH] add sync tests --- tests/server.py | 14 ++ tests/sync/test_page_request_gc.py | 34 +++ tests/sync/test_route_web_socket.py | 316 ++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 tests/sync/test_page_request_gc.py create mode 100644 tests/sync/test_route_web_socket.py diff --git a/tests/server.py b/tests/server.py index f963d8a60..07ef6e3bf 100644 --- a/tests/server.py +++ b/tests/server.py @@ -218,6 +218,20 @@ def done_cb(task: asyncio.Task) -> None: future.add_done_callback(done_cb) yield cb_wrapper + @contextlib.contextmanager + def expect_websocket( + self, + ) -> Generator[ExpectResponse["WebSocketProtocol"], None, None]: + future = self.wait_for_web_socket() + + cb_wrapper: ExpectResponse["WebSocketProtocol"] = ExpectResponse() + + def done_cb(_: asyncio.Future) -> None: + cb_wrapper._value = future.result() + + future.add_done_callback(done_cb) + yield cb_wrapper + def set_auth(self, path: str, username: str, password: str) -> None: self.auth[path] = (username, password) diff --git a/tests/sync/test_page_request_gc.py b/tests/sync/test_page_request_gc.py new file mode 100644 index 000000000..bfddc2320 --- /dev/null +++ b/tests/sync/test_page_request_gc.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from playwright.sync_api import Page +from tests.server import Server + + +def test_should_work(page: Page, server: Server) -> None: + page.evaluate( + """() => { + globalThis.objectToDestroy = { hello: 'world' }; + globalThis.weakRef = new WeakRef(globalThis.objectToDestroy); + }""" + ) + page.request_gc() + assert page.evaluate("() => globalThis.weakRef.deref()") == {"hello": "world"} + + page.request_gc() + assert page.evaluate("() => globalThis.weakRef.deref()") == {"hello": "world"} + + page.evaluate("() => globalThis.objectToDestroy = null") + page.request_gc() + assert page.evaluate("() => globalThis.weakRef.deref()") is None diff --git a/tests/sync/test_route_web_socket.py b/tests/sync/test_route_web_socket.py new file mode 100644 index 000000000..1f6649b6d --- /dev/null +++ b/tests/sync/test_route_web_socket.py @@ -0,0 +1,316 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +import time +from typing import Any, Awaitable, Callable, Literal, Optional, Union + +from playwright.sync_api import Frame, Page, WebSocketRoute +from tests.server import Server, WebSocketProtocol + + +def assert_equal( + actual_cb: Callable[[], Union[Any, Awaitable[Any]]], expected: Any +) -> None: + __tracebackhide__ = True + start_time = time.time() + attempts = 0 + while True: + actual = actual_cb() + if actual == expected: + return + attempts += 1 + if time.time() - start_time > 10: + raise TimeoutError(f"Timed out after 10 seconds. Last actual was: {actual}") + time.sleep(0.1) + + +def setup_ws( + target: Union[Page, Frame], + port: int, + protocol: Union[Literal["blob"], Literal["arraybuffer"]], +) -> None: + target.goto("about:blank") + target.evaluate( + """({ port, binaryType }) => { + window.log = []; + window.ws = new WebSocket('ws://localhost:' + port + '/ws'); + window.ws.binaryType = binaryType; + window.ws.addEventListener('open', () => window.log.push('open')); + window.ws.addEventListener('close', event => window.log.push(`close code=${event.code} reason=${event.reason} wasClean=${event.wasClean}`)); + window.ws.addEventListener('error', event => window.log.push(`error`)); + window.ws.addEventListener('message', async event => { + let data; + if (typeof event.data === 'string') + data = event.data; + else if (event.data instanceof Blob) + data = 'blob:' + event.data.text(); + else + data = 'arraybuffer:' + (new Blob([event.data])).text(); + window.log.push(`message: data=${data} origin=${event.origin} lastEventId=${event.lastEventId}`); + }); + window.wsOpened = new Promise(f => window.ws.addEventListener('open', () => f())); + }""", + {"port": port, "binaryType": protocol}, + ) + + +def test_should_work_with_ws_close(page: Page, server: Server) -> None: + route: Optional["WebSocketRoute"] = None + + def _handle_ws(ws: WebSocketRoute) -> None: + ws.connect_to_server() + nonlocal route + route = ws + + page.route_web_socket(re.compile(".*"), _handle_ws) + + with server.expect_websocket() as ws_task: + setup_ws(page, server.PORT, "blob") + page.evaluate("window.wsOpened") + ws = ws_task.value + assert route + route.send("hello") + assert_equal( + lambda: page.evaluate("window.log"), + [ + "open", + f"message: data=hello origin=ws://localhost:{server.PORT} lastEventId=", + ], + ) + + closed_event = [] + ws.events.once("close", lambda code, reason: closed_event.append((code, reason))) + route.close(code=3009, reason="oops") + assert_equal( + lambda: page.evaluate("window.log"), + [ + "open", + f"message: data=hello origin=ws://localhost:{server.PORT} lastEventId=", + "close code=3009 reason=oops wasClean=true", + ], + ) + assert closed_event == [(3009, "oops")] + + +def test_should_pattern_match(page: Page, server: Server) -> None: + page.route_web_socket(re.compile(r".*/ws$"), lambda ws: ws.connect_to_server()) + page.route_web_socket( + "**/mock-ws", lambda ws: ws.on_message(lambda message: ws.send("mock-response")) + ) + + page.goto("about:blank") + with server.expect_websocket() as ws_info: + page.evaluate( + """async ({ port }) => { + window.log = []; + window.ws1 = new WebSocket('ws://localhost:' + port + '/ws'); + window.ws1.addEventListener('message', event => window.log.push(`ws1:${event.data}`)); + window.ws2 = new WebSocket('ws://localhost:' + port + '/something/something/mock-ws'); + window.ws2.addEventListener('message', event => window.log.push(`ws2:${event.data}`)); + await Promise.all([ + new Promise(f => window.ws1.addEventListener('open', f)), + new Promise(f => window.ws2.addEventListener('open', f)), + ]); + }""", + {"port": server.PORT}, + ) + ws = ws_info.value + ws.events.on("message", lambda payload, isBinary: ws.sendMessage(b"response")) + + page.evaluate("window.ws1.send('request')") + assert_equal(lambda: page.evaluate("window.log"), ["ws1:response"]) + + page.evaluate("window.ws2.send('request')") + assert_equal( + lambda: page.evaluate("window.log"), ["ws1:response", "ws2:mock-response"] + ) + + +def test_should_work_with_server(page: Page, server: Server) -> None: + route = None + + def _handle_ws(ws: WebSocketRoute) -> None: + server = ws.connect_to_server() + + def _ws_on_message(message: Union[str, bytes]) -> None: + if message == "to-respond": + ws.send("response") + return + if message == "to-block": + return + if message == "to-modify": + server.send("modified") + return + server.send(message) + + ws.on_message(_ws_on_message) + + def _server_on_message(message: Union[str, bytes]) -> None: + if message == "to-block": + return + if message == "to-modify": + ws.send("modified") + return + ws.send(message) + + server.on_message(_server_on_message) + server.send("fake") + nonlocal route + route = ws + + page.route_web_socket(re.compile(".*"), _handle_ws) + log = [] + + def _once_web_socket_connection(ws: WebSocketProtocol) -> None: + ws.events.on( + "message", lambda data, is_binary: log.append(f"message: {data.decode()}") + ) + ws.events.on( + "close", + lambda code, reason: log.append(f"close: code={code} reason={reason}"), + ) + + server.once_web_socket_connection(_once_web_socket_connection) + + with server.expect_websocket() as ws_info: + setup_ws(page, server.PORT, "blob") + page.evaluate("window.wsOpened") + ws = ws_info.value + assert_equal(lambda: log, ["message: fake"]) + + ws.sendMessage(b"to-modify") + ws.sendMessage(b"to-block") + ws.sendMessage(b"pass-server") + assert_equal( + lambda: page.evaluate("window.log"), + [ + "open", + f"message: data=modified origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=pass-server origin=ws://localhost:{server.PORT} lastEventId=", + ], + ) + + page.evaluate( + """() => { + window.ws.send('to-respond'); + window.ws.send('to-modify'); + window.ws.send('to-block'); + window.ws.send('pass-client'); + }""" + ) + assert_equal( + lambda: log, ["message: fake", "message: modified", "message: pass-client"] + ) + assert_equal( + lambda: page.evaluate("window.log"), + [ + "open", + f"message: data=modified origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=pass-server origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=response origin=ws://localhost:{server.PORT} lastEventId=", + ], + ) + assert route + route.send("another") + assert_equal( + lambda: page.evaluate("window.log"), + [ + "open", + f"message: data=modified origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=pass-server origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=response origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=another origin=ws://localhost:{server.PORT} lastEventId=", + ], + ) + + page.evaluate( + """() => { + window.ws.send('pass-client-2'); + }""" + ) + assert_equal( + lambda: log, + [ + "message: fake", + "message: modified", + "message: pass-client", + "message: pass-client-2", + ], + ) + + page.evaluate( + """() => { + window.ws.close(3009, 'problem'); + }""" + ) + assert_equal( + lambda: log, + [ + "message: fake", + "message: modified", + "message: pass-client", + "message: pass-client-2", + "close: code=3009 reason=problem", + ], + ) + + +def test_should_work_without_server(page: Page, server: Server) -> None: + route = None + + def _handle_ws(ws: WebSocketRoute) -> None: + def _ws_on_message(message: Union[str, bytes]) -> None: + if message == "to-respond": + ws.send("response") + + ws.on_message(_ws_on_message) + nonlocal route + route = ws + + page.route_web_socket(re.compile(".*"), _handle_ws) + setup_ws(page, server.PORT, "blob") + + page.evaluate( + """async () => { + await window.wsOpened; + window.ws.send('to-respond'); + window.ws.send('to-block'); + window.ws.send('to-respond'); + }""" + ) + + assert_equal( + lambda: page.evaluate("window.log"), + [ + "open", + f"message: data=response origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=response origin=ws://localhost:{server.PORT} lastEventId=", + ], + ) + assert route + route.send("another") + # wait for the message to be processed + page.wait_for_timeout(100) + route.close(code=3008, reason="oops") + assert_equal( + lambda: page.evaluate("window.log"), + [ + "open", + f"message: data=response origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=response origin=ws://localhost:{server.PORT} lastEventId=", + f"message: data=another origin=ws://localhost:{server.PORT} lastEventId=", + "close code=3008 reason=oops wasClean=true", + ], + )