diff --git a/.azure-pipelines/guardian/SDL/.gdnsuppress b/.azure-pipelines/guardian/SDL/.gdnsuppress new file mode 100644 index 000000000..2d1eca140 --- /dev/null +++ b/.azure-pipelines/guardian/SDL/.gdnsuppress @@ -0,0 +1,105 @@ +{ + "hydrated": false, + "properties": { + "helpUri": "https://eng.ms/docs/microsoft-security/security/azure-security/cloudai-security-fundamentals-engineering/security-integration/guardian-wiki/microsoft-guardian/general/suppressions", + "hydrationStatus": "This file does not contain identifying data. It is safe to check into your repo. To hydrate this file with identifying data, run `guardian hydrate --help` and follow the guidance." + }, + "version": "1.0.0", + "suppressionSets": { + "default": { + "name": "default", + "createdDate": "2024-02-06 21:00:02Z", + "lastUpdatedDate": "2024-02-06 21:00:02Z" + } + }, + "results": { + "bffa73d7410f5963f2538f06124ac5524c076da77867a0a19ccf60e508062dff": { + "signature": "bffa73d7410f5963f2538f06124ac5524c076da77867a0a19ccf60e508062dff", + "alternativeSignatures": [], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "964642e3cd0f022d5b63f5d3c467d034df4b1664e58dd132b6cd54c98bdae6a1": { + "signature": "964642e3cd0f022d5b63f5d3c467d034df4b1664e58dd132b6cd54c98bdae6a1", + "alternativeSignatures": [ + "f2d5560538c833834ca11e62fa6509618ab5454e1e71faf2847cb6fd07f4c4e0" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "5b0f97262e176cd67207fd63a2c74b9984829286e9229d10efc32d6b73130e37": { + "signature": "5b0f97262e176cd67207fd63a2c74b9984829286e9229d10efc32d6b73130e37", + "alternativeSignatures": [ + "29a18985690880b8caeebc339c7d2afd107510838cdc6561c1f5493478712581" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "636fe8a4848f24231e94dc13a238022f90a2894cd47a483e351e467eeb98de52": { + "signature": "636fe8a4848f24231e94dc13a238022f90a2894cd47a483e351e467eeb98de52", + "alternativeSignatures": [ + "e20632aa7941af4239fd857f802e05582c841fb9ae84e17c71ca6c7fc713246b" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "67ae7118600b0793ec3f0a58a753888e13ce4badcc15575614ee6aa622e5009c": { + "signature": "67ae7118600b0793ec3f0a58a753888e13ce4badcc15575614ee6aa622e5009c", + "alternativeSignatures": [ + "d1e68c2c7d9815f47331dd34c31db2634804b45b078a53d00843082747155ac9" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "9b7d0de03b9e0e0b2711e31df7c804528c357bf5aa2d689fb5a5f42750e84077": { + "signature": "9b7d0de03b9e0e0b2711e31df7c804528c357bf5aa2d689fb5a5f42750e84077", + "alternativeSignatures": [ + "e42bf5a49be2b1b815d1fde98ebf9d463fd2e70be1e8ca661f1210ce5b0c4953" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "06ecbceae8bfd10acf8c35f21af3926d172c7930f24a204cc58b61efc6c4c770": { + "signature": "06ecbceae8bfd10acf8c35f21af3926d172c7930f24a204cc58b61efc6c4c770", + "alternativeSignatures": [ + "035d6eb1444a809987923a39793fbb1ab9e4462405f38f94bc425c579705a9f2" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "c103671af429c32de81f2dc2e7a92999de88a517d916a8f75c8e37448bb2efe9": { + "signature": "c103671af429c32de81f2dc2e7a92999de88a517d916a8f75c8e37448bb2efe9", + "alternativeSignatures": [ + "3f904a503c12b62c2922900a2e689632e06272a815448939b1fdd435bcf74388" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + }, + "d1196285a4e64cf6f0f7f22a29bf5b33b540137da1a89ed2af0c880d2a8c1d64": { + "signature": "d1196285a4e64cf6f0f7f22a29bf5b33b540137da1a89ed2af0c880d2a8c1d64", + "alternativeSignatures": [ + "1c24094ca9e68a76a81c747853860e46fd139c9f47f0fdbad9133538e7d064b2" + ], + "memberOf": [ + "default" + ], + "createdDate": "2024-02-06 21:00:02Z" + } + } +} diff --git a/.azure-pipelines/publish.yml b/.azure-pipelines/publish.yml index 74c85ea53..10d6ead8b 100644 --- a/.azure-pipelines/publish.yml +++ b/.azure-pipelines/publish.yml @@ -1,4 +1,3 @@ -# don't trigger for Pull Requests pr: none trigger: @@ -6,33 +5,53 @@ trigger: include: - '*' -pool: - vmImage: ubuntu-latest +resources: + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release -steps: -- task: UsePythonVersion@0 - inputs: - versionSpec: '3.8' - displayName: 'Use Python' - -- script: | - python -m pip install --upgrade pip - pip install -r local-requirements.txt - pip install -e . - python setup.py bdist_wheel --all - displayName: 'Install & Build' - -- task: EsrpRelease@4 - inputs: - ConnectedServiceName: 'Playwright-ESRP' - Intent: 'PackageDistribution' - ContentType: 'PyPi' - ContentSource: 'Folder' - FolderLocation: './dist/' - WaitForReleaseCompletion: true - Owners: 'maxschmitt@microsoft.com' - Approvers: 'maxschmitt@microsoft.com' - ServiceEndpointUrl: 'https://api.esrp.microsoft.com' - MainPublisher: 'Playwright' - DomainTenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' - displayName: 'ESRP Release to PIP' +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + pool: + name: DevDivPlaywrightAzurePipelinesUbuntu2204 + os: linux + sdl: + sourceAnalysisPool: + name: DevDivPlaywrightAzurePipelinesWindows2022 + # The image must be windows-based due to restrictions of the SDL tools. See: https://aka.ms/AAo6v8e + # In the case of a windows build, this can be the same as the above pool image. + os: windows + suppression: + suppressionFile: $(Build.SourcesDirectory)\.azure-pipelines\guardian\SDL\.gdnsuppress + stages: + - stage: Stage + jobs: + - job: HostJob + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.8' + displayName: 'Use Python' + - script: | + python -m pip install --upgrade pip + pip install -r local-requirements.txt + pip install -e . + python setup.py bdist_wheel --all + displayName: 'Install & Build' + - task: EsrpRelease@4 + inputs: + ConnectedServiceName: 'Playwright-ESRP' + Intent: 'PackageDistribution' + ContentType: 'PyPi' + ContentSource: 'Folder' + FolderLocation: './dist/' + WaitForReleaseCompletion: true + Owners: 'maxschmitt@microsoft.com' + Approvers: 'maxschmitt@microsoft.com' + ServiceEndpointUrl: 'https://api.esrp.microsoft.com' + MainPublisher: 'Playwright' + DomainTenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' + displayName: 'ESRP Release to PIP' diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index b74140629..000000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: Bug Report -about: Something doesn't work like it should? Tell us! -title: "[BUG]" -labels: '' -assignees: '' - ---- - - - - - - - -### System info -- Playwright Version: [v1.XX] -- Operating System: [All, Windows 11, Ubuntu 20, macOS 13.2, etc.] -- Browser: [All, Chromium, Firefox, WebKit] -- Other info: - -### Source code - -- [ ] I provided exact source code that allows reproducing the issue locally. - - - - - - -**Link to the GitHub repository with the repro** - -[https://github.com/your_profile/playwright_issue_title] - -or - -**Test file (self-contained)** - -```python -from playwright.sync_api import sync_playwright -with sync_playwright() as p: - browser = p.chromium.launch() - page = browser.new_page() - # ... - browser.close() -``` - -**Steps** -- [Run the test] -- [...] - -**Expected** - -[Describe expected behavior] - -**Actual** - -[Describe actual behavior] diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..620ff4109 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,96 @@ +name: Bug Report 🪲 +description: Create a bug report to help us improve +title: '[Bug]: ' +body: + - type: markdown + attributes: + value: | + # Please follow these steps first: + - type: markdown + attributes: + value: | + ## Troubleshoot + If Playwright is not behaving the way you expect, we'd ask you to look at the [documentation](https://playwright.dev/python/docs/intro) and search the issue tracker for evidence supporting your expectation. + Please make reasonable efforts to troubleshoot and rule out issues with your code, the configuration, or any 3rd party libraries you might be using. + Playwright offers [several debugging tools](https://playwright.dev/python/docs/debug) that you can use to troubleshoot your issues. + - type: markdown + attributes: + value: | + ## Ask for help through appropriate channels + If you feel unsure about the cause of the problem, consider asking for help on for example [StackOverflow](https://stackoverflow.com/questions/ask) or our [Discord channel](https://aka.ms/playwright/discord) before posting a bug report. The issue tracker is not a help forum. + - type: markdown + attributes: + value: | + ## Make a minimal reproduction + To file the report, you will need a GitHub repository with a minimal (but complete) example and simple/clear steps on how to reproduce the bug. + The simpler you can make it, the more likely we are to successfully verify and fix the bug. + - type: markdown + attributes: + value: | + > [!IMPORTANT] + > Bug reports without a minimal reproduction will be rejected. + + --- + - type: input + id: version + attributes: + label: Version + description: | + The version of Playwright you are using. + Is it the [latest](https://github.com/microsoft/playwright-python/releases)? Test and see if the bug has already been fixed. + placeholder: ex. 1.41.1 + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Please link to a repository with a minimal reproduction and describe accurately how we can reproduce/verify the bug. + value: | + Example steps (replace with your own): + 1. Clone my repo at https://github.com//example + 2. pip install -r requirements.txt + 3. python test.py + 4. You should see the error come up + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: A description of what you expect to happen. + placeholder: I expect to see X or Y + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: Actual behavior + description: | + A clear and concise description of the unexpected behavior. + Please include any relevant output here, especially any error messages. + placeholder: A bug happened! + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else that might be relevant + validations: + required: false + - type: textarea + id: envinfo + attributes: + label: Environment + description: | + Please provide information about the environment you are running in. + value: | + - Operating System: [Ubuntu 22.04] + - CPU: [arm64] + - Browser: [All, Chromium, Firefox, WebKit] + - Python Version: [3.12] + - Other info: + render: Text + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 726c186c0..13b5b0a96 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,4 @@ +blank_issues_enabled: false contact_links: - name: Join our Discord Server url: https://aka.ms/playwright/discord diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 000000000..eaf31b8bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,29 @@ +name: Documentation 📖 +description: Submit a request to add or update documentation +title: '[Docs]: ' +labels: ['Documentation :book:'] +body: + - type: markdown + attributes: + value: | + ### Thank you for helping us improve our documentation! + Please be sure you are looking at [the Next version of the documentation](https://playwright.dev/python/docs/next/intro) before opening an issue here. + - type: textarea + id: links + attributes: + label: Page(s) + description: | + Links to one or more documentation pages that should be modified. + If you are reporting an issue with a specific section of a page, try to link directly to the nearest anchor. + If you are suggesting that a new page be created, link to the parent of the proposed page. + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: | + Describe the change you are requesting. + If the issue pertains to a single function or matcher, be sure to specify the entire call signature. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..efec3315c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,30 @@ +name: Feature Request 🚀 +description: Submit a proposal for a new feature +title: '[Feature]: ' +body: + - type: markdown + attributes: + value: | + ### Thank you for taking the time to suggest a new feature! + - type: textarea + id: description + attributes: + label: '🚀 Feature Request' + description: A clear and concise description of what the feature is. + validations: + required: true + - type: textarea + id: example + attributes: + label: Example + description: Describe how this feature would be used. + validations: + required: false + - type: textarea + id: motivation + attributes: + label: Motivation + description: | + Outline your motivation for the proposal. How will it make Playwright better? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 37ec8a7d2..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: Feature request -about: Request new features to be added -title: "[Feature]" -labels: '' -assignees: '' - ---- - -Let us know what functionality you'd like to see in Playwright and what your use case is. -Do you think others might benefit from this as well? diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 000000000..9615afdc8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,27 @@ +name: 'Questions / Help 💬' +description: If you have questions, please check StackOverflow or Discord +title: '[Please read the message below]' +labels: [':speech_balloon: Question'] +body: + - type: markdown + attributes: + value: | + ## Questions and Help 💬 + + This issue tracker is reserved for bug reports and feature requests. + + For anything else, such as questions or getting help, please see: + + - [The Playwright documentation](https://playwright.dev) + - [Our Discord server](https://aka.ms/playwright/discord) + - type: checkboxes + id: no-post + attributes: + label: | + Please do not submit this issue. + description: | + > [!IMPORTANT] + > This issue will be closed. + options: + - label: I understand + required: true diff --git a/.github/ISSUE_TEMPLATE/regression.md b/.github/ISSUE_TEMPLATE/regression.md deleted file mode 100644 index 44a903108..000000000 --- a/.github/ISSUE_TEMPLATE/regression.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Report regression -about: Functionality that used to work and does not any more -title: "[REGRESSION]: " -labels: '' -assignees: '' - ---- - -**Context:** -- GOOD Playwright Version: [what Playwright version worked nicely?] -- BAD Playwright Version: [what Playwright version doesn't work any more?] -- Operating System: [e.g. Windows, Linux or Mac] -- Extra: [any specific details about your environment] - -**Code Snippet** - -Help us help you! Put down a short code snippet that illustrates your bug and -that we can run and debug locally. For example: - -```python -from playwright.sync_api import sync_playwright -with sync_playwright() as p: - browser = p.chromium.launch() - page = browser.new_page() - # ... - browser.close() -``` - -**Describe the bug** - -Add any other details about the problem here. diff --git a/.github/ISSUE_TEMPLATE/regression.yml b/.github/ISSUE_TEMPLATE/regression.yml new file mode 100644 index 000000000..35879ad72 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/regression.yml @@ -0,0 +1,92 @@ +name: Report regression +description: Functionality that used to work and does not any more +title: "[Regression]: " + +body: + - type: markdown + attributes: + value: | + # Please follow these steps first: + - type: markdown + attributes: + value: | + ## Make a minimal reproduction + To file the report, you will need a GitHub repository with a minimal (but complete) example and simple/clear steps on how to reproduce the regression. + The simpler you can make it, the more likely we are to successfully verify and fix the regression. + - type: markdown + attributes: + value: | + > [!IMPORTANT] + > Regression reports without a minimal reproduction will be rejected. + + --- + - type: input + id: goodVersion + attributes: + label: Last Good Version + description: | + Last version of Playwright where the feature was working. + placeholder: ex. 1.40.1 + validations: + required: true + - type: input + id: badVersion + attributes: + label: First Bad Version + description: | + First version of Playwright where the feature was broken. + Is it the [latest](https://github.com/microsoft/playwright-python/releases)? Test and see if the regression has already been fixed. + placeholder: ex. 1.41.1 + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Please link to a repository with a minimal reproduction and describe accurately how we can reproduce/verify the bug. + value: | + Example steps (replace with your own): + 1. Clone my repo at https://github.com//example + 2. pip -r requirements.txt + 3. python test.py + 4. You should see the error come up + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: A description of what you expect to happen. + placeholder: I expect to see X or Y + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: Actual behavior + description: A clear and concise description of the unexpected behavior. + placeholder: A bug happened! + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else that might be relevant + validations: + required: false + - type: textarea + id: envinfo + attributes: + label: Environment + description: | + Please provide information about the environment you are running in. + value: | + - Operating System: [Ubuntu 22.04] + - CPU: [arm64] + - Browser: [All, Chromium, Firefox, WebKit] + - Python Version: [3.12] + - Other info: + render: Text + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..6a7695c06 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/publish_canary_docker.yml b/.github/workflows/publish_canary_docker.yml deleted file mode 100644 index 757dee7b5..000000000 --- a/.github/workflows/publish_canary_docker.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: "publish canary docker" - -on: - workflow_dispatch: - schedule: - - cron: "10 0 * * *" - -jobs: - publish-canary: - name: "Publish canary Docker" - runs-on: ubuntu-20.04 - if: github.repository == 'microsoft/playwright-python' - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Install dependencies & browsers - run: | - python -m pip install --upgrade pip - pip install -r local-requirements.txt - pip install -e . - - uses: azure/docker-login@v1 - with: - login-server: playwright.azurecr.io - username: playwright - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Set up Docker QEMU for arm64 docker builds - uses: docker/setup-qemu-action@v2 - with: - platforms: arm64 - - name: publish docker canary - run: ./utils/docker/publish_docker.sh canary diff --git a/.github/workflows/publish_release_docker.yml b/.github/workflows/publish_docker.yml similarity index 98% rename from .github/workflows/publish_release_docker.yml rename to .github/workflows/publish_docker.yml index 8bb9c5c7b..d69645bee 100644 --- a/.github/workflows/publish_release_docker.yml +++ b/.github/workflows/publish_docker.yml @@ -14,7 +14,7 @@ on: jobs: publish-docker-release: name: "publish to DockerHub" - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: github.repository == 'microsoft/playwright-python' steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index fc5380287..528570e8f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 🎭 [Playwright](https://playwright.dev) for Python [![PyPI version](https://badge.fury.io/py/playwright.svg)](https://pypi.python.org/pypi/playwright/) [![Anaconda version](https://img.shields.io/conda/v/microsoft/playwright)](https://anaconda.org/Microsoft/playwright) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) -Playwright is a Python library to automate [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) browsers with a single API. Playwright delivers automation that is **ever-green**, **capable**, **reliable** and **fast**. [See how Playwright is better](https://playwright.dev/python/docs/why-playwright). +Playwright is a Python library to automate [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) browsers with a single API. Playwright delivers automation that is **ever-green**, **capable**, **reliable** and **fast**. [See how Playwright is better](https://playwright.dev/python). | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 120.0.6099.28 | ✅ | ✅ | ✅ | +| Chromium 124.0.6367.8 | ✅ | ✅ | ✅ | | WebKit 17.4 | ✅ | ✅ | ✅ | -| Firefox 119.0 | ✅ | ✅ | ✅ | +| Firefox 124.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/examples/todomvc/mvctests/test_new_todo.py b/examples/todomvc/mvctests/test_new_todo.py index 15a0dbbbf..f9e069c7b 100644 --- a/examples/todomvc/mvctests/test_new_todo.py +++ b/examples/todomvc/mvctests/test_new_todo.py @@ -64,7 +64,7 @@ def test_new_todo_test_should_clear_text_input_field_when_an_item_is_added( assert_number_of_todos_in_local_storage(page, 1) -def test_new_todo_test_should_append_new_items_to_the_ottom_of_the_list( +def test_new_todo_test_should_append_new_items_to_the_bottom_of_the_list( page: Page, ) -> None: # Create 3 items. diff --git a/local-requirements.txt b/local-requirements.txt index f710e99b9..03c4a73b5 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,24 +1,24 @@ -auditwheel==5.4.0 +auditwheel==6.0.0 autobahn==23.1.2 -black==23.9.1 -flake8==6.1.0 -flaky==3.7.0 -mypy==1.8.0 -objgraph==3.6.0 -Pillow==10.0.1 +black==24.3.0 +flake8==7.0.0 +flaky==3.8.1 +mypy==1.9.0 +objgraph==3.6.1 +Pillow==10.3.0 pixelmatch==0.3.0 pre-commit==3.4.0 -pyOpenSSL==23.2.0 -pytest==7.4.2 +pyOpenSSL==24.1.0 +pytest==8.1.1 pytest-asyncio==0.21.1 -pytest-cov==4.1.0 -pytest-repeat==0.9.1 -pytest-timeout==2.1.0 -pytest-xdist==3.3.1 +pytest-cov==5.0.0 +pytest-repeat==0.9.3 +pytest-timeout==2.3.1 +pytest-xdist==3.5.0 requests==2.31.0 -service_identity==23.1.0 -setuptools==68.2.2 -twisted==23.10.0 -types-pyOpenSSL==23.2.0.2 -types-requests==2.31.0.10 -wheel==0.41.2 +service_identity==24.1.0 +setuptools==69.2.0 +twisted==24.3.0 +types-pyOpenSSL==24.0.0.20240311 +types-requests==2.31.0.20240311 +wheel==0.42.0 diff --git a/meta.yaml b/meta.yaml index 55db08c7a..2b113e15d 100644 --- a/meta.yaml +++ b/meta.yaml @@ -27,9 +27,9 @@ requirements: - setuptools_scm run: - python - - greenlet ==3.0.1 + - greenlet ==3.0.3 - pyee ==11.0.1 - - typing_extensions # [py<39] + test: # [build_platform == target_platform] requires: - pip diff --git a/playwright/__main__.py b/playwright/__main__.py index e012cc449..a5dfdad40 100644 --- a/playwright/__main__.py +++ b/playwright/__main__.py @@ -19,9 +19,9 @@ def main() -> None: - driver_executable = compute_driver_executable() + driver_executable, driver_cli = compute_driver_executable() completed_process = subprocess.run( - [str(driver_executable), *sys.argv[1:]], env=get_driver_env() + [driver_executable, driver_cli, *sys.argv[1:]], env=get_driver_env() ) sys.exit(completed_process.returncode) diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index c20f8d845..f06a6247e 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -12,13 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys -from typing import Any, Dict, List, Optional, Sequence, Union - -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal, TypedDict -else: # pragma: no cover - from typing_extensions import Literal, TypedDict +from typing import Any, Dict, List, Literal, Optional, Sequence, TypedDict, Union # These are the structures that we like keeping in a JSON form for their potential # reuse between SDKs / services. They are public and are a part of the diff --git a/playwright/_impl/_async_base.py b/playwright/_impl/_async_base.py index e497232d8..e9544b733 100644 --- a/playwright/_impl/_async_base.py +++ b/playwright/_impl/_async_base.py @@ -13,8 +13,9 @@ # limitations under the License. import asyncio +from contextlib import AbstractAsyncContextManager from types import TracebackType -from typing import Any, Callable, Generic, Type, TypeVar +from typing import Any, Callable, Generic, Optional, Type, TypeVar from playwright._impl._impl_to_api_mapping import ImplToApiMapping, ImplWrapper @@ -40,7 +41,7 @@ def is_done(self) -> bool: return self._future.done() -class AsyncEventContextManager(Generic[T]): +class AsyncEventContextManager(Generic[T], AbstractAsyncContextManager): def __init__(self, future: "asyncio.Future[T]") -> None: self._event = AsyncEventInfo[T](future) @@ -49,9 +50,9 @@ async def __aenter__(self) -> AsyncEventInfo[T]: async def __aexit__( self, - exc_type: Type[BaseException], - exc_val: BaseException, - exc_tb: TracebackType, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], ) -> None: if exc_val: self._event._cancel() diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index e7e6f19a8..c540ce4c0 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -14,7 +14,6 @@ import asyncio import json -import sys from pathlib import Path from types import SimpleNamespace from typing import ( @@ -23,6 +22,7 @@ Callable, Dict, List, + Literal, Optional, Pattern, Sequence, @@ -70,6 +70,7 @@ ) from playwright._impl._network import Request, Response, Route, serialize_headers from playwright._impl._page import BindingCall, Page, Worker +from playwright._impl._str_utils import escape_regex_flags from playwright._impl._tracing import Tracing from playwright._impl._waiter import Waiter from playwright._impl._web_error import WebError @@ -77,11 +78,6 @@ if TYPE_CHECKING: # pragma: no cover from playwright._impl._browser import Browser -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal - class BrowserContext(ChannelOwner): Events = SimpleNamespace( @@ -128,7 +124,7 @@ def __init__( ) self._channel.on( "route", - lambda params: asyncio.create_task( + lambda params: self._loop.create_task( self._on_route( from_channel(params.get("route")), ) @@ -196,6 +192,7 @@ def __init__( self.Events.Close, lambda context: self._closed_future.set_result(True) ) self._close_reason: Optional[str] = None + self._har_routers: List[HarRouter] = [] self._set_event_to_subscription_mapping( { BrowserContext.Events.Console: "console", @@ -219,10 +216,16 @@ def _on_page(self, page: Page) -> None: async def _on_route(self, route: Route) -> None: route._context = self + page = route.request._safe_page() route_handlers = self._routes.copy() for route_handler in route_handlers: + # If the page or the context was closed we stall all requests right away. + if (page and page._close_was_called) or self._close_was_called: + return if not route_handler.matches(route.request.url): continue + if route_handler not in self._routes: + continue if route_handler.will_expire: self._routes.remove(route_handler) try: @@ -236,7 +239,12 @@ async def _on_route(self, route: Route) -> None: ) if handled: return - await route._internal_continue(is_internal=True) + try: + # If the page is closed or unrouteAll() was called without waiting and interception disabled, + # the method will throw an error - silence it. + await route._internal_continue(is_internal=True) + except Exception: + pass def _on_binding(self, binding_call: BindingCall) -> None: func = self._bindings.get(binding_call._initializer["name"]) @@ -295,8 +303,34 @@ async def cookies(self, urls: Union[str, Sequence[str]] = None) -> List[Cookie]: async def add_cookies(self, cookies: Sequence[SetCookieParam]) -> None: await self._channel.send("addCookies", dict(cookies=cookies)) - async def clear_cookies(self) -> None: - await self._channel.send("clearCookies") + async def clear_cookies( + self, + name: Union[str, Pattern[str]] = None, + domain: Union[str, Pattern[str]] = None, + path: Union[str, Pattern[str]] = None, + ) -> None: + await self._channel.send( + "clearCookies", + { + "name": name if isinstance(name, str) else None, + "nameRegexSource": name.pattern if isinstance(name, Pattern) else None, + "nameRegexFlags": escape_regex_flags(name) + if isinstance(name, Pattern) + else None, + "domain": domain if isinstance(domain, str) else None, + "domainRegexSource": domain.pattern + if isinstance(domain, Pattern) + else None, + "domainRegexFlags": escape_regex_flags(domain) + if isinstance(domain, Pattern) + else None, + "path": path if isinstance(path, str) else None, + "pathRegexSource": path.pattern if isinstance(path, Pattern) else None, + "pathRegexFlags": escape_regex_flags(path) + if isinstance(path, Pattern) + else None, + }, + ) async def grant_permissions( self, permissions: Sequence[str], origin: str = None @@ -361,13 +395,37 @@ async def route( async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None ) -> None: - self._routes = list( - filter( - lambda r: r.matcher.match != url or (handler and r.handler != handler), - self._routes, - ) - ) + removed = [] + remaining = [] + for route in self._routes: + if route.matcher.match != url or (handler and route.handler != handler): + remaining.append(route) + else: + removed.append(route) + await self._unroute_internal(removed, remaining, "default") + + async def _unroute_internal( + self, + removed: List[RouteHandler], + remaining: List[RouteHandler], + behavior: Literal["default", "ignoreErrors", "wait"] = None, + ) -> None: + self._routes = remaining await self._update_interception_patterns() + if behavior is None or behavior == "default": + return + await asyncio.gather(*map(lambda router: router.stop(behavior), removed)) # type: ignore + + def _dispose_har_routers(self) -> None: + for router in self._har_routers: + router.dispose() + self._har_routers = [] + + async def unroute_all( + self, behavior: Literal["default", "ignoreErrors", "wait"] = None + ) -> None: + await self._unroute_internal(self._routes, [], behavior) + self._dispose_har_routers() async def _record_into_har( self, @@ -419,6 +477,7 @@ async def route_from_har( not_found_action=notFound or "abort", url_matcher=url, ) + self._har_routers.append(router) await router.add_context_route(self) async def _update_interception_patterns(self) -> None: @@ -450,6 +509,7 @@ def _on_close(self) -> None: if self._browser: self._browser._contexts.remove(self) + self._dispose_har_routers() self.emit(BrowserContext.Events.Close, self) async def close(self, reason: str = None) -> None: diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index f1e0dd34f..937ab3f8b 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -28,16 +28,17 @@ List, Mapping, Optional, + TypedDict, Union, cast, ) -from greenlet import greenlet from pyee import EventEmitter from pyee.asyncio import AsyncIOEventEmitter import playwright -from playwright._impl._errors import TargetClosedError +from playwright._impl._errors import TargetClosedError, rewrite_error +from playwright._impl._greenlets import EventGreenlet from playwright._impl._helper import Error, ParsedMessagePayload, parse_error from playwright._impl._transport import Transport @@ -46,18 +47,13 @@ from playwright._impl._playwright import Playwright -if sys.version_info >= (3, 8): # pragma: no cover - from typing import TypedDict -else: # pragma: no cover - from typing_extensions import TypedDict - - class Channel(AsyncIOEventEmitter): def __init__(self, connection: "Connection", object: "ChannelOwner") -> None: super().__init__() self._connection = connection self._guid = object._guid self._object = object + self.on("error", lambda exc: self._connection._on_event_listener_error(exc)) async def send(self, method: str, params: Dict = None) -> Any: return await self._connection.wrap_api_call( @@ -82,13 +78,13 @@ async def inner_send( ) -> Any: if params is None: params = {} - callback = self._connection._send_message_to_server( - self._object, method, _filter_none(params) - ) if self._connection._error: error = self._connection._error self._connection._error = None raise error + callback = self._connection._send_message_to_server( + self._object, method, _filter_none(params) + ) done, _ = await asyncio.wait( { self._connection._transport.on_error_future, @@ -339,20 +335,22 @@ def _send_message_to_server( "line": frames[0]["line"], "column": frames[0]["column"], } - if len(frames) > 0 + if frames else None ) + metadata = { + "wallTime": int(datetime.datetime.now().timestamp() * 1000), + "apiName": stack_trace_information["apiName"], + "internal": not stack_trace_information["apiName"], + } + if location: + metadata["location"] = location # type: ignore message = { "id": id, "guid": object._guid, "method": method, "params": self._replace_channels_with_guids(params), - "metadata": { - "wallTime": int(datetime.datetime.now().timestamp() * 1000), - "apiName": stack_trace_information["apiName"], - "location": location, - "internal": not stack_trace_information["apiName"], - }, + "metadata": metadata, } if self._tracing_count > 0 and frames and object._guid != "localUtils": self.local_utils.add_stack_to_tracing_no_reply(id, frames) @@ -376,11 +374,12 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: return error = msg.get("error") if error and not msg.get("result"): - parsed_error = parse_error(error["error"]) # type: ignore + parsed_error = parse_error( + error["error"], format_call_log(msg.get("log")) # type: ignore + ) parsed_error._stack = "".join( traceback.format_list(callback.stack_trace)[-10:] ) - parsed_error._message += format_call_log(msg.get("log")) # type: ignore callback.future.set_exception(parsed_error) else: result = self._replace_guids_with_channels(msg.get("result")) @@ -419,10 +418,22 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: try: if self._is_sync: for listener in object._channel.listeners(method): + # Event handlers like route/locatorHandlerTriggered require us to perform async work. + # In order to report their potential errors to the user, we need to catch it and store it in the connection + def _done_callback(future: asyncio.Future) -> None: + exc = future.exception() + if exc: + self._on_event_listener_error(exc) + + def _listener_with_error_handler_attached(params: Any) -> None: + potential_future = listener(params) + if asyncio.isfuture(potential_future): + potential_future.add_done_callback(_done_callback) + # Each event handler is a potentilly blocking context, create a fiber for each # and switch to them in order, until they block inside and pass control to each # other and then eventually back to dispatcher as listener functions return. - g = greenlet(listener) + g = EventGreenlet(_listener_with_error_handler_attached) if should_replace_guids_with_channels: g.switch(self._replace_guids_with_channels(params)) else: @@ -435,9 +446,13 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: else: object._channel.emit(method, params) except BaseException as exc: - print("Error occurred in event listener", file=sys.stderr) - traceback.print_exc() - self._error = exc + self._on_event_listener_error(exc) + + def _on_event_listener_error(self, exc: BaseException) -> None: + print("Error occurred in event listener", file=sys.stderr) + traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr) + # Save the error to throw at the next API call. This "replicates" unhandled rejection in Node.js. + self._error = exc def _create_remote_object( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict @@ -490,9 +505,12 @@ async def wrap_api_call( return await cb() task = asyncio.current_task(self._loop) st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack()) - self._api_zone.set(_extract_stack_trace_information_from_stack(st, is_internal)) + parsed_st = _extract_stack_trace_information_from_stack(st, is_internal) + self._api_zone.set(parsed_st) try: return await cb() + except Exception as error: + raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None finally: self._api_zone.set(None) @@ -503,9 +521,12 @@ def wrap_api_call_sync( return cb() task = asyncio.current_task(self._loop) st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack()) - self._api_zone.set(_extract_stack_trace_information_from_stack(st, is_internal)) + parsed_st = _extract_stack_trace_information_from_stack(st, is_internal) + self._api_zone.set(parsed_st) try: return cb() + except Exception as error: + raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None finally: self._api_zone.set(None) @@ -532,7 +553,7 @@ class ParsedStackTrace(TypedDict): def _extract_stack_trace_information_from_stack( st: List[inspect.FrameInfo], is_internal: bool -) -> Optional[ParsedStackTrace]: +) -> ParsedStackTrace: playwright_module_path = str(Path(playwright.__file__).parents[0]) last_internal_api_name = "" api_name = "" diff --git a/playwright/_impl/_driver.py b/playwright/_impl/_driver.py index d8004d296..9e8cdc1e7 100644 --- a/playwright/_impl/_driver.py +++ b/playwright/_impl/_driver.py @@ -16,17 +16,18 @@ import os import sys from pathlib import Path +from typing import Tuple import playwright from playwright._repo_version import version -def compute_driver_executable() -> Path: - package_path = Path(inspect.getfile(playwright)).parent - platform = sys.platform - if platform == "win32": - return package_path / "driver" / "playwright.cmd" - return package_path / "driver" / "playwright.sh" +def compute_driver_executable() -> Tuple[str, str]: + driver_path = Path(inspect.getfile(playwright)).parent / "driver" + cli_path = str(driver_path / "package" / "cli.js") + if sys.platform == "win32": + return (str(driver_path / "node.exe"), cli_path) + return (os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node")), cli_path) def get_driver_env() -> dict: diff --git a/playwright/_impl/_element_handle.py b/playwright/_impl/_element_handle.py index 6c585bb0d..74e5bdff9 100644 --- a/playwright/_impl/_element_handle.py +++ b/playwright/_impl/_element_handle.py @@ -13,7 +13,6 @@ # limitations under the License. import base64 -import sys from pathlib import Path from typing import ( TYPE_CHECKING, @@ -21,6 +20,7 @@ Callable, Dict, List, + Literal, Optional, Sequence, Union, @@ -45,11 +45,6 @@ ) from playwright._impl._set_input_files_helpers import convert_input_files -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal - if TYPE_CHECKING: # pragma: no cover from playwright._impl._frame import Frame from playwright._impl._locator import Locator @@ -298,6 +293,7 @@ async def screenshot( scale: Literal["css", "device"] = None, mask: Sequence["Locator"] = None, maskColor: str = None, + style: str = None, ) -> bytes: params = locals_to_params(locals()) if "path" in params: diff --git a/playwright/_impl/_errors.py b/playwright/_impl/_errors.py index 9bd6ab901..c47d918ef 100644 --- a/playwright/_impl/_errors.py +++ b/playwright/_impl/_errors.py @@ -50,3 +50,11 @@ class TimeoutError(Error): class TargetClosedError(Error): def __init__(self, message: str = None) -> None: super().__init__(message or "Target page, context or browser has been closed") + + +def rewrite_error(error: Exception, message: str) -> Exception: + rewritten_exc = type(error)(message) + if isinstance(rewritten_exc, Error) and isinstance(error, Error): + rewritten_exc._name = error.name + rewritten_exc._stack = error.stack + return rewritten_exc diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index 75047ff79..bfeef1489 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -13,7 +13,6 @@ # limitations under the License. import asyncio -import sys from pathlib import Path from typing import ( TYPE_CHECKING, @@ -43,6 +42,7 @@ DocumentLoadState, FrameNavigatedEvent, KeyboardModifier, + Literal, MouseButton, URLMatch, URLMatcher, @@ -53,6 +53,7 @@ from playwright._impl._js_handle import ( JSHandle, Serializable, + add_source_url_to_script, parse_result, serialize_argument, ) @@ -72,11 +73,6 @@ from playwright._impl._set_input_files_helpers import convert_input_files from playwright._impl._waiter import Waiter -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal - if TYPE_CHECKING: # pragma: no cover from playwright._impl._page import Page @@ -455,10 +451,8 @@ async def add_script_tag( ) -> ElementHandle: params = locals_to_params(locals()) if path: - params["content"] = ( - (await async_readfile(path)).decode() - + "\n//# sourceURL=" - + str(Path(path)) + params["content"] = add_source_url_to_script( + (await async_readfile(path)).decode(), path ) del params["path"] return from_channel(await self._channel.send("addScriptTag", params)) diff --git a/playwright/_impl/_glob.py b/playwright/_impl/_glob.py new file mode 100644 index 000000000..2d899a789 --- /dev/null +++ b/playwright/_impl/_glob.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import re + +# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping +escaped_chars = {"$", "^", "+", ".", "*", "(", ")", "|", "\\", "?", "{", "}", "[", "]"} + + +def glob_to_regex(glob: str) -> "re.Pattern[str]": + tokens = ["^"] + in_group = False + + i = 0 + while i < len(glob): + c = glob[i] + if c == "\\" and i + 1 < len(glob): + char = glob[i + 1] + tokens.append("\\" + char if char in escaped_chars else char) + i += 1 + elif c == "*": + before_deep = glob[i - 1] if i > 0 else None + star_count = 1 + while i + 1 < len(glob) and glob[i + 1] == "*": + star_count += 1 + i += 1 + after_deep = glob[i + 1] if i + 1 < len(glob) else None + is_deep = ( + star_count > 1 + and (before_deep == "/" or before_deep is None) + and (after_deep == "/" or after_deep is None) + ) + if is_deep: + tokens.append("((?:[^/]*(?:/|$))*)") + i += 1 + else: + tokens.append("([^/]*)") + else: + if c == "?": + tokens.append(".") + elif c == "[": + tokens.append("[") + elif c == "]": + tokens.append("]") + elif c == "{": + in_group = True + tokens.append("(") + elif c == "}": + in_group = False + tokens.append(")") + elif c == "," and in_group: + tokens.append("|") + else: + tokens.append("\\" + c if c in escaped_chars else c) + i += 1 + + tokens.append("$") + return re.compile("".join(tokens)) diff --git a/playwright/_impl/_greenlets.py b/playwright/_impl/_greenlets.py new file mode 100644 index 000000000..a381e6e53 --- /dev/null +++ b/playwright/_impl/_greenlets.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +from typing import Tuple + +import greenlet + + +def _greenlet_trace_callback( + event: str, args: Tuple[greenlet.greenlet, greenlet.greenlet] +) -> None: + if event in ("switch", "throw"): + origin, target = args + print(f"Transfer from {origin} to {target} with {event}") + + +if os.environ.get("INTERNAL_PW_GREENLET_DEBUG"): + greenlet.settrace(_greenlet_trace_callback) + + +class MainGreenlet(greenlet.greenlet): + def __str__(self) -> str: + return "" + + +class RouteGreenlet(greenlet.greenlet): + def __str__(self) -> str: + return "" + + +class LocatorHandlerGreenlet(greenlet.greenlet): + def __str__(self) -> str: + return "" + + +class EventGreenlet(greenlet.greenlet): + def __str__(self) -> str: + return "" diff --git a/playwright/_impl/_har_router.py b/playwright/_impl/_har_router.py index a96ba70bf..33ff37871 100644 --- a/playwright/_impl/_har_router.py +++ b/playwright/_impl/_har_router.py @@ -75,6 +75,13 @@ async def _handle(self, route: "Route") -> None: return if action == "fulfill": + # If the response status is -1, the request was canceled or stalled, so we just stall it here. + # See https://github.com/microsoft/playwright/issues/29311. + # TODO: it'd be better to abort such requests, but then we likely need to respect the timing, + # because the request might have been stalled for a long time until the very end of the + # test when HAR was recorded but we'd abort it immediately. + if response.get("status") == -1: + return body = response["body"] assert body is not None await route.fulfill( @@ -102,16 +109,14 @@ async def add_context_route(self, context: "BrowserContext") -> None: url=self._options_url_match or "**/*", handler=lambda route, _: asyncio.create_task(self._handle(route)), ) - context.once("close", lambda _: self._dispose()) async def add_page_route(self, page: "Page") -> None: await page.route( url=self._options_url_match or "**/*", handler=lambda route, _: asyncio.create_task(self._handle(route)), ) - page.once("close", lambda _: self._dispose()) - def _dispose(self) -> None: + def dispose(self) -> None: asyncio.create_task( self._local_utils._channel.send("harClose", {"harId": self._har_id}) ) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 1b4902613..0e6b91cd2 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -12,12 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. import asyncio -import fnmatch -import inspect import math import os import re -import sys import time import traceback from pathlib import Path @@ -26,29 +23,25 @@ TYPE_CHECKING, Any, Callable, - Coroutine, Dict, List, + Literal, Optional, Pattern, + Set, + TypedDict, TypeVar, Union, cast, ) from urllib.parse import urljoin -from greenlet import greenlet - from playwright._impl._api_structures import NameValue from playwright._impl._errors import Error, TargetClosedError, TimeoutError +from playwright._impl._glob import glob_to_regex +from playwright._impl._greenlets import RouteGreenlet from playwright._impl._str_utils import escape_regex_flags -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal, TypedDict -else: # pragma: no cover - from typing_extensions import Literal, TypedDict - - if TYPE_CHECKING: # pragma: no cover from playwright._impl._api_structures import HeadersArray from playwright._impl._network import Request, Response, Route @@ -149,7 +142,7 @@ def __init__(self, base_url: Union[str, None], match: URLMatch) -> None: if isinstance(match, str): if base_url and not match.startswith("*"): match = urljoin(base_url, match) - regex = fnmatch.translate(match) + regex = glob_to_regex(match) self._regex_obj = re.compile(regex) elif isinstance(match, Pattern): self._regex_obj = match @@ -217,26 +210,24 @@ def serialize_error(ex: Exception, tb: Optional[TracebackType]) -> ErrorPayload: ) -def parse_error(error: ErrorPayload) -> Error: +def parse_error(error: ErrorPayload, log: Optional[str] = None) -> Error: base_error_class = Error if error.get("name") == "TimeoutError": base_error_class = TimeoutError if error.get("name") == "TargetClosedError": base_error_class = TargetClosedError - exc = base_error_class(cast(str, patch_error_message(error.get("message")))) + if not log: + log = "" + exc = base_error_class(patch_error_message(error["message"]) + log) exc._name = error["name"] exc._stack = error["stack"] return exc -def patch_error_message(message: Optional[str]) -> Optional[str]: - if message is None: - return None - +def patch_error_message(message: str) -> str: match = re.match(r"(\w+)(: expected .*)", message) if match: message = to_snake_case(match.group(1)) + match.group(2) - assert message is not None message = message.replace( "Pass { acceptDownloads: true }", "Pass { accept_downloads: True }" ) @@ -257,6 +248,15 @@ def monotonic_time() -> int: return math.floor(time.monotonic() * 1000) +class RouteHandlerInvocation: + complete: "asyncio.Future" + route: "Route" + + def __init__(self, complete: "asyncio.Future", route: "Route") -> None: + self.complete = complete + self.route = route + + class RouteHandler: def __init__( self, @@ -270,32 +270,67 @@ def __init__( self._times = times if times else math.inf self._handled_count = 0 self._is_sync = is_sync + self._ignore_exception = False + self._active_invocations: Set[RouteHandlerInvocation] = set() def matches(self, request_url: str) -> bool: return self.matcher.matches(request_url) async def handle(self, route: "Route") -> bool: + handler_invocation = RouteHandlerInvocation( + asyncio.get_running_loop().create_future(), route + ) + self._active_invocations.add(handler_invocation) + try: + return await self._handle_internal(route) + except Exception as e: + # If the handler was stopped (without waiting for completion), we ignore all exceptions. + if self._ignore_exception: + return False + raise e + finally: + handler_invocation.complete.set_result(None) + self._active_invocations.remove(handler_invocation) + + async def _handle_internal(self, route: "Route") -> bool: handled_future = route._start_handling() - handler_task = [] - - def impl() -> None: - self._handled_count += 1 - result = cast( - Callable[["Route", "Request"], Union[Coroutine, Any]], self.handler - )(route, route.request) - if inspect.iscoroutine(result): - handler_task.append(asyncio.create_task(result)) - - # As with event handlers, each route handler is a potentially blocking context - # so it needs a fiber. + + self._handled_count += 1 if self._is_sync: - g = greenlet(impl) + handler_finished_future = route._loop.create_future() + + def _handler() -> None: + try: + self.handler(route, route.request) # type: ignore + handler_finished_future.set_result(None) + except Exception as e: + handler_finished_future.set_exception(e) + + # As with event handlers, each route handler is a potentially blocking context + # so it needs a fiber. + g = RouteGreenlet(_handler) g.switch() + await handler_finished_future else: - impl() - - [handled, *_] = await asyncio.gather(handled_future, *handler_task) - return handled + coro_or_future = self.handler(route, route.request) # type: ignore + if coro_or_future: + # separate task so that we get a proper stack trace for exceptions / tracing api_name extraction + await asyncio.ensure_future(coro_or_future) + return await handled_future + + async def stop(self, behavior: Literal["ignoreErrors", "wait"]) -> None: + # When a handler is manually unrouted or its page/context is closed we either + # - wait for the current handler invocations to finish + # - or do not wait, if the user opted out of it, but swallow all exceptions + # that happen after the unroute/close. + if behavior == "ignoreErrors": + self._ignore_exception = True + else: + tasks = [] + for activation in self._active_invocations: + if not activation.route._did_throw: + tasks.append(activation.complete) + await asyncio.gather(*tasks) @property def will_expire(self) -> bool: diff --git a/playwright/_impl/_js_handle.py b/playwright/_impl/_js_handle.py index 4bd8146b1..415d79a76 100644 --- a/playwright/_impl/_js_handle.py +++ b/playwright/_impl/_js_handle.py @@ -13,12 +13,14 @@ # limitations under the License. import collections.abc +import datetime import math -from datetime import datetime -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from urllib.parse import ParseResult, urlparse, urlunparse from playwright._impl._connection import Channel, ChannelOwner, from_channel +from playwright._impl._errors import is_target_closed_error from playwright._impl._map import Map if TYPE_CHECKING: # pragma: no cover @@ -101,7 +103,11 @@ def as_element(self) -> Optional["ElementHandle"]: return None async def dispose(self) -> None: - await self._channel.send("dispose") + try: + await self._channel.send("dispose") + except Exception as e: + if not is_target_closed_error(e): + raise e async def json_value(self) -> Any: return parse_result(await self._channel.send("jsonValue")) @@ -127,8 +133,13 @@ def serialize_value( return dict(v="-0") if math.isnan(value): return dict(v="NaN") - if isinstance(value, datetime): - return dict(d=value.isoformat() + "Z") + if isinstance(value, datetime.datetime): + # Node.js Date objects are always in UTC. + return { + "d": datetime.datetime.strftime( + value.astimezone(datetime.timezone.utc), "%Y-%m-%dT%H:%M:%S.%fZ" + ) + } if isinstance(value, bool): return {"b": value} if isinstance(value, (int, float)): @@ -204,7 +215,10 @@ def parse_value(value: Any, refs: Optional[Dict[int, Any]] = None) -> Any: return a if "d" in value: - return datetime.fromisoformat(value["d"][:-1]) + # Node.js Date objects are always in UTC. + return datetime.datetime.strptime( + value["d"], "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=datetime.timezone.utc) if "o" in value: o: Dict = {} @@ -226,3 +240,7 @@ def parse_value(value: Any, refs: Optional[Dict[int, Any]] = None) -> Any: def parse_result(result: Any) -> Any: return parse_value(result) + + +def add_source_url_to_script(source: str, path: Union[str, Path]) -> str: + return source + "\n//# sourceURL=" + str(path).replace("\n", "") diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 55955d089..c5e92d874 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -14,7 +14,6 @@ import json import pathlib -import sys from typing import ( TYPE_CHECKING, Any, @@ -22,6 +21,7 @@ Callable, Dict, List, + Literal, Optional, Pattern, Sequence, @@ -53,11 +53,6 @@ escape_for_text_selector, ) -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal - if TYPE_CHECKING: # pragma: no cover from playwright._impl._frame import Frame from playwright._impl._js_handle import JSHandle @@ -330,6 +325,10 @@ def last(self) -> "Locator": def nth(self, index: int) -> "Locator": return Locator(self._frame, f"{self._selector} >> nth={index}") + @property + def content_frame(self) -> "FrameLocator": + return FrameLocator(self._frame, self._selector) + def filter( self, hasText: Union[str, Pattern[str]] = None, @@ -523,6 +522,7 @@ async def screenshot( scale: Literal["css", "device"] = None, mask: Sequence["Locator"] = None, maskColor: str = None, + style: str = None, ) -> bytes: params = locals_to_params(locals()) return await self._with_element( @@ -821,6 +821,10 @@ def first(self) -> "FrameLocator": def last(self) -> "FrameLocator": return FrameLocator(self._frame, f"{self._frame_selector} >> nth=-1") + @property + def owner(self) -> "Locator": + return Locator(self._frame, self._frame_selector) + def nth(self, index: int) -> "FrameLocator": return FrameLocator(self._frame, f"{self._frame_selector} >> nth={index}") diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 102767cf6..1fe436c80 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -18,7 +18,6 @@ import json import json as json_utils import mimetypes -import sys from collections import defaultdict from pathlib import Path from types import SimpleNamespace @@ -30,15 +29,10 @@ Dict, List, Optional, + TypedDict, Union, cast, ) - -if sys.version_info >= (3, 8): # pragma: no cover - from typing import TypedDict -else: # pragma: no cover - from typing_extensions import TypedDict - from urllib import parse from playwright._impl._api_structures import ( @@ -175,7 +169,7 @@ def post_data_json(self) -> Optional[Any]: if not post_data: return None content_type = self.headers["content-type"] - if content_type == "application/x-www-form-urlencoded": + if "application/x-www-form-urlencoded" in content_type: return dict(parse.parse_qsl(post_data)) try: return json.loads(post_data) @@ -267,6 +261,12 @@ def _target_closed_future(self) -> asyncio.Future: return asyncio.Future() return page._closed_or_crashed_future + def _safe_page(self) -> "Optional[Page]": + frame = from_nullable_channel(self._initializer.get("frame")) + if not frame: + return None + return cast("Frame", frame)._page + class Route(ChannelOwner): def __init__( @@ -275,6 +275,7 @@ def __init__( super().__init__(parent, type, guid, initializer) self._handling_future: Optional[asyncio.Future["bool"]] = None self._context: "BrowserContext" = cast("BrowserContext", None) + self._did_throw = False def _start_handling(self) -> "asyncio.Future[bool]": self._handling_future = asyncio.Future() @@ -298,17 +299,17 @@ def request(self) -> Request: return from_channel(self._initializer["request"]) async def abort(self, errorCode: str = None) -> None: - self._check_not_handled() - await self._race_with_page_close( - self._channel.send( - "abort", - { - "errorCode": errorCode, - "requestUrl": self.request._initializer["url"], - }, + await self._handle_route( + lambda: self._race_with_page_close( + self._channel.send( + "abort", + { + "errorCode": errorCode, + "requestUrl": self.request._initializer["url"], + }, + ) ) ) - self._report_handled(True) async def fulfill( self, @@ -320,7 +321,22 @@ async def fulfill( contentType: str = None, response: "APIResponse" = None, ) -> None: - self._check_not_handled() + await self._handle_route( + lambda: self._inner_fulfill( + status, headers, body, json, path, contentType, response + ) + ) + + async def _inner_fulfill( + self, + status: int = None, + headers: Dict[str, str] = None, + body: Union[str, bytes] = None, + json: Any = None, + path: Union[str, Path] = None, + contentType: str = None, + response: "APIResponse" = None, + ) -> None: params = locals_to_params(locals()) if json is not None: @@ -375,7 +391,15 @@ async def fulfill( params["requestUrl"] = self.request._initializer["url"] await self._race_with_page_close(self._channel.send("fulfill", params)) - self._report_handled(True) + + async def _handle_route(self, callback: Callable) -> None: + self._check_not_handled() + try: + await callback() + self._report_handled(True) + except Exception as e: + self._did_throw = True + raise e async def fetch( self, @@ -418,10 +442,12 @@ async def continue_( postData: Union[Any, str, bytes] = None, ) -> None: overrides = cast(FallbackOverrideParameters, locals_to_params(locals())) - self._check_not_handled() - self.request._apply_fallback_overrides(overrides) - await self._internal_continue() - self._report_handled(True) + + async def _inner() -> None: + self.request._apply_fallback_overrides(overrides) + await self._internal_continue() + + return await self._handle_route(_inner) def _internal_continue( self, is_internal: bool = False @@ -458,11 +484,11 @@ async def continue_route() -> None: return continue_route() async def _redirected_navigation_request(self, url: str) -> None: - self._check_not_handled() - await self._race_with_page_close( - self._channel.send("redirectNavigationRequest", {"url": url}) + await self._handle_route( + lambda: self._race_with_page_close( + self._channel.send("redirectNavigationRequest", {"url": url}) + ) ) - self._report_handled(True) async def _race_with_page_close(self, future: Coroutine) -> None: fut = asyncio.create_task(future) diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index cfa571f74..db6cf13b8 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -25,6 +25,7 @@ Callable, Dict, List, + Literal, Optional, Pattern, Sequence, @@ -54,6 +55,7 @@ from playwright._impl._event_context_manager import EventContextManagerImpl from playwright._impl._file_chooser import FileChooser from playwright._impl._frame import Frame +from playwright._impl._greenlets import LocatorHandlerGreenlet from playwright._impl._har_router import HarRouter from playwright._impl._helper import ( ColorScheme, @@ -81,6 +83,7 @@ from playwright._impl._js_handle import ( JSHandle, Serializable, + add_source_url_to_script, parse_result, serialize_argument, ) @@ -88,11 +91,6 @@ from playwright._impl._video import Video from playwright._impl._waiter import Waiter -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal - if TYPE_CHECKING: # pragma: no cover from playwright._impl._browser_context import BrowserContext from playwright._impl._fetch import APIRequestContext @@ -152,6 +150,9 @@ def __init__( self._video: Optional[Video] = None self._opener = cast("Page", from_nullable_channel(initializer.get("opener"))) self._close_reason: Optional[str] = None + self._close_was_called = False + self._har_routers: List[HarRouter] = [] + self._locator_handlers: Dict[str, Callable] = {} self._channel.on( "bindingCall", @@ -177,9 +178,15 @@ def __init__( "frameDetached", lambda params: self._on_frame_detached(from_channel(params["frame"])), ) + self._channel.on( + "locatorHandlerTriggered", + lambda params: self._loop.create_task( + self._on_locator_handler_triggered(params["uid"]) + ), + ) self._channel.on( "route", - lambda params: asyncio.create_task( + lambda params: self._loop.create_task( self._on_route(from_channel(params["route"])) ), ) @@ -196,17 +203,21 @@ def __init__( self._closed_or_crashed_future: asyncio.Future = asyncio.Future() self.on( Page.Events.Close, - lambda _: self._closed_or_crashed_future.set_result( - self._close_error_with_reason() - ) - if not self._closed_or_crashed_future.done() - else None, + lambda _: ( + self._closed_or_crashed_future.set_result( + self._close_error_with_reason() + ) + if not self._closed_or_crashed_future.done() + else None + ), ) self.on( Page.Events.Crash, - lambda _: self._closed_or_crashed_future.set_result(TargetClosedError()) - if not self._closed_or_crashed_future.done() - else None, + lambda _: ( + self._closed_or_crashed_future.set_result(TargetClosedError()) + if not self._closed_or_crashed_future.done() + else None + ), ) self._set_event_to_subscription_mapping( @@ -238,17 +249,29 @@ async def _on_route(self, route: Route) -> None: route._context = self.context route_handlers = self._routes.copy() for route_handler in route_handlers: + # If the page was closed we stall all requests right away. + if self._close_was_called or self.context._close_was_called: + return if not route_handler.matches(route.request.url): continue + if route_handler not in self._routes: + continue if route_handler.will_expire: self._routes.remove(route_handler) try: handled = await route_handler.handle(route) finally: if len(self._routes) == 0: + + async def _update_interceptor_patterns_ignore_exceptions() -> None: + try: + await self._update_interception_patterns() + except Error: + pass + asyncio.create_task( self._connection.wrap_api_call( - lambda: self._update_interception_patterns(), True + _update_interceptor_patterns_ignore_exceptions, True ) ) if handled: @@ -272,6 +295,7 @@ def _on_close(self) -> None: self._browser_context._pages.remove(self) if self in self._browser_context._background_pages: self._browser_context._background_pages.remove(self) + self._dispose_har_routers() self.emit(Page.Events.Close, self) def _on_crash(self) -> None: @@ -563,7 +587,9 @@ async def add_init_script( self, script: str = None, path: Union[str, Path] = None ) -> None: if path: - script = (await async_readfile(path)).decode() + script = add_source_url_to_script( + (await async_readfile(path)).decode(), path + ) if not isinstance(script, str): raise Error("Either path or script parameter must be specified") await self._channel.send("addInitScript", dict(source=script)) @@ -585,13 +611,42 @@ async def route( async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None ) -> None: - self._routes = list( - filter( - lambda r: r.matcher.match != url or (handler and r.handler != handler), - self._routes, + removed = [] + remaining = [] + for route in self._routes: + if route.matcher.match != url or (handler and route.handler != handler): + remaining.append(route) + else: + removed.append(route) + await self._unroute_internal(removed, remaining, "default") + + async def _unroute_internal( + self, + removed: List[RouteHandler], + remaining: List[RouteHandler], + behavior: Literal["default", "ignoreErrors", "wait"] = None, + ) -> None: + self._routes = remaining + await self._update_interception_patterns() + if behavior is None or behavior == "default": + return + await asyncio.gather( + *map( + lambda route: route.stop(behavior), # type: ignore + removed, ) ) - await self._update_interception_patterns() + + def _dispose_har_routers(self) -> None: + for router in self._har_routers: + router.dispose() + self._har_routers = [] + + async def unroute_all( + self, behavior: Literal["default", "ignoreErrors", "wait"] = None + ) -> None: + await self._unroute_internal(self._routes, [], behavior) + self._dispose_har_routers() async def route_from_har( self, @@ -617,6 +672,7 @@ async def route_from_har( not_found_action=notFound or "abort", url_matcher=url, ) + self._har_routers.append(router) await router.add_page_route(self) async def _update_interception_patterns(self) -> None: @@ -639,6 +695,7 @@ async def screenshot( scale: Literal["css", "device"] = None, mask: Sequence["Locator"] = None, maskColor: str = None, + style: str = None, ) -> bytes: params = locals_to_params(locals()) if "path" in params: @@ -667,6 +724,7 @@ async def title(self) -> str: async def close(self, runBeforeUnload: bool = None, reason: str = None) -> None: self._close_reason = reason + self._close_was_called = True try: await self._channel.send("close", locals_to_params(locals())) if self._owned_context: @@ -993,6 +1051,8 @@ async def pdf( preferCSSPageSize: bool = None, margin: PdfMargins = None, path: Union[str, Path] = None, + outline: bool = None, + tagged: bool = None, ) -> bytes: params = locals_to_params(locals()) if "path" in params: @@ -1202,6 +1262,48 @@ async def set_checked( trial=trial, ) + async def add_locator_handler(self, locator: "Locator", handler: Callable) -> None: + if locator._frame != self._main_frame: + raise Error("Locator must belong to the main frame of this page") + uid = await self._channel.send( + "registerLocatorHandler", + { + "selector": locator._selector, + }, + ) + self._locator_handlers[uid] = handler + + async def _on_locator_handler_triggered(self, uid: str) -> None: + try: + if self._dispatcher_fiber: + handler_finished_future = self._loop.create_future() + + def _handler() -> None: + try: + self._locator_handlers[uid]() + handler_finished_future.set_result(None) + except Exception as e: + handler_finished_future.set_exception(e) + + g = LocatorHandlerGreenlet(_handler) + g.switch() + await handler_finished_future + else: + coro_or_future = self._locator_handlers[uid]() + if coro_or_future: + await coro_or_future + + finally: + try: + await self._connection.wrap_api_call( + lambda: self._channel.send( + "resolveLocatorHandlerNoReply", {"uid": uid} + ), + is_internal=True, + ) + except Error: + pass + class Worker(ChannelOwner): Events = SimpleNamespace(Close="close") diff --git a/playwright/_impl/_set_input_files_helpers.py b/playwright/_impl/_set_input_files_helpers.py index a5db6c1da..e47946be7 100644 --- a/playwright/_impl/_set_input_files_helpers.py +++ b/playwright/_impl/_set_input_files_helpers.py @@ -14,14 +14,8 @@ import base64 import collections.abc import os -import sys from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Union, cast - -if sys.version_info >= (3, 8): # pragma: no cover - from typing import TypedDict -else: # pragma: no cover - from typing_extensions import TypedDict +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, TypedDict, Union, cast from playwright._impl._connection import Channel, from_channel from playwright._impl._helper import Error @@ -62,12 +56,14 @@ async def convert_input_files( assert isinstance(item, (str, Path)) last_modified_ms = int(os.path.getmtime(item) * 1000) stream: WritableStream = from_channel( - await context._channel.send( - "createTempFile", - { - "name": os.path.basename(item), - "lastModifiedMs": last_modified_ms, - }, + await context._connection.wrap_api_call( + lambda: context._channel.send( + "createTempFile", + { + "name": os.path.basename(cast(str, item)), + "lastModifiedMs": last_modified_ms, + }, + ) ) ) await stream.copy(item) diff --git a/playwright/_impl/_sync_base.py b/playwright/_impl/_sync_base.py index 0eefbba13..f07b947b2 100644 --- a/playwright/_impl/_sync_base.py +++ b/playwright/_impl/_sync_base.py @@ -15,15 +15,15 @@ import asyncio import inspect import traceback +from contextlib import AbstractContextManager from types import TracebackType from typing import ( Any, Callable, Coroutine, - Dict, Generator, Generic, - List, + Optional, Type, TypeVar, Union, @@ -66,7 +66,7 @@ def is_done(self) -> bool: return self._future.done() -class EventContextManager(Generic[T]): +class EventContextManager(Generic[T], AbstractContextManager): def __init__(self, sync_base: "SyncBase", future: "asyncio.Future[T]") -> None: self._event = EventInfo[T](sync_base, future) @@ -75,9 +75,9 @@ def __enter__(self) -> EventInfo[T]: def __exit__( self, - exc_type: Type[BaseException], - exc_val: BaseException, - exc_tb: TracebackType, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], ) -> None: if exc_val: self._event._cancel() @@ -133,38 +133,6 @@ def remove_listener(self, event: Any, f: Any) -> None: """Removes the function ``f`` from ``event``.""" self._impl_obj.remove_listener(event, self._wrap_handler(f)) - def _gather(self, *actions: Callable) -> List[Any]: - g_self = greenlet.getcurrent() - results: Dict[Callable, Any] = {} - exceptions: List[Exception] = [] - - def action_wrapper(action: Callable) -> Callable: - def body() -> Any: - try: - results[action] = action() - except Exception as e: - results[action] = e - exceptions.append(e) - g_self.switch() - - return body - - async def task() -> None: - for action in actions: - g = greenlet.greenlet(action_wrapper(action)) - g.switch() - - self._loop.create_task(task()) - - while len(results) < len(actions): - self._dispatcher_fiber.switch() - - asyncio._set_running_loop(self._loop) - if exceptions: - raise exceptions[0] - - return list(map(lambda action: results[action], actions)) - class SyncContextManager(SyncBase): def __enter__(self: Self) -> Self: diff --git a/playwright/_impl/_transport.py b/playwright/_impl/_transport.py index d49b5a2d5..f07d31dcd 100644 --- a/playwright/_impl/_transport.py +++ b/playwright/_impl/_transport.py @@ -19,10 +19,9 @@ import subprocess import sys from abc import ABC, abstractmethod -from pathlib import Path from typing import Callable, Dict, Optional, Union -from playwright._impl._driver import get_driver_env +from playwright._impl._driver import compute_driver_executable, get_driver_env from playwright._impl._helper import ParsedMessagePayload @@ -90,12 +89,9 @@ def deserialize_message(self, data: Union[str, bytes]) -> ParsedMessagePayload: class PipeTransport(Transport): - def __init__( - self, loop: asyncio.AbstractEventLoop, driver_executable: Path - ) -> None: + def __init__(self, loop: asyncio.AbstractEventLoop) -> None: super().__init__(loop) self._stopped = False - self._driver_executable = driver_executable def request_stop(self) -> None: assert self._output @@ -120,8 +116,10 @@ async def connect(self) -> None: startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE + executable_path, entrypoint_path = compute_driver_executable() self._proc = await asyncio.create_subprocess_exec( - str(self._driver_executable), + executable_path, + entrypoint_path, "run-driver", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, diff --git a/playwright/async_api/_context_manager.py b/playwright/async_api/_context_manager.py index 2876d85e5..0c93f7043 100644 --- a/playwright/async_api/_context_manager.py +++ b/playwright/async_api/_context_manager.py @@ -16,7 +16,6 @@ from typing import Any from playwright._impl._connection import Connection -from playwright._impl._driver import compute_driver_executable from playwright._impl._object_factory import create_remote_object from playwright._impl._transport import PipeTransport from playwright.async_api._generated import Playwright as AsyncPlaywright @@ -32,7 +31,7 @@ async def __aenter__(self) -> AsyncPlaywright: self._connection = Connection( None, create_remote_object, - PipeTransport(loop, compute_driver_executable()), + PipeTransport(loop), loop, ) loop.create_task(self._connection.run()) diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index d8276a125..244a891e3 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -14,13 +14,8 @@ import pathlib -import sys import typing - -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal +from typing import Literal from playwright._impl._accessibility import Accessibility as AccessibilityImpl from playwright._impl._api_structures import ( @@ -210,11 +205,6 @@ def redirected_from(self) -> typing.Optional["Request"]: print(response.request.redirected_from.url) # \"http://example.com\" ``` - ```py - response = page.goto(\"http://example.com\") - print(response.request.redirected_from.url) # \"http://example.com\" - ``` - If the website `https://google.com` has no redirects: ```py @@ -222,11 +212,6 @@ def redirected_from(self) -> typing.Optional["Request"]: print(response.request.redirected_from) # None ``` - ```py - response = page.goto(\"https://google.com\") - print(response.request.redirected_from) # None - ``` - Returns ------- Union[Request, None] @@ -290,13 +275,6 @@ def timing(self) -> ResourceTiming: print(request.timing) ``` - ```py - with page.expect_event(\"requestfinished\") as request_info: - page.goto(\"http://example.com\") - request = request_info.value - print(request.timing) - ``` - Returns ------- {startTime: float, domainLookupStart: float, domainLookupEnd: float, connectStart: float, secureConnectionStart: float, connectEnd: float, requestStart: float, responseStart: float, responseEnd: float} @@ -707,23 +685,12 @@ async def fulfill( body=\"not found!\")) ``` - ```py - page.route(\"**/*\", lambda route: route.fulfill( - status=404, - content_type=\"text/plain\", - body=\"not found!\")) - ``` - An example of serving static file: ```py await page.route(\"**/xhr_endpoint\", lambda route: route.fulfill(path=\"mock_data.json\")) ``` - ```py - page.route(\"**/xhr_endpoint\", lambda route: route.fulfill(path=\"mock_data.json\")) - ``` - Parameters ---------- status : Union[int, None] @@ -783,16 +750,6 @@ async def handle(route): await page.route(\"https://dog.ceo/api/breeds/list/all\", handle) ``` - ```py - def handle(route): - response = route.fetch() - json = response.json() - json[\"message\"][\"big_red_dog\"] = [] - route.fulfill(response=response, json=json) - - page.route(\"https://dog.ceo/api/breeds/list/all\", handle) - ``` - **Details** Note that `headers` option will apply to the fetched request as well as any redirects initiated by it. If you want @@ -856,28 +813,22 @@ async def fallback( await page.route(\"**/*\", lambda route: route.fallback()) # Runs first. ``` - ```py - page.route(\"**/*\", lambda route: route.abort()) # Runs last. - page.route(\"**/*\", lambda route: route.fallback()) # Runs second. - page.route(\"**/*\", lambda route: route.fallback()) # Runs first. - ``` - Registering multiple routes is useful when you want separate handlers to handle different kinds of requests, for example API calls vs page resources or GET requests vs POST requests as in the example below. ```py # Handle GET requests. - def handle_get(route): + async def handle_get(route): if route.request.method != \"GET\": - route.fallback() + await route.fallback() return # Handling GET only. # ... # Handle POST requests. - def handle_post(route): + async def handle_post(route): if route.request.method != \"POST\": - route.fallback() + await route.fallback() return # Handling POST only. # ... @@ -886,27 +837,6 @@ def handle_post(route): await page.route(\"**/*\", handle_post) ``` - ```py - # Handle GET requests. - def handle_get(route): - if route.request.method != \"GET\": - route.fallback() - return - # Handling GET only. - # ... - - # Handle POST requests. - def handle_post(route): - if route.request.method != \"POST\": - route.fallback() - return - # Handling POST only. - # ... - - page.route(\"**/*\", handle_get) - page.route(\"**/*\", handle_post) - ``` - One can also modify request while falling back to the subsequent handler, that way intermediate route handler can modify url, method, headers and postData of the request. @@ -923,19 +853,6 @@ async def handle(route, request): await page.route(\"**/*\", handle) ``` - ```py - def handle(route, request): - # override headers - headers = { - **request.headers, - \"foo\": \"foo-value\", # set \"foo\" header - \"bar\": None # remove \"bar\" header - } - route.fallback(headers=headers) - - page.route(\"**/*\", handle) - ``` - Parameters ---------- url : Union[str, None] @@ -985,19 +902,6 @@ async def handle(route, request): await page.route(\"**/*\", handle) ``` - ```py - def handle(route, request): - # override headers - headers = { - **request.headers, - \"foo\": \"foo-value\", # set \"foo\" header - \"bar\": None # remove \"bar\" header - } - route.continue_(headers=headers) - - page.route(\"**/*\", handle) - ``` - **Details** Note that any overrides such as `url` or `headers` only apply to the request being routed. If this request results @@ -1284,10 +1188,6 @@ async def insert_text(self, text: str) -> None: await page.keyboard.insert_text(\"嗨\") ``` - ```py - page.keyboard.insert_text(\"嗨\") - ``` - **NOTE** Modifier keys DO NOT effect `keyboard.insertText`. Holding down `Shift` will not type the text in upper case. @@ -1316,11 +1216,6 @@ async def type(self, text: str, *, delay: typing.Optional[float] = None) -> None await page.keyboard.type(\"World\", delay=100) # types slower, like a user ``` - ```py - page.keyboard.type(\"Hello\") # types instantly - page.keyboard.type(\"World\", delay=100) # types slower, like a user - ``` - **NOTE** Modifier keys DO NOT effect `keyboard.type`. Holding down `Shift` will not type the text in upper case. **NOTE** For characters that are not on a US keyboard, only an `input` event will be sent. @@ -1358,8 +1253,8 @@ async def press(self, key: str, *, delay: typing.Optional[float] = None) -> None If `key` is a single character, it is case-sensitive, so the values `a` and `A` will generate different respective texts. - Shortcuts such as `key: \"Control+o\"` or `key: \"Control+Shift+T\"` are supported as well. When specified with the - modifier, modifier is pressed and being held while the subsequent key is being pressed. + Shortcuts such as `key: \"Control+o\"`, `key: \"Control++` or `key: \"Control+Shift+T\"` are supported as well. When + specified with the modifier, modifier is pressed and being held while the subsequent key is being pressed. **Usage** @@ -1375,18 +1270,6 @@ async def press(self, key: str, *, delay: typing.Optional[float] = None) -> None await browser.close() ``` - ```py - page = browser.new_page() - page.goto(\"https://keycode.info\") - page.keyboard.press(\"a\") - page.screenshot(path=\"a.png\") - page.keyboard.press(\"ArrowLeft\") - page.screenshot(path=\"arrow_left.png\") - page.keyboard.press(\"Shift+O\") - page.screenshot(path=\"o.png\") - browser.close() - ``` - Shortcut for `keyboard.down()` and `keyboard.up()`. Parameters @@ -1587,11 +1470,6 @@ async def evaluate( assert await tweet_handle.evaluate(\"node => node.innerText\") == \"10 retweets\" ``` - ```py - tweet_handle = page.query_selector(\".tweet .retweets\") - assert tweet_handle.evaluate(\"node => node.innerText\") == \"10 retweets\" - ``` - Parameters ---------- expression : str @@ -1681,14 +1559,6 @@ async def get_properties(self) -> typing.Dict[str, "JSHandle"]: await handle.dispose() ``` - ```py - handle = page.evaluate_handle(\"({ window, document })\") - properties = handle.get_properties() - window_handle = properties.get(\"window\") - document_handle = properties.get(\"document\") - handle.dispose() - ``` - Returns ------- Dict[str, JSHandle] @@ -1912,10 +1782,6 @@ async def dispatch_event( await element_handle.dispatch_event(\"click\") ``` - ```py - element_handle.dispatch_event(\"click\") - ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. @@ -1939,12 +1805,6 @@ async def dispatch_event( await element_handle.dispatch_event(\"#source\", \"dragstart\", {\"dataTransfer\": data_transfer}) ``` - ```py - # note you can only create data_transfer in chromium and firefox - data_transfer = page.evaluate_handle(\"new DataTransfer()\") - element_handle.dispatch_event(\"#source\", \"dragstart\", {\"dataTransfer\": data_transfer}) - ``` - Parameters ---------- type : str @@ -2217,15 +2077,6 @@ async def select_option( await handle.select_option(value=[\"red\", \"green\", \"blue\"]) ``` - ```py - # Single selection matching the value or label - handle.select_option(\"blue\") - # single selection matching both the label - handle.select_option(label=\"blue\") - # multiple selection - handle.select_option(value=[\"red\", \"green\", \"blue\"]) - ``` - Parameters ---------- value : Union[Sequence[str], str, None] @@ -2533,8 +2384,8 @@ async def press( If `key` is a single character, it is case-sensitive, so the values `a` and `A` will generate different respective texts. - Shortcuts such as `key: \"Control+o\"` or `key: \"Control+Shift+T\"` are supported as well. When specified with the - modifier, modifier is pressed and being held while the subsequent key is being pressed. + Shortcuts such as `key: \"Control+o\"`, `key: \"Control++` or `key: \"Control+Shift+T\"` are supported as well. When + specified with the modifier, modifier is pressed and being held while the subsequent key is being pressed. Parameters ---------- @@ -2745,11 +2596,6 @@ async def bounding_box(self) -> typing.Optional[FloatRect]: await page.mouse.click(box[\"x\"] + box[\"width\"] / 2, box[\"y\"] + box[\"height\"] / 2) ``` - ```py - box = element_handle.bounding_box() - page.mouse.click(box[\"x\"] + box[\"width\"] / 2, box[\"y\"] + box[\"height\"] / 2) - ``` - Returns ------- Union[{x: float, y: float, width: float, height: float}, None] @@ -2769,7 +2615,8 @@ async def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """ElementHandle.screenshot @@ -2820,6 +2667,10 @@ async def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -2838,6 +2689,7 @@ async def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) @@ -2902,12 +2754,6 @@ async def eval_on_selector( assert await tweet_handle.eval_on_selector(\".retweets\", \"node => node.innerText\") == \"10\" ``` - ```py - tweet_handle = page.query_selector(\".tweet\") - assert tweet_handle.eval_on_selector(\".like\", \"node => node.innerText\") == \"100\" - assert tweet_handle.eval_on_selector(\".retweets\", \"node => node.innerText\") == \"10\" - ``` - Parameters ---------- selector : str @@ -2956,11 +2802,6 @@ async def eval_on_selector_all( assert await feed_handle.eval_on_selector_all(\".tweet\", \"nodes => nodes.map(n => n.innerText)\") == [\"hello!\", \"hi!\"] ``` - ```py - feed_handle = page.query_selector(\".feed\") - assert feed_handle.eval_on_selector_all(\".tweet\", \"nodes => nodes.map(n => n.innerText)\") == [\"hello!\", \"hi!\"] - ``` - Parameters ---------- selector : str @@ -2997,9 +2838,8 @@ async def wait_for_element_state( Depending on the `state` parameter, this method waits for one of the [actionability](https://playwright.dev/python/docs/actionability) checks to pass. This method throws when the element is detached while waiting, unless waiting for the `\"hidden\"` state. - `\"visible\"` Wait until the element is [visible](https://playwright.dev/python/docs/actionability#visible). - - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or - [not attached](https://playwright.dev/python/docs/actionability#attached). Note that waiting for hidden does not throw when the element - detaches. + - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or not attached. Note that + waiting for hidden does not throw when the element detaches. - `\"stable\"` Wait until the element is both [visible](https://playwright.dev/python/docs/actionability#visible) and [stable](https://playwright.dev/python/docs/actionability#stable). - `\"enabled\"` Wait until the element is [enabled](https://playwright.dev/python/docs/actionability#enabled). @@ -3050,13 +2890,6 @@ async def wait_for_selector( span = await div.wait_for_selector(\"span\", state=\"attached\") ``` - ```py - page.set_content(\"
\") - div = page.query_selector(\"div\") - # waiting for the \"span\" selector relative to the div. - span = div.wait_for_selector(\"span\", state=\"attached\") - ``` - **NOTE** This method does not work across navigations, use `page.wait_for_selector()` instead. Parameters @@ -3118,11 +2951,6 @@ async def snapshot( print(snapshot) ``` - ```py - snapshot = page.accessibility.snapshot() - print(snapshot) - ``` - An example of logging the focused node's name: ```py @@ -3141,22 +2969,6 @@ def find_focused_node(node): print(node[\"name\"]) ``` - ```py - def find_focused_node(node): - if node.get(\"focused\"): - return node - for child in (node.get(\"children\") or []): - found_node = find_focused_node(child) - if found_node: - return found_node - return None - - snapshot = page.accessibility.snapshot() - node = find_focused_node(snapshot) - if node: - print(node[\"name\"]) - ``` - Parameters ---------- interesting_only : Union[bool, None] @@ -3412,12 +3224,6 @@ def expect_navigation( # Resolves after navigation has finished ``` - ```py - with frame.expect_navigation(): - frame.click(\"a.delayed-navigation\") # clicking the link will indirectly cause a navigation - # Resolves after navigation has finished - ``` - **NOTE** Usage of the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) to change the URL is considered a navigation. @@ -3472,11 +3278,6 @@ async def wait_for_url( await frame.wait_for_url(\"**/target.html\") ``` - ```py - frame.click(\"a.delayed-navigation\") # clicking the link will indirectly cause a navigation - frame.wait_for_url(\"**/target.html\") - ``` - Parameters ---------- url : Union[Callable[[str], bool], Pattern[str], str] @@ -3527,11 +3328,6 @@ async def wait_for_load_state( await frame.wait_for_load_state() # the promise resolves after \"load\" event. ``` - ```py - frame.click(\"button\") # click triggers navigation. - frame.wait_for_load_state() # the promise resolves after \"load\" event. - ``` - Parameters ---------- state : Union["domcontentloaded", "load", "networkidle", None] @@ -3570,12 +3366,6 @@ async def frame_element(self) -> "ElementHandle": assert frame == content_frame ``` - ```py - frame_element = frame.frame_element() - content_frame = frame_element.content_frame() - assert frame == content_frame - ``` - Returns ------- ElementHandle @@ -3604,11 +3394,6 @@ async def evaluate( print(result) # prints \"56\" ``` - ```py - result = frame.evaluate(\"([x, y]) => Promise.resolve(x * y)\", [7, 8]) - print(result) # prints \"56\" - ``` - A string can also be passed in instead of a function. ```py @@ -3617,12 +3402,6 @@ async def evaluate( print(await frame.evaluate(f\"1 + {x}\")) # prints \"11\" ``` - ```py - print(frame.evaluate(\"1 + 2\")) # prints \"3\" - x = 10 - print(frame.evaluate(f\"1 + {x}\")) # prints \"11\" - ``` - `ElementHandle` instances can be passed as an argument to the `frame.evaluate()`: ```py @@ -3631,12 +3410,6 @@ async def evaluate( await body_handle.dispose() ``` - ```py - body_handle = frame.evaluate(\"document.body\") - html = frame.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) - body_handle.dispose() - ``` - Parameters ---------- expression : str @@ -3676,21 +3449,12 @@ async def evaluate_handle( a_window_handle # handle for the window object. ``` - ```py - a_window_handle = frame.evaluate_handle(\"Promise.resolve(window)\") - a_window_handle # handle for the window object. - ``` - A string can also be passed in instead of a function. ```py a_handle = await page.evaluate_handle(\"document\") # handle for the \"document\" ``` - ```py - a_handle = page.evaluate_handle(\"document\") # handle for the \"document\" - ``` - `JSHandle` instances can be passed as an argument to the `frame.evaluate_handle()`: ```py @@ -3700,13 +3464,6 @@ async def evaluate_handle( await result_handle.dispose() ``` - ```py - a_handle = page.evaluate_handle(\"document.body\") - result_handle = page.evaluate_handle(\"body => body.innerHTML\", a_handle) - print(result_handle.json_value()) - result_handle.dispose() - ``` - Parameters ---------- expression : str @@ -3825,23 +3582,6 @@ async def main(): asyncio.run(main()) ``` - ```py - from playwright.sync_api import sync_playwright, Playwright - - def run(playwright: Playwright): - chromium = playwright.chromium - browser = chromium.launch() - page = browser.new_page() - for current_url in [\"https://google.com\", \"https://bbc.com\"]: - page.goto(current_url, wait_until=\"domcontentloaded\") - element = page.main_frame.wait_for_selector(\"img\") - print(\"Loaded image: \" + str(element.get_attribute(\"src\"))) - browser.close() - - with sync_playwright() as playwright: - run(playwright) - ``` - Parameters ---------- selector : str @@ -4097,10 +3837,6 @@ async def dispatch_event( await frame.dispatch_event(\"button#submit\", \"click\") ``` - ```py - frame.dispatch_event(\"button#submit\", \"click\") - ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. @@ -4124,12 +3860,6 @@ async def dispatch_event( await frame.dispatch_event(\"#source\", \"dragstart\", { \"dataTransfer\": data_transfer }) ``` - ```py - # note you can only create data_transfer in chromium and firefox - data_transfer = frame.evaluate_handle(\"new DataTransfer()\") - frame.dispatch_event(\"#source\", \"dragstart\", { \"dataTransfer\": data_transfer }) - ``` - Parameters ---------- selector : str @@ -4183,12 +3913,6 @@ async def eval_on_selector( html = await frame.eval_on_selector(\".main-container\", \"(e, suffix) => e.outerHTML + suffix\", \"hello\") ``` - ```py - search_value = frame.eval_on_selector(\"#search\", \"el => el.value\") - preload_href = frame.eval_on_selector(\"link[rel=preload]\", \"el => el.href\") - html = frame.eval_on_selector(\".main-container\", \"(e, suffix) => e.outerHTML + suffix\", \"hello\") - ``` - Parameters ---------- selector : str @@ -4235,10 +3959,6 @@ async def eval_on_selector_all( divs_counts = await frame.eval_on_selector_all(\"div\", \"(divs, min) => divs.length >= min\", 10) ``` - ```py - divs_counts = frame.eval_on_selector_all(\"div\", \"(divs, min) => divs.length >= min\", 10) - ``` - Parameters ---------- selector : str @@ -4709,8 +4429,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -4756,10 +4481,6 @@ def get_by_alt_text( await page.get_by_alt_text(\"Playwright logo\").click() ``` - ```py - page.get_by_alt_text(\"Playwright logo\").click() - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -4801,11 +4522,6 @@ def get_by_label( await page.get_by_label(\"Password\").fill(\"secret\") ``` - ```py - page.get_by_label(\"Username\").fill(\"john\") - page.get_by_label(\"Password\").fill(\"secret\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -4845,10 +4561,6 @@ def get_by_placeholder( await page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") ``` - ```py - page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -4992,14 +4704,6 @@ def get_by_role( await page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() ``` - ```py - expect(page.get_by_role(\"heading\", name=\"Sign up\")).to_be_visible() - - page.get_by_role(\"checkbox\", name=\"Subscribe\").check() - - page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() - ``` - **Details** Role selector **does not replace** accessibility audits and conformance tests, but rather gives early feedback @@ -5095,10 +4799,6 @@ def get_by_test_id( await page.get_by_test_id(\"directions\").click() ``` - ```py - page.get_by_test_id(\"directions\").click() - ``` - **Details** By default, the `data-testid` attribute is used as a test id. Use `selectors.set_test_id_attribute()` to @@ -5157,23 +4857,6 @@ def get_by_text( page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) ``` - ```py - # Matches - page.get_by_text(\"world\") - - # Matches first
- page.get_by_text(\"Hello world\") - - # Matches second
- page.get_by_text(\"Hello\", exact=True) - - # Matches both
s - page.get_by_text(re.compile(\"Hello\")) - - # Matches second
- page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) - ``` - **Details** Matching by text always normalizes whitespace, even with exact match. For example, it turns multiple spaces into @@ -5221,10 +4904,6 @@ def get_by_title( await expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") ``` - ```py - expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -5256,11 +4935,6 @@ def frame_locator(self, selector: str) -> "FrameLocator": await locator.click() ``` - ```py - locator = frame.frame_locator(\"#my-iframe\").get_by_text(\"Submit\") - locator.click() - ``` - Parameters ---------- selector : str @@ -5611,15 +5285,6 @@ async def select_option( await frame.select_option(\"select#colors\", value=[\"red\", \"green\", \"blue\"]) ``` - ```py - # Single selection matching the value or label - frame.select_option(\"select#colors\", \"blue\") - # single selection matching both the label - frame.select_option(\"select#colors\", label=\"blue\") - # multiple selection - frame.select_option(\"select#colors\", value=[\"red\", \"green\", \"blue\"]) - ``` - Parameters ---------- selector : str @@ -5836,8 +5501,8 @@ async def press( If `key` is a single character, it is case-sensitive, so the values `a` and `A` will generate different respective texts. - Shortcuts such as `key: \"Control+o\"` or `key: \"Control+Shift+T\"` are supported as well. When specified with the - modifier, modifier is pressed and being held while the subsequent key is being pressed. + Shortcuts such as `key: \"Control+o\"`, `key: \"Control++` or `key: \"Control+Shift+T\"` are supported as well. When + specified with the modifier, modifier is pressed and being held while the subsequent key is being pressed. Parameters ---------- @@ -6051,21 +5716,6 @@ async def main(): asyncio.run(main()) ``` - ```py - from playwright.sync_api import sync_playwright, Playwright - - def run(playwright: Playwright): - webkit = playwright.webkit - browser = webkit.launch() - page = browser.new_page() - page.evaluate(\"window.x = 0; setTimeout(() => { window.x = 100 }, 1000);\") - page.main_frame.wait_for_function(\"() => window.x > 0\") - browser.close() - - with sync_playwright() as playwright: - run(playwright) - ``` - To pass an argument to the predicate of `frame.waitForFunction` function: ```py @@ -6073,11 +5723,6 @@ def run(playwright: Playwright): await frame.wait_for_function(\"selector => !!document.querySelector(selector)\", selector) ``` - ```py - selector = \".foo\" - frame.wait_for_function(\"selector => !!document.querySelector(selector)\", selector) - ``` - Parameters ---------- expression : str @@ -6217,6 +5862,32 @@ def last(self) -> "FrameLocator": """ return mapping.from_impl(self._impl_obj.last) + @property + def owner(self) -> "Locator": + """FrameLocator.owner + + Returns a `Locator` object pointing to the same `iframe` as this frame locator. + + Useful when you have a `FrameLocator` object obtained somewhere, and later on would like to interact with the + `iframe` element. + + For a reverse operation, use `locator.content_frame()`. + + **Usage** + + ```py + frame_locator = page.frame_locator(\"iframe[name=\\\"embedded\\\"]\") + # ... + locator = frame_locator.owner + await expect(locator).to_be_visible() + ``` + + Returns + ------- + Locator + """ + return mapping.from_impl(self._impl_obj.owner) + def locator( self, selector_or_locator: typing.Union["Locator", str], @@ -6245,8 +5916,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -6292,10 +5968,6 @@ def get_by_alt_text( await page.get_by_alt_text(\"Playwright logo\").click() ``` - ```py - page.get_by_alt_text(\"Playwright logo\").click() - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -6337,11 +6009,6 @@ def get_by_label( await page.get_by_label(\"Password\").fill(\"secret\") ``` - ```py - page.get_by_label(\"Username\").fill(\"john\") - page.get_by_label(\"Password\").fill(\"secret\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -6381,10 +6048,6 @@ def get_by_placeholder( await page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") ``` - ```py - page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -6528,14 +6191,6 @@ def get_by_role( await page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() ``` - ```py - expect(page.get_by_role(\"heading\", name=\"Sign up\")).to_be_visible() - - page.get_by_role(\"checkbox\", name=\"Subscribe\").check() - - page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() - ``` - **Details** Role selector **does not replace** accessibility audits and conformance tests, but rather gives early feedback @@ -6631,10 +6286,6 @@ def get_by_test_id( await page.get_by_test_id(\"directions\").click() ``` - ```py - page.get_by_test_id(\"directions\").click() - ``` - **Details** By default, the `data-testid` attribute is used as a test id. Use `selectors.set_test_id_attribute()` to @@ -6693,23 +6344,6 @@ def get_by_text( page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) ``` - ```py - # Matches - page.get_by_text(\"world\") - - # Matches first
- page.get_by_text(\"Hello world\") - - # Matches second
- page.get_by_text(\"Hello\", exact=True) - - # Matches both
s - page.get_by_text(re.compile(\"Hello\")) - - # Matches second
- page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) - ``` - **Details** Matching by text always normalizes whitespace, even with exact match. For example, it turns multiple spaces into @@ -6757,10 +6391,6 @@ def get_by_title( await expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") ``` - ```py - expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -6970,41 +6600,6 @@ async def main(): asyncio.run(main()) ``` - ```py - from playwright.sync_api import sync_playwright, Playwright - - def run(playwright: Playwright): - tag_selector = \"\"\" - { - // Returns the first element matching given selector in the root's subtree. - query(root, selector) { - return root.querySelector(selector); - }, - // Returns all elements matching given selector in the root's subtree. - queryAll(root, selector) { - return Array.from(root.querySelectorAll(selector)); - } - }\"\"\" - - # Register the engine. Selectors will be prefixed with \"tag=\". - playwright.selectors.register(\"tag\", tag_selector) - browser = playwright.chromium.launch() - page = browser.new_page() - page.set_content('
') - - # Use the selector prefixed with its name. - button = page.locator('tag=button') - # Combine it with built-in locators. - page.locator('tag=div').get_by_text('Click me').click() - # Can use it in any methods supporting selectors. - button_count = page.locator('tag=button').count() - print(button_count) - browser.close() - - with sync_playwright() as playwright: - run(playwright) - ``` - Parameters ---------- name : str @@ -7275,10 +6870,6 @@ async def save_as(self, path: typing.Union[str, pathlib.Path]) -> None: await download.save_as(\"/path/to/save/at/\" + download.suggested_filename) ``` - ```py - download.save_as(\"/path/to/save/at/\" + download.suggested_filename) - ``` - Parameters ---------- path : Union[pathlib.Path, str] @@ -7359,8 +6950,7 @@ def on( ], ) -> None: """ - Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. Also - emitted if the page throws an error or a warning. + Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. The arguments passed into `console.log` are available on the `ConsoleMessage` event handler argument. @@ -7375,15 +6965,6 @@ async def print_args(msg): page.on(\"console\", print_args) await page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") - ``` - - ```py - def print_args(msg): - for arg in msg.args: - print(arg.json_value()) - - page.on(\"console\", print_args) - page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") ```""" @typing.overload @@ -7407,17 +6988,6 @@ def on( except Error as e: pass # when the page crashes, exception message contains \"crash\". - ``` - - ```py - try: - # crash might happen during a click. - page.click(\"button\") - # or while waiting for an event. - page.wait_for_event(\"popup\") - except Error as e: - pass - # when the page crashes, exception message contains \"crash\". ```""" @typing.overload @@ -7530,14 +7100,6 @@ def on( # Navigate to a page with an exception. await page.goto(\"data:text/html,\") - ``` - - ```py - # Log all uncaught errors to the terminal - page.on(\"pageerror\", lambda exc: print(f\"uncaught exception: {exc}\")) - - # Navigate to a page with an exception. - page.goto(\"data:text/html,\") ```""" @typing.overload @@ -7561,13 +7123,6 @@ def on( print(await popup.evaluate(\"location.href\")) ``` - ```py - with page.expect_event(\"popup\") as page_info: - page.get_by_text(\"open the popup\").click() - popup = page_info.value - print(popup.evaluate(\"location.href\")) - ``` - **NOTE** Use `page.wait_for_load_state()` to wait until the page gets to a particular state (you should not need it in most cases).""" @@ -7663,8 +7218,7 @@ def once( ], ) -> None: """ - Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. Also - emitted if the page throws an error or a warning. + Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. The arguments passed into `console.log` are available on the `ConsoleMessage` event handler argument. @@ -7679,15 +7233,6 @@ async def print_args(msg): page.on(\"console\", print_args) await page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") - ``` - - ```py - def print_args(msg): - for arg in msg.args: - print(arg.json_value()) - - page.on(\"console\", print_args) - page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") ```""" @typing.overload @@ -7711,17 +7256,6 @@ def once( except Error as e: pass # when the page crashes, exception message contains \"crash\". - ``` - - ```py - try: - # crash might happen during a click. - page.click(\"button\") - # or while waiting for an event. - page.wait_for_event(\"popup\") - except Error as e: - pass - # when the page crashes, exception message contains \"crash\". ```""" @typing.overload @@ -7834,14 +7368,6 @@ def once( # Navigate to a page with an exception. await page.goto(\"data:text/html,\") - ``` - - ```py - # Log all uncaught errors to the terminal - page.on(\"pageerror\", lambda exc: print(f\"uncaught exception: {exc}\")) - - # Navigate to a page with an exception. - page.goto(\"data:text/html,\") ```""" @typing.overload @@ -7865,13 +7391,6 @@ def once( print(await popup.evaluate(\"location.href\")) ``` - ```py - with page.expect_event(\"popup\") as page_info: - page.get_by_text(\"open the popup\").click() - popup = page_info.value - print(popup.evaluate(\"location.href\")) - ``` - **NOTE** Use `page.wait_for_load_state()` to wait until the page gets to a particular state (you should not need it in most cases).""" @@ -8116,10 +7635,6 @@ def frame( frame = page.frame(name=\"frame-name\") ``` - ```py - frame = page.frame(url=r\".*domain.*\") - ``` - Parameters ---------- name : Union[str, None] @@ -8269,23 +7784,6 @@ async def main(): asyncio.run(main()) ``` - ```py - from playwright.sync_api import sync_playwright, Playwright - - def run(playwright: Playwright): - chromium = playwright.chromium - browser = chromium.launch() - page = browser.new_page() - for current_url in [\"https://google.com\", \"https://bbc.com\"]: - page.goto(current_url, wait_until=\"domcontentloaded\") - element = page.wait_for_selector(\"img\") - print(\"Loaded image: \" + str(element.get_attribute(\"src\"))) - browser.close() - - with sync_playwright() as playwright: - run(playwright) - ``` - Parameters ---------- selector : str @@ -8541,10 +8039,6 @@ async def dispatch_event( await page.dispatch_event(\"button#submit\", \"click\") ``` - ```py - page.dispatch_event(\"button#submit\", \"click\") - ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. @@ -8568,12 +8062,6 @@ async def dispatch_event( await page.dispatch_event(\"#source\", \"dragstart\", { \"dataTransfer\": data_transfer }) ``` - ```py - # note you can only create data_transfer in chromium and firefox - data_transfer = page.evaluate_handle(\"new DataTransfer()\") - page.dispatch_event(\"#source\", \"dragstart\", { \"dataTransfer\": data_transfer }) - ``` - Parameters ---------- selector : str @@ -8624,11 +8112,6 @@ async def evaluate( print(result) # prints \"56\" ``` - ```py - result = page.evaluate(\"([x, y]) => Promise.resolve(x * y)\", [7, 8]) - print(result) # prints \"56\" - ``` - A string can also be passed in instead of a function: ```py @@ -8637,12 +8120,6 @@ async def evaluate( print(await page.evaluate(f\"1 + {x}\")) # prints \"11\" ``` - ```py - print(page.evaluate(\"1 + 2\")) # prints \"3\" - x = 10 - print(page.evaluate(f\"1 + {x}\")) # prints \"11\" - ``` - `ElementHandle` instances can be passed as an argument to the `page.evaluate()`: ```py @@ -8651,12 +8128,6 @@ async def evaluate( await body_handle.dispose() ``` - ```py - body_handle = page.evaluate(\"document.body\") - html = page.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) - body_handle.dispose() - ``` - Parameters ---------- expression : str @@ -8696,21 +8167,12 @@ async def evaluate_handle( a_window_handle # handle for the window object. ``` - ```py - a_window_handle = page.evaluate_handle(\"Promise.resolve(window)\") - a_window_handle # handle for the window object. - ``` - A string can also be passed in instead of a function: ```py a_handle = await page.evaluate_handle(\"document\") # handle for the \"document\" ``` - ```py - a_handle = page.evaluate_handle(\"document\") # handle for the \"document\" - ``` - `JSHandle` instances can be passed as an argument to the `page.evaluate_handle()`: ```py @@ -8720,13 +8182,6 @@ async def evaluate_handle( await result_handle.dispose() ``` - ```py - a_handle = page.evaluate_handle(\"document.body\") - result_handle = page.evaluate_handle(\"body => body.innerHTML\", a_handle) - print(result_handle.json_value()) - result_handle.dispose() - ``` - Parameters ---------- expression : str @@ -8770,12 +8225,6 @@ async def eval_on_selector( html = await page.eval_on_selector(\".main-container\", \"(e, suffix) => e.outer_html + suffix\", \"hello\") ``` - ```py - search_value = page.eval_on_selector(\"#search\", \"el => el.value\") - preload_href = page.eval_on_selector(\"link[rel=preload]\", \"el => el.href\") - html = page.eval_on_selector(\".main-container\", \"(e, suffix) => e.outer_html + suffix\", \"hello\") - ``` - Parameters ---------- selector : str @@ -8820,10 +8269,6 @@ async def eval_on_selector_all( div_counts = await page.eval_on_selector_all(\"div\", \"(divs, min) => divs.length >= min\", 10) ``` - ```py - div_counts = page.eval_on_selector_all(\"div\", \"(divs, min) => divs.length >= min\", 10) - ``` - Parameters ---------- selector : str @@ -8961,35 +8406,6 @@ async def main(): asyncio.run(main()) ``` - ```py - import hashlib - from playwright.sync_api import sync_playwright, Playwright - - def sha256(text): - m = hashlib.sha256() - m.update(bytes(text, \"utf8\")) - return m.hexdigest() - - def run(playwright: Playwright): - webkit = playwright.webkit - browser = webkit.launch(headless=False) - page = browser.new_page() - page.expose_function(\"sha256\", sha256) - page.set_content(\"\"\" - - -
- \"\"\") - page.click(\"button\") - - with sync_playwright() as playwright: - run(playwright) - ``` - Parameters ---------- name : str @@ -9055,30 +8471,6 @@ async def main(): asyncio.run(main()) ``` - ```py - from playwright.sync_api import sync_playwright, Playwright - - def run(playwright: Playwright): - webkit = playwright.webkit - browser = webkit.launch(headless=False) - context = browser.new_context() - page = context.new_page() - page.expose_binding(\"pageURL\", lambda source: source[\"page\"].url) - page.set_content(\"\"\" - - -
- \"\"\") - page.click(\"button\") - - with sync_playwright() as playwright: - run(playwright) - ``` - An example of passing an element handle: ```py @@ -9095,20 +8487,6 @@ async def print(source, element): \"\"\") ``` - ```py - def print(source, element): - print(element.text_content()) - - page.expose_binding(\"clicked\", print, handle=true) - page.set_content(\"\"\" - -
Click me
-
Or click me
- \"\"\") - ``` - Parameters ---------- name : str @@ -9324,11 +8702,6 @@ async def wait_for_load_state( await page.wait_for_load_state() # the promise resolves after \"load\" event. ``` - ```py - page.get_by_role(\"button\").click() # click triggers navigation. - page.wait_for_load_state() # the promise resolves after \"load\" event. - ``` - ```py async with page.expect_popup() as page_info: await page.get_by_role(\"button\").click() # click triggers a popup. @@ -9338,15 +8711,6 @@ async def wait_for_load_state( print(await popup.title()) # popup is ready to use. ``` - ```py - with page.expect_popup() as page_info: - page.get_by_role(\"button\").click() # click triggers a popup. - popup = page_info.value - # Wait for the \"DOMContentLoaded\" event. - popup.wait_for_load_state(\"domcontentloaded\") - print(popup.title()) # popup is ready to use. - ``` - Parameters ---------- state : Union["domcontentloaded", "load", "networkidle", None] @@ -9387,11 +8751,6 @@ async def wait_for_url( await page.wait_for_url(\"**/target.html\") ``` - ```py - page.click(\"a.delayed-navigation\") # clicking the link will indirectly cause a navigation - page.wait_for_url(\"**/target.html\") - ``` - Parameters ---------- url : Union[Callable[[str], bool], Pattern[str], str] @@ -9573,25 +8932,6 @@ async def emulate_media( # → False ``` - ```py - page.evaluate(\"matchMedia('screen').matches\") - # → True - page.evaluate(\"matchMedia('print').matches\") - # → False - - page.emulate_media(media=\"print\") - page.evaluate(\"matchMedia('screen').matches\") - # → False - page.evaluate(\"matchMedia('print').matches\") - # → True - - page.emulate_media() - page.evaluate(\"matchMedia('screen').matches\") - # → True - page.evaluate(\"matchMedia('print').matches\") - # → False - ``` - ```py await page.emulate_media(color_scheme=\"dark\") await page.evaluate(\"matchMedia('(prefers-color-scheme: dark)').matches\") @@ -9602,15 +8942,6 @@ async def emulate_media( # → False ``` - ```py - page.emulate_media(color_scheme=\"dark\") - page.evaluate(\"matchMedia('(prefers-color-scheme: dark)').matches\") - # → True - page.evaluate(\"matchMedia('(prefers-color-scheme: light)').matches\") - # → False - page.evaluate(\"matchMedia('(prefers-color-scheme: no-preference)').matches\") - ``` - Parameters ---------- media : Union["null", "print", "screen", None] @@ -9653,12 +8984,6 @@ async def set_viewport_size(self, viewport_size: ViewportSize) -> None: await page.goto(\"https://example.com\") ``` - ```py - page = browser.new_page() - page.set_viewport_size({\"width\": 640, \"height\": 480}) - page.goto(\"https://example.com\") - ``` - Parameters ---------- viewport_size : {width: int, height: int} @@ -9701,11 +9026,6 @@ async def add_init_script( await page.add_init_script(path=\"./preload.js\") ``` - ```py - # in your playwright script, assuming the preload.js file is in same directory - page.add_init_script(path=\"./preload.js\") - ``` - **NOTE** The order of evaluation of multiple scripts installed via `browser_context.add_init_script()` and `page.add_init_script()` is not defined. @@ -9756,13 +9076,6 @@ async def route( await browser.close() ``` - ```py - page = browser.new_page() - page.route(\"**/*.{png,jpg,jpeg}\", lambda route: route.abort()) - page.goto(\"https://example.com\") - browser.close() - ``` - or the same snippet using a regex pattern instead: ```py @@ -9772,34 +9085,18 @@ async def route( await browser.close() ``` - ```py - page = browser.new_page() - page.route(re.compile(r\"(\\.png$)|(\\.jpg$)\"), lambda route: route.abort()) - page.goto(\"https://example.com\") - browser.close() - ``` - It is possible to examine the request to decide the route action. For example, mocking all requests that contain some post data, and leaving all other requests as is: ```py - def handle_route(route): + async def handle_route(route: Route): if (\"my-string\" in route.request.post_data): - route.fulfill(body=\"mocked-data\") + await route.fulfill(body=\"mocked-data\") else: - route.continue_() + await route.continue_() await page.route(\"/api/**\", handle_route) ``` - ```py - def handle_route(route): - if (\"my-string\" in route.request.post_data): - route.fulfill(body=\"mocked-data\") - else: - route.continue_() - page.route(\"/api/**\", handle_route) - ``` - Page routes take precedence over browser context routes (set up with `browser_context.route()`) when request matches both handlers. @@ -9856,6 +9153,30 @@ async def unroute( ) ) + async def unroute_all( + self, + *, + behavior: typing.Optional[Literal["default", "ignoreErrors", "wait"]] = None + ) -> None: + """Page.unroute_all + + Removes all routes created with `page.route()` and `page.route_from_har()`. + + Parameters + ---------- + behavior : Union["default", "ignoreErrors", "wait", None] + Specifies wether to wait for already running handlers and what to do if they throw errors: + - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may + result in unhandled error + - `'wait'` - wait for current handler calls (if any) to finish + - `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers + after unrouting are silently caught + """ + + return mapping.from_maybe_impl( + await self._impl_obj.unroute_all(behavior=behavior) + ) + async def route_from_har( self, har: typing.Union[pathlib.Path, str], @@ -9924,7 +9245,8 @@ async def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """Page.screenshot @@ -9973,6 +9295,10 @@ async def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -9993,6 +9319,7 @@ async def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) @@ -10362,8 +9689,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -10409,10 +9741,6 @@ def get_by_alt_text( await page.get_by_alt_text(\"Playwright logo\").click() ``` - ```py - page.get_by_alt_text(\"Playwright logo\").click() - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -10454,11 +9782,6 @@ def get_by_label( await page.get_by_label(\"Password\").fill(\"secret\") ``` - ```py - page.get_by_label(\"Username\").fill(\"john\") - page.get_by_label(\"Password\").fill(\"secret\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -10498,10 +9821,6 @@ def get_by_placeholder( await page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") ``` - ```py - page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -10645,14 +9964,6 @@ def get_by_role( await page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() ``` - ```py - expect(page.get_by_role(\"heading\", name=\"Sign up\")).to_be_visible() - - page.get_by_role(\"checkbox\", name=\"Subscribe\").check() - - page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() - ``` - **Details** Role selector **does not replace** accessibility audits and conformance tests, but rather gives early feedback @@ -10748,10 +10059,6 @@ def get_by_test_id( await page.get_by_test_id(\"directions\").click() ``` - ```py - page.get_by_test_id(\"directions\").click() - ``` - **Details** By default, the `data-testid` attribute is used as a test id. Use `selectors.set_test_id_attribute()` to @@ -10810,23 +10117,6 @@ def get_by_text( page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) ``` - ```py - # Matches - page.get_by_text(\"world\") - - # Matches first
- page.get_by_text(\"Hello world\") - - # Matches second
- page.get_by_text(\"Hello\", exact=True) - - # Matches both
s - page.get_by_text(re.compile(\"Hello\")) - - # Matches second
- page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) - ``` - **Details** Matching by text always normalizes whitespace, even with exact match. For example, it turns multiple spaces into @@ -10874,10 +10164,6 @@ def get_by_title( await expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") ``` - ```py - expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -10909,11 +10195,6 @@ def frame_locator(self, selector: str) -> "FrameLocator": await locator.click() ``` - ```py - locator = page.frame_locator(\"#my-iframe\").get_by_text(\"Submit\") - locator.click() - ``` - Parameters ---------- selector : str @@ -11195,17 +10476,6 @@ async def drag_and_drop( ) ``` - ```py - page.drag_and_drop(\"#source\", \"#target\") - # or specify exact positions relative to the top-left corners of the elements: - page.drag_and_drop( - \"#source\", - \"#target\", - source_position={\"x\": 34, \"y\": 7}, - target_position={\"x\": 10, \"y\": 20} - ) - ``` - Parameters ---------- source : str @@ -11291,15 +10561,6 @@ async def select_option( await page.select_option(\"select#colors\", value=[\"red\", \"green\", \"blue\"]) ``` - ```py - # Single selection matching the value or label - page.select_option(\"select#colors\", \"blue\") - # single selection matching both the label - page.select_option(\"select#colors\", label=\"blue\") - # multiple selection - page.select_option(\"select#colors\", value=[\"red\", \"green\", \"blue\"]) - ``` - Parameters ---------- selector : str @@ -11519,8 +10780,8 @@ async def press( If `key` is a single character, it is case-sensitive, so the values `a` and `A` will generate different respective texts. - Shortcuts such as `key: \"Control+o\"` or `key: \"Control+Shift+T\"` are supported as well. When specified with the - modifier, modifier is pressed and being held while the subsequent key is being pressed. + Shortcuts such as `key: \"Control+o\"`, `key: \"Control++` or `key: \"Control+Shift+T\"` are supported as well. When + specified with the modifier, modifier is pressed and being held while the subsequent key is being pressed. **Usage** @@ -11536,18 +10797,6 @@ async def press( await browser.close() ``` - ```py - page = browser.new_page() - page.goto(\"https://keycode.info\") - page.press(\"body\", \"A\") - page.screenshot(path=\"a.png\") - page.press(\"body\", \"ArrowLeft\") - page.screenshot(path=\"arrow_left.png\") - page.press(\"body\", \"Shift+O\") - page.screenshot(path=\"o.png\") - browser.close() - ``` - Parameters ---------- selector : str @@ -11723,11 +10972,6 @@ async def wait_for_timeout(self, timeout: float) -> None: await page.wait_for_timeout(1000) ``` - ```py - # wait for 1 second - page.wait_for_timeout(1000) - ``` - Parameters ---------- timeout : float @@ -11772,21 +11016,6 @@ async def main(): asyncio.run(main()) ``` - ```py - from playwright.sync_api import sync_playwright, Playwright - - def run(playwright: Playwright): - webkit = playwright.webkit - browser = webkit.launch() - page = browser.new_page() - page.evaluate(\"window.x = 0; setTimeout(() => { window.x = 100 }, 1000);\") - page.wait_for_function(\"() => window.x > 0\") - browser.close() - - with sync_playwright() as playwright: - run(playwright) - ``` - To pass an argument to the predicate of `page.wait_for_function()` function: ```py @@ -11794,11 +11023,6 @@ def run(playwright: Playwright): await page.wait_for_function(\"selector => !!document.querySelector(selector)\", selector) ``` - ```py - selector = \".foo\" - page.wait_for_function(\"selector => !!document.querySelector(selector)\", selector) - ``` - Parameters ---------- expression : str @@ -11859,7 +11083,9 @@ async def pdf( height: typing.Optional[typing.Union[str, float]] = None, prefer_css_page_size: typing.Optional[bool] = None, margin: typing.Optional[PdfMargins] = None, - path: typing.Optional[typing.Union[str, pathlib.Path]] = None + path: typing.Optional[typing.Union[str, pathlib.Path]] = None, + outline: typing.Optional[bool] = None, + tagged: typing.Optional[bool] = None ) -> bytes: """Page.pdf @@ -11882,12 +11108,6 @@ async def pdf( await page.pdf(path=\"page.pdf\") ``` - ```py - # generates a pdf with \"screen\" media type. - page.emulate_media(media=\"screen\") - page.pdf(path=\"page.pdf\") - ``` - The `width`, `height`, and `margin` options accept values labeled with units. Unlabeled values are treated as pixels. @@ -11954,6 +11174,10 @@ async def pdf( path : Union[pathlib.Path, str, None] The file path to save the PDF to. If `path` is a relative path, then it is resolved relative to the current working directory. If no path is provided, the PDF won't be saved to the disk. + outline : Union[bool, None] + Whether or not to embed the document outline into the PDF. Defaults to `false`. + tagged : Union[bool, None] + Whether or not to generate tagged (accessible) PDF. Defaults to `false`. Returns ------- @@ -11975,6 +11199,8 @@ async def pdf( preferCSSPageSize=prefer_css_page_size, margin=margin, path=path, + outline=outline, + tagged=tagged, ) ) @@ -11998,12 +11224,6 @@ def expect_event( frame = await event_info.value ``` - ```py - with page.expect_event(\"framenavigated\") as event_info: - page.get_by_role(\"button\") - frame = event_info.value - ``` - Parameters ---------- event : str @@ -12148,13 +11368,6 @@ def expect_navigation( # Resolves after navigation has finished ``` - ```py - with page.expect_navigation(): - # This action triggers the navigation after a timeout. - page.get_by_text(\"Navigate after timeout\").click() - # Resolves after navigation has finished - ``` - **NOTE** Usage of the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) to change the URL is considered a navigation. @@ -12246,17 +11459,6 @@ def expect_request( second_request = await second.value ``` - ```py - with page.expect_request(\"http://example.com/resource\") as first: - page.get_by_text(\"trigger request\").click() - first_request = first.value - - # or with a lambda - with page.expect_request(lambda request: request.url == \"http://example.com\" and request.method == \"get\") as second: - page.get_by_text(\"trigger request\").click() - second_request = second.value - ``` - Parameters ---------- url_or_predicate : Union[Callable[[Request], bool], Pattern[str], str] @@ -12337,19 +11539,6 @@ def expect_response( return response.ok ``` - ```py - with page.expect_response(\"https://example.com/resource\") as response_info: - page.get_by_text(\"trigger response\").click() - response = response_info.value - return response.ok - - # or with a lambda - with page.expect_response(lambda response: response.url == \"https://example.com\" and response.status == 200) as response_info: - page.get_by_text(\"trigger response\").click() - response = response_info.value - return response.ok - ``` - Parameters ---------- url_or_predicate : Union[Callable[[Response], bool], Pattern[str], str] @@ -12501,6 +11690,100 @@ async def set_checked( ) ) + async def add_locator_handler( + self, locator: "Locator", handler: typing.Callable + ) -> None: + """Page.add_locator_handler + + **NOTE** This method is experimental and its behavior may change in the upcoming releases. + + When testing a web page, sometimes unexpected overlays like a \"Sign up\" dialog appear and block actions you want to + automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making + them tricky to handle in automated tests. + + This method lets you set up a special function, called a handler, that activates when it detects that overlay is + visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. + + Things to keep in mind: + - When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as + a part of your normal test flow, instead of using `page.add_locator_handler()`. + - Playwright checks for the overlay every time before executing or retrying an action that requires an + [actionability check](https://playwright.dev/python/docs/actionability), or before performing an auto-waiting assertion check. When overlay + is visible, Playwright calls the handler first, and then proceeds with the action/assertion. Note that the + handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't + perform any actions, the handler will not be triggered. + - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. + If your handler takes too long, it might cause timeouts. + - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the + actions within a handler don't depend on another handler. + + **NOTE** Running the handler will alter your page state mid-test. For example it will change the currently focused + element and move the mouse. Make sure that actions that run after the handler are self-contained and do not rely on + the focus and mouse state being unchanged.

For example, consider a test that calls + `locator.focus()` followed by `keyboard.press()`. If your handler clicks a button between these two + actions, the focused element most likely will be wrong, and key press will happen on the unexpected element. Use + `locator.press()` instead to avoid this problem.

Another example is a series of mouse + actions, where `mouse.move()` is followed by `mouse.down()`. Again, when the handler runs between + these two actions, the mouse position will be wrong during the mouse down. Prefer self-contained actions like + `locator.click()` that do not rely on the state being unchanged by a handler. + + **Usage** + + An example that closes a \"Sign up to the newsletter\" dialog when it appears: + + ```py + # Setup the handler. + def handler(): + page.get_by_role(\"button\", name=\"No thanks\").click() + page.add_locator_handler(page.get_by_text(\"Sign up to the newsletter\"), handler) + + # Write the test as usual. + page.goto(\"https://example.com\") + page.get_by_role(\"button\", name=\"Start here\").click() + ``` + + An example that skips the \"Confirm your security details\" page when it is shown: + + ```py + # Setup the handler. + def handler(): + page.get_by_role(\"button\", name=\"Remind me later\").click() + page.add_locator_handler(page.get_by_text(\"Confirm your security details\"), handler) + + # Write the test as usual. + page.goto(\"https://example.com\") + page.get_by_role(\"button\", name=\"Start here\").click() + ``` + + An example with a custom callback on every actionability check. It uses a `` locator that is always visible, + so the handler is called before every actionability check: + + ```py + # Setup the handler. + def handler(): + page.evaluate(\"window.removeObstructionsForTestIfNeeded()\") + page.add_locator_handler(page.locator(\"body\"), handler) + + # Write the test as usual. + page.goto(\"https://example.com\") + page.get_by_role(\"button\", name=\"Start here\").click() + ``` + + Parameters + ---------- + locator : Locator + Locator that triggers the handler. + handler : Callable + Function that should be run once `locator` appears. This function should get rid of the element that blocks actions + like click. + """ + + return mapping.from_maybe_impl( + await self._impl_obj.add_locator_handler( + locator=locator._impl_obj, handler=self._wrap_handler(handler) + ) + ) + mapping.register(PageImpl, Page) @@ -12548,10 +11831,6 @@ def on( ```py background_page = await context.wait_for_event(\"backgroundpage\") - ``` - - ```py - background_page = context.wait_for_event(\"backgroundpage\") ```""" @typing.overload @@ -12577,8 +11856,7 @@ def on( ], ) -> None: """ - Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. Also - emitted if the page throws an error or a warning. + Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. The arguments passed into `console.log` and the page are available on the `ConsoleMessage` event handler argument. @@ -12593,15 +11871,6 @@ async def print_args(msg): context.on(\"console\", print_args) await page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") - ``` - - ```py - def print_args(msg): - for arg in msg.args: - print(arg.json_value()) - - context.on(\"console\", print_args) - page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") ```""" @typing.overload @@ -12647,13 +11916,6 @@ def on( print(await page.evaluate(\"location.href\")) ``` - ```py - with context.expect_page() as page_info: - page.get_by_text(\"open new page\").click(), - page = page_info.value - print(page.evaluate(\"location.href\")) - ``` - **NOTE** Use `page.wait_for_load_state()` to wait until the page gets to a particular state (you should not need it in most cases).""" @@ -12747,10 +12009,6 @@ def once( ```py background_page = await context.wait_for_event(\"backgroundpage\") - ``` - - ```py - background_page = context.wait_for_event(\"backgroundpage\") ```""" @typing.overload @@ -12776,8 +12034,7 @@ def once( ], ) -> None: """ - Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. Also - emitted if the page throws an error or a warning. + Emitted when JavaScript within the page calls one of console API methods, e.g. `console.log` or `console.dir`. The arguments passed into `console.log` and the page are available on the `ConsoleMessage` event handler argument. @@ -12792,15 +12049,6 @@ async def print_args(msg): context.on(\"console\", print_args) await page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") - ``` - - ```py - def print_args(msg): - for arg in msg.args: - print(arg.json_value()) - - context.on(\"console\", print_args) - page.evaluate(\"console.log('hello', 5, { foo: 'bar' })\") ```""" @typing.overload @@ -12846,13 +12094,6 @@ def once( print(await page.evaluate(\"location.href\")) ``` - ```py - with context.expect_page() as page_info: - page.get_by_text(\"open new page\").click(), - page = page_info.value - print(page.evaluate(\"location.href\")) - ``` - **NOTE** Use `page.wait_for_load_state()` to wait until the page gets to a particular state (you should not need it in most cases).""" @@ -13096,10 +12337,6 @@ async def add_cookies(self, cookies: typing.Sequence[SetCookieParam]) -> None: await browser_context.add_cookies([cookie_object1, cookie_object2]) ``` - ```py - browser_context.add_cookies([cookie_object1, cookie_object2]) - ``` - Parameters ---------- cookies : Sequence[{name: str, value: str, url: Union[str, None], domain: Union[str, None], path: Union[str, None], expires: Union[float, None], httpOnly: Union[bool, None], secure: Union[bool, None], sameSite: Union["Lax", "None", "Strict", None]}] @@ -13112,13 +12349,40 @@ async def add_cookies(self, cookies: typing.Sequence[SetCookieParam]) -> None: await self._impl_obj.add_cookies(cookies=mapping.to_impl(cookies)) ) - async def clear_cookies(self) -> None: + async def clear_cookies( + self, + *, + name: typing.Optional[typing.Union[str, typing.Pattern[str]]] = None, + domain: typing.Optional[typing.Union[str, typing.Pattern[str]]] = None, + path: typing.Optional[typing.Union[str, typing.Pattern[str]]] = None + ) -> None: """BrowserContext.clear_cookies - Clears context cookies. + Removes cookies from context. Accepts optional filter. + + **Usage** + + ```py + await context.clear_cookies() + await context.clear_cookies(name=\"session-id\") + await context.clear_cookies(domain=\"my-origin.com\") + await context.clear_cookies(path=\"/api/v1\") + await context.clear_cookies(name=\"session-id\", domain=\"my-origin.com\") + ``` + + Parameters + ---------- + name : Union[Pattern[str], str, None] + Only removes cookies with the given name. + domain : Union[Pattern[str], str, None] + Only removes cookies with the given domain. + path : Union[Pattern[str], str, None] + Only removes cookies with the given path. """ - return mapping.from_maybe_impl(await self._impl_obj.clear_cookies()) + return mapping.from_maybe_impl( + await self._impl_obj.clear_cookies(name=name, domain=domain, path=path) + ) async def grant_permissions( self, permissions: typing.Sequence[str], *, origin: typing.Optional[str] = None @@ -13170,13 +12434,6 @@ async def clear_permissions(self) -> None: # do stuff .. context.clear_permissions() ``` - - ```py - context = browser.new_context() - context.grant_permissions([\"clipboard-read\"]) - # do stuff .. - context.clear_permissions() - ``` """ return mapping.from_maybe_impl(await self._impl_obj.clear_permissions()) @@ -13194,10 +12451,6 @@ async def set_geolocation( await browser_context.set_geolocation({\"latitude\": 59.95, \"longitude\": 30.31667}) ``` - ```py - browser_context.set_geolocation({\"latitude\": 59.95, \"longitude\": 30.31667}) - ``` - **NOTE** Consider using `browser_context.grant_permissions()` to grant permissions for the browser context pages to read its geolocation. @@ -13270,11 +12523,6 @@ async def add_init_script( await browser_context.add_init_script(path=\"preload.js\") ``` - ```py - # in your playwright script, assuming the preload.js file is in same directory. - browser_context.add_init_script(path=\"preload.js\") - ``` - **NOTE** The order of evaluation of multiple scripts installed via `browser_context.add_init_script()` and `page.add_init_script()` is not defined. @@ -13315,41 +12563,15 @@ async def expose_binding( ```py import asyncio - from playwright.async_api import async_playwright, Playwright - - async def run(playwright: Playwright): - webkit = playwright.webkit - browser = await webkit.launch(headless=False) - context = await browser.new_context() - await context.expose_binding(\"pageURL\", lambda source: source[\"page\"].url) - page = await context.new_page() - await page.set_content(\"\"\" - - -
- \"\"\") - await page.get_by_role(\"button\").click() - - async def main(): - async with async_playwright() as playwright: - await run(playwright) - asyncio.run(main()) - ``` - - ```py - from playwright.sync_api import sync_playwright, Playwright + from playwright.async_api import async_playwright, Playwright - def run(playwright: Playwright): + async def run(playwright: Playwright): webkit = playwright.webkit - browser = webkit.launch(headless=False) - context = browser.new_context() - context.expose_binding(\"pageURL\", lambda source: source[\"page\"].url) - page = context.new_page() - page.set_content(\"\"\" + browser = await webkit.launch(headless=False) + context = await browser.new_context() + await context.expose_binding(\"pageURL\", lambda source: source[\"page\"].url) + page = await context.new_page() + await page.set_content(\"\"\" -
Click me
-
Or click me
- \"\"\") - ``` - Parameters ---------- name : str @@ -13458,36 +12668,6 @@ async def main(): asyncio.run(main()) ``` - ```py - import hashlib - from playwright.sync_api import sync_playwright - - def sha256(text: str) -> str: - m = hashlib.sha256() - m.update(bytes(text, \"utf8\")) - return m.hexdigest() - - def run(playwright: Playwright): - webkit = playwright.webkit - browser = webkit.launch(headless=False) - context = browser.new_context() - context.expose_function(\"sha256\", sha256) - page = context.new_page() - page.set_content(\"\"\" - - -
- \"\"\") - page.get_by_role(\"button\").click() - - with sync_playwright() as playwright: - run(playwright) - ``` - Parameters ---------- name : str @@ -13533,14 +12713,6 @@ async def route( await browser.close() ``` - ```py - context = browser.new_context() - page = context.new_page() - context.route(\"**/*.{png,jpg,jpeg}\", lambda route: route.abort()) - page.goto(\"https://example.com\") - browser.close() - ``` - or the same snippet using a regex pattern instead: ```py @@ -13552,37 +12724,18 @@ async def route( await browser.close() ``` - ```py - context = browser.new_context() - page = context.new_page() - context.route(re.compile(r\"(\\.png$)|(\\.jpg$)\"), lambda route: route.abort()) - page = await context.new_page() - page = context.new_page() - page.goto(\"https://example.com\") - browser.close() - ``` - It is possible to examine the request to decide the route action. For example, mocking all requests that contain some post data, and leaving all other requests as is: ```py - def handle_route(route): + async def handle_route(route: Route): if (\"my-string\" in route.request.post_data): - route.fulfill(body=\"mocked-data\") + await route.fulfill(body=\"mocked-data\") else: - route.continue_() + await route.continue_() await context.route(\"/api/**\", handle_route) ``` - ```py - def handle_route(route): - if (\"my-string\" in route.request.post_data): - route.fulfill(body=\"mocked-data\") - else: - route.continue_() - context.route(\"/api/**\", handle_route) - ``` - Page routes (set up with `page.route()`) take precedence over browser context routes when request matches both handlers. @@ -13640,6 +12793,30 @@ async def unroute( ) ) + async def unroute_all( + self, + *, + behavior: typing.Optional[Literal["default", "ignoreErrors", "wait"]] = None + ) -> None: + """BrowserContext.unroute_all + + Removes all routes created with `browser_context.route()` and `browser_context.route_from_har()`. + + Parameters + ---------- + behavior : Union["default", "ignoreErrors", "wait", None] + Specifies wether to wait for already running handlers and what to do if they throw errors: + - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may + result in unhandled error + - `'wait'` - wait for current handler calls (if any) to finish + - `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers + after unrouting are silently caught + """ + + return mapping.from_maybe_impl( + await self._impl_obj.unroute_all(behavior=behavior) + ) + async def route_from_har( self, har: typing.Union[pathlib.Path, str], @@ -13715,12 +12892,6 @@ def expect_event( page = await event_info.value ``` - ```py - with context.expect_event(\"page\") as event_info: - page.get_by_role(\"button\").click() - page = event_info.value - ``` - Parameters ---------- event : str @@ -13977,13 +13148,6 @@ def contexts(self) -> typing.List["BrowserContext"]: print(len(browser.contexts())) # prints `1` ``` - ```py - browser = pw.webkit.launch() - print(len(browser.contexts())) # prints `0` - context = browser.new_context() - print(len(browser.contexts())) # prints `1` - ``` - Returns ------- List[BrowserContext] @@ -14096,19 +13260,6 @@ async def new_context( await browser.close() ``` - ```py - browser = playwright.firefox.launch() # or \"chromium\" or \"webkit\". - # create a new incognito browser context. - context = browser.new_context() - # create a new page in a pristine context. - page = context.new_page() - page.goto(\"https://example.com\") - - # gracefully close up everything - context.close() - browser.close() - ``` - Parameters ---------- viewport : Union[{width: int, height: int}, None] @@ -14546,12 +13697,6 @@ async def start_tracing( await browser.stop_tracing() ``` - ```py - browser.start_tracing(page, path=\"trace.json\") - page.goto(\"https://www.google.com\") - browser.stop_tracing() - ``` - Parameters ---------- page : Union[Page, None] @@ -14658,12 +13803,6 @@ async def launch( ) ``` - ```py - browser = playwright.chromium.launch( # or \"firefox\" or \"webkit\". - ignore_default_args=[\"--mute-audio\"] - ) - ``` - > **Chromium-only** Playwright can also be used to control the Google Chrome or Microsoft Edge browsers, but it works best with the version of Chromium it is bundled with. There is no guarantee it will work with any other version. Use `executablePath` option with extreme caution. @@ -14690,8 +13829,10 @@ async def launch( "msedge", "msedge-beta", "msedge-dev", "msedge-canary". Read more about using [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge). args : Union[Sequence[str], None] + **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + Additional arguments to pass to the browser instance. The list of Chromium flags can be found - [here](http://peter.sh/experiments/chromium-command-line-switches/). + [here](https://peter.sh/experiments/chromium-command-line-switches/). ignore_default_args : Union[Sequence[str], bool, None] If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. @@ -14714,6 +13855,7 @@ async def launch( devtools : Union[bool, None] **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the `headless` option will be set `false`. + Deprecated: Use [debugging tools](../debug.md) instead. proxy : Union[{server: str, bypass: Union[str, None], username: Union[str, None], password: Union[str, None]}, None] Network proxy settings. downloads_path : Union[pathlib.Path, str, None] @@ -14845,8 +13987,10 @@ async def launch_persistent_context( resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. args : Union[Sequence[str], None] + **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + Additional arguments to pass to the browser instance. The list of Chromium flags can be found - [here](http://peter.sh/experiments/chromium-command-line-switches/). + [here](https://peter.sh/experiments/chromium-command-line-switches/). ignore_default_args : Union[Sequence[str], bool, None] If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. @@ -14869,6 +14013,7 @@ async def launch_persistent_context( devtools : Union[bool, None] **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the `headless` option will be set `false`. + Deprecated: Use [debugging tools](../debug.md) instead. proxy : Union[{server: str, bypass: Union[str, None], username: Union[str, None], password: Union[str, None]}, None] Network proxy settings. downloads_path : Union[pathlib.Path, str, None] @@ -15071,12 +14216,6 @@ async def connect_over_cdp( page = default_context.pages[0] ``` - ```py - browser = playwright.chromium.connect_over_cdp(\"http://localhost:9222\") - default_context = browser.contexts[0] - page = default_context.pages[0] - ``` - Parameters ---------- endpoint_url : str @@ -15192,23 +14331,6 @@ async def main(): asyncio.run(main()) ``` - ```py - from playwright.sync_api import sync_playwright, Playwright - - def run(playwright: Playwright): - webkit = playwright.webkit - iphone = playwright.devices[\"iPhone 6\"] - browser = webkit.launch() - context = browser.new_context(**iphone) - page = context.new_page() - page.goto(\"http://example.com\") - # other actions... - browser.close() - - with sync_playwright() as playwright: - run(playwright) - ``` - Returns ------- Dict @@ -15323,24 +14445,18 @@ async def start( **Usage** ```py - await context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto(\"https://playwright.dev\") await context.tracing.stop(path = \"trace.zip\") ``` - ```py - context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) - page = context.new_page() - page.goto(\"https://playwright.dev\") - context.tracing.stop(path = \"trace.zip\") - ``` - Parameters ---------- name : Union[str, None] - If specified, the trace is going to be saved into the file with the given name inside the `tracesDir` folder - specified in `browser_type.launch()`. + If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the + `tracesDir` folder specified in `browser_type.launch()`. To specify the final trace zip file name, you need + to pass `path` option to `tracing.stop()` instead. title : Union[str, None] Trace name to be shown in the Trace Viewer. snapshots : Union[bool, None] @@ -15375,7 +14491,7 @@ async def start_chunk( **Usage** ```py - await context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto(\"https://playwright.dev\") @@ -15390,29 +14506,14 @@ async def start_chunk( await context.tracing.stop_chunk(path = \"trace2.zip\") ``` - ```py - context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) - page = context.new_page() - page.goto(\"https://playwright.dev\") - - context.tracing.start_chunk() - page.get_by_text(\"Get Started\").click() - # Everything between start_chunk and stop_chunk will be recorded in the trace. - context.tracing.stop_chunk(path = \"trace1.zip\") - - context.tracing.start_chunk() - page.goto(\"http://example.com\") - # Save a second trace file with different actions. - context.tracing.stop_chunk(path = \"trace2.zip\") - ``` - Parameters ---------- title : Union[str, None] Trace name to be shown in the Trace Viewer. name : Union[str, None] - If specified, the trace is going to be saved into the file with the given name inside the `tracesDir` folder - specified in `browser_type.launch()`. + If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the + `tracesDir` folder specified in `browser_type.launch()`. To specify the final trace zip file name, you need + to pass `path` option to `tracing.stop_chunk()` instead. """ return mapping.from_maybe_impl( @@ -15490,15 +14591,37 @@ def last(self) -> "Locator": banana = await page.get_by_role(\"listitem\").last ``` + Returns + ------- + Locator + """ + return mapping.from_impl(self._impl_obj.last) + + @property + def content_frame(self) -> "FrameLocator": + """Locator.content_frame + + Returns a `FrameLocator` object pointing to the same `iframe` as this locator. + + Useful when you have a `Locator` object obtained somewhere, and later on would like to interact with the content + inside the frame. + + For a reverse operation, use `frame_locator.owner()`. + + **Usage** + ```py - banana = page.get_by_role(\"listitem\").last + locator = page.locator(\"iframe[name=\\\"embedded\\\"]\") + # ... + frame_locator = locator.content_frame + await frame_locator.get_by_role(\"button\").click() ``` Returns ------- - Locator + FrameLocator """ - return mapping.from_impl(self._impl_obj.last) + return mapping.from_impl(self._impl_obj.content_frame) async def bounding_box( self, *, timeout: typing.Optional[float] = None @@ -15528,11 +14651,6 @@ async def bounding_box( await page.mouse.click(box[\"x\"] + box[\"width\"] / 2, box[\"y\"] + box[\"height\"] / 2) ``` - ```py - box = page.get_by_role(\"button\").bounding_box() - page.mouse.click(box[\"x\"] + box[\"width\"] / 2, box[\"y\"] + box[\"height\"] / 2) - ``` - Parameters ---------- timeout : Union[float, None] @@ -15583,10 +14701,6 @@ async def check( await page.get_by_role(\"checkbox\").check() ``` - ```py - page.get_by_role(\"checkbox\").check() - ``` - Parameters ---------- position : Union[{x: float, y: float}, None] @@ -15656,10 +14770,6 @@ async def click( await page.get_by_role(\"button\").click() ``` - ```py - page.get_by_role(\"button\").click() - ``` - Shift-right-click at a specific position on a canvas: ```py @@ -15668,12 +14778,6 @@ async def click( ) ``` - ```py - page.locator(\"canvas\").click( - button=\"right\", modifiers=[\"Shift\"], position={\"x\": 23, \"y\": 32} - ) - ``` - Parameters ---------- modifiers : Union[Sequence[Union["Alt", "Control", "Meta", "Shift"]], None] @@ -15806,10 +14910,6 @@ async def dispatch_event( await locator.dispatch_event(\"click\") ``` - ```py - locator.dispatch_event(\"click\") - ``` - **Details** The snippet above dispatches the `click` event on the element. Regardless of the visibility state of the element, @@ -15839,12 +14939,6 @@ async def dispatch_event( await locator.dispatch_event(\"#source\", \"dragstart\", {\"dataTransfer\": data_transfer}) ``` - ```py - # note you can only create data_transfer in chromium and firefox - data_transfer = page.evaluate_handle(\"new DataTransfer()\") - locator.dispatch_event(\"#source\", \"dragstart\", {\"dataTransfer\": data_transfer}) - ``` - Parameters ---------- type : str @@ -15889,11 +14983,6 @@ async def evaluate( assert await tweets.evaluate(\"node => node.innerText\") == \"10 retweets\" ``` - ```py - tweets = page.locator(\".tweet .retweets\") - assert tweets.evaluate(\"node => node.innerText\") == \"10 retweets\" - ``` - Parameters ---------- expression : str @@ -15939,11 +15028,6 @@ async def evaluate_all( more_than_ten = await locator.evaluate_all(\"(divs, min) => divs.length > min\", 10) ``` - ```py - locator = page.locator(\"div\") - more_than_ten = locator.evaluate_all(\"(divs, min) => divs.length > min\", 10) - ``` - Parameters ---------- expression : str @@ -16029,10 +15113,6 @@ async def fill( await page.get_by_role(\"textbox\").fill(\"example value\") ``` - ```py - page.get_by_role(\"textbox\").fill(\"example value\") - ``` - **Details** This method waits for [actionability](https://playwright.dev/python/docs/actionability) checks, focuses the element, fills it and triggers an @@ -16093,10 +15173,6 @@ async def clear( await page.get_by_role(\"textbox\").clear() ``` - ```py - page.get_by_role(\"textbox\").clear() - ``` - Parameters ---------- timeout : Union[float, None] @@ -16144,8 +15220,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -16191,10 +15272,6 @@ def get_by_alt_text( await page.get_by_alt_text(\"Playwright logo\").click() ``` - ```py - page.get_by_alt_text(\"Playwright logo\").click() - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -16236,11 +15313,6 @@ def get_by_label( await page.get_by_label(\"Password\").fill(\"secret\") ``` - ```py - page.get_by_label(\"Username\").fill(\"john\") - page.get_by_label(\"Password\").fill(\"secret\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -16280,10 +15352,6 @@ def get_by_placeholder( await page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") ``` - ```py - page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -16427,14 +15495,6 @@ def get_by_role( await page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() ``` - ```py - expect(page.get_by_role(\"heading\", name=\"Sign up\")).to_be_visible() - - page.get_by_role(\"checkbox\", name=\"Subscribe\").check() - - page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() - ``` - **Details** Role selector **does not replace** accessibility audits and conformance tests, but rather gives early feedback @@ -16530,10 +15590,6 @@ def get_by_test_id( await page.get_by_test_id(\"directions\").click() ``` - ```py - page.get_by_test_id(\"directions\").click() - ``` - **Details** By default, the `data-testid` attribute is used as a test id. Use `selectors.set_test_id_attribute()` to @@ -16592,23 +15648,6 @@ def get_by_text( page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) ``` - ```py - # Matches - page.get_by_text(\"world\") - - # Matches first
- page.get_by_text(\"Hello world\") - - # Matches second
- page.get_by_text(\"Hello\", exact=True) - - # Matches both
s - page.get_by_text(re.compile(\"Hello\")) - - # Matches second
- page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) - ``` - **Details** Matching by text always normalizes whitespace, even with exact match. For example, it turns multiple spaces into @@ -16656,10 +15695,6 @@ def get_by_title( await expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") ``` - ```py - expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") - ``` - Parameters ---------- text : Union[Pattern[str], str] @@ -16688,11 +15723,6 @@ def frame_locator(self, selector: str) -> "FrameLocator": await locator.click() ``` - ```py - locator = page.frame_locator(\"iframe\").get_by_text(\"Submit\") - locator.click() - ``` - Parameters ---------- selector : str @@ -16749,10 +15779,6 @@ def nth(self, index: int) -> "Locator": banana = await page.get_by_role(\"listitem\").nth(2) ``` - ```py - banana = page.get_by_role(\"listitem\").nth(2) - ``` - Parameters ---------- index : int @@ -16788,14 +15814,6 @@ def filter( ``` - ```py - row_locator = page.locator(\"tr\") - # ... - row_locator.filter(has_text=\"text in column 1\").filter( - has=page.get_by_role(\"button\", name=\"column 2 button\") - ).screenshot() - ``` - Parameters ---------- has_text : Union[Pattern[str], str, None] @@ -16806,8 +15824,13 @@ def filter( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -16849,15 +15872,6 @@ def or_(self, locator: "Locator") -> "Locator": await new_email.click() ``` - ```py - new_email = page.get_by_role(\"button\", name=\"New\") - dialog = page.get_by_text(\"Confirm security settings\") - expect(new_email.or_(dialog)).to_be_visible() - if (dialog.is_visible()): - page.get_by_role(\"button\", name=\"Dismiss\").click() - new_email.click() - ``` - Parameters ---------- locator : Locator @@ -16883,10 +15897,6 @@ def and_(self, locator: "Locator") -> "Locator": button = page.get_by_role(\"button\").and_(page.getByTitle(\"Subscribe\")) ``` - ```py - button = page.get_by_role(\"button\").and_(page.getByTitle(\"Subscribe\")) - ``` - Parameters ---------- locator : Locator @@ -16945,11 +15955,6 @@ async def all(self) -> typing.List["Locator"]: await li.click(); ``` - ```py - for li in page.get_by_role('listitem').all(): - li.click(); - ``` - Returns ------- List[Locator] @@ -16971,10 +15976,6 @@ async def count(self) -> int: count = await page.get_by_role(\"listitem\").count() ``` - ```py - count = page.get_by_role(\"listitem\").count() - ``` - Returns ------- int @@ -17002,28 +16003,15 @@ async def drag_to( This method drags the locator to another target locator or target position. It will first move to the source element, perform a `mousedown`, then move to the target element or position and perform a `mouseup`. - **Usage** - - ```py - source = page.locator(\"#source\") - target = page.locator(\"#target\") - - await source.drag_to(target) - # or specify exact positions relative to the top-left corners of the elements: - await source.drag_to( - target, - source_position={\"x\": 34, \"y\": 7}, - target_position={\"x\": 10, \"y\": 20} - ) - ``` - + **Usage** + ```py source = page.locator(\"#source\") target = page.locator(\"#target\") - source.drag_to(target) + await source.drag_to(target) # or specify exact positions relative to the top-left corners of the elements: - source.drag_to( + await source.drag_to( target, source_position={\"x\": 34, \"y\": 7}, target_position={\"x\": 10, \"y\": 20} @@ -17115,10 +16103,6 @@ async def hover( await page.get_by_role(\"link\").hover() ``` - ```py - page.get_by_role(\"link\").hover() - ``` - **Details** This method hovers over the element by performing the following steps: @@ -17218,10 +16202,6 @@ async def input_value(self, *, timeout: typing.Optional[float] = None) -> str: value = await page.get_by_role(\"textbox\").input_value() ``` - ```py - value = page.get_by_role(\"textbox\").input_value() - ``` - **Details** Throws elements that are not an input, textarea or a select. However, if the element is inside the `
``` - ```py - feed_handle = await page.query_selector(\".feed\") - assert await feed_handle.eval_on_selector_all(\".tweet\", \"nodes => nodes.map(n => n.innerText)\") == [\"hello!\", \"hi!\"] - ``` - ```py feed_handle = page.query_selector(\".feed\") assert feed_handle.eval_on_selector_all(\".tweet\", \"nodes => nodes.map(n => n.innerText)\") == [\"hello!\", \"hi!\"] @@ -3037,9 +2878,8 @@ def wait_for_element_state( Depending on the `state` parameter, this method waits for one of the [actionability](https://playwright.dev/python/docs/actionability) checks to pass. This method throws when the element is detached while waiting, unless waiting for the `\"hidden\"` state. - `\"visible\"` Wait until the element is [visible](https://playwright.dev/python/docs/actionability#visible). - - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or - [not attached](https://playwright.dev/python/docs/actionability#attached). Note that waiting for hidden does not throw when the element - detaches. + - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or not attached. Note that + waiting for hidden does not throw when the element detaches. - `\"stable\"` Wait until the element is both [visible](https://playwright.dev/python/docs/actionability#visible) and [stable](https://playwright.dev/python/docs/actionability#stable). - `\"enabled\"` Wait until the element is [enabled](https://playwright.dev/python/docs/actionability#enabled). @@ -3085,13 +2925,6 @@ def wait_for_selector( **Usage** - ```py - await page.set_content(\"
\") - div = await page.query_selector(\"div\") - # waiting for the \"span\" selector relative to the div. - span = await div.wait_for_selector(\"span\", state=\"attached\") - ``` - ```py page.set_content(\"
\") div = page.query_selector(\"div\") @@ -3157,11 +2990,6 @@ def snapshot( An example of dumping the entire accessibility tree: - ```py - snapshot = await page.accessibility.snapshot() - print(snapshot) - ``` - ```py snapshot = page.accessibility.snapshot() print(snapshot) @@ -3169,22 +2997,6 @@ def snapshot( An example of logging the focused node's name: - ```py - def find_focused_node(node): - if node.get(\"focused\"): - return node - for child in (node.get(\"children\") or []): - found_node = find_focused_node(child) - if found_node: - return found_node - return None - - snapshot = await page.accessibility.snapshot() - node = find_focused_node(snapshot) - if node: - print(node[\"name\"]) - ``` - ```py def find_focused_node(node): if node.get(\"focused\"): @@ -3458,12 +3270,6 @@ def expect_navigation( This method waits for the frame to navigate to a new URL. It is useful for when you run code which will indirectly cause the frame to navigate. Consider this example: - ```py - async with frame.expect_navigation(): - await frame.click(\"a.delayed-navigation\") # clicking the link will indirectly cause a navigation - # Resolves after navigation has finished - ``` - ```py with frame.expect_navigation(): frame.click(\"a.delayed-navigation\") # clicking the link will indirectly cause a navigation @@ -3519,11 +3325,6 @@ def wait_for_url( **Usage** - ```py - await frame.click(\"a.delayed-navigation\") # clicking the link will indirectly cause a navigation - await frame.wait_for_url(\"**/target.html\") - ``` - ```py frame.click(\"a.delayed-navigation\") # clicking the link will indirectly cause a navigation frame.wait_for_url(\"**/target.html\") @@ -3576,11 +3377,6 @@ def wait_for_load_state( **Usage** - ```py - await frame.click(\"button\") # click triggers navigation. - await frame.wait_for_load_state() # the promise resolves after \"load\" event. - ``` - ```py frame.click(\"button\") # click triggers navigation. frame.wait_for_load_state() # the promise resolves after \"load\" event. @@ -3618,12 +3414,6 @@ def frame_element(self) -> "ElementHandle": **Usage** - ```py - frame_element = await frame.frame_element() - content_frame = await frame_element.content_frame() - assert frame == content_frame - ``` - ```py frame_element = frame.frame_element() content_frame = frame_element.content_frame() @@ -3653,11 +3443,6 @@ def evaluate( **Usage** - ```py - result = await frame.evaluate(\"([x, y]) => Promise.resolve(x * y)\", [7, 8]) - print(result) # prints \"56\" - ``` - ```py result = frame.evaluate(\"([x, y]) => Promise.resolve(x * y)\", [7, 8]) print(result) # prints \"56\" @@ -3665,12 +3450,6 @@ def evaluate( A string can also be passed in instead of a function. - ```py - print(await frame.evaluate(\"1 + 2\")) # prints \"3\" - x = 10 - print(await frame.evaluate(f\"1 + {x}\")) # prints \"11\" - ``` - ```py print(frame.evaluate(\"1 + 2\")) # prints \"3\" x = 10 @@ -3679,12 +3458,6 @@ def evaluate( `ElementHandle` instances can be passed as an argument to the `frame.evaluate()`: - ```py - body_handle = await frame.evaluate(\"document.body\") - html = await frame.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) - await body_handle.dispose() - ``` - ```py body_handle = frame.evaluate(\"document.body\") html = frame.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) @@ -3725,11 +3498,6 @@ def evaluate_handle( **Usage** - ```py - a_window_handle = await frame.evaluate_handle(\"Promise.resolve(window)\") - a_window_handle # handle for the window object. - ``` - ```py a_window_handle = frame.evaluate_handle(\"Promise.resolve(window)\") a_window_handle # handle for the window object. @@ -3737,23 +3505,12 @@ def evaluate_handle( A string can also be passed in instead of a function. - ```py - a_handle = await page.evaluate_handle(\"document\") # handle for the \"document\" - ``` - ```py a_handle = page.evaluate_handle(\"document\") # handle for the \"document\" ``` `JSHandle` instances can be passed as an argument to the `frame.evaluate_handle()`: - ```py - a_handle = await page.evaluate_handle(\"document.body\") - result_handle = await page.evaluate_handle(\"body => body.innerHTML\", a_handle) - print(await result_handle.json_value()) - await result_handle.dispose() - ``` - ```py a_handle = page.evaluate_handle(\"document.body\") result_handle = page.evaluate_handle(\"body => body.innerHTML\", a_handle) @@ -3861,26 +3618,6 @@ def wait_for_selector( This method works across navigations: - ```py - import asyncio - from playwright.async_api import async_playwright, Playwright - - async def run(playwright: Playwright): - chromium = playwright.chromium - browser = await chromium.launch() - page = await browser.new_page() - for current_url in [\"https://google.com\", \"https://bbc.com\"]: - await page.goto(current_url, wait_until=\"domcontentloaded\") - element = await page.main_frame.wait_for_selector(\"img\") - print(\"Loaded image: \" + str(await element.get_attribute(\"src\"))) - await browser.close() - - async def main(): - async with async_playwright() as playwright: - await run(playwright) - asyncio.run(main()) - ``` - ```py from playwright.sync_api import sync_playwright, Playwright @@ -4163,10 +3900,6 @@ def dispatch_event( **Usage** - ```py - await frame.dispatch_event(\"button#submit\", \"click\") - ``` - ```py frame.dispatch_event(\"button#submit\", \"click\") ``` @@ -4188,12 +3921,6 @@ def dispatch_event( You can also specify `JSHandle` as the property value if you want live objects to be passed into the event: - ```py - # note you can only create data_transfer in chromium and firefox - data_transfer = await frame.evaluate_handle(\"new DataTransfer()\") - await frame.dispatch_event(\"#source\", \"dragstart\", { \"dataTransfer\": data_transfer }) - ``` - ```py # note you can only create data_transfer in chromium and firefox data_transfer = frame.evaluate_handle(\"new DataTransfer()\") @@ -4249,12 +3976,6 @@ def eval_on_selector( **Usage** - ```py - search_value = await frame.eval_on_selector(\"#search\", \"el => el.value\") - preload_href = await frame.eval_on_selector(\"link[rel=preload]\", \"el => el.href\") - html = await frame.eval_on_selector(\".main-container\", \"(e, suffix) => e.outerHTML + suffix\", \"hello\") - ``` - ```py search_value = frame.eval_on_selector(\"#search\", \"el => el.value\") preload_href = frame.eval_on_selector(\"link[rel=preload]\", \"el => el.href\") @@ -4305,10 +4026,6 @@ def eval_on_selector_all( **Usage** - ```py - divs_counts = await frame.eval_on_selector_all(\"div\", \"(divs, min) => divs.length >= min\", 10) - ``` - ```py divs_counts = frame.eval_on_selector_all(\"div\", \"(divs, min) => divs.length >= min\", 10) ``` @@ -4799,8 +4516,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -4842,10 +4564,6 @@ def get_by_alt_text( Playwright logo ``` - ```py - await page.get_by_alt_text(\"Playwright logo\").click() - ``` - ```py page.get_by_alt_text(\"Playwright logo\").click() ``` @@ -4886,11 +4604,6 @@ def get_by_label( ``` - ```py - await page.get_by_label(\"Username\").fill(\"john\") - await page.get_by_label(\"Password\").fill(\"secret\") - ``` - ```py page.get_by_label(\"Username\").fill(\"john\") page.get_by_label(\"Password\").fill(\"secret\") @@ -4931,10 +4644,6 @@ def get_by_placeholder( You can fill the input after locating it by the placeholder text: - ```py - await page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") - ``` - ```py page.get_by_placeholder(\"name@example.com\").fill(\"playwright@microsoft.com\") ``` @@ -5074,14 +4783,6 @@ def get_by_role( You can locate each element by it's implicit role: - ```py - await expect(page.get_by_role(\"heading\", name=\"Sign up\")).to_be_visible() - - await page.get_by_role(\"checkbox\", name=\"Subscribe\").check() - - await page.get_by_role(\"button\", name=re.compile(\"submit\", re.IGNORECASE)).click() - ``` - ```py expect(page.get_by_role(\"heading\", name=\"Sign up\")).to_be_visible() @@ -5181,10 +4882,6 @@ def get_by_test_id( You can locate the element by it's test id: - ```py - await page.get_by_test_id(\"directions\").click() - ``` - ```py page.get_by_test_id(\"directions\").click() ``` @@ -5247,23 +4944,6 @@ def get_by_text( page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) ``` - ```py - # Matches - page.get_by_text(\"world\") - - # Matches first
- page.get_by_text(\"Hello world\") - - # Matches second
- page.get_by_text(\"Hello\", exact=True) - - # Matches both
s - page.get_by_text(re.compile(\"Hello\")) - - # Matches second
- page.get_by_text(re.compile(\"^hello$\", re.IGNORECASE)) - ``` - **Details** Matching by text always normalizes whitespace, even with exact match. For example, it turns multiple spaces into @@ -5307,10 +4987,6 @@ def get_by_title( You can check the issues count after locating it by the title text: - ```py - await expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") - ``` - ```py expect(page.get_by_title(\"Issues count\")).to_have_text(\"25 issues\") ``` @@ -5341,11 +5017,6 @@ def frame_locator(self, selector: str) -> "FrameLocator": Following snippet locates element with text \"Submit\" in the iframe with id `my-frame`, like `', content_type="text/html" + body='', + content_type="text/html", ), ) await page.route( @@ -638,6 +640,26 @@ async def test_locators_frame_should_work_with_locator_frame_locator( await button.click() +async def test_locator_content_frame_should_work(page: Page, server: Server) -> None: + await route_iframe(page) + await page.goto(server.EMPTY_PAGE) + locator = page.locator("iframe") + frame_locator = locator.content_frame + button = frame_locator.locator("button") + assert await button.inner_text() == "Hello iframe" + await expect(button).to_have_text("Hello iframe") + await button.click() + + +async def test_frame_locator_owner_should_work(page: Page, server: Server) -> None: + await route_iframe(page) + await page.goto(server.EMPTY_PAGE) + frame_locator = page.frame_locator("iframe") + locator = frame_locator.owner + await expect(locator).to_be_visible() + assert await locator.get_attribute("name") == "frame1" + + async def route_ambiguous(page: Page) -> None: await page.route( "**/empty.html", @@ -1083,3 +1105,19 @@ async def test_locator_all_should_work(page: Page) -> None: for p in await page.locator("p").all(): texts.append(await p.text_content()) assert texts == ["A", "B", "C"] + + +async def test_locator_click_timeout_error_should_contain_call_log(page: Page) -> None: + with pytest.raises(Error) as exc_info: + await page.get_by_role("button", name="Hello Python").click(timeout=42) + formatted_exception = "".join( + traceback.format_exception(type(exc_info.value), value=exc_info.value, tb=None) + ) + assert "Locator.click: Timeout 42ms exceeded." in formatted_exception + assert ( + 'waiting for get_by_role("button", name="Hello Python")' in formatted_exception + ) + assert ( + "During handling of the above exception, another exception occurred" + not in formatted_exception + ) diff --git a/tests/async/test_network.py b/tests/async/test_network.py index 486a98914..b97d38f29 100644 --- a/tests/async/test_network.py +++ b/tests/async/test_network.py @@ -633,7 +633,7 @@ async def test_network_events_request_failed( ) -> None: def handle_request(request: TestServerRequest) -> None: request.setHeader("Content-Type", "text/css") - request.transport.loseConnection() + request.loseConnection() server.set_route("/one-style.css", handle_request) @@ -830,7 +830,10 @@ async def test_set_extra_http_headers_should_throw_for_non_string_header_values( except Error as exc: error = exc assert error - assert error.message == "headers[0].value: expected string, got number" + assert ( + error.message + == "Page.set_extra_http_headers: headers[0].value: expected string, got number" + ) async def test_response_server_addr(page: Page, server: Server) -> None: diff --git a/tests/async/test_page_add_locator_handler.py b/tests/async/test_page_add_locator_handler.py new file mode 100644 index 000000000..8eb08c59d --- /dev/null +++ b/tests/async/test_page_add_locator_handler.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio + +import pytest + +from playwright.async_api import Error, Page, expect +from tests.server import Server +from tests.utils import TARGET_CLOSED_ERROR_MESSAGE + + +async def test_should_work(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/input/handle-locator.html") + + before_count = 0 + after_count = 0 + + async def handler() -> None: + nonlocal before_count + nonlocal after_count + before_count += 1 + await page.locator("#close").click() + after_count += 1 + + await page.add_locator_handler( + page.locator("text=This interstitial covers the button"), handler + ) + + for args in [ + ["mouseover", 1], + ["mouseover", 1, "capture"], + ["mouseover", 2], + ["mouseover", 2, "capture"], + ["pointerover", 1], + ["pointerover", 1, "capture"], + ["none", 1], + ["remove", 1], + ["hide", 1], + ]: + await page.locator("#aside").hover() + before_count = 0 + after_count = 0 + await page.evaluate( + "(args) => { window.clicked = 0; window.setupAnnoyingInterstitial(...args); }", + args, + ) + assert before_count == 0 + assert after_count == 0 + await page.locator("#target").click() + assert before_count == args[1] + assert after_count == args[1] + assert await page.evaluate("window.clicked") == 1 + await expect(page.locator("#interstitial")).not_to_be_visible() + + +async def test_should_work_with_a_custom_check(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/input/handle-locator.html") + + async def handler() -> None: + if await page.get_by_text("This interstitial covers the button").is_visible(): + await page.locator("#close").click() + + await page.add_locator_handler(page.locator("body"), handler) + + for args in [ + ["mouseover", 2], + ["none", 1], + ["remove", 1], + ["hide", 1], + ]: + await page.locator("#aside").hover() + await page.evaluate( + "(args) => { window.clicked = 0; window.setupAnnoyingInterstitial(...args); }", + args, + ) + await page.locator("#target").click() + assert await page.evaluate("window.clicked") == 1 + await expect(page.locator("#interstitial")).not_to_be_visible() + + +async def test_should_work_with_locator_hover(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/input/handle-locator.html") + + await page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), + lambda: page.locator("#close").click(), + ) + + await page.locator("#aside").hover() + await page.evaluate( + '() => { window.setupAnnoyingInterstitial("pointerover", 1, "capture"); }' + ) + await page.locator("#target").hover() + await expect(page.locator("#interstitial")).not_to_be_visible() + assert ( + await page.eval_on_selector( + "#target", "e => window.getComputedStyle(e).backgroundColor" + ) + == "rgb(255, 255, 0)" + ) + + +async def test_should_not_work_with_force_true(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/input/handle-locator.html") + + await page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), + lambda: page.locator("#close").click(), + ) + + await page.locator("#aside").hover() + await page.evaluate('() => { window.setupAnnoyingInterstitial("none", 1); }') + await page.locator("#target").click(force=True, timeout=2000) + assert await page.locator("#interstitial").is_visible() + assert await page.evaluate("window.clicked") is None + + +async def test_should_throw_when_page_closes(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/input/handle-locator.html") + + await page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), lambda: page.close() + ) + + await page.locator("#aside").hover() + await page.evaluate( + '() => { window.clicked = 0; window.setupAnnoyingInterstitial("mouseover", 1); }' + ) + with pytest.raises(Error) as exc: + await page.locator("#target").click() + assert TARGET_CLOSED_ERROR_MESSAGE in exc.value.message + + +async def test_should_throw_when_handler_times_out(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/input/handle-locator.html") + + called = 0 + stall_future: asyncio.Future[None] = asyncio.Future() + + async def handler() -> None: + nonlocal called + called += 1 + # Deliberately timeout. + await stall_future + + await page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), handler + ) + + await page.locator("#aside").hover() + await page.evaluate( + '() => { window.clicked = 0; window.setupAnnoyingInterstitial("mouseover", 1); }' + ) + with pytest.raises(Error) as exc: + await page.locator("#target").click(timeout=3000) + assert "Timeout 3000ms exceeded" in exc.value.message + + with pytest.raises(Error) as exc: + await page.locator("#target").click(timeout=3000) + assert "Timeout 3000ms exceeded" in exc.value.message + + # Should not enter the same handler while it is still running. + assert called == 1 + stall_future.cancel() + + +async def test_should_work_with_to_be_visible(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/input/handle-locator.html") + + called = 0 + + async def handler() -> None: + nonlocal called + called += 1 + await page.locator("#close").click() + + await page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), handler + ) + + await page.evaluate( + '() => { window.clicked = 0; window.setupAnnoyingInterstitial("remove", 1); }' + ) + await expect(page.locator("#target")).to_be_visible() + await expect(page.locator("#interstitial")).not_to_be_visible() + assert called == 1 diff --git a/tests/async/test_page_network_request.py b/tests/async/test_page_network_request.py index 375342ae8..779875eda 100644 --- a/tests/async/test_page_network_request.py +++ b/tests/async/test_page_network_request.py @@ -42,3 +42,22 @@ async def test_should_not_allow_to_access_frame_on_popup_main_request( await response.finished() await popup_promise await clicked + + +async def test_should_parse_the_data_if_content_type_is_application_x_www_form_urlencoded_charset_UTF_8( + page: Page, server: Server +) -> None: + await page.goto(server.EMPTY_PAGE) + async with page.expect_event("request") as request_info: + await page.evaluate( + """() => fetch('./post', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: 'foo=bar&baz=123' + })""" + ) + request = await request_info.value + assert request + assert request.post_data_json == {"foo": "bar", "baz": "123"} diff --git a/tests/async/test_page_request_fallback.py b/tests/async/test_page_request_fallback.py index 456c911a3..1cea1204a 100644 --- a/tests/async/test_page_request_fallback.py +++ b/tests/async/test_page_request_fallback.py @@ -164,10 +164,9 @@ async def handler_with_header_mods(route: Route) -> None: await page.route("**/*", handler_with_header_mods) await page.goto(server.EMPTY_PAGE) - async with page.expect_request("/sleep.zzz") as request_info: + with server.expect_request("/sleep.zzz") as server_request_info: await page.evaluate("() => fetch('/sleep.zzz')") - request = await request_info.value - values.append(request.headers.get("foo")) + values.append(server_request_info.value.getHeader("foo")) assert values == ["bar", "bar", "bar"] diff --git a/tests/async/test_interception.py b/tests/async/test_page_route.py similarity index 92% rename from tests/async/test_interception.py rename to tests/async/test_page_route.py index 911d7ddd8..8e0b74130 100644 --- a/tests/async/test_interception.py +++ b/tests/async/test_page_route.py @@ -20,6 +20,7 @@ import pytest +from playwright._impl._glob import glob_to_regex from playwright.async_api import ( Browser, BrowserContext, @@ -1009,21 +1010,28 @@ async def handle_request(route: Route) -> None: assert len(intercepted) == 1 -async def test_context_route_should_support_times_parameter( +async def test_should_work_if_handler_with_times_parameter_was_removed_from_another_handler( context: BrowserContext, page: Page, server: Server ) -> None: intercepted = [] - async def handle_request(route: Route) -> None: + async def handler(route: Route) -> None: + intercepted.append("first") await route.continue_() - intercepted.append(True) - await context.route("**/empty.html", handle_request, times=1) + await page.route("**/*", handler, times=1) + async def handler2(route: Route) -> None: + intercepted.append("second") + await page.unroute("**/*", handler) + await route.fallback() + + await page.route("**/*", handler2) await page.goto(server.EMPTY_PAGE) + assert intercepted == ["second"] + intercepted.clear() await page.goto(server.EMPTY_PAGE) - await page.goto(server.EMPTY_PAGE) - assert len(intercepted) == 1 + assert intercepted == ["second"] async def test_should_fulfill_with_global_fetch_result( @@ -1041,3 +1049,47 @@ async def handle_request(route: Route) -> None: assert response assert response.status == 200 assert await response.json() == {"foo": "bar"} + + +async def test_glob_to_regex() -> None: + assert glob_to_regex("**/*.js").match("https://localhost:8080/foo.js") + assert not glob_to_regex("**/*.css").match("https://localhost:8080/foo.js") + assert not glob_to_regex("*.js").match("https://localhost:8080/foo.js") + assert glob_to_regex("https://**/*.js").match("https://localhost:8080/foo.js") + assert glob_to_regex("http://localhost:8080/simple/path.js").match( + "http://localhost:8080/simple/path.js" + ) + assert glob_to_regex("http://localhost:8080/?imple/path.js").match( + "http://localhost:8080/Simple/path.js" + ) + assert glob_to_regex("**/{a,b}.js").match("https://localhost:8080/a.js") + assert glob_to_regex("**/{a,b}.js").match("https://localhost:8080/b.js") + assert not glob_to_regex("**/{a,b}.js").match("https://localhost:8080/c.js") + + assert glob_to_regex("**/*.{png,jpg,jpeg}").match("https://localhost:8080/c.jpg") + assert glob_to_regex("**/*.{png,jpg,jpeg}").match("https://localhost:8080/c.jpeg") + assert glob_to_regex("**/*.{png,jpg,jpeg}").match("https://localhost:8080/c.png") + assert not glob_to_regex("**/*.{png,jpg,jpeg}").match( + "https://localhost:8080/c.css" + ) + assert glob_to_regex("foo*").match("foo.js") + assert not glob_to_regex("foo*").match("foo/bar.js") + assert not glob_to_regex("http://localhost:3000/signin-oidc*").match( + "http://localhost:3000/signin-oidc/foo" + ) + assert glob_to_regex("http://localhost:3000/signin-oidc*").match( + "http://localhost:3000/signin-oidcnice" + ) + + assert glob_to_regex("**/three-columns/settings.html?**id=[a-z]**").match( + "http://mydomain:8080/blah/blah/three-columns/settings.html?id=settings-e3c58efe-02e9-44b0-97ac-dd138100cf7c&blah" + ) + + assert glob_to_regex("\\?") == re.compile(r"^\?$") + assert glob_to_regex("\\") == re.compile(r"^\\$") + assert glob_to_regex("\\\\") == re.compile(r"^\\$") + assert glob_to_regex("\\[") == re.compile(r"^\[$") + assert glob_to_regex("[a-z]") == re.compile(r"^[a-z]$") + assert glob_to_regex("$^+.\\*()|\\?\\{\\}\\[\\]") == re.compile( + r"^\$\^\+\.\*\(\)\|\?\{\}\[\]$" + ) diff --git a/tests/async/test_pdf.py b/tests/async/test_pdf.py index a57a33d05..7e916dc11 100644 --- a/tests/async/test_pdf.py +++ b/tests/async/test_pdf.py @@ -18,16 +18,30 @@ import pytest from playwright.async_api import Page +from tests.server import Server + +pytestmark = pytest.mark.only_browser("chromium") -@pytest.mark.only_browser("chromium") async def test_should_be_able_to_save_pdf_file(page: Page, tmpdir: Path) -> None: output_file = tmpdir / "foo.png" await page.pdf(path=str(output_file)) assert os.path.getsize(output_file) > 0 -@pytest.mark.only_browser("chromium") async def test_should_be_able_capture_pdf_without_path(page: Page) -> None: buffer = await page.pdf() assert buffer + + +async def test_should_be_able_to_generate_outline( + page: Page, server: Server, tmpdir: Path +) -> None: + await page.goto(server.PREFIX + "/headings.html") + output_file_no_outline = tmpdir / "outputNoOutline.pdf" + output_file_outline = tmpdir / "outputOutline.pdf" + await page.pdf(path=output_file_no_outline) + await page.pdf(path=output_file_outline, tagged=True, outline=True) + assert os.path.getsize(output_file_outline) > os.path.getsize( + output_file_no_outline + ) diff --git a/tests/async/test_queryselector.py b/tests/async/test_queryselector.py index 0a09a40c9..28dd720d7 100644 --- a/tests/async/test_queryselector.py +++ b/tests/async/test_queryselector.py @@ -182,7 +182,7 @@ async def test_selectors_register_should_handle_errors( await selectors.register("$", dummy_selector_engine_script) assert ( exc.value.message - == "Selector engine name may only contain [a-zA-Z0-9_] characters" + == "Selectors.register: Selector engine name may only contain [a-zA-Z0-9_] characters" ) # Selector names are case-sensitive. @@ -195,11 +195,16 @@ async def test_selectors_register_should_handle_errors( with pytest.raises(Error) as exc: await selectors.register("dummy", dummy_selector_engine_script) - assert exc.value.message == '"dummy" selector engine has been already registered' + assert ( + exc.value.message + == 'Selectors.register: "dummy" selector engine has been already registered' + ) with pytest.raises(Error) as exc: await selectors.register("css", dummy_selector_engine_script) - assert exc.value.message == '"css" is a predefined selector engine' + assert ( + exc.value.message == 'Selectors.register: "css" is a predefined selector engine' + ) async def test_should_work_with_layout_selectors(page: Page) -> None: diff --git a/tests/async/test_selector_generator.py b/tests/async/test_selector_generator.py index c03e575d3..1239973a5 100644 --- a/tests/async/test_selector_generator.py +++ b/tests/async/test_selector_generator.py @@ -21,27 +21,30 @@ async def test_should_use_data_test_id_in_strict_errors( page: Page, playwright: Playwright ) -> None: playwright.selectors.set_test_id_attribute("data-custom-id") - await page.set_content( + try: + await page.set_content( + """ +
+
+
+
+
+
+
+
+
+
+
+
+
""" -
-
-
-
-
-
-
-
-
-
-
-
-
- """ - ) - with pytest.raises(Error) as exc_info: - await page.locator(".foo").hover(timeout=200) - assert "strict mode violation" in exc_info.value.message - assert '
None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.continue_() + + await context.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await context.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + await context.unroute( + re.compile(".*"), + _handler2, + ) + route_barrier_future.set_result(None) + await navigation_task + assert second_handler_called + + +async def test_context_unroute_all_removes_all_handlers( + page: Page, context: BrowserContext, server: Server +) -> None: + await context.route( + "**/*", + lambda route: route.abort(), + ) + await context.route( + "**/empty.html", + lambda route: route.abort(), + ) + await context.unroute_all() + await page.goto(server.EMPTY_PAGE) + + +async def test_context_unroute_all_should_not_wait_for_pending_handlers_to_complete( + page: Page, context: BrowserContext, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await context.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await context.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + nonlocal did_unroute + await context.unroute_all(behavior="wait") + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + assert did_unroute is False + route_barrier_future.set_result(None) + await unroute_task + assert did_unroute + await navigation_task + assert second_handler_called is False + + +async def test_context_unroute_all_should_not_wait_for_pending_handlers_to_complete_if_behavior_is_ignore_errors( + page: Page, context: BrowserContext, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await context.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + raise Exception("Handler error") + + await context.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + await context.unroute_all(behavior="ignoreErrors") + nonlocal did_unroute + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + await unroute_task + assert did_unroute + route_barrier_future.set_result(None) + try: + await navigation_task + except Error: + pass + # The error in the unrouted handler should be silently caught and remaining handler called. + assert not second_handler_called + + +async def test_page_close_should_not_wait_for_active_route_handlers_on_the_owning_context( + page: Page, context: BrowserContext, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await context.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + await page.route( + re.compile(".*"), + lambda route: route.fallback(), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + await route_future + await page.close() + + +async def test_context_close_should_not_wait_for_active_route_handlers_on_the_owned_pages( + page: Page, context: BrowserContext, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + await page.route(re.compile(".*"), lambda route: route.fallback()) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + await route_future + await context.close() + + +async def test_page_unroute_should_not_wait_for_pending_handlers_to_complete( + page: Page, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.continue_() + + await page.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await page.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + await page.unroute( + re.compile(".*"), + _handler2, + ) + route_barrier_future.set_result(None) + await navigation_task + assert second_handler_called + + +async def test_page_unroute_all_removes_all_routes(page: Page, server: Server) -> None: + await page.route( + "**/*", + lambda route: route.abort(), + ) + await page.route( + "**/empty.html", + lambda route: route.abort(), + ) + await page.unroute_all() + response = must(await page.goto(server.EMPTY_PAGE)) + assert response.ok + + +async def test_page_unroute_should_wait_for_pending_handlers_to_complete( + page: Page, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await page.route( + "**/*", + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await page.route( + "**/*", + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + await page.unroute_all(behavior="wait") + nonlocal did_unroute + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + assert did_unroute is False + route_barrier_future.set_result(None) + await unroute_task + assert did_unroute + await navigation_task + assert second_handler_called is False + + +async def test_page_unroute_all_should_not_wait_for_pending_handlers_to_complete_if_behavior_is_ignore_errors( + page: Page, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await page.route(re.compile(".*"), _handler1) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + raise Exception("Handler error") + + await page.route(re.compile(".*"), _handler2) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + await page.unroute_all(behavior="ignoreErrors") + nonlocal did_unroute + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + await unroute_task + assert did_unroute + route_barrier_future.set_result(None) + try: + await navigation_task + except Error: + pass + # The error in the unrouted handler should be silently caught. + assert not second_handler_called + + +async def test_page_close_does_not_wait_for_active_route_handlers( + page: Page, server: Server +) -> None: + stalling_future: "asyncio.Future[None]" = asyncio.Future() + second_handler_called = False + + def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + + await page.route( + "**/*", + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await stalling_future + + await page.route( + "**/*", + _handler2, + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + await route_future + await page.close() + await asyncio.sleep(0.5) + assert not second_handler_called + stalling_future.cancel() + + +async def test_route_continue_should_not_throw_if_page_has_been_closed( + page: Page, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + route = await route_future + await page.close() + # Should not throw. + await route.continue_() + + +async def test_route_fallback_should_not_throw_if_page_has_been_closed( + page: Page, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + route = await route_future + await page.close() + # Should not throw. + await route.fallback() + + +async def test_route_fulfill_should_not_throw_if_page_has_been_closed( + page: Page, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + "**/*", + lambda route: route_future.set_result(route), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + route = await route_future + await page.close() + # Should not throw. + await route.fulfill() diff --git a/tests/conftest.py b/tests/conftest.py index 6dbd34478..d5e9226f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -233,7 +233,7 @@ def __init__( node_executable = driver_dir / "node.exe" else: node_executable = driver_dir / "node" - cli_js = driver_dir / "package" / "lib" / "cli" / "cli.js" + cli_js = driver_dir / "package" / "cli.js" tmpfile.write_text(json.dumps(launch_server_options)) self.process = subprocess.Popen( [ diff --git a/tests/sync/conftest.py b/tests/sync/conftest.py index 075cccde2..b825ca2fe 100644 --- a/tests/sync/conftest.py +++ b/tests/sync/conftest.py @@ -13,9 +13,11 @@ # limitations under the License. -from typing import Dict, Generator +import asyncio +from typing import Any, Callable, Dict, Generator, List import pytest +from greenlet import greenlet from playwright.sync_api import ( Browser, @@ -53,7 +55,7 @@ def browser_type( browser_type = playwright.firefox elif browser_name == "webkit": browser_type = playwright.webkit - assert browser_type + assert browser_type, f"Unkown browser name '{browser_name}'" yield browser_type @@ -83,3 +85,39 @@ def page(context: BrowserContext) -> Generator[Page, None, None]: @pytest.fixture(scope="session") def selectors(playwright: Playwright) -> Selectors: return playwright.selectors + + +@pytest.fixture(scope="session") +def sync_gather(playwright: Playwright) -> Generator[Callable, None, None]: + def _sync_gather_impl(*actions: Callable) -> List[Any]: + g_self = greenlet.getcurrent() + results: Dict[Callable, Any] = {} + exceptions: List[Exception] = [] + + def action_wrapper(action: Callable) -> Callable: + def body() -> Any: + try: + results[action] = action() + except Exception as e: + results[action] = e + exceptions.append(e) + g_self.switch() + + return body + + async def task() -> None: + for action in actions: + g = greenlet(action_wrapper(action)) + g.switch() + + asyncio.create_task(task()) + + while len(results) < len(actions): + playwright._dispatcher_fiber.switch() + + if exceptions: + raise exceptions[0] + + return list(map(lambda action: results[action], actions)) + + yield _sync_gather_impl diff --git a/tests/sync/test_assertions.py b/tests/sync/test_assertions.py index f2df44ab5..d7180fc94 100644 --- a/tests/sync/test_assertions.py +++ b/tests/sync/test_assertions.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import re -from datetime import datetime import pytest @@ -163,7 +163,11 @@ def test_assertions_locator_to_have_js_property(page: Page, server: Server) -> N ) expect(page.locator("div")).to_have_js_property( "foo", - {"a": 1, "b": "string", "c": datetime.utcfromtimestamp(1627503992000 / 1000)}, + { + "a": 1, + "b": "string", + "c": datetime.datetime.fromtimestamp(1627503992000 / 1000), + }, ) diff --git a/tests/sync/test_browsercontext_request_fallback.py b/tests/sync/test_browsercontext_request_fallback.py index e653800d7..6feb19942 100644 --- a/tests/sync/test_browsercontext_request_fallback.py +++ b/tests/sync/test_browsercontext_request_fallback.py @@ -174,10 +174,9 @@ def handler_with_header_mods(route: Route) -> None: context.route("**/*", handler_with_header_mods) page.goto(server.EMPTY_PAGE) - with page.expect_request("/sleep.zzz") as request_info: + with server.expect_request("/sleep.zzz") as server_request_info: page.evaluate("() => fetch('/sleep.zzz')") - request = request_info.value - values.append(request.headers.get("foo")) + values.append(server_request_info.value.getHeader("foo")) assert values == ["bar", "bar", "bar"] diff --git a/tests/sync/test_browsercontext_request_intercept.py b/tests/sync/test_browsercontext_request_intercept.py index b136038ec..16cca8cfd 100644 --- a/tests/sync/test_browsercontext_request_intercept.py +++ b/tests/sync/test_browsercontext_request_intercept.py @@ -14,6 +14,7 @@ from pathlib import Path +import pytest from twisted.web import http from playwright.sync_api import BrowserContext, Page, Route @@ -121,3 +122,15 @@ def handle_route(route: Route) -> None: assert request.uri.decode() == "/title.html" original = (assetdir / "title.html").read_text() assert response.text() == original + + +def test_should_show_exception_after_fulfill(page: Page, server: Server) -> None: + def _handle(route: Route) -> None: + route.continue_() + raise Exception("Exception text!?") + + page.route("*/**", _handle) + page.goto(server.EMPTY_PAGE) + # Any next API call should throw because handler did throw during previous goto() + with pytest.raises(Exception, match="Exception text!?"): + page.goto(server.EMPTY_PAGE) diff --git a/tests/sync/test_browsercontext_storage_state.py b/tests/sync/test_browsercontext_storage_state.py index fc901a5cf..c785b1479 100644 --- a/tests/sync/test_browsercontext_storage_state.py +++ b/tests/sync/test_browsercontext_storage_state.py @@ -31,13 +31,13 @@ def test_should_capture_local_storage(context: BrowserContext) -> None: assert origins assert len(origins) == 2 assert origins[0] == { - "origin": "https://www.example.com", - "localStorage": [{"name": "name1", "value": "value1"}], - } - assert origins[1] == { "origin": "https://www.domain.com", "localStorage": [{"name": "name2", "value": "value2"}], } + assert origins[1] == { + "origin": "https://www.example.com", + "localStorage": [{"name": "name1", "value": "value1"}], + } def test_should_set_local_storage(browser: Browser) -> None: diff --git a/tests/sync/test_element_handle.py b/tests/sync/test_element_handle.py index d5e1055ce..c2faa4a6e 100644 --- a/tests/sync/test_element_handle.py +++ b/tests/sync/test_element_handle.py @@ -401,7 +401,8 @@ def test_should_timeout_waiting_for_visible(page: Page) -> None: assert div with pytest.raises(Error) as exc_info: div.scroll_into_view_if_needed(timeout=3000) - assert "element is not displayed, retrying in 100ms" in exc_info.value.message + assert "element is not visible" in exc_info.value.message + assert "retrying scroll into view action" in exc_info.value.message def test_fill_input(page: Page, server: Server) -> None: @@ -660,3 +661,11 @@ def test_set_checked(page: Page) -> None: assert page.evaluate("checkbox.checked") input.set_checked(False) assert page.evaluate("checkbox.checked") is False + + +def test_should_allow_disposing_twice(page: Page) -> None: + page.set_content("
39
") + element = page.query_selector("section") + assert element + element.dispose() + element.dispose() diff --git a/tests/sync/test_fetch_browser_context.py b/tests/sync/test_fetch_browser_context.py index 5a8b38769..dd10d5adf 100644 --- a/tests/sync/test_fetch_browser_context.py +++ b/tests/sync/test_fetch_browser_context.py @@ -52,7 +52,7 @@ def test_fetch_should_work(context: BrowserContext, server: Server) -> None: def test_should_throw_on_network_error(context: BrowserContext, server: Server) -> None: - server.set_route("/test", lambda request: request.transport.loseConnection()) + server.set_route("/test", lambda request: request.loseConnection()) with pytest.raises(Error, match="socket hang up"): context.request.fetch(server.PREFIX + "/test") diff --git a/tests/sync/test_locators.py b/tests/sync/test_locators.py index 2c0455d57..07509e10e 100644 --- a/tests/sync/test_locators.py +++ b/tests/sync/test_locators.py @@ -14,6 +14,7 @@ import os import re +import traceback from typing import Callable from urllib.parse import urlparse @@ -529,7 +530,8 @@ def route_iframe(page: Page) -> None: page.route( "**/empty.html", lambda route: route.fulfill( - body='', content_type="text/html" + body='', + content_type="text/html", ), ) page.route( @@ -590,6 +592,26 @@ def test_locators_frame_should_work_with_locator_frame_locator( button.click() +def test_locator_content_frame_should_work(page: Page, server: Server) -> None: + route_iframe(page) + page.goto(server.EMPTY_PAGE) + locator = page.locator("iframe") + frame_locator = locator.content_frame + button = frame_locator.locator("button") + assert button.inner_text() == "Hello iframe" + expect(button).to_have_text("Hello iframe") + button.click() + + +def test_frame_locator_owner_should_work(page: Page, server: Server) -> None: + route_iframe(page) + page.goto(server.EMPTY_PAGE) + frame_locator = page.frame_locator("iframe") + locator = frame_locator.owner + expect(locator).to_be_visible() + assert locator.get_attribute("name") == "frame1" + + def route_ambiguous(page: Page) -> None: page.route( "**/empty.html", @@ -937,3 +959,19 @@ def test_locator_all_should_work(page: Page) -> None: for p in page.locator("p").all(): texts.append(p.text_content()) assert texts == ["A", "B", "C"] + + +def test_locator_click_timeout_error_should_contain_call_log(page: Page) -> None: + with pytest.raises(Error) as exc_info: + page.get_by_role("button", name="Hello Python").click(timeout=42) + formatted_exception = "".join( + traceback.format_exception(type(exc_info.value), value=exc_info.value, tb=None) + ) + assert "Locator.click: Timeout 42ms exceeded." in formatted_exception + assert ( + 'waiting for get_by_role("button", name="Hello Python")' in formatted_exception + ) + assert ( + "During handling of the above exception, another exception occurred" + not in formatted_exception + ) diff --git a/tests/sync/test_page_add_locator_handler.py b/tests/sync/test_page_add_locator_handler.py new file mode 100644 index 000000000..e4ba14462 --- /dev/null +++ b/tests/sync/test_page_add_locator_handler.py @@ -0,0 +1,197 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from playwright.sync_api import Error, Page, expect +from tests.server import Server +from tests.utils import TARGET_CLOSED_ERROR_MESSAGE + + +def test_should_work(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/handle-locator.html") + + before_count = 0 + after_count = 0 + + def handler() -> None: + nonlocal before_count + nonlocal after_count + before_count += 1 + page.locator("#close").click() + after_count += 1 + + page.add_locator_handler( + page.locator("text=This interstitial covers the button"), handler + ) + + for args in [ + ["mouseover", 1], + ["mouseover", 1, "capture"], + ["mouseover", 2], + ["mouseover", 2, "capture"], + ["pointerover", 1], + ["pointerover", 1, "capture"], + ["none", 1], + ["remove", 1], + ["hide", 1], + ]: + page.locator("#aside").hover() + before_count = 0 + after_count = 0 + page.evaluate( + "(args) => { window.clicked = 0; window.setupAnnoyingInterstitial(...args); }", + args, + ) + assert before_count == 0 + assert after_count == 0 + page.locator("#target").click() + assert before_count == args[1] + assert after_count == args[1] + assert page.evaluate("window.clicked") == 1 + expect(page.locator("#interstitial")).not_to_be_visible() + + +def test_should_work_with_a_custom_check(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/handle-locator.html") + + def handler() -> None: + if page.get_by_text("This interstitial covers the button").is_visible(): + page.locator("#close").click() + + page.add_locator_handler(page.locator("body"), handler) + + for args in [ + ["mouseover", 2], + ["none", 1], + ["remove", 1], + ["hide", 1], + ]: + page.locator("#aside").hover() + page.evaluate( + "(args) => { window.clicked = 0; window.setupAnnoyingInterstitial(...args); }", + args, + ) + page.locator("#target").click() + assert page.evaluate("window.clicked") == 1 + expect(page.locator("#interstitial")).not_to_be_visible() + + +def test_should_work_with_locator_hover(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/handle-locator.html") + + page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), + lambda: page.locator("#close").click(), + ) + + page.locator("#aside").hover() + page.evaluate( + '() => { window.setupAnnoyingInterstitial("pointerover", 1, "capture"); }' + ) + page.locator("#target").hover() + expect(page.locator("#interstitial")).not_to_be_visible() + assert ( + page.eval_on_selector( + "#target", "e => window.getComputedStyle(e).backgroundColor" + ) + == "rgb(255, 255, 0)" + ) + + +def test_should_not_work_with_force_true(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/handle-locator.html") + + page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), + lambda: page.locator("#close").click(), + ) + + page.locator("#aside").hover() + page.evaluate('() => { window.setupAnnoyingInterstitial("none", 1); }') + page.locator("#target").click(force=True, timeout=2000) + assert page.locator("#interstitial").is_visible() + assert page.evaluate("window.clicked") is None + + +def test_should_throw_when_page_closes(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/handle-locator.html") + + page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), lambda: page.close() + ) + + page.locator("#aside").hover() + page.evaluate( + '() => { window.clicked = 0; window.setupAnnoyingInterstitial("mouseover", 1); }' + ) + with pytest.raises(Error) as exc: + page.locator("#target").click() + assert TARGET_CLOSED_ERROR_MESSAGE in exc.value.message + + +def test_should_throw_when_handler_times_out(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/handle-locator.html") + + called = 0 + + def handler() -> None: + nonlocal called + called += 1 + # Deliberately timeout. + try: + page.wait_for_timeout(9999999) + except Error: + pass + + page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), handler + ) + + page.locator("#aside").hover() + page.evaluate( + '() => { window.clicked = 0; window.setupAnnoyingInterstitial("mouseover", 1); }' + ) + with pytest.raises(Error) as exc: + page.locator("#target").click(timeout=3000) + assert "Timeout 3000ms exceeded" in exc.value.message + + with pytest.raises(Error) as exc: + page.locator("#target").click(timeout=3000) + assert "Timeout 3000ms exceeded" in exc.value.message + + # Should not enter the same handler while it is still running. + assert called == 1 + + +def test_should_work_with_to_be_visible(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/handle-locator.html") + + called = 0 + + def handler() -> None: + nonlocal called + called += 1 + page.locator("#close").click() + + page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), handler + ) + + page.evaluate( + '() => { window.clicked = 0; window.setupAnnoyingInterstitial("remove", 1); }' + ) + expect(page.locator("#target")).to_be_visible() + expect(page.locator("#interstitial")).not_to_be_visible() + assert called == 1 diff --git a/tests/sync/test_page_request_fallback.py b/tests/sync/test_page_request_fallback.py index 09a3c9845..53570960c 100644 --- a/tests/sync/test_page_request_fallback.py +++ b/tests/sync/test_page_request_fallback.py @@ -162,10 +162,9 @@ def handler_with_header_mods(route: Route) -> None: page.route("**/*", handler_with_header_mods) page.goto(server.EMPTY_PAGE) - with page.expect_request("/sleep.zzz") as request_info: + with server.expect_request("/sleep.zzz") as server_request_info: page.evaluate("() => fetch('/sleep.zzz')") - request = request_info.value - _append_with_return_value(values, request.headers.get("foo")) + _append_with_return_value(values, server_request_info.value.getHeader("foo")) assert values == ["bar", "bar", "bar"] diff --git a/tests/sync/test_queryselector.py b/tests/sync/test_queryselector.py index f773b5109..27b972e95 100644 --- a/tests/sync/test_queryselector.py +++ b/tests/sync/test_queryselector.py @@ -156,7 +156,7 @@ def test_selectors_register_should_handle_errors( selectors.register("$", dummy_selector_engine_script) assert ( exc.value.message - == "Selector engine name may only contain [a-zA-Z0-9_] characters" + == "Selectors.register: Selector engine name may only contain [a-zA-Z0-9_] characters" ) # Selector names are case-sensitive. @@ -165,11 +165,16 @@ def test_selectors_register_should_handle_errors( with pytest.raises(Error) as exc: selectors.register("dummy", dummy_selector_engine_script) - assert exc.value.message == '"dummy" selector engine has been already registered' + assert ( + exc.value.message + == 'Selectors.register: "dummy" selector engine has been already registered' + ) with pytest.raises(Error) as exc: selectors.register("css", dummy_selector_engine_script) - assert exc.value.message == '"css" is a predefined selector engine' + assert ( + exc.value.message == 'Selectors.register: "css" is a predefined selector engine' + ) def test_should_work_with_layout_selectors(page: Page) -> None: diff --git a/tests/sync/test_sync.py b/tests/sync/test_sync.py index 3f27a4140..64eace1e9 100644 --- a/tests/sync/test_sync.py +++ b/tests/sync/test_sync.py @@ -14,7 +14,7 @@ import multiprocessing import os -from typing import Any, Dict +from typing import Any, Callable, Dict import pytest @@ -266,10 +266,12 @@ def test_sync_set_default_timeout(page: Page) -> None: assert "Timeout 1ms exceeded." in exc.value.message -def test_close_should_reject_all_promises(context: BrowserContext) -> None: +def test_close_should_reject_all_promises( + context: BrowserContext, sync_gather: Callable +) -> None: new_page = context.new_page() with pytest.raises(Error) as exc_info: - new_page._gather( + sync_gather( lambda: new_page.evaluate("() => new Promise(r => {})"), lambda: new_page.close(), ) @@ -344,21 +346,3 @@ def test_call_sync_method_after_playwright_close_with_own_loop( p.start() p.join() assert p.exitcode == 0 - - -def test_should_collect_stale_handles(page: Page, server: Server) -> None: - page.on("request", lambda request: None) - response = page.goto(server.PREFIX + "/title.html") - assert response - for i in range(1000): - page.evaluate( - """async () => { - const response = await fetch('/'); - await response.text(); - }""" - ) - with pytest.raises(Exception) as exc_info: - response.all_headers() - assert "The object has been collected to prevent unbounded heap growth." in str( - exc_info.value - ) diff --git a/tests/sync/test_unroute_behavior.py b/tests/sync/test_unroute_behavior.py new file mode 100644 index 000000000..12ae9e22d --- /dev/null +++ b/tests/sync/test_unroute_behavior.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from playwright.sync_api import BrowserContext, Page +from tests.server import Server +from tests.utils import must + + +def test_context_unroute_all_removes_all_handlers( + page: Page, context: BrowserContext, server: Server +) -> None: + context.route( + "**/*", + lambda route: route.abort(), + ) + context.route( + "**/empty.html", + lambda route: route.abort(), + ) + context.unroute_all() + page.goto(server.EMPTY_PAGE) + + +def test_page_unroute_all_removes_all_routes(page: Page, server: Server) -> None: + page.route( + "**/*", + lambda route: route.abort(), + ) + page.route( + "**/empty.html", + lambda route: route.abort(), + ) + page.unroute_all() + response = must(page.goto(server.EMPTY_PAGE)) + assert response.ok diff --git a/utils/docker/.gitignore b/utils/docker/.gitignore new file mode 100644 index 000000000..8803f25de --- /dev/null +++ b/utils/docker/.gitignore @@ -0,0 +1 @@ +oras/ diff --git a/utils/docker/publish_docker.sh b/utils/docker/publish_docker.sh index e5bba0eb9..2b4e73a53 100755 --- a/utils/docker/publish_docker.sh +++ b/utils/docker/publish_docker.sh @@ -26,11 +26,6 @@ else exit 1 fi -if [[ -z "${GITHUB_SHA}" ]]; then - echo "ERROR: GITHUB_SHA env variable must be specified" - exit 1 -fi - FOCAL_TAGS=( "next-focal" ) @@ -42,7 +37,6 @@ fi JAMMY_TAGS=( "next" "next-jammy" - "sha-${GITHUB_SHA}" ) if [[ "$RELEASE_CHANNEL" == "stable" ]]; then JAMMY_TAGS+=("latest") @@ -57,6 +51,27 @@ tag_and_push() { echo "-- tagging: $target" docker tag $source $target docker push $target + attach_eol_manifest $target +} + +attach_eol_manifest() { + local image="$1" + local today=$(date -u +'%Y-%m-%d') + install_oras_if_needed + # oras is re-using Docker credentials, so we don't need to login. + # Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary + ./oras/oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image +} + +install_oras_if_needed() { + if [[ -x oras/oras ]]; then + return + fi + local version="1.1.0" + curl -sLO "https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_amd64.tar.gz" + mkdir -p oras + tar -zxf oras_${version}_linux_amd64.tar.gz -C oras + rm oras_${version}_linux_amd64.tar.gz } publish_docker_images_with_arch_suffix() {