From 3d1b875a9cc7d975fefb3058022ed31068421950 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 24 Nov 2025 16:39:37 +0100 Subject: [PATCH 01/13] chore: roll to 1.57.0-beta-1763718928000 (#3007) --- README.md | 4 +- playwright/_impl/_accessibility.py | 69 ----- playwright/_impl/_browser_context.py | 5 +- playwright/_impl/_console_message.py | 6 + playwright/_impl/_element_handle.py | 2 + playwright/_impl/_frame.py | 18 ++ playwright/_impl/_glob.py | 11 +- playwright/_impl/_helper.py | 68 +++-- playwright/_impl/_locator.py | 20 +- playwright/_impl/_page.py | 34 ++- playwright/async_api/__init__.py | 2 - playwright/async_api/_generated.py | 231 ++++++++++------ playwright/sync_api/__init__.py | 2 - playwright/sync_api/_generated.py | 217 +++++++++------ scripts/expected_api_mismatch.txt | 3 - scripts/generate_api.py | 3 - setup.py | 4 +- tests/async/test_accessibility.py | 388 -------------------------- tests/async/test_click.py | 32 +++ tests/async/test_locators.py | 39 +++ tests/async/test_page.py | 100 +++++++ tests/async/test_page_route.py | 14 + tests/async/test_worker.py | 39 +++ tests/sync/test_accessibility.py | 393 --------------------------- 24 files changed, 639 insertions(+), 1065 deletions(-) delete mode 100644 playwright/_impl/_accessibility.py delete mode 100644 tests/async/test_accessibility.py delete mode 100644 tests/sync/test_accessibility.py diff --git a/README.md b/README.md index b54d5a364..c1797dfe0 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 141.0.7390.37 | ✅ | ✅ | ✅ | +| Chromium 143.0.7499.4 | ✅ | ✅ | ✅ | | WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 142.0.1 | ✅ | ✅ | ✅ | +| Firefox 144.0.2 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_accessibility.py b/playwright/_impl/_accessibility.py deleted file mode 100644 index fe6909c21..000000000 --- a/playwright/_impl/_accessibility.py +++ /dev/null @@ -1,69 +0,0 @@ -# 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 Dict, Optional - -from playwright._impl._connection import Channel -from playwright._impl._element_handle import ElementHandle -from playwright._impl._helper import locals_to_params - - -def _ax_node_from_protocol(axNode: Dict) -> Dict: - result = {**axNode} - if "valueNumber" in axNode: - result["value"] = axNode["valueNumber"] - elif "valueString" in axNode: - result["value"] = axNode["valueString"] - - if "checked" in axNode: - result["checked"] = ( - True - if axNode.get("checked") == "checked" - else ( - False if axNode.get("checked") == "unchecked" else axNode.get("checked") - ) - ) - - if "pressed" in axNode: - result["pressed"] = ( - True - if axNode.get("pressed") == "pressed" - else ( - False if axNode.get("pressed") == "released" else axNode.get("pressed") - ) - ) - - if axNode.get("children"): - result["children"] = list(map(_ax_node_from_protocol, axNode["children"])) - if "valueNumber" in result: - del result["valueNumber"] - if "valueString" in result: - del result["valueString"] - return result - - -class Accessibility: - def __init__(self, channel: Channel) -> None: - self._channel = channel - self._loop = channel._connection._loop - self._dispatcher_fiber = channel._connection._dispatcher_fiber - - async def snapshot( - self, interestingOnly: bool = None, root: ElementHandle = None - ) -> Optional[Dict]: - params = locals_to_params(locals()) - if root: - params["root"] = root._channel - result = await self._channel.send("accessibilitySnapshot", None, params) - return _ax_node_from_protocol(result) if result else None diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index bab7d1bf1..f56564a27 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -688,10 +688,13 @@ def _on_request_finished( def _on_console_message(self, event: Dict) -> None: message = ConsoleMessage(event, self._loop, self._dispatcher_fiber) - self.emit(BrowserContext.Events.Console, message) + worker = message.worker + if worker: + worker.emit(Worker.Events.Console, message) page = message.page if page: page.emit(Page.Events.Console, message) + self.emit(BrowserContext.Events.Console, message) def _on_dialog(self, dialog: Dialog) -> None: has_listeners = self.emit(BrowserContext.Events.Dialog, dialog) diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index 53c0dee95..7866df2ae 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: # pragma: no cover from playwright._impl._page import Page + from playwright._impl._worker import Worker class ConsoleMessage: @@ -31,6 +32,7 @@ def __init__( self._loop = loop self._dispatcher_fiber = dispatcher_fiber self._page: Optional["Page"] = from_nullable_channel(event.get("page")) + self._worker: Optional["Worker"] = from_nullable_channel(event.get("worker")) def __repr__(self) -> str: return f"" @@ -76,3 +78,7 @@ def location(self) -> SourceLocation: @property def page(self) -> Optional["Page"]: return self._page + + @property + def worker(self) -> Optional["Worker"]: + return self._worker diff --git a/playwright/_impl/_element_handle.py b/playwright/_impl/_element_handle.py index 1561e19fc..3854669d0 100644 --- a/playwright/_impl/_element_handle.py +++ b/playwright/_impl/_element_handle.py @@ -138,6 +138,7 @@ async def click( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: await self._channel.send( "click", self._frame._timeout, locals_to_params(locals()) @@ -153,6 +154,7 @@ async def dblclick( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: await self._channel.send( "dblclick", self._frame._timeout, locals_to_params(locals()) diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index fe19a576d..b976667e7 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -545,6 +545,23 @@ async def click( noWaitAfter: bool = None, strict: bool = None, trial: bool = None, + ) -> None: + await self._click(**locals_to_params(locals())) + + async def _click( + self, + selector: str, + modifiers: Sequence[KeyboardModifier] = None, + position: Position = None, + delay: float = None, + button: MouseButton = None, + clickCount: int = None, + timeout: float = None, + force: bool = None, + noWaitAfter: bool = None, + strict: bool = None, + trial: bool = None, + steps: int = None, ) -> None: await self._channel.send("click", self._timeout, locals_to_params(locals())) @@ -734,6 +751,7 @@ async def drag_and_drop( strict: bool = None, timeout: float = None, trial: bool = None, + steps: int = None, ) -> None: await self._channel.send( "dragAndDrop", self._timeout, locals_to_params(locals()) diff --git a/playwright/_impl/_glob.py b/playwright/_impl/_glob.py index a0e6dcd4b..b38826996 100644 --- a/playwright/_impl/_glob.py +++ b/playwright/_impl/_glob.py @@ -28,12 +28,21 @@ def glob_to_regex_pattern(glob: str) -> str: tokens.append("\\" + char if char in escaped_chars else char) i += 1 elif c == "*": + char_before = glob[i - 1] if i > 0 else None star_count = 1 while i + 1 < len(glob) and glob[i + 1] == "*": star_count += 1 i += 1 if star_count > 1: - tokens.append("(.*)") + char_after = glob[i + 1] if i + 1 < len(glob) else None + if char_after == "/": + if char_before == "/": + tokens.append("((.+/)|)") + else: + tokens.append("(.*/)") + i += 1 + else: + tokens.append("(.*)") else: tokens.append("([^/]*)") else: diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 8f1ca8594..1d7e4f67b 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -35,7 +35,7 @@ Union, cast, ) -from urllib.parse import urljoin, urlparse +from urllib.parse import ParseResult, urljoin, urlparse, urlunparse from playwright._impl._api_structures import NameValue from playwright._impl._errors import ( @@ -210,8 +210,12 @@ def map_token(original: str, replacement: str) -> str: # Handle special case of http*://, note that the new schema has to be # a web schema so that slashes are properly inserted after domain. if index == 0 and token.endswith(":"): - # Using a simple replacement for the scheme part - processed_parts.append(map_token(token, "http:")) + # Replace any pattern with http: + if "*" in token or "{" in token: + processed_parts.append(map_token(token, "http:")) + else: + # Preserve explicit schema as is as it may affect trailing slashes after domain. + processed_parts.append(token) continue question_index = token.find("?") if question_index == -1: @@ -222,55 +226,49 @@ def map_token(original: str, replacement: str) -> str: processed_parts.append(new_prefix + new_suffix) relative_path = "/".join(processed_parts) - resolved_url, case_insensitive_part = resolve_base_url(base_url, relative_path) + resolved, case_insensitive_part = resolve_base_url(base_url, relative_path) - for replacement, original in token_map.items(): - normalize = case_insensitive_part and replacement in case_insensitive_part - resolved_url = resolved_url.replace( - replacement, original.lower() if normalize else original, 1 + for token, original in token_map.items(): + normalize = case_insensitive_part and token in case_insensitive_part + resolved = resolved.replace( + token, original.lower() if normalize else original, 1 ) - return ensure_trailing_slash(resolved_url) + return resolved def resolve_base_url( base_url: Optional[str], given_url: str ) -> Tuple[str, Optional[str]]: try: - resolved = urljoin(base_url if base_url is not None else "", given_url) - parsed = urlparse(resolved) + url = nodelike_urlparse( + urljoin(base_url if base_url is not None else "", given_url) + ) + resolved = urlunparse(url) # Schema and domain are case-insensitive. hostname_port = ( - parsed.hostname or "" + url.hostname or "" ) # can't use parsed.netloc because it includes userinfo (username:password) - if parsed.port: - hostname_port += f":{parsed.port}" - case_insensitive_prefix = f"{parsed.scheme}://{hostname_port}" + if url.port: + hostname_port += f":{url.port}" + case_insensitive_prefix = f"{url.scheme}://{hostname_port}" return resolved, case_insensitive_prefix except Exception: return given_url, None -# In Node.js, new URL('http://localhost') returns 'http://localhost/'. -# To ensure the same url matching behavior, do the same. -def ensure_trailing_slash(url: str) -> str: - split = url.split("://", maxsplit=1) - if len(split) == 2: - # URL parser doesn't like strange/unknown schemes, so we replace it for parsing, then put it back - parsable_url = "http://" + split[1] - else: - # Given current rules, this should never happen _and_ still be a valid matcher. We require the protocol to be part of the match, - # so either the user is using a glob that starts with "*" (and none of this code is running), or the user actually has `something://` in `match` - parsable_url = url - parsed = urlparse(parsable_url, allow_fragments=True) - if len(split) == 2: - # Replace the scheme that we removed earlier - parsed = parsed._replace(scheme=split[0]) - if parsed.path == "": - parsed = parsed._replace(path="/") - url = parsed.geturl() - - return url +def nodelike_urlparse(url: str) -> ParseResult: + parsed = urlparse(url, allow_fragments=True) + + # https://url.spec.whatwg.org/#special-scheme + is_special_url = parsed.scheme in ["http", "https", "ws", "wss", "ftp", "file"] + if is_special_url: + # special urls have a list path, list paths are serialized as follows: https://url.spec.whatwg.org/#url-path-serializer + # urllib diverges, so we patch it here + if parsed.path == "": + parsed = parsed._replace(path="/") + + return parsed class HarLookupResult(TypedDict, total=False): diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index a65b68266..2e6a7abed 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -14,6 +14,7 @@ import json import pathlib +import re from typing import ( TYPE_CHECKING, Any, @@ -155,9 +156,10 @@ async def click( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: params = locals_to_params(locals()) - return await self._frame.click(self._selector, strict=True, **params) + return await self._frame._click(self._selector, strict=True, **params) async def dblclick( self, @@ -169,6 +171,7 @@ async def dblclick( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: params = locals_to_params(locals()) return await self._frame.dblclick(self._selector, strict=True, **params) @@ -343,6 +346,20 @@ def describe(self, description: str) -> "Locator": f"{self._selector} >> internal:describe={json.dumps(description)}", ) + @property + def description(self) -> Optional[str]: + try: + match = re.search( + r' >> internal:describe=("(?:[^"\\]|\\.)*")$', self._selector + ) + if match: + description = json.loads(match.group(1)) + if isinstance(description, str): + return description + except (json.JSONDecodeError, ValueError): + pass + return None + def filter( self, hasText: Union[str, Pattern[str]] = None, @@ -414,6 +431,7 @@ async def drag_to( trial: bool = None, sourcePosition: Position = None, targetPosition: Position = None, + steps: int = None, ) -> None: params = locals_to_params(locals()) del params["target"] diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 29a583a7c..1f05a9048 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -33,7 +33,6 @@ cast, ) -from playwright._impl._accessibility import Accessibility from playwright._impl._api_structures import ( AriaRole, FilePayload, @@ -150,7 +149,6 @@ class Page(ChannelOwner): WebSocket="websocket", Worker="worker", ) - accessibility: Accessibility keyboard: Keyboard mouse: Mouse touchscreen: Touchscreen @@ -160,7 +158,6 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._browser_context = cast("BrowserContext", parent) - self.accessibility = Accessibility(self._channel) self.keyboard = Keyboard(self._channel) self.mouse = Mouse(self._channel) self.touchscreen = Touchscreen(self._channel) @@ -854,7 +851,7 @@ async def click( trial: bool = None, strict: bool = None, ) -> None: - return await self._main_frame.click(**locals_to_params(locals())) + return await self._main_frame._click(**locals_to_params(locals())) async def dblclick( self, @@ -1017,6 +1014,7 @@ async def drag_and_drop( timeout: float = None, strict: bool = None, trial: bool = None, + steps: int = None, ) -> None: return await self._main_frame.drag_and_drop(**locals_to_params(locals())) @@ -1452,12 +1450,13 @@ async def page_errors(self) -> List[Error]: class Worker(ChannelOwner): - Events = SimpleNamespace(Close="close") + Events = SimpleNamespace(Close="close", Console="console") def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> None: super().__init__(parent, type, guid, initializer) + self._set_event_to_subscription_mapping({Worker.Events.Console: "console"}) self._channel.on("close", lambda _: self._on_close()) self._page: Optional[Page] = None self._context: Optional["BrowserContext"] = None @@ -1502,6 +1501,31 @@ async def evaluate_handle( ) ) + def expect_event( + self, + event: str, + predicate: Callable = None, + timeout: float = None, + ) -> EventContextManagerImpl: + if timeout is None: + if self._page: + timeout = self._page._timeout_settings.timeout() + elif self._context: + timeout = self._context._timeout_settings.timeout() + else: + timeout = 30000 + waiter = Waiter(self, f"worker.expect_event({event})") + waiter.reject_on_timeout( + cast(float, timeout), + f'Timeout {timeout}ms exceeded while waiting for event "{event}"', + ) + if event != Worker.Events.Close: + waiter.reject_on_event( + self, Worker.Events.Close, lambda: TargetClosedError() + ) + waiter.wait_for_event(self, event, predicate) + return EventContextManagerImpl(waiter.result()) + class BindingCall(ChannelOwner): def __init__( diff --git a/playwright/async_api/__init__.py b/playwright/async_api/__init__.py index 257ac2022..c05735fcd 100644 --- a/playwright/async_api/__init__.py +++ b/playwright/async_api/__init__.py @@ -30,7 +30,6 @@ from playwright._impl._assertions import PageAssertions as PageAssertionsImpl from playwright.async_api._context_manager import PlaywrightContextManager from playwright.async_api._generated import ( - Accessibility, APIRequest, APIRequestContext, APIResponse, @@ -150,7 +149,6 @@ def __call__( __all__ = [ "expect", "async_playwright", - "Accessibility", "APIRequest", "APIRequestContext", "APIResponse", diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 4c8304b25..79210603c 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -18,7 +18,6 @@ import typing from typing import Literal -from playwright._impl._accessibility import Accessibility as AccessibilityImpl from playwright._impl._api_structures import ( ClientCertificate, Cookie, @@ -1458,7 +1457,8 @@ async def move( y : float Y coordinate relative to the main frame's viewport in CSS pixels. steps : Union[int, None] - Defaults to 1. Sends intermediate `mousemove` events. + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl(await self._impl_obj.move(x=x, y=y, steps=steps)) @@ -2084,6 +2084,7 @@ async def click( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """ElementHandle.click @@ -2126,6 +2127,9 @@ async def click( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -2139,6 +2143,7 @@ async def click( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) @@ -2155,6 +2160,7 @@ async def dblclick( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """ElementHandle.dblclick @@ -2194,6 +2200,9 @@ async def dblclick( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -2206,6 +2215,7 @@ async def dblclick( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) @@ -3090,72 +3100,6 @@ async def wait_for_selector( mapping.register(ElementHandleImpl, ElementHandle) -class Accessibility(AsyncBase): - - async def snapshot( - self, - *, - interesting_only: typing.Optional[bool] = None, - root: typing.Optional["ElementHandle"] = None, - ) -> typing.Optional[typing.Dict]: - """Accessibility.snapshot - - Captures the current state of the accessibility tree. The returned object represents the root accessible node of - the page. - - **NOTE** The Chromium accessibility tree contains nodes that go unused on most platforms and by most screen - readers. Playwright will discard them as well for an easier to process tree, unless `interestingOnly` is set to - `false`. - - **Usage** - - An example of dumping the entire accessibility tree: - - ```py - snapshot = await page.accessibility.snapshot() - print(snapshot) - ``` - - An example of logging the focused node's name: - - ```py - def find_focused_node(node): - if node.get(\"focused\"): - return node - for child in (node.get(\"children\") or []): - found_node = find_focused_node(child) - if found_node: - return found_node - return None - - snapshot = await page.accessibility.snapshot() - node = find_focused_node(snapshot) - if node: - print(node[\"name\"]) - ``` - - Parameters - ---------- - interesting_only : Union[bool, None] - Prune uninteresting nodes from the tree. Defaults to `true`. - root : Union[ElementHandle, None] - The root DOM element for the snapshot. Defaults to the whole page. - - Returns - ------- - Union[Dict, None] - """ - - return mapping.from_maybe_impl( - await self._impl_obj.snapshot( - interestingOnly=interesting_only, root=mapping.to_impl(root) - ) - ) - - -mapping.register(AccessibilityImpl, Accessibility) - - class FileChooser(AsyncBase): @property @@ -5357,6 +5301,7 @@ async def drag_and_drop( strict: typing.Optional[bool] = None, timeout: typing.Optional[float] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Frame.drag_and_drop @@ -5388,6 +5333,9 @@ async def drag_and_drop( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + of the drag. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -5401,6 +5349,7 @@ async def drag_and_drop( strict=strict, timeout=timeout, trial=trial, + steps=steps, ) ) @@ -6600,6 +6549,7 @@ def nth(self, index: int) -> "FrameLocator": class Worker(AsyncBase): + @typing.overload def on( self, event: Literal["close"], @@ -6608,8 +6558,27 @@ def on( """ Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is terminated.""" + + @typing.overload + def on( + self, + event: Literal["console"], + f: typing.Callable[ + ["ConsoleMessage"], "typing.Union[typing.Awaitable[None], None]" + ], + ) -> None: + """ + Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + """ + + def on( + self, + event: str, + f: typing.Callable[..., typing.Union[typing.Awaitable[None], None]], + ) -> None: return super().on(event=event, f=f) + @typing.overload def once( self, event: Literal["close"], @@ -6618,6 +6587,24 @@ def once( """ Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is terminated.""" + + @typing.overload + def once( + self, + event: Literal["console"], + f: typing.Callable[ + ["ConsoleMessage"], "typing.Union[typing.Awaitable[None], None]" + ], + ) -> None: + """ + Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + """ + + def once( + self, + event: str, + f: typing.Callable[..., typing.Union[typing.Awaitable[None], None]], + ) -> None: return super().once(event=event, f=f) @property @@ -6695,6 +6682,47 @@ async def evaluate_handle( ) ) + def expect_event( + self, + event: str, + predicate: typing.Optional[typing.Callable] = None, + *, + timeout: typing.Optional[float] = None, + ) -> AsyncEventContextManager: + """Worker.expect_event + + Waits for event to fire and passes its value into the predicate function. Returns when the predicate returns truthy + value. Will throw an error if the page is closed before the event is fired. Returns the event data value. + + **Usage** + + ```py + async with worker.expect_event(\"console\") as event_info: + await worker.evaluate(\"console.log(42)\") + message = await event_info.value + ``` + + Parameters + ---------- + event : str + Event name, same one typically passed into `*.on(event)`. + predicate : Union[Callable, None] + Receives the event data and resolves to truthy value when the waiting should resolve. + timeout : Union[float, None] + Maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The + default value can be changed by using the `browser_context.set_default_timeout()`. + + Returns + ------- + EventContextManager + """ + + return AsyncEventContextManager( + self._impl_obj.expect_event( + event=event, predicate=self._wrap_handler(predicate), timeout=timeout + ).future + ) + mapping.register(WorkerImpl, Worker) @@ -7051,6 +7079,19 @@ def page(self) -> typing.Optional["Page"]: """ return mapping.from_impl_nullable(self._impl_obj.page) + @property + def worker(self) -> typing.Optional["Worker"]: + """ConsoleMessage.worker + + The web worker or service worker that produced this console message, if any. Note that console messages from web + workers also have non-null `console_message.page()`. + + Returns + ------- + Union[Worker, None] + """ + return mapping.from_impl_nullable(self._impl_obj.worker) + mapping.register(ConsoleMessageImpl, ConsoleMessage) @@ -7825,16 +7866,6 @@ def once( ) -> None: return super().once(event=event, f=f) - @property - def accessibility(self) -> "Accessibility": - """Page.accessibility - - Returns - ------- - Accessibility - """ - return mapping.from_impl(self._impl_obj.accessibility) - @property def keyboard(self) -> "Keyboard": """Page.keyboard @@ -10893,6 +10924,7 @@ async def drag_and_drop( timeout: typing.Optional[float] = None, strict: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Page.drag_and_drop @@ -10940,6 +10972,9 @@ async def drag_and_drop( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + of the drag. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -10953,6 +10988,7 @@ async def drag_and_drop( timeout=timeout, strict=strict, trial=trial, + steps=steps, ) ) @@ -15338,6 +15374,30 @@ def content_frame(self) -> "FrameLocator": """ return mapping.from_impl(self._impl_obj.content_frame) + @property + def description(self) -> typing.Optional[str]: + """Locator.description + + Returns locator description previously set with `locator.describe()`. Returns `null` if no custom + description has been set. Prefer `Locator.toString()` for a human-readable representation, as it uses the + description when available. + + **Usage** + + ```py + button = page.get_by_role(\"button\").describe(\"Subscribe button\") + print(button.description()) # \"Subscribe button\" + + input = page.get_by_role(\"textbox\") + print(input.description()) # None + ``` + + Returns + ------- + Union[str, None] + """ + return mapping.from_maybe_impl(self._impl_obj.description) + async def bounding_box( self, *, timeout: typing.Optional[float] = None ) -> typing.Optional[FloatRect]: @@ -15457,6 +15517,7 @@ async def click( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Locator.click @@ -15521,6 +15582,9 @@ async def click( to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys are pressed. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -15534,6 +15598,7 @@ async def click( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) @@ -15550,6 +15615,7 @@ async def dblclick( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Locator.dblclick @@ -15595,6 +15661,9 @@ async def dblclick( to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys are pressed. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -15607,6 +15676,7 @@ async def dblclick( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) @@ -16750,6 +16820,7 @@ async def drag_to( trial: typing.Optional[bool] = None, source_position: typing.Optional[Position] = None, target_position: typing.Optional[Position] = None, + steps: typing.Optional[int] = None, ) -> None: """Locator.drag_to @@ -16796,6 +16867,9 @@ async def drag_to( target_position : Union[{x: float, y: float}, None] Drops on the target element at this point relative to the top-left corner of the element's padding box. If not specified, some visible point of the element is used. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + of the drag. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -16807,6 +16881,7 @@ async def drag_to( trial=trial, sourcePosition=source_position, targetPosition=target_position, + steps=steps, ) ) diff --git a/playwright/sync_api/__init__.py b/playwright/sync_api/__init__.py index e901cadbf..53dee2cad 100644 --- a/playwright/sync_api/__init__.py +++ b/playwright/sync_api/__init__.py @@ -30,7 +30,6 @@ from playwright._impl._assertions import PageAssertions as PageAssertionsImpl from playwright.sync_api._context_manager import PlaywrightContextManager from playwright.sync_api._generated import ( - Accessibility, APIRequest, APIRequestContext, APIResponse, @@ -149,7 +148,6 @@ def __call__( __all__ = [ "expect", - "Accessibility", "APIRequest", "APIRequestContext", "APIResponse", diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index ced1a7d8c..0d892a0c9 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -18,7 +18,6 @@ import typing from typing import Literal -from playwright._impl._accessibility import Accessibility as AccessibilityImpl from playwright._impl._api_structures import ( ClientCertificate, Cookie, @@ -1456,7 +1455,8 @@ def move(self, x: float, y: float, *, steps: typing.Optional[int] = None) -> Non y : float Y coordinate relative to the main frame's viewport in CSS pixels. steps : Union[int, None] - Defaults to 1. Sends intermediate `mousemove` events. + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -2094,6 +2094,7 @@ def click( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """ElementHandle.click @@ -2136,6 +2137,9 @@ def click( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -2150,6 +2154,7 @@ def click( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) ) @@ -2167,6 +2172,7 @@ def dblclick( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """ElementHandle.dblclick @@ -2206,6 +2212,9 @@ def dblclick( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -2219,6 +2228,7 @@ def dblclick( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) ) @@ -3134,74 +3144,6 @@ def wait_for_selector( mapping.register(ElementHandleImpl, ElementHandle) -class Accessibility(SyncBase): - - def snapshot( - self, - *, - interesting_only: typing.Optional[bool] = None, - root: typing.Optional["ElementHandle"] = None, - ) -> typing.Optional[typing.Dict]: - """Accessibility.snapshot - - Captures the current state of the accessibility tree. The returned object represents the root accessible node of - the page. - - **NOTE** The Chromium accessibility tree contains nodes that go unused on most platforms and by most screen - readers. Playwright will discard them as well for an easier to process tree, unless `interestingOnly` is set to - `false`. - - **Usage** - - An example of dumping the entire accessibility tree: - - ```py - snapshot = page.accessibility.snapshot() - print(snapshot) - ``` - - An example of logging the focused node's name: - - ```py - def find_focused_node(node): - if node.get(\"focused\"): - return node - for child in (node.get(\"children\") or []): - found_node = find_focused_node(child) - if found_node: - return found_node - return None - - snapshot = page.accessibility.snapshot() - node = find_focused_node(snapshot) - if node: - print(node[\"name\"]) - ``` - - Parameters - ---------- - interesting_only : Union[bool, None] - Prune uninteresting nodes from the tree. Defaults to `true`. - root : Union[ElementHandle, None] - The root DOM element for the snapshot. Defaults to the whole page. - - Returns - ------- - Union[Dict, None] - """ - - return mapping.from_maybe_impl( - self._sync( - self._impl_obj.snapshot( - interestingOnly=interesting_only, root=mapping.to_impl(root) - ) - ) - ) - - -mapping.register(AccessibilityImpl, Accessibility) - - class FileChooser(SyncBase): @property @@ -5448,6 +5390,7 @@ def drag_and_drop( strict: typing.Optional[bool] = None, timeout: typing.Optional[float] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Frame.drag_and_drop @@ -5479,6 +5422,9 @@ def drag_and_drop( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + of the drag. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -5493,6 +5439,7 @@ def drag_and_drop( strict=strict, timeout=timeout, trial=trial, + steps=steps, ) ) ) @@ -6708,20 +6655,42 @@ def nth(self, index: int) -> "FrameLocator": class Worker(SyncBase): + @typing.overload def on( self, event: Literal["close"], f: typing.Callable[["Worker"], "None"] ) -> None: """ Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is terminated.""" + + @typing.overload + def on( + self, event: Literal["console"], f: typing.Callable[["ConsoleMessage"], "None"] + ) -> None: + """ + Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + """ + + def on(self, event: str, f: typing.Callable[..., None]) -> None: return super().on(event=event, f=f) + @typing.overload def once( self, event: Literal["close"], f: typing.Callable[["Worker"], "None"] ) -> None: """ Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is terminated.""" + + @typing.overload + def once( + self, event: Literal["console"], f: typing.Callable[["ConsoleMessage"], "None"] + ) -> None: + """ + Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + """ + + def once(self, event: str, f: typing.Callable[..., None]) -> None: return super().once(event=event, f=f) @property @@ -6801,6 +6770,47 @@ def evaluate_handle( ) ) + def expect_event( + self, + event: str, + predicate: typing.Optional[typing.Callable] = None, + *, + timeout: typing.Optional[float] = None, + ) -> EventContextManager: + """Worker.expect_event + + Waits for event to fire and passes its value into the predicate function. Returns when the predicate returns truthy + value. Will throw an error if the page is closed before the event is fired. Returns the event data value. + + **Usage** + + ```py + with worker.expect_event(\"console\") as event_info: + worker.evaluate(\"console.log(42)\") + message = event_info.value + ``` + + Parameters + ---------- + event : str + Event name, same one typically passed into `*.on(event)`. + predicate : Union[Callable, None] + Receives the event data and resolves to truthy value when the waiting should resolve. + timeout : Union[float, None] + Maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The + default value can be changed by using the `browser_context.set_default_timeout()`. + + Returns + ------- + EventContextManager + """ + return EventContextManager( + self, + self._impl_obj.expect_event( + event=event, predicate=self._wrap_handler(predicate), timeout=timeout + ).future, + ) + mapping.register(WorkerImpl, Worker) @@ -7159,6 +7169,19 @@ def page(self) -> typing.Optional["Page"]: """ return mapping.from_impl_nullable(self._impl_obj.page) + @property + def worker(self) -> typing.Optional["Worker"]: + """ConsoleMessage.worker + + The web worker or service worker that produced this console message, if any. Note that console messages from web + workers also have non-null `console_message.page()`. + + Returns + ------- + Union[Worker, None] + """ + return mapping.from_impl_nullable(self._impl_obj.worker) + mapping.register(ConsoleMessageImpl, ConsoleMessage) @@ -7829,16 +7852,6 @@ def once( def once(self, event: str, f: typing.Callable[..., None]) -> None: return super().once(event=event, f=f) - @property - def accessibility(self) -> "Accessibility": - """Page.accessibility - - Returns - ------- - Accessibility - """ - return mapping.from_impl(self._impl_obj.accessibility) - @property def keyboard(self) -> "Keyboard": """Page.keyboard @@ -10960,6 +10973,7 @@ def drag_and_drop( timeout: typing.Optional[float] = None, strict: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Page.drag_and_drop @@ -11007,6 +11021,9 @@ def drag_and_drop( trial : Union[bool, None] When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + of the drag. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -11021,6 +11038,7 @@ def drag_and_drop( timeout=timeout, strict=strict, trial=trial, + steps=steps, ) ) ) @@ -15382,6 +15400,30 @@ def content_frame(self) -> "FrameLocator": """ return mapping.from_impl(self._impl_obj.content_frame) + @property + def description(self) -> typing.Optional[str]: + """Locator.description + + Returns locator description previously set with `locator.describe()`. Returns `null` if no custom + description has been set. Prefer `Locator.toString()` for a human-readable representation, as it uses the + description when available. + + **Usage** + + ```py + button = page.get_by_role(\"button\").describe(\"Subscribe button\") + print(button.description()) # \"Subscribe button\" + + input = page.get_by_role(\"textbox\") + print(input.description()) # None + ``` + + Returns + ------- + Union[str, None] + """ + return mapping.from_maybe_impl(self._impl_obj.description) + def bounding_box( self, *, timeout: typing.Optional[float] = None ) -> typing.Optional[FloatRect]: @@ -15503,6 +15545,7 @@ def click( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Locator.click @@ -15567,6 +15610,9 @@ def click( to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys are pressed. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -15581,6 +15627,7 @@ def click( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) ) @@ -15598,6 +15645,7 @@ def dblclick( force: typing.Optional[bool] = None, no_wait_after: typing.Optional[bool] = None, trial: typing.Optional[bool] = None, + steps: typing.Optional[int] = None, ) -> None: """Locator.dblclick @@ -15643,6 +15691,9 @@ def dblclick( to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys are pressed. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor + position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -15656,6 +15707,7 @@ def dblclick( force=force, noWaitAfter=no_wait_after, trial=trial, + steps=steps, ) ) ) @@ -16815,6 +16867,7 @@ def drag_to( trial: typing.Optional[bool] = None, source_position: typing.Optional[Position] = None, target_position: typing.Optional[Position] = None, + steps: typing.Optional[int] = None, ) -> None: """Locator.drag_to @@ -16861,6 +16914,9 @@ def drag_to( target_position : Union[{x: float, y: float}, None] Drops on the target element at this point relative to the top-left corner of the element's padding box. If not specified, some visible point of the element is used. + steps : Union[int, None] + Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + of the drag. When set to 1, emits a single `mousemove` event at the destination location. """ return mapping.from_maybe_impl( @@ -16873,6 +16929,7 @@ def drag_to( trial=trial, sourcePosition=source_position, targetPosition=target_position, + steps=steps, ) ) ) diff --git a/scripts/expected_api_mismatch.txt b/scripts/expected_api_mismatch.txt index c6b3c7a95..f47493440 100644 --- a/scripts/expected_api_mismatch.txt +++ b/scripts/expected_api_mismatch.txt @@ -4,9 +4,6 @@ Parameter not documented: Browser.new_context(default_browser_type=) Parameter not documented: Browser.new_page(default_browser_type=) -# We don't expand the type of the return value here. -Parameter type mismatch in Accessibility.snapshot(return=): documented as Union[{role: str, name: str, value: Union[float, str], description: str, keyshortcuts: str, roledescription: str, valuetext: str, disabled: bool, expanded: bool, focused: bool, modal: bool, multiline: bool, multiselectable: bool, readonly: bool, required: bool, selected: bool, checked: Union["mixed", bool], pressed: Union["mixed", bool], level: int, valuemin: float, valuemax: float, autocomplete: str, haspopup: str, invalid: str, orientation: str, children: List[Dict]}, None], code has Union[Dict, None] - # One vs two arguments in the callback, Python explicitly unions. Parameter type mismatch in BrowserContext.route(handler=): documented as Callable[[Route, Request], Union[Any, Any]], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any]] Parameter type mismatch in BrowserContext.unroute(handler=): documented as Union[Callable[[Route, Request], Union[Any, Any]], None], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any], None] diff --git a/scripts/generate_api.py b/scripts/generate_api.py index b0e7e2a32..3d2f45156 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -18,7 +18,6 @@ from typing import Any, Dict, List, Match, Optional, Union, cast, get_args, get_origin from typing import get_type_hints as typing_get_type_hints -from playwright._impl._accessibility import Accessibility from playwright._impl._assertions import ( APIResponseAssertions, LocatorAssertions, @@ -225,7 +224,6 @@ def return_value(value: Any) -> List[str]: from typing import Literal -from playwright._impl._accessibility import Accessibility as AccessibilityImpl 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 from playwright._impl._browser import Browser as BrowserImpl from playwright._impl._browser_context import BrowserContext as BrowserContextImpl @@ -265,7 +263,6 @@ def return_value(value: Any) -> List[str]: Touchscreen, JSHandle, ElementHandle, - Accessibility, FileChooser, Frame, FrameLocator, diff --git a/setup.py b/setup.py index 2046c2220..1645ef7b1 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import zipfile from typing import Dict -driver_version = "1.56.1" +driver_version = "1.57.0-beta-1763718928000" base_wheel_bundles = [ { @@ -102,7 +102,7 @@ def download_driver(zip_name: str) -> None: destination_path = "driver/" + zip_file if os.path.exists(destination_path): return - url = "https://playwright.azureedge.net/builds/driver/" + url = "https://cdn.playwright.dev/builds/driver/" if ( "-alpha" in driver_version or "-beta" in driver_version diff --git a/tests/async/test_accessibility.py b/tests/async/test_accessibility.py deleted file mode 100644 index 41fe599c2..000000000 --- a/tests/async/test_accessibility.py +++ /dev/null @@ -1,388 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import sys - -import pytest - -from playwright.async_api import Page - - -async def test_accessibility_should_work( - page: Page, is_firefox: bool, is_chromium: bool, is_webkit: bool -) -> None: - if is_webkit and sys.platform == "darwin": - pytest.skip("Test disabled on WebKit on macOS") - await page.set_content( - """ - Accessibility Test - - -

Inputs

- - - - - - - - - """ - ) - # autofocus happens after a delay in chrome these days - await page.wait_for_function("document.activeElement.hasAttribute('autofocus')") - - if is_firefox: - golden = { - "role": "document", - "name": "Accessibility Test", - "children": [ - {"role": "heading", "name": "Inputs", "level": 1}, - {"role": "textbox", "name": "Empty input", "focused": True}, - {"role": "textbox", "name": "readonly input", "readonly": True}, - {"role": "textbox", "name": "disabled input", "disabled": True}, - {"role": "textbox", "name": "Input with whitespace", "value": " "}, - {"role": "textbox", "name": "", "value": "value only"}, - { - "role": "textbox", - "name": "", - "value": "and a value", - }, # firefox doesn't use aria-placeholder for the name - { - "role": "textbox", - "name": "", - "value": "and a value", - "description": "This is a description!", - }, # and here - ], - } - elif is_chromium: - golden = { - "role": "WebArea", - "name": "Accessibility Test", - "children": [ - {"role": "heading", "name": "Inputs", "level": 1}, - {"role": "textbox", "name": "Empty input", "focused": True}, - {"role": "textbox", "name": "readonly input", "readonly": True}, - {"role": "textbox", "name": "disabled input", "disabled": True}, - {"role": "textbox", "name": "Input with whitespace", "value": " "}, - {"role": "textbox", "name": "", "value": "value only"}, - {"role": "textbox", "name": "placeholder", "value": "and a value"}, - { - "role": "textbox", - "name": "placeholder", - "value": "and a value", - "description": "This is a description!", - }, - ], - } - else: - golden = { - "role": "WebArea", - "name": "Accessibility Test", - "children": [ - {"role": "heading", "name": "Inputs", "level": 1}, - {"role": "textbox", "name": "Empty input", "focused": True}, - {"role": "textbox", "name": "readonly input", "readonly": True}, - {"role": "textbox", "name": "disabled input", "disabled": True}, - {"role": "textbox", "name": "Input with whitespace", "value": " "}, - {"role": "textbox", "name": "", "value": "value only"}, - {"role": "textbox", "name": "placeholder", "value": "and a value"}, - { - "role": "textbox", - "name": "This is a description!", - "value": "and a value", - }, # webkit uses the description over placeholder for the name - ], - } - assert await page.accessibility.snapshot() == golden - - -async def test_accessibility_should_work_with_regular_text( - page: Page, is_firefox: bool -) -> None: - await page.set_content("
Hello World
") - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "role": "text leaf" if is_firefox else "text", - "name": "Hello World", - } - - -async def test_accessibility_roledescription(page: Page) -> None: - await page.set_content('

Hi

') - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["roledescription"] == "foo" - - -async def test_accessibility_orientation(page: Page) -> None: - await page.set_content( - '11' - ) - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["orientation"] == "vertical" - - -async def test_accessibility_autocomplete(page: Page) -> None: - await page.set_content('
hi
') - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["autocomplete"] == "list" - - -async def test_accessibility_multiselectable(page: Page) -> None: - await page.set_content( - '
hey
' - ) - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["multiselectable"] - - -async def test_accessibility_keyshortcuts(page: Page) -> None: - await page.set_content( - '
hey
' - ) - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["keyshortcuts"] == "foo" - - -async def test_accessibility_filtering_children_of_leaf_nodes_should_not_report_text_nodes_inside_controls( - page: Page, is_firefox: bool -) -> None: - await page.set_content( - """ -
-
Tab1
-
Tab2
-
""" - ) - golden = { - "role": "document" if is_firefox else "WebArea", - "name": "", - "children": [ - {"role": "tab", "name": "Tab1", "selected": True}, - {"role": "tab", "name": "Tab2"}, - ], - } - assert await page.accessibility.snapshot() == golden - - -# Firefox does not support contenteditable="plaintext-only". -# WebKit rich text accessibility is iffy -@pytest.mark.only_browser("chromium") -async def test_accessibility_plain_text_field_with_role_should_not_have_children( - page: Page, -) -> None: - await page.set_content( - """ -
Edit this image:my fake image
""" - ) - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "multiline": True, - "name": "", - "role": "textbox", - "value": "Edit this image:", - } - - -@pytest.mark.only_browser("chromium") -async def test_accessibility_plain_text_field_without_role_should_not_have_content( - page: Page, -) -> None: - await page.set_content( - """ -
Edit this image:my fake image
""" - ) - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "name": "", - "role": "generic", - "value": "Edit this image:", - } - - -@pytest.mark.only_browser("chromium") -async def test_accessibility_plain_text_field_with_tabindex_and_without_role_should_not_have_content( - page: Page, -) -> None: - await page.set_content( - """ -
Edit this image:my fake image
""" - ) - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "name": "", - "role": "generic", - "value": "Edit this image:", - } - - -async def test_accessibility_non_editable_textbox_with_role_and_tabIndex_and_label_should_not_have_children( - page: Page, is_chromium: bool, is_firefox: bool -) -> None: - await page.set_content( - """ -
- this is the inner content - yo -
""" - ) - if is_firefox: - golden = { - "role": "textbox", - "name": "my favorite textbox", - "value": "this is the inner content yo", - } - elif is_chromium: - golden = { - "role": "textbox", - "name": "my favorite textbox", - "value": "this is the inner content ", - } - else: - golden = { - "role": "textbox", - "name": "my favorite textbox", - "value": "this is the inner content ", - } - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == golden - - -async def test_accessibility_checkbox_with_and_tabIndex_and_label_should_not_have_children( - page: Page, -) -> None: - await page.set_content( - """ -
- this is the inner content - yo -
""" - ) - golden = {"role": "checkbox", "name": "my favorite checkbox", "checked": True} - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == golden - - -async def test_accessibility_checkbox_without_label_should_not_have_children( - page: Page, is_firefox: bool -) -> None: - await page.set_content( - """ -
- this is the inner content - yo -
""" - ) - golden = { - "role": "checkbox", - "name": "this is the inner content yo", - "checked": True, - } - snapshot = await page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == golden - - -async def test_accessibility_should_work_a_button(page: Page) -> None: - await page.set_content("") - - button = await page.query_selector("button") - assert await page.accessibility.snapshot(root=button) == { - "role": "button", - "name": "My Button", - } - - -async def test_accessibility_should_work_an_input(page: Page) -> None: - await page.set_content('') - - input = await page.query_selector("input") - assert await page.accessibility.snapshot(root=input) == { - "role": "textbox", - "name": "My Input", - "value": "My Value", - } - - -async def test_accessibility_should_work_on_a_menu(page: Page) -> None: - await page.set_content( - """ -
-
First Item
-
Second Item
-
Third Item
-
- """ - ) - - menu = await page.query_selector('div[role="menu"]') - golden = { - "role": "menu", - "name": "My Menu", - "children": [ - {"role": "menuitem", "name": "First Item"}, - {"role": "menuitem", "name": "Second Item"}, - {"role": "menuitem", "name": "Third Item"}, - ], - } - actual = await page.accessibility.snapshot(root=menu) - assert actual - # Different per browser channel - if "orientation" in actual: - del actual["orientation"] - assert actual == golden - - -async def test_accessibility_should_return_null_when_the_element_is_no_longer_in_DOM( - page: Page, -) -> None: - await page.set_content("") - button = await page.query_selector("button") - await page.eval_on_selector("button", "button => button.remove()") - assert await page.accessibility.snapshot(root=button) is None - - -async def test_accessibility_should_show_uninteresting_nodes(page: Page) -> None: - await page.set_content( - """ -
-
- hello -
- world -
-
-
- """ - ) - - root = await page.query_selector("#root") - snapshot = await page.accessibility.snapshot(root=root, interesting_only=False) - assert snapshot - assert snapshot["role"] == "textbox" - assert "hello" in snapshot["value"] - assert "world" in snapshot["value"] - assert snapshot["children"] diff --git a/tests/async/test_click.py b/tests/async/test_click.py index fd783546d..8fb06ec38 100644 --- a/tests/async/test_click.py +++ b/tests/async/test_click.py @@ -1111,3 +1111,35 @@ async def test_check_the_box_by_aria_role(page: Page) -> None: ) await page.check("div") assert await page.evaluate("checkbox.getAttribute ('aria-checked')") + + +async def test_click_with_tweened_mouse_movement(page: Page, browser_name: str) -> None: + await page.set_content( + """ + +
Click me
+ + """ + ) + + # The test becomes flaky on WebKit without next line. + if browser_name == "webkit": + await page.evaluate("() => new Promise(requestAnimationFrame)") + await page.mouse.move(100, 100) + await page.evaluate( + """() => { + window.result = []; + document.addEventListener('mousemove', event => { + window.result.push([event.clientX, event.clientY]); + }); + }""" + ) + # Centerpoint at 150 + 100/2, 280 + 40/2 = 200, 300 + await page.locator("div").click(steps=5) + assert await page.evaluate("result") == [ + [120, 140], + [140, 180], + [160, 220], + [180, 260], + [200, 300], + ] diff --git a/tests/async/test_locators.py b/tests/async/test_locators.py index 980de041f..1e49bf303 100644 --- a/tests/async/test_locators.py +++ b/tests/async/test_locators.py @@ -1152,3 +1152,42 @@ async def test_locator_should_ignore_deprecated_is_hidden_and_visible_timeout( div = page.locator("div") assert await div.is_hidden(timeout=10) is False assert await div.is_visible(timeout=10) is True + + +async def test_description_should_return_none_for_locator_without_description( + page: Page, +) -> None: + locator = page.locator("button") + assert locator.description is None + + +async def test_description_should_return_description_for_locator_with_simple_description( + page: Page, +) -> None: + locator = page.locator("button").describe("Submit button") + assert locator.description == "Submit button" + + +async def test_description_should_return_description_with_special_characters( + page: Page, +) -> None: + locator = page.locator("div").describe("Button with \"quotes\" and 'apostrophes'") + assert locator.description == "Button with \"quotes\" and 'apostrophes'" + + +async def test_description_should_return_description_for_chained_locators( + page: Page, +) -> None: + locator = page.locator("form").locator("input").describe("Form input field") + assert locator.description == "Form input field" + + +async def test_description_should_return_description_for_locator_with_multiple_describe_calls( + page: Page, +) -> None: + locator1 = page.locator("foo").describe("First description") + assert locator1.description == "First description" + locator2 = locator1.locator("button").describe("Second description") + assert locator2.description == "Second description" + locator3 = locator2.locator("button") + assert locator3.description is None diff --git a/tests/async/test_page.py b/tests/async/test_page.py index 607c86fb3..562d98248 100644 --- a/tests/async/test_page.py +++ b/tests/async/test_page.py @@ -1320,6 +1320,106 @@ async def test_drag_and_drop_with_position(page: Page, server: Server) -> None: ] +async def test_drag_and_drop_with_tweened_mouse_movement(page: Page) -> None: + await page.set_content( + """ + +
+
+ + """ + ) + events_handle = await page.evaluate_handle( + """ + () => { + const events = []; + document.addEventListener('mousedown', event => { + events.push({ + type: 'mousedown', + x: event.pageX, + y: event.pageY, + }); + }); + document.addEventListener('mouseup', event => { + events.push({ + type: 'mouseup', + x: event.pageX, + y: event.pageY, + }); + }); + document.addEventListener('mousemove', event => { + events.push({ + type: 'mousemove', + x: event.pageX, + y: event.pageY, + }); + }); + return events; + } + """ + ) + await page.drag_and_drop("#red", "#blue", steps=4) + assert await events_handle.json_value() == [ + {"type": "mousemove", "x": 50, "y": 50}, + {"type": "mousedown", "x": 50, "y": 50}, + {"type": "mousemove", "x": 75, "y": 75}, + {"type": "mousemove", "x": 100, "y": 100}, + {"type": "mousemove", "x": 125, "y": 125}, + {"type": "mousemove", "x": 150, "y": 150}, + {"type": "mouseup", "x": 150, "y": 150}, + ] + + +async def test_locator_drag_to_with_tweened_mouse_movement(page: Page) -> None: + await page.set_content( + """ + +
+
+ + """ + ) + events_handle = await page.evaluate_handle( + """ + () => { + const events = []; + document.addEventListener('mousedown', event => { + events.push({ + type: 'mousedown', + x: event.pageX, + y: event.pageY, + }); + }); + document.addEventListener('mouseup', event => { + events.push({ + type: 'mouseup', + x: event.pageX, + y: event.pageY, + }); + }); + document.addEventListener('mousemove', event => { + events.push({ + type: 'mousemove', + x: event.pageX, + y: event.pageY, + }); + }); + return events; + } + """ + ) + await page.locator("#red").drag_to(page.locator("#blue"), steps=4) + assert await events_handle.json_value() == [ + {"type": "mousemove", "x": 50, "y": 50}, + {"type": "mousedown", "x": 50, "y": 50}, + {"type": "mousemove", "x": 75, "y": 75}, + {"type": "mousemove", "x": 100, "y": 100}, + {"type": "mousemove", "x": 125, "y": 125}, + {"type": "mousemove", "x": 150, "y": 150}, + {"type": "mouseup", "x": 150, "y": 150}, + ] + + async def test_should_check_box_using_set_checked(page: Page) -> None: await page.set_content("``") await page.set_checked("input", True) diff --git a/tests/async/test_page_route.py b/tests/async/test_page_route.py index db46c330d..848a95045 100644 --- a/tests/async/test_page_route.py +++ b/tests/async/test_page_route.py @@ -1083,6 +1083,10 @@ def glob_to_regex(pattern: str) -> re.Pattern: "http://localhost:3000/signin-oidcnice" ) + assert glob_to_regex("**/*.js").match("/foo.js") + assert not glob_to_regex("asd/**.js").match("/foo.js") + assert not glob_to_regex("**/*.js").match("bar_foo.js") + # range [] is NOT supported assert glob_to_regex("**/api/v[0-9]").fullmatch("http://example.com/api/v[0-9]") assert not glob_to_regex("**/api/v[0-9]").fullmatch( @@ -1147,6 +1151,12 @@ def glob_to_regex(pattern: str) -> re.Pattern: assert url_matches(None, "https://localhost:3000/?a=b", "**?a=b") assert url_matches(None, "https://localhost:3000/?a=b", "**=b") + # Custom schema. + assert url_matches(None, "my.custom.protocol://foo", "my.custom.protocol://**") + assert not url_matches(None, "my.p://foo", "my.{p,y}://**") + assert url_matches(None, "my.p://foo/", "my.{p,y}://**") + assert url_matches(None, "file:///foo/", "f*e://**") + # This is not supported, we treat ? as a query separator. assert not url_matches( None, @@ -1174,6 +1184,10 @@ def glob_to_regex(pattern: str) -> re.Pattern: assert url_matches("http://first.host/", "http://second.host/foo", "**/foo") assert url_matches("http://playwright.dev/", "http://localhost/", "*//localhost/") + # /**/ should match /. + assert url_matches(None, "https://foo/bar.js", "https://foo/**/bar.js") + assert url_matches(None, "https://foo/bar.js", "https://foo/**/**/bar.js") + custom_prefixes = ["about", "data", "chrome", "edge", "file"] for prefix in custom_prefixes: assert url_matches( diff --git a/tests/async/test_worker.py b/tests/async/test_worker.py index bba22fc0d..cd6828053 100644 --- a/tests/async/test_worker.py +++ b/tests/async/test_worker.py @@ -201,3 +201,42 @@ async def test_workers_should_format_number_using_context_locale( worker = await worker_info.value assert await worker.evaluate("() => (10000.20).toLocaleString()") == "10\u00a0000,2" await context.close() + + +async def test_worker_should_report_console_event(page: Page) -> None: + async with page.expect_worker() as worker_info: + await page.evaluate( + "() => { window.worker = new Worker(URL.createObjectURL(new Blob(['42'], { type: 'application/javascript' }))); }" + ) + worker = await worker_info.value + + async with worker.expect_event("console") as message1_info: + async with page.expect_console_message() as message2_info: + async with page.context.expect_console_message() as message3_info: + await worker.evaluate("() => { console.log('hello from worker'); }") + + message1 = await message1_info.value + message2 = await message2_info.value + message3 = await message3_info.value + + assert message1.text == "hello from worker" + assert message1 is message2 + assert message1 is message3 + assert message1.worker is worker + + +async def test_worker_should_report_console_event_when_not_listening_on_page_or_context( + page: Page, +) -> None: + async with page.expect_worker() as worker_info: + await page.evaluate( + "() => { window.worker = new Worker(URL.createObjectURL(new Blob(['42'], { type: 'application/javascript' }))); }" + ) + worker = await worker_info.value + + async with worker.expect_event("console") as message_info: + await worker.evaluate("() => { console.log('hello from worker'); }") + + message = await message_info.value + assert message.text == "hello from worker" + assert message.worker is worker diff --git a/tests/sync/test_accessibility.py b/tests/sync/test_accessibility.py deleted file mode 100644 index 10ec5d1b2..000000000 --- a/tests/sync/test_accessibility.py +++ /dev/null @@ -1,393 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import sys - -import pytest - -from playwright.sync_api import Page - - -def test_accessibility_should_work( - page: Page, is_firefox: bool, is_chromium: bool, is_webkit: bool -) -> None: - if is_webkit and sys.platform == "darwin": - pytest.skip("Test disabled on WebKit on macOS") - page.set_content( - """ - Accessibility Test - - -

Inputs

- - - - - - - - - """ - ) - # autofocus happens after a delay in chrome these days - page.wait_for_function("document.activeElement.hasAttribute('autofocus')") - - if is_firefox: - golden = { - "role": "document", - "name": "Accessibility Test", - "children": [ - {"role": "heading", "name": "Inputs", "level": 1}, - {"role": "textbox", "name": "Empty input", "focused": True}, - {"role": "textbox", "name": "readonly input", "readonly": True}, - {"role": "textbox", "name": "disabled input", "disabled": True}, - {"role": "textbox", "name": "Input with whitespace", "value": " "}, - {"role": "textbox", "name": "", "value": "value only"}, - { - "role": "textbox", - "name": "", - "value": "and a value", - }, # firefox doesn't use aria-placeholder for the name - { - "role": "textbox", - "name": "", - "value": "and a value", - "description": "This is a description!", - }, # and here - ], - } - elif is_chromium: - golden = { - "role": "WebArea", - "name": "Accessibility Test", - "children": [ - {"role": "heading", "name": "Inputs", "level": 1}, - {"role": "textbox", "name": "Empty input", "focused": True}, - {"role": "textbox", "name": "readonly input", "readonly": True}, - {"role": "textbox", "name": "disabled input", "disabled": True}, - {"role": "textbox", "name": "Input with whitespace", "value": " "}, - {"role": "textbox", "name": "", "value": "value only"}, - {"role": "textbox", "name": "placeholder", "value": "and a value"}, - { - "role": "textbox", - "name": "placeholder", - "value": "and a value", - "description": "This is a description!", - }, - ], - } - else: - golden = { - "role": "WebArea", - "name": "Accessibility Test", - "children": [ - {"role": "heading", "name": "Inputs", "level": 1}, - {"role": "textbox", "name": "Empty input", "focused": True}, - {"role": "textbox", "name": "readonly input", "readonly": True}, - {"role": "textbox", "name": "disabled input", "disabled": True}, - {"role": "textbox", "name": "Input with whitespace", "value": " "}, - {"role": "textbox", "name": "", "value": "value only"}, - {"role": "textbox", "name": "placeholder", "value": "and a value"}, - { - "role": "textbox", - "name": ( - "placeholder" - if ( - sys.platform == "darwin" - and int(os.uname().release.split(".")[0]) >= 21 - ) - else "This is a description!" - ), - "value": "and a value", - }, # webkit uses the description over placeholder for the name - ], - } - assert page.accessibility.snapshot() == golden - - -def test_accessibility_should_work_with_regular_text( - page: Page, is_firefox: bool -) -> None: - page.set_content("
Hello World
") - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "role": "text leaf" if is_firefox else "text", - "name": "Hello World", - } - - -def test_accessibility_roledescription(page: Page) -> None: - page.set_content('

Hi

') - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["roledescription"] == "foo" - - -def test_accessibility_orientation(page: Page) -> None: - page.set_content('11') - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["orientation"] == "vertical" - - -def test_accessibility_autocomplete(page: Page) -> None: - page.set_content('
hi
') - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["autocomplete"] == "list" - - -def test_accessibility_multiselectable(page: Page) -> None: - page.set_content('
hey
') - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["multiselectable"] - - -def test_accessibility_keyshortcuts(page: Page) -> None: - page.set_content('
hey
') - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0]["keyshortcuts"] == "foo" - - -def test_accessibility_filtering_children_of_leaf_nodes_should_not_report_text_nodes_inside_controls( - page: Page, is_firefox: bool -) -> None: - page.set_content( - """ -
-
Tab1
-
Tab2
-
""" - ) - golden = { - "role": "document" if is_firefox else "WebArea", - "name": "", - "children": [ - {"role": "tab", "name": "Tab1", "selected": True}, - {"role": "tab", "name": "Tab2"}, - ], - } - assert page.accessibility.snapshot() == golden - - -# Firefox does not support contenteditable="plaintext-only". -# WebKit rich text accessibility is iffy -@pytest.mark.only_browser("chromium") -def test_accessibility_plain_text_field_with_role_should_not_have_children( - page: Page, -) -> None: - page.set_content( - """ -
Edit this image:my fake image
""" - ) - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "multiline": True, - "name": "", - "role": "textbox", - "value": "Edit this image:", - } - - -@pytest.mark.only_browser("chromium") -def test_accessibility_plain_text_field_without_role_should_not_have_content( - page: Page, -) -> None: - page.set_content( - """ -
Edit this image:my fake image
""" - ) - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "name": "", - "role": "generic", - "value": "Edit this image:", - } - - -@pytest.mark.only_browser("chromium") -def test_accessibility_plain_text_field_with_tabindex_and_without_role_should_not_have_content( - page: Page, -) -> None: - page.set_content( - """ -
Edit this image:my fake image
""" - ) - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == { - "name": "", - "role": "generic", - "value": "Edit this image:", - } - - -def test_accessibility_non_editable_textbox_with_role_and_tabIndex_and_label_should_not_have_children( - page: Page, is_chromium: bool, is_firefox: bool -) -> None: - page.set_content( - """ -
- this is the inner content - yo -
""" - ) - if is_firefox: - golden = { - "role": "textbox", - "name": "my favorite textbox", - "value": "this is the inner content yo", - } - elif is_chromium: - golden = { - "role": "textbox", - "name": "my favorite textbox", - "value": "this is the inner content ", - } - else: - golden = { - "role": "textbox", - "name": "my favorite textbox", - "value": "this is the inner content ", - } - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == golden - - -def test_accessibility_checkbox_with_and_tabIndex_and_label_should_not_have_children( - page: Page, -) -> None: - page.set_content( - """ -
- this is the inner content - yo -
""" - ) - golden = {"role": "checkbox", "name": "my favorite checkbox", "checked": True} - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == golden - - -def test_accessibility_checkbox_without_label_should_not_have_children( - page: Page, -) -> None: - page.set_content( - """ -
- this is the inner content - yo -
""" - ) - golden = { - "role": "checkbox", - "name": "this is the inner content yo", - "checked": True, - } - snapshot = page.accessibility.snapshot() - assert snapshot - assert snapshot["children"][0] == golden - - -def test_accessibility_should_work_a_button(page: Page) -> None: - page.set_content("") - - button = page.query_selector("button") - assert page.accessibility.snapshot(root=button) == { - "role": "button", - "name": "My Button", - } - - -def test_accessibility_should_work_an_input(page: Page) -> None: - page.set_content('') - - input = page.query_selector("input") - assert page.accessibility.snapshot(root=input) == { - "role": "textbox", - "name": "My Input", - "value": "My Value", - } - - -def test_accessibility_should_work_on_a_menu( - page: Page, is_webkit: bool, is_chromium: str, browser_channel: str -) -> None: - page.set_content( - """ -
-
First Item
-
Second Item
-
Third Item
-
- """ - ) - - menu = page.query_selector('div[role="menu"]') - golden = { - "role": "menu", - "name": "My Menu", - "children": [ - {"role": "menuitem", "name": "First Item"}, - {"role": "menuitem", "name": "Second Item"}, - {"role": "menuitem", "name": "Third Item"}, - ], - } - actual = page.accessibility.snapshot(root=menu) - assert actual - # Different per browser channel - if "orientation" in actual: - del actual["orientation"] - assert actual == golden - - -def test_accessibility_should_return_null_when_the_element_is_no_longer_in_DOM( - page: Page, -) -> None: - page.set_content("") - button = page.query_selector("button") - page.eval_on_selector("button", "button => button.remove()") - assert page.accessibility.snapshot(root=button) is None - - -def test_accessibility_should_show_uninteresting_nodes(page: Page) -> None: - page.set_content( - """ -
-
- hello -
- world -
-
-
- """ - ) - - root = page.query_selector("#root") - assert root - snapshot = page.accessibility.snapshot(root=root, interesting_only=False) - assert snapshot - assert snapshot["role"] == "textbox" - assert "hello" in snapshot["value"] - assert "world" in snapshot["value"] - assert snapshot["children"] From 2d3075502f8d6e4574ae34f71b8f77f61d14903d Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 4 Dec 2025 15:24:10 +0100 Subject: [PATCH 02/13] chore: roll 1.57.0 (#3012) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1645ef7b1..a0a51c3e4 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import zipfile from typing import Dict -driver_version = "1.57.0-beta-1763718928000" +driver_version = "1.57.0" base_wheel_bundles = [ { From 731b5395c3bd8dd26b9317ec8e7599a29cf99547 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 5 Dec 2025 17:35:17 +0100 Subject: [PATCH 03/13] chore: implement Request.service_worker (#3013) --- playwright/_impl/_network.py | 9 ++++++++- playwright/async_api/_generated.py | 18 ++++++++++++++++++ playwright/sync_api/_generated.py | 18 ++++++++++++++++++ setup.py | 2 +- tests/sync/test_network.py | 14 +++++++++++++- 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index a999ce73c..9f2c29f6e 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -66,7 +66,7 @@ from playwright._impl._browser_context import BrowserContext from playwright._impl._fetch import APIResponse from playwright._impl._frame import Frame - from playwright._impl._page import Page + from playwright._impl._page import Page, Worker class FallbackOverrideParameters(TypedDict, total=False): @@ -184,6 +184,13 @@ def url(self) -> str: def resource_type(self) -> str: return self._initializer["resourceType"] + @property + def service_worker(self) -> Optional["Worker"]: + return cast( + Optional["Worker"], + from_nullable_channel(self._initializer.get("serviceWorker")), + ) + @property def method(self) -> str: return cast(str, self._fallback_overrides.method or self._initializer["method"]) diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 79210603c..469046b21 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -113,6 +113,24 @@ def resource_type(self) -> str: """ return mapping.from_maybe_impl(self._impl_obj.resource_type) + @property + def service_worker(self) -> typing.Optional["Worker"]: + """Request.service_worker + + The Service `Worker` that is performing the request. + + **Details** + + This method is Chromium only. It's safe to call when using other browsers, but it will always be `null`. + + Requests originated in a Service Worker do not have a `request.frame()` available. + + Returns + ------- + Union[Worker, None] + """ + return mapping.from_impl_nullable(self._impl_obj.service_worker) + @property def method(self) -> str: """Request.method diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 0d892a0c9..82c7b983f 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -113,6 +113,24 @@ def resource_type(self) -> str: """ return mapping.from_maybe_impl(self._impl_obj.resource_type) + @property + def service_worker(self) -> typing.Optional["Worker"]: + """Request.service_worker + + The Service `Worker` that is performing the request. + + **Details** + + This method is Chromium only. It's safe to call when using other browsers, but it will always be `null`. + + Requests originated in a Service Worker do not have a `request.frame()` available. + + Returns + ------- + Union[Worker, None] + """ + return mapping.from_impl_nullable(self._impl_obj.service_worker) + @property def method(self) -> str: """Request.method diff --git a/setup.py b/setup.py index a0a51c3e4..fa81e5e85 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import zipfile from typing import Dict -driver_version = "1.57.0" +driver_version = "1.57.0-beta-1764944708000" base_wheel_bundles = [ { diff --git a/tests/sync/test_network.py b/tests/sync/test_network.py index 9ba91c431..8c196affb 100644 --- a/tests/sync/test_network.py +++ b/tests/sync/test_network.py @@ -92,7 +92,7 @@ def handle_request(route: Route) -> None: assert response.json() == {"foo": "bar"} -def test_should_report_if_request_was_from_service_worker( +def test_should_report_if_response_was_from_service_worker( page: Page, server: Server ) -> None: response = page.goto(server.PREFIX + "/serviceworkers/fetch/sw.html") @@ -102,3 +102,15 @@ def test_should_report_if_request_was_from_service_worker( with page.expect_response("**/example.txt") as response_info: page.evaluate("() => fetch('/example.txt')") assert response_info.value.from_service_worker + + +@pytest.mark.only_browser("chromium") +def test_should_report_service_worker_request(page: Page, server: Server) -> None: + with page.context.expect_event("serviceworker") as worker_info: + page.goto(server.PREFIX + "/serviceworkers/fetch/sw.html") + page.evaluate("() => window.activationPromise") + with page.context.expect_event( + "request", lambda r: r.service_worker is not None + ) as request_info: + page.evaluate("() => fetch('/example.txt')") + assert request_info.value.service_worker == worker_info.value From d3f5438d53dc10657ec8c5859049069b3b3b281a Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 8 Dec 2025 11:31:56 +0100 Subject: [PATCH 04/13] chore: throw FileNotFoundError for nonexistant files (#3014) --- playwright/_impl/_set_input_files_helpers.py | 4 +++- tests/sync/test_locators.py | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/playwright/_impl/_set_input_files_helpers.py b/playwright/_impl/_set_input_files_helpers.py index 0f40d5b99..65307e0a2 100644 --- a/playwright/_impl/_set_input_files_helpers.py +++ b/playwright/_impl/_set_input_files_helpers.py @@ -14,6 +14,7 @@ import base64 import collections.abc import os +import stat from pathlib import Path from typing import ( TYPE_CHECKING, @@ -144,7 +145,8 @@ def resolve_paths_and_directory_for_input_files( local_paths: Optional[List[str]] = None local_directory: Optional[str] = None for item in items: - if os.path.isdir(item): + item_stat = os.stat(item) # Raises FileNotFoundError if doesn't exist + if stat.S_ISDIR(item_stat.st_mode): if local_directory: raise Error("Multiple directories are not supported") local_directory = str(Path(item).resolve()) diff --git a/tests/sync/test_locators.py b/tests/sync/test_locators.py index b554f0544..49d4d46ae 100644 --- a/tests/sync/test_locators.py +++ b/tests/sync/test_locators.py @@ -327,6 +327,12 @@ def test_locators_should_upload_a_file(page: Page, server: Server) -> None: ) +def test_locators_upload_nonexistant_file(page: Page, server: Server) -> None: + page.goto(server.PREFIX + "/input/fileupload.html") + with pytest.raises(FileNotFoundError): + page.locator("input[type=file]").set_input_files("nonexistant.html") + + def test_locators_should_press(page: Page) -> None: page.set_content("") page.locator("input").press("h") From 47a5d35ef4f815a2021349f86ae391f7c20c02d6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 30 Jan 2026 14:39:39 +0100 Subject: [PATCH 05/13] chore: roll to 1.58.0 (#3026) Co-authored-by: Claude Opus 4.5 --- .claude/skills/playwright-roll/SKILL.md | 23 ++++++++++++++ README.md | 4 +-- playwright/_impl/_browser_type.py | 3 +- playwright/_impl/_console_message.py | 1 + playwright/async_api/_generated.py | 40 +++++++++++-------------- playwright/sync_api/_generated.py | 40 +++++++++++-------------- setup.py | 2 +- tests/async/test_expect_misc.py | 2 +- tests/async/test_worker.py | 6 ++-- tests/sync/test_expect_misc.py | 2 +- 10 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 .claude/skills/playwright-roll/SKILL.md diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md new file mode 100644 index 000000000..ece920353 --- /dev/null +++ b/.claude/skills/playwright-roll/SKILL.md @@ -0,0 +1,23 @@ +--- +name: playwright-roll +description: Roll Playwright Python to a new version +--- + +Help the user roll to a new version of Playwright. +../../../ROLLING.md contains general instructions and scripts. + +Start with updating the version and generating the API to see the state of things. + +Afterwards, work through the list of changes that need to be backported. +You can find a list of pull requests that might need to be taking into account in the issue titled "Backport changes". +Work through them one-by-one and check off the items that you have handled. +Not all of them will be relevant, some might have partially been reverted, etc. - so feel free to check with the upstream release branch. + +Rolling includes: +- updating client implementation to match changes in the upstream JS implementation (see ../playwright/packages/playwright-core/src/client) +- adding a couple of new tests to verify new/changed functionality + +## Tips & Tricks +- Project checkouts are in the parent directory (`../`). +- when updating checkboxes, store the issue content into /tmp and edit it there, then update the issue based on the file +- use the "gh" cli to interact with GitHub diff --git a/README.md b/README.md index c1797dfe0..c5e2d70b0 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 143.0.7499.4 | ✅ | ✅ | ✅ | +| Chromium 145.0.7632.6 | ✅ | ✅ | ✅ | | WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 144.0.2 | ✅ | ✅ | ✅ | +| Firefox 146.0.1 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index 93173160c..3aef7dd6d 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -82,7 +82,6 @@ async def launch( timeout: float = None, env: Env = None, headless: bool = None, - devtools: bool = None, proxy: ProxySettings = None, downloadsPath: Union[str, Path] = None, slowMo: float = None, @@ -118,7 +117,6 @@ async def launch_persistent_context( timeout: float = None, env: Env = None, headless: bool = None, - devtools: bool = None, proxy: ProxySettings = None, downloadsPath: Union[str, Path] = None, slowMo: float = None, @@ -200,6 +198,7 @@ async def connect_over_cdp( timeout: float = None, slowMo: float = None, headers: Dict[str, str] = None, + isLocal: bool = None, ) -> Browser: params = locals_to_params(locals()) if params.get("headers"): diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index 7866df2ae..f37e3dd4d 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -57,6 +57,7 @@ def type(self) -> Union[ Literal["startGroup"], Literal["startGroupCollapsed"], Literal["table"], + Literal["time"], Literal["timeEnd"], Literal["trace"], Literal["warning"], diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 469046b21..bd694886d 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -946,9 +946,12 @@ async def handle(route, request): `route.continue_()` will immediately send the request to the network, other matching handlers won't be invoked. Use `route.fallback()` If you want next matching handler in the chain to be invoked. - **NOTE** The `Cookie` header cannot be overridden using this method. If a value is provided, it will be ignored, - and the cookie will be loaded from the browser's cookie store. To set custom cookies, use - `browser_context.add_cookies()`. + **NOTE** Some request headers are **forbidden** and cannot be overridden (for example, `Cookie`, `Host`, + `Content-Length` and others, see + [this MDN page](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header) for full list). If an + override is provided for a forbidden header, it will be ignored and the original request header will be used. + + To set custom cookies, use `browser_context.add_cookies()`. Parameters ---------- @@ -7039,6 +7042,7 @@ def type( Literal["startGroup"], Literal["startGroupCollapsed"], Literal["table"], + Literal["time"], Literal["timeEnd"], Literal["trace"], Literal["warning"], @@ -7047,7 +7051,7 @@ def type( Returns ------- - Union["assert", "clear", "count", "debug", "dir", "dirxml", "endGroup", "error", "info", "log", "profile", "profileEnd", "startGroup", "startGroupCollapsed", "table", "timeEnd", "trace", "warning"] + Union["assert", "clear", "count", "debug", "dir", "dirxml", "endGroup", "error", "info", "log", "profile", "profileEnd", "startGroup", "startGroupCollapsed", "table", "time", "timeEnd", "trace", "warning"] """ return mapping.from_maybe_impl(self._impl_obj.type) @@ -14440,7 +14444,6 @@ async def launch( timeout: typing.Optional[float] = None, env: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, headless: typing.Optional[bool] = None, - devtools: typing.Optional[bool] = None, proxy: typing.Optional[ProxySettings] = None, downloads_path: typing.Optional[typing.Union[pathlib.Path, str]] = None, slow_mo: typing.Optional[float] = None, @@ -14514,12 +14517,7 @@ async def launch( headless : Union[bool, None] Whether to run browser in headless mode. More details for [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and - [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true` unless the - `devtools` option is `true`. - devtools : Union[bool, None] - **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the - `headless` option will be set `false`. - Deprecated: Use [debugging tools](../debug.md) instead. + [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true`. proxy : Union[{server: str, bypass: Union[str, None], username: Union[str, None], password: Union[str, None]}, None] Network proxy settings. downloads_path : Union[pathlib.Path, str, None] @@ -14557,7 +14555,6 @@ async def launch( timeout=timeout, env=mapping.to_impl(env), headless=headless, - devtools=devtools, proxy=proxy, downloadsPath=downloads_path, slowMo=slow_mo, @@ -14583,7 +14580,6 @@ async def launch_persistent_context( timeout: typing.Optional[float] = None, env: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, headless: typing.Optional[bool] = None, - devtools: typing.Optional[bool] = None, proxy: typing.Optional[ProxySettings] = None, downloads_path: typing.Optional[typing.Union[pathlib.Path, str]] = None, slow_mo: typing.Optional[float] = None, @@ -14691,12 +14687,7 @@ async def launch_persistent_context( headless : Union[bool, None] Whether to run browser in headless mode. More details for [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and - [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true` unless the - `devtools` option is `true`. - devtools : Union[bool, None] - **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the - `headless` option will be set `false`. - Deprecated: Use [debugging tools](../debug.md) instead. + [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true`. proxy : Union[{server: str, bypass: Union[str, None], username: Union[str, None], password: Union[str, None]}, None] Network proxy settings. downloads_path : Union[pathlib.Path, str, None] @@ -14858,7 +14849,6 @@ async def launch_persistent_context( timeout=timeout, env=mapping.to_impl(env), headless=headless, - devtools=devtools, proxy=proxy, downloadsPath=downloads_path, slowMo=slow_mo, @@ -14908,6 +14898,7 @@ async def connect_over_cdp( timeout: typing.Optional[float] = None, slow_mo: typing.Optional[float] = None, headers: typing.Optional[typing.Dict[str, str]] = None, + is_local: typing.Optional[bool] = None, ) -> "Browser": """BrowserType.connect_over_cdp @@ -14942,6 +14933,9 @@ async def connect_over_cdp( on. Defaults to 0. headers : Union[Dict[str, str], None] Additional HTTP headers to be sent with connect request. Optional. + is_local : Union[bool, None] + Tells Playwright that it runs on the same host as the CDP server. It will enable certain optimizations that rely + upon the file system being the same between Playwright and the Browser. Returns ------- @@ -14954,6 +14948,7 @@ async def connect_over_cdp( timeout=timeout, slowMo=slow_mo, headers=mapping.to_impl(headers), + isLocal=is_local, ) ) @@ -15397,8 +15392,7 @@ def description(self) -> typing.Optional[str]: """Locator.description Returns locator description previously set with `locator.describe()`. Returns `null` if no custom - description has been set. Prefer `Locator.toString()` for a human-readable representation, as it uses the - description when available. + description has been set. **Usage** @@ -19176,7 +19170,7 @@ async def to_contain_text( from playwright.async_api import expect # ✓ Contains the right items in the right order - await expect(page.locator(\"ul > li\")).to_contain_text([\"Text 1\", \"Text 3\", \"Text 4\"]) + await expect(page.locator(\"ul > li\")).to_contain_text([\"Text 1\", \"Text 3\"]) # ✖ Wrong order await expect(page.locator(\"ul > li\")).to_contain_text([\"Text 3\", \"Text 2\"]) diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 82c7b983f..0df2087df 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -960,9 +960,12 @@ def handle(route, request): `route.continue_()` will immediately send the request to the network, other matching handlers won't be invoked. Use `route.fallback()` If you want next matching handler in the chain to be invoked. - **NOTE** The `Cookie` header cannot be overridden using this method. If a value is provided, it will be ignored, - and the cookie will be loaded from the browser's cookie store. To set custom cookies, use - `browser_context.add_cookies()`. + **NOTE** Some request headers are **forbidden** and cannot be overridden (for example, `Cookie`, `Host`, + `Content-Length` and others, see + [this MDN page](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header) for full list). If an + override is provided for a forbidden header, it will be ignored and the original request header will be used. + + To set custom cookies, use `browser_context.add_cookies()`. Parameters ---------- @@ -7129,6 +7132,7 @@ def type( Literal["startGroup"], Literal["startGroupCollapsed"], Literal["table"], + Literal["time"], Literal["timeEnd"], Literal["trace"], Literal["warning"], @@ -7137,7 +7141,7 @@ def type( Returns ------- - Union["assert", "clear", "count", "debug", "dir", "dirxml", "endGroup", "error", "info", "log", "profile", "profileEnd", "startGroup", "startGroupCollapsed", "table", "timeEnd", "trace", "warning"] + Union["assert", "clear", "count", "debug", "dir", "dirxml", "endGroup", "error", "info", "log", "profile", "profileEnd", "startGroup", "startGroupCollapsed", "table", "time", "timeEnd", "trace", "warning"] """ return mapping.from_maybe_impl(self._impl_obj.type) @@ -14459,7 +14463,6 @@ def launch( timeout: typing.Optional[float] = None, env: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, headless: typing.Optional[bool] = None, - devtools: typing.Optional[bool] = None, proxy: typing.Optional[ProxySettings] = None, downloads_path: typing.Optional[typing.Union[pathlib.Path, str]] = None, slow_mo: typing.Optional[float] = None, @@ -14533,12 +14536,7 @@ def launch( headless : Union[bool, None] Whether to run browser in headless mode. More details for [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and - [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true` unless the - `devtools` option is `true`. - devtools : Union[bool, None] - **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the - `headless` option will be set `false`. - Deprecated: Use [debugging tools](../debug.md) instead. + [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true`. proxy : Union[{server: str, bypass: Union[str, None], username: Union[str, None], password: Union[str, None]}, None] Network proxy settings. downloads_path : Union[pathlib.Path, str, None] @@ -14577,7 +14575,6 @@ def launch( timeout=timeout, env=mapping.to_impl(env), headless=headless, - devtools=devtools, proxy=proxy, downloadsPath=downloads_path, slowMo=slow_mo, @@ -14604,7 +14601,6 @@ def launch_persistent_context( timeout: typing.Optional[float] = None, env: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, headless: typing.Optional[bool] = None, - devtools: typing.Optional[bool] = None, proxy: typing.Optional[ProxySettings] = None, downloads_path: typing.Optional[typing.Union[pathlib.Path, str]] = None, slow_mo: typing.Optional[float] = None, @@ -14712,12 +14708,7 @@ def launch_persistent_context( headless : Union[bool, None] Whether to run browser in headless mode. More details for [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and - [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true` unless the - `devtools` option is `true`. - devtools : Union[bool, None] - **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the - `headless` option will be set `false`. - Deprecated: Use [debugging tools](../debug.md) instead. + [Firefox](https://hacks.mozilla.org/2017/12/using-headless-mode-in-firefox/). Defaults to `true`. proxy : Union[{server: str, bypass: Union[str, None], username: Union[str, None], password: Union[str, None]}, None] Network proxy settings. downloads_path : Union[pathlib.Path, str, None] @@ -14880,7 +14871,6 @@ def launch_persistent_context( timeout=timeout, env=mapping.to_impl(env), headless=headless, - devtools=devtools, proxy=proxy, downloadsPath=downloads_path, slowMo=slow_mo, @@ -14931,6 +14921,7 @@ def connect_over_cdp( timeout: typing.Optional[float] = None, slow_mo: typing.Optional[float] = None, headers: typing.Optional[typing.Dict[str, str]] = None, + is_local: typing.Optional[bool] = None, ) -> "Browser": """BrowserType.connect_over_cdp @@ -14965,6 +14956,9 @@ def connect_over_cdp( on. Defaults to 0. headers : Union[Dict[str, str], None] Additional HTTP headers to be sent with connect request. Optional. + is_local : Union[bool, None] + Tells Playwright that it runs on the same host as the CDP server. It will enable certain optimizations that rely + upon the file system being the same between Playwright and the Browser. Returns ------- @@ -14978,6 +14972,7 @@ def connect_over_cdp( timeout=timeout, slowMo=slow_mo, headers=mapping.to_impl(headers), + isLocal=is_local, ) ) ) @@ -15423,8 +15418,7 @@ def description(self) -> typing.Optional[str]: """Locator.description Returns locator description previously set with `locator.describe()`. Returns `null` if no custom - description has been set. Prefer `Locator.toString()` for a human-readable representation, as it uses the - description when available. + description has been set. **Usage** @@ -19289,7 +19283,7 @@ def to_contain_text( from playwright.sync_api import expect # ✓ Contains the right items in the right order - expect(page.locator(\"ul > li\")).to_contain_text([\"Text 1\", \"Text 3\", \"Text 4\"]) + expect(page.locator(\"ul > li\")).to_contain_text([\"Text 1\", \"Text 3\"]) # ✖ Wrong order expect(page.locator(\"ul > li\")).to_contain_text([\"Text 3\", \"Text 2\"]) diff --git a/setup.py b/setup.py index fa81e5e85..5a8229db1 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import zipfile from typing import Dict -driver_version = "1.57.0-beta-1764944708000" +driver_version = "1.58.0" base_wheel_bundles = [ { diff --git a/tests/async/test_expect_misc.py b/tests/async/test_expect_misc.py index 9c6a8aa01..2148b0d9e 100644 --- a/tests/async/test_expect_misc.py +++ b/tests/async/test_expect_misc.py @@ -58,7 +58,7 @@ async def test_to_be_in_viewport_should_have_good_stack( page: Page, server: Server ) -> None: with pytest.raises(AssertionError) as exc_info: - await expect(page.locator("body")).not_to_be_in_viewport(timeout=100) + await expect(page.locator("body")).not_to_be_in_viewport(timeout=1000) assert 'unexpected value "viewport ratio' in str(exc_info.value) diff --git a/tests/async/test_worker.py b/tests/async/test_worker.py index cd6828053..4c2e4ebeb 100644 --- a/tests/async/test_worker.py +++ b/tests/async/test_worker.py @@ -189,7 +189,7 @@ async def test_workers_should_report_network_activity_on_worker_creation( async def test_workers_should_format_number_using_context_locale( - browser: Browser, server: Server + browser: Browser, server: Server, browser_name: str ) -> None: context = await browser.new_context(locale="ru-RU") page = await context.new_page() @@ -199,7 +199,9 @@ async def test_workers_should_format_number_using_context_locale( "() => new Worker(URL.createObjectURL(new Blob(['console.log(1)'], {type: 'application/javascript'})))" ) worker = await worker_info.value - assert await worker.evaluate("() => (10000.20).toLocaleString()") == "10\u00a0000,2" + # https://github.com/microsoft/playwright/issues/38919 + expected = "10,000.2" if browser_name == "firefox" else "10\u00a0000,2" + assert await worker.evaluate("() => (10000.20).toLocaleString()") == expected await context.close() diff --git a/tests/sync/test_expect_misc.py b/tests/sync/test_expect_misc.py index 042929fde..4d8b68cc0 100644 --- a/tests/sync/test_expect_misc.py +++ b/tests/sync/test_expect_misc.py @@ -56,7 +56,7 @@ def test_to_be_in_viewport_should_respect_ratio_option( def test_to_be_in_viewport_should_have_good_stack(page: Page, server: Server) -> None: with pytest.raises(AssertionError) as exc_info: - expect(page.locator("body")).not_to_be_in_viewport(timeout=100) + expect(page.locator("body")).not_to_be_in_viewport(timeout=1000) assert 'unexpected value "viewport ratio' in str(exc_info.value) From 3aecfcfded4239d157bb3e86cb682f6c8ecf6827 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 11 Feb 2026 13:58:01 +0100 Subject: [PATCH 06/13] fix: context.request timeout (#3032) --- playwright/_impl/_browser_context.py | 1 + tests/sync/test_page_request_timeout.py | 36 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/sync/test_page_request_timeout.py diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index f56564a27..e27a9437a 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -124,6 +124,7 @@ def __init__( self._tracing = cast(Tracing, from_channel(initializer["tracing"])) self._har_recorders: Dict[str, HarRecordingMetadata] = {} self._request: APIRequestContext = from_channel(initializer["requestContext"]) + self._request._timeout_settings = self._timeout_settings self._clock = Clock(self) self._channel.on( "bindingCall", diff --git a/tests/sync/test_page_request_timeout.py b/tests/sync/test_page_request_timeout.py new file mode 100644 index 000000000..8b65da26d --- /dev/null +++ b/tests/sync/test_page_request_timeout.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from playwright.sync_api import BrowserContext, Error, Page +from tests.server import Server + + +def test_context_request_should_support_timeout_option( + page: Page, context: BrowserContext, server: Server +) -> None: + # https://github.com/microsoft/playwright/issues/39220 + + server.set_route("/", lambda req: None) + with pytest.raises(Error, match="Timeout 123ms exceeded"): + page.request.get(server.PREFIX, timeout=123) + with pytest.raises(Error, match="Timeout 123ms exceeded"): + context.request.get(server.PREFIX, timeout=123) + + context.set_default_timeout(123) + with pytest.raises(Error, match="Timeout 123ms exceeded"): + page.request.get(server.PREFIX) + with pytest.raises(Error, match="Timeout 123ms exceeded"): + context.request.get(server.PREFIX) From 581316cb385f285dc3b07728982b5e730c0e2cca Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 28 Apr 2026 18:39:56 -0700 Subject: [PATCH 07/13] chore: roll to 1.59.1 (#3050) Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/skills/playwright-roll/SKILL.md | 292 +++++++- .github/workflows/ci.yml | 11 +- .github/workflows/publish.yml | 4 +- CLAUDE.md | 57 ++ README.md | 6 +- playwright/_impl/_api_structures.py | 19 + playwright/_impl/_browser.py | 15 + playwright/_impl/_browser_context.py | 18 + playwright/_impl/_browser_type.py | 8 +- playwright/_impl/_cdp_session.py | 8 + playwright/_impl/_console_message.py | 4 + playwright/_impl/_debugger.py | 54 ++ playwright/_impl/_dialog.py | 14 +- playwright/_impl/_local_utils.py | 4 +- playwright/_impl/_locator.py | 15 +- playwright/_impl/_network.py | 12 + playwright/_impl/_object_factory.py | 3 + playwright/_impl/_page.py | 73 +- playwright/_impl/_screencast.py | 130 ++++ playwright/_impl/_tracing.py | 16 +- playwright/_impl/_video.py | 41 +- playwright/async_api/__init__.py | 8 + playwright/async_api/_generated.py | 700 +++++++++++++++++- playwright/sync_api/__init__.py | 8 + playwright/sync_api/_generated.py | 658 +++++++++++++++- scripts/documentation_provider.py | 32 +- scripts/generate_api.py | 8 +- setup.py | 2 +- tests/async/test_browser.py | 14 + tests/async/test_browsercontext.py | 7 + .../test_browsercontext_storage_state.py | 26 + tests/async/test_click.py | 28 +- tests/async/test_debugger.py | 43 ++ tests/async/test_dialog.py | 12 + tests/async/test_locators.py | 6 + tests/async/test_navigation.py | 4 +- tests/async/test_page_aria_snapshot.py | 17 + tests/async/test_page_event_console.py | 32 +- tests/async/test_page_event_pageerror.py | 24 + tests/async/test_page_network_request.py | 21 + tests/async/test_page_network_response.py | 7 + tests/async/test_popup.py | 6 + tests/async/test_route_web_socket.py | 18 +- tests/async/test_screencast.py | 72 ++ tests/conftest.py | 20 + tests/sync/test_debugger.py | 20 + tests/sync/test_page_aria_snapshot.py | 17 + tests/sync/test_page_event_console.py | 13 + tests/sync/test_page_network_response.py | 6 + tests/sync/test_route_web_socket.py | 18 +- tests/sync/test_screencast.py | 53 ++ 51 files changed, 2506 insertions(+), 198 deletions(-) create mode 100644 CLAUDE.md create mode 100644 playwright/_impl/_debugger.py create mode 100644 playwright/_impl/_screencast.py create mode 100644 tests/async/test_debugger.py create mode 100644 tests/async/test_screencast.py create mode 100644 tests/sync/test_debugger.py create mode 100644 tests/sync/test_screencast.py diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md index ece920353..9a6adbd03 100644 --- a/.claude/skills/playwright-roll/SKILL.md +++ b/.claude/skills/playwright-roll/SKILL.md @@ -1,23 +1,285 @@ --- name: playwright-roll -description: Roll Playwright Python to a new version +description: Roll Playwright Python to a new driver version. Walks the upstream `docs/src/api/` commit range, ports each public-API change, suppresses the rest in `expected_api_mismatch.txt`, regenerates the typed surface, and adds tests. --- -Help the user roll to a new version of Playwright. -../../../ROLLING.md contains general instructions and scripts. +# Rolling Playwright Python -Start with updating the version and generating the API to see the state of things. +The goal of a roll is to move `driver_version` in `setup.py` to a new release, port every public API change introduced upstream during that interval, and suppress the rest, so that `./scripts/update_api.sh` runs clean and the test suite still passes. -Afterwards, work through the list of changes that need to be backported. -You can find a list of pull requests that might need to be taking into account in the issue titled "Backport changes". -Work through them one-by-one and check off the items that you have handled. -Not all of them will be relevant, some might have partially been reverted, etc. - so feel free to check with the upstream release branch. +The previous human-facing summary lives in `../../../ROLLING.md`. This skill is the operational playbook — read it end to end before starting. -Rolling includes: -- updating client implementation to match changes in the upstream JS implementation (see ../playwright/packages/playwright-core/src/client) -- adding a couple of new tests to verify new/changed functionality +## Mental model -## Tips & Tricks -- Project checkouts are in the parent directory (`../`). -- when updating checkboxes, store the issue content into /tmp and edit it there, then update the issue based on the file -- use the "gh" cli to interact with GitHub +The Python port is hand-written code in `playwright/_impl/`, plus a generator (`scripts/generate_*.py`, `scripts/documentation_provider.py`) that: + +1. introspects the Python `_impl` classes via `inspect`, +2. emits typed wrapper classes into `playwright/{async,sync}_api/_generated.py`, and +3. diffs the introspected surface against `playwright/driver/package/api.json` (downloaded inside the new driver wheel). + +Anything in `api.json` that is missing or differently typed in `_impl/` causes generation to fail. Three resolutions: + +- **PORT** — the new API is intended for Python (no `langs.only` filter, or `langs.only` includes `"python"`). Implement it in `_impl/`. +- **MISMATCH** — the API genuinely exists for Python but is shaped differently (a callback signature uses unions, a kwarg uses a legacy name, etc.) and there's a justified reason to keep the divergence. Add a precise line to `scripts/expected_api_mismatch.txt` with a comment explaining *why*. +- **N/A** — the commit only touches docs, has `* langs: js` (or any other filter that excludes Python), is server-side, Electron-only, or was reverted later in the same release. No action. + +The upstream documentation source of truth is `docs/src/api/*.md` in the playwright repo. Every `## method:` / `## property:` / `## event:` / `### option:` / `### param:` block has an optional `* langs: js` (or `js, python`, etc.) filter. The Python doclint resolves these into `langs` fields on each member of `api.json`. **An empty `langs: {}` means "all languages including Python" — *implement it*, don't suppress it.** + +> **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 + +`CONTRIBUTING.md` covers this. Notes from past rolls: + +- The repo says Python 3.9 is required, but 3.9+ works. If `python3.9` isn't available, use `python3` (3.12 is fine). +- If `python3-venv` is missing system-wide, use `uv venv env` instead, then `uv pip install --python env/bin/python --upgrade pip`. Don't try to `apt install` — sudo is denied in the harness. +- Always activate the venv before any `pip`, `pytest`, `mypy`, or `pre-commit` invocation. + +### 2. Bump the driver and download it + +```sh +# Edit setup.py +driver_version = "" # e.g. "1.59.1" + +source env/bin/activate +python -m build --wheel # downloads the new driver from cdn.playwright.dev +playwright install chromium # NOT --with-deps; sudo is denied +``` + +The wheel build prints `Fetching https://cdn.playwright.dev/builds/driver/playwright--linux.zip` and unpacks the driver under `playwright/driver/package/`. From this point, `playwright/driver/package/api.json` reflects the new release. + +### 3. Identify the commit range + +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 + ``` +- **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 +``` + +A normal roll yields 50–100 commits. If you see 0 or thousands, the range is wrong. + +Format the file as a markdown checklist and add the standard preamble (status legend, where to look up `api.json` etc.) — see the file from the 1.58→1.59 roll for the template. + +### 4. Walk the commit list + +For each commit, in chronological order: + +```sh +git -C ~/code/playwright show -- docs/src/api/ +``` + +Look for: +- `## (async )?method:` / `## property:` / `## event:` additions or removals; +- `* langs: ...` lines on those blocks; +- `### param:` / `### option:` additions or removals; +- new `class-X.md` files (whole new classes — usually `langs: js`); +- type changes in `- returns:` lines. + +Classify and act. + +#### Verifying classifications (do this before suppressing anything) + +Before tagging anything as MISMATCH or N/A based on appearance, dump the actual `langs` from `api.json`: + +```python +import json +data = json.load(open("playwright/driver/package/api.json")) +classes = {c["name"]: c for c in data} +for cls_name in ["Page", "BrowserContext", "Screencast", "Debugger"]: + cls = classes.get(cls_name) + if not cls: + continue + print(f"\n{cls_name}: cls_langs={cls.get('langs', {})}") + for m in cls["members"]: + print(f" {m['name']} kind={m.get('kind')} langs={m.get('langs', {})}") +``` + +For options/params nested inside an `Object`-typed arg, walk one level deeper: + +```python +for a in member.get("args", []): + if a["name"] == "options": + for prop in a.get("type", {}).get("properties", []): + print(prop["name"], prop.get("langs", {})) +``` + +A few rules of thumb that catch most "actually a PORT" cases: +- If the *containing class* has empty `langs: {}` and the *member* has empty `langs: {}`, it's for Python — implement it. +- If the member is empty but a single *option* has `langs: js`, the method is for Python and you only skip that option (e.g. `Screencast.start.size` is `langs: js` while `Screencast.start` itself isn't). +- If you're about to add three or more `Method not implemented:` entries for the same class, stop — you almost certainly need to implement the class. + +#### 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: + +| Upstream JS | Python | +|---|---| +| `async foo(): Promise` | `async def foo(self) -> X:` | +| `foo(): X` (sync getter, no args, no body) | `@property def foo(self) -> X:` (the doc generator treats argument-less sync getters as properties — see `documentation_provider.py:133`. If you make it a method instead, you'll get a "Method vs property mismatch" error.) | +| `await this._channel.foo({ a, b })` | `await self._channel.send("foo", None, locals_to_params(locals()))` | +| `(await this._channel.foo()).value` | `await self._channel.send("foo", None)` (Python's `send()` auto-unwraps single-key responses; only call `send_return_as_dict` when the protocol returns multiple keys.) | +| `(await this._channel.foo()).artifact` (multi-key, may be empty) | `result = await self._channel.send_return_as_dict("foo", None); (result or {}).get("artifact")` — `send_return_as_dict` returns **`None`** (not `{}`) when the protocol response carries no fields. | +| `try { ... } catch (e) { if (isTargetClosedError(e)) return; throw e; }` | `try: ...; except Exception as e: if is_target_closed_error(e): return; raise` (import from `playwright._impl._errors`) | +| Inline `[Object]` return like `{endpoint: string}` | A `TypedDict` in `playwright/_impl/_api_structures.py` — *not* `Dict[str, str]`. The doc generator serializes TypedDicts as `{field: type, ...}` via `get_type_hints` and that matches the inline-object form exactly. See `RemoteAddr`, `BrowserBindResult`, `DebuggerPausedDetails`. | +| `binary` event/return field | The Python channel layer hands you a base64 string. Decode with `base64.b64decode(value)` before exposing as `bytes`. See `Screencast._dispatch_frame`. | + +When implementing a new ChannelOwner subclass (one constructed by the protocol with `(parent, type, guid, initializer)`): +1. Register it in `playwright/_impl/_object_factory.py:create_remote_object` — otherwise the guid resolves to `DummyObject` and downstream code breaks mysteriously. +2. Import it and add it to `generated_types` in `scripts/generate_api.py`, plus add a `XxxImpl` import in the `header` string. + +When implementing a non-ChannelOwner wrapper class (a plain class that holds a Page/Context reference, like `Screencast`, `Clock`): +- Set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber` in `__init__`. The generated `AsyncBase`/`SyncBase` wrappers read these; missing them gives `AttributeError: 'X' object has no attribute '_loop'` at first use. + +When adding a new TypedDict in `_api_structures.py`: +- Add it to the `from playwright._impl._api_structures import …` line in `scripts/generate_api.py` so the generator can resolve it as a forward reference in type hints. +- Re-export it from both `playwright/async_api/__init__.py` and `playwright/sync_api/__init__.py`: assignment line plus an entry in `__all__`. Same pattern as `ViewportSize`, `RemoteAddr`. + +If the new API was previously suppressed in `expected_api_mismatch.txt`, **remove that line** when implementing it. + +If a doc rename involves a *positional* parameter (no default, before any `*`), users almost certainly call it positionally — you can rename freely. The 1.59 `BrowserType.connect.wsEndpoint` → `endpoint` is the canonical example. Don't suppress this kind of rename; just rename in `_impl/`. **Important corollary:** when docs rename a param, the wire-protocol field usually also changed in `packages/protocol/src/protocol.yml` and the server-side dispatcher in `packages/playwright-core/src/server/dispatchers/*Dispatcher.ts`. If so, you must also update the channel-send dict key (e.g. `{"wsEndpoint": …}` → `{"endpoint": …}`). A "Parameter not documented" suppression for a renamed param is a code smell hiding a wire-protocol bug. + +#### MISMATCH + +A MISMATCH is a *justified, durable* divergence between the docs and the Python surface. Use it sparingly — most apparent mismatches turn out to be PORTs you skipped. Legitimate examples in the current `expected_api_mismatch.txt`: + +- Hidden internal kwargs (`Browser.new_context(default_browser_type=)`). +- Callback signatures where Python explicitly unions one-arg and two-arg variants but the docs document only the canonical form (`Page.route(handler=)`, `WebSocketRoute.on_close(handler=)`). + +Add a precise line to `scripts/expected_api_mismatch.txt` with a `# comment` group header explaining *why* the divergence is intentional. The exact wording comes from the generator's error message. Examples: + +``` +# One vs two arguments in the callback, Python explicitly unions. +Parameter type mismatch in Page.route(handler=): documented as Callable[[Route, Request], Union[Any, Any]], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any]] +``` + +The generator removes lines from `expected_api_mismatch.txt` that no longer match an error. If you see "No longer there: …" in the script's stderr, delete that line. + +**Do not suppress** these — they're PORTs in disguise: + +- "Internal tooling" classes/methods whose `langs` field is empty (`Screencast.*`, `Debugger.*`, `Page.pick_locator`, `BrowserContext.debugger`, `Browser.bind/unbind`, `Page.{clear,console}_*`). The 1.59 roll suppressed all of these initially, then had to walk every one back. Verify `langs` first. +- A renamed positional parameter (`Parameter not documented: X.y(old_name=)` + `Parameter not implemented: X.y(new_name=)`). Just rename in `_impl/` and update the channel-send dict key. +- A `Parameter type mismatch in X.y(return=): documented as {field: T}, code has Dict[str, T]`. Use a TypedDict. + +#### N/A + +Common N/A flavors: +- Whole new class with `langs: js` (Disposable, Inspector/Screencast, Debugger, Overlay). +- Members with `langs: js` (most "tooling" / MCP / agentic features). +- Doc-only edits (typo fixes, "Improve `not` property sections", etc.). +- Reverts that cancel an earlier add in the same range (always check the rest of the range before porting something that gets reverted). +- Java/C# `langs:` blocks. +- Electron-only changes (`docs/src/api/class-electron.md`). + +Tick the box in `/tmp/roll--commits.md` with one line: `[x] : `. + +### 5. Regenerate + +```sh +./scripts/update_api.sh +``` + +The script does, in order: +1. `git checkout HEAD -- playwright/{async,sync}_api/_generated.py` (resets to last committed), +2. runs `scripts/generate_{sync,async}_api.py` which dumps to `.x` then renames into place, +3. invokes `pre-commit run --files` on the generated files. + +Failure modes and fixes: + +| Symptom | Cause | Fix | +|---|---|---| +| `Method not implemented: X.y` | `api.json` documents `X.y`, no Python impl exists. | PORT it, or add a MISMATCH line. | +| `Parameter not implemented: X.y(z=)` | New parameter on existing method. | Add the kwarg in `_impl/`, or MISMATCH. | +| `Method vs property mismatch: X.y` | You implemented as a method but the doc treats it as a property (sync, no args, has return type). | Add `@property` in `_impl/`. | +| `Method not documented: X.y` | Python has it but `api.json` doesn't. | The upstream removed the API; remove from `_impl/` and from `_generated.py` callers. | +| `Parameter type mismatch in X.y(z=): documented as ..., code has ...` | Type signature doesn't line up. | Match the type in `_impl/`, or MISMATCH it for known historical divergences. | +| `pyright … reportInconsistentOverload` (single-event class) | A class gained its first event in `api.json`, so the generator emits one `Literal[…]` overload + impl, which pyright wants ≥2 of. | The generator already handles this — `documentation_provider.print_events` emits a second `@typing.overload` with `event: str` plus a generic impl. If you regress this, see how 1.59 handled `CDPSession` getting a `close` event. | +| `pre-commit` keeps reformatting `_generated.py` on each run | First run after regen always reformats once; rerun until idle. | `pre-commit run --all-files` to settle. | +| `Parameter not documented: X.y(z=)` | Python has a kwarg the docs don't mention (e.g. legacy name from a doc rename). | If the param is positional with no default, just rename it in `_impl/`. Check `protocol.yml` and the server dispatcher — if the wire field renamed too, also update the channel-send dict key. Only MISMATCH for genuinely hidden internal kwargs (`default_browser_type`). | +| `KeyError: 'templates'` deep in `inner_serialize_doc_type` | A `Promise\|X` union upstream collapsed to a bare `Promise` with no templates in `api.json`. | `documentation_provider.inner_serialize_doc_type` should treat that as `Any` (`if "templates" not in type: return "Any"`). | +| `"void" is not defined (reportUndefinedVariable)` in generated event handlers | `api.json` has a `void`-typed event payload that the serializer left as the literal string `"void"`. | `documentation_provider.inner_serialize_doc_type` should map `"void"` to `"None"` alongside `"null"`. | +| `AttributeError: 'X' object has no attribute '_loop'` (or `_dispatcher_fiber`) at first use of a new wrapper class | The non-ChannelOwner wrapper isn't initializing the fields the generated `AsyncBase`/`SyncBase` reads. | In the wrapper's `__init__`, set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber`. | +| `'NoneType' object has no attribute 'get'` after `send_return_as_dict` | Method's protocol response carries no fields and `send_return_as_dict` returned `None`. | `(result or {}).get(...)`. | +| Frame/buffer payload arrives as a `str` instead of `bytes` | Protocol `binary` fields cross the wire as base64. | `base64.b64decode(value)` in the impl before exposing. | + +After the script settles, run `pre-commit run --all-files` once more to confirm everything is idle. + +### 6. Add tests + +For each PORT, add one async test and a matching sync test. Conventions: + +- Tests go in the existing topic file (`test_page_network_request.py`, `test_browsercontext.py`, `test_dialog.py`, …) — don't create new files unless there's no obvious home. +- Use `from playwright.async_api import …`, **not** `from playwright._impl._page import Page` (the impl class doesn't have the public wrappers like `expect_console_message`). +- For event-info objects, `await message_info.value` (it's an `async` property). +- Don't write tests that hang the page (e.g. `page.evaluate(... fetch slow ...)` followed by `page.close()` from a fixture) — the request task gets a `TargetClosedError`. Use `page.on("event", handler)` to capture state at event time instead. +- `playwright install chromium` (no `--with-deps`) is sufficient for the test suite under sandbox. + +### 7. Update existing high-touch artifacts + +- `setup.py`: already done in step 2. +- `README.md`: gets the chromium/firefox/webkit version table updated automatically by `scripts/update_versions.py` (called from `update_api.sh`). Don't edit by hand. +- The "Backport changes" tracking issue on GitHub (filed by `microsoft-playwright-automation`) is the *intent* tracker, but it's frequently out of sync with what's actually been ported. Treat it as a starting point, not the source of truth — the `docs/src/api/` commit walk is authoritative. + +### 8. Final verification + +```sh +pre-commit run --all-files +mypy playwright # 2 pre-existing errors in _json_pipe.py and _artifact.py are unrelated +pytest --browser chromium tests/async/ tests/sync/ +``` + +Then a smoke regression on a few neighboring suites (`tests/async/test_browser*.py`, `test_cdp_session.py`, `test_tracing.py`, `test_dialog.py`, `test_page_*.py`) to make sure nothing inherent to the port shifted. + +## Reference: `expected_api_mismatch.txt` line forms + +Exact strings the generator emits (and that this file must contain to suppress): + +``` +Method not implemented: . +Parameter not implemented: .(=) +Parameter not documented: .(=) +Method vs property mismatch: . +Method not documented: . +Parameter type mismatch in .(=): documented as , code has +``` + +Class names use the upstream PascalCase (`BrowserContext`, `BrowserType`); method/param names are converted to `snake_case` matching the Python surface. + +## Tips & gotchas + +- **`langs.only` is your filter — and the only filter.** Don't classify by name (`Screencast`, `Debugger`, `pickLocator`) or by intuition ("looks like internal tooling"). Always check `langs` in `api.json`. The 1.59 roll cost two extra audit passes by trusting names over `langs`. +- **Audit your own classifications a second time.** After the first walk through the commit range, before opening the PR, re-read every line in `expected_api_mismatch.txt` you added during this roll and ask "is this divergence justified, or did I skip a port?" Run the `langs`-dump snippet on each suspicious entry. The 1.59 roll's first PR had ~20 wrong suppressions; the second pass cut them to 0. +- **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. +- **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. +- **`api.json` may carry doclint quirks.** 1.59 hit two: `Promise|X` collapsed to a bare `Promise` with no `templates`, and `void`-typed events serialized as the literal string `"void"`. Both are upstream artifacts; patch `inner_serialize_doc_type` to handle them rather than fighting the api.json side. +- **Don't edit `_generated.py` to fix lint or typing.** Fix `_impl/`, `documentation_provider.py`, or `expected_api_mismatch.txt` instead. Hand-editing the generated file is reverted on the next regen. +- **`/tmp/roll--commits.md` is a working artifact, not a deliverable.** Don't commit it. The commit message and PR description are where the audit summary belongs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65ba1f433..ecb593832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,13 @@ jobs: matrix: os: [ubuntu-22.04, macos-13, windows-2022] runs-on: ${{ matrix.os }} + defaults: + run: + # `setup-miniconda` activates the env only for login shells; using + # `bash -el` (recommended by the action) ensures `conda` and the + # installed `conda-build` are on PATH on every OS, including Windows + # where the default shell is pwsh and skips the activation hooks. + shell: bash -el {0} steps: - uses: actions/checkout@v5 with: @@ -169,11 +176,11 @@ jobs: - name: Get conda uses: conda-incubator/setup-miniconda@v3 with: - python-version: 3.9 + python-version: '3.12' channels: conda-forge miniconda-version: latest - name: Prepare - run: conda install conda-build conda-verify + run: conda install -n base "conda-build>=26" conda-verify - name: Build run: conda build . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c6e71028a..e9a7048c5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,11 +31,11 @@ jobs: - name: Get conda uses: conda-incubator/setup-miniconda@v3 with: - python-version: 3.9 + python-version: '3.12' channels: conda-forge miniconda-version: latest - name: Prepare - run: conda install anaconda-client conda-build conda-verify + run: conda install -n base anaconda-client "conda-build>=26" conda-verify - name: Build and Upload env: ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ce4ec7c07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +Guidance for Claude when working in this repository. + +## What this is + +Python bindings for [Playwright](https://playwright.dev). The Python client talks JSON over a pipe to the Node-based driver bundled in `playwright/driver/`. The pipe protocol is defined upstream in `packages/protocol/src/protocol.yml`. + +## Layout + +- `playwright/_impl/` — hand-written client implementation (one module per object: `_browser.py`, `_page.py`, `_locator.py`, `_network.py`, etc.). Edit these to add or change behavior. +- `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py` — **auto-generated**. Never edit by hand; rerun `./scripts/update_api.sh` after changing `_impl/` or the driver. +- `scripts/generate_api.py`, `scripts/generate_async_api.py`, `scripts/generate_sync_api.py`, `scripts/documentation_provider.py` — codegen and validation. They diff the Python implementation against the driver's `playwright/driver/package/api.json` and abort if either side is out of sync. +- `scripts/expected_api_mismatch.txt` — explicit allowlist of "documented in JS, not in Python" or "named differently in Python" gaps. Lines that no longer apply must be removed. +- `tests/async/`, `tests/sync/` — pytest suites. Most new tests are added to the async file with a sync mirror. +- `setup.py` — `driver_version = "X.Y.Z"` is the source of truth for which driver build is downloaded from `cdn.playwright.dev`. +- `ROLLING.md`, `CONTRIBUTING.md` — human-facing setup and roll docs. + +## Setup + +`CONTRIBUTING.md` has the full sequence. The short version: + +```sh +python3 -m venv env && source env/bin/activate +pip install --upgrade pip +pip install -r local-requirements.txt +pip install -e . +python -m build --wheel # downloads the driver listed in setup.py +pre-commit install +``` + +If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (then `uv pip install --python env/bin/python --upgrade pip`). + +## Common commands + +- Regenerate `_generated.py`: `./scripts/update_api.sh` (runs codegen + pre-commit on the generated files). +- Lint everything: `pre-commit run --all-files`. +- Type-check: `mypy playwright`. +- Run tests: `pytest --browser chromium [-k name]`. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo). + +When changing public API, edit `_impl/`, then run `./scripts/update_api.sh`. The script regenerates `_generated.py` and validates against the driver's `api.json`. If validation fails, fix the mismatch in `_impl/`, in `expected_api_mismatch.txt`, or in `documentation_provider.py` — not by hand-editing `_generated.py`. + +## Rolling Playwright to a new version + +This is the recurring high-stakes task. Use the dedicated skill: + +→ **[`.claude/skills/playwright-roll/SKILL.md`](.claude/skills/playwright-roll/SKILL.md)** + +It documents the full process: the upstream commit-range diff over `docs/src/api/`, how to classify each commit (PORT / MISMATCH / N/A), how to handle the `langs:` filter, the recurring failure modes, and the tests/sync-mirroring conventions. + +## House style + +- Don't hand-edit generated files. +- Don't add `# type: ignore` or modify `_generated.py` to silence pyright; fix the source of the mismatch. +- New public methods on impl classes need a sync test mirror under `tests/sync/`. +- Keep `expected_api_mismatch.txt` minimal — every entry needs a one-line rationale comment above it. +- Prefer `locals_to_params(locals())` for forwarding optional kwargs to channel sends, matching the rest of the codebase. diff --git a/README.md b/README.md index c5e2d70b0..f0a4fc423 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 145.0.7632.6 | ✅ | ✅ | ✅ | -| WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 146.0.1 | ✅ | ✅ | ✅ | +| Chromium 147.0.7727.15 | ✅ | ✅ | ✅ | +| WebKit 26.4 | ✅ | ✅ | ✅ | +| Firefox 148.0.2 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index c0d0ee442..256b59435 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -166,6 +166,10 @@ class RemoteAddr(TypedDict): port: int +class BrowserBindResult(TypedDict): + endpoint: str + + class SecurityDetails(TypedDict): issuer: Optional[str] protocol: Optional[str] @@ -311,3 +315,18 @@ class TracingGroupLocation(TypedDict): file: str line: Optional[int] column: Optional[int] + + +class DebuggerLocation(TypedDict): + file: str + line: Optional[int] + column: Optional[int] + + +class DebuggerPausedDetails(TypedDict): + location: DebuggerLocation + title: str + + +class ScreencastFrame(TypedDict): + data: bytes diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 5a9a87450..6454f8c3f 100644 --- a/playwright/_impl/_browser.py +++ b/playwright/_impl/_browser.py @@ -27,6 +27,7 @@ ) from playwright._impl._api_structures import ( + BrowserBindResult, ClientCertificate, Geolocation, HttpCredentials, @@ -247,6 +248,20 @@ def version(self) -> str: async def new_browser_cdp_session(self) -> CDPSession: return from_channel(await self._channel.send("newBrowserCDPSession", None)) + async def bind( + self, + title: str, + workspaceDir: str = None, + host: str = None, + port: int = None, + ) -> BrowserBindResult: + return await self._channel.send_return_as_dict( + "startServer", None, locals_to_params(locals()) + ) + + async def unbind(self) -> None: + await self._channel.send("stopServer", None) + async def start_tracing( self, page: Page = None, diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index e27a9437a..4b1c19c40 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._debugger import Debugger from playwright._impl._dialog import Dialog from playwright._impl._errors import Error, TargetClosedError from playwright._impl._event_context_manager import EventContextManagerImpl @@ -122,6 +123,7 @@ def __init__( self._base_url: Optional[str] = self._options.get("baseURL") self._videos_dir: Optional[str] = self._options.get("recordVideo") self._tracing = cast(Tracing, from_channel(initializer["tracing"])) + self._debugger: Debugger = cast(Debugger, from_channel(initializer["debugger"])) self._har_recorders: Dict[str, HarRecordingMetadata] = {} self._request: APIRequestContext = from_channel(initializer["requestContext"]) self._request._timeout_settings = self._timeout_settings @@ -582,6 +584,9 @@ def _on_close(self) -> None: self._tracing._reset_stack_counter() self.emit(BrowserContext.Events.Close, self) + def is_closed(self) -> bool: + return self._closing_or_closed + async def close(self, reason: str = None) -> None: if self._closing_or_closed: return @@ -627,6 +632,15 @@ async def storage_state( await async_writefile(path, json.dumps(result)) return result + async def set_storage_state( + self, storageState: Union[StorageState, str, Path] + ) -> None: + if isinstance(storageState, (str, Path)): + state = json.loads(await async_readfile(storageState)) + else: + state = storageState + await self._channel.send("setStorageState", None, {"storageState": state}) + def _effective_close_reason(self) -> Optional[str]: if self._close_reason: return self._close_reason @@ -753,6 +767,10 @@ async def new_cdp_session(self, page: Union[Page, Frame]) -> CDPSession: def tracing(self) -> Tracing: return self._tracing + @property + def debugger(self) -> Debugger: + return self._debugger + @property def request(self) -> "APIRequestContext": return self._request diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index 3aef7dd6d..ba376c336 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -86,6 +86,7 @@ async def launch( downloadsPath: Union[str, Path] = None, slowMo: float = None, tracesDir: Union[pathlib.Path, str] = None, + artifactsDir: Union[pathlib.Path, str] = None, chromiumSandbox: bool = None, firefoxUserPrefs: Dict[str, Union[str, float, bool]] = None, ) -> Browser: @@ -143,6 +144,7 @@ async def launch_persistent_context( contrast: Contrast = None, acceptDownloads: bool = None, tracesDir: Union[pathlib.Path, str] = None, + artifactsDir: Union[pathlib.Path, str] = None, chromiumSandbox: bool = None, firefoxUserPrefs: Dict[str, Union[str, float, bool]] = None, recordHarPath: Union[Path, str] = None, @@ -213,7 +215,7 @@ async def connect_over_cdp( async def connect( self, - wsEndpoint: str, + endpoint: str, timeout: float = None, slowMo: float = None, headers: Dict[str, str] = None, @@ -229,7 +231,7 @@ async def connect( "connect", None, { - "wsEndpoint": wsEndpoint, + "endpoint": endpoint, "headers": headers, "slowMo": slowMo, "timeout": timeout if timeout is not None else 0, @@ -361,3 +363,5 @@ def normalize_launch_params(params: Dict) -> None: params["downloadsPath"] = str(Path(params["downloadsPath"])) if "tracesDir" in params: params["tracesDir"] = str(Path(params["tracesDir"])) + if "artifactsDir" in params: + params["artifactsDir"] = str(Path(params["artifactsDir"])) diff --git a/playwright/_impl/_cdp_session.py b/playwright/_impl/_cdp_session.py index 95e65c57a..07d291ae2 100644 --- a/playwright/_impl/_cdp_session.py +++ b/playwright/_impl/_cdp_session.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from types import SimpleNamespace from typing import Any, Dict from playwright._impl._connection import ChannelOwner @@ -19,14 +20,21 @@ class CDPSession(ChannelOwner): + Events = SimpleNamespace( + Event="event", + Close="close", + ) + def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> None: super().__init__(parent, type, guid, initializer) self._channel.on("event", lambda params: self._on_event(params)) + self._channel.on("close", lambda _: self.emit(CDPSession.Events.Close, self)) def _on_event(self, params: Any) -> None: self.emit(params["method"], params.get("params")) + self.emit(CDPSession.Events.Event, params) async def send(self, method: str, params: Dict = None) -> Dict: return await self._channel.send("send", None, locals_to_params(locals())) diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index f37e3dd4d..d98901d34 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -76,6 +76,10 @@ def args(self) -> List[JSHandle]: def location(self) -> SourceLocation: return self._event["location"] + @property + def timestamp(self) -> float: + return self._event["timestamp"] + @property def page(self) -> Optional["Page"]: return self._page diff --git a/playwright/_impl/_debugger.py b/playwright/_impl/_debugger.py new file mode 100644 index 000000000..36e4bd989 --- /dev/null +++ b/playwright/_impl/_debugger.py @@ -0,0 +1,54 @@ +# 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 types import SimpleNamespace +from typing import Any, Dict, Optional + +from playwright._impl._api_structures import DebuggerLocation, DebuggerPausedDetails +from playwright._impl._connection import ChannelOwner + + +class Debugger(ChannelOwner): + Events = SimpleNamespace( + PausedStateChanged="pausedstatechanged", + ) + + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + self._paused_details: Optional[DebuggerPausedDetails] = None + self._channel.on( + "pausedStateChanged", lambda params: self._on_paused_state_changed(params) + ) + + def _on_paused_state_changed(self, params: Dict[str, Any]) -> None: + self._paused_details = params.get("pausedDetails") + self.emit(Debugger.Events.PausedStateChanged) + + async def request_pause(self) -> None: + await self._channel.send("requestPause", None) + + async def resume(self) -> None: + await self._channel.send("resume", None) + + async def next(self) -> None: + await self._channel.send("next", None) + + async def run_to(self, location: DebuggerLocation) -> None: + await self._channel.send("runTo", None, {"location": location}) + + @property + def paused_details(self) -> Optional[DebuggerPausedDetails]: + return self._paused_details diff --git a/playwright/_impl/_dialog.py b/playwright/_impl/_dialog.py index 226e703b9..f6750e396 100644 --- a/playwright/_impl/_dialog.py +++ b/playwright/_impl/_dialog.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Dict, Optional from playwright._impl._connection import ChannelOwner, from_nullable_channel +from playwright._impl._errors import is_target_closed_error from playwright._impl._helper import locals_to_params if TYPE_CHECKING: # pragma: no cover @@ -51,7 +52,12 @@ async def accept(self, promptText: str = None) -> None: await self._channel.send("accept", None, locals_to_params(locals())) async def dismiss(self) -> None: - await self._channel.send( - "dismiss", - None, - ) + try: + await self._channel.send( + "dismiss", + None, + ) + except Exception as e: + if is_target_closed_error(e): + return + raise diff --git a/playwright/_impl/_local_utils.py b/playwright/_impl/_local_utils.py index c2d2d3fca..cd8fd8343 100644 --- a/playwright/_impl/_local_utils.py +++ b/playwright/_impl/_local_utils.py @@ -62,7 +62,9 @@ async def har_unzip(self, zipFile: str, harFile: str) -> None: params = locals_to_params(locals()) await self._channel.send("harUnzip", None, params) - async def tracing_started(self, tracesDir: Optional[str], traceName: str) -> str: + async def tracing_started( + self, tracesDir: Optional[str], traceName: str, live: bool = False + ) -> str: params = locals_to_params(locals()) return await self._channel.send("tracingStarted", None, params) diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 2e6a7abed..5f1b8f29a 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -564,7 +564,12 @@ async def screenshot( ), ) - async def aria_snapshot(self, timeout: float = None) -> str: + async def aria_snapshot( + self, + timeout: float = None, + depth: int = None, + mode: Literal["ai", "default"] = None, + ) -> str: return await self._frame._channel.send( "ariaSnapshot", self._frame._timeout, @@ -574,6 +579,14 @@ async def aria_snapshot(self, timeout: float = None) -> str: }, ) + async def normalize(self) -> "Locator": + result = await self._frame._channel.send( + "resolveSelector", + None, + {"selector": self._selector}, + ) + return Locator(self._frame, result) + async def scroll_into_view_if_needed( self, timeout: float = None, diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 9f2c29f6e..06bf88267 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -154,6 +154,7 @@ def __init__( self._fallback_overrides: SerializedFallbackOverrides = ( SerializedFallbackOverrides() ) + self._response: Optional["Response"] = None def __repr__(self) -> str: return f"" @@ -243,6 +244,10 @@ async def response(self) -> Optional["Response"]: ) ) + @property + def existing_response(self) -> Optional["Response"]: + return self._response + @property def frame(self) -> "Frame": if not self._initializer.get("frame"): @@ -799,6 +804,7 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._request: Request = from_channel(self._initializer["request"]) + self._request._response = self timing = self._initializer["timing"] self._request._timing["startTime"] = timing["startTime"] self._request._timing["domainLookupStart"] = timing["domainLookupStart"] @@ -881,6 +887,12 @@ async def security_details(self) -> Optional[SecurityDetails]: None, ) + async def http_version(self) -> str: + return await self._channel.send( + "httpVersion", + None, + ) + async def finished(self) -> None: async def on_finished() -> None: await self._request._target_closed_future() diff --git a/playwright/_impl/_object_factory.py b/playwright/_impl/_object_factory.py index b44009bc3..7abfb4b33 100644 --- a/playwright/_impl/_object_factory.py +++ b/playwright/_impl/_object_factory.py @@ -20,6 +20,7 @@ from playwright._impl._browser_type import BrowserType from playwright._impl._cdp_session import CDPSession from playwright._impl._connection import ChannelOwner +from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog from playwright._impl._element_handle import ElementHandle from playwright._impl._fetch import APIRequestContext @@ -64,6 +65,8 @@ def create_remote_object( return BrowserContext(parent, type, guid, initializer) if type == "CDPSession": return CDPSession(parent, type, guid, initializer) + if type == "Debugger": + return Debugger(parent, type, guid, initializer) if type == "Dialog": return Dialog(parent, type, guid, initializer) if type == "ElementHandle": diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 1f05a9048..4cecbd64c 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -98,6 +98,7 @@ WebSocketRouteHandler, serialize_headers, ) +from playwright._impl._screencast import Screencast from playwright._impl._video import Video from playwright._impl._waiter import Waiter @@ -175,7 +176,11 @@ def __init__( self._timeout_settings: TimeoutSettings = TimeoutSettings( self._browser_context._timeout_settings ) - self._video: Optional[Video] = None + self._video: Video = Video( + self, + cast(Optional[Artifact], from_nullable_channel(initializer.get("video"))), + ) + self._screencast: Screencast = Screencast(self) self._opener = cast("Page", from_nullable_channel(initializer.get("opener"))) self._close_reason: Optional[str] = None self._close_was_called = False @@ -224,7 +229,6 @@ def __init__( self._on_web_socket_route(from_channel(params["webSocketRoute"])) ), ) - self._channel.on("video", lambda params: self._on_video(params)) self._channel.on("viewportSizeChanged", self._on_viewport_size_changed) self._channel.on( "webSocket", @@ -356,10 +360,6 @@ def _on_download(self, params: Any) -> None: Page.Events.Download, Download(self, url, suggested_filename, artifact) ) - def _on_video(self, params: Any) -> None: - artifact = from_channel(params["artifact"]) - self._force_video()._artifact_ready(artifact) - def _on_viewport_size_changed(self, params: Any) -> None: self._viewport_size = params["viewportSize"] @@ -823,6 +823,18 @@ async def screenshot( async def title(self) -> str: return await self._main_frame.title() + async def aria_snapshot( + self, + timeout: float = None, + depth: int = None, + mode: Literal["ai", "default"] = None, + ) -> str: + return await self._main_frame._channel.send( + "ariaSnapshot", + self._main_frame._timeout, + locals_to_params(locals()), + ) + async def close(self, runBeforeUnload: bool = None, reason: str = None) -> None: self._close_reason = reason self._close_was_called = True @@ -1168,21 +1180,17 @@ async def pdf( await async_writefile(path, decoded_binary) return decoded_binary - def _force_video(self) -> Video: - if not self._video: - self._video = Video(self) + @property + def video(self) -> Optional[Video]: + # Video is only exposed when the page actually produced a recording artifact. + # The initializer carries the artifact; if absent, no video was recorded. + if not self._video._artifact: + return None return self._video @property - def video( - self, - ) -> Optional[Video]: - # Note: we are creating Video object lazily, because we do not know - # BrowserContextOptions when constructing the page - it is assigned - # too late during launchPersistentContext. - if not self._browser_context._videos_dir: - return None - return self._force_video() + def screencast(self) -> Screencast: + return self._screencast def _close_error_with_reason(self) -> TargetClosedError: return TargetClosedError( @@ -1435,8 +1443,12 @@ async def requests(self) -> List[Request]: request_objects = await self._channel.send("requests", None) return [from_channel(r) for r in request_objects] - async def console_messages(self) -> List[ConsoleMessage]: - message_dicts = await self._channel.send("consoleMessages", None) + async def console_messages( + self, filter: Literal["all", "since-navigation"] = None + ) -> List[ConsoleMessage]: + message_dicts = await self._channel.send( + "consoleMessages", None, locals_to_params(locals()) + ) return [ ConsoleMessage( {**event, "page": self._channel}, self._loop, self._dispatcher_fiber @@ -1444,10 +1456,27 @@ async def console_messages(self) -> List[ConsoleMessage]: for event in message_dicts ] - async def page_errors(self) -> List[Error]: - error_objects = await self._channel.send("pageErrors", None) + async def page_errors( + self, filter: Literal["all", "since-navigation"] = None + ) -> List[Error]: + error_objects = await self._channel.send( + "pageErrors", None, locals_to_params(locals()) + ) return [parse_error(error["error"]) for error in error_objects] + async def clear_console_messages(self) -> None: + await self._channel.send("clearConsoleMessages", None) + + async def clear_page_errors(self) -> None: + await self._channel.send("clearPageErrors", None) + + async def pick_locator(self) -> "Locator": + selector = await self._channel.send("pickLocator", None, {}) + return self.locator(selector) + + async def cancel_pick_locator(self) -> None: + await self._channel.send("cancelPickLocator", None, {}) + class Worker(ChannelOwner): Events = SimpleNamespace(Close="close", Console="console") diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py new file mode 100644 index 000000000..1f19c7220 --- /dev/null +++ b/playwright/_impl/_screencast.py @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union + +from playwright._impl._api_structures import ScreencastFrame +from playwright._impl._artifact import Artifact +from playwright._impl._connection import from_nullable_channel +from playwright._impl._errors import Error +from playwright._impl._helper import locals_to_params + +if TYPE_CHECKING: # pragma: no cover + from playwright._impl._page import Page + + +ScreencastFrameCallback = Callable[[ScreencastFrame], Any] +ScreencastPosition = Literal[ + "bottom", + "bottom-left", + "bottom-right", + "top", + "top-left", + "top-right", +] + + +class Screencast: + def __init__(self, page: "Page") -> None: + self._page = page + self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber + self._started = False + self._save_path: Optional[Union[str, Path]] = None + self._on_frame: Optional[ScreencastFrameCallback] = None + self._artifact: Optional[Artifact] = None + page._channel.on("screencastFrame", lambda params: self._dispatch_frame(params)) + + def _dispatch_frame(self, params: dict) -> None: + if not self._on_frame: + return + data = params["data"] + if isinstance(data, str): + data = base64.b64decode(data) + result = self._on_frame({"data": data}) + if hasattr(result, "__await__"): + self._page._loop.create_task(result) + + async def start( + self, + onFrame: ScreencastFrameCallback = None, + path: Union[str, Path] = None, + quality: int = None, + ) -> None: + if self._started: + raise Error("Screencast is already started") + self._started = True + self._on_frame = onFrame + result = await self._page._channel.send_return_as_dict( + "screencastStart", + None, + { + "quality": quality, + "sendFrames": bool(onFrame), + "record": bool(path), + }, + ) + artifact_channel = (result or {}).get("artifact") + if artifact_channel: + self._artifact = from_nullable_channel(artifact_channel) + self._save_path = path + + async def stop(self) -> None: + self._started = False + self._on_frame = None + await self._page._channel.send("screencastStop", None) + if self._save_path and self._artifact: + await self._artifact.save_as(self._save_path) + self._artifact = None + self._save_path = None + + async def show_actions( + self, + duration: float = None, + position: ScreencastPosition = None, + fontSize: int = None, + ) -> None: + await self._page._channel.send( + "screencastShowActions", None, locals_to_params(locals()) + ) + + async def hide_actions(self) -> None: + await self._page._channel.send("screencastHideActions", None) + + async def show_overlay(self, html: str, duration: float = None) -> None: + await self._page._channel.send( + "screencastShowOverlay", None, locals_to_params(locals()) + ) + + async def show_chapter( + self, + title: str, + description: str = None, + duration: float = None, + ) -> None: + await self._page._channel.send( + "screencastChapter", None, locals_to_params(locals()) + ) + + async def show_overlays(self) -> None: + await self._page._channel.send( + "screencastSetOverlayVisible", None, {"visible": True} + ) + + async def hide_overlays(self) -> None: + await self._page._channel.send( + "screencastSetOverlayVisible", None, {"visible": False} + ) diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index bbc6ec35e..8dda75994 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -27,6 +27,7 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._include_sources: bool = False + self._is_live: bool = False self._stacks_id: Optional[str] = None self._is_tracing: bool = False self._traces_dir: Optional[str] = None @@ -38,11 +39,22 @@ async def start( snapshots: bool = None, screenshots: bool = None, sources: bool = None, + live: bool = None, ) -> None: params = locals_to_params(locals()) self._include_sources = bool(sources) + self._is_live = bool(live) - await self._channel.send("tracingStart", None, params) + await self._channel.send( + "tracingStart", + None, + { + "name": name, + "snapshots": snapshots, + "screenshots": screenshots, + "live": live, + }, + ) trace_name = await self._channel.send( "tracingStartChunk", None, {"title": title, "name": name} ) @@ -58,7 +70,7 @@ async def _start_collecting_stacks(self, trace_name: str) -> None: self._is_tracing = True self._connection.set_is_tracing(True) self._stacks_id = await self._connection.local_utils.tracing_started( - self._traces_dir, trace_name + self._traces_dir, trace_name, self._is_live ) async def stop_chunk(self, path: Union[pathlib.Path, str] = None) -> None: diff --git a/playwright/_impl/_video.py b/playwright/_impl/_video.py index 68dedf6f8..e991803e3 100644 --- a/playwright/_impl/_video.py +++ b/playwright/_impl/_video.py @@ -13,7 +13,7 @@ # limitations under the License. import pathlib -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Optional, Union from playwright._impl._artifact import Artifact from playwright._impl._helper import Error @@ -23,49 +23,34 @@ class Video: - def __init__(self, page: "Page") -> None: + def __init__(self, page: "Page", artifact: Optional[Artifact]) -> None: self._loop = page._loop self._dispatcher_fiber = page._dispatcher_fiber self._page = page - self._artifact_future = page._loop.create_future() - if page.is_closed(): - self._page_closed() - else: - page.on("close", lambda page: self._page_closed()) + self._is_remote = page._connection.is_remote + self._artifact = artifact def __repr__(self) -> str: return f"