import os 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.text import capfirst class PlaywrightTestCaseMeta(type(LiveServerTestCase)): # List of browsers to dynamically create test classes for. browsers = [] # Sentinel value to differentiate browser-specific instances. browser = None # Run browsers with a visible window when True. headed = False # Delay in milliseconds between Playwright operations. slow_mo = 0 def __new__(cls, name, bases, attrs): """ Dynamically create new classes and add them to the test module when multiple browser specs are provided (e.g. --playwright=XXXX). """ 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 # 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, "__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_browser(cls, browser): from playwright.sync_api import Playwright if not hasattr(Playwright, browser): raise ImportError( "Playwright browser specification '%s' is not valid." % browser ) class ChangeViewportSize: def __init__(self, width, height, page): self.page = page self.new_size = {"width": width, "height": height} def __enter__(self): self.old_size = self.page.viewport_size self.page.set_viewport_size(self.new_size) return self def __exit__(self, exc_type, exc_value, traceback): if self.old_size: self.page.set_viewport_size(self.old_size) @tag("playwright") class PlaywrightTestCase(LiveServerTestCase, metaclass=PlaywrightTestCaseMeta): default_timeout = 10000 # milliseconds screenshots = False browser_timezone = "America/Chicago" @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) @classmethod def setUpClass(cls): # Playwright's sync API leaves an asyncio event loop running on the # test thread, which would otherwise raise SynchronousOnlyOperation on # ORM use. cls._old_async_unsafe = os.environ.get("DJANGO_ALLOW_ASYNC_UNSAFE") os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" cls.addClassCleanup(cls._restore_async_unsafe) from playwright.sync_api import expect, sync_playwright cls.expect = staticmethod(expect) cls._pw = sync_playwright().start() cls.addClassCleanup(cls._quit_playwright) super().setUpClass() browser_type = getattr(cls._pw, cls.browser) ignore_default_args = [] if cls.screenshots: ignore_default_args = ["--hide-scrollbars"] cls._browser = browser_type.launch( headless=not cls.headed, slow_mo=cls.slow_mo, ignore_default_args=ignore_default_args, # unittest.installHandler() in DiscoverRunner already handles # SIGINT. handle_sigint=False, ) # Use a fixed browser timezone so browser-based tests don't depend on # the local timezone of the machine running them. cls._browser_context = cls._browser.new_context( timezone_id=cls.browser_timezone ) cls.page = cls._browser_context.new_page() cls.page.set_default_timeout(cls.default_timeout) cls.addClassCleanup(cls._close_browser) @contextmanager def desktop_size(self): with ChangeViewportSize(1280, 720, self.page): yield @contextmanager def small_screen_size(self): with ChangeViewportSize(1024, 768, self.page): yield @contextmanager def mobile_size(self): with ChangeViewportSize(360, 800, self.page): 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 reading/writing storage. self.page.goto(self.live_server_url) self.page.local_storage.set_item("theme", "dark") with self.desktop_size(): try: yield finally: self.page.local_storage.remove_item("theme") @contextmanager def high_contrast(self): self.page.emulate_media(forced_colors="active") with self.desktop_size(): try: yield finally: self.page.emulate_media(forced_colors="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.page.screenshot(path=path) @classmethod def _close_browser(cls): # Close resources 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, "page"): cls.page.close() del cls.page if hasattr(cls, "_browser_context"): cls._browser_context.close() del cls._browser_context if hasattr(cls, "_browser"): cls._browser.close() del cls._browser @classmethod def _quit_playwright(cls): cls._close_browser() if hasattr(cls, "_pw"): cls._pw.stop() del cls._pw @classmethod def _restore_async_unsafe(cls): if cls._old_async_unsafe is None: os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE", None) else: os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = cls._old_async_unsafe 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", {"playwright_screenshot"}.union(getattr(func, "tags", set())) ) return func return wrapper