Skip to content

Commit f2d3870

Browse files
authored
[py] Server class to manage (download/run) grid server (#15666)
* [py] Add class to manage remote/grid server * [py] User Server class in conftest * [py] Add server tests
1 parent 169ce63 commit f2d3870

File tree

5 files changed

+346
-55
lines changed

5 files changed

+346
-55
lines changed

py/conftest.py

Lines changed: 18 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,11 @@
1717

1818
import os
1919
import platform
20-
import socket
21-
import subprocess
22-
import time
23-
from urllib.request import urlopen
2420

2521
import pytest
2622

2723
from selenium import webdriver
24+
from selenium.webdriver.remote.server import Server
2825
from test.selenium.webdriver.common.network import get_lan_ip
2926
from test.selenium.webdriver.common.webserver import SimpleWebServer
3027

@@ -125,7 +122,7 @@ def driver(request):
125122

126123
# skip tests in the 'remote' directory if run with a local driver
127124
if request.node.path.parts[-2] == "remote" and driver_class != "Remote":
128-
pytest.skip(f"Remote tests can't be run with driver '{driver_option}'")
125+
pytest.skip(f"Remote tests can't be run with driver '{driver_option.lower()}'")
129126

130127
# skip tests that can't run on certain platforms
131128
_platform = platform.system()
@@ -295,60 +292,26 @@ def server(request):
295292
yield None
296293
return
297294

298-
_host = "localhost"
299-
_port = 4444
300-
_path = os.path.join(
295+
jar_path = os.path.join(
301296
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
302297
"java/src/org/openqa/selenium/grid/selenium_server_deploy.jar",
303298
)
304299

305-
def wait_for_server(url, timeout):
306-
start = time.time()
307-
while time.time() - start < timeout:
308-
try:
309-
urlopen(url)
310-
return 1
311-
except OSError:
312-
time.sleep(0.2)
313-
return 0
314-
315-
_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
316-
url = f"http://{_host}:{_port}/status"
317-
try:
318-
_socket.connect((_host, _port))
319-
print(
320-
"The remote driver server is already running or something else"
321-
"is using port {}, continuing...".format(_port)
322-
)
323-
except Exception:
324-
remote_env = os.environ.copy()
325-
if platform.system() == "Linux":
326-
# There are issues with window size/position when running Firefox
327-
# under Wayland, so we use XWayland instead.
328-
remote_env["MOZ_ENABLE_WAYLAND"] = "0"
329-
print("Starting the Selenium server")
330-
process = subprocess.Popen(
331-
[
332-
"java",
333-
"-jar",
334-
_path,
335-
"standalone",
336-
"--port",
337-
"4444",
338-
"--selenium-manager",
339-
"true",
340-
"--enable-managed-downloads",
341-
"true",
342-
],
343-
env=remote_env,
344-
)
345-
print(f"Selenium server running as process: {process.pid}")
346-
assert wait_for_server(url, 10), f"Timed out waiting for Selenium server at {url}"
347-
print("Selenium server is ready")
348-
yield process
349-
process.terminate()
350-
process.wait()
351-
print("Selenium server has been terminated")
300+
remote_env = os.environ.copy()
301+
if platform.system() == "Linux":
302+
# There are issues with window size/position when running Firefox
303+
# under Wayland, so we use XWayland instead.
304+
remote_env["MOZ_ENABLE_WAYLAND"] = "0"
305+
306+
if os.path.exists(jar_path):
307+
# use the grid server built by bazel
308+
server = Server(path=jar_path, env=remote_env)
309+
else:
310+
# use the local grid server (downloads a new one if needed)
311+
server = Server(env=remote_env)
312+
server.start()
313+
yield server
314+
server.stop()
352315

353316

354317
@pytest.fixture(autouse=True, scope="session")

py/docs/source/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ Webdriver.remote
151151
selenium.webdriver.remote.mobile
152152
selenium.webdriver.remote.remote_connection
153153
selenium.webdriver.remote.script_key
154+
selenium.webdriver.remote.server
154155
selenium.webdriver.remote.shadowroot
155156
selenium.webdriver.remote.switch_to
156157
selenium.webdriver.remote.utils
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Licensed to the Software Freedom Conservancy (SFC) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The SFC licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import collections
19+
import os
20+
import re
21+
import shutil
22+
import socket
23+
import subprocess
24+
import time
25+
import urllib
26+
27+
from selenium.webdriver.common.selenium_manager import SeleniumManager
28+
29+
30+
class Server:
31+
"""Manage a Selenium Grid (Remote) Server in standalone mode.
32+
33+
This class contains functionality for downloading the server and starting/stopping it.
34+
35+
For more information on Selenium Grid, see:
36+
- https://www.selenium.dev/documentation/grid/getting_started/
37+
38+
Parameters:
39+
-----------
40+
host : str
41+
Hostname or IP address to bind to (determined automatically if not specified)
42+
port : int or str
43+
Port to listen on (4444 if not specified)
44+
path : str
45+
Path/filename of existing server .jar file (Selenium Manager is used if not specified)
46+
version : str
47+
Version of server to download (latest version if not specified)
48+
log_level : str
49+
Logging level to control logging output ("INFO" if not specified)
50+
Available levels: "SEVERE", "WARNING", "INFO", "CONFIG", "FINE", "FINER", "FINEST"
51+
env: collections.abc.Mapping
52+
Mapping that defines the environment variables for the server process
53+
"""
54+
55+
def __init__(self, host=None, port=4444, path=None, version=None, log_level="INFO", env=None):
56+
if path and version:
57+
raise TypeError("Not allowed to specify a version when using an existing server path")
58+
59+
self.host = host
60+
self.port = self._validate_port(port)
61+
self.path = self._validate_path(path)
62+
self.version = self._validate_version(version)
63+
self.log_level = self._validate_log_level(log_level)
64+
self.env = self._validate_env(env)
65+
66+
self.process = None
67+
self.status_url = self._get_status_url()
68+
69+
def _get_status_url(self):
70+
host = self.host if self.host is not None else "localhost"
71+
return f"http://{host}:{self.port}/status"
72+
73+
def _validate_path(self, path):
74+
if path and not os.path.exists(path):
75+
raise OSError(f"Can't find server .jar located at {path}")
76+
return path
77+
78+
def _validate_port(self, port):
79+
try:
80+
port = int(port)
81+
except ValueError:
82+
raise TypeError(f"{__class__.__name__}.__init__() got an invalid port: '{port}'")
83+
if not (0 <= port <= 65535):
84+
raise ValueError("port must be 0-65535")
85+
return port
86+
87+
def _validate_version(self, version):
88+
if version:
89+
if not re.match(r"^\d+\.\d+\.\d+$", str(version)):
90+
raise TypeError(f"{__class__.__name__}.__init__() got an invalid version: '{version}'")
91+
return version
92+
93+
def _validate_log_level(self, log_level):
94+
levels = ("SEVERE", "WARNING", "INFO", "CONFIG", "FINE", "FINER", "FINEST")
95+
if log_level not in levels:
96+
raise TypeError(f"log_level must be one of: {', '.join(levels)}")
97+
return log_level
98+
99+
def _validate_env(self, env):
100+
if env is not None and not isinstance(env, collections.abc.Mapping):
101+
raise TypeError("env must be a mapping of environment variables")
102+
return env
103+
104+
def _wait_for_server(self, timeout=10):
105+
start = time.time()
106+
while time.time() - start < timeout:
107+
try:
108+
urllib.request.urlopen(self.status_url)
109+
return True
110+
except urllib.error.URLError:
111+
time.sleep(0.2)
112+
return False
113+
114+
def download_if_needed(self, version=None):
115+
"""Download the server if it doesn't already exist.
116+
117+
Latest version is downloaded unless specified.
118+
"""
119+
args = ["--grid"]
120+
if version is not None:
121+
args.append(version)
122+
return SeleniumManager().binary_paths(args)["driver_path"]
123+
124+
def start(self):
125+
"""Start the server.
126+
127+
Selenium Manager will detect the server location and download it if necessary,
128+
unless an existing server path was specified.
129+
"""
130+
path = self.download_if_needed(self.version) if self.path is None else self.path
131+
132+
java_path = shutil.which("java")
133+
if java_path is None:
134+
raise OSError("Can't find java on system PATH. JRE is required to run the Selenium server")
135+
136+
command = [
137+
java_path,
138+
"-jar",
139+
path,
140+
"standalone",
141+
"--port",
142+
str(self.port),
143+
"--log-level",
144+
self.log_level,
145+
"--selenium-manager",
146+
"true",
147+
"--enable-managed-downloads",
148+
"true",
149+
]
150+
if self.host is not None:
151+
command.extend(["--host", self.host])
152+
153+
host = self.host if self.host is not None else "localhost"
154+
155+
try:
156+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
157+
sock.connect((host, self.port))
158+
raise ConnectionError(f"Selenium server is already running, or something else is using port {self.port}")
159+
except ConnectionRefusedError:
160+
print("Starting Selenium server...")
161+
self.process = subprocess.Popen(command, env=self.env)
162+
print(f"Selenium server running as process: {self.process.pid}")
163+
if not self._wait_for_server():
164+
raise TimeoutError(f"Timed out waiting for Selenium server at {self.status_url}")
165+
print("Selenium server is ready")
166+
return self.process
167+
168+
def stop(self):
169+
"""Stop the server."""
170+
if self.process is None:
171+
raise RuntimeError("Selenium server isn't running")
172+
else:
173+
if self.process.poll() is None:
174+
self.process.terminate()
175+
self.process.wait()
176+
self.process = None
177+
print("Selenium server has been terminated")
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Licensed to the Software Freedom Conservancy (SFC) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The SFC licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import os
19+
20+
import pytest
21+
22+
from selenium.webdriver.remote.server import Server
23+
24+
25+
@pytest.fixture
26+
def standalone_server():
27+
server = Server()
28+
server_path = server.download_if_needed()
29+
remove_file(server_path)
30+
yield server
31+
remove_file(server_path)
32+
33+
34+
def remove_file(path):
35+
try:
36+
os.remove(path)
37+
except FileNotFoundError:
38+
pass
39+
40+
41+
def test_download_latest_server(standalone_server):
42+
server_path = standalone_server.download_if_needed()
43+
assert os.path.exists(server_path)
44+
assert os.path.getsize(server_path) > 0

0 commit comments

Comments
 (0)