diff --git a/py/conftest.py b/py/conftest.py index b1a3e7ca8afd0..3cc2693259515 100644 --- a/py/conftest.py +++ b/py/conftest.py @@ -17,14 +17,11 @@ import os import platform -import socket -import subprocess -import time -from urllib.request import urlopen import pytest from selenium import webdriver +from selenium.webdriver.remote.server import Server from test.selenium.webdriver.common.network import get_lan_ip from test.selenium.webdriver.common.webserver import SimpleWebServer @@ -125,7 +122,7 @@ def driver(request): # skip tests in the 'remote' directory if run with a local driver if request.node.path.parts[-2] == "remote" and driver_class != "Remote": - pytest.skip(f"Remote tests can't be run with driver '{driver_option}'") + pytest.skip(f"Remote tests can't be run with driver '{driver_option.lower()}'") # skip tests that can't run on certain platforms _platform = platform.system() @@ -295,60 +292,26 @@ def server(request): yield None return - _host = "localhost" - _port = 4444 - _path = os.path.join( + jar_path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "java/src/org/openqa/selenium/grid/selenium_server_deploy.jar", ) - def wait_for_server(url, timeout): - start = time.time() - while time.time() - start < timeout: - try: - urlopen(url) - return 1 - except OSError: - time.sleep(0.2) - return 0 - - _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - url = f"http://{_host}:{_port}/status" - try: - _socket.connect((_host, _port)) - print( - "The remote driver server is already running or something else" - "is using port {}, continuing...".format(_port) - ) - except Exception: - remote_env = os.environ.copy() - if platform.system() == "Linux": - # There are issues with window size/position when running Firefox - # under Wayland, so we use XWayland instead. - remote_env["MOZ_ENABLE_WAYLAND"] = "0" - print("Starting the Selenium server") - process = subprocess.Popen( - [ - "java", - "-jar", - _path, - "standalone", - "--port", - "4444", - "--selenium-manager", - "true", - "--enable-managed-downloads", - "true", - ], - env=remote_env, - ) - print(f"Selenium server running as process: {process.pid}") - assert wait_for_server(url, 10), f"Timed out waiting for Selenium server at {url}" - print("Selenium server is ready") - yield process - process.terminate() - process.wait() - print("Selenium server has been terminated") + remote_env = os.environ.copy() + if platform.system() == "Linux": + # There are issues with window size/position when running Firefox + # under Wayland, so we use XWayland instead. + remote_env["MOZ_ENABLE_WAYLAND"] = "0" + + if os.path.exists(jar_path): + # use the grid server built by bazel + server = Server(path=jar_path, env=remote_env) + else: + # use the local grid server (downloads a new one if needed) + server = Server(env=remote_env) + server.start() + yield server + server.stop() @pytest.fixture(autouse=True, scope="session") diff --git a/py/docs/source/api.rst b/py/docs/source/api.rst index f4f1398d7bc5e..dc34be77815db 100644 --- a/py/docs/source/api.rst +++ b/py/docs/source/api.rst @@ -151,6 +151,7 @@ Webdriver.remote selenium.webdriver.remote.mobile selenium.webdriver.remote.remote_connection selenium.webdriver.remote.script_key + selenium.webdriver.remote.server selenium.webdriver.remote.shadowroot selenium.webdriver.remote.switch_to selenium.webdriver.remote.utils diff --git a/py/selenium/webdriver/remote/server.py b/py/selenium/webdriver/remote/server.py new file mode 100644 index 0000000000000..463d55508e589 --- /dev/null +++ b/py/selenium/webdriver/remote/server.py @@ -0,0 +1,177 @@ +# 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 collections +import os +import re +import shutil +import socket +import subprocess +import time +import urllib + +from selenium.webdriver.common.selenium_manager import SeleniumManager + + +class Server: + """Manage a Selenium Grid (Remote) Server in standalone mode. + + This class contains functionality for downloading the server and starting/stopping it. + + For more information on Selenium Grid, see: + - https://www.selenium.dev/documentation/grid/getting_started/ + + Parameters: + ----------- + host : str + Hostname or IP address to bind to (determined automatically if not specified) + port : int or str + Port to listen on (4444 if not specified) + path : str + Path/filename of existing server .jar file (Selenium Manager is used if not specified) + version : str + Version of server to download (latest version if not specified) + log_level : str + Logging level to control logging output ("INFO" if not specified) + Available levels: "SEVERE", "WARNING", "INFO", "CONFIG", "FINE", "FINER", "FINEST" + env: collections.abc.Mapping + Mapping that defines the environment variables for the server process + """ + + def __init__(self, host=None, port=4444, path=None, version=None, log_level="INFO", env=None): + if path and version: + raise TypeError("Not allowed to specify a version when using an existing server path") + + self.host = host + self.port = self._validate_port(port) + self.path = self._validate_path(path) + self.version = self._validate_version(version) + self.log_level = self._validate_log_level(log_level) + self.env = self._validate_env(env) + + self.process = None + self.status_url = self._get_status_url() + + def _get_status_url(self): + host = self.host if self.host is not None else "localhost" + return f"http://{host}:{self.port}/status" + + def _validate_path(self, path): + if path and not os.path.exists(path): + raise OSError(f"Can't find server .jar located at {path}") + return path + + def _validate_port(self, port): + try: + port = int(port) + except ValueError: + raise TypeError(f"{__class__.__name__}.__init__() got an invalid port: '{port}'") + if not (0 <= port <= 65535): + raise ValueError("port must be 0-65535") + return port + + def _validate_version(self, version): + if version: + if not re.match(r"^\d+\.\d+\.\d+$", str(version)): + raise TypeError(f"{__class__.__name__}.__init__() got an invalid version: '{version}'") + return version + + def _validate_log_level(self, log_level): + levels = ("SEVERE", "WARNING", "INFO", "CONFIG", "FINE", "FINER", "FINEST") + if log_level not in levels: + raise TypeError(f"log_level must be one of: {', '.join(levels)}") + return log_level + + def _validate_env(self, env): + if env is not None and not isinstance(env, collections.abc.Mapping): + raise TypeError("env must be a mapping of environment variables") + return env + + def _wait_for_server(self, timeout=10): + start = time.time() + while time.time() - start < timeout: + try: + urllib.request.urlopen(self.status_url) + return True + except urllib.error.URLError: + time.sleep(0.2) + return False + + def download_if_needed(self, version=None): + """Download the server if it doesn't already exist. + + Latest version is downloaded unless specified. + """ + args = ["--grid"] + if version is not None: + args.append(version) + return SeleniumManager().binary_paths(args)["driver_path"] + + def start(self): + """Start the server. + + Selenium Manager will detect the server location and download it if necessary, + unless an existing server path was specified. + """ + path = self.download_if_needed(self.version) if self.path is None else self.path + + java_path = shutil.which("java") + if java_path is None: + raise OSError("Can't find java on system PATH. JRE is required to run the Selenium server") + + command = [ + java_path, + "-jar", + path, + "standalone", + "--port", + str(self.port), + "--log-level", + self.log_level, + "--selenium-manager", + "true", + "--enable-managed-downloads", + "true", + ] + if self.host is not None: + command.extend(["--host", self.host]) + + host = self.host if self.host is not None else "localhost" + + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.connect((host, self.port)) + raise ConnectionError(f"Selenium server is already running, or something else is using port {self.port}") + except ConnectionRefusedError: + print("Starting Selenium server...") + self.process = subprocess.Popen(command, env=self.env) + print(f"Selenium server running as process: {self.process.pid}") + if not self._wait_for_server(): + raise TimeoutError(f"Timed out waiting for Selenium server at {self.status_url}") + print("Selenium server is ready") + return self.process + + def stop(self): + """Stop the server.""" + if self.process is None: + raise RuntimeError("Selenium server isn't running") + else: + if self.process.poll() is None: + self.process.terminate() + self.process.wait() + self.process = None + print("Selenium server has been terminated") diff --git a/py/test/selenium/webdriver/remote/remote_server_tests.py b/py/test/selenium/webdriver/remote/remote_server_tests.py new file mode 100644 index 0000000000000..b1ea60abaf18d --- /dev/null +++ b/py/test/selenium/webdriver/remote/remote_server_tests.py @@ -0,0 +1,44 @@ +# 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 os + +import pytest + +from selenium.webdriver.remote.server import Server + + +@pytest.fixture +def standalone_server(): + server = Server() + server_path = server.download_if_needed() + remove_file(server_path) + yield server + remove_file(server_path) + + +def remove_file(path): + try: + os.remove(path) + except FileNotFoundError: + pass + + +def test_download_latest_server(standalone_server): + server_path = standalone_server.download_if_needed() + assert os.path.exists(server_path) + assert os.path.getsize(server_path) > 0 diff --git a/py/test/unit/selenium/webdriver/remote/remote_server_tests.py b/py/test/unit/selenium/webdriver/remote/remote_server_tests.py new file mode 100644 index 0000000000000..eb1afefdd96e1 --- /dev/null +++ b/py/test/unit/selenium/webdriver/remote/remote_server_tests.py @@ -0,0 +1,106 @@ +# 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 os +import re + +import pytest + +from selenium.webdriver.remote.server import Server + + +def test_server_with_defaults(): + server = Server() + assert server.host is None + assert server.port == 4444 + assert server.path is None + assert server.version is None + assert server.log_level == "INFO" + assert server.env is None + + +def test_server_with_args(): + server = Server("foo", 9999) + assert server.host == "foo" + assert server.port == 9999 + + +def test_server_with_kwargs(): + server = Server(host="foo", port=9999, version="1.1.1", log_level="WARNING", env={"FOO": "bar"}) + assert server.host == "foo" + assert server.port == 9999 + assert server.path is None + assert server.version == "1.1.1" + assert server.log_level == "WARNING" + assert server.env == {"FOO": "bar"} + + +def test_server_with_invalid_port(): + port = "invalid" + msg = f"Server.__init__() got an invalid port: '{port}'" + with pytest.raises(TypeError, match=re.escape(msg)): + Server(port=port) + + +def test_server_with_port_out_of_range(): + with pytest.raises(ValueError, match="port must be 0-65535"): + Server(port=99999) + + +def test_server_with_bad_path(): + path = "/path/to/nowhere" + msg = f"Can't find server .jar located at {path}" + with pytest.raises(OSError, match=re.escape(msg)): + Server(path=path) + + +def test_server_with_invalid_version(): + versions = ("0.0", "invalid") + for version in versions: + msg = f"Server.__init__() got an invalid version: '{version}'" + with pytest.raises(TypeError, match=re.escape(msg)): + Server(version=version) + + +def test_server_with_invalid_log_level(): + msg = ", ".join(("SEVERE", "WARNING", "INFO", "CONFIG", "FINE", "FINER", "FINEST")) + with pytest.raises(TypeError, match=f"log_level must be one of: {msg}"): + Server(log_level="BAD") + + +def test_server_with_env_os_environ(): + server = Server(env=os.environ) + assert isinstance(server.env, os._Environ) + + +def test_server_with_env_dict(): + env = {} + server = Server(env=env) + assert isinstance(server.env, dict) + assert server.env == {} + + +def test_server_with_invalid_env(): + with pytest.raises(TypeError, match="env must be a mapping of environment variables"): + Server(env=[]) + + +def test_stopping_server_thats_not_running(): + server = Server() + with pytest.raises(RuntimeError, match="Selenium server isn't running"): + server.stop() + assert server.process is None