From 4ffb41ed861169d57befe7cf888b1a8f9bc3bdc1 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 20 Jul 2020 12:22:54 +0200 Subject: [PATCH] feat: added selector engine --- playwright/__init__.py | 2 + playwright/js_handle.py | 8 +- playwright/object_factory.py | 3 + playwright/playwright.py | 9 +- playwright/selectors.py | 39 +++++++ tests/assets/sectionselectorengine.js | 10 ++ tests/conftest.py | 5 + tests/test_queryselector.py | 154 ++++++++++++++++++++++++++ tests/utils.py | 12 +- 9 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 playwright/selectors.py create mode 100644 tests/assets/sectionselectorengine.js create mode 100644 tests/test_queryselector.py diff --git a/playwright/__init__.py b/playwright/__init__.py index 1ae7a7a9b..7db2a1221 100644 --- a/playwright/__init__.py +++ b/playwright/__init__.py @@ -20,6 +20,7 @@ firefox = playwright_object.firefox webkit = playwright_object.webkit devices = playwright_object.devices +selectors = playwright_object.selectors browser_types = playwright_object.browser_types Error = helper.Error TimeoutError = helper.TimeoutError @@ -30,6 +31,7 @@ "firefox", "webkit", "devices", + "selectors", "Error", "TimeoutError", ] diff --git a/playwright/js_handle.py b/playwright/js_handle.py index 7b38427ed..e8f2ded21 100644 --- a/playwright/js_handle.py +++ b/playwright/js_handle.py @@ -73,10 +73,10 @@ async def getProperty(self, name: str) -> "JSHandle": return from_channel(await self._channel.send("getProperty", dict(name=name))) async def getProperties(self) -> Dict[str, "JSHandle"]: - map = dict() - for property in await self._channel.send("getPropertyList"): - map[property["name"]] = from_channel(property["value"]) - return map + return { + prop["name"]: from_channel(prop["value"]) + for prop in await self._channel.send("getPropertyList") + } def asElement(self) -> Optional["ElementHandle"]: return None diff --git a/playwright/object_factory.py b/playwright/object_factory.py index a3d5a310b..e019d30a3 100644 --- a/playwright/object_factory.py +++ b/playwright/object_factory.py @@ -28,6 +28,7 @@ from playwright.network import Request, Response, Route from playwright.page import BindingCall, Page from playwright.playwright import Playwright +from playwright.selectors import Selectors from playwright.worker import Worker @@ -73,4 +74,6 @@ def create_remote_object( return Route(scope, guid, initializer) if type == "worker": return Worker(scope, guid, initializer) + if type == "selectors": + return Selectors(scope, guid, initializer) return DummyObject(scope, guid, initializer) diff --git a/playwright/playwright.py b/playwright/playwright.py index 7239ab2ac..85aa6207f 100644 --- a/playwright/playwright.py +++ b/playwright/playwright.py @@ -16,6 +16,7 @@ from playwright.browser_type import BrowserType from playwright.connection import ChannelOwner, ConnectionScope, from_channel +from playwright.selectors import Selectors class Playwright(ChannelOwner): @@ -24,9 +25,11 @@ def __init__(self, scope: ConnectionScope, guid: str, initializer: Dict) -> None self.chromium: BrowserType = from_channel(initializer["chromium"]) self.firefox: BrowserType = from_channel(initializer["firefox"]) self.webkit: BrowserType = from_channel(initializer["webkit"]) - self.devices = dict() - for device in initializer["deviceDescriptors"]: - self.devices[device["name"]] = device["descriptor"] + self.selectors: Selectors = from_channel(initializer["selectors"]) + self.devices = { + device["name"]: device["descriptor"] + for device in initializer["deviceDescriptors"] + } self.browser_types: Dict[str, BrowserType] = dict( chromium=self.chromium, webkit=self.webkit, firefox=self.firefox ) diff --git a/playwright/selectors.py b/playwright/selectors.py new file mode 100644 index 000000000..ee2f24cc8 --- /dev/null +++ b/playwright/selectors.py @@ -0,0 +1,39 @@ +# 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 typing import Dict, Optional + +from playwright.connection import ChannelOwner, ConnectionScope +from playwright.element_handle import ElementHandle + + +class Selectors(ChannelOwner): + def __init__(self, scope: ConnectionScope, guid: str, initializer: Dict) -> None: + super().__init__(scope, guid, initializer) + + async def register( + self, name: str, source: str = "", path: str = None, contentScript: bool = False + ) -> None: + if path: + with open(path, "r") as file: + source = file.read() + await self._channel.send( + "register", + dict(name=name, source=source, options={"contentScript": contentScript}), + ) + + async def _createSelector(self, name: str, handle: ElementHandle) -> Optional[str]: + return await self._channel.send( + "createSelector", dict(name=name, handle=handle._channel) + ) diff --git a/tests/assets/sectionselectorengine.js b/tests/assets/sectionselectorengine.js new file mode 100644 index 000000000..e831d03ce --- /dev/null +++ b/tests/assets/sectionselectorengine.js @@ -0,0 +1,10 @@ +({ + create(root, target) { + }, + query(root, selector) { + return root.querySelector('section'); + }, + queryAll(root, selector) { + return Array.from(root.querySelectorAll('section')); + } +}) diff --git a/tests/conftest.py b/tests/conftest.py index c49014b20..7441acc39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,11 @@ def event_loop(): loop.close() +@pytest.fixture(scope="session") +def selectors(): + return playwright.selectors + + @pytest.fixture(scope="session") def browser_type(browser_name: str): return playwright.browser_types[browser_name] diff --git a/tests/test_queryselector.py b/tests/test_queryselector.py new file mode 100644 index 000000000..a3bfb6fb4 --- /dev/null +++ b/tests/test_queryselector.py @@ -0,0 +1,154 @@ +import os + +import pytest + +from playwright.helper import Error +from playwright.page import Page + + +async def test_selectors_register_should_work(selectors, page: Page, utils): + await utils.register_selector_engine( + selectors, + "tag", + """{ + create(root, target) { + return target.nodeName; + }, + query(root, selector) { + return root.querySelector(selector); + }, + queryAll(root, selector) { + return Array.from(root.querySelectorAll(selector)); + } + }""", + ) + await page.setContent("
") + assert ( + await selectors._createSelector("tag", await page.querySelector("div")) == "DIV" + ) + assert await page.evalOnSelector("tag=DIV", "e => e.nodeName") == "DIV" + assert await page.evalOnSelector("tag=SPAN", "e => e.nodeName") == "SPAN" + assert await page.evalOnSelectorAll("tag=DIV", "es => es.length") == 2 + + # Selector names are case-sensitive. + with pytest.raises(Error) as exc: + await page.querySelector("tAG=DIV") + assert 'Unknown engine "tAG" while parsing selector tAG=DIV' in exc.value.message + + +async def test_selectors_register_should_work_with_path(selectors, page: Page, utils): + await utils.register_selector_engine( + selectors, + "foo", + path=os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "assets/sectionselectorengine.js", + ), + ) + await page.setContent("
") + assert await page.evalOnSelector("foo=whatever", "e => e.nodeName") == "SECTION" + + +async def test_selectors_register_should_work_in_main_and_isolated_world( + selectors, page: Page, utils +): + dummy_selector_script = """{ + create(root, target) { }, + query(root, selector) { + return window.__answer; + }, + queryAll(root, selector) { + return [document.body, document.documentElement, window.__answer]; + } + }""" + + await utils.register_selector_engine(selectors, "main", dummy_selector_script) + await utils.register_selector_engine( + selectors, "isolated", dummy_selector_script, contentScript=True + ) + await page.setContent("
") + await page.evaluate('() => window.__answer = document.querySelector("span")') + # Works in main if asked. + assert await page.evalOnSelector("main=ignored", "e => e.nodeName") == "SPAN" + assert ( + await page.evalOnSelector("css=div >> main=ignored", "e => e.nodeName") + == "SPAN" + ) + assert await page.evalOnSelectorAll( + "main=ignored", "es => window.__answer !== undefined" + ) + assert ( + await page.evalOnSelectorAll("main=ignored", "es => es.filter(e => e).length") + == 3 + ) + # Works in isolated by default. + assert await page.querySelector("isolated=ignored") is None + assert await page.querySelector("css=div >> isolated=ignored") is None + # $$eval always works in main, to avoid adopting nodes one by one. + assert await page.evalOnSelectorAll( + "isolated=ignored", "es => window.__answer !== undefined" + ) + assert ( + await page.evalOnSelectorAll( + "isolated=ignored", "es => es.filter(e => e).length" + ) + == 3 + ) + # At least one engine in main forces all to be in main. + assert ( + await page.evalOnSelector("main=ignored >> isolated=ignored", "e => e.nodeName") + == "SPAN" + ) + assert ( + await page.evalOnSelector("isolated=ignored >> main=ignored", "e => e.nodeName") + == "SPAN" + ) + # Can be chained to css. + assert ( + await page.evalOnSelector("main=ignored >> css=section", "e => e.nodeName") + == "SECTION" + ) + + +async def test_selectors_register_should_handle_errors(selectors, page: Page, utils): + with pytest.raises(Error) as exc: + await page.querySelector("neverregister=ignored") + assert ( + 'Unknown engine "neverregister" while parsing selector neverregister=ignored' + in exc.value.message + ) + + dummy_selector_engine_script = """{ + create(root, target) { + return target.nodeName; + }, + query(root, selector) { + return root.querySelector('dummy'); + }, + queryAll(root, selector) { + return Array.from(root.querySelectorAll('dummy')); + } + }""" + + with pytest.raises(Error) as exc: + await selectors.register("$", dummy_selector_engine_script) + assert ( + "Selector engine name may only contain [a-zA-Z0-9_] characters" + == exc.value.message + ) + + # Selector names are case-sensitive. + await utils.register_selector_engine( + selectors, "dummy", dummy_selector_engine_script + ) + await utils.register_selector_engine( + selectors, "duMMy", dummy_selector_engine_script + ) + + with pytest.raises(Error) as exc: + await selectors.register("dummy", dummy_selector_engine_script) + assert exc.value.message == '"dummy" selector engine has been already registered' + + with pytest.raises(Error) as exc: + await selectors.register("css", dummy_selector_engine_script) + assert exc.value.message == '"css" is a predefined selector engine' diff --git a/tests/utils.py b/tests/utils.py index 453ea32f4..928a5acc1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -17,8 +17,9 @@ from playwright.element_handle import ElementHandle from playwright.frame import Frame -from playwright.helper import Viewport +from playwright.helper import Error, Viewport from playwright.page import Page +from playwright.selectors import Selectors class Utils: @@ -60,5 +61,14 @@ async def verify_viewport(self, page: Page, width: int, height: int): assert await page.evaluate("window.innerWidth") == width assert await page.evaluate("window.innerHeight") == height + async def register_selector_engine( + self, selectors: Selectors, *args, **kwargs + ) -> None: + try: + await selectors.register(*args, **kwargs) + except Error as exc: + if "has been already registered" not in exc.message: + raise exc + utils = Utils()