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..6310829a0 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -234,11 +234,12 @@ class FrameExpectOptions(TypedDict, total=False): pseudo: Optional[str] -class FrameExpectResult(TypedDict): +class FrameExpectResult(TypedDict, total=False): matches: bool received: Any - log: List[str] + log: Optional[str] errorMessage: Optional[str] + timedOut: Optional[bool] AriaRole = Literal[ @@ -344,7 +345,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/_assertions.py b/playwright/_impl/_assertions.py index 47b4e2d8b..cb93a791a 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -120,12 +120,13 @@ async def _expect_impl( ) error_message = result.get("errorMessage") error_message = f"\n{error_message}" if error_message else "" + log = result.get("log") or "" 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}" + f"{out_message}\nActual value: {actual}{error_message} {log}{aria_snapshot_message}" ), self._is_soft, ) 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/_connection.py b/playwright/_impl/_connection.py index bbc42b6e1..d2d719c01 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -422,6 +422,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: parsed_error = parse_error( error["error"], format_call_log(msg.get("log")) # type: ignore ) + parsed_error._details = msg.get("errorDetails") parsed_error._stack = "".join(callback.stack_trace.format()) callback.future.set_exception(parsed_error) else: 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/_errors.py b/playwright/_impl/_errors.py index c47d918ef..63c83226f 100644 --- a/playwright/_impl/_errors.py +++ b/playwright/_impl/_errors.py @@ -16,7 +16,7 @@ # stable API. -from typing import Optional +from typing import Any, Optional def is_target_closed_error(error: Exception) -> bool: @@ -28,6 +28,8 @@ def __init__(self, message: str) -> None: self._message = message self._name: Optional[str] = None self._stack: Optional[str] = None + self._details: Optional[Any] = None + self._log: Optional[str] = None super().__init__(message) @property @@ -57,4 +59,6 @@ def rewrite_error(error: Exception, message: str) -> Exception: if isinstance(rewritten_exc, Error) and isinstance(error, Error): rewritten_exc._name = error.name rewritten_exc._stack = error.stack + rewritten_exc._details = error._details + rewritten_exc._log = error._log return rewritten_exc 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/_frame.py b/playwright/_impl/_frame.py index 2422f2b1a..3bf5992a5 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -189,19 +189,41 @@ async def _expect( ) -> FrameExpectResult: if "expectedValue" in options: options["expectedValue"] = serialize_argument(options["expectedValue"]) - result = await self._channel.send_return_as_dict( - "expect", - self._timeout, - { - "selector": selector, - "expression": expression, - **options, - }, - title=title, - ) - if result.get("received"): - result["received"] = parse_value(result["received"]) - return result + try: + await self._channel.send( + "expect", + self._timeout, + { + "selector": selector, + "expression": expression, + **options, + }, + title=title, + ) + return {"matches": not options.get("isNot")} + except Error as e: + if not e._details: + raise e + details = cast(Dict[str, Any], e._details) + received = details.get("received") + if received: + received = { + "value": ( + parse_value(received["value"]) if "value" in received else None + ), + "ariaSnapshot": received.get("ariaSnapshot"), + } + return { + "matches": bool(options.get("isNot")), + "received": received, + "timedOut": details.get("timedOut"), + "errorMessage": ( + "Error: " + details["customErrorMessage"] + if details.get("customErrorMessage") + else None + ), + "log": e._log, + } def expect_navigation( self, diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 213fdc1e3..1a78ba138 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -85,6 +85,7 @@ class ErrorPayload(TypedDict, total=False): name: str stack: str value: Optional[Any] + details: Optional[Any] class HarRecordingMetadata(TypedDict, total=False): @@ -358,6 +359,8 @@ def parse_error(error: ErrorPayload, log: Optional[str] = None) -> Error: exc = base_error_class(patch_error_message(error["message"]) + log) exc._name = error["name"] exc._stack = error["stack"] + exc._details = error.get("details") + exc._log = log return exc diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 9bf59c313..313c1c841 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 @@ -849,14 +852,19 @@ async def aria_snapshot( async def close(self, runBeforeUnload: bool = None, reason: str = None) -> None: self._close_reason = reason - self._close_was_called = True + if not runBeforeUnload: + self._close_was_called = True try: - await self._channel.send("close", None, locals_to_params(locals())) if self._owned_context: await self._owned_context.close() + elif runBeforeUnload: + await self._channel.send("runBeforeUnload", None) + else: + await self._channel.send("close", None, {"reason": reason}) except Exception as e: - if not is_target_closed_error(e) and not runBeforeUnload: - raise e + if is_target_closed_error(e) and not runBeforeUnload: + return + raise e def is_closed(self) -> bool: return self._is_closed @@ -1206,6 +1214,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..9e2b22b89 --- /dev/null +++ b/tests/async/test_browsercontext_credentials.py @@ -0,0 +1,44 @@ +# 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") + assert result["rpId"] == "localhost" + assert "id" in result + + credentials = await creds.get() + assert len(credentials) == 1 + assert credentials[0]["id"] == result["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 new file mode 100644 index 000000000..d958d2803 --- /dev/null +++ b/tests/async/test_page_web_storage.py @@ -0,0 +1,90 @@ +# 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.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.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 + 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.local_storage.set_item("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.local_storage.set_item("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.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.session_storage.set_item("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.session_storage.set_item("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.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 5532f7847..331ea6df2 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 Browser, Page, ScreencastFrame, ScreencastSize from tests.server import Server @@ -35,17 +35,19 @@ def on_frame(frame: ScreencastFrame) -> None: event.set() await page.screencast.start(on_frame=on_frame) - await page.goto(server.EMPTY_PAGE) - await page.evaluate("() => document.body.style.backgroundColor = 'red'") - # Force a couple of paint cycles so engines that only emit on visual change - # still produce a frame. Mirrors upstream `ensureSomeFrames`. - 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() + try: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => document.body.style.backgroundColor = 'red'") + # Force a couple of paint cycles so engines that only emit on visual change + # still produce a frame. Mirrors upstream `ensureSomeFrames`. + for _ in range(3): + await page.evaluate( + "() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))" + ) + await page.screenshot() + await asyncio.wait_for(event.wait(), timeout=10) + finally: + await page.screencast.stop() assert len(received) >= 1 assert all(isinstance(d, bytes) and len(d) > 0 for d in received) @@ -70,3 +72,44 @@ 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_on_frame_receives_viewport_size( + browser: Browser, server: Server +) -> None: + context = await browser.new_context(viewport={"width": 1000, "height": 400}) + async with context: + page = await context.new_page() + received: list = [] + + def on_frame(frame: ScreencastFrame) -> None: + received.append(frame) + + size: ScreencastSize = {"width": 500, "height": 400} + await page.screencast.start(on_frame=on_frame, size=size) + try: + await page.goto(server.EMPTY_PAGE) + await page.evaluate("() => document.body.style.backgroundColor = 'red'") + for _ in range(100): + await page.evaluate( + "() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))" + ) + await page.screenshot() + finally: + await page.screencast.stop() + assert len(received) >= 1 + assert any(frame["viewportWidth"] == 1000 for frame in received) + for frame in received: + assert frame["viewportHeight"] == 400 + assert isinstance(frame["timestamp"], (int, float)) + + +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() diff --git a/tests/async/test_tracing.py b/tests/async/test_tracing.py index 076329c5e..afe4dd2b8 100644 --- a/tests/async/test_tracing.py +++ b/tests/async/test_tracing.py @@ -269,10 +269,8 @@ async def test_should_work_with_playwright_context_managers( [ re.compile(r'Navigate to "/empty\.html"'), re.compile(r"Set content"), - re.compile(r'Wait for event "page\.expect_event\(console\)"'), re.compile(r"Evaluate"), re.compile(r"Click"), - re.compile(r'Wait for event "page\.expect_event\(popup\)"'), re.compile(r"Evaluate"), ] ) @@ -298,8 +296,6 @@ async def test_should_display_wait_for_load_state_even_if_did_not_wait_for_it( await expect(trace_viewer.action_titles).to_have_text( [ re.compile(r'Navigate to "/empty\.html"'), - re.compile(r'Wait for event "frame\.wait_for_load_state"'), - re.compile(r'Wait for event "frame\.wait_for_load_state"'), ] ) diff --git a/tests/sync/test_browsercontext_credentials.py b/tests/sync/test_browsercontext_credentials.py new file mode 100644 index 000000000..7cd19ec6a --- /dev/null +++ b/tests/sync/test_browsercontext_credentials.py @@ -0,0 +1,42 @@ +# 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") + assert result["rpId"] == "localhost" + assert "id" in result + + credentials = creds.get() + assert len(credentials) == 1 + assert credentials[0]["id"] == result["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 new file mode 100644 index 000000000..98df60e6a --- /dev/null +++ b/tests/sync/test_page_web_storage.py @@ -0,0 +1,90 @@ +# 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.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.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 + assert {"name": "b", "value": "2"} in items + + +def test_local_storage_remove_item(page: Page, server: Server) -> None: + page.goto(server.EMPTY_PAGE) + page.local_storage.set_item("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.local_storage.set_item("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.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.session_storage.set_item("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.session_storage.set_item("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.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 0dcd30530..82fc028df 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 Browser, Page, ScreencastSize from tests.server import Server @@ -27,19 +27,21 @@ def test_should_expose_screencast_property(page: Page) -> None: def test_start_should_deliver_frames_via_callback(page: Page, server: Server) -> None: received: list = [] page.screencast.start(on_frame=lambda f: received.append(f["data"])) - page.goto(server.EMPTY_PAGE) - page.evaluate("() => document.body.style.backgroundColor = 'red'") - # Force a couple of paint cycles so engines that only emit on visual change - # still produce a frame. Mirrors upstream `ensureSomeFrames`. - 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() + try: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => document.body.style.backgroundColor = 'red'") + # Force a couple of paint cycles so engines that only emit on visual change + # still produce a frame. Mirrors upstream `ensureSomeFrames`. + 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) + finally: + page.screencast.stop() assert len(received) >= 1 assert all(isinstance(d, bytes) and len(d) > 0 for d in received) @@ -51,3 +53,38 @@ def test_starting_twice_should_throw(page: Page) -> None: page.screencast.start(on_frame=lambda f: None) finally: page.screencast.stop() + + +def test_on_frame_receives_viewport_size(browser: Browser, server: Server) -> None: + context = browser.new_context(viewport={"width": 1000, "height": 400}) + with context: + page = context.new_page() + received: list = [] + size: ScreencastSize = {"width": 500, "height": 400} + page.screencast.start(on_frame=lambda f: received.append(f), size=size) + try: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => document.body.style.backgroundColor = 'red'") + for _ in range(100): + page.evaluate( + "() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))" + ) + page.screenshot() + finally: + page.screencast.stop() + assert len(received) >= 1 + assert any(frame["viewportWidth"] == 1000 for frame in received) + for frame in received: + assert frame["viewportHeight"] == 400 + assert isinstance(frame["timestamp"], (int, float)) + + +def test_show_actions_should_accept_cursor_param(page: Page) -> None: + page.screencast.start(on_frame=lambda f: None) + try: + with page.screencast.show_actions(duration=100, cursor="pointer"): + pass + with page.screencast.show_actions(duration=100, cursor="none"): + pass + finally: + page.screencast.stop() diff --git a/tests/sync/test_tracing.py b/tests/sync/test_tracing.py index badbe7e81..2f0e9b043 100644 --- a/tests/sync/test_tracing.py +++ b/tests/sync/test_tracing.py @@ -272,10 +272,8 @@ def test_should_work_with_playwright_context_managers( [ re.compile(r'Navigate to "/empty\.html"'), re.compile(r"Set content"), - re.compile(r'Wait for event "page\.expect_event\(console\)"'), re.compile(r"Evaluate"), re.compile(r"Click"), - re.compile(r'Wait for event "page\.expect_event\(popup\)"'), re.compile(r"Evaluate"), ] ) @@ -301,8 +299,6 @@ def test_should_display_wait_for_load_state_even_if_did_not_wait_for_it( expect(trace_viewer.action_titles).to_have_text( [ re.compile(r'Navigate to "/empty\.html"'), - re.compile(r'Wait for event "frame\.wait_for_load_state"'), - re.compile(r'Wait for event "frame\.wait_for_load_state"'), ] )