# Copyright (c) Microsoft Corporation. # # Licensed 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 hashlib import json import shutil import os import sys import warnings from pathlib import Path from typing import ( Any, Callable, Dict, Generator, List, Literal, Optional, Protocol, Sequence, Union, Pattern, cast, ) import pytest from playwright.sync_api import ( Browser, BrowserContext, BrowserType, Error, Page, Playwright, sync_playwright, ProxySettings, StorageState, HttpCredentials, Geolocation, ViewportSize, ) from slugify import slugify import tempfile @pytest.fixture(scope="session") def _pw_artifacts_folder() -> Generator[tempfile.TemporaryDirectory, None, None]: artifacts_folder = tempfile.TemporaryDirectory(prefix="playwright-pytest-") yield artifacts_folder try: # On Windows, files can be still in use. # https://github.com/microsoft/playwright-pytest/issues/163 artifacts_folder.cleanup() except (PermissionError, NotADirectoryError): pass @pytest.fixture(scope="session", autouse=True) def delete_output_dir(pytestconfig: Any) -> None: output_dir = pytestconfig.getoption("--output") if os.path.exists(output_dir): try: shutil.rmtree(output_dir) except (FileNotFoundError, PermissionError): # When running in parallel, another thread may have already deleted the files pass except OSError as error: if error.errno != 16: raise # We failed to remove folder, might be due to the whole folder being mounted inside a container: # https://github.com/microsoft/playwright/issues/12106 # https://github.com/microsoft/playwright-python/issues/1781 # Do a best-effort to remove all files inside of it instead. entries = os.listdir(output_dir) for entry in entries: shutil.rmtree(entry) def pytest_generate_tests(metafunc: Any) -> None: if "browser_name" in metafunc.fixturenames: browsers = metafunc.config.option.browser or ["chromium"] metafunc.parametrize("browser_name", browsers, scope="session") def pytest_configure(config: Any) -> None: config.addinivalue_line( "markers", "skip_browser(name): mark test to be skipped a specific browser" ) config.addinivalue_line( "markers", "only_browser(name): mark test to run only on a specific browser" ) config.addinivalue_line( "markers", "browser_context_args(**kwargs): provide additional arguments to browser.new_context()", ) # Making test result information available in fixtures # https://docs.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item: Any) -> Generator[None, Any, None]: # execute all other hooks to obtain the report object outcome = yield rep = outcome.get_result() # set a report attribute for each phase of a call, which can # be "setup", "call", "teardown" setattr(item, "rep_" + rep.when, rep) def _get_skiplist(item: Any, values: List[str], value_name: str) -> List[str]: skipped_values: List[str] = [] # Allowlist only_marker = item.get_closest_marker(f"only_{value_name}") if only_marker: skipped_values = values skipped_values.remove(only_marker.args[0]) # Denylist skip_marker = item.get_closest_marker(f"skip_{value_name}") if skip_marker: skipped_values.append(skip_marker.args[0]) return skipped_values def pytest_runtest_setup(item: Any) -> None: if not hasattr(item, "callspec"): return browser_name = item.callspec.params.get("browser_name") if not browser_name: return skip_browsers_names = _get_skiplist( item, ["chromium", "firefox", "webkit"], "browser" ) if browser_name in skip_browsers_names: pytest.skip("skipped for this browser: {}".format(browser_name)) VSCODE_PYTHON_EXTENSION_ID = "ms-python.python" @pytest.fixture(scope="session") def browser_type_launch_args(pytestconfig: Any) -> Dict: launch_options = {} headed_option = pytestconfig.getoption("--headed") if headed_option: launch_options["headless"] = False elif VSCODE_PYTHON_EXTENSION_ID in sys.argv[0] and _is_debugger_attached(): # When the VSCode debugger is attached, then launch the browser headed by default launch_options["headless"] = False browser_channel_option = pytestconfig.getoption("--browser-channel") if browser_channel_option: launch_options["channel"] = browser_channel_option slowmo_option = pytestconfig.getoption("--slowmo") if slowmo_option: launch_options["slow_mo"] = slowmo_option return launch_options def _is_debugger_attached() -> bool: pydevd = sys.modules.get("pydevd") if not pydevd or not hasattr(pydevd, "get_global_debugger"): return False debugger = pydevd.get_global_debugger() if not debugger or not hasattr(debugger, "is_attached"): return False return debugger.is_attached() @pytest.fixture def output_path(pytestconfig: Any, request: pytest.FixtureRequest) -> str: output_dir = Path(pytestconfig.getoption("--output")).absolute() return os.path.join(output_dir, _truncate_file_name(slugify(request.node.nodeid))) def _truncate_file_name(file_name: str) -> str: if len(file_name) < 256: return file_name return f"{file_name[:100]}-{hashlib.sha256(file_name.encode()).hexdigest()[:7]}-{file_name[-100:]}" @pytest.fixture(scope="session") def browser_context_args( pytestconfig: Any, playwright: Playwright, device: Optional[str], base_url: Optional[str], _pw_artifacts_folder: tempfile.TemporaryDirectory, ) -> Dict: context_args = {} if device: context_args.update(playwright.devices[device]) if base_url: context_args["base_url"] = base_url video_option = pytestconfig.getoption("--video") capture_video = video_option in ["on", "retain-on-failure"] if capture_video: context_args["record_video_dir"] = _pw_artifacts_folder.name return context_args @pytest.fixture() def _artifacts_recorder( request: pytest.FixtureRequest, output_path: str, playwright: Playwright, pytestconfig: Any, _pw_artifacts_folder: tempfile.TemporaryDirectory, ) -> Generator["ArtifactsRecorder", None, None]: artifacts_recorder = ArtifactsRecorder( pytestconfig, request, output_path, playwright, _pw_artifacts_folder ) yield artifacts_recorder # If request.node is missing rep_call, then some error happened during execution # that prevented teardown, but should still be counted as a failure failed = request.node.rep_call.failed if hasattr(request.node, "rep_call") else True artifacts_recorder.did_finish_test(failed) @pytest.fixture(scope="session") def playwright() -> Generator[Playwright, None, None]: pw = sync_playwright().start() yield pw pw.stop() @pytest.fixture(scope="session") def browser_type(playwright: Playwright, browser_name: str) -> BrowserType: return getattr(playwright, browser_name) @pytest.fixture(scope="session") def connect_options() -> Optional[Dict]: return None @pytest.fixture(scope="session") def launch_browser( browser_type_launch_args: Dict, browser_type: BrowserType, connect_options: Optional[Dict], ) -> Callable[..., Browser]: def launch(**kwargs: Dict) -> Browser: launch_options = {**browser_type_launch_args, **kwargs} if connect_options: browser = browser_type.connect( **( { **connect_options, "headers": { "x-playwright-launch-options": json.dumps(launch_options), **(connect_options.get("headers") or {}), }, } ) ) else: browser = browser_type.launch(**launch_options) return browser return launch @pytest.fixture(scope="session") def browser(launch_browser: Callable[[], Browser]) -> Generator[Browser, None, None]: browser = launch_browser() yield browser browser.close() class CreateContextCallback(Protocol): def __call__( self, viewport: Optional[ViewportSize] = None, screen: Optional[ViewportSize] = None, no_viewport: Optional[bool] = None, ignore_https_errors: Optional[bool] = None, java_script_enabled: Optional[bool] = None, bypass_csp: Optional[bool] = None, user_agent: Optional[str] = None, locale: Optional[str] = None, timezone_id: Optional[str] = None, geolocation: Optional[Geolocation] = None, permissions: Optional[Sequence[str]] = None, extra_http_headers: Optional[Dict[str, str]] = None, offline: Optional[bool] = None, http_credentials: Optional[HttpCredentials] = None, device_scale_factor: Optional[float] = None, is_mobile: Optional[bool] = None, has_touch: Optional[bool] = None, color_scheme: Optional[ Literal["dark", "light", "no-preference", "null"] ] = None, reduced_motion: Optional[Literal["no-preference", "null", "reduce"]] = None, forced_colors: Optional[Literal["active", "none", "null"]] = None, accept_downloads: Optional[bool] = None, default_browser_type: Optional[str] = None, proxy: Optional[ProxySettings] = None, record_har_path: Optional[Union[str, Path]] = None, record_har_omit_content: Optional[bool] = None, record_video_dir: Optional[Union[str, Path]] = None, record_video_size: Optional[ViewportSize] = None, storage_state: Optional[Union[StorageState, str, Path]] = None, base_url: Optional[str] = None, strict_selectors: Optional[bool] = None, service_workers: Optional[Literal["allow", "block"]] = None, record_har_url_filter: Optional[Union[str, Pattern[str]]] = None, record_har_mode: Optional[Literal["full", "minimal"]] = None, record_har_content: Optional[Literal["attach", "embed", "omit"]] = None, ) -> BrowserContext: ... @pytest.fixture def new_context( browser: Browser, browser_context_args: Dict, _artifacts_recorder: "ArtifactsRecorder", request: pytest.FixtureRequest, ) -> Generator[CreateContextCallback, None, None]: browser_context_args = browser_context_args.copy() context_args_marker = next(request.node.iter_markers("browser_context_args"), None) additional_context_args = context_args_marker.kwargs if context_args_marker else {} browser_context_args.update(additional_context_args) contexts: List[BrowserContext] = [] def _new_context(**kwargs: Any) -> BrowserContext: context = browser.new_context(**browser_context_args, **kwargs) original_close = context.close def _close_wrapper(*args: Any, **kwargs: Any) -> None: contexts.remove(context) _artifacts_recorder.on_will_close_browser_context(context) original_close(*args, **kwargs) context.close = _close_wrapper contexts.append(context) _artifacts_recorder.on_did_create_browser_context(context) return context yield cast(CreateContextCallback, _new_context) for context in contexts.copy(): context.close() @pytest.fixture def context(new_context: CreateContextCallback) -> BrowserContext: return new_context() @pytest.fixture def page(context: BrowserContext) -> Page: return context.new_page() @pytest.fixture(scope="session") def is_webkit(browser_name: str) -> bool: return browser_name == "webkit" @pytest.fixture(scope="session") def is_firefox(browser_name: str) -> bool: return browser_name == "firefox" @pytest.fixture(scope="session") def is_chromium(browser_name: str) -> bool: return browser_name == "chromium" @pytest.fixture(scope="session") def browser_name(pytestconfig: Any) -> Optional[str]: # When using unittest.TestCase it won't use pytest_generate_tests # For that we still try to give the user a slightly less feature-rich experience browser_names = pytestconfig.getoption("--browser") if len(browser_names) == 0: return "chromium" if len(browser_names) == 1: return browser_names[0] warnings.warn( "When using unittest.TestCase specifying multiple browsers is not supported" ) return browser_names[0] @pytest.fixture(scope="session") def browser_channel(pytestconfig: Any) -> Optional[str]: return pytestconfig.getoption("--browser-channel") @pytest.fixture(scope="session") def device(pytestconfig: Any) -> Optional[str]: return pytestconfig.getoption("--device") PW_ASYNC_CANONICAL_NAME = "pytest_playwright_asyncio.pytest_playwright" PLUGIN_INCOMPATIBLE_MESSAGE = "pytest-playwright and pytest-playwright-asyncio are not compatible. Please use only one of them." def pytest_addoption( parser: pytest.Parser, pluginmanager: pytest.PytestPluginManager ) -> None: # Check for incompatible async plugin with canonical name if pluginmanager.has_plugin(PW_ASYNC_CANONICAL_NAME): raise RuntimeError(PLUGIN_INCOMPATIBLE_MESSAGE) # Check for incompatible async plugin with common name common_name_plugin = pluginmanager.get_plugin("playwright-asyncio") if ( common_name_plugin is not None and pluginmanager.get_canonical_name(common_name_plugin) == PW_ASYNC_CANONICAL_NAME ): raise RuntimeError(PLUGIN_INCOMPATIBLE_MESSAGE) group = parser.getgroup("playwright", "Playwright") group.addoption( "--browser", action="append", default=[], help="Browser engine which should be used", choices=["chromium", "firefox", "webkit"], ) group.addoption( "--headed", action="store_true", default=False, help="Run tests in headed mode.", ) group.addoption( "--browser-channel", action="store", default=None, help="Browser channel to be used.", ) group.addoption( "--slowmo", default=0, type=int, help="Run tests with slow mo", ) group.addoption( "--device", default=None, action="store", help="Device to be emulated.", ) group.addoption( "--output", default="test-results", help="Directory for artifacts produced by tests, defaults to test-results.", ) group.addoption( "--tracing", default="off", choices=["on", "off", "retain-on-failure"], help="Whether to record a trace for each test.", ) group.addoption( "--video", default="off", choices=["on", "off", "retain-on-failure"], help="Whether to record video for each test.", ) group.addoption( "--screenshot", default="off", choices=["on", "off", "only-on-failure"], help="Whether to automatically capture a screenshot after each test.", ) group.addoption( "--full-page-screenshot", action="store_true", default=False, help="Whether to take a full page screenshot", ) class ArtifactsRecorder: def __init__( self, pytestconfig: Any, request: pytest.FixtureRequest, output_path: str, playwright: Playwright, pw_artifacts_folder: tempfile.TemporaryDirectory, ) -> None: self._request = request self._pytestconfig = pytestconfig self._playwright = playwright self._output_path = output_path self._pw_artifacts_folder = pw_artifacts_folder self._all_pages: List[Page] = [] self._screenshots: List[str] = [] self._traces: List[str] = [] self._tracing_option = pytestconfig.getoption("--tracing") self._capture_trace = self._tracing_option in ["on", "retain-on-failure"] def _build_artifact_test_folder(self, folder_or_file_name: str) -> str: return os.path.join( self._output_path, _truncate_file_name(folder_or_file_name), ) def did_finish_test(self, failed: bool) -> None: screenshot_option = self._pytestconfig.getoption("--screenshot") capture_screenshot = screenshot_option == "on" or ( failed and screenshot_option == "only-on-failure" ) if capture_screenshot: for index, screenshot in enumerate(self._screenshots): human_readable_status = "failed" if failed else "finished" screenshot_path = self._build_artifact_test_folder( f"test-{human_readable_status}-{index + 1}.png", ) os.makedirs(os.path.dirname(screenshot_path), exist_ok=True) shutil.move(screenshot, screenshot_path) else: for screenshot in self._screenshots: os.remove(screenshot) if self._tracing_option == "on" or ( failed and self._tracing_option == "retain-on-failure" ): for index, trace in enumerate(self._traces): trace_file_name = ( "trace.zip" if len(self._traces) == 1 else f"trace-{index + 1}.zip" ) trace_path = self._build_artifact_test_folder(trace_file_name) os.makedirs(os.path.dirname(trace_path), exist_ok=True) shutil.move(trace, trace_path) else: for trace in self._traces: os.remove(trace) video_option = self._pytestconfig.getoption("--video") preserve_video = video_option == "on" or ( failed and video_option == "retain-on-failure" ) if preserve_video: for index, page in enumerate(self._all_pages): video = page.video if not video: continue try: video_file_name = ( "video.webm" if len(self._all_pages) == 1 else f"video-{index + 1}.webm" ) video.save_as( path=self._build_artifact_test_folder(video_file_name) ) except Error: # Silent catch empty videos. pass else: for page in self._all_pages: # Can be changed to "if page.video" without try/except once https://github.com/microsoft/playwright-python/pull/2410 is released and widely adopted. if video_option in ["on", "retain-on-failure"]: try: if page.video: page.video.delete() except Error: pass def on_did_create_browser_context(self, context: BrowserContext) -> None: context.on("page", lambda page: self._all_pages.append(page)) if self._request and self._capture_trace: context.tracing.start( title=slugify(self._request.node.nodeid), screenshots=True, snapshots=True, sources=True, ) def on_will_close_browser_context(self, context: BrowserContext) -> None: if self._capture_trace: trace_path = Path(self._pw_artifacts_folder.name) / _create_guid() context.tracing.stop(path=trace_path) self._traces.append(str(trace_path)) else: context.tracing.stop() if self._pytestconfig.getoption("--screenshot") in ["on", "only-on-failure"]: for page in context.pages: try: screenshot_path = ( Path(self._pw_artifacts_folder.name) / _create_guid() ) page.screenshot( timeout=5000, path=screenshot_path, type="png", full_page=self._pytestconfig.getoption( "--full-page-screenshot" ), ) self._screenshots.append(str(screenshot_path)) except Error: pass def _create_guid() -> str: return hashlib.sha256(os.urandom(16)).hexdigest()