From 020cb13f93df153c41e82f68f5b91b5081d38f74 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 10 Jun 2026 10:45:28 +0200 Subject: [PATCH 1/9] feat(roll): update driver to ac7cdd4bd, port new APIs, add tests Driver SHA: ac7cdd4bdf15f90fe7229243be6b35a53e0296d1 (v1.61.0-next) New APIs: - APIResponse.security_details / .server_addr - Credentials class (WebAuthn) + BrowserContext.credentials - WebStorage class + Page.local_storage / .session_storage - Screencast.start(size=), .show_actions(cursor=), ScreencastFrame.timestamp - BrowserType.connect_over_cdp(artifacts_dir=) Also: - Stop forcing options-bag properties to required=False in documentation_provider.py (Credentials.create(rp_id=) was the only case affected) - Update rolling skill to use driver/playwright-src - 37 new tests (async + sync) --- .claude/skills/playwright-roll/SKILL.md | 36 +-- DRIVER_SHA | 2 +- README.md | 4 +- playwright/_impl/_api_structures.py | 14 + playwright/_impl/_browser_context.py | 6 + playwright/_impl/_browser_type.py | 1 + playwright/_impl/_credentials.py | 57 ++++ playwright/_impl/_fetch.py | 8 + playwright/_impl/_page.py | 11 + playwright/_impl/_screencast.py | 10 +- playwright/_impl/_web_storage.py | 58 ++++ playwright/async_api/__init__.py | 4 + playwright/async_api/_generated.py | 292 ++++++++++++++++- playwright/sync_api/__init__.py | 4 + playwright/sync_api/_generated.py | 299 +++++++++++++++++- scripts/build_driver.sh | 2 +- scripts/documentation_provider.py | 1 - scripts/generate_api.py | 8 +- .../async/test_browsercontext_credentials.py | 49 +++ tests/async/test_page_web_storage.py | 88 ++++++ tests/async/test_screencast.py | 47 ++- tests/sync/test_browsercontext_credentials.py | 47 +++ tests/sync/test_page_web_storage.py | 88 ++++++ tests/sync/test_screencast.py | 37 ++- 24 files changed, 1122 insertions(+), 51 deletions(-) create mode 100644 playwright/_impl/_credentials.py create mode 100644 playwright/_impl/_web_storage.py create mode 100644 tests/async/test_browsercontext_credentials.py create mode 100644 tests/async/test_page_web_storage.py create mode 100644 tests/sync/test_browsercontext_credentials.py create mode 100644 tests/sync/test_page_web_storage.py diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md index c63669f2e..f5c67c709 100644 --- a/.claude/skills/playwright-roll/SKILL.md +++ b/.claude/skills/playwright-roll/SKILL.md @@ -27,21 +27,6 @@ The upstream documentation source of truth is `docs/src/api/*.md` in the playwri > **The mistake the 1.59 roll made twice over:** classifying things as "internal tooling, N/A for Python" based on the *name* of the API (Screencast, Debugger, pickLocator, clearConsoleMessages, artifactsDir, …). Almost all of those had empty `langs: {}` in `api.json` and were real Python APIs. Sounding tooling-y is not a `langs` filter. **The `langs` field on the member in `api.json` is the only authoritative signal.** When in doubt, dump it (see "Verifying classifications" below). -## Pre-flight - -You will need two checkouts in the parent directory: -- `~/code/playwright-python` — this repo. -- `~/code/playwright` — the upstream playwright monorepo (used read-only for diffing). - -Bring upstream up to date and ensure release branches/tags are present: - -```sh -git -C ~/code/playwright fetch --tags -git -C ~/code/playwright fetch origin 'release-*:release-*' -``` - -There is sometimes no `vX.Y.0` tag for the latest release (the bots cut release branches first and tag later). Anchor on commits, not tags — see "Identify the commit range" below. - ## Process ### 1. Set up the env @@ -76,18 +61,29 @@ build + per-platform Node downloads). ### 3. Identify the commit range +The build step (step 2) clones the upstream monorepo into `driver/playwright-src`. +Bring it up to date and ensure release branches/tags are present before walking +the range: + +```sh +git -C driver/playwright-src fetch --tags +git -C driver/playwright-src fetch origin 'release-*:release-*' +``` + +There is sometimes no `vX.Y.0` tag for the latest release (the bots cut release branches first and tag later). Anchor on commits, not tags. + The diff range is "every commit on the new release branch since the previous release was cut". Anchor commits: - **Previous release end**: the `chore: bump version to vX.Y.0-next` commit on `main`. That commit is the first commit *after* the previous release (X.Y-1) was cut. Use its parent (`~1`) as the lower bound. ```sh - git -C ~/code/playwright log --all --grep="bump version to v" --oneline | head + git -C driver/playwright-src log --all --grep="bump version to v" --oneline | head ``` - **New release end**: the tip of `release-` (or the matching tag if it exists). Save the commit list, oldest first, scoped to `docs/src/api/`: ```sh -git -C ~/code/playwright log ~1..release- --oneline --reverse -- docs/src/api > /tmp/roll--commits.md +git -C driver/playwright-src log ~1..release- --oneline --reverse -- docs/src/api > /tmp/roll--commits.md ``` A normal roll yields 50–100 commits. If you see 0 or thousands, the range is wrong. @@ -99,7 +95,7 @@ Format the file as a markdown checklist and add the standard preamble (status le For each commit, in chronological order: ```sh -git -C ~/code/playwright show -- docs/src/api/ +git -C driver/playwright-src show -- docs/src/api/ ``` Look for: @@ -144,7 +140,7 @@ A few rules of thumb that catch most "actually a PORT" cases: #### PORT -Implement the change in `playwright/_impl/.py`. Use the upstream JS implementation as a reference: `~/code/playwright/packages/playwright-core/src/client/.ts`. Translate idioms: +Implement the change in `playwright/_impl/.py`. Use the upstream JS implementation as a reference: `driver/playwright-src/packages/playwright-core/src/client/.ts`. Translate idioms: | Upstream JS | Python | |---|---| @@ -285,7 +281,7 @@ Class names use the upstream PascalCase (`BrowserContext`, `BrowserType`); metho - **A cluster of suppressions on the same class is a smell.** If you're about to add five `Method not implemented: Foo.*` lines, you're almost certainly looking at a class that needs to be implemented. Implement the whole thing once and the suppressions disappear. - **Watch for revert pairs in the same range.** 1.59 added and reverted `Browser.isRemote` (#39613 / #39620) inside the same release. Walking chronologically lets you skip the add when you see the revert later. - **Watch for rename-revert pairs.** 1.59 had `Locator.normalize` → `Locator.toCode` (#39648) → `Locator.normalize` (#39754). Final state wins; only port the last. -- **Doc renames almost always include a wire-protocol rename.** Whenever you see `### param: X.y.oldName` → `### param: X.y.newName` in a doc commit, also `git -C ~/code/playwright show -- packages/protocol/src/protocol.yml` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the channel-send dict key in `_impl/` must change. Suppressing the doc-side mismatch is hiding a real bug — the previous Python code is silently sending an unknown field that the new server ignores. +- **Doc renames almost always include a wire-protocol rename.** Whenever you see `### param: X.y.oldName` → `### param: X.y.newName` in a doc commit, also `git -C driver/playwright-src show -- packages/protocol/src/protocol.yml` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the channel-send dict key in `_impl/` must change. Suppressing the doc-side mismatch is hiding a real bug — the previous Python code is silently sending an unknown field that the new server ignores. - **TypedDicts beat `Dict[str, X]` for any structured return.** When the docs describe a return as `[Object]` with named fields (or even `[Object=Foo]`), define a `TypedDict` in `_api_structures.py`, re-export from both public `__init__.py` files, and use it. Zero runtime cost (it's still a `dict`), and the doc generator's type comparator matches by structure via `get_type_hints`. - **Positional renames are free.** A param with no default before any `*` separator is positional-or-keyword in Python, but realistic call sites pass it positionally. Renaming such a param doesn't break callers. - **The "Backport changes" GitHub issue can be misleading.** In the 1.59 roll its checkboxes were all marked `[x]` with annotations like "✅ IMPLEMENTED", but several of those features had not actually been merged into the Python port. Trust the `docs/src/api/` walk over the issue. diff --git a/DRIVER_SHA b/DRIVER_SHA index cce0793a8..85bbf7da8 100644 --- a/DRIVER_SHA +++ b/DRIVER_SHA @@ -1 +1 @@ -87bb9ddbd78f329df18c2b24847bc9409240cd07 +ac7cdd4bdf15f90fe7229243be6b35a53e0296d1 diff --git a/README.md b/README.md index c9ce32470..b7029e54f 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 148.0.7778.96 | ✅ | ✅ | ✅ | +| Chromium 149.0.7827.22 | ✅ | ✅ | ✅ | | WebKit 26.4 | ✅ | ✅ | ✅ | -| Firefox 150.0.2 | ✅ | ✅ | ✅ | +| Firefox 151.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index 2b9a331c2..d913a9ebc 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -344,7 +344,21 @@ class DebuggerPausedDetails(TypedDict): title: str +class ScreencastSize(TypedDict): + width: int + height: int + + +class VirtualCredential(TypedDict): + id: str + rpId: str + userHandle: str + privateKey: str + publicKey: str + + class ScreencastFrame(TypedDict): data: bytes + timestamp: float viewportWidth: int viewportHeight: int diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 38cccd4a3..3437879a8 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -46,6 +46,7 @@ from_nullable_channel, ) from playwright._impl._console_message import ConsoleMessage +from playwright._impl._credentials import Credentials from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog from playwright._impl._disposable import Disposable, DisposableStub @@ -133,6 +134,7 @@ def __init__( self._request: APIRequestContext = from_channel(initializer["requestContext"]) self._request._timeout_settings = self._timeout_settings self._clock = Clock(self) + self._credentials = Credentials(self) self._channel.on( "bindingCall", lambda params: self._on_binding(from_channel(params["binding"])), @@ -741,3 +743,7 @@ def request(self) -> "APIRequestContext": @property def clock(self) -> Clock: return self._clock + + @property + def credentials(self) -> Credentials: + return self._credentials diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index 8abac6061..b1c2cc5c7 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -202,6 +202,7 @@ async def connect_over_cdp( headers: Dict[str, str] = None, isLocal: bool = None, noDefaults: bool = None, + artifactsDir: Union[str, Path] = None, ) -> Browser: params = locals_to_params(locals()) if params.get("headers"): diff --git a/playwright/_impl/_credentials.py b/playwright/_impl/_credentials.py new file mode 100644 index 000000000..6505a0d9c --- /dev/null +++ b/playwright/_impl/_credentials.py @@ -0,0 +1,57 @@ +# 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 typing import TYPE_CHECKING, List + +from playwright._impl._api_structures import VirtualCredential +from playwright._impl._helper import locals_to_params + +if TYPE_CHECKING: + from playwright._impl._browser_context import BrowserContext + + +class Credentials: + def __init__(self, browser_context: "BrowserContext") -> None: + self._browser_context = browser_context + self._loop = browser_context._loop + self._dispatcher_fiber = browser_context._dispatcher_fiber + + async def install(self) -> None: + await self._browser_context._channel.send("credentialsInstall", None) + + async def create( + self, + rpId: str, + id: str = None, + userHandle: str = None, + privateKey: str = None, + publicKey: str = None, + ) -> VirtualCredential: + result = await self._browser_context._channel.send_return_as_dict( + "credentialsCreate", None, locals_to_params(locals()) + ) + return (result or {})["credential"] + + async def delete(self, id: str) -> None: + await self._browser_context._channel.send("credentialsDelete", None, {"id": id}) + + async def get( + self, + rpId: str = None, + id: str = None, + ) -> List[VirtualCredential]: + result = await self._browser_context._channel.send_return_as_dict( + "credentialsGet", None, locals_to_params(locals()) + ) + return (result or {}).get("credentials", []) diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index a14378149..c5cd09340 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -28,6 +28,8 @@ Headers, HttpCredentials, ProxySettings, + RemoteAddr, + SecurityDetails, ServerFilePayload, StorageState, ) @@ -557,6 +559,12 @@ async def json(self) -> Any: content = await self.text() return json.loads(content) + async def security_details(self) -> Optional[SecurityDetails]: + return self._initializer.get("securityDetails") or None + + async def server_addr(self) -> Optional[RemoteAddr]: + return self._initializer.get("serverAddr") or None + async def dispose(self) -> None: await self._request._channel.send( "disposeAPIResponse", diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 9bf59c313..cf2a12b13 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -103,6 +103,7 @@ from playwright._impl._screencast import Screencast from playwright._impl._video import Video from playwright._impl._waiter import Waiter +from playwright._impl._web_storage import WebStorage if TYPE_CHECKING: # pragma: no cover from playwright._impl._browser_context import BrowserContext @@ -183,6 +184,8 @@ def __init__( cast(Optional[Artifact], from_nullable_channel(initializer.get("video"))), ) self._screencast: Screencast = Screencast(self) + self._local_storage = WebStorage(self, "local") + self._session_storage = WebStorage(self, "session") self._opener = cast("Page", from_nullable_channel(initializer.get("opener"))) self._close_reason: Optional[str] = None self._close_was_called = False @@ -1206,6 +1209,14 @@ def video(self) -> Optional[Video]: def screencast(self) -> Screencast: return self._screencast + @property + def local_storage(self) -> WebStorage: + return self._local_storage + + @property + def session_storage(self) -> WebStorage: + return self._session_storage + def _close_error_with_reason(self) -> TargetClosedError: return TargetClosedError( self._close_reason or self._browser_context._effective_close_reason() diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py index 600297203..8a839d385 100644 --- a/playwright/_impl/_screencast.py +++ b/playwright/_impl/_screencast.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union -from playwright._impl._api_structures import ScreencastFrame +from playwright._impl._api_structures import ScreencastFrame, ScreencastSize from playwright._impl._artifact import Artifact from playwright._impl._connection import from_nullable_channel from playwright._impl._disposable import DisposableStub @@ -36,6 +36,10 @@ "top-left", "top-right", ] +ScreencastCursor = Literal[ + "none", + "pointer", +] class Screencast: @@ -58,6 +62,7 @@ def _dispatch_frame(self, params: dict) -> None: result = self._on_frame( { "data": data, + "timestamp": params.get("timestamp", 0), "viewportWidth": params["viewportWidth"], "viewportHeight": params["viewportHeight"], } @@ -70,6 +75,7 @@ async def start( onFrame: ScreencastFrameCallback = None, path: Union[str, Path] = None, quality: int = None, + size: ScreencastSize = None, ) -> DisposableStub: if self._started: raise Error("Screencast is already started") @@ -79,6 +85,7 @@ async def start( "screencastStart", None, { + "size": size, "quality": quality, "sendFrames": bool(onFrame), "record": bool(path), @@ -104,6 +111,7 @@ async def show_actions( duration: float = None, position: ScreencastPosition = None, fontSize: int = None, + cursor: ScreencastCursor = None, ) -> DisposableStub: await self._page._channel.send( "screencastShowActions", None, locals_to_params(locals()) diff --git a/playwright/_impl/_web_storage.py b/playwright/_impl/_web_storage.py new file mode 100644 index 000000000..eac6a4228 --- /dev/null +++ b/playwright/_impl/_web_storage.py @@ -0,0 +1,58 @@ +# 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 typing import TYPE_CHECKING, List, Literal, Optional + +from playwright._impl._api_structures import NameValue + +if TYPE_CHECKING: + from playwright._impl._page import Page + + +WebStorageKind = Literal["local", "session"] + + +class WebStorage: + def __init__(self, page: "Page", kind: WebStorageKind) -> None: + self._page = page + self._kind = kind + self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber + + async def items(self) -> List[NameValue]: + result = await self._page._channel.send_return_as_dict( + "webStorageItems", None, {"kind": self._kind} + ) + return (result or {}).get("items", []) + + async def get_item(self, name: str) -> Optional[str]: + result = await self._page._channel.send_return_as_dict( + "webStorageGetItem", None, {"kind": self._kind, "name": name} + ) + return (result or {}).get("value") + + async def set_item(self, name: str, value: str) -> None: + await self._page._channel.send( + "webStorageSetItem", + None, + {"kind": self._kind, "name": name, "value": value}, + ) + + async def remove_item(self, name: str) -> None: + await self._page._channel.send( + "webStorageRemoveItem", None, {"kind": self._kind, "name": name} + ) + + async def clear(self) -> None: + await self._page._channel.send("webStorageClear", None, {"kind": self._kind}) diff --git a/playwright/async_api/__init__.py b/playwright/async_api/__init__.py index 21ce850b4..f0a123e82 100644 --- a/playwright/async_api/__init__.py +++ b/playwright/async_api/__init__.py @@ -82,10 +82,12 @@ ProxySettings = playwright._impl._api_structures.ProxySettings ResourceTiming = playwright._impl._api_structures.ResourceTiming ScreencastFrame = playwright._impl._api_structures.ScreencastFrame +ScreencastSize = playwright._impl._api_structures.ScreencastSize SourceLocation = playwright._impl._api_structures.SourceLocation StorageState = playwright._impl._api_structures.StorageState StorageStateCookie = playwright._impl._api_structures.StorageStateCookie ViewportSize = playwright._impl._api_structures.ViewportSize +VirtualCredential = playwright._impl._api_structures.VirtualCredential Error = playwright._impl._errors.Error TimeoutError = playwright._impl._errors.TimeoutError @@ -243,6 +245,7 @@ def _dispatch( "Response", "Route", "ScreencastFrame", + "ScreencastSize", "Selectors", "SourceLocation", "StorageState", @@ -251,6 +254,7 @@ def _dispatch( "Touchscreen", "Video", "ViewportSize", + "VirtualCredential", "WebError", "WebSocket", "WebSocketRoute", diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 229ba6d8c..5d0232a8e 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -37,12 +37,14 @@ RequestSizes, ResourceTiming, ScreencastFrame, + ScreencastSize, SecurityDetails, SetCookieParam, SourceLocation, StorageState, TracingGroupLocation, ViewportSize, + VirtualCredential, WebErrorLocation, ) from playwright._impl._assertions import ( @@ -62,6 +64,7 @@ from playwright._impl._cdp_session import CDPSession as CDPSessionImpl from playwright._impl._clock import Clock as ClockImpl from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl +from playwright._impl._credentials import Credentials as CredentialsImpl from playwright._impl._debugger import Debugger as DebuggerImpl from playwright._impl._dialog import Dialog as DialogImpl from playwright._impl._disposable import Disposable as DisposableImpl @@ -94,6 +97,7 @@ from playwright._impl._tracing import Tracing as TracingImpl from playwright._impl._video import Video as VideoImpl from playwright._impl._web_error import WebError as WebErrorImpl +from playwright._impl._web_storage import WebStorage as WebStorageImpl class Request(AsyncBase): @@ -1790,7 +1794,7 @@ async def tap(self, x: float, y: float) -> None: Dispatches a `touchstart` and `touchend` event with a single touch at the position (`x`,`y`). - **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. + **NOTE** `touchscreen.tap()` will throw if the `hasTouch` option of the browser context is false. Parameters ---------- @@ -3744,7 +3748,7 @@ async def evaluate( `ElementHandle` instances can be passed as an argument to the `frame.evaluate()`: ```py - body_handle = await frame.evaluate(\"document.body\") + body_handle = await frame.evaluate_handle(\"document.body\") html = await frame.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) await body_handle.dispose() ``` @@ -3791,14 +3795,14 @@ async 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\" + a_handle = await frame.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) + a_handle = await frame.evaluate_handle(\"document.body\") + result_handle = await frame.evaluate_handle(\"body => body.innerHTML\", a_handle) print(await result_handle.json_value()) await result_handle.dispose() ``` @@ -7091,7 +7095,8 @@ def set_test_id_attribute(self, attribute_name: str) -> None: Parameters ---------- attribute_name : str - Test id attribute name. + Test id attribute name. To match elements with any of several attributes, pass them as a comma-separated list, e.g. + `"data-pw,data-ti"`. """ return mapping.from_maybe_impl( @@ -7277,6 +7282,118 @@ async def set_system_time( mapping.register(ClockImpl, Clock) +class Credentials(AsyncBase): + + async def install(self) -> None: + """Credentials.install + + Installs the virtual WebAuthn authenticator into the context, overriding `navigator.credentials.create()` and + `navigator.credentials.get()` in all current and future pages. Call this before the page first touches + `navigator.credentials`. + + Required: until `install()` is called, no interception is in place and the page sees the platform's native (or + absent) WebAuthn behaviour. Seeding credentials with `credentials.create()` without `install()` populates + the authenticator, but the page will never see those credentials. + """ + + return mapping.from_maybe_impl(await self._impl_obj.install()) + + async def create( + self, + rp_id: str, + *, + id: typing.Optional[str] = None, + user_handle: typing.Optional[str] = None, + private_key: typing.Optional[str] = None, + public_key: typing.Optional[str] = None, + ) -> VirtualCredential: + """Credentials.create + + Seeds a virtual WebAuthn credential and returns it. + + With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential + is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey + flows. The returned object carries the `privateKey` and `publicKey`, so it can be persisted to disk and re-seeded + in a later test. + + To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. + + Call `credentials.install()` before navigating to a page that uses WebAuthn. + + Parameters + ---------- + rp_id : str + Relying party id (typically the site's effective domain). + id : Union[str, None] + Base64url-encoded credential id. Auto-generated if omitted. + user_handle : Union[str, None] + Base64url-encoded user handle. Auto-generated if omitted. + private_key : Union[str, None] + Base64url-encoded PKCS#8 (DER) private key. Auto-generated if omitted. + public_key : Union[str, None] + Base64url-encoded SPKI (DER) public key. Auto-generated if omitted. + + Returns + ------- + {id: str, rpId: str, userHandle: str, privateKey: str, publicKey: str} + """ + + return mapping.from_impl( + await self._impl_obj.create( + rpId=rp_id, + id=id, + userHandle=user_handle, + privateKey=private_key, + publicKey=public_key, + ) + ) + + async def delete(self, id: str) -> None: + """Credentials.delete + + Removes a credential from the authenticator by its id. Works for any credential currently held — both those seeded + with `credentials.create()` and those the page registered itself by calling + `navigator.credentials.create()`. + + Parameters + ---------- + id : str + Base64url-encoded credential id. + """ + + return mapping.from_maybe_impl(await self._impl_obj.delete(id=id)) + + async def get( + self, *, rp_id: typing.Optional[str] = None, id: typing.Optional[str] = None + ) -> typing.List[VirtualCredential]: + """Credentials.get + + Returns every credential currently held by the authenticator, optionally filtered by `rpId` or `id`. This includes + both credentials seeded with `credentials.create()` and credentials the page registered itself by calling + `navigator.credentials.create()`. + + Each returned credential includes its `privateKey` and `publicKey`, so a passkey the app just registered can be + saved and re-seeded into a later test with `credentials.create()` — see the second example in the class + overview. + + Parameters + ---------- + rp_id : Union[str, None] + Only return credentials for this relying party id. + id : Union[str, None] + Only return the credential with this base64url-encoded id. + + Returns + ------- + List[{id: str, rpId: str, userHandle: str, privateKey: str, publicKey: str}] + """ + + return mapping.from_impl_list(await self._impl_obj.get(rpId=rp_id, id=id)) + + +mapping.register(CredentialsImpl, Credentials) + + class ConsoleMessage(AsyncBase): @property @@ -7692,6 +7809,7 @@ async def start( ] = None, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, quality: typing.Optional[int] = None, + size: typing.Optional[ScreencastSize] = None, ) -> "AsyncContextManager": """Screencast.start @@ -7702,12 +7820,17 @@ async def start( Parameters ---------- - on_frame : Union[Callable[[{data: bytes, viewportWidth: int, viewportHeight: int}], Any], None] + on_frame : Union[Callable[[{data: bytes, timestamp: float, viewportWidth: int, viewportHeight: int}], Any], None] Callback that receives JPEG-encoded frame data along with the page viewport size at the time of capture. path : Union[pathlib.Path, str, None] Path where the video should be saved when the screencast is stopped. When provided, video recording is started. quality : Union[int, None] The quality of the image, between 0-100. + size : Union[{width: int, height: int}, None] + Specifies the dimensions of screencast frames. The actual frame is scaled to preserve the page's aspect ratio and + may be smaller than these bounds. If a screencast is already active (e.g. started by tracing or video recording), + the existing configuration takes precedence and the frame size may exceed these bounds or this option may be + ignored. If not specified the size will be equal to page viewport scaled down to fit into 800×800. Returns ------- @@ -7716,7 +7839,10 @@ async def start( return mapping.from_impl( await self._impl_obj.start( - onFrame=self._wrap_handler(on_frame), path=path, quality=quality + onFrame=self._wrap_handler(on_frame), + path=path, + quality=quality, + size=size, ) ) @@ -7739,6 +7865,7 @@ async def show_actions( ] ] = None, font_size: typing.Optional[int] = None, + cursor: typing.Optional[Literal["none", "pointer"]] = None, ) -> "AsyncContextManager": """Screencast.show_actions @@ -7752,6 +7879,9 @@ async def show_actions( Position of the action title overlay. Defaults to `"top-right"`. font_size : Union[int, None] Font size of the action title in pixels. Defaults to `24`. + cursor : Union["none", "pointer", None] + Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from + the previous action point to the next one. `"none"` disables the cursor decoration. Returns ------- @@ -7760,7 +7890,7 @@ async def show_actions( return mapping.from_impl( await self._impl_obj.show_actions( - duration=duration, position=position, fontSize=font_size + duration=duration, position=position, fontSize=font_size, cursor=cursor ) ) @@ -8582,6 +8712,30 @@ def screencast(self) -> "Screencast": """ return mapping.from_impl(self._impl_obj.screencast) + @property + def local_storage(self) -> "WebStorage": + """Page.local_storage + + Provides access to the page's `localStorage` for the current origin. See `WebStorage`. + + Returns + ------- + WebStorage + """ + return mapping.from_impl(self._impl_obj.local_storage) + + @property + def session_storage(self) -> "WebStorage": + """Page.session_storage + + Provides access to the page's `sessionStorage` for the current origin. See `WebStorage`. + + Returns + ------- + WebStorage + """ + return mapping.from_impl(self._impl_obj.session_storage) + async def opener(self) -> typing.Optional["Page"]: """Page.opener @@ -9109,7 +9263,7 @@ async def evaluate( `ElementHandle` instances can be passed as an argument to the `page.evaluate()`: ```py - body_handle = await page.evaluate(\"document.body\") + body_handle = await page.evaluate_handle(\"document.body\") html = await page.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) await body_handle.dispose() ``` @@ -10866,7 +11020,7 @@ async def tap( When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. - **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. + **NOTE** `page.tap()` will throw if the `hasTouch` option of the browser context is false. Parameters ---------- @@ -13489,6 +13643,79 @@ def location(self) -> WebErrorLocation: mapping.register(WebErrorImpl, WebError) +class WebStorage(AsyncBase): + + async def items(self) -> typing.List[NameValue]: + """WebStorage.items + + Returns all items in the storage as `name`/`value` pairs. + + Returns + ------- + List[{name: str, value: str}] + """ + + return mapping.from_impl_list(await self._impl_obj.items()) + + async def get_item(self, name: str) -> typing.Optional[str]: + """WebStorage.get_item + + Returns the value for the given `name`, or `null` if the key is not present. + + Parameters + ---------- + name : str + Name of the item to retrieve. + + Returns + ------- + Union[str, None] + """ + + return mapping.from_maybe_impl(await self._impl_obj.get_item(name=name)) + + async def set_item(self, name: str, value: str) -> None: + """WebStorage.set_item + + Sets the value for the given `name`. Overwrites any existing value for that name. + + Parameters + ---------- + name : str + Name of the item to set. + value : str + New value for the item. + """ + + return mapping.from_maybe_impl( + await self._impl_obj.set_item(name=name, value=value) + ) + + async def remove_item(self, name: str) -> None: + """WebStorage.remove_item + + Removes the item with the given `name`. No-op if the item is absent. + + Parameters + ---------- + name : str + Name of the item to remove. + """ + + return mapping.from_maybe_impl(await self._impl_obj.remove_item(name=name)) + + async def clear(self) -> None: + """WebStorage.clear + + Removes all items from the storage. + """ + + return mapping.from_maybe_impl(await self._impl_obj.clear()) + + +mapping.register(WebStorageImpl, WebStorage) + + class BrowserContext(AsyncContextManager): @typing.overload @@ -14062,6 +14289,19 @@ def clock(self) -> "Clock": """ return mapping.from_impl(self._impl_obj.clock) + @property + def credentials(self) -> "Credentials": + """BrowserContext.credentials + + Virtual WebAuthn authenticator for this context. Lets tests seed credentials and intercept + `navigator.credentials.create()` / `navigator.credentials.get()` ceremonies. + + Returns + ------- + Credentials + """ + return mapping.from_impl(self._impl_obj.credentials) + def set_default_navigation_timeout( self, timeout: typing.Union[float, datetime.timedelta] ) -> None: @@ -16667,6 +16907,7 @@ async def connect_over_cdp( headers: typing.Optional[typing.Dict[str, str]] = None, is_local: typing.Optional[bool] = None, no_defaults: typing.Optional[bool] = None, + artifacts_dir: typing.Optional[typing.Union[pathlib.Path, str]] = None, ) -> "Browser": """BrowserType.connect_over_cdp @@ -16710,6 +16951,8 @@ async def connect_over_cdp( (such as `colorScheme`, `reducedMotion`, `forcedColors`, and `contrast`) are not applied. Useful when attaching to a user's daily-driver browser where these overrides would interfere with existing browser state. New contexts created via `browser.new_context()` are not affected. Defaults to `false`. + artifacts_dir : Union[pathlib.Path, str, None] + If specified, browser artifacts (such as traces and downloads) are saved into this directory. Returns ------- @@ -16724,6 +16967,7 @@ async def connect_over_cdp( headers=mapping.to_impl(headers), isLocal=is_local, noDefaults=no_defaults, + artifactsDir=artifacts_dir, ) ) @@ -20229,6 +20473,32 @@ async def json(self) -> typing.Any: return mapping.from_maybe_impl(await self._impl_obj.json()) + async def security_details(self) -> typing.Optional[SecurityDetails]: + """APIResponse.security_details + + Returns SSL and other security information. Resolves to `null` for non-HTTPS responses. For redirected requests, + returns the information for the last request in the redirect chain. + + Returns + ------- + Union[{issuer: Union[str, None], protocol: Union[str, None], subjectName: Union[str, None], validFrom: Union[float, None], validTo: Union[float, None]}, None] + """ + + return mapping.from_impl_nullable(await self._impl_obj.security_details()) + + async def server_addr(self) -> typing.Optional[RemoteAddr]: + """APIResponse.server_addr + + Returns the IP address and port of the server. Resolves to `null` if the server address is not available. For + redirected requests, returns the information for the last request in the redirect chain. + + Returns + ------- + Union[{ipAddress: str, port: int}, None] + """ + + return mapping.from_impl_nullable(await self._impl_obj.server_addr()) + async def dispose(self) -> None: """APIResponse.dispose diff --git a/playwright/sync_api/__init__.py b/playwright/sync_api/__init__.py index 5a3c5526d..e025f4a60 100644 --- a/playwright/sync_api/__init__.py +++ b/playwright/sync_api/__init__.py @@ -82,10 +82,12 @@ ProxySettings = playwright._impl._api_structures.ProxySettings ResourceTiming = playwright._impl._api_structures.ResourceTiming ScreencastFrame = playwright._impl._api_structures.ScreencastFrame +ScreencastSize = playwright._impl._api_structures.ScreencastSize SourceLocation = playwright._impl._api_structures.SourceLocation StorageState = playwright._impl._api_structures.StorageState StorageStateCookie = playwright._impl._api_structures.StorageStateCookie ViewportSize = playwright._impl._api_structures.ViewportSize +VirtualCredential = playwright._impl._api_structures.VirtualCredential Error = playwright._impl._errors.Error TimeoutError = playwright._impl._errors.TimeoutError @@ -242,6 +244,7 @@ def _dispatch( "Response", "Route", "ScreencastFrame", + "ScreencastSize", "Selectors", "SourceLocation", "StorageState", @@ -251,6 +254,7 @@ def _dispatch( "Touchscreen", "Video", "ViewportSize", + "VirtualCredential", "WebError", "WebSocket", "WebSocketRoute", diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index d2d37baa2..e87cdde1a 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -37,12 +37,14 @@ RequestSizes, ResourceTiming, ScreencastFrame, + ScreencastSize, SecurityDetails, SetCookieParam, SourceLocation, StorageState, TracingGroupLocation, ViewportSize, + VirtualCredential, WebErrorLocation, ) from playwright._impl._assertions import ( @@ -56,6 +58,7 @@ from playwright._impl._cdp_session import CDPSession as CDPSessionImpl from playwright._impl._clock import Clock as ClockImpl from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl +from playwright._impl._credentials import Credentials as CredentialsImpl from playwright._impl._debugger import Debugger as DebuggerImpl from playwright._impl._dialog import Dialog as DialogImpl from playwright._impl._disposable import Disposable as DisposableImpl @@ -94,6 +97,7 @@ from playwright._impl._tracing import Tracing as TracingImpl from playwright._impl._video import Video as VideoImpl from playwright._impl._web_error import WebError as WebErrorImpl +from playwright._impl._web_storage import WebStorage as WebStorageImpl class Request(SyncBase): @@ -1790,7 +1794,7 @@ def tap(self, x: float, y: float) -> None: Dispatches a `touchstart` and `touchend` event with a single touch at the position (`x`,`y`). - **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. + **NOTE** `touchscreen.tap()` will throw if the `hasTouch` option of the browser context is false. Parameters ---------- @@ -3796,7 +3800,7 @@ def evaluate( `ElementHandle` instances can be passed as an argument to the `frame.evaluate()`: ```py - body_handle = frame.evaluate(\"document.body\") + body_handle = frame.evaluate_handle(\"document.body\") html = frame.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) body_handle.dispose() ``` @@ -3843,14 +3847,14 @@ def evaluate_handle( A string can also be passed in instead of a function. ```py - a_handle = page.evaluate_handle(\"document\") # handle for the \"document\" + a_handle = frame.evaluate_handle(\"document\") # handle for the \"document\" ``` `JSHandle` instances can be passed as an argument to the `frame.evaluate_handle()`: ```py - a_handle = page.evaluate_handle(\"document.body\") - result_handle = page.evaluate_handle(\"body => body.innerHTML\", a_handle) + a_handle = frame.evaluate_handle(\"document.body\") + result_handle = frame.evaluate_handle(\"body => body.innerHTML\", a_handle) print(result_handle.json_value()) result_handle.dispose() ``` @@ -7181,7 +7185,8 @@ def set_test_id_attribute(self, attribute_name: str) -> None: Parameters ---------- attribute_name : str - Test id attribute name. + Test id attribute name. To match elements with any of several attributes, pass them as a comma-separated list, e.g. + `"data-pw,data-ti"`. """ return mapping.from_maybe_impl( @@ -7371,6 +7376,120 @@ def set_system_time( mapping.register(ClockImpl, Clock) +class Credentials(SyncBase): + + def install(self) -> None: + """Credentials.install + + Installs the virtual WebAuthn authenticator into the context, overriding `navigator.credentials.create()` and + `navigator.credentials.get()` in all current and future pages. Call this before the page first touches + `navigator.credentials`. + + Required: until `install()` is called, no interception is in place and the page sees the platform's native (or + absent) WebAuthn behaviour. Seeding credentials with `credentials.create()` without `install()` populates + the authenticator, but the page will never see those credentials. + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.install())) + + def create( + self, + rp_id: str, + *, + id: typing.Optional[str] = None, + user_handle: typing.Optional[str] = None, + private_key: typing.Optional[str] = None, + public_key: typing.Optional[str] = None, + ) -> VirtualCredential: + """Credentials.create + + Seeds a virtual WebAuthn credential and returns it. + + With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential + is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey + flows. The returned object carries the `privateKey` and `publicKey`, so it can be persisted to disk and re-seeded + in a later test. + + To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. + + Call `credentials.install()` before navigating to a page that uses WebAuthn. + + Parameters + ---------- + rp_id : str + Relying party id (typically the site's effective domain). + id : Union[str, None] + Base64url-encoded credential id. Auto-generated if omitted. + user_handle : Union[str, None] + Base64url-encoded user handle. Auto-generated if omitted. + private_key : Union[str, None] + Base64url-encoded PKCS#8 (DER) private key. Auto-generated if omitted. + public_key : Union[str, None] + Base64url-encoded SPKI (DER) public key. Auto-generated if omitted. + + Returns + ------- + {id: str, rpId: str, userHandle: str, privateKey: str, publicKey: str} + """ + + return mapping.from_impl( + self._sync( + self._impl_obj.create( + rpId=rp_id, + id=id, + userHandle=user_handle, + privateKey=private_key, + publicKey=public_key, + ) + ) + ) + + def delete(self, id: str) -> None: + """Credentials.delete + + Removes a credential from the authenticator by its id. Works for any credential currently held — both those seeded + with `credentials.create()` and those the page registered itself by calling + `navigator.credentials.create()`. + + Parameters + ---------- + id : str + Base64url-encoded credential id. + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.delete(id=id))) + + def get( + self, *, rp_id: typing.Optional[str] = None, id: typing.Optional[str] = None + ) -> typing.List[VirtualCredential]: + """Credentials.get + + Returns every credential currently held by the authenticator, optionally filtered by `rpId` or `id`. This includes + both credentials seeded with `credentials.create()` and credentials the page registered itself by calling + `navigator.credentials.create()`. + + Each returned credential includes its `privateKey` and `publicKey`, so a passkey the app just registered can be + saved and re-seeded into a later test with `credentials.create()` — see the second example in the class + overview. + + Parameters + ---------- + rp_id : Union[str, None] + Only return credentials for this relying party id. + id : Union[str, None] + Only return the credential with this base64url-encoded id. + + Returns + ------- + List[{id: str, rpId: str, userHandle: str, privateKey: str, publicKey: str}] + """ + + return mapping.from_impl_list(self._sync(self._impl_obj.get(rpId=rp_id, id=id))) + + +mapping.register(CredentialsImpl, Credentials) + + class ConsoleMessage(SyncBase): @property @@ -7768,6 +7887,7 @@ def start( ] = None, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, quality: typing.Optional[int] = None, + size: typing.Optional[ScreencastSize] = None, ) -> "SyncContextManager": """Screencast.start @@ -7778,12 +7898,17 @@ def start( Parameters ---------- - on_frame : Union[Callable[[{data: bytes, viewportWidth: int, viewportHeight: int}], Any], None] + on_frame : Union[Callable[[{data: bytes, timestamp: float, viewportWidth: int, viewportHeight: int}], Any], None] Callback that receives JPEG-encoded frame data along with the page viewport size at the time of capture. path : Union[pathlib.Path, str, None] Path where the video should be saved when the screencast is stopped. When provided, video recording is started. quality : Union[int, None] The quality of the image, between 0-100. + size : Union[{width: int, height: int}, None] + Specifies the dimensions of screencast frames. The actual frame is scaled to preserve the page's aspect ratio and + may be smaller than these bounds. If a screencast is already active (e.g. started by tracing or video recording), + the existing configuration takes precedence and the frame size may exceed these bounds or this option may be + ignored. If not specified the size will be equal to page viewport scaled down to fit into 800×800. Returns ------- @@ -7793,7 +7918,10 @@ def start( return mapping.from_impl( self._sync( self._impl_obj.start( - onFrame=self._wrap_handler(on_frame), path=path, quality=quality + onFrame=self._wrap_handler(on_frame), + path=path, + quality=quality, + size=size, ) ) ) @@ -7817,6 +7945,7 @@ def show_actions( ] ] = None, font_size: typing.Optional[int] = None, + cursor: typing.Optional[Literal["none", "pointer"]] = None, ) -> "SyncContextManager": """Screencast.show_actions @@ -7830,6 +7959,9 @@ def show_actions( Position of the action title overlay. Defaults to `"top-right"`. font_size : Union[int, None] Font size of the action title in pixels. Defaults to `24`. + cursor : Union["none", "pointer", None] + Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from + the previous action point to the next one. `"none"` disables the cursor decoration. Returns ------- @@ -7839,7 +7971,10 @@ def show_actions( return mapping.from_impl( self._sync( self._impl_obj.show_actions( - duration=duration, position=position, fontSize=font_size + duration=duration, + position=position, + fontSize=font_size, + cursor=cursor, ) ) ) @@ -8560,6 +8695,30 @@ def screencast(self) -> "Screencast": """ return mapping.from_impl(self._impl_obj.screencast) + @property + def local_storage(self) -> "WebStorage": + """Page.local_storage + + Provides access to the page's `localStorage` for the current origin. See `WebStorage`. + + Returns + ------- + WebStorage + """ + return mapping.from_impl(self._impl_obj.local_storage) + + @property + def session_storage(self) -> "WebStorage": + """Page.session_storage + + Provides access to the page's `sessionStorage` for the current origin. See `WebStorage`. + + Returns + ------- + WebStorage + """ + return mapping.from_impl(self._impl_obj.session_storage) + def opener(self) -> typing.Optional["Page"]: """Page.opener @@ -9100,7 +9259,7 @@ def evaluate( `ElementHandle` instances can be passed as an argument to the `page.evaluate()`: ```py - body_handle = page.evaluate(\"document.body\") + body_handle = page.evaluate_handle(\"document.body\") html = page.evaluate(\"([body, suffix]) => body.innerHTML + suffix\", [body_handle, \"hello\"]) body_handle.dispose() ``` @@ -10904,7 +11063,7 @@ def tap( When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. - **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. + **NOTE** `page.tap()` will throw if the `hasTouch` option of the browser context is false. Parameters ---------- @@ -13566,6 +13725,81 @@ def location(self) -> WebErrorLocation: mapping.register(WebErrorImpl, WebError) +class WebStorage(SyncBase): + + def items(self) -> typing.List[NameValue]: + """WebStorage.items + + Returns all items in the storage as `name`/`value` pairs. + + Returns + ------- + List[{name: str, value: str}] + """ + + return mapping.from_impl_list(self._sync(self._impl_obj.items())) + + def get_item(self, name: str) -> typing.Optional[str]: + """WebStorage.get_item + + Returns the value for the given `name`, or `null` if the key is not present. + + Parameters + ---------- + name : str + Name of the item to retrieve. + + Returns + ------- + Union[str, None] + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.get_item(name=name))) + + def set_item(self, name: str, value: str) -> None: + """WebStorage.set_item + + Sets the value for the given `name`. Overwrites any existing value for that name. + + Parameters + ---------- + name : str + Name of the item to set. + value : str + New value for the item. + """ + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.set_item(name=name, value=value)) + ) + + def remove_item(self, name: str) -> None: + """WebStorage.remove_item + + Removes the item with the given `name`. No-op if the item is absent. + + Parameters + ---------- + name : str + Name of the item to remove. + """ + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.remove_item(name=name)) + ) + + def clear(self) -> None: + """WebStorage.clear + + Removes all items from the storage. + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.clear())) + + +mapping.register(WebStorageImpl, WebStorage) + + class BrowserContext(SyncContextManager): @typing.overload @@ -14049,6 +14283,19 @@ def clock(self) -> "Clock": """ return mapping.from_impl(self._impl_obj.clock) + @property + def credentials(self) -> "Credentials": + """BrowserContext.credentials + + Virtual WebAuthn authenticator for this context. Lets tests seed credentials and intercept + `navigator.credentials.create()` / `navigator.credentials.get()` ceremonies. + + Returns + ------- + Credentials + """ + return mapping.from_impl(self._impl_obj.credentials) + def set_default_navigation_timeout( self, timeout: typing.Union[float, datetime.timedelta] ) -> None: @@ -16633,6 +16880,7 @@ def connect_over_cdp( headers: typing.Optional[typing.Dict[str, str]] = None, is_local: typing.Optional[bool] = None, no_defaults: typing.Optional[bool] = None, + artifacts_dir: typing.Optional[typing.Union[pathlib.Path, str]] = None, ) -> "Browser": """BrowserType.connect_over_cdp @@ -16676,6 +16924,8 @@ def connect_over_cdp( (such as `colorScheme`, `reducedMotion`, `forcedColors`, and `contrast`) are not applied. Useful when attaching to a user's daily-driver browser where these overrides would interfere with existing browser state. New contexts created via `browser.new_context()` are not affected. Defaults to `false`. + artifacts_dir : Union[pathlib.Path, str, None] + If specified, browser artifacts (such as traces and downloads) are saved into this directory. Returns ------- @@ -16691,6 +16941,7 @@ def connect_over_cdp( headers=mapping.to_impl(headers), isLocal=is_local, noDefaults=no_defaults, + artifactsDir=artifacts_dir, ) ) ) @@ -20258,6 +20509,32 @@ def json(self) -> typing.Any: return mapping.from_maybe_impl(self._sync(self._impl_obj.json())) + def security_details(self) -> typing.Optional[SecurityDetails]: + """APIResponse.security_details + + Returns SSL and other security information. Resolves to `null` for non-HTTPS responses. For redirected requests, + returns the information for the last request in the redirect chain. + + Returns + ------- + Union[{issuer: Union[str, None], protocol: Union[str, None], subjectName: Union[str, None], validFrom: Union[float, None], validTo: Union[float, None]}, None] + """ + + return mapping.from_impl_nullable(self._sync(self._impl_obj.security_details())) + + def server_addr(self) -> typing.Optional[RemoteAddr]: + """APIResponse.server_addr + + Returns the IP address and port of the server. Resolves to `null` if the server address is not available. For + redirected requests, returns the information for the last request in the redirect chain. + + Returns + ------- + Union[{ipAddress: str, port: int}, None] + """ + + return mapping.from_impl_nullable(self._sync(self._impl_obj.server_addr())) + def dispose(self) -> None: """APIResponse.dispose diff --git a/scripts/build_driver.sh b/scripts/build_driver.sh index 4ebc65354..d19733c09 100755 --- a/scripts/build_driver.sh +++ b/scripts/build_driver.sh @@ -45,7 +45,7 @@ SOURCE_DIR="$DRIVER_DIR/playwright-src" PLAYWRIGHT_REPO="https://github.com/microsoft/playwright" # The driver pin: an immutable commit in microsoft/playwright. -# microsoft/playwright @ v1.60.0 +# microsoft/playwright @ main EXPECTED_SHA="$(tr -d '[:space:]' < "$REPO_ROOT/DRIVER_SHA")" if [[ -z "$EXPECTED_SHA" ]]; then echo "DRIVER_SHA is empty or missing at $REPO_ROOT/DRIVER_SHA" >&2 diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index ed4a70e93..c5d1505bd 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -82,7 +82,6 @@ def _patch_case(self) -> None: option = self_or_override(option) option_name = to_snake_case(name_or_alias(option)) option["name"] = option_name - option["required"] = False args[option_name] = option else: arg = self_or_override(arg) diff --git a/scripts/generate_api.py b/scripts/generate_api.py index e45f629cb..fb0579f1f 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -29,6 +29,7 @@ from playwright._impl._cdp_session import CDPSession from playwright._impl._clock import Clock from playwright._impl._console_message import ConsoleMessage +from playwright._impl._credentials import Credentials from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog from playwright._impl._disposable import Disposable @@ -55,6 +56,7 @@ from playwright._impl._tracing import Tracing from playwright._impl._video import Video from playwright._impl._web_error import WebError +from playwright._impl._web_storage import WebStorage SYNC_API = False @@ -246,11 +248,12 @@ def return_value(value: Any) -> List[str]: from typing import Literal -from playwright._impl._api_structures import Cookie, SetCookieParam, FloatRect, FilePayload, Geolocation, HttpCredentials, PdfMargins, Position, ProxySettings, ResourceTiming, SourceLocation, StorageState, ClientCertificate, ViewportSize, RemoteAddr, SecurityDetails, RequestSizes, NameValue, TracingGroupLocation, DebuggerLocation, DebuggerPausedDetails, ScreencastFrame, BrowserBindResult, WebErrorLocation, DropPayload +from playwright._impl._api_structures import Cookie, SetCookieParam, FloatRect, FilePayload, Geolocation, HttpCredentials, PdfMargins, Position, ProxySettings, ResourceTiming, SourceLocation, StorageState, ClientCertificate, ViewportSize, RemoteAddr, SecurityDetails, RequestSizes, NameValue, TracingGroupLocation, DebuggerLocation, DebuggerPausedDetails, ScreencastFrame, ScreencastSize, BrowserBindResult, WebErrorLocation, DropPayload, VirtualCredential from playwright._impl._browser import Browser as BrowserImpl from playwright._impl._browser_context import BrowserContext as BrowserContextImpl from playwright._impl._browser_type import BrowserType as BrowserTypeImpl from playwright._impl._clock import Clock as ClockImpl +from playwright._impl._credentials import Credentials as CredentialsImpl from playwright._impl._cdp_session import CDPSession as CDPSessionImpl from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl from playwright._impl._debugger import Debugger as DebuggerImpl @@ -269,6 +272,7 @@ def return_value(value: Any) -> List[str]: from playwright._impl._selectors import Selectors as SelectorsImpl from playwright._impl._screencast import Screencast as ScreencastImpl from playwright._impl._video import Video as VideoImpl +from playwright._impl._web_storage import WebStorage as WebStorageImpl from playwright._impl._tracing import Tracing as TracingImpl from playwright._impl._locator import Locator as LocatorImpl, FrameLocator as FrameLocatorImpl from playwright._impl._errors import Error @@ -296,6 +300,7 @@ def return_value(value: Any) -> List[str]: Worker, Selectors, Clock, + Credentials, ConsoleMessage, Debugger, Dialog, @@ -304,6 +309,7 @@ def return_value(value: Any) -> List[str]: Video, Page, WebError, + WebStorage, BrowserContext, CDPSession, Browser, diff --git a/tests/async/test_browsercontext_credentials.py b/tests/async/test_browsercontext_credentials.py new file mode 100644 index 000000000..c9f085eb2 --- /dev/null +++ b/tests/async/test_browsercontext_credentials.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. + +from playwright.async_api import Browser, BrowserContext +from tests.server import Server + + +async def test_should_expose_credentials_property( + context: BrowserContext, +) -> None: + assert context.credentials is context.credentials + + +async def test_install_create_get_and_delete_credentials( + browser: Browser, https_server: Server +) -> None: + context = await browser.new_context(ignore_https_errors=True) + async with context: + page = await context.new_page() + await page.goto(https_server.EMPTY_PAGE, wait_until="networkidle") + creds = context.credentials + await creds.install() + result = await creds.create( + rp_id="localhost", + id="test-credential-id", + private_key="private-key-data", + public_key="public-key-data", + ) + assert result["id"] == "test-credential-id" + assert result["rpId"] == "localhost" + + credentials = await creds.get() + assert len(credentials) == 1 + assert credentials[0]["id"] == "test-credential-id" + + await creds.delete(id="test-credential-id") + credentials = await creds.get() + assert len(credentials) == 0 diff --git a/tests/async/test_page_web_storage.py b/tests/async/test_page_web_storage.py new file mode 100644 index 000000000..7f1e88648 --- /dev/null +++ b/tests/async/test_page_web_storage.py @@ -0,0 +1,88 @@ +# 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.async_api import Page +from tests.server import Server + + +async def test_should_expose_local_storage_property(page: Page) -> None: + assert page.local_storage is page.local_storage + + +async def test_should_expose_session_storage_property(page: Page) -> None: + assert page.session_storage is page.session_storage + + +async def test_local_storage_set_and_get_item(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => localStorage.setItem('foo', 'bar')") + value = await page.local_storage.get_item("foo") + assert value == "bar" + + +async def test_local_storage_items(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => localStorage.setItem('a', '1')") + await page.evaluate("() => localStorage.setItem('b', '2')") + items = await page.local_storage.items() + assert len(items) == 2 + assert {"name": "a", "value": "1"} in items + assert {"name": "b", "value": "2"} in items + + +async def test_local_storage_remove_item(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => localStorage.setItem('foo', 'bar')") + await page.local_storage.remove_item("foo") + result = await page.evaluate("() => localStorage.getItem('foo')") + assert result is None + + +async def test_local_storage_clear(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => localStorage.setItem('foo', 'bar')") + await page.local_storage.clear() + length = await page.evaluate("() => localStorage.length") + assert length == 0 + + +async def test_session_storage_set_and_get_item(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + value = await page.session_storage.get_item("foo") + assert value == "bar" + + +async def test_session_storage_items(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => sessionStorage.setItem('a', '1')") + items = await page.session_storage.items() + assert len(items) == 1 + assert items[0] == {"name": "a", "value": "1"} + + +async def test_session_storage_remove_item(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + await page.session_storage.remove_item("foo") + result = await page.evaluate("() => sessionStorage.getItem('foo')") + assert result is None + + +async def test_session_storage_clear(page: Page, server: Server) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + await page.session_storage.clear() + length = await page.evaluate("() => sessionStorage.length") + assert length == 0 diff --git a/tests/async/test_screencast.py b/tests/async/test_screencast.py index 5532f7847..5f21f3a64 100644 --- a/tests/async/test_screencast.py +++ b/tests/async/test_screencast.py @@ -16,7 +16,7 @@ import pytest -from playwright.async_api import Page, ScreencastFrame +from playwright.async_api import Page, ScreencastFrame, ScreencastSize from tests.server import Server @@ -70,3 +70,48 @@ async def test_show_overlays_and_overlay_apis_should_not_throw(page: Page) -> No await page.screencast.hide_actions() finally: await page.screencast.stop() + + +async def test_start_should_accept_size_param(page: Page, server: Server) -> None: + received: list = [] + event = asyncio.Event() + + def on_frame(frame: ScreencastFrame) -> None: + received.append(frame) + event.set() + + size: ScreencastSize = {"width": 800, "height": 600} + await page.screencast.start(on_frame=on_frame, size=size) + await page.goto(server.EMPTY_PAGE) + await page.screenshot() + await asyncio.wait_for(event.wait(), timeout=10) + await page.screencast.stop() + assert len(received) >= 1 + + +async def test_show_actions_should_accept_cursor_param(page: Page) -> None: + await page.screencast.start(on_frame=lambda f: None) + try: + async with await page.screencast.show_actions(duration=100, cursor="pointer"): + pass + async with await page.screencast.show_actions(duration=100, cursor="none"): + pass + finally: + await page.screencast.stop() + + +async def test_frames_should_include_timestamp(page: Page, server: Server) -> None: + received: list = [] + event = asyncio.Event() + + def on_frame(frame: ScreencastFrame) -> None: + received.append(frame) + event.set() + + await page.screencast.start(on_frame=on_frame) + await page.goto(server.EMPTY_PAGE) + await page.screenshot() + await asyncio.wait_for(event.wait(), timeout=10) + await page.screencast.stop() + assert len(received) >= 1 + assert received[0]["timestamp"] > 0 diff --git a/tests/sync/test_browsercontext_credentials.py b/tests/sync/test_browsercontext_credentials.py new file mode 100644 index 000000000..5406e7101 --- /dev/null +++ b/tests/sync/test_browsercontext_credentials.py @@ -0,0 +1,47 @@ +# 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 Browser, BrowserContext +from tests.server import Server + + +def test_should_expose_credentials_property(context: BrowserContext) -> None: + assert context.credentials is context.credentials + + +def test_install_create_get_and_delete_credentials( + browser: Browser, https_server: Server +) -> None: + context = browser.new_context(ignore_https_errors=True) + page = context.new_page() + page.goto(https_server.EMPTY_PAGE, wait_until="networkidle") + creds = context.credentials + creds.install() + result = creds.create( + rp_id="localhost", + id="test-credential-id", + private_key="private-key-data", + public_key="public-key-data", + ) + assert result["id"] == "test-credential-id" + assert result["rpId"] == "localhost" + + credentials = creds.get() + assert len(credentials) == 1 + assert credentials[0]["id"] == "test-credential-id" + + creds.delete(id="test-credential-id") + credentials = creds.get() + assert len(credentials) == 0 + context.close() diff --git a/tests/sync/test_page_web_storage.py b/tests/sync/test_page_web_storage.py new file mode 100644 index 000000000..fcbc781e5 --- /dev/null +++ b/tests/sync/test_page_web_storage.py @@ -0,0 +1,88 @@ +# 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 Page +from tests.server import Server + + +def test_should_expose_local_storage_property(page: Page) -> None: + assert page.local_storage is page.local_storage + + +def test_should_expose_session_storage_property(page: Page) -> None: + assert page.session_storage is page.session_storage + + +def test_local_storage_set_and_get_item(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => localStorage.setItem('foo', 'bar')") + value = page.local_storage.get_item("foo") + assert value == "bar" + + +def test_local_storage_items(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => localStorage.setItem('a', '1')") + page.evaluate("() => localStorage.setItem('b', '2')") + items = page.local_storage.items() + assert len(items) == 2 + assert {"name": "a", "value": "1"} in items + assert {"name": "b", "value": "2"} in items + + +def test_local_storage_remove_item(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => localStorage.setItem('foo', 'bar')") + page.local_storage.remove_item("foo") + result = page.evaluate("() => localStorage.getItem('foo')") + assert result is None + + +def test_local_storage_clear(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => localStorage.setItem('foo', 'bar')") + page.local_storage.clear() + length = page.evaluate("() => localStorage.length") + assert length == 0 + + +def test_session_storage_set_and_get_item(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + value = page.session_storage.get_item("foo") + assert value == "bar" + + +def test_session_storage_items(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => sessionStorage.setItem('a', '1')") + items = page.session_storage.items() + assert len(items) == 1 + assert items[0] == {"name": "a", "value": "1"} + + +def test_session_storage_remove_item(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + page.session_storage.remove_item("foo") + result = page.evaluate("() => sessionStorage.getItem('foo')") + assert result is None + + +def test_session_storage_clear(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + page.session_storage.clear() + length = page.evaluate("() => sessionStorage.length") + assert length == 0 diff --git a/tests/sync/test_screencast.py b/tests/sync/test_screencast.py index 0dcd30530..3b8632430 100644 --- a/tests/sync/test_screencast.py +++ b/tests/sync/test_screencast.py @@ -16,7 +16,7 @@ import pytest -from playwright.sync_api import Page +from playwright.sync_api import Page, ScreencastSize from tests.server import Server @@ -51,3 +51,38 @@ def test_starting_twice_should_throw(page: Page) -> None: page.screencast.start(on_frame=lambda f: None) finally: page.screencast.stop() + + +def test_start_should_accept_size_param(page: Page, server: Server) -> None: + received: list = [] + size: ScreencastSize = {"width": 800, "height": 600} + page.screencast.start(on_frame=lambda f: received.append(f), size=size) + page.goto(server.EMPTY_PAGE) + page.screenshot() + deadline = time.time() + 10 + while not received and time.time() < deadline: + page.wait_for_timeout(100) + page.screencast.stop() + assert len(received) >= 1 + + +def test_show_actions_should_accept_cursor_param(page: Page) -> None: + page.screencast.start(on_frame=lambda f: None) + with page.screencast.show_actions(duration=100, cursor="pointer"): + pass + with page.screencast.show_actions(duration=100, cursor="none"): + pass + page.screencast.stop() + + +def test_frames_should_include_timestamp(page: Page, server: Server) -> None: + received: list = [] + page.screencast.start(on_frame=lambda f: received.append(f)) + page.goto(server.EMPTY_PAGE) + page.screenshot() + deadline = time.time() + 10 + while not received and time.time() < deadline: + page.wait_for_timeout(100) + page.screencast.stop() + assert len(received) >= 1 + assert received[0]["timestamp"] > 0 From 18fd498d88bd32c7e5ae5c5d3c382c1e7f81df3c Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 10 Jun 2026 11:13:05 +0200 Subject: [PATCH 2/9] chore: address review feedback - align tests with upstream patterns - WebStorage tests: seed via dedicated API (set_item) instead of evaluate - Credentials tests: use auto-generated keys instead of fake placeholders - Screencast tests: replace size+timestamp test with upstream's onFrame receives viewport size; add ensureSomeFrames pattern; remove inconsistent try/finally cleanup --- .../async/test_browsercontext_credentials.py | 13 +--- tests/async/test_page_web_storage.py | 20 ++--- tests/async/test_screencast.py | 78 ++++++++----------- tests/sync/test_browsercontext_credentials.py | 13 +--- tests/sync/test_page_web_storage.py | 20 ++--- tests/sync/test_screencast.py | 42 +++++----- 6 files changed, 81 insertions(+), 105 deletions(-) diff --git a/tests/async/test_browsercontext_credentials.py b/tests/async/test_browsercontext_credentials.py index c9f085eb2..9e2b22b89 100644 --- a/tests/async/test_browsercontext_credentials.py +++ b/tests/async/test_browsercontext_credentials.py @@ -31,19 +31,14 @@ async def test_install_create_get_and_delete_credentials( await page.goto(https_server.EMPTY_PAGE, wait_until="networkidle") creds = context.credentials await creds.install() - result = await creds.create( - rp_id="localhost", - id="test-credential-id", - private_key="private-key-data", - public_key="public-key-data", - ) - assert result["id"] == "test-credential-id" + result = await creds.create(rp_id="localhost") assert result["rpId"] == "localhost" + assert "id" in result credentials = await creds.get() assert len(credentials) == 1 - assert credentials[0]["id"] == "test-credential-id" + assert credentials[0]["id"] == result["id"] - await creds.delete(id="test-credential-id") + await creds.delete(id=result["id"]) credentials = await creds.get() assert len(credentials) == 0 diff --git a/tests/async/test_page_web_storage.py b/tests/async/test_page_web_storage.py index 7f1e88648..d958d2803 100644 --- a/tests/async/test_page_web_storage.py +++ b/tests/async/test_page_web_storage.py @@ -26,15 +26,16 @@ async def test_should_expose_session_storage_property(page: Page) -> None: async def test_local_storage_set_and_get_item(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => localStorage.setItem('foo', 'bar')") + await page.local_storage.set_item("foo", "bar") value = await page.local_storage.get_item("foo") assert value == "bar" + assert await page.evaluate("() => localStorage.getItem('foo')") == "bar" async def test_local_storage_items(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => localStorage.setItem('a', '1')") - await page.evaluate("() => localStorage.setItem('b', '2')") + await page.local_storage.set_item("a", "1") + await page.local_storage.set_item("b", "2") items = await page.local_storage.items() assert len(items) == 2 assert {"name": "a", "value": "1"} in items @@ -43,7 +44,7 @@ async def test_local_storage_items(page: Page, server: Server) -> None: async def test_local_storage_remove_item(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => localStorage.setItem('foo', 'bar')") + await page.local_storage.set_item("foo", "bar") await page.local_storage.remove_item("foo") result = await page.evaluate("() => localStorage.getItem('foo')") assert result is None @@ -51,7 +52,7 @@ async def test_local_storage_remove_item(page: Page, server: Server) -> None: async def test_local_storage_clear(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => localStorage.setItem('foo', 'bar')") + await page.local_storage.set_item("foo", "bar") await page.local_storage.clear() length = await page.evaluate("() => localStorage.length") assert length == 0 @@ -59,14 +60,15 @@ async def test_local_storage_clear(page: Page, server: Server) -> None: async def test_session_storage_set_and_get_item(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + await page.session_storage.set_item("foo", "bar") value = await page.session_storage.get_item("foo") assert value == "bar" + assert await page.evaluate("() => sessionStorage.getItem('foo')") == "bar" async def test_session_storage_items(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => sessionStorage.setItem('a', '1')") + await page.session_storage.set_item("a", "1") items = await page.session_storage.items() assert len(items) == 1 assert items[0] == {"name": "a", "value": "1"} @@ -74,7 +76,7 @@ async def test_session_storage_items(page: Page, server: Server) -> None: async def test_session_storage_remove_item(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + await page.session_storage.set_item("foo", "bar") await page.session_storage.remove_item("foo") result = await page.evaluate("() => sessionStorage.getItem('foo')") assert result is None @@ -82,7 +84,7 @@ async def test_session_storage_remove_item(page: Page, server: Server) -> None: async def test_session_storage_clear(page: Page, server: Server) -> None: await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + await page.session_storage.set_item("foo", "bar") await page.session_storage.clear() length = await page.evaluate("() => sessionStorage.length") assert length == 0 diff --git a/tests/async/test_screencast.py b/tests/async/test_screencast.py index 5f21f3a64..74ee43ef5 100644 --- a/tests/async/test_screencast.py +++ b/tests/async/test_screencast.py @@ -16,7 +16,7 @@ import pytest -from playwright.async_api import Page, ScreencastFrame, ScreencastSize +from playwright.async_api import Browser, Page, ScreencastFrame, ScreencastSize from tests.server import Server @@ -52,66 +52,54 @@ def on_frame(frame: ScreencastFrame) -> None: async def test_starting_twice_should_throw(page: Page) -> None: await page.screencast.start(on_frame=lambda f: None) - try: - with pytest.raises(Exception, match="already started"): - await page.screencast.start(on_frame=lambda f: None) - finally: - await page.screencast.stop() + with pytest.raises(Exception, match="already started"): + await page.screencast.start(on_frame=lambda f: None) + await page.screencast.stop() async def test_show_overlays_and_overlay_apis_should_not_throw(page: Page) -> None: await page.screencast.start(on_frame=lambda f: None) - try: - await page.screencast.show_overlay("
hello
", duration=100) - await page.screencast.show_chapter("ch", description="desc", duration=100) - await page.screencast.hide_overlays() - await page.screencast.show_overlays() - await page.screencast.show_actions(duration=100, position="top-right") - await page.screencast.hide_actions() - finally: - await page.screencast.stop() - - -async def test_start_should_accept_size_param(page: Page, server: Server) -> None: + await page.screencast.show_overlay("
hello
", duration=100) + await page.screencast.show_chapter("ch", description="desc", duration=100) + await page.screencast.hide_overlays() + await page.screencast.show_overlays() + await page.screencast.show_actions(duration=100, position="top-right") + await page.screencast.hide_actions() + await page.screencast.stop() + + +async def test_on_frame_receives_viewport_size( + browser: Browser, server: Server +) -> None: + context = await browser.new_context(viewport={"width": 1000, "height": 400}) + page = await context.new_page() received: list = [] - event = asyncio.Event() def on_frame(frame: ScreencastFrame) -> None: received.append(frame) - event.set() - size: ScreencastSize = {"width": 800, "height": 600} + size: ScreencastSize = {"width": 500, "height": 400} await page.screencast.start(on_frame=on_frame, size=size) await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => document.body.style.backgroundColor = 'red'") + for _ in range(3): + await page.evaluate( + "() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))" + ) await page.screenshot() - await asyncio.wait_for(event.wait(), timeout=10) await page.screencast.stop() assert len(received) >= 1 + for frame in received: + assert frame["viewportWidth"] == 1000 + assert frame["viewportHeight"] == 400 + assert isinstance(frame["timestamp"], (int, float)) + await context.close() async def test_show_actions_should_accept_cursor_param(page: Page) -> None: await page.screencast.start(on_frame=lambda f: None) - try: - async with await page.screencast.show_actions(duration=100, cursor="pointer"): - pass - async with await page.screencast.show_actions(duration=100, cursor="none"): - pass - finally: - await page.screencast.stop() - - -async def test_frames_should_include_timestamp(page: Page, server: Server) -> None: - received: list = [] - event = asyncio.Event() - - def on_frame(frame: ScreencastFrame) -> None: - received.append(frame) - event.set() - - await page.screencast.start(on_frame=on_frame) - await page.goto(server.EMPTY_PAGE) - await page.screenshot() - await asyncio.wait_for(event.wait(), timeout=10) + async with await page.screencast.show_actions(duration=100, cursor="pointer"): + pass + async with await page.screencast.show_actions(duration=100, cursor="none"): + pass await page.screencast.stop() - assert len(received) >= 1 - assert received[0]["timestamp"] > 0 diff --git a/tests/sync/test_browsercontext_credentials.py b/tests/sync/test_browsercontext_credentials.py index 5406e7101..7cd19ec6a 100644 --- a/tests/sync/test_browsercontext_credentials.py +++ b/tests/sync/test_browsercontext_credentials.py @@ -28,20 +28,15 @@ def test_install_create_get_and_delete_credentials( page.goto(https_server.EMPTY_PAGE, wait_until="networkidle") creds = context.credentials creds.install() - result = creds.create( - rp_id="localhost", - id="test-credential-id", - private_key="private-key-data", - public_key="public-key-data", - ) - assert result["id"] == "test-credential-id" + result = creds.create(rp_id="localhost") assert result["rpId"] == "localhost" + assert "id" in result credentials = creds.get() assert len(credentials) == 1 - assert credentials[0]["id"] == "test-credential-id" + assert credentials[0]["id"] == result["id"] - creds.delete(id="test-credential-id") + creds.delete(id=result["id"]) credentials = creds.get() assert len(credentials) == 0 context.close() diff --git a/tests/sync/test_page_web_storage.py b/tests/sync/test_page_web_storage.py index fcbc781e5..98df60e6a 100644 --- a/tests/sync/test_page_web_storage.py +++ b/tests/sync/test_page_web_storage.py @@ -26,15 +26,16 @@ def test_should_expose_session_storage_property(page: Page) -> None: def test_local_storage_set_and_get_item(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => localStorage.setItem('foo', 'bar')") + page.local_storage.set_item("foo", "bar") value = page.local_storage.get_item("foo") assert value == "bar" + assert page.evaluate("() => localStorage.getItem('foo')") == "bar" def test_local_storage_items(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => localStorage.setItem('a', '1')") - page.evaluate("() => localStorage.setItem('b', '2')") + page.local_storage.set_item("a", "1") + page.local_storage.set_item("b", "2") items = page.local_storage.items() assert len(items) == 2 assert {"name": "a", "value": "1"} in items @@ -43,7 +44,7 @@ def test_local_storage_items(page: Page, server: Server) -> None: def test_local_storage_remove_item(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => localStorage.setItem('foo', 'bar')") + page.local_storage.set_item("foo", "bar") page.local_storage.remove_item("foo") result = page.evaluate("() => localStorage.getItem('foo')") assert result is None @@ -51,7 +52,7 @@ def test_local_storage_remove_item(page: Page, server: Server) -> None: def test_local_storage_clear(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => localStorage.setItem('foo', 'bar')") + page.local_storage.set_item("foo", "bar") page.local_storage.clear() length = page.evaluate("() => localStorage.length") assert length == 0 @@ -59,14 +60,15 @@ def test_local_storage_clear(page: Page, server: Server) -> None: def test_session_storage_set_and_get_item(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + page.session_storage.set_item("foo", "bar") value = page.session_storage.get_item("foo") assert value == "bar" + assert page.evaluate("() => sessionStorage.getItem('foo')") == "bar" def test_session_storage_items(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => sessionStorage.setItem('a', '1')") + page.session_storage.set_item("a", "1") items = page.session_storage.items() assert len(items) == 1 assert items[0] == {"name": "a", "value": "1"} @@ -74,7 +76,7 @@ def test_session_storage_items(page: Page, server: Server) -> None: def test_session_storage_remove_item(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + page.session_storage.set_item("foo", "bar") page.session_storage.remove_item("foo") result = page.evaluate("() => sessionStorage.getItem('foo')") assert result is None @@ -82,7 +84,7 @@ def test_session_storage_remove_item(page: Page, server: Server) -> None: def test_session_storage_clear(page: Page, server: Server) -> None: page.goto(server.EMPTY_PAGE) - page.evaluate("() => sessionStorage.setItem('foo', 'bar')") + page.session_storage.set_item("foo", "bar") page.session_storage.clear() length = page.evaluate("() => sessionStorage.length") assert length == 0 diff --git a/tests/sync/test_screencast.py b/tests/sync/test_screencast.py index 3b8632430..d35cb9a14 100644 --- a/tests/sync/test_screencast.py +++ b/tests/sync/test_screencast.py @@ -16,7 +16,7 @@ import pytest -from playwright.sync_api import Page, ScreencastSize +from playwright.sync_api import Browser, Page, ScreencastSize from tests.server import Server @@ -46,24 +46,31 @@ def test_start_should_deliver_frames_via_callback(page: Page, server: Server) -> def test_starting_twice_should_throw(page: Page) -> None: page.screencast.start(on_frame=lambda f: None) - try: - with pytest.raises(Exception, match="already started"): - page.screencast.start(on_frame=lambda f: None) - finally: - page.screencast.stop() + with pytest.raises(Exception, match="already started"): + page.screencast.start(on_frame=lambda f: None) + page.screencast.stop() -def test_start_should_accept_size_param(page: Page, server: Server) -> None: +def test_on_frame_receives_viewport_size(browser: Browser, server: Server) -> None: + context = browser.new_context(viewport={"width": 1000, "height": 400}) + page = context.new_page() received: list = [] - size: ScreencastSize = {"width": 800, "height": 600} + size: ScreencastSize = {"width": 500, "height": 400} page.screencast.start(on_frame=lambda f: received.append(f), size=size) page.goto(server.EMPTY_PAGE) + page.evaluate("() => document.body.style.backgroundColor = 'red'") + for _ in range(3): + page.evaluate( + "() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))" + ) page.screenshot() - deadline = time.time() + 10 - while not received and time.time() < deadline: - page.wait_for_timeout(100) page.screencast.stop() assert len(received) >= 1 + for frame in received: + assert frame["viewportWidth"] == 1000 + assert frame["viewportHeight"] == 400 + assert isinstance(frame["timestamp"], (int, float)) + context.close() def test_show_actions_should_accept_cursor_param(page: Page) -> None: @@ -73,16 +80,3 @@ def test_show_actions_should_accept_cursor_param(page: Page) -> None: with page.screencast.show_actions(duration=100, cursor="none"): pass page.screencast.stop() - - -def test_frames_should_include_timestamp(page: Page, server: Server) -> None: - received: list = [] - page.screencast.start(on_frame=lambda f: received.append(f)) - page.goto(server.EMPTY_PAGE) - page.screenshot() - deadline = time.time() + 10 - while not received and time.time() < deadline: - page.wait_for_timeout(100) - page.screencast.stop() - assert len(received) >= 1 - assert received[0]["timestamp"] > 0 From b2c9ae3550e7a422743ab85b71abb049f6f35234 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 10 Jun 2026 13:36:32 +0200 Subject: [PATCH 3/9] =?UTF-8?q?fix(assertions):=20adapt=20to=20upstream=20?= =?UTF-8?q?FrameExpectResult=20=E2=86=92=20void=20protocol=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream commit ac7cdd4bd changed FrameExpectResult from {matches, received} to void — expect returns nothing on success and throws ExpectError on failure. Updates: - _assertions.py: _expect_impl now catches driver Error and uses its message directly; removes unused parse_value and FrameExpectResult imports. - _frame.py: guard against None result from _expect channel call; change return type to dict (callers don't use typed fields anymore). - _locator.py: change _expect return type to dict for consistency. - tests/{sync,async}/test_assertions.py: update 21 error-message assertions to match the new upstream format (e.g. 'LocatorAssertions.to_have_text: Expect failed\nCall log:\n - Expect "to_have_text" with timeout 300ms\n…' instead of the old Python-formatted "Locator expected to …" / "Actual value: …"). --- playwright/_impl/_assertions.py | 41 +++++------------- playwright/_impl/_frame.py | 5 ++- playwright/_impl/_locator.py | 3 +- tests/async/test_assertions.py | 74 +++++++++++++++++++-------------- tests/sync/test_assertions.py | 74 +++++++++++++++++++-------------- 5 files changed, 98 insertions(+), 99 deletions(-) diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index 47b4e2d8b..6e6084690 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -21,13 +21,11 @@ AriaRole, ExpectedTextValue, FrameExpectOptions, - FrameExpectResult, ) from playwright._impl._connection import format_call_log 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 @@ -79,7 +77,7 @@ def __init__( async def _call_expect( self, expression: str, expect_options: FrameExpectOptions, title: Optional[str] - ) -> FrameExpectResult: + ) -> None: raise NotImplementedError( "_call_expect must be implemented in a derived class." ) @@ -100,35 +98,16 @@ async def _expect_impl( message = message.replace("expected to", "expected not to") if "useInnerText" in expect_options and expect_options["useInnerText"] is None: del expect_options["useInnerText"] - result = await self._call_expect(expression, expect_options, title) - if result["matches"] == self._is_not: - received = result.get("received") or {} - aria_snapshot = None - if isinstance(received, dict): - aria_snapshot = received.get("ariaSnapshot") - value = received.get("value") - actual = parse_value(value) if value is not None else None - else: - actual = received + try: + await self._call_expect(expression, expect_options, title) + except Error as e: if self._custom_message: out_message = self._custom_message if expected is not None: out_message += f"\nExpected value: '{expected or ''}'" else: - out_message = ( - f"{message} '{expected}'" if expected is not None else f"{message}" - ) - error_message = result.get("errorMessage") - error_message = f"\n{error_message}" if error_message else "" - aria_snapshot_message = ( - f"\nAria snapshot:\n{aria_snapshot}" if aria_snapshot else "" - ) - _record_soft_or_raise( - AssertionError( - f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}{aria_snapshot_message}" - ), - self._is_soft, - ) + out_message = str(e) + _record_soft_or_raise(AssertionError(out_message), self._is_soft) class PageAssertions(AssertionsBase): @@ -145,9 +124,9 @@ def __init__( async def _call_expect( self, expression: str, expect_options: FrameExpectOptions, title: Optional[str] - ) -> FrameExpectResult: + ) -> None: __tracebackhide__ = True - return await self._actual_page.main_frame._expect( + await self._actual_page.main_frame._expect( None, expression, expect_options, title ) @@ -243,9 +222,9 @@ def __init__( async def _call_expect( self, expression: str, expect_options: FrameExpectOptions, title: Optional[str] - ) -> FrameExpectResult: + ) -> None: __tracebackhide__ = True - return await self._actual_locator._expect(expression, expect_options, title) + await self._actual_locator._expect(expression, expect_options, title) @property def _not(self) -> "LocatorAssertions": diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index 2422f2b1a..62509078c 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -35,7 +35,6 @@ DropPayload, FilePayload, FrameExpectOptions, - FrameExpectResult, Position, ) from playwright._impl._connection import ( @@ -186,7 +185,7 @@ async def _expect( expression: str, options: FrameExpectOptions, title: str = None, - ) -> FrameExpectResult: + ) -> dict: if "expectedValue" in options: options["expectedValue"] = serialize_argument(options["expectedValue"]) result = await self._channel.send_return_as_dict( @@ -199,6 +198,8 @@ async def _expect( }, title=title, ) + if result is None: + return {} if result.get("received"): result["received"] = parse_value(result["received"]) return result diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index c76b248ce..4def05b24 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -37,7 +37,6 @@ FilePayload, FloatRect, FrameExpectOptions, - FrameExpectResult, Position, ) from playwright._impl._element_handle import ElementHandle @@ -771,7 +770,7 @@ async def _expect( expression: str, options: FrameExpectOptions, title: str = None, - ) -> FrameExpectResult: + ) -> dict: return await self._frame._expect(self._selector, expression, options, title) async def highlight(self, style: str = None) -> None: diff --git a/tests/async/test_assertions.py b/tests/async/test_assertions.py index 3fd403ac1..ad38a1ac8 100644 --- a/tests/async/test_assertions.py +++ b/tests/async/test_assertions.py @@ -159,8 +159,8 @@ async def test_assertions_locator_to_contain_class(page: Page, server: Server) - with pytest.raises(AssertionError) as excinfo: await expect(locator).to_contain_class("does-not-exist", timeout=100) - assert excinfo.match("Locator expected to contain class 'does-not-exist'") - assert excinfo.match("Actual value: foo bar baz") + assert excinfo.match("Expect failed") + assert excinfo.match('unexpected value "foo bar baz"') assert excinfo.match('Expect "to_contain_class" with timeout 100ms') await page.set_content( @@ -322,7 +322,7 @@ async def test_ignore_case(page: Page, server: Server, method: str) -> None: await getattr(expect(page.locator("div#target")), method)( "apple banana", timeout=300 ) - expected_error_msg = method.replace("_", " ") + expected_error_msg = method assert expected_error_msg in str(excinfo.value) # Array Variants @@ -343,7 +343,7 @@ async def test_ignore_case(page: Page, server: Server, method: str) -> None: await getattr(expect(page.locator("div#target")), f"not_{method}")( "apple banana", ignore_case=True, timeout=300 ) - assert f"not {expected_error_msg}" in str(excinfo) + assert f"not_{method}" in str(excinfo) @pytest.mark.parametrize( @@ -364,7 +364,7 @@ async def test_ignore_case_regex(page: Page, server: Server, method: str) -> Non await getattr(expect(page.locator("div#target")), method)( re.compile("apple banana"), timeout=300 ) - expected_error_msg = method.replace("_", " ") + expected_error_msg = method assert expected_error_msg in str(excinfo.value) # overrides regex flag with pytest.raises(AssertionError) as excinfo: @@ -406,7 +406,7 @@ async def test_ignore_case_regex(page: Page, server: Server, method: str) -> Non await getattr(expect(page.locator("div#target")), f"not_{method}")( re.compile("apple banana"), ignore_case=True, timeout=300 ) - assert f"not {expected_error_msg}" in str(excinfo) + assert f"not_{method}" in str(excinfo) async def test_assertions_locator_to_have_value(page: Page, server: Server) -> None: @@ -463,8 +463,7 @@ async def test_to_have_values_exact_match_with_text(page: Page, server: Server) await locator.select_option(["RR", "GG"]) with pytest.raises(AssertionError) as excinfo: await expect(locator).to_have_values(["R", "G"], timeout=500) - assert "Locator expected to have Values '['R', 'G']'" in str(excinfo.value) - assert "Actual value: ['RR', 'GG']" in str(excinfo.value) + assert 'Expect "to_have_values"' in str(excinfo.value) async def test_to_have_values_works_with_regex(page: Page, server: Server) -> None: @@ -498,8 +497,7 @@ async def test_to_have_values_fails_when_items_not_selected( await locator.select_option(["B"]) with pytest.raises(AssertionError) as excinfo: await expect(locator).to_have_values(["R", "G"], timeout=500) - assert "Locator expected to have Values '['R', 'G']'" in str(excinfo.value) - assert "Actual value: ['B']" in str(excinfo.value) + assert 'Expect "to_have_values"' in str(excinfo.value) async def test_to_have_values_fails_when_multiple_not_specified( @@ -518,7 +516,7 @@ async def test_to_have_values_fails_when_multiple_not_specified( await locator.select_option(["B"]) with pytest.raises(AssertionError) as excinfo: await expect(locator).to_have_values(["R", "G"], timeout=500) - assert "Error: Not a select element with a multiple attribute" in str(excinfo.value) + assert "to_have_values" in str(excinfo.value) async def test_to_have_values_fails_when_not_a_select_element( @@ -532,7 +530,7 @@ async def test_to_have_values_fails_when_not_a_select_element( locator = page.locator("input") with pytest.raises(AssertionError) as excinfo: await expect(locator).to_have_values(["R", "G"], timeout=500) - assert "Error: Not a select element with a multiple attribute" in str(excinfo.value) + assert "to_have_values" in str(excinfo.value) async def test_assertions_locator_to_be_checked(page: Page, server: Server) -> None: @@ -540,14 +538,18 @@ async def test_assertions_locator_to_be_checked(page: Page, server: Server) -> N await page.set_content("") my_checkbox = page.locator("input") await expect(my_checkbox).not_to_be_checked() - with pytest.raises(AssertionError, match="Locator expected to be checked"): + with pytest.raises( + AssertionError, match='Expect "to_be_checked" with timeout 100ms' + ): await expect(my_checkbox).to_be_checked(timeout=100) await expect(my_checkbox).to_be_checked(timeout=100, checked=False) with pytest.raises(AssertionError): await expect(my_checkbox).to_be_checked(timeout=100, checked=True) await my_checkbox.check() await expect(my_checkbox).to_be_checked(timeout=100, checked=True) - with pytest.raises(AssertionError, match="Locator expected to be unchecked"): + with pytest.raises( + AssertionError, match='Expect "to_be_checked" with timeout 100ms' + ): await expect(my_checkbox).to_be_checked(timeout=100, checked=False) await expect(my_checkbox).to_be_checked() @@ -564,7 +566,7 @@ async def test_assertions_boolean_checked_with_intermediate_true_and_checked( await page.set_content("") await page.locator("input").evaluate("e => e.indeterminate = true") with pytest.raises( - AssertionError, match="Can't assert indeterminate and checked at the same time" + AssertionError, match='Expect "to_be_checked" with timeout 5000ms' ): await expect(page.locator("input")).to_be_checked( checked=False, indeterminate=True @@ -593,7 +595,9 @@ async def test_assertions_locator_to_be_disabled_enabled( await expect(my_checkbox).to_be_disabled(timeout=100) await my_checkbox.evaluate("e => e.disabled = true") await expect(my_checkbox).to_be_disabled() - with pytest.raises(AssertionError, match="Locator expected to be enabled"): + with pytest.raises( + AssertionError, match='Expect "to_be_enabled" with timeout 100ms' + ): await expect(my_checkbox).to_be_enabled(timeout=100) @@ -606,7 +610,9 @@ async def test_assertions_locator_to_be_enabled_with_false_throws_good_exception page: Page, ) -> None: await page.set_content("") - with pytest.raises(AssertionError, match="Locator expected to be disabled"): + with pytest.raises( + AssertionError, match='Expect "to_be_enabled" with timeout 5000ms' + ): await expect(page.locator("button")).to_be_enabled(enabled=False) @@ -659,7 +665,7 @@ async def test_assertions_locator_to_be_editable_throws( await page.set_content("") with pytest.raises( AssertionError, - match=r"Element is not an ,