From 3295b249427624fac7132d4c0b00f698e647990c Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 3 Oct 2025 08:01:04 -0700 Subject: [PATCH 1/5] Initial port of major changes --- README.md | 4 +- playwright/_impl/_glob.py | 12 +---- playwright/_impl/_page.py | 18 +++++++ playwright/async_api/_generated.py | 65 +++++++++++++++++------- playwright/sync_api/_generated.py | 65 +++++++++++++++++------- setup.py | 2 +- tests/async/test_page_event_console.py | 35 +++++++++++++ tests/async/test_page_event_pageerror.py | 36 +++++++++++++ tests/async/test_page_event_request.py | 62 ++++++++++++++++++++++ tests/async/test_page_route.py | 6 ++- 10 files changed, 255 insertions(+), 50 deletions(-) create mode 100644 tests/async/test_page_event_console.py create mode 100644 tests/async/test_page_event_pageerror.py create mode 100644 tests/async/test_page_event_request.py diff --git a/README.md b/README.md index cf85c6116..b54d5a364 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 140.0.7339.16 | ✅ | ✅ | ✅ | +| Chromium 141.0.7390.37 | ✅ | ✅ | ✅ | | WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 141.0 | ✅ | ✅ | ✅ | +| Firefox 142.0.1 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_glob.py b/playwright/_impl/_glob.py index 08b7ce466..a0e6dcd4b 100644 --- a/playwright/_impl/_glob.py +++ b/playwright/_impl/_glob.py @@ -28,20 +28,12 @@ def glob_to_regex_pattern(glob: str) -> str: tokens.append("\\" + char if char in escaped_chars else char) i += 1 elif c == "*": - before_deep = 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 - after_deep = glob[i + 1] if i + 1 < len(glob) else None - is_deep = ( - star_count > 1 - and (before_deep == "/" or before_deep is None) - and (after_deep == "/" or after_deep is None) - ) - if is_deep: - tokens.append("((?:[^/]*(?:/|$))*)") - i += 1 + if star_count > 1: + tokens.append("(.*)") else: tokens.append("([^/]*)") else: diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 1019b2f6e..0e74bbe41 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -79,6 +79,7 @@ async_writefile, locals_to_params, make_dirs_for_file, + parse_error, serialize_error, url_matches, ) @@ -1434,6 +1435,23 @@ async def remove_locator_handler(self, locator: "Locator") -> None: {"uid": uid}, ) + 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) + return [ + ConsoleMessage( + {**event, "page": self._channel}, self._loop, self._dispatcher_fiber + ) + for event in message_dicts + ] + + async def page_errors(self) -> List[Error]: + error_objects = await self._channel.send("pageErrors", None) + return [parse_error(error["error"]) for error in error_objects] + class Worker(ChannelOwner): Events = SimpleNamespace(Close="close") diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index bdda2b2b0..71f5aff82 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -12254,6 +12254,49 @@ async def remove_locator_handler(self, locator: "Locator") -> None: await self._impl_obj.remove_locator_handler(locator=locator._impl_obj) ) + async def requests(self) -> typing.List["Request"]: + """Page.requests + + Returns up to (currently) 100 last network request from this page. See `page.on('request')` for more details. + + Returned requests should be accessed immediately, otherwise they might be collected to prevent unbounded memory + growth as new requests come in. Once collected, retrieving most information about the request is impossible. + + Note that requests reported through the `page.on('request')` request are not collected, so there is a trade off + between efficient memory usage with `page.requests()` and the amount of available information reported + through `page.on('request')`. + + Returns + ------- + List[Request] + """ + + return mapping.from_impl_list(await self._impl_obj.requests()) + + async def console_messages(self) -> typing.List["ConsoleMessage"]: + """Page.console_messages + + Returns up to (currently) 200 last console messages from this page. See `page.on('console')` for more details. + + Returns + ------- + List[ConsoleMessage] + """ + + return mapping.from_impl_list(await self._impl_obj.console_messages()) + + async def page_errors(self) -> typing.List["Error"]: + """Page.page_errors + + Returns up to (currently) 200 last page errors from this page. See `page.on('page_error')` for more details. + + Returns + ------- + List[Error] + """ + + return mapping.from_impl_list(await self._impl_obj.page_errors()) + mapping.register(PageImpl, Page) @@ -12297,13 +12340,7 @@ def on( f: typing.Callable[["Page"], "typing.Union[typing.Awaitable[None], None]"], ) -> None: """ - **NOTE** Only works with Chromium browser's persistent context. - - Emitted when new background page is created in the context. - - ```py - background_page = await context.wait_for_event(\"backgroundpage\") - ```""" + This event is not emitted.""" @typing.overload def on( @@ -12477,13 +12514,7 @@ def once( f: typing.Callable[["Page"], "typing.Union[typing.Awaitable[None], None]"], ) -> None: """ - **NOTE** Only works with Chromium browser's persistent context. - - Emitted when new background page is created in the context. - - ```py - background_page = await context.wait_for_event(\"backgroundpage\") - ```""" + This event is not emitted.""" @typing.overload def once( @@ -12679,9 +12710,7 @@ def browser(self) -> typing.Optional["Browser"]: def background_pages(self) -> typing.List["Page"]: """BrowserContext.background_pages - **NOTE** Background pages are only supported on Chromium-based browsers. - - All existing background pages in the context. + Returns an empty list. Returns ------- @@ -16617,7 +16646,7 @@ def and_(self, locator: "Locator") -> "Locator": The following example finds a button with a specific title. ```py - button = page.get_by_role(\"button\").and_(page.getByTitle(\"Subscribe\")) + button = page.get_by_role(\"button\").and_(page.get_by_title(\"Subscribe\")) ``` Parameters diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 83fedfbe9..024014c51 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -12342,6 +12342,49 @@ def remove_locator_handler(self, locator: "Locator") -> None: self._sync(self._impl_obj.remove_locator_handler(locator=locator._impl_obj)) ) + def requests(self) -> typing.List["Request"]: + """Page.requests + + Returns up to (currently) 100 last network request from this page. See `page.on('request')` for more details. + + Returned requests should be accessed immediately, otherwise they might be collected to prevent unbounded memory + growth as new requests come in. Once collected, retrieving most information about the request is impossible. + + Note that requests reported through the `page.on('request')` request are not collected, so there is a trade off + between efficient memory usage with `page.requests()` and the amount of available information reported + through `page.on('request')`. + + Returns + ------- + List[Request] + """ + + return mapping.from_impl_list(self._sync(self._impl_obj.requests())) + + def console_messages(self) -> typing.List["ConsoleMessage"]: + """Page.console_messages + + Returns up to (currently) 200 last console messages from this page. See `page.on('console')` for more details. + + Returns + ------- + List[ConsoleMessage] + """ + + return mapping.from_impl_list(self._sync(self._impl_obj.console_messages())) + + def page_errors(self) -> typing.List["Error"]: + """Page.page_errors + + Returns up to (currently) 200 last page errors from this page. See `page.on('page_error')` for more details. + + Returns + ------- + List[Error] + """ + + return mapping.from_impl_list(self._sync(self._impl_obj.page_errors())) + mapping.register(PageImpl, Page) @@ -12383,13 +12426,7 @@ def on( self, event: Literal["backgroundpage"], f: typing.Callable[["Page"], "None"] ) -> None: """ - **NOTE** Only works with Chromium browser's persistent context. - - Emitted when new background page is created in the context. - - ```py - background_page = context.wait_for_event(\"backgroundpage\") - ```""" + This event is not emitted.""" @typing.overload def on( @@ -12529,13 +12566,7 @@ def once( self, event: Literal["backgroundpage"], f: typing.Callable[["Page"], "None"] ) -> None: """ - **NOTE** Only works with Chromium browser's persistent context. - - Emitted when new background page is created in the context. - - ```py - background_page = context.wait_for_event(\"backgroundpage\") - ```""" + This event is not emitted.""" @typing.overload def once( @@ -12701,9 +12732,7 @@ def browser(self) -> typing.Optional["Browser"]: def background_pages(self) -> typing.List["Page"]: """BrowserContext.background_pages - **NOTE** Background pages are only supported on Chromium-based browsers. - - All existing background pages in the context. + Returns an empty list. Returns ------- @@ -16680,7 +16709,7 @@ def and_(self, locator: "Locator") -> "Locator": The following example finds a button with a specific title. ```py - button = page.get_by_role(\"button\").and_(page.getByTitle(\"Subscribe\")) + button = page.get_by_role(\"button\").and_(page.get_by_title(\"Subscribe\")) ``` Parameters diff --git a/setup.py b/setup.py index 543395520..c2a56354b 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import zipfile from typing import Dict -driver_version = "1.55.0-beta-1756314050000" +driver_version = "1.56.0-beta-1759412259000" base_wheel_bundles = [ { diff --git a/tests/async/test_page_event_console.py b/tests/async/test_page_event_console.py new file mode 100644 index 000000000..4326e1823 --- /dev/null +++ b/tests/async/test_page_event_console.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from playwright._impl._page import Page + + +async def test_console_messages_should_work(page: Page) -> None: + await page.evaluate( + """() => { + for (let i = 0; i < 301; i++) + console.log('message' + i); + }""" + ) + + messages = await page.console_messages() + objects = [{"text": m.text, "type": m.type, "page": m.page} for m in messages] + + expected = [] + for i in range(201, 301): + expected.append({"text": f"message{i}", "type": "log", "page": page}) + + assert len(objects) >= 100, "should be at least 100 messages" + message_count = len(messages) - len(expected) + assert objects[message_count:] == expected, "should return last messages" diff --git a/tests/async/test_page_event_pageerror.py b/tests/async/test_page_event_pageerror.py new file mode 100644 index 000000000..4914af5d1 --- /dev/null +++ b/tests/async/test_page_event_pageerror.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. + +from playwright.async_api import Page + + +async def test_page_errors_should_work(page: Page) -> None: + await page.evaluate( + """async () => { + for (let i = 0; i < 301; i++) + window.setTimeout(() => { throw new Error('error' + i); }, 0); + await new Promise(f => window.setTimeout(f, 100)); + }""" + ) + + errors = await page.page_errors() + messages = [e.message for e in errors] + + expected = [] + for i in range(201, 301): + expected.append(f"error{i}") + + assert len(messages) >= 100, "should be at least 100 errors" + message_count = len(messages) - len(expected) + assert messages[message_count:] == expected, "should return last errors" diff --git a/tests/async/test_page_event_request.py b/tests/async/test_page_event_request.py new file mode 100644 index 000000000..73d6fd785 --- /dev/null +++ b/tests/async/test_page_event_request.py @@ -0,0 +1,62 @@ +# 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 asyncio + +from playwright._impl._network import Request, Route +from playwright._impl._page import Page +from tests.server import Server + + +async def test_should_return_last_requests(page: Page, server: Server) -> None: + await page.goto(server.PREFIX + "/title.html") + for i in range(200): + + def _handle_route(route: Route) -> None: + asyncio.ensure_future( + route.fulfill( + status=200, + body=f"url:{route.request.url}", + ) + ) + + await page.route(f"**/fetch?{i}", _handle_route) + + # #0 is the navigation request, so start with #1. + for i in range(1, 100): + await page.evaluate("url => fetch(url)", server.PREFIX + f"/fetch?{i}") + first_100_requests_with_goto = await page.requests() + first_100_requests = first_100_requests_with_goto[1:] + + for i in range(100, 200): + await page.evaluate("url => fetch(url)", server.PREFIX + f"/fetch?{i}") + last_100_requests = await page.requests() + + all_requests = first_100_requests + last_100_requests + + async def gather_response(request: Request) -> dict: + response = await request.response() + assert response + return {"text": await response.text(), "url": request.url} + + # All 199 requests are fully functional. + received = await asyncio.gather( + *[gather_response(request) for request in all_requests] + ) + + expected = [] + for i in range(1, 200): + url = server.PREFIX + f"/fetch?{i}" + expected.append({"url": url, "text": f"url:{url}"}) + assert received == expected diff --git a/tests/async/test_page_route.py b/tests/async/test_page_route.py index b561af0a2..e45ed62ff 100644 --- a/tests/async/test_page_route.py +++ b/tests/async/test_page_route.py @@ -1145,6 +1145,10 @@ def glob_to_regex(pattern: str) -> re.Pattern: None, "https://playwright.dev/foobar?a=b", "https://playwright.dev/foobar?A=B" ) + assert url_matches(None, "https://localhost:3000/?a=b", "**/?a=b") + assert url_matches(None, "https://localhost:3000/?a=b", "**?a=b") + assert url_matches(None, "https://localhost:3000/?a=b", "**=b") + # This is not supported, we treat ? as a query separator. assert not url_matches( None, @@ -1190,7 +1194,7 @@ def glob_to_regex(pattern: str) -> re.Pattern: "custom://example.com/foo/bar?id=123", "{custom,another}://example.com/foo/bar?id=123", ) - assert not url_matches( + assert url_matches( None, "custom://example.com/foo/bar?id=123", "**example.com/foo/bar?id=123" ) From d7750ae8364f59774c1cfe21b4e720cfa860304b Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 3 Oct 2025 10:08:37 -0700 Subject: [PATCH 2/5] Roll other remaining commits --- playwright/_impl/_browser_context.py | 12 ++---------- playwright/_impl/_page.py | 2 -- tests/async/test_page_aria_snapshot.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 391e61ec6..bab7d1bf1 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -88,6 +88,7 @@ class BrowserContext(ChannelOwner): Events = SimpleNamespace( + # Deprecated in v1.56, never emitted anymore. BackgroundPage="backgroundpage", Close="close", Console="console", @@ -117,7 +118,6 @@ def __init__( self._timeout_settings = TimeoutSettings(None) self._owner_page: Optional[Page] = None self._options: Dict[str, Any] = initializer["options"] - self._background_pages: Set[Page] = set() self._service_workers: Set[Worker] = set() self._base_url: Optional[str] = self._options.get("baseURL") self._videos_dir: Optional[str] = self._options.get("recordVideo") @@ -149,10 +149,6 @@ def __init__( ) ), ) - self._channel.on( - "backgroundPage", - lambda params: self._on_background_page(from_channel(params["page"])), - ) self._channel.on( "serviceWorker", @@ -658,10 +654,6 @@ def expect_page( ) -> EventContextManagerImpl[Page]: return self.expect_event(BrowserContext.Events.Page, predicate, timeout) - def _on_background_page(self, page: Page) -> None: - self._background_pages.add(page) - self.emit(BrowserContext.Events.BackgroundPage, page) - def _on_service_worker(self, worker: Worker) -> None: worker._context = self self._service_workers.add(worker) @@ -736,7 +728,7 @@ def _on_response(self, response: Response, page: Optional[Page]) -> None: @property def background_pages(self) -> List[Page]: - return list(self._background_pages) + return [] @property def service_workers(self) -> List[Worker]: diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 0e74bbe41..29a583a7c 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -345,8 +345,6 @@ def _on_close(self) -> None: self._is_closed = True if self in self._browser_context._pages: self._browser_context._pages.remove(self) - if self in self._browser_context._background_pages: - self._browser_context._background_pages.remove(self) self._dispose_har_routers() self.emit(Page.Events.Close, self) diff --git a/tests/async/test_page_aria_snapshot.py b/tests/async/test_page_aria_snapshot.py index 30a9c9661..cef17cdd0 100644 --- a/tests/async/test_page_aria_snapshot.py +++ b/tests/async/test_page_aria_snapshot.py @@ -204,3 +204,13 @@ async def test_should_snapshot_with_restored_contain_mode_inside_deep_equal( - listitem: 1.1 """, ) + + +async def test_match_values_both_against_regex_and_string(page: Page) -> None: + await page.set_content('Log in') + await expect(page.locator("body")).to_match_aria_snapshot( + """ + - link "Log in": + - /url: /auth?r=/ + """, + ) From 8f917d2baaa68eae7f7f01f11d16752d2cc2db2e Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 3 Oct 2025 11:11:00 -0700 Subject: [PATCH 3/5] Fix assertion messages --- playwright/_impl/_api_structures.py | 1 + playwright/_impl/_assertions.py | 4 +++- tests/async/test_assertions.py | 8 ++++---- tests/sync/test_assertions.py | 8 ++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index 0afa0d02e..c0d0ee442 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -218,6 +218,7 @@ class FrameExpectResult(TypedDict): matches: bool received: Any log: List[str] + errorMessage: Optional[str] AriaRole = Literal[ diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index 3aadbf5fe..aea37d35c 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -80,8 +80,10 @@ async def _expect_impl( out_message = ( f"{message} '{expected}'" if expected is not None else f"{message}" ) + error_message = result.get("errorMessage") + error_message = f"\n{error_message}" if error_message else "" raise AssertionError( - f"{out_message}\nActual value: {actual} {format_call_log(result.get('log'))}" + f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}" ) diff --git a/tests/async/test_assertions.py b/tests/async/test_assertions.py index 3213e5523..49e3c3e7f 100644 --- a/tests/async/test_assertions.py +++ b/tests/async/test_assertions.py @@ -516,7 +516,7 @@ async def test_to_have_values_fails_when_multiple_not_specified( ) locator = page.locator("select") await locator.select_option(["B"]) - with pytest.raises(Error) as excinfo: + with pytest.raises(AssertionError) as excinfo: await expect(locator).to_have_values(["R", "G"], timeout=500) assert "Error: Not a select element with a multiple attribute" in str(excinfo.value) @@ -530,7 +530,7 @@ async def test_to_have_values_fails_when_not_a_select_element( """ ) locator = page.locator("input") - with pytest.raises(Error) as excinfo: + with pytest.raises(AssertionError) as excinfo: await expect(locator).to_have_values(["R", "G"], timeout=500) assert "Error: Not a select element with a multiple attribute" in str(excinfo.value) @@ -564,7 +564,7 @@ async def test_assertions_boolean_checked_with_intermediate_true_and_checked( await page.set_content("") await page.locator("input").evaluate("e => e.indeterminate = true") with pytest.raises( - Error, match="Can't assert indeterminate and checked at the same time" + AssertionError, match="Can't assert indeterminate and checked at the same time" ): await expect(page.locator("input")).to_be_checked( checked=False, indeterminate=True @@ -658,7 +658,7 @@ async def test_assertions_locator_to_be_editable_throws( await page.goto(server.EMPTY_PAGE) await page.set_content("") with pytest.raises( - Error, + AssertionError, match=r"Element is not an ,