From 33ec141b4e2ad77b58e7a50bc0fea474f43124ae Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 30 Apr 2026 14:00:57 +0200 Subject: [PATCH 1/3] fix: support async predicates in page.expect_request/expect_response Closes #1545 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playwright/_impl/_helper.py | 9 ++++-- playwright/_impl/_page.py | 5 ++-- playwright/_impl/_waiter.py | 44 ++++++++++++++++++++++++++---- playwright/async_api/_generated.py | 12 +++++--- scripts/documentation_provider.py | 10 +++++++ scripts/expected_api_mismatch.txt | 4 +++ scripts/generate_api.py | 11 ++++++++ scripts/generate_sync_api.py | 3 ++ tests/async/test_page.py | 36 +++++++++++++++++++++++- 9 files changed, 120 insertions(+), 14 deletions(-) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 1d7e4f67b..dc0a2479d 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -22,6 +22,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, List, @@ -54,8 +55,12 @@ from playwright._impl._network import Request, Response, Route, WebSocketRoute URLMatch = Union[str, Pattern[str], Callable[[str], bool]] -URLMatchRequest = Union[str, Pattern[str], Callable[["Request"], bool]] -URLMatchResponse = Union[str, Pattern[str], Callable[["Response"], bool]] +URLMatchRequest = Union[ + str, Pattern[str], Callable[["Request"], Union[bool, Awaitable[bool]]] +] +URLMatchResponse = Union[ + str, Pattern[str], Callable[["Response"], Union[bool, Awaitable[bool]]] +] RouteHandlerCallback = Union[ Callable[["Route"], Any], Callable[["Route", "Request"], Any] ] diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 020058acf..5a8444624 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -22,6 +22,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, List, @@ -1278,7 +1279,7 @@ def expect_request( urlOrPredicate: URLMatchRequest, timeout: float = None, ) -> EventContextManagerImpl[Request]: - def my_predicate(request: Request) -> bool: + def my_predicate(request: Request) -> Union[bool, Awaitable[bool]]: if not callable(urlOrPredicate): return url_matches( self._browser_context._base_url, @@ -1310,7 +1311,7 @@ def expect_response( urlOrPredicate: URLMatchResponse, timeout: float = None, ) -> EventContextManagerImpl[Response]: - def my_predicate(request: Response) -> bool: + def my_predicate(request: Response) -> Union[bool, Awaitable[bool]]: if not callable(urlOrPredicate): return url_matches( self._browser_context._base_url, diff --git a/playwright/_impl/_waiter.py b/playwright/_impl/_waiter.py index f7ff4b6c1..b5bf53382 100644 --- a/playwright/_impl/_waiter.py +++ b/playwright/_impl/_waiter.py @@ -13,10 +13,11 @@ # limitations under the License. import asyncio +import inspect import math import uuid from asyncio.tasks import Task -from typing import Any, Callable, List, Tuple, Union +from typing import Any, Callable, List, Optional, Tuple, Union from pyee import EventEmitter @@ -71,9 +72,11 @@ def reject_on_event( error: Union[Error, Callable[..., Error]], predicate: Callable = None, ) -> None: + def on_match() -> None: + self._reject(error() if callable(error) else error) + def listener(event_data: Any = None) -> None: - if not predicate or predicate(event_data): - self._reject(error() if callable(error) else error) + self._evaluate_predicate(predicate, event_data, on_match) emitter.on(event, listener) self._registered_listeners.append((emitter, event, listener)) @@ -117,12 +120,43 @@ def wait_for_event( predicate: Callable = None, ) -> None: def listener(event_data: Any = None) -> None: - if not predicate or predicate(event_data): - self._fulfill(event_data) + self._evaluate_predicate( + predicate, event_data, lambda: self._fulfill(event_data) + ) emitter.on(event, listener) self._registered_listeners.append((emitter, event, listener)) + def _evaluate_predicate( + self, + predicate: Optional[Callable], + event_data: Any, + on_match: Callable[[], None], + ) -> None: + if predicate is None: + on_match() + return + try: + result = predicate(event_data) + except Exception as e: + self._reject(e) + return + if inspect.iscoroutine(result): + + async def _await_predicate(coro: Any) -> None: + try: + matched = await coro + except Exception as e: + self._reject(e) + return + if matched and not self._result.done(): + on_match() + + self._pending_tasks.append(self._loop.create_task(_await_predicate(result))) + return + if result: + on_match() + def result(self) -> asyncio.Future: return self._result diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 5ca533ef2..130230390 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -12306,7 +12306,9 @@ def expect_popup( def expect_request( self, url_or_predicate: typing.Union[ - str, typing.Pattern[str], typing.Callable[["Request"], bool] + str, + typing.Pattern[str], + typing.Callable[["Request"], typing.Union[bool, typing.Awaitable[bool]]], ], *, timeout: typing.Optional[float] = None, @@ -12331,7 +12333,7 @@ def expect_request( Parameters ---------- - url_or_predicate : Union[Callable[[Request], bool], Pattern[str], str] + url_or_predicate : Union[Callable[[Request], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] Request URL string, regex or predicate receiving `Request` object. When a `baseURL` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. @@ -12384,7 +12386,9 @@ def expect_request_finished( def expect_response( self, url_or_predicate: typing.Union[ - str, typing.Pattern[str], typing.Callable[["Response"], bool] + str, + typing.Pattern[str], + typing.Callable[["Response"], typing.Union[bool, typing.Awaitable[bool]]], ], *, timeout: typing.Optional[float] = None, @@ -12411,7 +12415,7 @@ def expect_response( Parameters ---------- - url_or_predicate : Union[Callable[[Response], bool], Pattern[str], str] + url_or_predicate : Union[Callable[[Response], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] Request URL string, regex or predicate receiving `Response` object. When a `baseURL` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index 8016a601d..4c2d9b95d 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -408,6 +408,16 @@ def serialize_python_type(self, value: Any, direction: str) -> str: return f"{{{', '.join(signature)}}}" if origin == Union: args = get_args(value) + if not self.is_async: + # Sync API doesn't accept awaitable callbacks; drop the + # Awaitable arm so docstring types match the sync signature. + args = tuple( + a + for a in args + if str(get_origin(a)) != "" + ) + if len(args) == 1: + return self.serialize_python_type(args[0], direction) if len(args) == 2 and str(args[1]) == "": return self.make_optional( self.serialize_python_type(args[0], direction) diff --git a/scripts/expected_api_mismatch.txt b/scripts/expected_api_mismatch.txt index cddb87f15..d9bcc41a0 100644 --- a/scripts/expected_api_mismatch.txt +++ b/scripts/expected_api_mismatch.txt @@ -20,3 +20,7 @@ Parameter type mismatch in BrowserContext.route_web_socket(handler=): documented Parameter type mismatch in Page.route_web_socket(handler=): documented as Callable[[WebSocketRoute], Union[Any, Any]], code has Callable[[WebSocketRoute], Any] Parameter type mismatch in WebSocketRoute.on_close(handler=): documented as Callable[[Union[int, undefined]], Union[Any, Any]], code has Callable[[Union[int, None], Union[str, None]], Any] Parameter type mismatch in WebSocketRoute.on_message(handler=): documented as Callable[[str], Union[Any, Any]], code has Callable[[Union[bytes, str]], Any] + +# Async API additionally accepts an `async def` predicate. +Parameter type mismatch in Page.expect_request(url_or_predicate=): documented as Union[Callable[[Request], bool], Pattern[str], str], code has Union[Callable[[Request], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] +Parameter type mismatch in Page.expect_response(url_or_predicate=): documented as Union[Callable[[Response], bool], Pattern[str], str], code has Union[Callable[[Response], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 9d217b7c5..07a1f2182 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -56,6 +56,8 @@ from playwright._impl._video import Video from playwright._impl._web_error import WebError +SYNC_API = False + def process_type(value: Any, param: bool = False) -> str: value = str(value) @@ -65,6 +67,15 @@ def process_type(value: Any, param: bool = False) -> str: value = re.sub(r"playwright\._impl\._api_structures.([\w]+)", r"\1", value) value = re.sub(r"playwright\._impl\.[\w]+\.([\w]+)", r'"\1"', value) value = re.sub(r"typing.Literal", "Literal", value) + if SYNC_API: + # Sync API does not accept awaitable callbacks; collapse + # Union[X, Awaitable[X]] (used for predicates the async API also + # accepts as `async def`) down to just X. + value = re.sub( + r"typing\.Union\[([^\[\],]+),\s*typing\.Awaitable\[\1\]\]", + r"\1", + value, + ) if param: value = re.sub(r"^typing.Union\[([^,]+), None\]$", r"\1 = None", value) value = re.sub( diff --git a/scripts/generate_sync_api.py b/scripts/generate_sync_api.py index 5ccf3b672..dbabce413 100755 --- a/scripts/generate_sync_api.py +++ b/scripts/generate_sync_api.py @@ -19,6 +19,7 @@ from types import FunctionType from typing import Any +import generate_api from documentation_provider import DocumentationProvider from generate_api import ( api_globals, @@ -33,6 +34,8 @@ signature, ) +generate_api.SYNC_API = True + documentation_provider = DocumentationProvider(False) diff --git a/tests/async/test_page.py b/tests/async/test_page.py index 562d98248..7b01bb636 100644 --- a/tests/async/test_page.py +++ b/tests/async/test_page.py @@ -16,7 +16,7 @@ import os import re from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import pytest @@ -352,6 +352,40 @@ async def test_wait_for_response_should_work_with_predicate( assert response.url == server.PREFIX + "/digits/2.png" +async def test_wait_for_response_should_work_with_async_predicate( + page: Page, server: Server +) -> None: + await page.goto(server.EMPTY_PAGE) + + async def predicate(response: Any) -> bool: + await asyncio.sleep(0) + return response.url == server.PREFIX + "/digits/2.png" + + async with page.expect_response(predicate) as response_info: + await page.evaluate( + """() => { + fetch('/digits/1.png') + fetch('/digits/2.png') + fetch('/digits/3.png') + }""" + ) + response = await response_info.value + assert response.url == server.PREFIX + "/digits/2.png" + + +async def test_expect_response_should_reject_when_async_predicate_throws( + page: Page, server: Server +) -> None: + await page.goto(server.EMPTY_PAGE) + + async def predicate(response: Any) -> bool: + raise Exception("Async oops!") + + with pytest.raises(Exception, match="Async oops!"): + async with page.expect_response(predicate): + await page.evaluate("() => fetch('/digits/1.png')") + + async def test_wait_for_response_should_work_with_no_timeout( page: Page, server: Server ) -> None: From c3e4cc8c6c9d624d66a26713eaf39b9abc07ef43 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 30 Apr 2026 14:14:14 +0200 Subject: [PATCH 2/3] test: cover sync predicate exception propagation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/async/test_page.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/async/test_page.py b/tests/async/test_page.py index 7b01bb636..2fcf4c92d 100644 --- a/tests/async/test_page.py +++ b/tests/async/test_page.py @@ -386,6 +386,19 @@ async def predicate(response: Any) -> bool: await page.evaluate("() => fetch('/digits/1.png')") +async def test_expect_response_should_reject_when_sync_predicate_throws( + page: Page, server: Server +) -> None: + await page.goto(server.EMPTY_PAGE) + + def predicate(response: Any) -> bool: + raise Exception("Sync oops!") + + with pytest.raises(Exception, match="Sync oops!"): + async with page.expect_response(predicate): + await page.evaluate("() => fetch('/digits/1.png')") + + async def test_wait_for_response_should_work_with_no_timeout( page: Page, server: Server ) -> None: From ffe1ed1b07c2504e55d04e47400791151f7d3868 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 30 Apr 2026 14:19:43 +0200 Subject: [PATCH 3/3] docs: forbid posting on GitHub under maintainer's account without approval Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ce4ec7c07..4153ac4d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,10 @@ This is the recurring high-stakes task. Use the dedicated skill: It documents the full process: the upstream commit-range diff over `docs/src/api/`, how to classify each commit (PORT / MISMATCH / N/A), how to handle the `langs:` filter, the recurring failure modes, and the tests/sync-mirroring conventions. +## Working on PRs + +- Never post comments, replies, or reviews on GitHub PRs/issues under my account without my explicit approval. Draft the proposed text and wait for me to approve before sending. + ## House style - Don't hand-edit generated files.