From 558941cda286d3eac4e49bc0158a208a432383b9 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 24 Nov 2025 11:01:35 +0100 Subject: [PATCH 1/5] api --- 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/_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_worker.py | 39 +++ tests/sync/test_accessibility.py | 393 --------------------------- 21 files changed, 582 insertions(+), 1029 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/_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_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 895523da9099fd562f4f0fe5e3c239012b929cfc Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 24 Nov 2025 12:06:04 +0100 Subject: [PATCH 2/5] impl 37996 --- playwright/_impl/_glob.py | 11 ++++++++++- tests/async/test_page_route.py | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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/tests/async/test_page_route.py b/tests/async/test_page_route.py index db46c330d..d43d622e7 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( @@ -1174,6 +1178,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( From 3387f892886c8d3838a5b4625e8874dc0158cac0 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 24 Nov 2025 12:50:33 +0100 Subject: [PATCH 3/5] more route changes --- playwright/_impl/_helper.py | 61 +++++++++++++--------------------- tests/async/test_page_route.py | 6 ++++ 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 8f1ca8594..337fdbe31 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 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,57 +226,40 @@ 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 = urlparse(urljoin(base_url if base_url is not None else "", given_url)) + + # In Node.js, new URL('http://localhost') returns 'http://localhost/'. + if url.scheme.startswith("http") and url.path == "": + url = url._replace(path="/") + + 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 - - class HarLookupResult(TypedDict, total=False): action: Literal["error", "redirect", "fulfill", "noentry"] message: Optional[str] diff --git a/tests/async/test_page_route.py b/tests/async/test_page_route.py index d43d622e7..848a95045 100644 --- a/tests/async/test_page_route.py +++ b/tests/async/test_page_route.py @@ -1151,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, From e84ca7f8e1a35093a75d4d21827de9ba368cf9c4 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 24 Nov 2025 13:10:52 +0100 Subject: [PATCH 4/5] ws is also normalized --- playwright/_impl/_helper.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 337fdbe31..96ad33f28 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -244,7 +244,9 @@ def resolve_base_url( url = urlparse(urljoin(base_url if base_url is not None else "", given_url)) # In Node.js, new URL('http://localhost') returns 'http://localhost/'. - if url.scheme.startswith("http") and url.path == "": + if ( + url.scheme.startswith("http") or url.scheme.startswith("ws") + ) and url.path == "": url = url._replace(path="/") resolved = urlunparse(url) From 5176931a4024cbd4ef44f75c580fa03d6dccb3d1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 24 Nov 2025 13:27:21 +0100 Subject: [PATCH 5/5] follow WHATG spec --- playwright/_impl/_helper.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 96ad33f28..1d7e4f67b 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -35,7 +35,7 @@ Union, cast, ) -from urllib.parse import urljoin, urlparse, urlunparse +from urllib.parse import ParseResult, urljoin, urlparse, urlunparse from playwright._impl._api_structures import NameValue from playwright._impl._errors import ( @@ -241,14 +241,9 @@ def resolve_base_url( base_url: Optional[str], given_url: str ) -> Tuple[str, Optional[str]]: try: - url = urlparse(urljoin(base_url if base_url is not None else "", given_url)) - - # In Node.js, new URL('http://localhost') returns 'http://localhost/'. - if ( - url.scheme.startswith("http") or url.scheme.startswith("ws") - ) and url.path == "": - url = url._replace(path="/") - + 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 = ( @@ -262,6 +257,20 @@ def resolve_base_url( return given_url, None +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): action: Literal["error", "redirect", "fulfill", "noentry"] message: Optional[str]