Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
seleniumhq
GitHub Repository: seleniumhq/selenium
Path: blob/trunk/py/conftest.py
4500 views
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 http.server
19
import os
20
import socketserver
21
import sys
22
import threading
23
import types
24
from dataclasses import dataclass
25
from pathlib import Path
26
27
import pytest
28
import rich.console
29
import rich.traceback
30
31
try:
32
from python.runfiles import Runfiles # only exists when using bazel
33
except ModuleNotFoundError:
34
Runfiles = None
35
36
from selenium import webdriver
37
from selenium.common.exceptions import WebDriverException
38
from selenium.webdriver.common.utils import free_port
39
from selenium.webdriver.remote.server import Server
40
from test.selenium.webdriver.common.network import get_lan_ip
41
from test.selenium.webdriver.common.webserver import SimpleWebServer
42
43
drivers = (
44
"chrome",
45
"edge",
46
"firefox",
47
"ie",
48
"safari",
49
"webkitgtk",
50
"wpewebkit",
51
)
52
53
54
TRACEBACK_WIDTH = 130
55
# don't force colors on RBE since errors get redirected to a log file
56
force_terminal = "REMOTE_BUILD" not in os.environ
57
console = rich.console.Console(force_terminal=force_terminal, width=TRACEBACK_WIDTH)
58
59
60
def extract_traceback_frames(tb):
61
"""Extract frames from a traceback object."""
62
frames = []
63
while tb:
64
if hasattr(tb, "tb_frame") and hasattr(tb, "tb_lineno"):
65
# Skip frames without source files
66
if Path(tb.tb_frame.f_code.co_filename).exists():
67
frames.append((tb.tb_frame, tb.tb_lineno, getattr(tb, "tb_lasti", 0)))
68
tb = getattr(tb, "tb_next", None)
69
return frames
70
71
72
def filter_frames(frames):
73
"""Filter out frames from pytest internals."""
74
skip_modules = ["pytest", "_pytest", "pluggy"]
75
filtered = []
76
for frame, lineno, lasti in reversed(frames):
77
mod_name = frame.f_globals.get("__name__", "")
78
if not any(skip in mod_name for skip in skip_modules):
79
filtered.append((frame, lineno, lasti))
80
return filtered
81
82
83
def rebuild_traceback(frames):
84
"""Rebuild a traceback object from frames list."""
85
new_tb = None
86
for frame, lineno, lasti in frames:
87
new_tb = types.TracebackType(new_tb, frame, lasti, lineno)
88
return new_tb
89
90
91
def pytest_runtest_makereport(item, call):
92
"""Hook to print Rich traceback for test failures."""
93
if call.excinfo is None:
94
return
95
exc_type = call.excinfo.type
96
exc_value = call.excinfo.value
97
exc_tb = call.excinfo.tb
98
frames = extract_traceback_frames(exc_tb)
99
filtered_frames = filter_frames(frames)
100
new_tb = rebuild_traceback(filtered_frames)
101
tb = rich.traceback.Traceback.from_exception(
102
exc_type,
103
exc_value,
104
new_tb,
105
show_locals=False,
106
max_frames=5,
107
width=TRACEBACK_WIDTH,
108
)
109
console.print("\n", tb)
110
111
112
def pytest_addoption(parser):
113
parser.addoption(
114
"--driver",
115
action="append",
116
choices=drivers,
117
dest="drivers",
118
metavar="DRIVER",
119
help="Driver to run tests against ({})".format(", ".join(drivers)),
120
)
121
parser.addoption(
122
"--browser-binary",
123
action="store",
124
dest="binary",
125
help="Location of the browser binary",
126
)
127
parser.addoption(
128
"--driver-binary",
129
action="store",
130
dest="executable",
131
help="Location of the service executable binary",
132
)
133
parser.addoption(
134
"--browser-args",
135
action="store",
136
dest="args",
137
help="Arguments to start the browser with",
138
)
139
parser.addoption(
140
"--headless",
141
action="store_true",
142
dest="headless",
143
help="Run tests in headless mode",
144
)
145
parser.addoption(
146
"--use-lan-ip",
147
action="store_true",
148
dest="use_lan_ip",
149
help="Start test server with lan ip instead of localhost",
150
)
151
parser.addoption(
152
"--bidi",
153
action="store_true",
154
dest="bidi",
155
help="Enable BiDi support",
156
)
157
parser.addoption(
158
"--remote",
159
action="store_true",
160
dest="remote",
161
help="Run tests against a remote Grid server",
162
)
163
164
165
def pytest_ignore_collect(collection_path, config):
166
drivers_opt = config.getoption("drivers")
167
_drivers = set(drivers).difference(drivers_opt or drivers)
168
if drivers_opt:
169
_drivers.add("unit")
170
if len([d for d in _drivers if d.lower() in collection_path.parts]) > 0:
171
return True
172
return None
173
174
175
def pytest_generate_tests(metafunc):
176
if "driver" in metafunc.fixturenames and metafunc.config.option.drivers:
177
metafunc.parametrize("driver", metafunc.config.option.drivers, indirect=True)
178
179
180
selenium_driver = None
181
182
183
def get_extensions_location():
184
"""Locate the test extensions directory.
185
186
Use Runfiles to locate it if running with bazel, otherwise find it in the local source tree.
187
"""
188
if Runfiles is not None:
189
r = Runfiles.Create()
190
extensions = r.Rlocation("selenium/py/test/extensions")
191
else:
192
extensions = str((Path(__file__).parent.parent / "common" / "extensions").resolve())
193
return extensions
194
195
196
class ContainerProtocol:
197
def __contains__(self, name):
198
if name.lower() in self.__dict__:
199
return True
200
return False
201
202
203
@dataclass
204
class SupportedDrivers(ContainerProtocol):
205
chrome: str = "Chrome"
206
firefox: str = "Firefox"
207
safari: str = "Safari"
208
edge: str = "Edge"
209
ie: str = "Ie"
210
webkitgtk: str = "WebKitGTK"
211
wpewebkit: str = "WPEWebKit"
212
213
214
@dataclass
215
class SupportedOptions(ContainerProtocol):
216
chrome: str = "ChromeOptions"
217
firefox: str = "FirefoxOptions"
218
edge: str = "EdgeOptions"
219
safari: str = "SafariOptions"
220
ie: str = "IeOptions"
221
webkitgtk: str = "WebKitGTKOptions"
222
wpewebkit: str = "WPEWebKitOptions"
223
224
225
@dataclass
226
class SupportedBidiDrivers(ContainerProtocol):
227
chrome: str = "Chrome"
228
firefox: str = "Firefox"
229
edge: str = "Edge"
230
231
232
class Driver:
233
def __init__(self, driver_class, request):
234
self.driver_class = driver_class
235
self._request = request
236
self._driver = None
237
self._service = None
238
self._server = None
239
self.options = driver_class
240
self.headless = driver_class
241
self.bidi = driver_class
242
243
@classmethod
244
def clean_options(cls, driver_class, request):
245
return cls(driver_class, request).options
246
247
@property
248
def supported_drivers(self):
249
return SupportedDrivers()
250
251
@property
252
def supported_options(self):
253
return SupportedOptions()
254
255
@property
256
def supported_bidi_drivers(self):
257
return SupportedBidiDrivers()
258
259
@property
260
def driver_class(self):
261
return self._driver_class
262
263
@driver_class.setter
264
def driver_class(self, cls_name):
265
if cls_name.lower() not in self.supported_drivers:
266
raise AttributeError(f"Invalid driver class {cls_name.lower()}")
267
self._driver_class = getattr(self.supported_drivers, cls_name.lower())
268
269
@property
270
def exe_platform(self):
271
if sys.platform == "win32":
272
return "Windows"
273
elif sys.platform == "darwin":
274
return "Darwin"
275
elif sys.platform == "linux":
276
return "Linux"
277
else:
278
return sys.platform.title()
279
280
@property
281
def browser_path(self):
282
if self._request.config.option.binary:
283
return self._request.config.option.binary
284
return None
285
286
@property
287
def browser_args(self):
288
if self._request.config.option.args:
289
return self._request.config.option.args
290
return None
291
292
@property
293
def driver_path(self):
294
if self._request.config.option.executable:
295
return self._request.config.option.executable
296
return None
297
298
@property
299
def headless(self):
300
return self._headless
301
302
@headless.setter
303
def headless(self, cls_name):
304
self._headless = self._request.config.option.headless
305
if self._headless:
306
if cls_name.lower() == "chrome" or cls_name.lower() == "edge":
307
self._options.add_argument("--headless")
308
if cls_name.lower() == "firefox":
309
self._options.add_argument("-headless")
310
311
@property
312
def bidi(self):
313
return self._bidi
314
315
@bidi.setter
316
def bidi(self, cls_name):
317
self._bidi = self._request.config.option.bidi
318
if self._bidi:
319
self._options.web_socket_url = True
320
self._options.unhandled_prompt_behavior = "ignore"
321
322
@property
323
def options(self):
324
return self._options
325
326
@options.setter
327
def options(self, cls_name):
328
if cls_name.lower() not in self.supported_options:
329
raise AttributeError(f"Invalid Options class {cls_name.lower()}")
330
331
if self.driver_class == self.supported_drivers.firefox:
332
self._options = getattr(webdriver, self.supported_options.firefox)()
333
if self.exe_platform == "Linux":
334
# There are issues with window size/position when running Firefox
335
# under Wayland, so we use XWayland instead.
336
os.environ["MOZ_ENABLE_WAYLAND"] = "0"
337
else:
338
opts_cls = getattr(self.supported_options, cls_name.lower())
339
self._options = getattr(webdriver, opts_cls)()
340
341
if cls_name.lower() in ("chrome", "edge"):
342
self._options.add_argument("--disable-dev-shm-usage")
343
344
if self.is_remote:
345
self._options.enable_downloads = True
346
347
if self.browser_path or self.browser_args:
348
if self.driver_class == self.supported_drivers.webkitgtk:
349
self._options.overlay_scrollbars_enabled = False
350
if self.browser_path is not None:
351
self._options.binary_location = self.browser_path.strip("'")
352
if self.browser_args is not None:
353
for arg in self.browser_args.split():
354
self._options.add_argument(arg)
355
356
@property
357
def service(self):
358
executable = self.driver_path
359
if executable:
360
module = getattr(webdriver, self.driver_class.lower())
361
self._service = module.service.Service(executable_path=executable)
362
return self._service
363
return None
364
365
@property
366
def driver(self):
367
if self._driver is None:
368
self._driver = self._initialize_driver()
369
return self._driver
370
371
@property
372
def is_platform_valid(self):
373
if self.driver_class.lower() == "safari" and self.exe_platform != "Darwin":
374
return False
375
if self.driver_class.lower() == "ie" and self.exe_platform != "Windows":
376
return False
377
if "webkit" in self.driver_class.lower() and self.exe_platform == "Windows":
378
return False
379
return True
380
381
@property
382
def is_remote(self):
383
return self._request.config.getoption("remote")
384
385
def _initialize_driver(self):
386
kwargs = {}
387
if self.options is not None:
388
kwargs["options"] = self.options
389
if self.is_remote:
390
kwargs["command_executor"] = self._server.status_url.removesuffix("/status")
391
return webdriver.Remote(**kwargs)
392
if self.driver_path is not None:
393
kwargs["service"] = self.service
394
return getattr(webdriver, self.driver_class)(**kwargs)
395
396
def stop_driver(self):
397
driver_to_stop = self._driver
398
self._driver = None
399
if driver_to_stop is not None:
400
driver_to_stop.quit()
401
402
403
@pytest.fixture
404
def driver(request, server):
405
global selenium_driver
406
driver_class = getattr(request, "param", "Chrome").lower()
407
408
if selenium_driver is None:
409
selenium_driver = Driver(driver_class, request)
410
if server:
411
selenium_driver._server = server
412
413
# skip tests if not available on the platform
414
if not selenium_driver.is_platform_valid:
415
pytest.skip(f"{driver_class} tests can only run on {selenium_driver.exe_platform}")
416
417
# skip tests in the 'remote' directory if not running with --remote flag
418
if request.node.path.parts[-2] == "remote" and not selenium_driver.is_remote:
419
pytest.skip("Remote tests require the --remote flag")
420
421
# skip tests for drivers that don't support BiDi when --bidi is enabled
422
if selenium_driver.bidi:
423
if driver_class.lower() not in selenium_driver.supported_bidi_drivers:
424
pytest.skip(f"{driver_class} does not support BiDi")
425
426
# conditionally mark tests as expected to fail based on driver
427
marker = request.node.get_closest_marker(f"xfail_{driver_class.lower()}")
428
# Also check for xfail_remote when running with --remote
429
if marker is None and selenium_driver.is_remote:
430
marker = request.node.get_closest_marker("xfail_remote")
431
if marker is not None:
432
kwargs = dict(marker.kwargs)
433
# Support condition kwarg - if condition is False, skip the xfail
434
condition = kwargs.pop("condition", True)
435
if callable(condition):
436
condition = condition()
437
if condition:
438
if "run" in kwargs:
439
if not kwargs["run"]:
440
pytest.skip()
441
yield
442
return
443
kwargs.pop("raises", None)
444
pytest.xfail(**kwargs)
445
446
# For BiDi tests, only restart driver when explicitly marked as needing fresh driver.
447
# Tests marked with @pytest.mark.needs_fresh_driver get full driver restart for test isolation.
448
# Cleanup after every test is recommended.
449
if selenium_driver is not None and selenium_driver.bidi:
450
if request.node.get_closest_marker("needs_fresh_driver"):
451
request.addfinalizer(selenium_driver.stop_driver)
452
else:
453
454
def ensure_valid_window():
455
try:
456
driver = selenium_driver._driver
457
if driver:
458
try:
459
# Check if current window is still valid
460
driver.current_window_handle
461
except Exception:
462
# restart driver
463
selenium_driver.stop_driver()
464
except Exception:
465
pass
466
467
request.addfinalizer(ensure_valid_window) # noqa: PT021
468
469
yield selenium_driver.driver
470
471
if request.node.get_closest_marker("no_driver_after_test"):
472
if selenium_driver is not None:
473
try:
474
selenium_driver.stop_driver()
475
except WebDriverException:
476
pass
477
except Exception:
478
raise
479
selenium_driver = None
480
481
482
@pytest.fixture(scope="session", autouse=True)
483
def stop_driver(request):
484
def fin():
485
global selenium_driver
486
if selenium_driver is not None:
487
selenium_driver.stop_driver()
488
selenium_driver = None
489
490
request.addfinalizer(fin) # noqa: PT021
491
492
493
def pytest_exception_interact(node, call, report):
494
if report.failed:
495
global selenium_driver
496
if selenium_driver is not None:
497
selenium_driver.stop_driver()
498
selenium_driver = None
499
500
501
@pytest.fixture
502
def pages(driver, webserver):
503
class Pages:
504
def url(self, name, localhost=False):
505
return webserver.where_is(name, localhost)
506
507
def load(self, name):
508
driver.get(self.url(name))
509
510
return Pages()
511
512
513
@pytest.fixture(autouse=True, scope="session")
514
def server(request):
515
is_remote = request.config.getoption("remote")
516
if not is_remote:
517
yield None
518
return
519
520
remote_env = os.environ.copy()
521
if sys.platform == "linux":
522
# There are issues with window size/position when running Firefox
523
# under Wayland, so we use XWayland instead.
524
remote_env["MOZ_ENABLE_WAYLAND"] = "0"
525
526
server = Server(env=remote_env, startup_timeout=60)
527
528
repo_root = Path(__file__).parent.parent # py/conftest.py -> py/ -> selenium/
529
jar_path = "java/src/org/openqa/selenium/grid/selenium_server_deploy.jar"
530
531
if Path(jar_path).exists():
532
# Found in runfiles (bazel test) - relative to cwd
533
server.path = jar_path
534
elif (repo_root / "bazel-bin" / jar_path).exists():
535
# Found in bazel-bin relative to repo root (pytest from anywhere)
536
server.path = str(repo_root / "bazel-bin" / jar_path)
537
538
if Runfiles is not None:
539
# Find bazel's Java
540
r = Runfiles.Create()
541
java_location_txt = r.Rlocation("_main/" + os.environ.get("SE_BAZEL_JAVA_LOCATION"))
542
try:
543
rel_path = Path(java_location_txt).read_text().strip().removeprefix("external/")
544
server.java_path = r.Rlocation(rel_path)
545
except Exception:
546
pass
547
548
server.port = free_port()
549
server.start()
550
yield server
551
server.stop()
552
553
554
@pytest.fixture(autouse=True, scope="session")
555
def webserver(request):
556
host = get_lan_ip() if request.config.getoption("use_lan_ip") else None
557
558
webserver = SimpleWebServer(host=host)
559
webserver.start()
560
yield webserver
561
webserver.stop()
562
563
564
@pytest.fixture
565
def edge_service():
566
from selenium.webdriver.edge.service import Service as EdgeService
567
568
return EdgeService
569
570
571
@pytest.fixture
572
def driver_executable(request):
573
return request.config.option.executable
574
575
576
@pytest.fixture
577
def clean_driver(request):
578
_supported_drivers = SupportedDrivers()
579
try:
580
driver_class = getattr(_supported_drivers, request.config.option.drivers[0].lower())
581
except (AttributeError, TypeError):
582
raise Exception("This test requires a --driver to be specified.")
583
driver_reference = getattr(webdriver, driver_class)
584
585
# conditionally mark tests as expected to fail based on driver
586
marker = request.node.get_closest_marker(f"xfail_{driver_class.lower()}")
587
# Also check for xfail_remote when running with --remote
588
if marker is None and request.config.getoption("remote"):
589
marker = request.node.get_closest_marker("xfail_remote")
590
if marker is not None:
591
kwargs = dict(marker.kwargs)
592
if "run" in kwargs:
593
if not kwargs["run"]:
594
pytest.skip()
595
yield
596
return
597
kwargs.pop("raises", None)
598
pytest.xfail(**kwargs)
599
600
yield driver_reference
601
602
if request.node.get_closest_marker("no_driver_after_test"):
603
driver_reference = None
604
605
606
@pytest.fixture
607
def clean_service(request):
608
driver_class = request.config.option.drivers[0].lower()
609
selenium_driver = Driver(driver_class, request)
610
return selenium_driver.service
611
612
613
@pytest.fixture
614
def clean_options(request):
615
driver_class = request.config.option.drivers[0].lower()
616
return Driver.clean_options(driver_class, request)
617
618
619
@pytest.fixture
620
def firefox_options(request):
621
try:
622
driver_class = request.config.option.drivers[0].lower()
623
except (AttributeError, TypeError):
624
raise Exception("This test requires a --driver to be specified")
625
626
# skip if not Firefox
627
if driver_class != "firefox":
628
pytest.skip(f"This test requires Firefox. Got {driver_class}")
629
630
# skip tests in the 'remote' directory if not running with --remote flag
631
is_remote = request.config.getoption("remote")
632
if request.node.path.parts[-2] == "remote" and not is_remote:
633
pytest.skip("Remote tests require the --remote flag")
634
635
options = Driver.clean_options("firefox", request)
636
637
return options
638
639
640
@pytest.fixture
641
def chromium_options(request):
642
try:
643
driver_class = request.config.option.drivers[0].lower()
644
except (AttributeError, TypeError):
645
raise Exception("This test requires a --driver to be specified")
646
647
# skip if not Chrome or Edge
648
if driver_class not in ("chrome", "edge"):
649
pytest.skip(f"This test requires Chrome or Edge. Got {driver_class}")
650
651
# skip tests in the 'remote' directory if not running with --remote flag
652
is_remote = request.config.getoption("remote")
653
if request.node.path.parts[-2] == "remote" and not is_remote:
654
pytest.skip("Remote tests require the --remote flag")
655
656
options = Driver.clean_options(driver_class, request)
657
658
return options
659
660
661
@pytest.fixture
662
def proxy_server():
663
"""Creates HTTP proxy servers with custom response content, cleans up after the test."""
664
servers = []
665
666
def create_server(response_content=b"test response"):
667
port = free_port()
668
669
class CustomHandler(http.server.SimpleHTTPRequestHandler):
670
def do_GET(self):
671
self.send_response(200)
672
self.end_headers()
673
self.wfile.write(response_content)
674
675
server = socketserver.TCPServer(("localhost", port), CustomHandler)
676
thread = threading.Thread(target=server.serve_forever, daemon=True)
677
thread.start()
678
679
servers.append(server)
680
return {"port": port, "server": server}
681
682
yield create_server
683
684
for server in servers:
685
server.shutdown()
686
server.server_close()
687
688