From 98cca30a022957db7b363ba81a35348ec9d97520 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 24 Jul 2020 00:48:18 +0200 Subject: [PATCH] enh(sync): event class mapping to sync instances --- playwright/sync.py | 65 ++++++++++- playwright/sync_base.py | 34 +++++- playwright/wait_helper.py | 5 +- scripts/generate_sync_api.py | 3 +- tests/test_sync.py | 201 +++++++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 tests/test_sync.py diff --git a/playwright/sync.py b/playwright/sync.py index e7861e3cd..7f66a36da 100644 --- a/playwright/sync.py +++ b/playwright/sync.py @@ -15,7 +15,7 @@ import sys import typing -from playwright.sync_base import SyncBase +from playwright.sync_base import SyncBase, mapping if sys.version_info >= (3, 8): # pragma: no cover from typing import Literal @@ -128,6 +128,9 @@ def isNavigationRequest(self) -> bool: return self._sync(self._async_obj.isNavigationRequest()) +mapping.register(RequestAsync, Request) + + class Response(SyncBase): def __init__(self, obj: ResponseAsync): super().__init__(obj) @@ -200,6 +203,9 @@ def json(self) -> typing.Union[typing.Dict, typing.List]: return self._sync(self._async_obj.json()) +mapping.register(ResponseAsync, Response) + + class Route(SyncBase): def __init__(self, obj: RouteAsync): super().__init__(obj) @@ -258,6 +264,9 @@ def continue_( ) +mapping.register(RouteAsync, Route) + + class Keyboard(SyncBase): def __init__(self, obj: KeyboardAsync): super().__init__(obj) @@ -305,6 +314,9 @@ def press(self, key: str, delay: int = None) -> NoneType: return self._sync(self._async_obj.press(key=key, delay=delay)) +mapping.register(KeyboardAsync, Keyboard) + + class Mouse(SyncBase): def __init__(self, obj: MouseAsync): super().__init__(obj) @@ -371,6 +383,9 @@ def dblclick( ) +mapping.register(MouseAsync, Mouse) + + class JSHandle(SyncBase): def __init__(self, obj: JSHandleAsync): super().__init__(obj) @@ -440,6 +455,9 @@ def jsonValue(self) -> typing.Any: return self._sync(self._async_obj.jsonValue()) +mapping.register(JSHandleAsync, JSHandle) + + class ElementHandle(SyncBase): def __init__(self, obj: ElementHandleAsync): super().__init__(obj) @@ -707,6 +725,9 @@ def evalOnSelectorAll( ) +mapping.register(ElementHandleAsync, ElementHandle) + + class Accessibility(SyncBase): def __init__(self, obj: AccessibilityAsync): super().__init__(obj) @@ -746,6 +767,9 @@ def snapshot( ) +mapping.register(AccessibilityAsync, Accessibility) + + class Frame(SyncBase): def __init__(self, obj: FrameAsync): super().__init__(obj) @@ -1174,6 +1198,9 @@ def title(self) -> str: return self._sync(self._async_obj.title()) +mapping.register(FrameAsync, Frame) + + class Worker(SyncBase): def __init__(self, obj: WorkerAsync): super().__init__(obj) @@ -1226,6 +1253,9 @@ def evaluateHandle( ) +mapping.register(WorkerAsync, Worker) + + class Selectors(SyncBase): def __init__(self, obj: SelectorsAsync): super().__init__(obj) @@ -1267,6 +1297,9 @@ def register( ) +mapping.register(SelectorsAsync, Selectors) + + class ConsoleMessage(SyncBase): def __init__(self, obj: ConsoleMessageAsync): super().__init__(obj) @@ -1315,6 +1348,9 @@ def location(self) -> ConsoleMessageLocation: return self._async_obj.location +mapping.register(ConsoleMessageAsync, ConsoleMessage) + + class Dialog(SyncBase): def __init__(self, obj: DialogAsync): super().__init__(obj) @@ -1361,6 +1397,9 @@ def dismiss(self) -> NoneType: return self._sync(self._async_obj.dismiss()) +mapping.register(DialogAsync, Dialog) + + class Download(SyncBase): def __init__(self, obj: DownloadAsync): super().__init__(obj) @@ -1410,6 +1449,9 @@ def path(self) -> typing.Union[str, NoneType]: return self._sync(self._async_obj.path()) +mapping.register(DownloadAsync, Download) + + class BindingCall(SyncBase): def __init__(self, obj: BindingCallAsync): super().__init__(obj) @@ -1445,6 +1487,9 @@ def call(self, func: typing.Callable[[typing.Dict], typing.Any]) -> NoneType: return self._sync(self._async_obj.call(func=func)) +mapping.register(BindingCallAsync, BindingCall) + + class Page(SyncBase): def __init__(self, obj: PageAsync): super().__init__(obj) @@ -2075,6 +2120,9 @@ def pdf( ) +mapping.register(PageAsync, Page) + + class BrowserContext(SyncBase): def __init__(self, obj: BrowserContextAsync): super().__init__(obj) @@ -2197,6 +2245,9 @@ def close(self) -> NoneType: return self._sync(self._async_obj.close()) +mapping.register(BrowserContextAsync, BrowserContext) + + class Browser(SyncBase): def __init__(self, obj: BrowserAsync): super().__init__(obj) @@ -2327,6 +2378,9 @@ def close(self) -> NoneType: return self._sync(self._async_obj.close()) +mapping.register(BrowserAsync, Browser) + + class BrowserServer(SyncBase): def __init__(self, obj: BrowserServerAsync): super().__init__(obj) @@ -2373,6 +2427,9 @@ def close(self) -> NoneType: return self._sync(self._async_obj.close()) +mapping.register(BrowserServerAsync, BrowserServer) + + class BrowserType(SyncBase): def __init__(self, obj: BrowserTypeAsync): super().__init__(obj) @@ -2568,6 +2625,9 @@ def connect( ) +mapping.register(BrowserTypeAsync, BrowserType) + + class Playwright(SyncBase): def __init__(self, obj: PlaywrightAsync): super().__init__(obj) @@ -2618,3 +2678,6 @@ def selectors(self) -> "Selectors": @property def devices(self) -> typing.Dict: return self._async_obj.devices + + +mapping.register(PlaywrightAsync, Playwright) diff --git a/playwright/sync_base.py b/playwright/sync_base.py index 66e2e4ff7..bff56e805 100644 --- a/playwright/sync_base.py +++ b/playwright/sync_base.py @@ -13,13 +13,29 @@ # limitations under the License. import asyncio -from typing import Any, Callable, Union +from typing import Any, Callable, List, Tuple, Union from playwright.wait_helper import WaitHelper loop = asyncio.get_event_loop() +class AsyncToSyncMapping: + mapping: List[Tuple[type, type]] = [] + + def register(self, async_class: type, sync_class: type) -> None: + self.mapping.append((async_class, sync_class)) + + def get_sync_class(self, input_async_inst: object) -> Any: + for (async_class, sync_class) in self.mapping: + if isinstance(input_async_inst, async_class): + return sync_class + raise ValueError("should never happen") + + +mapping = AsyncToSyncMapping() + + class Event: def __init__( self, @@ -34,14 +50,15 @@ def __init__( wait_helper.reject_on_timeout( timeout or 30000, f'Timeout while waiting for event "${event}"' ) - self._future = asyncio.create_task( + self._future = loop.create_task( wait_helper.wait_for_event(sync_base._async_obj, event, predicate) ) @property def value(self) -> Any: if not self._value: - self._value = loop.run_until_complete(self._future) + value = loop.run_until_complete(self._future) + self._value = mapping.get_sync_class(value)._from_async(value) return self._value @@ -66,11 +83,20 @@ class SyncBase: def __init__(self, async_obj: Any) -> None: self._async_obj = async_obj + def __str__(self) -> str: + return self._async_obj.__str__() + def _sync(self, future: asyncio.Future) -> Any: return loop.run_until_complete(future) + def _map_event(self, handler: Callable[[Any], None]) -> Callable[[Any], None]: + return lambda event: handler(mapping.get_sync_class(event)._from_async(event)) + def on(self, event_name: str, handler: Any) -> None: - self._async_obj.on(event_name, handler) + self._async_obj.on(event_name, self._map_event(handler)) + + def once(self, event_name: str, handler: Any) -> None: + self._async_obj.once(event_name, self._map_event(handler)) def remove_listener(self, event_name: str, handler: Any) -> None: self._async_obj.remove_listener(event_name, handler) diff --git a/playwright/wait_helper.py b/playwright/wait_helper.py index 155aa498f..de9257ef3 100644 --- a/playwright/wait_helper.py +++ b/playwright/wait_helper.py @@ -23,6 +23,7 @@ class WaitHelper: def __init__(self) -> None: self._failures: List[asyncio.Future] = [] + self._loop = asyncio.get_event_loop() def reject_on_event( self, @@ -37,7 +38,7 @@ def reject_on_timeout(self, timeout: int, message: str) -> None: if timeout == 0: return self.reject_on( - asyncio.create_task(asyncio.sleep(timeout / 1000)), TimeoutError(message) + self._loop.create_task(asyncio.sleep(timeout / 1000)), TimeoutError(message) ) def reject_on(self, future: asyncio.Future, error: Error) -> None: @@ -45,7 +46,7 @@ async def future_wrapper() -> Error: await future return error - result = asyncio.create_task(future_wrapper()) + result = self._loop.create_task(future_wrapper()) result.add_done_callback(lambda f: future.cancel()) self._failures.append(result) diff --git a/scripts/generate_sync_api.py b/scripts/generate_sync_api.py index af5b59ee8..05d99f795 100644 --- a/scripts/generate_sync_api.py +++ b/scripts/generate_sync_api.py @@ -200,6 +200,7 @@ def generate(t: Any) -> None: prefix = " return " + prefix + f"self._sync(self._async_obj.{name}(" suffix = "))" + suffix print(f"{prefix}{arguments(value, len(prefix))}{suffix}") + print(f"mapping.register({short_name(t)}Async, {short_name(t)})") def main() -> None: @@ -222,7 +223,7 @@ def main() -> None: import typing import sys -from playwright.sync_base import SyncBase +from playwright.sync_base import SyncBase, mapping if sys.version_info >= (3, 8): # pragma: no cover from typing import Literal diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 000000000..e7e4d9036 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,201 @@ +# 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 os + +import pytest + +from playwright import Error, chromium, firefox, webkit +from playwright.sync import Browser, Page + + +@pytest.fixture(scope="session") +def browser(browser_name, launch_arguments): + browser_type = None + if browser_name == "chromium": + browser_type = chromium + elif browser_name == "firefox": + browser_type = firefox + elif browser_name == "webkit": + browser_type = webkit + browser = browser_type.launch(**launch_arguments) + yield browser + browser.close() + + +@pytest.fixture +def context(browser): + context = browser.newContext() + yield context + context.close() + + +@pytest.fixture +def page(context): + page = context.newPage() + yield page + page.close() + + +def test_sync_query_selector(page): + page.setContent( + """ +

Bar

+ """ + ) + assert ( + page.querySelector("#foo").innerText() == page.querySelector("h1").innerText() + ) + + +def test_sync_click(page): + page.setContent( + """ + + """ + ) + page.click("text=Bar") + assert page.evaluate("()=>window.clicked") + + +def test_sync_nested_query_selector(page): + page.setContent( + """ +
+ + + +
+ """ + ) + e1 = page.querySelector("#one") + e2 = e1.querySelector(".two") + e3 = e2.querySelector("label") + assert e3.innerText() == "MyValue" + + +def test_sync_handle_multiple_pages(context): + page1 = context.newPage() + page2 = context.newPage() + assert len(context.pages) == 2 + page1.setContent("one") + page2.setContent("two") + assert "one" in page1.content() + assert "two" in page2.content() + page1.close() + assert len(context.pages) == 1 + page2.close() + assert len(context.pages) == 0 + for page in [page1, page2]: + with pytest.raises(Error): + page.content() + + +def test_sync_wait_for_event(page: Page): + with page.expect_event("popup", timeout=10000) as popup: + page.evaluate("() => window.open('https://example.com')") + assert popup.value + + +def test_sync_wait_for_event_raise(page): + with pytest.raises(Error): + with page.expect_event("popup", timeout=500) as popup: + assert False + assert popup.value is None + + +def test_sync_make_existing_page_sync(page): + page = page + assert page.evaluate("() => ({'playwright': true})") == {"playwright": True} + page.setContent("

myElement

") + page.waitForSelector("text=myElement") + + +def test_sync_network_events(page, server): + server.set_route( + "/hello-world", + lambda request: ( + request.setHeader("Content-Type", "text/plain"), + request.write(b"Hello world"), + request.finish(), + ), + ) + page.goto(server.EMPTY_PAGE) + messages = [] + page.on( + "request", lambda request: messages.append(f">>{request.method}{request.url}") + ) + page.on( + "response", + lambda response: messages.append(f"<<{response.status}{response.url}"), + ) + response = page.evaluate("""async ()=> (await fetch("/hello-world")).text()""") + assert response == "Hello world" + assert messages == [ + f">>GET{server.PREFIX}/hello-world", + f"<<200{server.PREFIX}/hello-world", + ] + + +def test_console_should_work(page): + messages = [] + page.once("console", lambda m: messages.append(m)) + page.evaluate('() => console.log("hello", 5, {foo: "bar"})'), + assert len(messages) == 1 + message = messages[0] + assert message.text == "hello 5 JSHandle@object" + assert str(message) == "hello 5 JSHandle@object" + assert message.type == "log" + assert message.args[0].jsonValue() == "hello" + assert message.args[1].jsonValue() == 5 + assert message.args[2].jsonValue() == {"foo": "bar"} + + +def test_sync_download(browser: Browser, server): + server.set_route( + "/downloadWithFilename", + lambda request: ( + request.setHeader("Content-Type", "application/octet-stream"), + request.setHeader("Content-Disposition", "attachment; filename=file.txt"), + request.write(b"Hello world"), + request.finish(), + ), + ) + page = browser.newPage(acceptDownloads=True) + page.setContent(f'download') + + with page.expect_event("download") as download: + page.click("a") + assert download.value + assert download.value.suggestedFilename == "file.txt" + path = download.value.path() + assert os.path.isfile(path) + with open(path, "r") as fd: + assert fd.read() == "Hello world" + page.close() + + +def test_sync_workers_page_workers(page: Page, server): + with page.expect_event("worker") as event_worker: + page.goto(server.PREFIX + "/worker/worker.html") + assert event_worker.value + worker = page.workers[0] + assert "worker.js" in worker.url + + assert worker.evaluate('() => self["workerFunction"]()') == "worker function result" + + page.goto(server.EMPTY_PAGE) + assert len(page.workers) == 0