├── .azure-pipelines └── publish.yml ├── .github └── workflows │ ├── ci.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── conda_build_config.yaml ├── conda_build_config_asyncio.yaml ├── local-requirements.txt ├── meta.yaml ├── pytest-playwright-asyncio ├── LICENSE ├── README.md ├── pyproject.toml └── pytest_playwright_asyncio │ ├── __init__.py │ ├── py.typed │ └── pytest_playwright.py ├── pytest-playwright ├── LICENSE ├── README.md ├── pyproject.toml └── pytest_playwright │ ├── __init__.py │ ├── py.typed │ └── pytest_playwright.py ├── setup.cfg └── tests ├── assets └── django │ ├── __init__.py │ ├── settings.py │ └── urls.py ├── conftest.py ├── test_asyncio.py └── test_sync.py /.azure-pipelines/publish.yml: -------------------------------------------------------------------------------- 1 | pr: none 2 | 3 | trigger: 4 | tags: 5 | include: 6 | - '*' 7 | 8 | resources: 9 | repositories: 10 | - repository: 1esPipelines 11 | type: git 12 | name: 1ESPipelineTemplates/1ESPipelineTemplates 13 | ref: refs/tags/release 14 | 15 | extends: 16 | template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines 17 | parameters: 18 | pool: 19 | name: DevDivPlaywrightAzurePipelinesUbuntu2204 20 | os: linux 21 | sdl: 22 | sourceAnalysisPool: 23 | name: DevDivPlaywrightAzurePipelinesWindows2022 24 | # The image must be windows-based due to restrictions of the SDL tools. See: https://aka.ms/AAo6v8e 25 | # In the case of a windows build, this can be the same as the above pool image. 26 | os: windows 27 | stages: 28 | - stage: Stage 29 | jobs: 30 | - job: Build 31 | templateContext: 32 | outputs: 33 | - output: pipelineArtifact 34 | path: $(Build.ArtifactStagingDirectory)/esrp-build 35 | artifact: esrp-build 36 | steps: 37 | - task: UsePythonVersion@0 38 | inputs: 39 | versionSpec: '3.9' 40 | displayName: 'Use Python' 41 | - script: | 42 | python -m pip install --upgrade pip 43 | pip install -r local-requirements.txt 44 | python -m build --outdir $(Build.ArtifactStagingDirectory)/esrp-build pytest-playwright 45 | python -m build --outdir $(Build.ArtifactStagingDirectory)/esrp-build pytest-playwright-asyncio 46 | displayName: 'Install & Build' 47 | - job: Publish 48 | dependsOn: Build 49 | templateContext: 50 | type: releaseJob 51 | isProduction: true 52 | inputs: 53 | - input: pipelineArtifact 54 | artifactName: esrp-build 55 | targetPath: $(Build.ArtifactStagingDirectory)/esrp-build 56 | steps: 57 | - checkout: none 58 | - task: EsrpRelease@9 59 | inputs: 60 | connectedservicename: 'Playwright-ESRP-PME' 61 | usemanagedidentity: true 62 | keyvaultname: 'playwright-esrp-pme' 63 | signcertname: 'ESRP-Release-Sign' 64 | clientid: '13434a40-7de4-4c23-81a3-d843dc81c2c5' 65 | intent: 'PackageDistribution' 66 | contenttype: 'PyPi' 67 | # Keeping it commented out as a workaround for: 68 | # https://portal.microsofticm.com/imp/v3/incidents/incident/499972482/summary 69 | # contentsource: 'folder' 70 | folderlocation: '$(Build.ArtifactStagingDirectory)/esrp-build' 71 | waitforreleasecompletion: true 72 | owners: 'maxschmitt@microsoft.com' 73 | approvers: 'maxschmitt@microsoft.com' 74 | serviceendpointurl: 'https://api.esrp.microsoft.com' 75 | mainpublisher: 'Playwright' 76 | domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' 77 | displayName: 'ESRP Release to PIP' 78 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.11 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r local-requirements.txt 20 | - name: Lint 21 | run: pre-commit run --show-diff-on-failure --color=always --all-files 22 | build: 23 | timeout-minutes: 30 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | os: [ubuntu-latest, windows-latest, macos-latest] 28 | python-version: ['3.9', '3.10', '3.11'] 29 | include: 30 | - os: ubuntu-latest 31 | python-version: '3.12' 32 | - os: ubuntu-latest 33 | python-version: '3.13' 34 | runs-on: ${{ matrix.os }} 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Set up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - name: Install dependencies 42 | shell: bash 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install -r local-requirements.txt 46 | pip install -e pytest-playwright 47 | pip install -e pytest-playwright-asyncio 48 | playwright install --with-deps 49 | if [ '${{ matrix.os }}' == 'macos-latest' ]; then 50 | playwright install msedge --with-deps 51 | fi 52 | - name: Test 53 | if: ${{ matrix.os != 'ubuntu-latest' }} 54 | run: pytest --cov=pytest_playwright --cov-report xml 55 | - name: Test on Linux 56 | if: ${{ matrix.os == 'ubuntu-latest' }} 57 | run: xvfb-run pytest --cov=pytest_playwright --cov-report xml 58 | build-conda: 59 | name: Conda Build 60 | strategy: 61 | fail-fast: false 62 | matrix: 63 | os: [ ubuntu-latest, macos-latest, windows-latest ] 64 | runs-on: ${{ matrix.os }} 65 | steps: 66 | - uses: actions/checkout@v4 67 | with: 68 | fetch-depth: 0 69 | - name: Get conda 70 | uses: conda-incubator/setup-miniconda@v3 71 | with: 72 | python-version: 3.9 73 | channels: microsoft,conda-forge 74 | - name: Prepare 75 | run: | 76 | conda install conda-build conda-verify 77 | # Until https://github.com/anaconda/conda-anaconda-telemetry/issues/87 has been fixed 78 | conda remove --name base conda-anaconda-telemetry 79 | - name: Build pytest-playwright 80 | run: conda build . 81 | - name: Build pytest-playwright-asyncio 82 | run: conda build --variant-config-file conda_build_config_asyncio.yaml . 83 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | on: 6 | release: 7 | types: [published] 8 | jobs: 9 | deploy-conda: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - name: Get conda 16 | uses: conda-incubator/setup-miniconda@v3 17 | with: 18 | python-version: 3.9 19 | channels: microsoft,conda-forge 20 | - name: Prepare 21 | run: | 22 | conda install anaconda-client conda-build conda-verify 23 | # Until https://github.com/anaconda/conda-anaconda-telemetry/issues/87 has been fixed 24 | conda remove --name base conda-anaconda-telemetry 25 | - name: Build and Upload 26 | env: 27 | ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} 28 | run: | 29 | conda config --set anaconda_upload yes 30 | conda build --user microsoft . 31 | conda build --user microsoft --variant-config-file conda_build_config_asyncio.yaml . 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # End of https://www.toptal.com/developers/gitignore/api/python 140 | .vscode 141 | 142 | # Jetbrains IDEs 143 | .idea 144 | 145 | .DS_Store 146 | /test-results/ 147 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | exclude: meta.yaml 11 | - id: check-added-large-files 12 | - repo: https://github.com/psf/black 13 | rev: 24.10.0 14 | hooks: 15 | - id: black 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.13.0 18 | hooks: 19 | - id: mypy 20 | additional_dependencies: [types-python-slugify==6.1.0] 21 | - repo: https://github.com/pycqa/flake8 22 | rev: 7.1.1 23 | hooks: 24 | - id: flake8 25 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Portions Copyright (c) Microsoft Corporation. 190 | Portions Copyright 2017 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pytest plugin for Playwright [](https://pypi.org/project/pytest-playwright/) 2 | 3 | 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/). 4 | 5 | - Support for **all modern browsers** including Chromium, WebKit and Firefox. 6 | - Support for **headless and headed** execution. 7 | - **Built-in fixtures** that provide browser primitives to test functions. 8 | 9 | ## Documentation 10 | 11 | See on [playwright.dev](https://playwright.dev/python/docs/test-runners) for examples and more detailed information. 12 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /conda_build_config.yaml: -------------------------------------------------------------------------------- 1 | package: pytest-playwright 2 | -------------------------------------------------------------------------------- /conda_build_config_asyncio.yaml: -------------------------------------------------------------------------------- 1 | package: pytest-playwright-asyncio 2 | -------------------------------------------------------------------------------- /local-requirements.txt: -------------------------------------------------------------------------------- 1 | black==24.3.0 2 | pytest-cov==3.0.0 3 | mypy==0.961 4 | build==1.2.2.post1 5 | twine==4.0.1 6 | wheel==0.38.1 7 | flake8==7.1.1 8 | pre-commit==4.0.1 9 | Django==4.2.22 10 | pytest-xdist==2.5.0 11 | pytest-asyncio==1.0.0 12 | -------------------------------------------------------------------------------- /meta.yaml: -------------------------------------------------------------------------------- 1 | channels: 2 | - microsoft 3 | - conda-forge 4 | 5 | package: 6 | name: "{{ package }}" 7 | version: "{{ environ.get('GIT_DESCRIBE_TAG') | replace('v', '') }}" 8 | 9 | source: 10 | path: . 11 | 12 | build: 13 | number: 0 14 | noarch: python 15 | script: python -m pip install --no-deps --ignore-installed ./{{ package }} 16 | 17 | requirements: 18 | host: 19 | - python >=3.9 20 | - setuptools-scm 21 | - pip 22 | run: 23 | - python >=3.9 24 | - microsoft::playwright >=1.37.0 25 | - pytest >=6.2.4,<9.0.0 26 | - pytest-base-url >=1.0.0,<3.0.0 27 | - python-slugify >=6.0.0,<9.0.0 28 | {% if package == 'pytest-playwright-asyncio' %} 29 | - pytest-asyncio >=0.24.0 30 | {% endif %} 31 | 32 | test: 33 | imports: 34 | - "{{ package | replace('-', '_') }}" 35 | commands: 36 | - pip check 37 | requires: 38 | - pip 39 | 40 | about: 41 | home: https://github.com/microsoft/playwright-pytest 42 | summary: A pytest wrapper with {% if package == 'pytest-playwright-asyncio' %} async{% endif %}fixtures for Playwright to automate web browsers 43 | license: Apache-2.0 44 | license_file: LICENSE 45 | -------------------------------------------------------------------------------- /pytest-playwright-asyncio/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Portions Copyright (c) Microsoft Corporation. 190 | Portions Copyright 2017 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /pytest-playwright-asyncio/README.md: -------------------------------------------------------------------------------- 1 | # Pytest plugin for Playwright [](https://pypi.org/project/pytest-playwright-asyncio/) 2 | 3 | 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/). 4 | 5 | - Support for **all modern browsers** including Chromium, WebKit and Firefox. 6 | - Support for **headless and headed** execution. 7 | - **Built-in fixtures** that provide browser primitives to test functions. 8 | 9 | ## Documentation 10 | 11 | See on [playwright.dev](https://playwright.dev/python/docs/test-runners) for examples and more detailed information. 12 | -------------------------------------------------------------------------------- /pytest-playwright-asyncio/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools==75.4.0", "setuptools_scm==8.1.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pytest-playwright-asyncio" 7 | description = "A pytest wrapper with async fixtures for Playwright to automate web browsers" 8 | readme = "README.md" 9 | authors = [ 10 | {name = "Microsoft"} 11 | ] 12 | license = {file = "LICENSE"} 13 | requires-python = ">=3.9" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "License :: OSI Approved :: Apache Software License", 22 | "Operating System :: OS Independent", 23 | "Framework :: Pytest", 24 | ] 25 | dynamic = ["version"] 26 | dependencies = [ 27 | "playwright>=1.18", 28 | "pytest>=6.2.4,<9.0.0", 29 | "pytest-base-url>=1.0.0,<3.0.0", 30 | "python-slugify>=6.0.0,<9.0.0", 31 | "pytest-asyncio>=0.24.0", 32 | ] 33 | 34 | [project.urls] 35 | homepage = "https://github.com/microsoft/playwright-pytest" 36 | 37 | [project.entry-points.pytest11] 38 | playwright-asyncio = "pytest_playwright_asyncio.pytest_playwright" 39 | 40 | [tool.setuptools] 41 | packages = ["pytest_playwright_asyncio"] 42 | [tool.setuptools_scm] 43 | root = ".." 44 | -------------------------------------------------------------------------------- /pytest-playwright-asyncio/pytest_playwright_asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pytest_playwright_asyncio.pytest_playwright import CreateContextCallback 16 | 17 | __all__ = [ 18 | "CreateContextCallback", 19 | ] 20 | -------------------------------------------------------------------------------- /pytest-playwright-asyncio/pytest_playwright_asyncio/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/playwright-pytest/364145f3b56749a72ffa57efc9804768d84291f8/pytest-playwright-asyncio/pytest_playwright_asyncio/py.typed -------------------------------------------------------------------------------- /pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import hashlib 16 | import json 17 | import shutil 18 | import os 19 | import sys 20 | import warnings 21 | from pathlib import Path 22 | from typing import ( 23 | Any, 24 | AsyncGenerator, 25 | Awaitable, 26 | Callable, 27 | Dict, 28 | Generator, 29 | List, 30 | Literal, 31 | Optional, 32 | Protocol, 33 | Sequence, 34 | Union, 35 | Pattern, 36 | cast, 37 | ) 38 | 39 | import pytest 40 | from playwright.async_api import ( 41 | Browser, 42 | BrowserContext, 43 | BrowserType, 44 | Error, 45 | Page, 46 | Playwright, 47 | async_playwright, 48 | ProxySettings, 49 | StorageState, 50 | HttpCredentials, 51 | Geolocation, 52 | ViewportSize, 53 | ) 54 | import pytest_asyncio 55 | from slugify import slugify 56 | import tempfile 57 | 58 | 59 | @pytest.fixture(scope="session") 60 | def _pw_artifacts_folder() -> Generator[tempfile.TemporaryDirectory, None, None]: 61 | artifacts_folder = tempfile.TemporaryDirectory(prefix="playwright-pytest-") 62 | yield artifacts_folder 63 | try: 64 | # On Windows, files can be still in use. 65 | # https://github.com/microsoft/playwright-pytest/issues/163 66 | artifacts_folder.cleanup() 67 | except (PermissionError, NotADirectoryError): 68 | pass 69 | 70 | 71 | @pytest.fixture(scope="session", autouse=True) 72 | def delete_output_dir(pytestconfig: Any) -> None: 73 | output_dir = pytestconfig.getoption("--output") 74 | if os.path.exists(output_dir): 75 | try: 76 | shutil.rmtree(output_dir) 77 | except (FileNotFoundError, PermissionError): 78 | # When running in parallel, another thread may have already deleted the files 79 | pass 80 | except OSError as error: 81 | if error.errno != 16: 82 | raise 83 | # We failed to remove folder, might be due to the whole folder being mounted inside a container: 84 | # https://github.com/microsoft/playwright/issues/12106 85 | # https://github.com/microsoft/playwright-python/issues/1781 86 | # Do a best-effort to remove all files inside of it instead. 87 | entries = os.listdir(output_dir) 88 | for entry in entries: 89 | shutil.rmtree(entry) 90 | 91 | 92 | def pytest_generate_tests(metafunc: Any) -> None: 93 | if "browser_name" in metafunc.fixturenames: 94 | browsers = metafunc.config.option.browser or ["chromium"] 95 | metafunc.parametrize("browser_name", browsers, scope="session") 96 | 97 | 98 | def pytest_configure(config: Any) -> None: 99 | config.addinivalue_line( 100 | "markers", "skip_browser(name): mark test to be skipped a specific browser" 101 | ) 102 | config.addinivalue_line( 103 | "markers", "only_browser(name): mark test to run only on a specific browser" 104 | ) 105 | config.addinivalue_line( 106 | "markers", 107 | "browser_context_args(**kwargs): provide additional arguments to browser.new_context()", 108 | ) 109 | 110 | 111 | # Making test result information available in fixtures 112 | # https://docs.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures 113 | @pytest.hookimpl(tryfirst=True, hookwrapper=True) 114 | def pytest_runtest_makereport(item: Any) -> Generator[None, Any, None]: 115 | # execute all other hooks to obtain the report object 116 | outcome = yield 117 | rep = outcome.get_result() 118 | 119 | # set a report attribute for each phase of a call, which can 120 | # be "setup", "call", "teardown" 121 | 122 | setattr(item, "rep_" + rep.when, rep) 123 | 124 | 125 | def _get_skiplist(item: Any, values: List[str], value_name: str) -> List[str]: 126 | skipped_values: List[str] = [] 127 | # Allowlist 128 | only_marker = item.get_closest_marker(f"only_{value_name}") 129 | if only_marker: 130 | skipped_values = values 131 | skipped_values.remove(only_marker.args[0]) 132 | 133 | # Denylist 134 | skip_marker = item.get_closest_marker(f"skip_{value_name}") 135 | if skip_marker: 136 | skipped_values.append(skip_marker.args[0]) 137 | 138 | return skipped_values 139 | 140 | 141 | def pytest_runtest_setup(item: Any) -> None: 142 | if not hasattr(item, "callspec"): 143 | return 144 | browser_name = item.callspec.params.get("browser_name") 145 | if not browser_name: 146 | return 147 | 148 | skip_browsers_names = _get_skiplist( 149 | item, ["chromium", "firefox", "webkit"], "browser" 150 | ) 151 | 152 | if browser_name in skip_browsers_names: 153 | pytest.skip("skipped for this browser: {}".format(browser_name)) 154 | 155 | 156 | VSCODE_PYTHON_EXTENSION_ID = "ms-python.python" 157 | 158 | 159 | @pytest.fixture(scope="session") 160 | def browser_type_launch_args(pytestconfig: Any) -> Dict: 161 | launch_options = {} 162 | headed_option = pytestconfig.getoption("--headed") 163 | if headed_option: 164 | launch_options["headless"] = False 165 | elif VSCODE_PYTHON_EXTENSION_ID in sys.argv[0] and _is_debugger_attached(): 166 | # When the VSCode debugger is attached, then launch the browser headed by default 167 | launch_options["headless"] = False 168 | browser_channel_option = pytestconfig.getoption("--browser-channel") 169 | if browser_channel_option: 170 | launch_options["channel"] = browser_channel_option 171 | slowmo_option = pytestconfig.getoption("--slowmo") 172 | if slowmo_option: 173 | launch_options["slow_mo"] = slowmo_option 174 | return launch_options 175 | 176 | 177 | def _is_debugger_attached() -> bool: 178 | pydevd = sys.modules.get("pydevd") 179 | if not pydevd or not hasattr(pydevd, "get_global_debugger"): 180 | return False 181 | debugger = pydevd.get_global_debugger() 182 | if not debugger or not hasattr(debugger, "is_attached"): 183 | return False 184 | return debugger.is_attached() 185 | 186 | 187 | @pytest.fixture 188 | def output_path(pytestconfig: Any, request: pytest.FixtureRequest) -> str: 189 | output_dir = Path(pytestconfig.getoption("--output")).absolute() 190 | return os.path.join(output_dir, _truncate_file_name(slugify(request.node.nodeid))) 191 | 192 | 193 | def _truncate_file_name(file_name: str) -> str: 194 | if len(file_name) < 256: 195 | return file_name 196 | return f"{file_name[:100]}-{hashlib.sha256(file_name.encode()).hexdigest()[:7]}-{file_name[-100:]}" 197 | 198 | 199 | @pytest.fixture(scope="session") 200 | def browser_context_args( 201 | pytestconfig: Any, 202 | playwright: Playwright, 203 | device: Optional[str], 204 | base_url: Optional[str], 205 | _pw_artifacts_folder: tempfile.TemporaryDirectory, 206 | ) -> Dict: 207 | context_args = {} 208 | if device: 209 | context_args.update(playwright.devices[device]) 210 | if base_url: 211 | context_args["base_url"] = base_url 212 | 213 | video_option = pytestconfig.getoption("--video") 214 | capture_video = video_option in ["on", "retain-on-failure"] 215 | if capture_video: 216 | context_args["record_video_dir"] = _pw_artifacts_folder.name 217 | 218 | return context_args 219 | 220 | 221 | @pytest_asyncio.fixture(loop_scope="session") 222 | async def _artifacts_recorder( 223 | request: pytest.FixtureRequest, 224 | output_path: str, 225 | playwright: Playwright, 226 | pytestconfig: Any, 227 | _pw_artifacts_folder: tempfile.TemporaryDirectory, 228 | ) -> AsyncGenerator["ArtifactsRecorder", None]: 229 | artifacts_recorder = ArtifactsRecorder( 230 | pytestconfig, request, output_path, playwright, _pw_artifacts_folder 231 | ) 232 | yield artifacts_recorder 233 | # If request.node is missing rep_call, then some error happened during execution 234 | # that prevented teardown, but should still be counted as a failure 235 | failed = request.node.rep_call.failed if hasattr(request.node, "rep_call") else True 236 | await artifacts_recorder.did_finish_test(failed) 237 | 238 | 239 | @pytest_asyncio.fixture(scope="session") 240 | async def playwright() -> AsyncGenerator[Playwright, None]: 241 | pw = await async_playwright().start() 242 | yield pw 243 | await pw.stop() 244 | 245 | 246 | @pytest.fixture(scope="session") 247 | def browser_type(playwright: Playwright, browser_name: str) -> BrowserType: 248 | return getattr(playwright, browser_name) 249 | 250 | 251 | @pytest.fixture(scope="session") 252 | def connect_options() -> Optional[Dict]: 253 | return None 254 | 255 | 256 | @pytest.fixture(scope="session") 257 | def launch_browser( 258 | browser_type_launch_args: Dict, 259 | browser_type: BrowserType, 260 | connect_options: Optional[Dict], 261 | ) -> Callable[..., Awaitable[Browser]]: 262 | async def launch(**kwargs: Dict) -> Browser: 263 | launch_options = {**browser_type_launch_args, **kwargs} 264 | if connect_options: 265 | browser = await browser_type.connect( 266 | **( 267 | { 268 | **connect_options, 269 | "headers": { 270 | "x-playwright-launch-options": json.dumps(launch_options), 271 | **(connect_options.get("headers") or {}), 272 | }, 273 | } 274 | ) 275 | ) 276 | else: 277 | browser = await browser_type.launch(**launch_options) 278 | return browser 279 | 280 | return launch 281 | 282 | 283 | @pytest_asyncio.fixture(scope="session") 284 | async def browser( 285 | launch_browser: Callable[[], Awaitable[Browser]] 286 | ) -> AsyncGenerator[Browser, None]: 287 | browser = await launch_browser() 288 | yield browser 289 | await browser.close() 290 | 291 | 292 | class CreateContextCallback(Protocol): 293 | def __call__( 294 | self, 295 | viewport: Optional[ViewportSize] = None, 296 | screen: Optional[ViewportSize] = None, 297 | no_viewport: Optional[bool] = None, 298 | ignore_https_errors: Optional[bool] = None, 299 | java_script_enabled: Optional[bool] = None, 300 | bypass_csp: Optional[bool] = None, 301 | user_agent: Optional[str] = None, 302 | locale: Optional[str] = None, 303 | timezone_id: Optional[str] = None, 304 | geolocation: Optional[Geolocation] = None, 305 | permissions: Optional[Sequence[str]] = None, 306 | extra_http_headers: Optional[Dict[str, str]] = None, 307 | offline: Optional[bool] = None, 308 | http_credentials: Optional[HttpCredentials] = None, 309 | device_scale_factor: Optional[float] = None, 310 | is_mobile: Optional[bool] = None, 311 | has_touch: Optional[bool] = None, 312 | color_scheme: Optional[ 313 | Literal["dark", "light", "no-preference", "null"] 314 | ] = None, 315 | reduced_motion: Optional[Literal["no-preference", "null", "reduce"]] = None, 316 | forced_colors: Optional[Literal["active", "none", "null"]] = None, 317 | accept_downloads: Optional[bool] = None, 318 | default_browser_type: Optional[str] = None, 319 | proxy: Optional[ProxySettings] = None, 320 | record_har_path: Optional[Union[str, Path]] = None, 321 | record_har_omit_content: Optional[bool] = None, 322 | record_video_dir: Optional[Union[str, Path]] = None, 323 | record_video_size: Optional[ViewportSize] = None, 324 | storage_state: Optional[Union[StorageState, str, Path]] = None, 325 | base_url: Optional[str] = None, 326 | strict_selectors: Optional[bool] = None, 327 | service_workers: Optional[Literal["allow", "block"]] = None, 328 | record_har_url_filter: Optional[Union[str, Pattern[str]]] = None, 329 | record_har_mode: Optional[Literal["full", "minimal"]] = None, 330 | record_har_content: Optional[Literal["attach", "embed", "omit"]] = None, 331 | ) -> Awaitable[BrowserContext]: ... 332 | 333 | 334 | @pytest_asyncio.fixture(loop_scope="session") 335 | async def new_context( 336 | browser: Browser, 337 | browser_context_args: Dict, 338 | _artifacts_recorder: "ArtifactsRecorder", 339 | request: pytest.FixtureRequest, 340 | ) -> AsyncGenerator[CreateContextCallback, None]: 341 | browser_context_args = browser_context_args.copy() 342 | context_args_marker = next(request.node.iter_markers("browser_context_args"), None) 343 | additional_context_args = context_args_marker.kwargs if context_args_marker else {} 344 | browser_context_args.update(additional_context_args) 345 | contexts: List[BrowserContext] = [] 346 | 347 | async def _new_context(**kwargs: Any) -> BrowserContext: 348 | context = await browser.new_context(**browser_context_args, **kwargs) 349 | original_close = context.close 350 | 351 | async def _close_wrapper(*args: Any, **kwargs: Any) -> None: 352 | contexts.remove(context) 353 | await _artifacts_recorder.on_will_close_browser_context(context) 354 | await original_close(*args, **kwargs) 355 | 356 | context.close = _close_wrapper 357 | contexts.append(context) 358 | await _artifacts_recorder.on_did_create_browser_context(context) 359 | return context 360 | 361 | yield cast(CreateContextCallback, _new_context) 362 | for context in contexts.copy(): 363 | await context.close() 364 | 365 | 366 | @pytest_asyncio.fixture(loop_scope="session") 367 | async def context(new_context: CreateContextCallback) -> BrowserContext: 368 | return await new_context() 369 | 370 | 371 | @pytest_asyncio.fixture(loop_scope="session") 372 | async def page(context: BrowserContext) -> Page: 373 | return await context.new_page() 374 | 375 | 376 | @pytest.fixture(scope="session") 377 | def is_webkit(browser_name: str) -> bool: 378 | return browser_name == "webkit" 379 | 380 | 381 | @pytest.fixture(scope="session") 382 | def is_firefox(browser_name: str) -> bool: 383 | return browser_name == "firefox" 384 | 385 | 386 | @pytest.fixture(scope="session") 387 | def is_chromium(browser_name: str) -> bool: 388 | return browser_name == "chromium" 389 | 390 | 391 | @pytest.fixture(scope="session") 392 | def browser_name(pytestconfig: Any) -> Optional[str]: 393 | # When using unittest.TestCase it won't use pytest_generate_tests 394 | # For that we still try to give the user a slightly less feature-rich experience 395 | browser_names = pytestconfig.getoption("--browser") 396 | if len(browser_names) == 0: 397 | return "chromium" 398 | if len(browser_names) == 1: 399 | return browser_names[0] 400 | warnings.warn( 401 | "When using unittest.TestCase specifying multiple browsers is not supported" 402 | ) 403 | return browser_names[0] 404 | 405 | 406 | @pytest.fixture(scope="session") 407 | def browser_channel(pytestconfig: Any) -> Optional[str]: 408 | return pytestconfig.getoption("--browser-channel") 409 | 410 | 411 | @pytest.fixture(scope="session") 412 | def device(pytestconfig: Any) -> Optional[str]: 413 | return pytestconfig.getoption("--device") 414 | 415 | 416 | def pytest_addoption(parser: Any) -> None: 417 | group = parser.getgroup("playwright", "Playwright") 418 | group.addoption( 419 | "--browser", 420 | action="append", 421 | default=[], 422 | help="Browser engine which should be used", 423 | choices=["chromium", "firefox", "webkit"], 424 | ) 425 | group.addoption( 426 | "--headed", 427 | action="store_true", 428 | default=False, 429 | help="Run tests in headed mode.", 430 | ) 431 | group.addoption( 432 | "--browser-channel", 433 | action="store", 434 | default=None, 435 | help="Browser channel to be used.", 436 | ) 437 | group.addoption( 438 | "--slowmo", 439 | default=0, 440 | type=int, 441 | help="Run tests with slow mo", 442 | ) 443 | group.addoption( 444 | "--device", 445 | default=None, 446 | action="store", 447 | help="Device to be emulated.", 448 | ) 449 | group.addoption( 450 | "--output", 451 | default="test-results", 452 | help="Directory for artifacts produced by tests, defaults to test-results.", 453 | ) 454 | group.addoption( 455 | "--tracing", 456 | default="off", 457 | choices=["on", "off", "retain-on-failure"], 458 | help="Whether to record a trace for each test.", 459 | ) 460 | group.addoption( 461 | "--video", 462 | default="off", 463 | choices=["on", "off", "retain-on-failure"], 464 | help="Whether to record video for each test.", 465 | ) 466 | group.addoption( 467 | "--screenshot", 468 | default="off", 469 | choices=["on", "off", "only-on-failure"], 470 | help="Whether to automatically capture a screenshot after each test.", 471 | ) 472 | group.addoption( 473 | "--full-page-screenshot", 474 | action="store_true", 475 | default=False, 476 | help="Whether to take a full page screenshot", 477 | ) 478 | 479 | 480 | class ArtifactsRecorder: 481 | def __init__( 482 | self, 483 | pytestconfig: Any, 484 | request: pytest.FixtureRequest, 485 | output_path: str, 486 | playwright: Playwright, 487 | pw_artifacts_folder: tempfile.TemporaryDirectory, 488 | ) -> None: 489 | self._request = request 490 | self._pytestconfig = pytestconfig 491 | self._playwright = playwright 492 | self._output_path = output_path 493 | self._pw_artifacts_folder = pw_artifacts_folder 494 | 495 | self._all_pages: List[Page] = [] 496 | self._screenshots: List[str] = [] 497 | self._traces: List[str] = [] 498 | self._tracing_option = pytestconfig.getoption("--tracing") 499 | self._capture_trace = self._tracing_option in ["on", "retain-on-failure"] 500 | 501 | def _build_artifact_test_folder(self, folder_or_file_name: str) -> str: 502 | return os.path.join( 503 | self._output_path, 504 | _truncate_file_name(folder_or_file_name), 505 | ) 506 | 507 | async def did_finish_test(self, failed: bool) -> None: 508 | screenshot_option = self._pytestconfig.getoption("--screenshot") 509 | capture_screenshot = screenshot_option == "on" or ( 510 | failed and screenshot_option == "only-on-failure" 511 | ) 512 | if capture_screenshot: 513 | for index, screenshot in enumerate(self._screenshots): 514 | human_readable_status = "failed" if failed else "finished" 515 | screenshot_path = self._build_artifact_test_folder( 516 | f"test-{human_readable_status}-{index + 1}.png", 517 | ) 518 | os.makedirs(os.path.dirname(screenshot_path), exist_ok=True) 519 | shutil.move(screenshot, screenshot_path) 520 | else: 521 | for screenshot in self._screenshots: 522 | os.remove(screenshot) 523 | 524 | if self._tracing_option == "on" or ( 525 | failed and self._tracing_option == "retain-on-failure" 526 | ): 527 | for index, trace in enumerate(self._traces): 528 | trace_file_name = ( 529 | "trace.zip" if len(self._traces) == 1 else f"trace-{index + 1}.zip" 530 | ) 531 | trace_path = self._build_artifact_test_folder(trace_file_name) 532 | os.makedirs(os.path.dirname(trace_path), exist_ok=True) 533 | shutil.move(trace, trace_path) 534 | else: 535 | for trace in self._traces: 536 | os.remove(trace) 537 | 538 | video_option = self._pytestconfig.getoption("--video") 539 | preserve_video = video_option == "on" or ( 540 | failed and video_option == "retain-on-failure" 541 | ) 542 | if preserve_video: 543 | for index, page in enumerate(self._all_pages): 544 | video = page.video 545 | if not video: 546 | continue 547 | try: 548 | video_file_name = ( 549 | "video.webm" 550 | if len(self._all_pages) == 1 551 | else f"video-{index + 1}.webm" 552 | ) 553 | await video.save_as( 554 | path=self._build_artifact_test_folder(video_file_name) 555 | ) 556 | except Error: 557 | # Silent catch empty videos. 558 | pass 559 | else: 560 | for page in self._all_pages: 561 | # Can be changed to "if page.video" without try/except once https://github.com/microsoft/playwright-python/pull/2410 is released and widely adopted. 562 | if video_option in ["on", "retain-on-failure"]: 563 | try: 564 | if page.video: 565 | await page.video.delete() 566 | except Error: 567 | pass 568 | 569 | async def on_did_create_browser_context(self, context: BrowserContext) -> None: 570 | context.on("page", lambda page: self._all_pages.append(page)) 571 | if self._request and self._capture_trace: 572 | await context.tracing.start( 573 | title=slugify(self._request.node.nodeid), 574 | screenshots=True, 575 | snapshots=True, 576 | sources=True, 577 | ) 578 | 579 | async def on_will_close_browser_context(self, context: BrowserContext) -> None: 580 | if self._capture_trace: 581 | trace_path = Path(self._pw_artifacts_folder.name) / _create_guid() 582 | await context.tracing.stop(path=trace_path) 583 | self._traces.append(str(trace_path)) 584 | else: 585 | await context.tracing.stop() 586 | 587 | if self._pytestconfig.getoption("--screenshot") in ["on", "only-on-failure"]: 588 | for page in context.pages: 589 | try: 590 | screenshot_path = ( 591 | Path(self._pw_artifacts_folder.name) / _create_guid() 592 | ) 593 | await page.screenshot( 594 | timeout=5000, 595 | path=screenshot_path, 596 | full_page=self._pytestconfig.getoption( 597 | "--full-page-screenshot" 598 | ), 599 | ) 600 | self._screenshots.append(str(screenshot_path)) 601 | except Error: 602 | pass 603 | 604 | 605 | def _create_guid() -> str: 606 | return hashlib.sha256(os.urandom(16)).hexdigest() 607 | -------------------------------------------------------------------------------- /pytest-playwright/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Portions Copyright (c) Microsoft Corporation. 190 | Portions Copyright 2017 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /pytest-playwright/README.md: -------------------------------------------------------------------------------- 1 | # Pytest plugin for Playwright [](https://pypi.org/project/pytest-playwright/) 2 | 3 | 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/). 4 | 5 | - Support for **all modern browsers** including Chromium, WebKit and Firefox. 6 | - Support for **headless and headed** execution. 7 | - **Built-in fixtures** that provide browser primitives to test functions. 8 | 9 | **Note**: If you are looking for an asyncio version of this plugin, check out [pytest-playwright-asyncio](https://pypi.org/project/pytest-playwright-asyncio/). 10 | 11 | ## Documentation 12 | 13 | See on [playwright.dev](https://playwright.dev/python/docs/test-runners) for examples and more detailed information. 14 | -------------------------------------------------------------------------------- /pytest-playwright/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools==75.4.0", "setuptools_scm==8.1.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pytest-playwright" 7 | description = "A pytest wrapper with fixtures for Playwright to automate web browsers" 8 | readme = "README.md" 9 | authors = [ 10 | {name = "Microsoft"} 11 | ] 12 | license = {file = "LICENSE"} 13 | requires-python = ">=3.9" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "License :: OSI Approved :: Apache Software License", 22 | "Operating System :: OS Independent", 23 | "Framework :: Pytest", 24 | ] 25 | dynamic = ["version"] 26 | dependencies = [ 27 | "playwright>=1.18", 28 | "pytest>=6.2.4,<9.0.0", 29 | "pytest-base-url>=1.0.0,<3.0.0", 30 | "python-slugify>=6.0.0,<9.0.0", 31 | ] 32 | 33 | [project.urls] 34 | homepage = "https://github.com/microsoft/playwright-pytest" 35 | 36 | [project.entry-points.pytest11] 37 | playwright = "pytest_playwright.pytest_playwright" 38 | 39 | [tool.setuptools] 40 | packages = ["pytest_playwright"] 41 | [tool.setuptools_scm] 42 | root = ".." 43 | -------------------------------------------------------------------------------- /pytest-playwright/pytest_playwright/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pytest_playwright.pytest_playwright import CreateContextCallback 16 | 17 | __all__ = [ 18 | "CreateContextCallback", 19 | ] 20 | -------------------------------------------------------------------------------- /pytest-playwright/pytest_playwright/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/playwright-pytest/364145f3b56749a72ffa57efc9804768d84291f8/pytest-playwright/pytest_playwright/py.typed -------------------------------------------------------------------------------- /pytest-playwright/pytest_playwright/pytest_playwright.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import hashlib 16 | import json 17 | import shutil 18 | import os 19 | import sys 20 | import warnings 21 | from pathlib import Path 22 | from typing import ( 23 | Any, 24 | Callable, 25 | Dict, 26 | Generator, 27 | List, 28 | Literal, 29 | Optional, 30 | Protocol, 31 | Sequence, 32 | Union, 33 | Pattern, 34 | cast, 35 | ) 36 | 37 | import pytest 38 | from playwright.sync_api import ( 39 | Browser, 40 | BrowserContext, 41 | BrowserType, 42 | Error, 43 | Page, 44 | Playwright, 45 | sync_playwright, 46 | ProxySettings, 47 | StorageState, 48 | HttpCredentials, 49 | Geolocation, 50 | ViewportSize, 51 | ) 52 | from slugify import slugify 53 | import tempfile 54 | 55 | 56 | @pytest.fixture(scope="session") 57 | def _pw_artifacts_folder() -> Generator[tempfile.TemporaryDirectory, None, None]: 58 | artifacts_folder = tempfile.TemporaryDirectory(prefix="playwright-pytest-") 59 | yield artifacts_folder 60 | try: 61 | # On Windows, files can be still in use. 62 | # https://github.com/microsoft/playwright-pytest/issues/163 63 | artifacts_folder.cleanup() 64 | except (PermissionError, NotADirectoryError): 65 | pass 66 | 67 | 68 | @pytest.fixture(scope="session", autouse=True) 69 | def delete_output_dir(pytestconfig: Any) -> None: 70 | output_dir = pytestconfig.getoption("--output") 71 | if os.path.exists(output_dir): 72 | try: 73 | shutil.rmtree(output_dir) 74 | except (FileNotFoundError, PermissionError): 75 | # When running in parallel, another thread may have already deleted the files 76 | pass 77 | except OSError as error: 78 | if error.errno != 16: 79 | raise 80 | # We failed to remove folder, might be due to the whole folder being mounted inside a container: 81 | # https://github.com/microsoft/playwright/issues/12106 82 | # https://github.com/microsoft/playwright-python/issues/1781 83 | # Do a best-effort to remove all files inside of it instead. 84 | entries = os.listdir(output_dir) 85 | for entry in entries: 86 | shutil.rmtree(entry) 87 | 88 | 89 | def pytest_generate_tests(metafunc: Any) -> None: 90 | if "browser_name" in metafunc.fixturenames: 91 | browsers = metafunc.config.option.browser or ["chromium"] 92 | metafunc.parametrize("browser_name", browsers, scope="session") 93 | 94 | 95 | def pytest_configure(config: Any) -> None: 96 | config.addinivalue_line( 97 | "markers", "skip_browser(name): mark test to be skipped a specific browser" 98 | ) 99 | config.addinivalue_line( 100 | "markers", "only_browser(name): mark test to run only on a specific browser" 101 | ) 102 | config.addinivalue_line( 103 | "markers", 104 | "browser_context_args(**kwargs): provide additional arguments to browser.new_context()", 105 | ) 106 | 107 | 108 | # Making test result information available in fixtures 109 | # https://docs.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures 110 | @pytest.hookimpl(tryfirst=True, hookwrapper=True) 111 | def pytest_runtest_makereport(item: Any) -> Generator[None, Any, None]: 112 | # execute all other hooks to obtain the report object 113 | outcome = yield 114 | rep = outcome.get_result() 115 | 116 | # set a report attribute for each phase of a call, which can 117 | # be "setup", "call", "teardown" 118 | 119 | setattr(item, "rep_" + rep.when, rep) 120 | 121 | 122 | def _get_skiplist(item: Any, values: List[str], value_name: str) -> List[str]: 123 | skipped_values: List[str] = [] 124 | # Allowlist 125 | only_marker = item.get_closest_marker(f"only_{value_name}") 126 | if only_marker: 127 | skipped_values = values 128 | skipped_values.remove(only_marker.args[0]) 129 | 130 | # Denylist 131 | skip_marker = item.get_closest_marker(f"skip_{value_name}") 132 | if skip_marker: 133 | skipped_values.append(skip_marker.args[0]) 134 | 135 | return skipped_values 136 | 137 | 138 | def pytest_runtest_setup(item: Any) -> None: 139 | if not hasattr(item, "callspec"): 140 | return 141 | browser_name = item.callspec.params.get("browser_name") 142 | if not browser_name: 143 | return 144 | 145 | skip_browsers_names = _get_skiplist( 146 | item, ["chromium", "firefox", "webkit"], "browser" 147 | ) 148 | 149 | if browser_name in skip_browsers_names: 150 | pytest.skip("skipped for this browser: {}".format(browser_name)) 151 | 152 | 153 | VSCODE_PYTHON_EXTENSION_ID = "ms-python.python" 154 | 155 | 156 | @pytest.fixture(scope="session") 157 | def browser_type_launch_args(pytestconfig: Any) -> Dict: 158 | launch_options = {} 159 | headed_option = pytestconfig.getoption("--headed") 160 | if headed_option: 161 | launch_options["headless"] = False 162 | elif VSCODE_PYTHON_EXTENSION_ID in sys.argv[0] and _is_debugger_attached(): 163 | # When the VSCode debugger is attached, then launch the browser headed by default 164 | launch_options["headless"] = False 165 | browser_channel_option = pytestconfig.getoption("--browser-channel") 166 | if browser_channel_option: 167 | launch_options["channel"] = browser_channel_option 168 | slowmo_option = pytestconfig.getoption("--slowmo") 169 | if slowmo_option: 170 | launch_options["slow_mo"] = slowmo_option 171 | return launch_options 172 | 173 | 174 | def _is_debugger_attached() -> bool: 175 | pydevd = sys.modules.get("pydevd") 176 | if not pydevd or not hasattr(pydevd, "get_global_debugger"): 177 | return False 178 | debugger = pydevd.get_global_debugger() 179 | if not debugger or not hasattr(debugger, "is_attached"): 180 | return False 181 | return debugger.is_attached() 182 | 183 | 184 | @pytest.fixture 185 | def output_path(pytestconfig: Any, request: pytest.FixtureRequest) -> str: 186 | output_dir = Path(pytestconfig.getoption("--output")).absolute() 187 | return os.path.join(output_dir, _truncate_file_name(slugify(request.node.nodeid))) 188 | 189 | 190 | def _truncate_file_name(file_name: str) -> str: 191 | if len(file_name) < 256: 192 | return file_name 193 | return f"{file_name[:100]}-{hashlib.sha256(file_name.encode()).hexdigest()[:7]}-{file_name[-100:]}" 194 | 195 | 196 | @pytest.fixture(scope="session") 197 | def browser_context_args( 198 | pytestconfig: Any, 199 | playwright: Playwright, 200 | device: Optional[str], 201 | base_url: Optional[str], 202 | _pw_artifacts_folder: tempfile.TemporaryDirectory, 203 | ) -> Dict: 204 | context_args = {} 205 | if device: 206 | context_args.update(playwright.devices[device]) 207 | if base_url: 208 | context_args["base_url"] = base_url 209 | 210 | video_option = pytestconfig.getoption("--video") 211 | capture_video = video_option in ["on", "retain-on-failure"] 212 | if capture_video: 213 | context_args["record_video_dir"] = _pw_artifacts_folder.name 214 | 215 | return context_args 216 | 217 | 218 | @pytest.fixture() 219 | def _artifacts_recorder( 220 | request: pytest.FixtureRequest, 221 | output_path: str, 222 | playwright: Playwright, 223 | pytestconfig: Any, 224 | _pw_artifacts_folder: tempfile.TemporaryDirectory, 225 | ) -> Generator["ArtifactsRecorder", None, None]: 226 | artifacts_recorder = ArtifactsRecorder( 227 | pytestconfig, request, output_path, playwright, _pw_artifacts_folder 228 | ) 229 | yield artifacts_recorder 230 | # If request.node is missing rep_call, then some error happened during execution 231 | # that prevented teardown, but should still be counted as a failure 232 | failed = request.node.rep_call.failed if hasattr(request.node, "rep_call") else True 233 | artifacts_recorder.did_finish_test(failed) 234 | 235 | 236 | @pytest.fixture(scope="session") 237 | def playwright() -> Generator[Playwright, None, None]: 238 | pw = sync_playwright().start() 239 | yield pw 240 | pw.stop() 241 | 242 | 243 | @pytest.fixture(scope="session") 244 | def browser_type(playwright: Playwright, browser_name: str) -> BrowserType: 245 | return getattr(playwright, browser_name) 246 | 247 | 248 | @pytest.fixture(scope="session") 249 | def connect_options() -> Optional[Dict]: 250 | return None 251 | 252 | 253 | @pytest.fixture(scope="session") 254 | def launch_browser( 255 | browser_type_launch_args: Dict, 256 | browser_type: BrowserType, 257 | connect_options: Optional[Dict], 258 | ) -> Callable[..., Browser]: 259 | def launch(**kwargs: Dict) -> Browser: 260 | launch_options = {**browser_type_launch_args, **kwargs} 261 | if connect_options: 262 | browser = browser_type.connect( 263 | **( 264 | { 265 | **connect_options, 266 | "headers": { 267 | "x-playwright-launch-options": json.dumps(launch_options), 268 | **(connect_options.get("headers") or {}), 269 | }, 270 | } 271 | ) 272 | ) 273 | else: 274 | browser = browser_type.launch(**launch_options) 275 | return browser 276 | 277 | return launch 278 | 279 | 280 | @pytest.fixture(scope="session") 281 | def browser(launch_browser: Callable[[], Browser]) -> Generator[Browser, None, None]: 282 | browser = launch_browser() 283 | yield browser 284 | browser.close() 285 | 286 | 287 | class CreateContextCallback(Protocol): 288 | def __call__( 289 | self, 290 | viewport: Optional[ViewportSize] = None, 291 | screen: Optional[ViewportSize] = None, 292 | no_viewport: Optional[bool] = None, 293 | ignore_https_errors: Optional[bool] = None, 294 | java_script_enabled: Optional[bool] = None, 295 | bypass_csp: Optional[bool] = None, 296 | user_agent: Optional[str] = None, 297 | locale: Optional[str] = None, 298 | timezone_id: Optional[str] = None, 299 | geolocation: Optional[Geolocation] = None, 300 | permissions: Optional[Sequence[str]] = None, 301 | extra_http_headers: Optional[Dict[str, str]] = None, 302 | offline: Optional[bool] = None, 303 | http_credentials: Optional[HttpCredentials] = None, 304 | device_scale_factor: Optional[float] = None, 305 | is_mobile: Optional[bool] = None, 306 | has_touch: Optional[bool] = None, 307 | color_scheme: Optional[ 308 | Literal["dark", "light", "no-preference", "null"] 309 | ] = None, 310 | reduced_motion: Optional[Literal["no-preference", "null", "reduce"]] = None, 311 | forced_colors: Optional[Literal["active", "none", "null"]] = None, 312 | accept_downloads: Optional[bool] = None, 313 | default_browser_type: Optional[str] = None, 314 | proxy: Optional[ProxySettings] = None, 315 | record_har_path: Optional[Union[str, Path]] = None, 316 | record_har_omit_content: Optional[bool] = None, 317 | record_video_dir: Optional[Union[str, Path]] = None, 318 | record_video_size: Optional[ViewportSize] = None, 319 | storage_state: Optional[Union[StorageState, str, Path]] = None, 320 | base_url: Optional[str] = None, 321 | strict_selectors: Optional[bool] = None, 322 | service_workers: Optional[Literal["allow", "block"]] = None, 323 | record_har_url_filter: Optional[Union[str, Pattern[str]]] = None, 324 | record_har_mode: Optional[Literal["full", "minimal"]] = None, 325 | record_har_content: Optional[Literal["attach", "embed", "omit"]] = None, 326 | ) -> BrowserContext: ... 327 | 328 | 329 | @pytest.fixture 330 | def new_context( 331 | browser: Browser, 332 | browser_context_args: Dict, 333 | _artifacts_recorder: "ArtifactsRecorder", 334 | request: pytest.FixtureRequest, 335 | ) -> Generator[CreateContextCallback, None, None]: 336 | browser_context_args = browser_context_args.copy() 337 | context_args_marker = next(request.node.iter_markers("browser_context_args"), None) 338 | additional_context_args = context_args_marker.kwargs if context_args_marker else {} 339 | browser_context_args.update(additional_context_args) 340 | contexts: List[BrowserContext] = [] 341 | 342 | def _new_context(**kwargs: Any) -> BrowserContext: 343 | context = browser.new_context(**browser_context_args, **kwargs) 344 | original_close = context.close 345 | 346 | def _close_wrapper(*args: Any, **kwargs: Any) -> None: 347 | contexts.remove(context) 348 | _artifacts_recorder.on_will_close_browser_context(context) 349 | original_close(*args, **kwargs) 350 | 351 | context.close = _close_wrapper 352 | contexts.append(context) 353 | _artifacts_recorder.on_did_create_browser_context(context) 354 | return context 355 | 356 | yield cast(CreateContextCallback, _new_context) 357 | for context in contexts.copy(): 358 | context.close() 359 | 360 | 361 | @pytest.fixture 362 | def context(new_context: CreateContextCallback) -> BrowserContext: 363 | return new_context() 364 | 365 | 366 | @pytest.fixture 367 | def page(context: BrowserContext) -> Page: 368 | return context.new_page() 369 | 370 | 371 | @pytest.fixture(scope="session") 372 | def is_webkit(browser_name: str) -> bool: 373 | return browser_name == "webkit" 374 | 375 | 376 | @pytest.fixture(scope="session") 377 | def is_firefox(browser_name: str) -> bool: 378 | return browser_name == "firefox" 379 | 380 | 381 | @pytest.fixture(scope="session") 382 | def is_chromium(browser_name: str) -> bool: 383 | return browser_name == "chromium" 384 | 385 | 386 | @pytest.fixture(scope="session") 387 | def browser_name(pytestconfig: Any) -> Optional[str]: 388 | # When using unittest.TestCase it won't use pytest_generate_tests 389 | # For that we still try to give the user a slightly less feature-rich experience 390 | browser_names = pytestconfig.getoption("--browser") 391 | if len(browser_names) == 0: 392 | return "chromium" 393 | if len(browser_names) == 1: 394 | return browser_names[0] 395 | warnings.warn( 396 | "When using unittest.TestCase specifying multiple browsers is not supported" 397 | ) 398 | return browser_names[0] 399 | 400 | 401 | @pytest.fixture(scope="session") 402 | def browser_channel(pytestconfig: Any) -> Optional[str]: 403 | return pytestconfig.getoption("--browser-channel") 404 | 405 | 406 | @pytest.fixture(scope="session") 407 | def device(pytestconfig: Any) -> Optional[str]: 408 | return pytestconfig.getoption("--device") 409 | 410 | 411 | def pytest_addoption(parser: Any) -> None: 412 | group = parser.getgroup("playwright", "Playwright") 413 | group.addoption( 414 | "--browser", 415 | action="append", 416 | default=[], 417 | help="Browser engine which should be used", 418 | choices=["chromium", "firefox", "webkit"], 419 | ) 420 | group.addoption( 421 | "--headed", 422 | action="store_true", 423 | default=False, 424 | help="Run tests in headed mode.", 425 | ) 426 | group.addoption( 427 | "--browser-channel", 428 | action="store", 429 | default=None, 430 | help="Browser channel to be used.", 431 | ) 432 | group.addoption( 433 | "--slowmo", 434 | default=0, 435 | type=int, 436 | help="Run tests with slow mo", 437 | ) 438 | group.addoption( 439 | "--device", 440 | default=None, 441 | action="store", 442 | help="Device to be emulated.", 443 | ) 444 | group.addoption( 445 | "--output", 446 | default="test-results", 447 | help="Directory for artifacts produced by tests, defaults to test-results.", 448 | ) 449 | group.addoption( 450 | "--tracing", 451 | default="off", 452 | choices=["on", "off", "retain-on-failure"], 453 | help="Whether to record a trace for each test.", 454 | ) 455 | group.addoption( 456 | "--video", 457 | default="off", 458 | choices=["on", "off", "retain-on-failure"], 459 | help="Whether to record video for each test.", 460 | ) 461 | group.addoption( 462 | "--screenshot", 463 | default="off", 464 | choices=["on", "off", "only-on-failure"], 465 | help="Whether to automatically capture a screenshot after each test.", 466 | ) 467 | group.addoption( 468 | "--full-page-screenshot", 469 | action="store_true", 470 | default=False, 471 | help="Whether to take a full page screenshot", 472 | ) 473 | 474 | 475 | class ArtifactsRecorder: 476 | def __init__( 477 | self, 478 | pytestconfig: Any, 479 | request: pytest.FixtureRequest, 480 | output_path: str, 481 | playwright: Playwright, 482 | pw_artifacts_folder: tempfile.TemporaryDirectory, 483 | ) -> None: 484 | self._request = request 485 | self._pytestconfig = pytestconfig 486 | self._playwright = playwright 487 | self._output_path = output_path 488 | self._pw_artifacts_folder = pw_artifacts_folder 489 | 490 | self._all_pages: List[Page] = [] 491 | self._screenshots: List[str] = [] 492 | self._traces: List[str] = [] 493 | self._tracing_option = pytestconfig.getoption("--tracing") 494 | self._capture_trace = self._tracing_option in ["on", "retain-on-failure"] 495 | 496 | def _build_artifact_test_folder(self, folder_or_file_name: str) -> str: 497 | return os.path.join( 498 | self._output_path, 499 | _truncate_file_name(folder_or_file_name), 500 | ) 501 | 502 | def did_finish_test(self, failed: bool) -> None: 503 | screenshot_option = self._pytestconfig.getoption("--screenshot") 504 | capture_screenshot = screenshot_option == "on" or ( 505 | failed and screenshot_option == "only-on-failure" 506 | ) 507 | if capture_screenshot: 508 | for index, screenshot in enumerate(self._screenshots): 509 | human_readable_status = "failed" if failed else "finished" 510 | screenshot_path = self._build_artifact_test_folder( 511 | f"test-{human_readable_status}-{index + 1}.png", 512 | ) 513 | os.makedirs(os.path.dirname(screenshot_path), exist_ok=True) 514 | shutil.move(screenshot, screenshot_path) 515 | else: 516 | for screenshot in self._screenshots: 517 | os.remove(screenshot) 518 | 519 | if self._tracing_option == "on" or ( 520 | failed and self._tracing_option == "retain-on-failure" 521 | ): 522 | for index, trace in enumerate(self._traces): 523 | trace_file_name = ( 524 | "trace.zip" if len(self._traces) == 1 else f"trace-{index + 1}.zip" 525 | ) 526 | trace_path = self._build_artifact_test_folder(trace_file_name) 527 | os.makedirs(os.path.dirname(trace_path), exist_ok=True) 528 | shutil.move(trace, trace_path) 529 | else: 530 | for trace in self._traces: 531 | os.remove(trace) 532 | 533 | video_option = self._pytestconfig.getoption("--video") 534 | preserve_video = video_option == "on" or ( 535 | failed and video_option == "retain-on-failure" 536 | ) 537 | if preserve_video: 538 | for index, page in enumerate(self._all_pages): 539 | video = page.video 540 | if not video: 541 | continue 542 | try: 543 | video_file_name = ( 544 | "video.webm" 545 | if len(self._all_pages) == 1 546 | else f"video-{index + 1}.webm" 547 | ) 548 | video.save_as( 549 | path=self._build_artifact_test_folder(video_file_name) 550 | ) 551 | except Error: 552 | # Silent catch empty videos. 553 | pass 554 | else: 555 | for page in self._all_pages: 556 | # Can be changed to "if page.video" without try/except once https://github.com/microsoft/playwright-python/pull/2410 is released and widely adopted. 557 | if video_option in ["on", "retain-on-failure"]: 558 | try: 559 | if page.video: 560 | page.video.delete() 561 | except Error: 562 | pass 563 | 564 | def on_did_create_browser_context(self, context: BrowserContext) -> None: 565 | context.on("page", lambda page: self._all_pages.append(page)) 566 | if self._request and self._capture_trace: 567 | context.tracing.start( 568 | title=slugify(self._request.node.nodeid), 569 | screenshots=True, 570 | snapshots=True, 571 | sources=True, 572 | ) 573 | 574 | def on_will_close_browser_context(self, context: BrowserContext) -> None: 575 | if self._capture_trace: 576 | trace_path = Path(self._pw_artifacts_folder.name) / _create_guid() 577 | context.tracing.stop(path=trace_path) 578 | self._traces.append(str(trace_path)) 579 | else: 580 | context.tracing.stop() 581 | 582 | if self._pytestconfig.getoption("--screenshot") in ["on", "only-on-failure"]: 583 | for page in context.pages: 584 | try: 585 | screenshot_path = ( 586 | Path(self._pw_artifacts_folder.name) / _create_guid() 587 | ) 588 | page.screenshot( 589 | timeout=5000, 590 | path=screenshot_path, 591 | full_page=self._pytestconfig.getoption( 592 | "--full-page-screenshot" 593 | ), 594 | ) 595 | self._screenshots.append(str(screenshot_path)) 596 | except Error: 597 | pass 598 | 599 | 600 | def _create_guid() -> str: 601 | return hashlib.sha256(os.urandom(16)).hexdigest() 602 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | E501 4 | W503 5 | E302 6 | # Conflicts with black https://github.com/PyCQA/flake8/issues/1921 7 | E704 8 | [mypy] 9 | ignore_missing_imports = True 10 | python_version = 3.9 11 | warn_unused_ignores = False 12 | warn_redundant_casts = True 13 | warn_unused_configs = True 14 | check_untyped_defs = True 15 | disallow_untyped_defs = True 16 | [tool:pytest] 17 | addopts = -p no:asyncio -p no:playwright -p no:playwright-asyncio --runpytest subprocess -vv 18 | testpaths = 19 | tests 20 | -------------------------------------------------------------------------------- /tests/assets/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/playwright-pytest/364145f3b56749a72ffa57efc9804768d84291f8/tests/assets/django/__init__.py -------------------------------------------------------------------------------- /tests/assets/django/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = "123" 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS = ["*"] 17 | 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = [ 22 | "django.contrib.admin", 23 | "django.contrib.auth", 24 | "django.contrib.contenttypes", 25 | "django.contrib.sessions", 26 | "django.contrib.messages", 27 | "django.contrib.staticfiles", 28 | ] 29 | 30 | MIDDLEWARE = [ 31 | "django.middleware.security.SecurityMiddleware", 32 | "django.contrib.sessions.middleware.SessionMiddleware", 33 | "django.middleware.common.CommonMiddleware", 34 | "django.middleware.csrf.CsrfViewMiddleware", 35 | "django.contrib.auth.middleware.AuthenticationMiddleware", 36 | "django.contrib.messages.middleware.MessageMiddleware", 37 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 38 | ] 39 | 40 | ROOT_URLCONF = "proj1.urls" 41 | 42 | TEMPLATES = [ 43 | { 44 | "BACKEND": "django.template.backends.django.DjangoTemplates", 45 | "DIRS": [], 46 | "APP_DIRS": True, 47 | "OPTIONS": { 48 | "context_processors": [ 49 | "django.template.context_processors.debug", 50 | "django.template.context_processors.request", 51 | "django.contrib.auth.context_processors.auth", 52 | "django.contrib.messages.context_processors.messages", 53 | ], 54 | }, 55 | }, 56 | ] 57 | 58 | WSGI_APPLICATION = "proj1.wsgi.application" 59 | 60 | 61 | # Database 62 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 63 | 64 | DATABASES = { 65 | "default": { 66 | "ENGINE": "django.db.backends.sqlite3", 67 | "NAME": ":memory:", 68 | } 69 | } 70 | 71 | 72 | # Password validation 73 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 74 | 75 | AUTH_PASSWORD_VALIDATORS = [ 76 | { 77 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 78 | }, 79 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 80 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 81 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 82 | ] 83 | 84 | 85 | # Internationalization 86 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 87 | 88 | LANGUAGE_CODE = "en-us" 89 | 90 | TIME_ZONE = "UTC" 91 | 92 | USE_I18N = True 93 | 94 | USE_TZ = True 95 | 96 | 97 | # Static files (CSS, JavaScript, Images) 98 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 99 | 100 | STATIC_URL = "/static/" 101 | -------------------------------------------------------------------------------- /tests/assets/django/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path # type:ignore 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import sys 16 | import os 17 | 18 | pytest_plugins = ["pytester"] 19 | 20 | # The testdir fixture which we use to perform unit tests will set the home directory 21 | # To a temporary directory of the created test. This would result that the browsers will 22 | # be re-downloaded each time. By setting the pw browser path directory we can prevent that. 23 | if sys.platform == "darwin": 24 | playwright_browser_path = os.path.expanduser("~/Library/Caches/ms-playwright") 25 | elif sys.platform == "linux": 26 | playwright_browser_path = os.path.expanduser("~/.cache/ms-playwright") 27 | elif sys.platform == "win32": 28 | user_profile = os.environ["USERPROFILE"] 29 | playwright_browser_path = f"{user_profile}\\AppData\\Local\\ms-playwright" 30 | 31 | os.environ["PLAYWRIGHT_BROWSERS_PATH"] = playwright_browser_path 32 | -------------------------------------------------------------------------------- /tests/test_asyncio.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import signal 17 | import subprocess 18 | import sys 19 | 20 | import pytest 21 | 22 | 23 | @pytest.fixture 24 | def pytester(pytester: pytest.Pytester) -> pytest.Pytester: 25 | # Pytester internally in their constructor overrides the HOME and USERPROFILE env variables. This confuses Chromium hence we unset them. 26 | # See https://github.com/pytest-dev/pytest/blob/83536b4b0074ca35d90933d3ad46cb6efe7f5145/src/_pytest/pytester.py#L704-L705 27 | os.environ.pop("HOME", None) 28 | os.environ.pop("USERPROFILE", None) 29 | return pytester 30 | 31 | 32 | @pytest.fixture(autouse=True) 33 | def _add_async_marker(testdir: pytest.Testdir) -> None: 34 | testdir.makefile( 35 | ".ini", 36 | pytest=""" 37 | [pytest] 38 | addopts = -p no:playwright 39 | asyncio_default_test_loop_scope = session 40 | asyncio_default_fixture_loop_scope = session 41 | """, 42 | ) 43 | 44 | 45 | def test_default(testdir: pytest.Testdir) -> None: 46 | testdir.makepyfile( 47 | """ 48 | import pytest 49 | 50 | @pytest.mark.asyncio 51 | async def test_default(page, browser_name): 52 | assert browser_name == "chromium" 53 | user_agent = await page.evaluate("window.navigator.userAgent") 54 | assert "HeadlessChrome" in user_agent 55 | await page.set_content('bar') 56 | assert await page.query_selector("#foo") 57 | """ 58 | ) 59 | result = testdir.runpytest() 60 | result.assert_outcomes(passed=1) 61 | 62 | 63 | def test_slowmo(testdir: pytest.Testdir) -> None: 64 | testdir.makepyfile( 65 | """ 66 | from time import monotonic 67 | import pytest 68 | @pytest.mark.asyncio 69 | async def test_slowmo(page): 70 | email = "test@test.com" 71 | await page.set_content("") 72 | start_time = monotonic() 73 | await page.type("input", email) 74 | end_time = monotonic() 75 | assert end_time - start_time >= 1 76 | assert end_time - start_time < 2 77 | """ 78 | ) 79 | result = testdir.runpytest("--browser", "chromium", "--slowmo", "1000") 80 | result.assert_outcomes(passed=1) 81 | 82 | 83 | @pytest.mark.parametrize( 84 | "channel", 85 | [ 86 | "chrome", 87 | "msedge", 88 | ], 89 | ) 90 | def test_browser_channel(channel: str, testdir: pytest.Testdir) -> None: 91 | if channel == "msedge" and sys.platform == "linux": 92 | pytest.skip("msedge not supported on linux") 93 | testdir.makepyfile( 94 | f""" 95 | import pytest 96 | 97 | @pytest.mark.asyncio 98 | async def test_browser_channel(page, browser_name, browser_channel): 99 | assert browser_name == "chromium" 100 | assert browser_channel == "{channel}" 101 | """ 102 | ) 103 | result = testdir.runpytest("--browser-channel", channel) 104 | result.assert_outcomes(passed=1) 105 | 106 | 107 | def test_invalid_browser_channel(testdir: pytest.Testdir) -> None: 108 | testdir.makepyfile( 109 | """ 110 | import pytest 111 | 112 | @pytest.mark.asyncio 113 | async def test_browser_channel(page, browser_name, browser_channel): 114 | assert browser_name == "chromium" 115 | """ 116 | ) 117 | result = testdir.runpytest("--browser-channel", "not-exists") 118 | result.assert_outcomes(errors=1) 119 | assert "Unsupported chromium channel" in "\n".join(result.outlines) 120 | 121 | 122 | def test_multiple_browsers(testdir: pytest.Testdir) -> None: 123 | testdir.makepyfile( 124 | """ 125 | import pytest 126 | @pytest.mark.asyncio 127 | async def test_multiple_browsers(page): 128 | await page.set_content('bar') 129 | assert page.query_selector("#foo") 130 | """ 131 | ) 132 | result = testdir.runpytest( 133 | "--browser", "chromium", "--browser", "firefox", "--browser", "webkit" 134 | ) 135 | result.assert_outcomes(passed=3) 136 | 137 | 138 | def test_browser_context_args(testdir: pytest.Testdir) -> None: 139 | testdir.makeconftest( 140 | """ 141 | import pytest 142 | @pytest.fixture(scope="session") 143 | def browser_context_args(): 144 | return {"user_agent": "foobar"} 145 | """, 146 | ) 147 | testdir.makepyfile( 148 | """ 149 | import pytest 150 | @pytest.mark.asyncio 151 | async def test_browser_context_args(page): 152 | assert await page.evaluate("window.navigator.userAgent") == "foobar" 153 | """ 154 | ) 155 | result = testdir.runpytest() 156 | result.assert_outcomes(passed=1) 157 | 158 | 159 | def test_user_defined_browser_context_args(testdir: pytest.Testdir) -> None: 160 | testdir.makeconftest( 161 | """ 162 | import pytest 163 | 164 | @pytest.fixture(scope="session") 165 | def browser_context_args(): 166 | return {"user_agent": "foobar"} 167 | """, 168 | ) 169 | testdir.makepyfile( 170 | """ 171 | import pytest 172 | 173 | @pytest.mark.browser_context_args(user_agent="overwritten", locale="new-locale") 174 | @pytest.mark.asyncio 175 | async def test_browser_context_args(page): 176 | assert await page.evaluate("window.navigator.userAgent") == "overwritten" 177 | assert await page.evaluate("window.navigator.languages") == ["new-locale"] 178 | """ 179 | ) 180 | result = testdir.runpytest() 181 | result.assert_outcomes(passed=1) 182 | 183 | 184 | def test_user_defined_browser_context_args_clear_again(testdir: pytest.Testdir) -> None: 185 | testdir.makeconftest( 186 | """ 187 | import pytest 188 | 189 | @pytest.fixture(scope="session") 190 | def browser_context_args(): 191 | return {"user_agent": "foobar"} 192 | """, 193 | ) 194 | testdir.makepyfile( 195 | """ 196 | import pytest 197 | 198 | @pytest.mark.browser_context_args(user_agent="overwritten") 199 | @pytest.mark.asyncio 200 | async def test_browser_context_args(page): 201 | assert await page.evaluate("window.navigator.userAgent") == "overwritten" 202 | 203 | @pytest.mark.asyncio 204 | async def test_browser_context_args2(page): 205 | assert await page.evaluate("window.navigator.userAgent") == "foobar" 206 | """ 207 | ) 208 | result = testdir.runpytest() 209 | result.assert_outcomes(passed=2) 210 | 211 | 212 | def test_chromium(testdir: pytest.Testdir) -> None: 213 | testdir.makepyfile( 214 | """ 215 | import pytest 216 | @pytest.mark.asyncio 217 | async def test_is_chromium(page, browser_name, is_chromium, is_firefox, is_webkit): 218 | assert browser_name == "chromium" 219 | assert is_chromium 220 | assert is_firefox is False 221 | assert is_webkit is False 222 | """ 223 | ) 224 | result = testdir.runpytest() 225 | result.assert_outcomes(passed=1) 226 | 227 | 228 | def test_firefox(testdir: pytest.Testdir) -> None: 229 | testdir.makepyfile( 230 | """ 231 | import pytest 232 | @pytest.mark.asyncio 233 | async def test_is_firefox(page, browser_name, is_chromium, is_firefox, is_webkit): 234 | assert browser_name == "firefox" 235 | assert is_chromium is False 236 | assert is_firefox 237 | assert is_webkit is False 238 | """ 239 | ) 240 | result = testdir.runpytest("--browser", "firefox") 241 | result.assert_outcomes(passed=1) 242 | 243 | 244 | def test_webkit(testdir: pytest.Testdir) -> None: 245 | testdir.makepyfile( 246 | """ 247 | import pytest 248 | @pytest.mark.asyncio 249 | async def test_is_webkit(page, browser_name, is_chromium, is_firefox, is_webkit): 250 | assert browser_name == "webkit" 251 | assert is_chromium is False 252 | assert is_firefox is False 253 | assert is_webkit 254 | """ 255 | ) 256 | result = testdir.runpytest("--browser", "webkit") 257 | result.assert_outcomes(passed=1) 258 | 259 | 260 | def test_goto(testdir: pytest.Testdir) -> None: 261 | testdir.makepyfile( 262 | """ 263 | import pytest 264 | @pytest.mark.asyncio 265 | async def test_base_url(page, base_url): 266 | assert base_url == "https://example.com" 267 | await page.goto("/foobar") 268 | assert page.url == "https://example.com/foobar" 269 | await page.goto("https://example.org") 270 | assert page.url == "https://example.org/" 271 | """ 272 | ) 273 | result = testdir.runpytest("--base-url", "https://example.com") 274 | result.assert_outcomes(passed=1) 275 | 276 | 277 | def test_base_url_via_fixture(testdir: pytest.Testdir) -> None: 278 | testdir.makepyfile( 279 | """ 280 | import pytest 281 | 282 | @pytest.fixture(scope="session") 283 | def base_url(): 284 | return "https://example.com" 285 | 286 | @pytest.mark.asyncio 287 | async def test_base_url(page, base_url): 288 | assert base_url == "https://example.com" 289 | await page.goto("/foobar") 290 | assert page.url == "https://example.com/foobar" 291 | """ 292 | ) 293 | result = testdir.runpytest() 294 | result.assert_outcomes(passed=1) 295 | 296 | 297 | def test_skip_browsers(testdir: pytest.Testdir) -> None: 298 | testdir.makepyfile( 299 | """ 300 | import pytest 301 | 302 | @pytest.mark.skip_browser("firefox") 303 | @pytest.mark.asyncio 304 | async def test_base_url(page, browser_name): 305 | assert browser_name in ["chromium", "webkit"] 306 | """ 307 | ) 308 | result = testdir.runpytest( 309 | "--browser", "chromium", "--browser", "firefox", "--browser", "webkit" 310 | ) 311 | result.assert_outcomes(passed=2, skipped=1) 312 | 313 | 314 | def test_only_browser(testdir: pytest.Testdir) -> None: 315 | testdir.makepyfile( 316 | """ 317 | import pytest 318 | 319 | @pytest.mark.only_browser("firefox") 320 | @pytest.mark.asyncio 321 | async def test_base_url(page, browser_name): 322 | assert browser_name == "firefox" 323 | """ 324 | ) 325 | result = testdir.runpytest( 326 | "--browser", "chromium", "--browser", "firefox", "--browser", "webkit" 327 | ) 328 | result.assert_outcomes(passed=1, skipped=2) 329 | 330 | 331 | def test_parameterization(testdir: pytest.Testdir) -> None: 332 | testdir.makepyfile( 333 | """ 334 | import pytest 335 | @pytest.mark.asyncio 336 | async def test_all_browsers(page): 337 | pass 338 | 339 | @pytest.mark.asyncio 340 | async def test_without_browser(): 341 | pass 342 | """ 343 | ) 344 | result = testdir.runpytest( 345 | "--verbose", 346 | "--browser", 347 | "chromium", 348 | "--browser", 349 | "firefox", 350 | "--browser", 351 | "webkit", 352 | ) 353 | result.assert_outcomes(passed=4) 354 | assert "test_without_browser PASSED" in "\n".join(result.outlines) 355 | 356 | 357 | def test_xdist(testdir: pytest.Testdir) -> None: 358 | testdir.makepyfile( 359 | """ 360 | import pytest 361 | @pytest.mark.asyncio 362 | async def test_a(page): 363 | await page.set_content('a') 364 | await page.wait_for_timeout(200) 365 | assert page.query_selector("#foo") 366 | 367 | @pytest.mark.asyncio 368 | async def test_b(page): 369 | await page.wait_for_timeout(2000) 370 | await page.set_content('a') 371 | assert page.query_selector("#foo") 372 | 373 | @pytest.mark.asyncio 374 | async def test_c(page): 375 | await page.set_content('a') 376 | await page.wait_for_timeout(200) 377 | assert page.query_selector("#foo") 378 | 379 | @pytest.mark.asyncio 380 | async def test_d(page): 381 | await page.set_content('a') 382 | await page.wait_for_timeout(200) 383 | assert page.query_selector("#foo") 384 | """ 385 | ) 386 | result = testdir.runpytest( 387 | "--verbose", 388 | "--browser", 389 | "chromium", 390 | "--browser", 391 | "firefox", 392 | "--browser", 393 | "webkit", 394 | "--numprocesses", 395 | "2", 396 | ) 397 | result.assert_outcomes(passed=12) 398 | assert "gw0" in "\n".join(result.outlines) 399 | assert "gw1" in "\n".join(result.outlines) 400 | 401 | 402 | def test_xdist_should_not_print_any_warnings(testdir: pytest.Testdir) -> None: 403 | original = os.environ.get("PYTHONWARNINGS") 404 | os.environ["PYTHONWARNINGS"] = "always" 405 | try: 406 | testdir.makepyfile( 407 | """ 408 | import pytest 409 | 410 | @pytest.mark.asyncio 411 | async def test_default(page): 412 | pass 413 | """ 414 | ) 415 | result = testdir.runpytest( 416 | "--numprocesses", 417 | "2", 418 | ) 419 | result.assert_outcomes(passed=1) 420 | assert "ResourceWarning" not in "".join(result.stderr.lines) 421 | finally: 422 | if original is not None: 423 | os.environ["PYTHONWARNINGS"] = original 424 | else: 425 | del os.environ["PYTHONWARNINGS"] 426 | 427 | 428 | def test_headed(testdir: pytest.Testdir) -> None: 429 | testdir.makepyfile( 430 | """ 431 | import pytest 432 | @pytest.mark.asyncio 433 | async def test_base_url(page, browser_name): 434 | user_agent = await page.evaluate("window.navigator.userAgent") 435 | assert "HeadlessChrome" not in user_agent 436 | """ 437 | ) 438 | result = testdir.runpytest("--browser", "chromium", "--headed") 439 | result.assert_outcomes(passed=1) 440 | 441 | 442 | def test_invalid_browser_name(testdir: pytest.Testdir) -> None: 443 | testdir.makepyfile( 444 | """ 445 | async def test_base_url(page): 446 | pass 447 | """ 448 | ) 449 | result = testdir.runpytest("--browser", "test123") 450 | assert any(["--browser: invalid choice" in line for line in result.errlines]) 451 | 452 | 453 | def test_browser_context_args_device(testdir: pytest.Testdir) -> None: 454 | testdir.makeconftest( 455 | """ 456 | import pytest 457 | 458 | @pytest.fixture(scope="session") 459 | def browser_context_args(browser_context_args, playwright): 460 | iphone_11 = playwright.devices['iPhone 11 Pro'] 461 | return {**browser_context_args, **iphone_11} 462 | """, 463 | ) 464 | testdir.makepyfile( 465 | """ 466 | import pytest 467 | @pytest.mark.asyncio 468 | async def test_browser_context_args(page): 469 | assert "iPhone" in await page.evaluate("window.navigator.userAgent") 470 | """ 471 | ) 472 | result = testdir.runpytest() 473 | result.assert_outcomes(passed=1) 474 | 475 | 476 | def test_launch_persistent_context_session(testdir: pytest.Testdir) -> None: 477 | testdir.makeconftest( 478 | """ 479 | import pytest_asyncio 480 | from playwright.sync_api import BrowserType 481 | from typing import Dict 482 | 483 | @pytest_asyncio.fixture(scope="session") 484 | async def context( 485 | browser_type: BrowserType, 486 | browser_type_launch_args: Dict, 487 | browser_context_args: Dict 488 | ): 489 | context = await browser_type.launch_persistent_context("./foobar", **{ 490 | **browser_type_launch_args, 491 | **browser_context_args, 492 | "locale": "de-DE", 493 | }) 494 | yield context 495 | await context.close() 496 | """, 497 | ) 498 | testdir.makepyfile( 499 | """ 500 | import pytest 501 | @pytest.mark.asyncio 502 | async def test_browser_context_args(page): 503 | assert await page.evaluate("navigator.language") == "de-DE" 504 | """ 505 | ) 506 | result = testdir.runpytest() 507 | result.assert_outcomes(passed=1) 508 | 509 | 510 | def test_context_page_on_session_level(testdir: pytest.Testdir) -> None: 511 | testdir.makeconftest( 512 | """ 513 | import pytest 514 | from playwright.sync_api import Browser, BrowserContext 515 | from typing import Dict 516 | import pytest_asyncio 517 | 518 | @pytest_asyncio.fixture(scope="session") 519 | async def context( 520 | browser: Browser, 521 | browser_context_args: Dict 522 | ): 523 | context = await browser.new_context(**{ 524 | **browser_context_args, 525 | }) 526 | yield context 527 | await context.close() 528 | 529 | @pytest_asyncio.fixture(scope="session") 530 | async def page( 531 | context: BrowserContext, 532 | ): 533 | page = await context.new_page() 534 | yield page 535 | """, 536 | ) 537 | testdir.makepyfile( 538 | """ 539 | import pytest 540 | @pytest.mark.asyncio 541 | async def test_a(page): 542 | await page.goto("data:text/html,