diff --git a/.azure-pipelines/publish.yml b/.azure-pipelines/publish.yml index 1a2e0e7a4..0b8c37402 100644 --- a/.azure-pipelines/publish.yml +++ b/.azure-pipelines/publish.yml @@ -60,7 +60,7 @@ extends: targetPath: $(Build.ArtifactStagingDirectory)/esrp-build steps: - checkout: none - - task: EsrpRelease@9 + - task: EsrpRelease@11 inputs: connectedservicename: 'Playwright-ESRP-PME' usemanagedidentity: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb06caff0..fb1bf4961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -91,7 +91,7 @@ jobs: browser: chromium runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -138,7 +138,7 @@ jobs: browser-channel: msedge runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -181,11 +181,11 @@ jobs: # where the default shell is pwsh and skips the activation hooks. shell: bash -el {0} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Get conda - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: python-version: '3.12' channels: conda-forge @@ -202,7 +202,7 @@ jobs: run: working-directory: examples/todomvc/ steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e9a7048c5..48087c53f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,14 +22,14 @@ jobs: runs-on: ${{ matrix.os }} defaults: run: - # Required for conda-incubator/setup-miniconda@v3 + # Required for conda-incubator/setup-miniconda@v4 shell: bash -el {0} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Get conda - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: python-version: '3.12' channels: conda-forge diff --git a/.github/workflows/publish_docker.yml b/.github/workflows/publish_docker.yml index 7494f1abc..83fa5b9b9 100644 --- a/.github/workflows/publish_docker.yml +++ b/.github/workflows/publish_docker.yml @@ -15,9 +15,9 @@ jobs: contents: read # This is required for actions/checkout to succeed environment: Docker steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Azure login - uses: azure/login@v2 + uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} @@ -29,7 +29,7 @@ jobs: with: python-version: "3.10" - name: Set up Docker QEMU for arm64 docker builds - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: platforms: arm64 - name: Install dependencies & browsers diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 464eb3b46..a25a382b5 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -30,7 +30,7 @@ jobs: - ubuntu-24.04 - ubuntu-24.04-arm steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/CLAUDE.md b/CLAUDE.md index ce4ec7c07..1cbaf3e1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,10 @@ This is the recurring high-stakes task. Use the dedicated skill: It documents the full process: the upstream commit-range diff over `docs/src/api/`, how to classify each commit (PORT / MISMATCH / N/A), how to handle the `langs:` filter, the recurring failure modes, and the tests/sync-mirroring conventions. +## Working on PRs + +- Never post comments, replies, or reviews on GitHub PRs/issues under my account without my explicit approval. Draft the proposed text and wait for me to approve before sending. + ## House style - Don't hand-edit generated files. @@ -55,3 +59,40 @@ It documents the full process: the upstream commit-range diff over `docs/src/api - New public methods on impl classes need a sync test mirror under `tests/sync/`. - Keep `expected_api_mismatch.txt` minimal — every entry needs a one-line rationale comment above it. - Prefer `locals_to_params(locals())` for forwarding optional kwargs to channel sends, matching the rest of the codebase. + +## Commit Convention + +Before committing, run `mypy playwright` and fix errors. + +Semantic commit messages: `label(scope): description` + +Labels: `fix`, `feat`, `chore`, `docs`, `test`, `devops` + +```bash +git checkout -b fix-12345 +# ... make changes ... +git add +git commit -m "$(cat <<'EOF' +fix(asyncio): do not deadlock in atexit handler + +Fixes: https://github.com/microsoft/playwright-python/issues/12345 +EOF +)" +git push origin fix-12345 +gh pr create --repo microsoft/playwright-python --head username:fix-12345 \ + --title "fix(asyncio): do not deadlock in atexit handler" \ + --body "$(cat <<'EOF' +## Summary +- + +Fixes https://github.com/microsoft/playwright-python/issues/12345 +EOF +)" +``` + +Never add Co-Authored-By agents in commit message. +Never add "Generated with" in commit message. +Never add test plan to PR description. Keep PR description short — a few bullet points at most. +Branch naming for issue fixes: `fix-` + +**Never `git push` without an explicit instruction to push.** Applies even when a PR is already open for the branch — additional commits are immediately visible to reviewers. Commit locally, report what was committed, and wait. Only push when the user's message contains "push", "upload", "create PR", "ship it", or equivalent. diff --git a/README.md b/README.md index f0a4fc423..46fcacdc2 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 147.0.7727.15 | ✅ | ✅ | ✅ | +| Chromium 148.0.7778.96 | ✅ | ✅ | ✅ | | WebKit 26.4 | ✅ | ✅ | ✅ | -| Firefox 148.0.2 | ✅ | ✅ | ✅ | +| Firefox 150.0.2 | ✅ | ✅ | ✅ | ## Documentation diff --git a/local-requirements.txt b/local-requirements.txt index 8a72b5745..43533b453 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,16 +1,17 @@ +asyncio-atexit==1.0.1 autobahn==23.1.2 black==25.1.0 build==1.3.0 -flake8==7.2.0 +flake8==7.3.0 mypy==1.17.1 objgraph==3.6.2 Pillow==11.3.0 pixelmatch==0.3.0 pre-commit==3.5.0 -pyOpenSSL==25.1.0 +pyOpenSSL==26.0.0 pytest==8.4.1 pytest-asyncio==1.1.0 -pytest-cov==6.3.0 +pytest-cov==7.1.0 pytest-repeat==0.9.4 pytest-rerunfailures==15.1 pytest-timeout==2.4.0 @@ -19,4 +20,4 @@ requests==2.32.5 service_identity==24.2.0 twisted==25.5.0 types-pyOpenSSL==24.1.0.20240722 -types-requests==2.32.4.20250809 +types-requests==2.32.4.20260107 diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index 256b59435..2b9a331c2 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -151,16 +151,31 @@ class ViewportSize(TypedDict): class SourceLocation(TypedDict): url: str + line: int + column: int lineNumber: int columnNumber: int +class WebErrorLocation(TypedDict): + url: str + line: int + column: int + + class FilePayload(TypedDict): name: str mimeType: str buffer: bytes +class DropPayload(TypedDict, total=False): + files: Optional[ + Union[str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]] + ] + data: Optional[Dict[str, str]] + + class RemoteAddr(TypedDict): ipAddress: str port: int @@ -216,6 +231,7 @@ class FrameExpectOptions(TypedDict, total=False): useInnerText: Optional[bool] isNot: bool timeout: Optional[float] + pseudo: Optional[str] class FrameExpectResult(TypedDict): @@ -330,3 +346,5 @@ class DebuggerPausedDetails(TypedDict): class ScreencastFrame(TypedDict): data: bytes + viewportWidth: int + viewportHeight: int diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index aea37d35c..8b7d24b9a 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -13,7 +13,7 @@ # limitations under the License. import collections.abc -from typing import Any, List, Optional, Pattern, Sequence, Union +from typing import Any, List, Literal, Optional, Pattern, Sequence, Union from urllib.parse import urljoin from playwright._impl._api_structures import ( @@ -26,6 +26,7 @@ from playwright._impl._errors import Error from playwright._impl._fetch import APIResponse from playwright._impl._helper import is_textual_mime_type +from playwright._impl._js_handle import parse_value from playwright._impl._locator import Locator from playwright._impl._page import Page from playwright._impl._str_utils import escape_regex_flags @@ -71,7 +72,14 @@ async def _expect_impl( del expect_options["useInnerText"] result = await self._call_expect(expression, expect_options, title) if result["matches"] == self._is_not: - actual = result.get("received") + received = result.get("received") or {} + if isinstance(received, dict): + if "value" in received and received["value"] is not None: + actual = parse_value(received["value"]) + else: + actual = received.get("ariaSnapshot") + else: + actual = received if self._custom_message: out_message = self._custom_message if expected is not None: @@ -161,6 +169,24 @@ async def not_to_have_url( __tracebackhide__ = True await self._not.to_have_url(urlOrRegExp, timeout, ignoreCase) + async def to_match_aria_snapshot( + self, expected: str, timeout: float = None + ) -> None: + __tracebackhide__ = True + await self._expect_impl( + "to.match.aria", + FrameExpectOptions(expectedValue=expected, timeout=timeout), + expected, + "Page expected to match Aria snapshot", + 'Expect "to_match_aria_snapshot"', + ) + + async def not_to_match_aria_snapshot( + self, expected: str, timeout: float = None + ) -> None: + __tracebackhide__ = True + await self._not.to_match_aria_snapshot(expected, timeout) + class LocatorAssertions(AssertionsBase): def __init__( @@ -400,13 +426,17 @@ async def to_have_css( name: str, value: Union[str, Pattern[str]], timeout: float = None, + pseudo: Literal["after", "before"] = None, ) -> None: __tracebackhide__ = True expected_text = to_expected_text_values([value]) await self._expect_impl( "to.have.css", FrameExpectOptions( - expressionArg=name, expectedText=expected_text, timeout=timeout + expressionArg=name, + expectedText=expected_text, + timeout=timeout, + pseudo=pseudo, ), value, "Locator expected to have CSS", diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 6454f8c3f..21b6e4d84 100644 --- a/playwright/_impl/_browser.py +++ b/playwright/_impl/_browser.py @@ -59,6 +59,7 @@ class Browser(ChannelOwner): Events = SimpleNamespace( + Context="context", Disconnected="disconnected", ) @@ -104,6 +105,7 @@ def _did_create_context(self, context: BrowserContext) -> None: # and will be configured later in `ConnectToBrowserType`. if self._browser_type: self._setup_browser_context(context) + self.emit(Browser.Events.Context, context) def _setup_browser_context(self, context: BrowserContext) -> None: context._tracing._traces_dir = self._traces_dir diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 6839d7c7f..38cccd4a3 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -36,8 +36,8 @@ Geolocation, SetCookieParam, StorageState, + WebErrorLocation, ) -from playwright._impl._artifact import Artifact from playwright._impl._cdp_session import CDPSession from playwright._impl._clock import Clock from playwright._impl._connection import ( @@ -57,7 +57,6 @@ from playwright._impl._helper import ( HarContentPolicy, HarMode, - HarRecordingMetadata, RouteFromHarNotFoundPolicy, RouteHandler, RouteHandlerCallback, @@ -95,7 +94,13 @@ class BrowserContext(ChannelOwner): Close="close", Console="console", Dialog="dialog", + Download="download", + FrameAttached="frameattached", + FrameDetached="framedetached", + FrameNavigated="framenavigated", Page="page", + PageClose="pageclose", + PageLoad="pageload", WebError="weberror", ServiceWorker="serviceworker", Request="request", @@ -125,7 +130,6 @@ def __init__( self._videos_dir: Optional[str] = self._options.get("recordVideo") self._tracing = cast(Tracing, from_channel(initializer["tracing"])) self._debugger: Debugger = cast(Debugger, from_channel(initializer["debugger"])) - self._har_recorders: Dict[str, HarRecordingMetadata] = {} self._request: APIRequestContext = from_channel(initializer["requestContext"]) self._request._timeout_settings = self._timeout_settings self._clock = Clock(self) @@ -171,6 +175,10 @@ def __init__( lambda params: self._on_page_error( parse_error(params["error"]["error"]), from_nullable_channel(params["page"]), + cast( + WebErrorLocation, + params.get("location") or {"url": "", "line": 0, "column": 0}, + ), ), ) self._channel.on( @@ -321,7 +329,7 @@ async def _initialize_har_from_options( content_policy: HarContentPolicy = record_har_content or ( "omit" if record_har_omit_content is True else default_policy ) - await self._record_into_har( + await self._tracing._record_into_har( har=record_har_path, page=None, url=record_har_url_filter, @@ -404,9 +412,7 @@ async def add_init_script( await self._channel.send("addInitScript", None, dict(source=script)) ) - async def expose_binding( - self, name: str, callback: Callable, handle: bool = None - ) -> Disposable: + async def expose_binding(self, name: str, callback: Callable) -> Disposable: for page in self._pages: if name in page._bindings: raise Error( @@ -416,9 +422,7 @@ async def expose_binding( raise Error(f'Function "{name}" has been already registered') self._bindings[name] = callback return from_channel( - await self._channel.send( - "exposeBinding", None, dict(name=name, needsHandle=handle or False) - ) + await self._channel.send("exposeBinding", None, dict(name=name)) ) async def expose_function(self, name: str, callback: Callable) -> Disposable: @@ -483,35 +487,6 @@ async def unroute_all( await self._unroute_internal(self._routes, [], behavior) self._dispose_har_routers() - async def _record_into_har( - self, - har: Union[Path, str], - page: Optional[Page] = None, - url: Union[Pattern[str], str] = None, - update_content: HarContentPolicy = None, - update_mode: HarMode = None, - ) -> None: - update_content = update_content or "attach" - params: Dict[str, Any] = { - "options": { - "zip": str(har).endswith(".zip"), - "content": update_content, - "urlGlob": url if isinstance(url, str) else None, - "urlRegexSource": url.pattern if isinstance(url, Pattern) else None, - "urlRegexFlags": ( - escape_regex_flags(url) if isinstance(url, Pattern) else None - ), - "mode": update_mode or "minimal", - } - } - if page: - params["page"] = page._channel - har_id = await self._channel.send("harStart", None, params) - self._har_recorders[har_id] = { - "path": str(har), - "content": update_content, - } - async def route_from_har( self, har: Union[Path, str], @@ -522,7 +497,7 @@ async def route_from_har( updateMode: HarMode = None, ) -> None: if update: - await self._record_into_har( + await self._tracing._record_into_har( har=har, page=None, url=url, @@ -602,27 +577,7 @@ async def close(self, reason: str = None) -> None: await self.request.dispose(reason=reason) async def _inner_close() -> None: - for har_id, params in self._har_recorders.items(): - har = cast( - Artifact, - from_channel( - await self._channel.send("harExport", None, {"harId": har_id}) - ), - ) - # Server side will compress artifact if content is attach or if file is .zip. - is_compressed = params.get("content") == "attach" or params[ - "path" - ].endswith(".zip") - need_compressed = params["path"].endswith(".zip") - if is_compressed and not need_compressed: - tmp_path = params["path"] + ".tmp" - await har.save_as(tmp_path) - await self._connection.local_utils.har_unzip( - zipFile=tmp_path, harFile=params["path"] - ) - else: - await har.save_as(params["path"]) - await har.delete() + await self._tracing._export_all_hars() await self._channel._connection.wrap_api_call(_inner_close, True) await self._channel.send("close", None, {"reason": reason}) @@ -732,10 +687,12 @@ def _on_dialog(self, dialog: Dialog) -> None: else: asyncio.create_task(dialog.dismiss()) - def _on_page_error(self, error: Error, page: Optional[Page]) -> None: + def _on_page_error( + self, error: Error, page: Optional[Page], location: WebErrorLocation + ) -> None: self.emit( BrowserContext.Events.WebError, - WebError(self._loop, self._dispatcher_fiber, page, error), + WebError(self._loop, self._dispatcher_fiber, page, error, location), ) if page: page.emit(Page.Events.PageError, error) diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index ba376c336..8abac6061 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -201,6 +201,7 @@ async def connect_over_cdp( slowMo: float = None, headers: Dict[str, str] = None, isLocal: bool = None, + noDefaults: bool = None, ) -> Browser: params = locals_to_params(locals()) if params.get("headers"): @@ -357,6 +358,8 @@ def normalize_launch_params(params: Dict) -> None: if params["ignoreDefaultArgs"] is True: params["ignoreAllDefaultArgs"] = True del params["ignoreDefaultArgs"] + elif params["ignoreDefaultArgs"] is False: + del params["ignoreDefaultArgs"] if "executablePath" in params: params["executablePath"] = str(Path(params["executablePath"])) if "downloadsPath" in params: diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index d98901d34..66879194e 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -74,7 +74,16 @@ def args(self) -> List[JSHandle]: @property def location(self) -> SourceLocation: - return self._event["location"] + # Wire format uses `lineNumber`/`columnNumber`; docs expose both `line`/`column` + # (legacy) and `lineNumber`/`columnNumber` (added upstream in 1.60). + loc = self._event["location"] + return { + "url": loc["url"], + "line": loc["lineNumber"], + "column": loc["columnNumber"], + "lineNumber": loc["lineNumber"], + "columnNumber": loc["columnNumber"], + } @property def timestamp(self) -> float: diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index 50bf4ad4a..a14378149 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -14,6 +14,7 @@ import base64 import json +import mimetypes import pathlib import typing from pathlib import Path @@ -32,6 +33,7 @@ ) from playwright._impl._connection import ChannelOwner, from_channel from playwright._impl._errors import is_target_closed_error +from playwright._impl._form_data import FormData from playwright._impl._helper import ( Error, NameValue, @@ -51,9 +53,9 @@ from playwright._impl._playwright import Playwright -FormType = Dict[str, Union[bool, float, str]] +FormType = Union[Dict[str, Union[bool, float, str]], FormData] DataType = Union[Any, bytes, str] -MultipartType = Dict[str, Union[bytes, bool, float, str, FilePayload]] +MultipartType = Union[Dict[str, Union[bytes, bool, float, str, FilePayload]], FormData] ParamsType = Union[Dict[str, Union[bool, float, str]], str] @@ -110,6 +112,7 @@ def __init__( async def dispose(self, reason: str = None) -> None: self._close_reason = reason + await self._tracing._export_all_hars() try: await self._channel.send("dispose", None, {"reason": reason}) except Error as e: @@ -118,6 +121,10 @@ async def dispose(self, reason: str = None) -> None: raise e self._tracing._reset_stack_counter() + @property + def tracing(self) -> Tracing: + return self._tracing + async def delete( self, url: str, @@ -212,7 +219,7 @@ async def patch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -241,7 +248,7 @@ async def put( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -270,7 +277,7 @@ async def post( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -300,7 +307,7 @@ async def fetch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -341,7 +348,7 @@ async def _inner_fetch( data: DataType = None, params: ParamsType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -381,21 +388,36 @@ async def _inner_fetch( else: raise Error(f"Unsupported 'data' type: {type(data)}") elif form: - form_data = object_to_array(form) + if isinstance(form, FormData): + form_data = [] + for fd_name, fd_value in form._fields: + if isinstance(fd_value, (pathlib.Path, dict)): + raise Error( + f"Form field {fd_name!r} must be a string, number or boolean. Use 'multipart' for file uploads." + ) + form_data.append(NameValue(name=fd_name, value=str(fd_value))) + else: + form_data = object_to_array(form) elif multipart: multipart_data = [] - # Convert file-like values to ServerFilePayload structs. - for name, value in multipart.items(): - if is_file_payload(value): - payload = cast(FilePayload, value) - assert isinstance( - payload["buffer"], bytes - ), f"Unexpected buffer type of 'data.{name}'" + if isinstance(multipart, FormData): + for fd_name, fd_value in multipart._fields: multipart_data.append( - FormField(name=name, file=file_payload_to_json(payload)) + await _form_data_field_to_form_field(fd_name, fd_value) ) - elif isinstance(value, str): - multipart_data.append(FormField(name=name, value=value)) + else: + # Convert file-like values to ServerFilePayload structs. + for name, value in multipart.items(): + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of 'data.{name}'" + multipart_data.append( + FormField(name=name, file=file_payload_to_json(payload)) + ) + elif isinstance(value, str): + multipart_data.append(FormField(name=name, value=value)) if ( post_data_buffer is None and json_data is None @@ -450,6 +472,28 @@ def file_payload_to_json(payload: FilePayload) -> ServerFilePayload: ) +async def _form_data_field_to_form_field(name: str, value: Any) -> FormField: + if isinstance(value, pathlib.Path): + mime_type, _ = mimetypes.guess_type(str(value)) + return FormField( + name=name, + file=ServerFilePayload( + name=value.name, + mimeType=mime_type or "application/octet-stream", + buffer=base64.b64encode(await async_readfile(str(value))).decode(), + ), + ) + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of form field {name!r}" + return FormField(name=name, file=file_payload_to_json(payload)) + if isinstance(value, (str, int, float, bool)): + return FormField(name=name, value=str(value)) + raise Error(f"Unsupported form field {name!r} value type: {type(value).__name__}") + + class APIResponse: def __init__(self, context: APIRequestContext, initializer: Dict) -> None: self._loop = context._loop diff --git a/playwright/_impl/_form_data.py b/playwright/_impl/_form_data.py new file mode 100644 index 000000000..384806abd --- /dev/null +++ b/playwright/_impl/_form_data.py @@ -0,0 +1,34 @@ +# 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 pathlib +from typing import List, Tuple, Union + +from playwright._impl._api_structures import FilePayload + +FormDataValue = Union[bool, float, str, pathlib.Path, FilePayload] + + +class FormData: + def __init__(self) -> None: + self._fields: List[Tuple[str, FormDataValue]] = [] + + def set(self, name: str, value: FormDataValue) -> "FormData": + self._fields = [(n, v) for (n, v) in self._fields if n != name] + self._fields.append((name, value)) + return self + + def append(self, name: str, value: FormDataValue) -> "FormData": + self._fields.append((name, value)) + return self diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index b976667e7..2422f2b1a 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -32,6 +32,7 @@ from playwright._impl._api_structures import ( AriaRole, + DropPayload, FilePayload, FrameExpectOptions, FrameExpectResult, @@ -122,6 +123,7 @@ def _on_load_state( self._load_states.remove(remove) if not self._parent_frame and add == "load" and self._page: self._page.emit("load", self._page) + self._page.context.emit("pageload", self._page) if not self._parent_frame and add == "domcontentloaded" and self._page: self._page.emit("domcontentloaded", self._page) @@ -131,6 +133,7 @@ def _on_frame_navigated(self, event: FrameNavigatedEvent) -> None: self._event_emitter.emit("navigated", event) if "error" not in event and self._page: self._page.emit("framenavigated", self) + self._page.context.emit("framenavigated", self) async def _query_count(self, selector: str) -> int: return await self._channel.send("queryCount", None, {"selector": selector}) @@ -662,6 +665,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -675,6 +679,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -812,6 +817,33 @@ async def set_input_files( }, ) + async def _drop( + self, + selector: str, + payload: "DropPayload", + strict: bool = None, + position: Position = None, + timeout: float = None, + ) -> None: + params: Dict[str, Any] = { + "selector": selector, + "strict": strict, + "position": position, + "timeout": self._timeout(timeout), + } + files = payload.get("files") if payload else None + if files is not None: + converted = await convert_input_files(files, self.page.context) + if "directoryStream" in converted or "directoryLocalPath" in converted: + raise Error( + "Dropping a directory is not supported, pass individual files instead." + ) + params.update(converted) + data = payload.get("data") if payload else None + if data is not None: + params["data"] = [{"mimeType": k, "value": v} for k, v in data.items()] + await self._channel.send("drop", self._timeout, params) + async def type( self, selector: str, @@ -911,5 +943,10 @@ async def set_checked( trial=trial, ) - async def _highlight(self, selector: str) -> None: - await self._channel.send("highlight", None, {"selector": selector}) + async def _highlight(self, selector: str, style: str = None) -> None: + await self._channel.send( + "highlight", None, {"selector": selector, "style": style} + ) + + async def _hide_highlight(self, selector: str) -> None: + await self._channel.send("hideHighlight", None, {"selector": selector}) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 1d7e4f67b..213fdc1e3 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import asyncio +import datetime import math import os import re @@ -22,6 +23,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, List, @@ -54,8 +56,12 @@ from playwright._impl._network import Request, Response, Route, WebSocketRoute URLMatch = Union[str, Pattern[str], Callable[[str], bool]] -URLMatchRequest = Union[str, Pattern[str], Callable[["Request"], bool]] -URLMatchResponse = Union[str, Pattern[str], Callable[["Response"], bool]] +URLMatchRequest = Union[ + str, Pattern[str], Callable[["Request"], Union[bool, Awaitable[bool]]] +] +URLMatchResponse = Union[ + str, Pattern[str], Callable[["Response"], Union[bool, Awaitable[bool]]] +] RouteHandlerCallback = Union[ Callable[["Route"], Any], Callable[["Route", "Request"], Any] ] @@ -569,3 +575,13 @@ def is_file_payload(value: Optional[Any]) -> bool: def is_textual_mime_type(mime_type: str) -> bool: return bool(TEXTUAL_MIME_TYPE.match(mime_type)) + + +def to_milliseconds( + value: Union[float, datetime.timedelta, None], +) -> Optional[float]: + if value is None: + return None + if isinstance(value, datetime.timedelta): + return value / datetime.timedelta(milliseconds=1) + return value diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 5f1b8f29a..c76b248ce 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -33,6 +33,7 @@ from playwright._impl._api_structures import ( AriaRole, + DropPayload, FilePayload, FloatRect, FrameExpectOptions, @@ -279,6 +280,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -292,6 +294,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -439,6 +442,20 @@ async def drag_to( self._selector, target._selector, strict=True, **params ) + async def drop( + self, + payload: DropPayload, + position: Position = None, + timeout: float = None, + ) -> None: + await self._frame._drop( + self._selector, + payload, + strict=True, + position=position, + timeout=timeout, + ) + async def get_attribute(self, name: str, timeout: float = None) -> Optional[str]: params = locals_to_params(locals()) return await self._frame.get_attribute( @@ -569,6 +586,7 @@ async def aria_snapshot( timeout: float = None, depth: int = None, mode: Literal["ai", "default"] = None, + boxes: bool = None, ) -> str: return await self._frame._channel.send( "ariaSnapshot", @@ -756,8 +774,11 @@ async def _expect( ) -> FrameExpectResult: return await self._frame._expect(self._selector, expression, options, title) - async def highlight(self) -> None: - await self._frame._highlight(self._selector) + async def highlight(self, style: str = None) -> None: + await self._frame._highlight(self._selector, style) + + async def hide_highlight(self) -> None: + await self._frame._hide_highlight(self._selector) class FrameLocator: @@ -823,6 +844,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -836,6 +858,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -938,6 +961,7 @@ def get_by_role_selector( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> str: props: List[Tuple[str, str]] = [] if checked is not None: @@ -959,6 +983,13 @@ def get_by_role_selector( escape_for_attribute_selector(name, exact=exact), ) ) + if description is not None: + props.append( + ( + "description", + escape_for_attribute_selector(description, exact=exact), + ) + ) if pressed is not None: props.append(("pressed", bool_to_js_bool(pressed))) props_str = "".join([f"[{t[0]}={t[1]}]" for t in props]) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 06bf88267..240fb6653 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -596,6 +596,10 @@ def connect_to_server(self) -> None: def url(self) -> str: return self._ws._initializer["url"] + @property + def protocols(self) -> List[str]: + return list(self._ws._initializer.get("protocols", [])) + def close(self, code: int = None, reason: str = None) -> None: _create_task_and_ignore_exception( self._ws._loop, @@ -694,6 +698,10 @@ def _channel_close_server(self, event: Dict) -> None: def url(self) -> str: return self._initializer["url"] + @property + def protocols(self) -> List[str]: + return list(self._initializer.get("protocols", [])) + async def close(self, code: int = None, reason: str = None) -> None: try: await self._channel.send( diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 020058acf..9bf59c313 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -22,6 +22,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, List, @@ -279,11 +280,13 @@ def _on_frame_attached(self, frame: Frame) -> None: frame._page = self self._frames.append(frame) self.emit(Page.Events.FrameAttached, frame) + self._browser_context.emit("frameattached", frame) def _on_frame_detached(self, frame: Frame) -> None: self._frames.remove(frame) frame._detached = True self.emit(Page.Events.FrameDetached, frame) + self._browser_context.emit("framedetached", frame) async def _on_route(self, route: Route) -> None: route._context = self.context @@ -349,6 +352,7 @@ def _on_close(self) -> None: self._browser_context._pages.remove(self) self._dispose_har_routers() self.emit(Page.Events.Close, self) + self._browser_context.emit("pageclose", self) def _on_crash(self) -> None: self.emit(Page.Events.Crash, self) @@ -357,9 +361,9 @@ def _on_download(self, params: Any) -> None: url = params["url"] suggested_filename = params["suggestedFilename"] artifact = cast(Artifact, from_channel(params["artifact"])) - self.emit( - Page.Events.Download, Download(self, url, suggested_filename, artifact) - ) + download = Download(self, url, suggested_filename, artifact) + self.emit(Page.Events.Download, download) + self._browser_context.emit("download", download) def _on_viewport_size_changed(self, params: Any) -> None: self._viewport_size = params["viewportSize"] @@ -505,9 +509,7 @@ async def add_style_tag( async def expose_function(self, name: str, callback: Callable) -> Disposable: return await self.expose_binding(name, lambda source, *args: callback(*args)) - async def expose_binding( - self, name: str, callback: Callable, handle: bool = None - ) -> Disposable: + async def expose_binding(self, name: str, callback: Callable) -> Disposable: if name in self._bindings: raise Error(f'Function "{name}" has been already registered') if name in self._browser_context._bindings: @@ -519,7 +521,7 @@ async def expose_binding( await self._channel.send( "exposeBinding", None, - dict(name=name, needsHandle=handle or False), + dict(name=name), ) ) @@ -662,6 +664,9 @@ def viewport_size(self) -> Optional[ViewportSize]: async def bring_to_front(self) -> None: await self._channel.send("bringToFront", None) + async def hide_highlight(self) -> None: + await self._channel.send("hideHighlight", None) + async def add_init_script( self, script: str = None, path: Union[str, Path] = None ) -> Disposable: @@ -749,7 +754,7 @@ async def route_from_har( updateMode: HarMode = None, ) -> None: if update: - await self._browser_context._record_into_har( + await self._browser_context._tracing._record_into_har( har=har, page=self, url=url, @@ -834,6 +839,7 @@ async def aria_snapshot( timeout: float = None, depth: int = None, mode: Literal["ai", "default"] = None, + boxes: bool = None, ) -> str: return await self._main_frame._channel.send( "ariaSnapshot", @@ -953,6 +959,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self._main_frame.get_by_role( role, @@ -965,6 +972,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) def get_by_test_id(self, testId: Union[str, Pattern[str]]) -> "Locator": @@ -1278,7 +1286,7 @@ def expect_request( urlOrPredicate: URLMatchRequest, timeout: float = None, ) -> EventContextManagerImpl[Request]: - def my_predicate(request: Request) -> bool: + def my_predicate(request: Request) -> Union[bool, Awaitable[bool]]: if not callable(urlOrPredicate): return url_matches( self._browser_context._base_url, @@ -1310,7 +1318,7 @@ def expect_response( urlOrPredicate: URLMatchResponse, timeout: float = None, ) -> EventContextManagerImpl[Response]: - def my_predicate(request: Response) -> bool: + def my_predicate(request: Response) -> Union[bool, Awaitable[bool]]: if not callable(urlOrPredicate): return url_matches( self._browser_context._base_url, diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py index 1f1da3c4f..600297203 100644 --- a/playwright/_impl/_screencast.py +++ b/playwright/_impl/_screencast.py @@ -55,7 +55,13 @@ def _dispatch_frame(self, params: dict) -> None: data = params["data"] if isinstance(data, str): data = base64.b64decode(data) - result = self._on_frame({"data": data}) + result = self._on_frame( + { + "data": data, + "viewportWidth": params["viewportWidth"], + "viewportHeight": params["viewportHeight"], + } + ) if hasattr(result, "__await__"): self._page._loop.create_task(result) diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index 2798b89d9..be79deef1 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -13,13 +13,17 @@ # limitations under the License. import pathlib -from typing import Dict, Optional, Union, cast +from typing import Any, Dict, Literal, Optional, Pattern, Union, cast from playwright._impl._api_structures import TracingGroupLocation from playwright._impl._artifact import Artifact -from playwright._impl._connection import ChannelOwner, from_nullable_channel +from playwright._impl._connection import ( + ChannelOwner, + from_channel, + from_nullable_channel, +) from playwright._impl._disposable import DisposableStub -from playwright._impl._helper import locals_to_params +from playwright._impl._helper import Error, locals_to_params class Tracing(ChannelOwner): @@ -32,6 +36,8 @@ def __init__( self._stacks_id: Optional[str] = None self._is_tracing: bool = False self._traces_dir: Optional[str] = None + self._har_id: Optional[str] = None + self._har_recorders: Dict[str, Dict[str, str]] = {} async def start( self, @@ -160,3 +166,116 @@ async def group_end(self) -> None: "tracingGroupEnd", None, ) + + async def start_har( + self, + path: Union[pathlib.Path, str], + content: Literal["attach", "embed", "omit"] = None, + mode: Literal["full", "minimal"] = None, + urlFilter: Union[str, Pattern[str]] = None, + ) -> DisposableStub: + if self._har_id: + raise Error("HAR recording has already been started") + is_zip = str(path).endswith(".zip") + default_content: Literal["attach", "embed", "omit"] = ( + "attach" if is_zip else "embed" + ) + self._har_id = await self._record_into_har( + har=path, + page=None, + url=urlFilter, + update_content=content or default_content, + update_mode=mode or "full", + ) + return DisposableStub(lambda: self.stop_har(), self) + + async def _record_into_har( + self, + har: Union[pathlib.Path, str], + page: Optional[ChannelOwner], + url: Union[str, Pattern[str]] = None, + update_content: Literal["attach", "embed", "omit"] = None, + update_mode: Literal["full", "minimal"] = None, + resourcesDir: Optional[str] = None, + ) -> str: + is_zip = str(har).endswith(".zip") + url_glob: Optional[str] = None + url_regex_source: Optional[str] = None + url_regex_flags: Optional[str] = None + if isinstance(url, str): + url_glob = url + elif url is not None: + url_regex_source = url.pattern + url_regex_flags = "".join( + flag + for flag, mask in (("i", 2), ("m", 8), ("s", 16)) + if url.flags & mask + ) + options: Dict[str, object] = { + "content": update_content or "attach", + "mode": update_mode or "minimal", + "harPath": None if is_zip else str(har), + } + if url_glob is not None: + options["urlGlob"] = url_glob + if url_regex_source is not None: + options["urlRegexSource"] = url_regex_source + if url_regex_flags is not None: + options["urlRegexFlags"] = url_regex_flags + if resourcesDir is not None: + options["resourcesDir"] = resourcesDir + params: Dict[str, Any] = {"options": options} + if page is not None: + params["page"] = page._channel + result = await self._channel.send_return_as_dict("harStart", None, params) + har_id = result["harId"] + self._har_recorders[har_id] = {"path": str(har)} + return har_id + + async def _export_all_hars(self) -> None: + for har_id in list(self._har_recorders.keys()): + await self._export_har(har_id) + self._har_id = None + + async def stop_har(self) -> None: + har_id = self._har_id + if not har_id: + return + self._har_id = None + await self._export_har(har_id) + + async def _export_har(self, har_id: str) -> None: + params = self._har_recorders.pop(har_id, None) + if not params: + return + is_local = not self._connection.is_remote + is_zip = params["path"].endswith(".zip") + + if is_local: + result = await self._channel.send_return_as_dict( + "harExport", None, {"harId": har_id, "mode": "entries"} + ) + if not is_zip: + # Server wrote HAR and resources to the user's chosen paths. + return + await self._connection.local_utils.zip( + { + "zipFile": params["path"], + "entries": result["entries"], + "mode": "write", + "includeSources": False, + } + ) + return + + result = await self._channel.send_return_as_dict( + "harExport", None, {"harId": har_id, "mode": "archive"} + ) + artifact = cast(Artifact, from_channel(result["artifact"])) + if is_zip: + await artifact.save_as(params["path"]) + await artifact.delete() + return + # Uncompressed har is not supported in thin clients + await artifact.save_as(params["path"] + ".tmp") + await artifact.delete() diff --git a/playwright/_impl/_transport.py b/playwright/_impl/_transport.py index 2ca84d459..3cc029e18 100644 --- a/playwright/_impl/_transport.py +++ b/playwright/_impl/_transport.py @@ -137,38 +137,44 @@ async def connect(self) -> None: async def run(self) -> None: assert self._proc.stdout assert self._proc.stdin - while not self._stopped: - try: - buffer = await self._proc.stdout.readexactly(4) - if self._stopped: - break - length = int.from_bytes(buffer, byteorder="little", signed=False) - buffer = bytes(0) - while length: - to_read = min(length, 32768) - data = await self._proc.stdout.readexactly(to_read) + try: + while not self._stopped: + try: + buffer = await self._proc.stdout.readexactly(4) + if self._stopped: + break + length = int.from_bytes(buffer, byteorder="little", signed=False) + buffer = bytes(0) + while length: + to_read = min(length, 32768) + data = await self._proc.stdout.readexactly(to_read) + if self._stopped: + break + length -= to_read + if len(buffer): + buffer = buffer + data + else: + buffer = data if self._stopped: break - length -= to_read - if len(buffer): - buffer = buffer + data - else: - buffer = data - if self._stopped: - break - obj = self.deserialize_message(buffer) - self.on_message(obj) - except asyncio.IncompleteReadError: - if not self._stopped: - self.on_error_future.set_exception( - Exception("Connection closed while reading from the driver") - ) - break - await asyncio.sleep(0) - - await self._proc.communicate() - self._stopped_future.set_result(None) + obj = self.deserialize_message(buffer) + self.on_message(obj) + except asyncio.IncompleteReadError: + if not self._stopped: + self.on_error_future.set_exception( + Exception("Connection closed while reading from the driver") + ) + break + await asyncio.sleep(0) + + await self._proc.communicate() + finally: + # Release waiters on wait_until_stopped() even if this task was + # cancelled before reaching the end (e.g. by asyncio.run()'s + # task-cancellation phase that runs before asyncio-atexit hooks). + if not self._stopped_future.done(): + self._stopped_future.set_result(None) def send(self, message: Dict) -> None: assert self._output diff --git a/playwright/_impl/_waiter.py b/playwright/_impl/_waiter.py index f7ff4b6c1..b5bf53382 100644 --- a/playwright/_impl/_waiter.py +++ b/playwright/_impl/_waiter.py @@ -13,10 +13,11 @@ # limitations under the License. import asyncio +import inspect import math import uuid from asyncio.tasks import Task -from typing import Any, Callable, List, Tuple, Union +from typing import Any, Callable, List, Optional, Tuple, Union from pyee import EventEmitter @@ -71,9 +72,11 @@ def reject_on_event( error: Union[Error, Callable[..., Error]], predicate: Callable = None, ) -> None: + def on_match() -> None: + self._reject(error() if callable(error) else error) + def listener(event_data: Any = None) -> None: - if not predicate or predicate(event_data): - self._reject(error() if callable(error) else error) + self._evaluate_predicate(predicate, event_data, on_match) emitter.on(event, listener) self._registered_listeners.append((emitter, event, listener)) @@ -117,12 +120,43 @@ def wait_for_event( predicate: Callable = None, ) -> None: def listener(event_data: Any = None) -> None: - if not predicate or predicate(event_data): - self._fulfill(event_data) + self._evaluate_predicate( + predicate, event_data, lambda: self._fulfill(event_data) + ) emitter.on(event, listener) self._registered_listeners.append((emitter, event, listener)) + def _evaluate_predicate( + self, + predicate: Optional[Callable], + event_data: Any, + on_match: Callable[[], None], + ) -> None: + if predicate is None: + on_match() + return + try: + result = predicate(event_data) + except Exception as e: + self._reject(e) + return + if inspect.iscoroutine(result): + + async def _await_predicate(coro: Any) -> None: + try: + matched = await coro + except Exception as e: + self._reject(e) + return + if matched and not self._result.done(): + on_match() + + self._pending_tasks.append(self._loop.create_task(_await_predicate(result))) + return + if result: + on_match() + def result(self) -> asyncio.Future: return self._result diff --git a/playwright/_impl/_web_error.py b/playwright/_impl/_web_error.py index 345f95b8f..4527e8d4b 100644 --- a/playwright/_impl/_web_error.py +++ b/playwright/_impl/_web_error.py @@ -15,6 +15,7 @@ from asyncio import AbstractEventLoop from typing import Any, Optional +from playwright._impl._api_structures import WebErrorLocation from playwright._impl._helper import Error from playwright._impl._page import Page @@ -26,11 +27,13 @@ def __init__( dispatcher_fiber: Any, page: Optional[Page], error: Error, + location: WebErrorLocation, ) -> None: self._loop = loop self._dispatcher_fiber = dispatcher_fiber self._page = page self._error = error + self._location = location @property def page(self) -> Optional[Page]: @@ -39,3 +42,7 @@ def page(self) -> Optional[Page]: @property def error(self) -> Error: return self._error + + @property + def location(self) -> WebErrorLocation: + return self._location diff --git a/playwright/async_api/__init__.py b/playwright/async_api/__init__.py index 6508994c3..6808ed6fb 100644 --- a/playwright/async_api/__init__.py +++ b/playwright/async_api/__init__.py @@ -22,6 +22,7 @@ import playwright._impl._api_structures import playwright._impl._errors +import playwright._impl._form_data import playwright.async_api._generated from playwright._impl._assertions import ( APIResponseAssertions as APIResponseAssertionsImpl, @@ -69,6 +70,7 @@ Cookie = playwright._impl._api_structures.Cookie FilePayload = playwright._impl._api_structures.FilePayload +FormData = playwright._impl._form_data.FormData FloatRect = playwright._impl._api_structures.FloatRect Geolocation = playwright._impl._api_structures.Geolocation HttpCredentials = playwright._impl._api_structures.HttpCredentials @@ -171,6 +173,7 @@ def __call__( "FileChooser", "FilePayload", "FloatRect", + "FormData", "Frame", "FrameLocator", "Geolocation", diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 5ca533ef2..229ba6d8c 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -24,6 +24,7 @@ Cookie, DebuggerLocation, DebuggerPausedDetails, + DropPayload, FilePayload, FloatRect, Geolocation, @@ -42,6 +43,7 @@ StorageState, TracingGroupLocation, ViewportSize, + WebErrorLocation, ) from playwright._impl._assertions import ( APIResponseAssertions as APIResponseAssertionsImpl, @@ -70,7 +72,9 @@ from playwright._impl._fetch import APIRequestContext as APIRequestContextImpl from playwright._impl._fetch import APIResponse as APIResponseImpl from playwright._impl._file_chooser import FileChooser as FileChooserImpl +from playwright._impl._form_data import FormData from playwright._impl._frame import Frame as FrameImpl +from playwright._impl._helper import to_milliseconds from playwright._impl._input import Keyboard as KeyboardImpl from playwright._impl._input import Mouse as MouseImpl from playwright._impl._input import Touchscreen as TouchscreenImpl @@ -791,7 +795,7 @@ async def fetch( post_data: typing.Optional[typing.Union[typing.Any, str, bytes]] = None, max_redirects: typing.Optional[int] = None, max_retries: typing.Optional[int] = None, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, ) -> "APIResponse": """Route.fetch @@ -850,7 +854,7 @@ async def handle(route): postData=mapping.to_impl(post_data), maxRedirects=max_redirects, maxRetries=max_retries, - timeout=timeout, + timeout=to_milliseconds(timeout), ) ) @@ -1120,12 +1124,61 @@ def url(self) -> str: """ return mapping.from_maybe_impl(self._impl_obj.url) + @typing.overload + def expect_event( + self, + event: typing.Literal["close"], + predicate: typing.Optional[typing.Callable[["WebSocket"], bool]] = None, + *, + timeout: typing.Optional[float] = None, + ) -> AsyncEventContextManager["WebSocket"]: ... + + @typing.overload + def expect_event( + self, + event: typing.Literal["framereceived"], + predicate: typing.Optional[ + typing.Callable[["typing.Union[bytes, str]"], bool] + ] = None, + *, + timeout: typing.Optional[float] = None, + ) -> AsyncEventContextManager["typing.Union[bytes, str]"]: ... + + @typing.overload + def expect_event( + self, + event: typing.Literal["framesent"], + predicate: typing.Optional[ + typing.Callable[["typing.Union[bytes, str]"], bool] + ] = None, + *, + timeout: typing.Optional[float] = None, + ) -> AsyncEventContextManager["typing.Union[bytes, str]"]: ... + + @typing.overload + def expect_event( + self, + event: typing.Literal["socketerror"], + predicate: typing.Optional[typing.Callable[["str"], bool]] = None, + *, + timeout: typing.Optional[float] = None, + ) -> AsyncEventContextManager["str"]: ... + + @typing.overload def expect_event( self, event: str, - predicate: typing.Optional[typing.Callable] = None, + predicate: typing.Optional[typing.Callable[..., bool]] = None, *, timeout: typing.Optional[float] = None, + ) -> AsyncEventContextManager[typing.Any]: ... + + def expect_event( + self, + event: str, + predicate: typing.Optional[typing.Callable] = None, + *, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, ) -> AsyncEventContextManager: """WebSocket.expect_event @@ -1149,16 +1202,67 @@ def expect_event( return AsyncEventContextManager( self._impl_obj.expect_event( - event=event, predicate=self._wrap_handler(predicate), timeout=timeout + event=event, + predicate=self._wrap_handler(predicate), + timeout=to_milliseconds(timeout), ).future ) + @typing.overload + async def wait_for_event( + self, + event: typing.Literal["close"], + predicate: typing.Optional[typing.Callable[["WebSocket"], bool]] = None, + *, + timeout: typing.Optional[float] = None, + ) -> "WebSocket": ... + + @typing.overload + async def wait_for_event( + self, + event: typing.Literal["framereceived"], + predicate: typing.Optional[ + typing.Callable[["typing.Union[bytes, str]"], bool] + ] = None, + *, + timeout: typing.Optional[float] = None, + ) -> "typing.Union[bytes, str]": ... + + @typing.overload + async def wait_for_event( + self, + event: typing.Literal["framesent"], + predicate: typing.Optional[ + typing.Callable[["typing.Union[bytes, str]"], bool] + ] = None, + *, + timeout: typing.Optional[float] = None, + ) -> "typing.Union[bytes, str]": ... + + @typing.overload + async def wait_for_event( + self, + event: typing.Literal["socketerror"], + predicate: typing.Optional[typing.Callable[["str"], bool]] = None, + *, + timeout: typing.Optional[float] = None, + ) -> "str": ... + + @typing.overload + async def wait_for_event( + self, + event: str, + predicate: typing.Optional[typing.Callable[..., bool]] = None, + *, + timeout: typing.Optional[float] = None, + ) -> typing.Any: ... + async def wait_for_event( self, event: str, predicate: typing.Optional[typing.Callable] = None, *, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, ) -> typing.Any: """WebSocket.wait_for_event @@ -1185,7 +1289,9 @@ async def wait_for_event( return mapping.from_maybe_impl( await self._impl_obj.wait_for_event( - event=event, predicate=self._wrap_handler(predicate), timeout=timeout + event=event, + predicate=self._wrap_handler(predicate), + timeout=to_milliseconds(timeout), ) ) @@ -1219,6 +1325,34 @@ def url(self) -> str: """ return mapping.from_maybe_impl(self._impl_obj.url) + @property + def protocols(self) -> typing.List[str]: + """WebSocketRoute.protocols + + The list of WebSocket subprotocols requested by the page, as passed via the second argument to the + [`WebSocket` constructor](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket). Corresponds to the + `Sec-WebSocket-Protocol` request header. + + Returns an empty array if no protocols were specified. + + **Usage** + + ```py + async def handler(ws: WebSocketRoute): + if \"chat.v2\" in ws.protocols: + ws.on_message(lambda message: ws.send(f\"v2:{message}\")) + else: + await ws.close(code=1002, reason=\"Unsupported protocol\") + + await page.route_web_socket(\"wss://example.com/ws\", handler) + ``` + + Returns + ------- + List[str] + """ + return mapping.from_maybe_impl(self._impl_obj.protocols) + async def close( self, *, code: typing.Optional[int] = None, reason: typing.Optional[str] = None ) -> None: @@ -2044,7 +2178,9 @@ async def dispatch_event( ) async def scroll_into_view_if_needed( - self, *, timeout: typing.Optional[float] = None + self, + *, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, ) -> None: """ElementHandle.scroll_into_view_if_needed @@ -2065,7 +2201,9 @@ async def scroll_into_view_if_needed( """ return mapping.from_maybe_impl( - await self._impl_obj.scroll_into_view_if_needed(timeout=timeout) + await self._impl_obj.scroll_into_view_if_needed( + timeout=to_milliseconds(timeout) + ) ) async def hover( @@ -2075,7 +2213,7 @@ async def hover( typing.Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] ] = None, position: typing.Optional[Position] = None, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, no_wait_after: typing.Optional[bool] = None, force: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, @@ -2118,7 +2256,7 @@ async def hover( await self._impl_obj.hover( modifiers=mapping.to_impl(modifiers), position=position, - timeout=timeout, + timeout=to_milliseconds(timeout), noWaitAfter=no_wait_after, force=force, trial=trial, @@ -2135,7 +2273,7 @@ async def click( delay: typing.Optional[float] = None, button: typing.Optional[Literal["left", "middle", "right"]] = None, click_count: typing.Optional[int] = None, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, @@ -2194,7 +2332,7 @@ async def click( delay=delay, button=button, clickCount=click_count, - timeout=timeout, + timeout=to_milliseconds(timeout), force=force, noWaitAfter=no_wait_after, trial=trial, @@ -2211,7 +2349,7 @@ async def dblclick( position: typing.Optional[Position] = None, delay: typing.Optional[float] = None, button: typing.Optional[Literal["left", "middle", "right"]] = None, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, @@ -2266,7 +2404,7 @@ async def dblclick( position=position, delay=delay, button=button, - timeout=timeout, + timeout=to_milliseconds(timeout), force=force, noWaitAfter=no_wait_after, trial=trial, @@ -2283,7 +2421,7 @@ async def select_option( element: typing.Optional[ typing.Union["ElementHandle", typing.Sequence["ElementHandle"]] ] = None, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, ) -> typing.List[str]: @@ -2344,7 +2482,7 @@ async def select_option( index=mapping.to_impl(index), label=mapping.to_impl(label), element=mapping.to_impl(element), - timeout=timeout, + timeout=to_milliseconds(timeout), force=force, noWaitAfter=no_wait_after, ) @@ -2357,7 +2495,7 @@ async def tap( typing.Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] ] = None, position: typing.Optional[Position] = None, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, @@ -2402,7 +2540,7 @@ async def tap( await self._impl_obj.tap( modifiers=mapping.to_impl(modifiers), position=position, - timeout=timeout, + timeout=to_milliseconds(timeout), force=force, noWaitAfter=no_wait_after, trial=trial, @@ -2413,7 +2551,7 @@ async def fill( self, value: str, *, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, no_wait_after: typing.Optional[bool] = None, force: typing.Optional[bool] = None, ) -> None: @@ -2445,7 +2583,10 @@ async def fill( return mapping.from_maybe_impl( await self._impl_obj.fill( - value=value, timeout=timeout, noWaitAfter=no_wait_after, force=force + value=value, + timeout=to_milliseconds(timeout), + noWaitAfter=no_wait_after, + force=force, ) ) @@ -2453,7 +2594,7 @@ async def select_text( self, *, force: typing.Optional[bool] = None, - timeout: typing.Optional[float] = None, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, ) -> None: """ElementHandle.select_text @@ -2474,10 +2615,16 @@ async def select_text( """ return mapping.from_maybe_impl( - await self._impl_obj.select_text(force=force, timeout=timeout) + await self._impl_obj.select_text( + force=force, timeout=to_milliseconds(timeout) + ) ) - async def input_value(self, *, timeout: typing.Optional[float] = None) -> str: + async def input_value( + self, + *, + timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, + ) -> str: """ElementHandle.input_value Returns `input.value` for the selected `` or `