From 27e8a9d693d7391f43e4401a814fe5e0c97c9444 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Thu, 26 Sep 2024 14:30:43 +0530 Subject: [PATCH 01/13] [py] override default locator converter for python --- .../webdriver/remote/locator_converter.py | 27 ++++++++++ py/selenium/webdriver/remote/webdriver.py | 28 +++------- .../remote/remote_custom_locator_tests.py | 54 +++++++++++++++++++ 3 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 py/selenium/webdriver/remote/locator_converter.py create mode 100644 py/test/selenium/webdriver/remote/remote_custom_locator_tests.py diff --git a/py/selenium/webdriver/remote/locator_converter.py b/py/selenium/webdriver/remote/locator_converter.py new file mode 100644 index 0000000000000..53d67c3876924 --- /dev/null +++ b/py/selenium/webdriver/remote/locator_converter.py @@ -0,0 +1,27 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you 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. + +class LocatorConverter: + def convert(self, by, value): + # Default conversion logic + if by == "id": + return "css selector", f'[id="{value}"]' + elif by == "class name": + return "css selector", f".{value}" + elif by == "name": + return "css selector", f'[name="{value}"]' + return by, value diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 41c4645bdc686..486863810b6b5 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -58,6 +58,7 @@ from .errorhandler import ErrorHandler from .file_detector import FileDetector from .file_detector import LocalFileDetector +from .locator_converter import LocatorConverter from .mobile import Mobile from .remote_connection import RemoteConnection from .script_key import ScriptKey @@ -171,6 +172,7 @@ def __init__( keep_alive: bool = True, file_detector: Optional[FileDetector] = None, options: Optional[Union[BaseOptions, List[BaseOptions]]] = None, + locator_converter: Optional[LocatorConverter] = None, ) -> None: """Create a new driver that will issue commands using the wire protocol. @@ -185,6 +187,8 @@ def __init__( - options - instance of a driver options.Options class """ + self.locator_converter = locator_converter or LocatorConverter() + if isinstance(options, list): capabilities = create_matches(options) _ignore_local_proxy = False @@ -729,22 +733,14 @@ def find_element(self, by=By.ID, value: Optional[str] = None) -> WebElement: :rtype: WebElement """ + by, value = self.locator_converter.convert(by, value) + if isinstance(by, RelativeBy): elements = self.find_elements(by=by, value=value) if not elements: raise NoSuchElementException(f"Cannot locate relative element with: {by.root}") return elements[0] - if by == By.ID: - by = By.CSS_SELECTOR - value = f'[id="{value}"]' - elif by == By.CLASS_NAME: - by = By.CSS_SELECTOR - value = f".{value}" - elif by == By.NAME: - by = By.CSS_SELECTOR - value = f'[name="{value}"]' - return self.execute(Command.FIND_ELEMENT, {"using": by, "value": value})["value"] def find_elements(self, by=By.ID, value: Optional[str] = None) -> List[WebElement]: @@ -757,22 +753,14 @@ def find_elements(self, by=By.ID, value: Optional[str] = None) -> List[WebElemen :rtype: list of WebElement """ + by, value = self.locator_converter.convert(by, value) + if isinstance(by, RelativeBy): _pkg = ".".join(__name__.split(".")[:-1]) raw_function = pkgutil.get_data(_pkg, "findElements.js").decode("utf8") find_element_js = f"/* findElements */return ({raw_function}).apply(null, arguments);" return self.execute_script(find_element_js, by.to_dict()) - if by == By.ID: - by = By.CSS_SELECTOR - value = f'[id="{value}"]' - elif by == By.CLASS_NAME: - by = By.CSS_SELECTOR - value = f".{value}" - elif by == By.NAME: - by = By.CSS_SELECTOR - value = f'[name="{value}"]' - # Return empty list if driver returns null # See https://github.com/SeleniumHQ/selenium/issues/4555 return self.execute(Command.FIND_ELEMENTS, {"using": by, "value": value})["value"] or [] diff --git a/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py new file mode 100644 index 0000000000000..4a5bc21338f7f --- /dev/null +++ b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py @@ -0,0 +1,54 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you 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 pytest + +from selenium import webdriver +from selenium.webdriver.remote.locator_converter import LocatorConverter +from selenium.webdriver.remote.webdriver import WebDriver + + +class CustomLocatorConverter(LocatorConverter): + def convert(self, by, value): + # Custom conversion logic + if by == "custom": + return "css selector", f'[custom-attr="{value}"]' + return super().convert(by, value) + + +@pytest.fixture +def driver() -> WebDriver: + # Setup for driver + options = webdriver.ChromeOptions() + driver = webdriver.Chrome(options=options) + driver.locator_converter = CustomLocatorConverter() + + driver.get("data:text/html,
Test
") + yield driver + # Teardown after test + driver.quit() + + +def test_find_element_with_custom_locator(driver: WebDriver) -> None: + element = driver.find_element("custom", "example") + assert element is not None + assert element.text == "Test" + + +def test_find_elements_with_custom_locator(driver: WebDriver) -> None: + elements = driver.find_elements("custom", "example") + assert len(elements) == 1 + assert elements[0].text == "Test" From 092be176de7dc0d25ce854efc9b87050f39f3034 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Tue, 8 Oct 2024 13:30:31 +0530 Subject: [PATCH 02/13] Support registering custom finders in py --- py/selenium/webdriver/common/by.py | 16 ++++++++++++++++ .../webdriver/remote/locator_converter.py | 1 + .../common/driver_element_finding_tests.py | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/py/selenium/webdriver/common/by.py b/py/selenium/webdriver/common/by.py index 56a3f96d6fbb8..65a74649f070e 100644 --- a/py/selenium/webdriver/common/by.py +++ b/py/selenium/webdriver/common/by.py @@ -16,7 +16,9 @@ # under the License. """The By implementation.""" +from typing import Dict from typing import Literal +from typing import Optional class By: @@ -31,5 +33,19 @@ class By: CLASS_NAME = "class name" CSS_SELECTOR = "css selector" + _custom_finders: Dict[str, str] = {} + + @classmethod + def register_custom_finder(cls, name: str, strategy: str) -> None: + cls._custom_finders[name] = strategy + + @classmethod + def get_finder(cls, name: str) -> Optional[str]: + return cls._custom_finders.get(name) or getattr(cls, name.upper(), None) + + @classmethod + def clear_custom_finders(cls) -> None: + cls._custom_finders.clear() + ByType = Literal["id", "xpath", "link text", "partial link text", "name", "tag name", "class name", "css selector"] diff --git a/py/selenium/webdriver/remote/locator_converter.py b/py/selenium/webdriver/remote/locator_converter.py index 53d67c3876924..b43da73ef47cd 100644 --- a/py/selenium/webdriver/remote/locator_converter.py +++ b/py/selenium/webdriver/remote/locator_converter.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. + class LocatorConverter: def convert(self, by, value): # Default conversion logic diff --git a/py/test/selenium/webdriver/common/driver_element_finding_tests.py b/py/test/selenium/webdriver/common/driver_element_finding_tests.py index 2578ac2f57861..205edb92e1f88 100644 --- a/py/test/selenium/webdriver/common/driver_element_finding_tests.py +++ b/py/test/selenium/webdriver/common/driver_element_finding_tests.py @@ -715,3 +715,21 @@ def test_should_not_be_able_to_find_an_element_on_a_blank_page(driver, pages): driver.get("about:blank") with pytest.raises(NoSuchElementException): driver.find_element(By.TAG_NAME, "a") + + +# custom finders tests + + +def test_register_and_get_custom_finder(): + By.register_custom_finder("custom", "custom strategy") + assert By.get_finder("custom") == "custom strategy" + + +def test_get_nonexistent_finder(): + assert By.get_finder("nonexistent") is None + + +def test_clear_custom_finders(): + By.register_custom_finder("custom", "custom strategy") + By.clear_custom_finders() + assert By.get_finder("custom") is None From 8a983a1495b81f6abb7d1b0aafc19dd5f58f562a Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Tue, 8 Oct 2024 17:00:55 +0530 Subject: [PATCH 03/13] Support registering extra HTTP commands and methods in python --- .../webdriver/remote/remote_connection.py | 9 +++++++- .../remote/remote_connection_tests.py | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index c3c28eca0cd59..a7d7f1fd944ce 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -280,6 +280,13 @@ def __init__(self, remote_server_addr: str, keep_alive: bool = False, ignore_pro self._conn = self._get_connection_manager() self._commands = remote_commands + extra_commands = {} + + @classmethod + def add_command(cls, name, method, url): + """Register a new command.""" + cls.extra_commands[name] = (method, url) + def execute(self, command, params): """Send a command to the remote server. @@ -291,7 +298,7 @@ def execute(self, command, params): - params - A dictionary of named parameters to send with the command as its JSON payload. """ - command_info = self._commands[command] + command_info = self._commands.get(command) or self.extra_commands.get(command) assert command_info is not None, f"Unrecognised command {command}" path_string = command_info[1] path = string.Template(path_string).substitute(params) diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index 2c798365d85f5..605d4ca287232 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +from unittest.mock import patch from urllib import parse import pytest @@ -24,6 +25,28 @@ from selenium.webdriver.remote.remote_connection import RemoteConnection +@pytest.fixture +def remote_connection(): + return RemoteConnection("http://localhost:4444") + + +def test_add_command(): + RemoteConnection.add_command("CUSTOM_COMMAND", "PUT", "/session/$sessionId/custom") + assert RemoteConnection.extra_commands["CUSTOM_COMMAND"] == ("PUT", "/session/$sessionId/custom") + + +@patch("selenium.webdriver.remote.remote_connection.RemoteConnection._request") +def test_execute_custom_command(mock_request, remote_connection): + RemoteConnection.add_command("CUSTOM_COMMAND", "PUT", "/session/$sessionId/custom") + mock_request.return_value = {"status": 0, "value": "OK"} + + params = {"sessionId": "12345"} + response = remote_connection.execute("CUSTOM_COMMAND", params) + + mock_request.assert_called_once_with("PUT", "http://localhost:4444/session/12345/custom", body="{}") + assert response == {"status": 0, "value": "OK"} + + def test_get_remote_connection_headers_defaults(): url = "http://remote" headers = RemoteConnection.get_remote_connection_headers(parse.urlparse(url)) From 81cca0f6fb1a63ecd184f8f6ca0c334967040921 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Wed, 9 Oct 2024 23:38:33 +0530 Subject: [PATCH 04/13] Support overriding User-Agent in python --- .../webdriver/remote/remote_connection.py | 7 +++++++ .../webdriver/remote/remote_connection_tests.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index a7d7f1fd944ce..c0f8981fefd98 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -143,6 +143,10 @@ class RemoteConnection: ) _ca_certs = os.getenv("REQUESTS_CA_BUNDLE") if "REQUESTS_CA_BUNDLE" in os.environ else certifi.where() + # Class variables for headers + extra_headers = None + user_agent = f"selenium/{__version__} (python {platform.system().lower()})" + @classmethod def get_timeout(cls): """:Returns: @@ -213,6 +217,9 @@ def get_remote_connection_headers(cls, parsed_url, keep_alive=False): if keep_alive: headers.update({"Connection": "keep-alive"}) + if cls.extra_headers: + headers.update(cls.extra_headers) + return headers def _get_proxy_url(self): diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index 605d4ca287232..c609211430053 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -262,3 +262,20 @@ def mock_no_proxy_settings(monkeypatch): monkeypatch.setenv("http_proxy", http_proxy) monkeypatch.setenv("no_proxy", "65.253.214.253,localhost,127.0.0.1,*zyz.xx,::1") monkeypatch.setenv("NO_PROXY", "65.253.214.253,localhost,127.0.0.1,*zyz.xx,::1,127.0.0.0/8") + + +@patch("selenium.webdriver.remote.remote_connection.RemoteConnection.get_remote_connection_headers") +def test_override_user_agent_in_headers(mock_get_remote_connection_headers, remote_connection): + RemoteConnection.user_agent = "rspec/1.0 (python 3.8)" + + mock_get_remote_connection_headers.return_value = { + "Accept": "application/json", + "Content-Type": "application/json;charset=UTF-8", + "User-Agent": "rspec/1.0 (python 3.8)", + } + + headers = RemoteConnection.get_remote_connection_headers(parse.urlparse("http://remote")) + + assert headers.get("User-Agent") == "rspec/1.0 (python 3.8)" + assert headers.get("Accept") == "application/json" + assert headers.get("Content-Type") == "application/json;charset=UTF-8" From f27d46c47179da96f568e732b7e39fdc2ecc0fb7 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Thu, 10 Oct 2024 18:18:27 +0530 Subject: [PATCH 05/13] Support registering extra headers --- .../webdriver/remote/remote_connection_tests.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index c609211430053..ca12f0384b26e 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -279,3 +279,15 @@ def test_override_user_agent_in_headers(mock_get_remote_connection_headers, remo assert headers.get("User-Agent") == "rspec/1.0 (python 3.8)" assert headers.get("Accept") == "application/json" assert headers.get("Content-Type") == "application/json;charset=UTF-8" + + +@patch("selenium.webdriver.remote.remote_connection.RemoteConnection._request") +def test_register_extra_headers(mock_request, remote_connection): + RemoteConnection.extra_headers = {"Foo": "bar"} + + mock_request.return_value = {"status": 0, "value": "OK"} + remote_connection.execute("newSession", {}) + + mock_request.assert_called_once_with("POST", "http://localhost:4444/session", body="{}") + headers = RemoteConnection.get_remote_connection_headers(parse.urlparse("http://localhost:4444"), False) + assert headers["Foo"] == "bar" From 845fa18a7daf59f3999700c430645ed7d74bee38 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Thu, 10 Oct 2024 19:28:32 +0530 Subject: [PATCH 06/13] [py] Support ignore certificates --- py/selenium/webdriver/remote/remote_connection.py | 15 +++++++++++++-- .../webdriver/remote/remote_connection_tests.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index c0f8981fefd98..b99dfe4b37235 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -243,7 +243,11 @@ def _separate_http_proxy_auth(self): def _get_connection_manager(self): pool_manager_init_args = {"timeout": self.get_timeout()} - if self._ca_certs: + + if self._ignore_certificates: + pool_manager_init_args["cert_reqs"] = "CERT_NONE" + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + elif self._ca_certs: pool_manager_init_args["cert_reqs"] = "CERT_REQUIRED" pool_manager_init_args["ca_certs"] = self._ca_certs @@ -259,9 +263,16 @@ def _get_connection_manager(self): return urllib3.PoolManager(**pool_manager_init_args) - def __init__(self, remote_server_addr: str, keep_alive: bool = False, ignore_proxy: bool = False): + def __init__( + self, + remote_server_addr: str, + keep_alive: bool = False, + ignore_proxy: bool = False, + ignore_certificates: bool = False, + ): self.keep_alive = keep_alive self._url = remote_server_addr + self._ignore_certificates = ignore_certificates # Env var NO_PROXY will override this part of the code _no_proxy = os.environ.get("no_proxy", os.environ.get("NO_PROXY")) diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index ca12f0384b26e..3e2dc4abc342f 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -291,3 +291,13 @@ def test_register_extra_headers(mock_request, remote_connection): mock_request.assert_called_once_with("POST", "http://localhost:4444/session", body="{}") headers = RemoteConnection.get_remote_connection_headers(parse.urlparse("http://localhost:4444"), False) assert headers["Foo"] == "bar" + + +def test_get_connection_manager_ignores_certificates(monkeypatch): + monkeypatch.setattr(RemoteConnection, "get_timeout", lambda _: 10) + remote_connection = RemoteConnection("http://remote", ignore_certificates=True) + conn = remote_connection._get_connection_manager() + + assert conn.connection_pool_kw["timeout"] == 10 + assert conn.connection_pool_kw["cert_reqs"] == "CERT_NONE" + assert isinstance(conn, urllib3.PoolManager) From 4503b1ee834a3db7b6bee6c047331684e6ab886d Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Thu, 10 Oct 2024 19:50:53 +0530 Subject: [PATCH 07/13] Support using custom element classes --- py/selenium/webdriver/remote/webdriver.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 486863810b6b5..fb69fd981b853 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -173,6 +173,7 @@ def __init__( file_detector: Optional[FileDetector] = None, options: Optional[Union[BaseOptions, List[BaseOptions]]] = None, locator_converter: Optional[LocatorConverter] = None, + web_element_cls: Optional[type] = None, ) -> None: """Create a new driver that will issue commands using the wire protocol. @@ -185,10 +186,9 @@ def __init__( - file_detector - Pass custom file detector object during instantiation. If None, then default LocalFileDetector() will be used. - options - instance of a driver options.Options class + - web_element_cls - Custom class to use for web elements. Defaults to WebElement. """ - self.locator_converter = locator_converter or LocatorConverter() - if isinstance(options, list): capabilities = create_matches(options) _ignore_local_proxy = False @@ -211,6 +211,8 @@ def __init__( self._switch_to = SwitchTo(self) self._mobile = Mobile(self) self.file_detector = file_detector or LocalFileDetector() + self.locator_converter = locator_converter or LocatorConverter() + self._web_element_cls = web_element_cls or self._web_element_cls self._authenticator_id = None self.start_client() self.start_session(capabilities) From 11c4a2c487cbc704ea47a9c392382b7697586cb4 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Fri, 11 Oct 2024 19:01:00 +0530 Subject: [PATCH 08/13] tests for custom element test --- .../webdriver/remote/custom_element_tests.py | 61 +++++++++++++++++++ .../remote/remote_custom_locator_tests.py | 10 +-- 2 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 py/test/selenium/webdriver/remote/custom_element_tests.py diff --git a/py/test/selenium/webdriver/remote/custom_element_tests.py b/py/test/selenium/webdriver/remote/custom_element_tests.py new file mode 100644 index 0000000000000..78921a474f45e --- /dev/null +++ b/py/test/selenium/webdriver/remote/custom_element_tests.py @@ -0,0 +1,61 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you 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 pytest + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement + + +# Custom element class +class MyCustomElement(WebElement): + def custom_method(self): + return "Custom element method" + + +@pytest.fixture +def driver(): + options = webdriver.ChromeOptions() + driver = webdriver.Chrome(options=options) + yield driver + driver.quit() + + +def test_find_element_with_custom_class(driver, pages): + """Test to ensure custom element class is used for a single element.""" + driver._web_element_cls = MyCustomElement + pages.load("simpleTest.html") + element = driver.find_element(By.TAG_NAME, "body") + assert isinstance(element, MyCustomElement) + assert element.custom_method() == "Custom element method" + + +def test_find_elements_with_custom_class(driver, pages): + """Test to ensure custom element class is used for multiple elements.""" + driver._web_element_cls = MyCustomElement + pages.load("simpleTest.html") + elements = driver.find_elements(By.TAG_NAME, "div") + assert all(isinstance(el, MyCustomElement) for el in elements) + assert all(el.custom_method() == "Custom element method" for el in elements) + + +def test_default_element_class(driver, pages): + """Test to ensure default WebElement class is used.""" + pages.load("simpleTest.html") + element = driver.find_element(By.TAG_NAME, "body") + assert isinstance(element, WebElement) + assert not hasattr(element, "custom_method") diff --git a/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py index 4a5bc21338f7f..f400c884fe809 100644 --- a/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py +++ b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py @@ -31,14 +31,12 @@ def convert(self, by, value): @pytest.fixture def driver() -> WebDriver: - # Setup for driver options = webdriver.ChromeOptions() driver = webdriver.Chrome(options=options) driver.locator_converter = CustomLocatorConverter() - driver.get("data:text/html,
Test
") + yield driver - # Teardown after test driver.quit() @@ -49,6 +47,8 @@ def test_find_element_with_custom_locator(driver: WebDriver) -> None: def test_find_elements_with_custom_locator(driver: WebDriver) -> None: + driver.get("data:text/html,
Test1
Test2
") elements = driver.find_elements("custom", "example") - assert len(elements) == 1 - assert elements[0].text == "Test" + assert len(elements) == 2 + assert elements[0].text == "Test1" + assert elements[1].text == "Test2" From 55ed141a7f18927a3ecf078176a10b99f6103588 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Tue, 15 Oct 2024 14:17:40 +0530 Subject: [PATCH 09/13] address review comments --- .../webdriver/remote/remote_connection.py | 15 +++++++------ py/selenium/webdriver/remote/webdriver.py | 1 + py/selenium/webdriver/remote/webelement.py | 22 ++----------------- .../remote/remote_connection_tests.py | 12 +++++++++- 4 files changed, 22 insertions(+), 28 deletions(-) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index b99dfe4b37235..05c376a41bbe0 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -143,9 +143,13 @@ class RemoteConnection: ) _ca_certs = os.getenv("REQUESTS_CA_BUNDLE") if "REQUESTS_CA_BUNDLE" in os.environ else certifi.where() + system = platform.system().lower() + if system == "darwin": + system = "mac" + # Class variables for headers extra_headers = None - user_agent = f"selenium/{__version__} (python {platform.system().lower()})" + user_agent = f"selenium/{__version__} (python {system}" @classmethod def get_timeout(cls): @@ -200,14 +204,10 @@ def get_remote_connection_headers(cls, parsed_url, keep_alive=False): - keep_alive (Boolean) - Is this a keep-alive connection (default: False) """ - system = platform.system().lower() - if system == "darwin": - system = "mac" - headers = { "Accept": "application/json", "Content-Type": "application/json;charset=UTF-8", - "User-Agent": f"selenium/{__version__} (python {system})", + "User-Agent": cls.user_agent, } if parsed_url.username: @@ -241,8 +241,9 @@ def _separate_http_proxy_auth(self): proxy_without_auth = protocol + no_protocol[len(auth) + 1 :] return proxy_without_auth, auth - def _get_connection_manager(self): + def _get_connection_manager(self, **pool_manager_kwargs): pool_manager_init_args = {"timeout": self.get_timeout()} + pool_manager_init_args.update(pool_manager_kwargs.get("init_args_for_pool_manager", {})) if self._ignore_certificates: pool_manager_init_args["cert_reqs"] = "CERT_NONE" diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index fb69fd981b853..8ef6292012089 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -186,6 +186,7 @@ def __init__( - file_detector - Pass custom file detector object during instantiation. If None, then default LocalFileDetector() will be used. - options - instance of a driver options.Options class + - locator_converter - Custom locator converter to use. Defaults to None. - web_element_cls - Custom class to use for web elements. Defaults to WebElement. """ diff --git a/py/selenium/webdriver/remote/webelement.py b/py/selenium/webdriver/remote/webelement.py index ef60757294caa..08c772eaad56e 100644 --- a/py/selenium/webdriver/remote/webelement.py +++ b/py/selenium/webdriver/remote/webelement.py @@ -404,16 +404,7 @@ def find_element(self, by=By.ID, value=None) -> WebElement: :rtype: WebElement """ - if by == By.ID: - by = By.CSS_SELECTOR - value = f'[id="{value}"]' - elif by == By.CLASS_NAME: - by = By.CSS_SELECTOR - value = f".{value}" - elif by == By.NAME: - by = By.CSS_SELECTOR - value = f'[name="{value}"]' - + by, value = self._parent.locator_converter.convert(by, value) return self._execute(Command.FIND_CHILD_ELEMENT, {"using": by, "value": value})["value"] def find_elements(self, by=By.ID, value=None) -> List[WebElement]: @@ -426,16 +417,7 @@ def find_elements(self, by=By.ID, value=None) -> List[WebElement]: :rtype: list of WebElement """ - if by == By.ID: - by = By.CSS_SELECTOR - value = f'[id="{value}"]' - elif by == By.CLASS_NAME: - by = By.CSS_SELECTOR - value = f".{value}" - elif by == By.NAME: - by = By.CSS_SELECTOR - value = f'[name="{value}"]' - + by, value = self._parent.locator_converter.convert(by, value) return self._execute(Command.FIND_CHILD_ELEMENTS, {"using": by, "value": value})["value"] def __hash__(self) -> int: diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index 3e2dc4abc342f..3b3ceae8faedc 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -55,7 +55,7 @@ def test_get_remote_connection_headers_defaults(): assert headers.get("Accept") == "application/json" assert headers.get("Content-Type") == "application/json;charset=UTF-8" assert headers.get("User-Agent").startswith(f"selenium/{__version__} (python ") - assert headers.get("User-Agent").split(" ")[-1] in {"windows)", "mac)", "linux)"} + assert headers.get("User-Agent").split(" ")[-1] in {"windows)", "mac)", "linux)", "mac", "windows", "linux"} def test_get_remote_connection_headers_adds_auth_header_if_pass(): @@ -301,3 +301,13 @@ def test_get_connection_manager_ignores_certificates(monkeypatch): assert conn.connection_pool_kw["timeout"] == 10 assert conn.connection_pool_kw["cert_reqs"] == "CERT_NONE" assert isinstance(conn, urllib3.PoolManager) + + +def test_get_connection_manager_with_custom_args(): + custom_args = {"retries": 3, "block": True} + remote_connection = RemoteConnection("http://remote", keep_alive=False) + conn = remote_connection._get_connection_manager(init_args_for_pool_manager=custom_args) + + assert isinstance(conn, urllib3.PoolManager) + assert conn.connection_pool_kw["retries"] == 3 + assert conn.connection_pool_kw["block"] is True From 906f44bcdd13d404aae2aa0d05af628a58d20268 Mon Sep 17 00:00:00 2001 From: Navin Chandra <98466550+navin772@users.noreply.github.com> Date: Wed, 16 Oct 2024 12:12:34 +0530 Subject: [PATCH 10/13] close parenthesis Co-authored-by: Kazuaki Matsuo --- py/selenium/webdriver/remote/remote_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index 05c376a41bbe0..31075c7f5cef2 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -149,7 +149,7 @@ class RemoteConnection: # Class variables for headers extra_headers = None - user_agent = f"selenium/{__version__} (python {system}" + user_agent = f"selenium/{__version__} (python {system})" @classmethod def get_timeout(cls): From 9272a49032827b98907a0d2434d466baebf16aeb Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Wed, 16 Oct 2024 12:24:14 +0530 Subject: [PATCH 11/13] pass `init_args_for_pool_manager` in constructor --- py/selenium/webdriver/remote/remote_connection.py | 6 ++++-- .../selenium/webdriver/remote/remote_connection_tests.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index 31075c7f5cef2..2bcc43dfb9864 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -241,9 +241,9 @@ def _separate_http_proxy_auth(self): proxy_without_auth = protocol + no_protocol[len(auth) + 1 :] return proxy_without_auth, auth - def _get_connection_manager(self, **pool_manager_kwargs): + def _get_connection_manager(self): pool_manager_init_args = {"timeout": self.get_timeout()} - pool_manager_init_args.update(pool_manager_kwargs.get("init_args_for_pool_manager", {})) + pool_manager_init_args.update(self._init_args_for_pool_manager.get("init_args_for_pool_manager", {})) if self._ignore_certificates: pool_manager_init_args["cert_reqs"] = "CERT_NONE" @@ -270,10 +270,12 @@ def __init__( keep_alive: bool = False, ignore_proxy: bool = False, ignore_certificates: bool = False, + init_args_for_pool_manager: dict = None, ): self.keep_alive = keep_alive self._url = remote_server_addr self._ignore_certificates = ignore_certificates + self._init_args_for_pool_manager = init_args_for_pool_manager or {} # Env var NO_PROXY will override this part of the code _no_proxy = os.environ.get("no_proxy", os.environ.get("NO_PROXY")) diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index 3b3ceae8faedc..11ffc0ad3c498 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -304,9 +304,9 @@ def test_get_connection_manager_ignores_certificates(monkeypatch): def test_get_connection_manager_with_custom_args(): - custom_args = {"retries": 3, "block": True} - remote_connection = RemoteConnection("http://remote", keep_alive=False) - conn = remote_connection._get_connection_manager(init_args_for_pool_manager=custom_args) + custom_args = {"init_args_for_pool_manager": {"retries": 3, "block": True}} + remote_connection = RemoteConnection("http://remote", keep_alive=False, init_args_for_pool_manager=custom_args) + conn = remote_connection._get_connection_manager() assert isinstance(conn, urllib3.PoolManager) assert conn.connection_pool_kw["retries"] == 3 From 6dd16a26d7a3124b558cb7a10f2011dfb5c90151 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Fri, 18 Oct 2024 09:28:05 +0530 Subject: [PATCH 12/13] use existing driver fixture in tests --- .../webdriver/remote/custom_element_tests.py | 11 ----------- .../remote/remote_custom_locator_tests.py | 18 ++---------------- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/py/test/selenium/webdriver/remote/custom_element_tests.py b/py/test/selenium/webdriver/remote/custom_element_tests.py index 78921a474f45e..3fccb52ad3119 100644 --- a/py/test/selenium/webdriver/remote/custom_element_tests.py +++ b/py/test/selenium/webdriver/remote/custom_element_tests.py @@ -14,9 +14,6 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -import pytest - -from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.remote.webelement import WebElement @@ -27,14 +24,6 @@ def custom_method(self): return "Custom element method" -@pytest.fixture -def driver(): - options = webdriver.ChromeOptions() - driver = webdriver.Chrome(options=options) - yield driver - driver.quit() - - def test_find_element_with_custom_class(driver, pages): """Test to ensure custom element class is used for a single element.""" driver._web_element_cls = MyCustomElement diff --git a/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py index f400c884fe809..e235f2ee2e999 100644 --- a/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py +++ b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py @@ -14,11 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -import pytest - -from selenium import webdriver from selenium.webdriver.remote.locator_converter import LocatorConverter -from selenium.webdriver.remote.webdriver import WebDriver class CustomLocatorConverter(LocatorConverter): @@ -29,24 +25,14 @@ def convert(self, by, value): return super().convert(by, value) -@pytest.fixture -def driver() -> WebDriver: - options = webdriver.ChromeOptions() - driver = webdriver.Chrome(options=options) - driver.locator_converter = CustomLocatorConverter() +def test_find_element_with_custom_locator(driver): driver.get("data:text/html,
Test
") - - yield driver - driver.quit() - - -def test_find_element_with_custom_locator(driver: WebDriver) -> None: element = driver.find_element("custom", "example") assert element is not None assert element.text == "Test" -def test_find_elements_with_custom_locator(driver: WebDriver) -> None: +def test_find_elements_with_custom_locator(driver): driver.get("data:text/html,
Test1
Test2
") elements = driver.find_elements("custom", "example") assert len(elements) == 2 From 21bc74cb229e4521b2fb6b3cf0841742efcc6a5c Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Sat, 19 Oct 2024 16:21:34 +0530 Subject: [PATCH 13/13] convert `add_command` to instance method --- .../webdriver/remote/remote_connection.py | 9 +++++--- .../remote/remote_connection_tests.py | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index b3a397e42d917..5b5f589599bca 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -303,10 +303,13 @@ def __init__( extra_commands = {} - @classmethod - def add_command(cls, name, method, url): + def add_command(self, name, method, url): """Register a new command.""" - cls.extra_commands[name] = (method, url) + self._commands[name] = (method, url) + + def get_command(self, name: str): + """Retrieve a command if it exists.""" + return self._commands.get(name) def execute(self, command, params): """Send a command to the remote server. diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index 11ffc0ad3c498..260214e5c918d 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -27,24 +27,27 @@ @pytest.fixture def remote_connection(): + """Fixture to create a RemoteConnection instance.""" return RemoteConnection("http://localhost:4444") -def test_add_command(): - RemoteConnection.add_command("CUSTOM_COMMAND", "PUT", "/session/$sessionId/custom") - assert RemoteConnection.extra_commands["CUSTOM_COMMAND"] == ("PUT", "/session/$sessionId/custom") +def test_add_command(remote_connection): + """Test adding a custom command to the connection.""" + remote_connection.add_command("CUSTOM_COMMAND", "PUT", "/session/$sessionId/custom") + assert remote_connection.get_command("CUSTOM_COMMAND") == ("PUT", "/session/$sessionId/custom") @patch("selenium.webdriver.remote.remote_connection.RemoteConnection._request") def test_execute_custom_command(mock_request, remote_connection): - RemoteConnection.add_command("CUSTOM_COMMAND", "PUT", "/session/$sessionId/custom") - mock_request.return_value = {"status": 0, "value": "OK"} + """Test executing a custom command through the connection.""" + remote_connection.add_command("CUSTOM_COMMAND", "PUT", "/session/$sessionId/custom") + mock_request.return_value = {"status": 200, "value": "OK"} params = {"sessionId": "12345"} response = remote_connection.execute("CUSTOM_COMMAND", params) mock_request.assert_called_once_with("PUT", "http://localhost:4444/session/12345/custom", body="{}") - assert response == {"status": 0, "value": "OK"} + assert response == {"status": 200, "value": "OK"} def test_get_remote_connection_headers_defaults(): @@ -266,17 +269,17 @@ def mock_no_proxy_settings(monkeypatch): @patch("selenium.webdriver.remote.remote_connection.RemoteConnection.get_remote_connection_headers") def test_override_user_agent_in_headers(mock_get_remote_connection_headers, remote_connection): - RemoteConnection.user_agent = "rspec/1.0 (python 3.8)" + RemoteConnection.user_agent = "custom-agent/1.0 (python 3.8)" mock_get_remote_connection_headers.return_value = { "Accept": "application/json", "Content-Type": "application/json;charset=UTF-8", - "User-Agent": "rspec/1.0 (python 3.8)", + "User-Agent": "custom-agent/1.0 (python 3.8)", } headers = RemoteConnection.get_remote_connection_headers(parse.urlparse("http://remote")) - assert headers.get("User-Agent") == "rspec/1.0 (python 3.8)" + assert headers.get("User-Agent") == "custom-agent/1.0 (python 3.8)" assert headers.get("Accept") == "application/json" assert headers.get("Content-Type") == "application/json;charset=UTF-8" @@ -285,7 +288,7 @@ def test_override_user_agent_in_headers(mock_get_remote_connection_headers, remo def test_register_extra_headers(mock_request, remote_connection): RemoteConnection.extra_headers = {"Foo": "bar"} - mock_request.return_value = {"status": 0, "value": "OK"} + mock_request.return_value = {"status": 200, "value": "OK"} remote_connection.execute("newSession", {}) mock_request.assert_called_once_with("POST", "http://localhost:4444/session", body="{}")