diff --git a/.azure-pipelines/publish.yml b/.azure-pipelines/publish.yml index 13a9d03..b717a72 100644 --- a/.azure-pipelines/publish.yml +++ b/.azure-pipelines/publish.yml @@ -36,7 +36,8 @@ extends: - script: | python -m pip install --upgrade pip pip install -r local-requirements.txt - python -m build --sdist + python -m build --sdist --outdir=dist pytest-playwright + python -m build --sdist --outdir=dist pytest-playwright-asyncio displayName: 'Install & Build' - task: EsrpRelease@7 inputs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3cd8f8..480cc3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,8 @@ jobs: run: | python -m pip install --upgrade pip pip install -r local-requirements.txt - pip install -e . + pip install -e pytest-playwright + pip install -e pytest-playwright-asyncio python -m playwright install --with-deps if [ '${{ matrix.os }}' == 'macos-latest' ]; then python -m playwright install msedge --with-deps @@ -73,4 +74,6 @@ jobs: - name: Prepare run: conda install conda-build conda-verify - name: Build - run: conda build . + run: | + conda build pytest-playwright + conda build pytest-playwright-asyncio diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 1d55158..e61015a 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -24,4 +24,5 @@ jobs: ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} run: | conda config --set anaconda_upload yes - conda build --user microsoft . + conda build --user microsoft pytest-playwright + conda build --user microsoft pytest-playwright-asyncio diff --git a/.gitignore b/.gitignore index 28538f2..cce14e7 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,5 @@ dmypy.json # Jetbrains IDEs .idea -_repo_version.py .DS_Store /test-results/ diff --git a/local-requirements.txt b/local-requirements.txt index ba8d555..b8da461 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -8,3 +8,4 @@ flake8==7.1.1 pre-commit==4.0.1 Django==4.2.16 pytest-xdist==2.5.0 +pytest-asyncio==0.24.0 diff --git a/pytest-playwright-asyncio/LICENSE b/pytest-playwright-asyncio/LICENSE new file mode 100644 index 0000000..4ace03d --- /dev/null +++ b/pytest-playwright-asyncio/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + 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. diff --git a/pytest-playwright-asyncio/README.md b/pytest-playwright-asyncio/README.md new file mode 100644 index 0000000..5ce3b33 --- /dev/null +++ b/pytest-playwright-asyncio/README.md @@ -0,0 +1,11 @@ +# Pytest plugin for Playwright [![PyPI](https://img.shields.io/pypi/v/pytest-playwright)](https://pypi.org/project/pytest-playwright/) + +Write end-to-end tests for your web apps with [Playwright](https://github.com/microsoft/playwright-python) and [pytest](https://docs.pytest.org/en/stable/). + +- Support for **all modern browsers** including Chromium, WebKit and Firefox. +- Support for **headless and headed** execution. +- **Built-in fixtures** that provide browser primitives to test functions. + +## Documentation + +See on [playwright.dev](https://playwright.dev/python/docs/test-runners) for examples and more detailed information. diff --git a/pytest-playwright-asyncio/meta.yaml b/pytest-playwright-asyncio/meta.yaml new file mode 100644 index 0000000..2c5bf1b --- /dev/null +++ b/pytest-playwright-asyncio/meta.yaml @@ -0,0 +1,40 @@ +channels: + - microsoft + - conda-forge + +package: + name: "pytest-playwright-asyncio" + version: "{{ environ.get('GIT_DESCRIBE_TAG') | replace('v', '') }}" + +source: + path: . + +build: + number: 0 + noarch: python + script: "{{ PYTHON }} -m pip install . --no-deps -vv" + +requirements: + host: + - python >=3.9 + - pip + run: + - python >=3.9 + - microsoft::playwright >=1.37.0 + - pytest >=6.2.4,<9.0.0 + - pytest-base-url >=1.0.0,<3.0.0 + - python-slugify >=6.0.0,<9.0.0 + +test: + imports: + - pytest_playwright + commands: + - pip check + requires: + - pip + +about: + home: https://github.com/microsoft/playwright-pytest + summary: A pytest wrapper with fixtures for Playwright to automate web browsers + license: Apache-2.0 + license_file: LICENSE diff --git a/pytest-playwright-asyncio/pyproject.toml b/pytest-playwright-asyncio/pyproject.toml new file mode 100644 index 0000000..5c1bbbb --- /dev/null +++ b/pytest-playwright-asyncio/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools==75.4.0", "setuptools_scm==8.1.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pytest-playwright-asyncio" +description = "A pytest wrapper with fixtures for Playwright to automate web browsers" +readme = "README.md" +authors = [ + {name = "Microsoft"} +] +license = {file = "LICENSE"} +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Framework :: Pytest", +] +dynamic = ["version"] +dependencies = [ + "playwright>=1.18", + "pytest>=6.2.4,<9.0.0", + "pytest-base-url>=1.0.0,<3.0.0", + "python-slugify>=6.0.0,<9.0.0", +] + +[project.urls] +homepage = "https://github.com/microsoft/playwright-pytest" + +[project.entry-points.pytest11] +playwright-asyncio = "pytest_playwright_asyncio.pytest_playwright" + +[tool.setuptools] +packages = ["pytest_playwright_asyncio"] +[tool.setuptools_scm] +root = ".." diff --git a/pytest-playwright-asyncio/pytest_playwright_asyncio/__init__.py b/pytest-playwright-asyncio/pytest_playwright_asyncio/__init__.py new file mode 100644 index 0000000..79f3c13 --- /dev/null +++ b/pytest-playwright-asyncio/pytest_playwright_asyncio/__init__.py @@ -0,0 +1,19 @@ +# 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. + +from pytest_playwright_asyncio.pytest_playwright import CreateContextCallback + +__all__ = [ + "CreateContextCallback", +] diff --git a/pytest_playwright/py.typed b/pytest-playwright-asyncio/pytest_playwright_asyncio/py.typed similarity index 100% rename from pytest_playwright/py.typed rename to pytest-playwright-asyncio/pytest_playwright_asyncio/py.typed diff --git a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py new file mode 100644 index 0000000..f457a30 --- /dev/null +++ b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py @@ -0,0 +1,586 @@ +# 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 shutil +import os +import sys +import warnings +from pathlib import Path +from typing import ( + Any, + AsyncGenerator, + Awaitable, + Callable, + Dict, + Generator, + List, + Literal, + Optional, + Protocol, + Sequence, + Union, + Pattern, + cast, +) + +import pytest +from playwright.async_api import ( + Browser, + BrowserContext, + BrowserType, + Error, + Page, + Playwright, + async_playwright, + ProxySettings, + StorageState, + HttpCredentials, + Geolocation, + ViewportSize, +) +import pytest_asyncio +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_asyncio.fixture(loop_scope="session") +async def _artifacts_recorder( + request: pytest.FixtureRequest, + output_path: str, + playwright: Playwright, + pytestconfig: Any, + _pw_artifacts_folder: tempfile.TemporaryDirectory, +) -> AsyncGenerator["ArtifactsRecorder", 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 + await artifacts_recorder.did_finish_test(failed) + + +@pytest_asyncio.fixture(scope="session") +async def playwright() -> AsyncGenerator[Playwright, None]: + pw = await async_playwright().start() + yield pw + await 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 launch_browser( + browser_type_launch_args: Dict, + browser_type: BrowserType, +) -> Callable[..., Awaitable[Browser]]: + async def launch(**kwargs: Dict) -> Browser: + launch_options = {**browser_type_launch_args, **kwargs} + browser = await browser_type.launch(**launch_options) + return browser + + return launch + + +@pytest_asyncio.fixture(scope="session") +async def browser( + launch_browser: Callable[[], Awaitable[Browser]] +) -> AsyncGenerator[Browser, None]: + browser = await launch_browser() + yield browser + await 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, + ) -> Awaitable[BrowserContext]: ... + + +@pytest_asyncio.fixture(loop_scope="session") +async def new_context( + browser: Browser, + browser_context_args: Dict, + _artifacts_recorder: "ArtifactsRecorder", + request: pytest.FixtureRequest, +) -> AsyncGenerator[CreateContextCallback, 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] = [] + + async def _new_context(**kwargs: Any) -> BrowserContext: + context = await browser.new_context(**browser_context_args, **kwargs) + original_close = context.close + + async def _close_wrapper(*args: Any, **kwargs: Any) -> None: + contexts.remove(context) + await _artifacts_recorder.on_will_close_browser_context(context) + await original_close(*args, **kwargs) + + context.close = _close_wrapper + contexts.append(context) + await _artifacts_recorder.on_did_create_browser_context(context) + return context + + yield cast(CreateContextCallback, _new_context) + for context in contexts.copy(): + await context.close() + + +@pytest_asyncio.fixture(loop_scope="session") +async def context(new_context: CreateContextCallback) -> BrowserContext: + return await new_context() + + +@pytest_asyncio.fixture(loop_scope="session") +async def page(context: BrowserContext) -> Page: + return await 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") + + +def pytest_addoption(parser: Any) -> None: + 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), + ) + + async 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" + ) + await 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: + await page.video.delete() + except Error: + pass + + async 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: + await context.tracing.start( + title=slugify(self._request.node.nodeid), + screenshots=True, + snapshots=True, + sources=True, + ) + + async def on_will_close_browser_context(self, context: BrowserContext) -> None: + if self._capture_trace: + trace_path = Path(self._pw_artifacts_folder.name) / _create_guid() + await context.tracing.stop(path=trace_path) + self._traces.append(str(trace_path)) + else: + await 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() + ) + await page.screenshot( + timeout=5000, + path=screenshot_path, + 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() diff --git a/pytest-playwright/LICENSE b/pytest-playwright/LICENSE new file mode 100644 index 0000000..4ace03d --- /dev/null +++ b/pytest-playwright/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + 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. diff --git a/pytest-playwright/README.md b/pytest-playwright/README.md new file mode 100644 index 0000000..5ce3b33 --- /dev/null +++ b/pytest-playwright/README.md @@ -0,0 +1,11 @@ +# Pytest plugin for Playwright [![PyPI](https://img.shields.io/pypi/v/pytest-playwright)](https://pypi.org/project/pytest-playwright/) + +Write end-to-end tests for your web apps with [Playwright](https://github.com/microsoft/playwright-python) and [pytest](https://docs.pytest.org/en/stable/). + +- Support for **all modern browsers** including Chromium, WebKit and Firefox. +- Support for **headless and headed** execution. +- **Built-in fixtures** that provide browser primitives to test functions. + +## Documentation + +See on [playwright.dev](https://playwright.dev/python/docs/test-runners) for examples and more detailed information. diff --git a/meta.yaml b/pytest-playwright/meta.yaml similarity index 97% rename from meta.yaml rename to pytest-playwright/meta.yaml index 262fe96..e954810 100644 --- a/meta.yaml +++ b/pytest-playwright/meta.yaml @@ -17,7 +17,6 @@ build: requirements: host: - python >=3.9 - - setuptools-scm - pip run: - python >=3.9 diff --git a/pyproject.toml b/pytest-playwright/pyproject.toml similarity index 98% rename from pyproject.toml rename to pytest-playwright/pyproject.toml index 486ed68..d5096ee 100644 --- a/pyproject.toml +++ b/pytest-playwright/pyproject.toml @@ -39,3 +39,4 @@ playwright = "pytest_playwright.pytest_playwright" [tool.setuptools] packages = ["pytest_playwright"] [tool.setuptools_scm] +root = ".." diff --git a/pytest-playwright/pytest_playwright/__init__.py b/pytest-playwright/pytest_playwright/__init__.py new file mode 100644 index 0000000..10963f8 --- /dev/null +++ b/pytest-playwright/pytest_playwright/__init__.py @@ -0,0 +1,19 @@ +# 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. + +from pytest_playwright.pytest_playwright import CreateContextCallback + +__all__ = [ + "CreateContextCallback", +] diff --git a/pytest_playwright/__init__.py b/pytest-playwright/pytest_playwright/py.typed similarity index 100% rename from pytest_playwright/__init__.py rename to pytest-playwright/pytest_playwright/py.typed diff --git a/pytest_playwright/pytest_playwright.py b/pytest-playwright/pytest_playwright/pytest_playwright.py similarity index 99% rename from pytest_playwright/pytest_playwright.py rename to pytest-playwright/pytest_playwright/pytest_playwright.py index d6f23da..6dfc0b8 100644 --- a/pytest_playwright/pytest_playwright.py +++ b/pytest-playwright/pytest_playwright/pytest_playwright.py @@ -536,7 +536,8 @@ def did_finish_test(self, failed: bool) -> None: # 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: - page.video.delete() + if page.video: + page.video.delete() except Error: pass diff --git a/setup.cfg b/setup.cfg index 5fcd530..8bd6812 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,9 +14,6 @@ warn_unused_configs = True check_untyped_defs = True disallow_untyped_defs = True [tool:pytest] -addopts = -p no:playwright --runpytest subprocess -vv +addopts = -p no:playwright -p no:playwright-asyncio --runpytest subprocess -vv testpaths = tests -[coverage:run] -omit = - pytest_playwright/_repo_version.py diff --git a/tests/conftest.py b/tests/conftest.py index 7425cad..9bf6f01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,4 +29,3 @@ playwright_browser_path = f"{user_profile}\\AppData\\Local\\ms-playwright" os.environ["PLAYWRIGHT_BROWSERS_PATH"] = playwright_browser_path -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.assets.django.settings") diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py new file mode 100644 index 0000000..a7036dc --- /dev/null +++ b/tests/test_asyncio.py @@ -0,0 +1,1021 @@ +# 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 os +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def _add_async_marker(testdir: pytest.Testdir) -> None: + testdir.makeconftest( + """ + import pytest + + from pytest_asyncio import is_async_test + + def pytest_collection_modifyitems(items): + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) + """ + ) + testdir.makefile( + ".ini", + pytest=""" + [pytest] + addopts = -p no:playwright + """, + ) + + +def makeconftest(testdir: pytest.Testdir, content: str) -> None: + lines = content.split("\n") + spaces = [len(line) - len(line.lstrip()) for line in lines if line.strip()] + min_spaces = min(spaces) if spaces else 0 + lines = [line[min_spaces:] for line in lines] + + testdir.makeconftest( + testdir.tmpdir.join("conftest.py").read_text("utf8") + "\n" + "\n".join(lines) + ) + + +def test_default(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + + @pytest.mark.asyncio + async def test_default(page, browser_name): + assert browser_name == "chromium" + user_agent = await page.evaluate("window.navigator.userAgent") + assert "HeadlessChrome" in user_agent + await page.set_content('bar') + assert await page.query_selector("#foo") + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_slowmo(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + from time import monotonic + import pytest + @pytest.mark.asyncio + async def test_slowmo(page): + email = "test@test.com" + await page.set_content("") + start_time = monotonic() + await page.type("input", email) + end_time = monotonic() + assert end_time - start_time >= 1 + assert end_time - start_time < 2 + """ + ) + result = testdir.runpytest("--browser", "chromium", "--slowmo", "1000") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "channel", + [ + "chrome", + "msedge", + ], +) +def test_browser_channel(channel: str, testdir: pytest.Testdir) -> None: + if channel == "msedge" and sys.platform == "linux": + pytest.skip("msedge not supported on linux") + testdir.makepyfile( + f""" + import pytest + + @pytest.mark.asyncio + async def test_browser_channel(page, browser_name, browser_channel): + assert browser_name == "chromium" + assert browser_channel == "{channel}" + """ + ) + result = testdir.runpytest("--browser-channel", channel) + result.assert_outcomes(passed=1) + + +def test_invalid_browser_channel(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + + @pytest.mark.asyncio + async def test_browser_channel(page, browser_name, browser_channel): + assert browser_name == "chromium" + """ + ) + result = testdir.runpytest("--browser-channel", "not-exists") + result.assert_outcomes(errors=1) + assert "Unsupported chromium channel" in "\n".join(result.outlines) + + +def test_multiple_browsers(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_multiple_browsers(page): + await page.set_content('bar') + assert page.query_selector("#foo") + """ + ) + result = testdir.runpytest( + "--browser", "chromium", "--browser", "firefox", "--browser", "webkit" + ) + result.assert_outcomes(passed=3) + + +def test_browser_context_args(testdir: pytest.Testdir) -> None: + makeconftest( + testdir, + """ + @pytest.fixture(scope="session") + def browser_context_args(): + return {"user_agent": "foobar"} + """, + ) + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_browser_context_args(page): + assert await page.evaluate("window.navigator.userAgent") == "foobar" + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_user_defined_browser_context_args(testdir: pytest.Testdir) -> None: + makeconftest( + testdir, + """ + import pytest + + @pytest.fixture(scope="session") + def browser_context_args(): + return {"user_agent": "foobar"} + """, + ) + testdir.makepyfile( + """ + import pytest + + @pytest.mark.browser_context_args(user_agent="overwritten", locale="new-locale") + @pytest.mark.asyncio + async def test_browser_context_args(page): + assert await page.evaluate("window.navigator.userAgent") == "overwritten" + assert await page.evaluate("window.navigator.languages") == ["new-locale"] + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_user_defined_browser_context_args_clear_again(testdir: pytest.Testdir) -> None: + makeconftest( + testdir, + """ + import pytest + + @pytest.fixture(scope="session") + def browser_context_args(): + return {"user_agent": "foobar"} + """, + ) + testdir.makepyfile( + """ + import pytest + + @pytest.mark.browser_context_args(user_agent="overwritten") + @pytest.mark.asyncio + async def test_browser_context_args(page): + assert await page.evaluate("window.navigator.userAgent") == "overwritten" + + @pytest.mark.asyncio + async def test_browser_context_args2(page): + assert await page.evaluate("window.navigator.userAgent") == "foobar" + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=2) + + +def test_chromium(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_is_chromium(page, browser_name, is_chromium, is_firefox, is_webkit): + assert browser_name == "chromium" + assert is_chromium + assert is_firefox is False + assert is_webkit is False + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_firefox(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_is_firefox(page, browser_name, is_chromium, is_firefox, is_webkit): + assert browser_name == "firefox" + assert is_chromium is False + assert is_firefox + assert is_webkit is False + """ + ) + result = testdir.runpytest("--browser", "firefox") + result.assert_outcomes(passed=1) + + +def test_webkit(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_is_webkit(page, browser_name, is_chromium, is_firefox, is_webkit): + assert browser_name == "webkit" + assert is_chromium is False + assert is_firefox is False + assert is_webkit + """ + ) + result = testdir.runpytest("--browser", "webkit") + result.assert_outcomes(passed=1) + + +def test_goto(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_base_url(page, base_url): + assert base_url == "https://example.com" + await page.goto("/foobar") + assert page.url == "https://example.com/foobar" + await page.goto("https://example.org") + assert page.url == "https://example.org/" + """ + ) + result = testdir.runpytest("--base-url", "https://example.com") + result.assert_outcomes(passed=1) + + +def test_base_url_via_fixture(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(scope="session") + def base_url(): + return "https://example.com" + + @pytest.mark.asyncio + async def test_base_url(page, base_url): + assert base_url == "https://example.com" + await page.goto("/foobar") + assert page.url == "https://example.com/foobar" + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_skip_browsers(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + + @pytest.mark.skip_browser("firefox") + @pytest.mark.asyncio + async def test_base_url(page, browser_name): + assert browser_name in ["chromium", "webkit"] + """ + ) + result = testdir.runpytest( + "--browser", "chromium", "--browser", "firefox", "--browser", "webkit" + ) + result.assert_outcomes(passed=2, skipped=1) + + +def test_only_browser(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + + @pytest.mark.only_browser("firefox") + @pytest.mark.asyncio + async def test_base_url(page, browser_name): + assert browser_name == "firefox" + """ + ) + result = testdir.runpytest( + "--browser", "chromium", "--browser", "firefox", "--browser", "webkit" + ) + result.assert_outcomes(passed=1, skipped=2) + + +def test_parameterization(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_all_browsers(page): + pass + + @pytest.mark.asyncio + async def test_without_browser(): + pass + """ + ) + result = testdir.runpytest( + "--verbose", + "--browser", + "chromium", + "--browser", + "firefox", + "--browser", + "webkit", + ) + result.assert_outcomes(passed=4) + assert "test_without_browser PASSED" in "\n".join(result.outlines) + + +def test_xdist(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_a(page): + await page.set_content('a') + await page.wait_for_timeout(200) + assert page.query_selector("#foo") + + @pytest.mark.asyncio + async def test_b(page): + await page.wait_for_timeout(2000) + await page.set_content('a') + assert page.query_selector("#foo") + + @pytest.mark.asyncio + async def test_c(page): + await page.set_content('a') + await page.wait_for_timeout(200) + assert page.query_selector("#foo") + + @pytest.mark.asyncio + async def test_d(page): + await page.set_content('a') + await page.wait_for_timeout(200) + assert page.query_selector("#foo") + """ + ) + result = testdir.runpytest( + "--verbose", + "--browser", + "chromium", + "--browser", + "firefox", + "--browser", + "webkit", + "--numprocesses", + "2", + ) + result.assert_outcomes(passed=12) + assert "gw0" in "\n".join(result.outlines) + assert "gw1" in "\n".join(result.outlines) + + +def test_xdist_should_not_print_any_warnings(testdir: pytest.Testdir) -> None: + original = os.environ.get("PYTHONWARNINGS") + os.environ["PYTHONWARNINGS"] = "always" + try: + testdir.makepyfile( + """ + import pytest + + @pytest.mark.asyncio + async def test_default(page): + pass + """ + ) + result = testdir.runpytest( + "--numprocesses", + "2", + ) + result.assert_outcomes(passed=1) + assert "ResourceWarning" not in "".join(result.stderr.lines) + finally: + if original is not None: + os.environ["PYTHONWARNINGS"] = original + else: + del os.environ["PYTHONWARNINGS"] + + +def test_headed(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_base_url(page, browser_name): + user_agent = await page.evaluate("window.navigator.userAgent") + assert "HeadlessChrome" not in user_agent + """ + ) + result = testdir.runpytest("--browser", "chromium", "--headed") + result.assert_outcomes(passed=1) + + +def test_invalid_browser_name(testdir: pytest.Testdir) -> None: + testdir.makepyfile( + """ + async def test_base_url(page): + pass + """ + ) + result = testdir.runpytest("--browser", "test123") + assert any(["--browser: invalid choice" in line for line in result.errlines]) + + +def test_browser_context_args_device(testdir: pytest.Testdir) -> None: + makeconftest( + testdir, + """ + import pytest + + @pytest.fixture(scope="session") + def browser_context_args(browser_context_args, playwright): + iphone_11 = playwright.devices['iPhone 11 Pro'] + return {**browser_context_args, **iphone_11} + """, + ) + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_browser_context_args(page): + assert "iPhone" in await page.evaluate("window.navigator.userAgent") + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_launch_persistent_context_session(testdir: pytest.Testdir) -> None: + makeconftest( + testdir, + """ + import pytest_asyncio + from playwright.sync_api import BrowserType + from typing import Dict + + @pytest_asyncio.fixture(scope="session") + async def context( + browser_type: BrowserType, + browser_type_launch_args: Dict, + browser_context_args: Dict + ): + context = await browser_type.launch_persistent_context("./foobar", **{ + **browser_type_launch_args, + **browser_context_args, + "locale": "de-DE", + }) + yield context + await context.close() + """, + ) + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_browser_context_args(page): + assert await page.evaluate("navigator.language") == "de-DE" + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_context_page_on_session_level(testdir: pytest.Testdir) -> None: + makeconftest( + testdir, + """ + import pytest + from playwright.sync_api import Browser, BrowserContext + from typing import Dict + import pytest_asyncio + + @pytest_asyncio.fixture(scope="session") + async def context( + browser: Browser, + browser_context_args: Dict + ): + context = await browser.new_context(**{ + **browser_context_args, + }) + yield context + await context.close() + + @pytest_asyncio.fixture(scope="session") + async def page( + context: BrowserContext, + ): + page = await context.new_page() + yield page + """, + ) + testdir.makepyfile( + """ + import pytest + @pytest.mark.asyncio + async def test_a(page): + await page.goto("data:text/html,