1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263
|
import sys
import unittest
from contextlib import contextmanager
from functools import wraps
from pathlib import Path
from django.conf import settings
from django.test import LiveServerTestCase, override_settings, tag
from django.utils.functional import classproperty
from django.utils.module_loading import import_string
from django.utils.text import capfirst
class SeleniumTestCaseBase(type(LiveServerTestCase)):
# List of browsers to dynamically create test classes for.
browsers = []
# A selenium hub URL to test against.
selenium_hub = None
# The external host Selenium Hub can reach.
external_host = None
# Sentinel value to differentiate browser-specific instances.
browser = None
# Run browsers in headless mode.
headless = False
def __new__(cls, name, bases, attrs):
"""
Dynamically create new classes and add them to the test module when
multiple browsers specs are provided (e.g. --selenium=firefox,chrome).
"""
test_class = super().__new__(cls, name, bases, attrs)
# If the test class is either browser-specific or a test base, return it.
if test_class.browser or not any(
name.startswith("test") and callable(value) for name, value in attrs.items()
):
return test_class
elif test_class.browsers:
# Reuse the created test class to make it browser-specific.
# We can't rename it to include the browser name or create a
# subclass like we do with the remaining browsers as it would
# either duplicate tests or prevent pickling of its instances.
first_browser = test_class.browsers[0]
test_class.browser = first_browser
# Listen on an external interface if using a selenium hub.
host = test_class.host if not test_class.selenium_hub else "0.0.0.0"
test_class.host = host
test_class.external_host = cls.external_host
# Create subclasses for each of the remaining browsers and expose
# them through the test's module namespace.
module = sys.modules[test_class.__module__]
for browser in test_class.browsers[1:]:
browser_test_class = cls.__new__(
cls,
"%s%s" % (capfirst(browser), name),
(test_class,),
{
"browser": browser,
"host": host,
"external_host": cls.external_host,
"__module__": test_class.__module__,
},
)
setattr(module, browser_test_class.__name__, browser_test_class)
return test_class
# If no browsers were specified, skip this class (it'll still be discovered).
return unittest.skip("No browsers specified.")(test_class)
@classmethod
def import_webdriver(cls, browser):
return import_string("selenium.webdriver.%s.webdriver.WebDriver" % browser)
@classmethod
def import_options(cls, browser):
return import_string("selenium.webdriver.%s.options.Options" % browser)
@classmethod
def get_capability(cls, browser):
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
return getattr(DesiredCapabilities, browser.upper())
def create_options(self):
options = self.import_options(self.browser)()
if self.headless:
match self.browser:
case "chrome" | "edge":
options.add_argument("--headless=new")
case "firefox":
options.add_argument("-headless")
return options
def create_webdriver(self):
options = self.create_options()
if self.selenium_hub:
from selenium import webdriver
for key, value in self.get_capability(self.browser).items():
options.set_capability(key, value)
return webdriver.Remote(command_executor=self.selenium_hub, options=options)
return self.import_webdriver(self.browser)(options=options)
class ChangeWindowSize:
def __init__(self, width, height, selenium):
self.selenium = selenium
self.new_size = (width, height)
def __enter__(self):
self.old_size = self.selenium.get_window_size()
self.selenium.set_window_size(*self.new_size)
return self
def __exit__(self, exc_type, exc_value, traceback):
self.selenium.set_window_size(self.old_size["width"], self.old_size["height"])
@tag("selenium")
class SeleniumTestCase(LiveServerTestCase, metaclass=SeleniumTestCaseBase):
implicit_wait = 10
external_host = None
screenshots = False
@classmethod
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not cls.screenshots:
return
for name, func in list(cls.__dict__.items()):
if not hasattr(func, "_screenshot_cases"):
continue
# Remove the main test.
delattr(cls, name)
# Add separate tests for each screenshot type.
for screenshot_case in getattr(func, "_screenshot_cases"):
@wraps(func)
def test(self, *args, _func=func, _case=screenshot_case, **kwargs):
with getattr(self, _case)():
return _func(self, *args, **kwargs)
test.__name__ = f"{name}_{screenshot_case}"
test.__qualname__ = f"{test.__qualname__}_{screenshot_case}"
test._screenshot_name = name
test._screenshot_case = screenshot_case
setattr(cls, test.__name__, test)
@classproperty
def live_server_url(cls):
return "http://%s:%s" % (cls.external_host or cls.host, cls.server_thread.port)
@classproperty
def allowed_host(cls):
return cls.external_host or cls.host
@classmethod
def setUpClass(cls):
cls.selenium = cls.create_webdriver()
cls.selenium.implicitly_wait(cls.implicit_wait)
super().setUpClass()
cls.addClassCleanup(cls._quit_selenium)
@contextmanager
def desktop_size(self):
with ChangeWindowSize(1280, 720, self.selenium):
yield
@contextmanager
def small_screen_size(self):
with ChangeWindowSize(1024, 768, self.selenium):
yield
@contextmanager
def mobile_size(self):
with ChangeWindowSize(360, 800, self.selenium):
yield
@contextmanager
def rtl(self):
with self.desktop_size():
with override_settings(LANGUAGE_CODE=settings.LANGUAGES_BIDI[-1]):
yield
@contextmanager
def dark(self):
# Navigate to a page before executing a script.
self.selenium.get(self.live_server_url)
self.selenium.execute_script("localStorage.setItem('theme', 'dark');")
with self.desktop_size():
try:
yield
finally:
self.selenium.execute_script("localStorage.removeItem('theme');")
def set_emulated_media(self, *, media=None, features=None):
if self.browser not in {"chrome", "edge"}:
self.skipTest(
"Emulation.setEmulatedMedia is only supported on Chromium and "
"Chrome-based browsers. See https://chromedevtools.github.io/devtools-"
"protocol/1-3/Emulation/#method-setEmulatedMedia for more details."
)
params = {}
if media is not None:
params["media"] = media
if features is not None:
params["features"] = features
# Not using .execute_cdp_cmd() as it isn't supported by the remote web driver
# when using --selenium-hub.
self.selenium.execute(
driver_command="executeCdpCommand",
params={"cmd": "Emulation.setEmulatedMedia", "params": params},
)
@contextmanager
def high_contrast(self):
self.set_emulated_media(features=[{"name": "forced-colors", "value": "active"}])
with self.desktop_size():
try:
yield
finally:
self.set_emulated_media(
features=[{"name": "forced-colors", "value": "none"}]
)
def take_screenshot(self, name):
if not self.screenshots:
return
test = getattr(self, self._testMethodName)
filename = f"{test._screenshot_name}--{name}--{test._screenshot_case}.png"
path = Path.cwd() / "screenshots" / filename
path.parent.mkdir(exist_ok=True, parents=True)
self.selenium.save_screenshot(path)
@classmethod
def _quit_selenium(cls):
# quit() the WebDriver before attempting to terminate and join the
# single-threaded LiveServerThread to avoid a dead lock if the browser
# kept a connection alive.
if hasattr(cls, "selenium"):
cls.selenium.quit()
@contextmanager
def disable_implicit_wait(self):
"""Disable the default implicit wait."""
self.selenium.implicitly_wait(0)
try:
yield
finally:
self.selenium.implicitly_wait(self.implicit_wait)
def screenshot_cases(method_names):
if isinstance(method_names, str):
method_names = method_names.split(",")
def wrapper(func):
func._screenshot_cases = method_names
setattr(func, "tags", {"screenshot"}.union(getattr(func, "tags", set())))
return func
return wrapper
|