From 6320df34917387fd32e2be76f0af310930b6bb5e Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 5 Jan 2024 15:15:25 +0100 Subject: [PATCH 1/6] chore(roll): roll to Playwright 1.41.0-alpha-1702670966000 part 1 --- README.md | 4 +- playwright/_impl/_browser_context.py | 7 + playwright/_impl/_element_handle.py | 1 + playwright/_impl/_locator.py | 1 + playwright/_impl/_page.py | 8 + playwright/_impl/_set_input_files_helpers.py | 14 +- playwright/async_api/_generated.py | 125 ++++- playwright/sync_api/_generated.py | 125 ++++- setup.py | 2 +- tests/async/conftest.py | 11 + tests/async/test_asyncio.py | 20 +- tests/async/test_browsercontext.py | 111 ---- .../test_browsercontext_request_fallback.py | 104 +--- tests/async/test_browsercontext_route.py | 516 ++++++++++++++++++ tests/async/test_expect_misc.py | 8 +- ...est_interception.py => test_page_route.py} | 19 +- tests/sync/test_sync.py | 18 - 17 files changed, 797 insertions(+), 297 deletions(-) create mode 100644 tests/async/test_browsercontext_route.py rename tests/async/{test_interception.py => test_page_route.py} (98%) diff --git a/README.md b/README.md index fc5380287..3cee8be65 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 120.0.6099.28 | ✅ | ✅ | ✅ | +| Chromium 121.0.6167.16 | ✅ | ✅ | ✅ | | WebKit 17.4 | ✅ | ✅ | ✅ | -| Firefox 119.0 | ✅ | ✅ | ✅ | +| Firefox 120.0.1 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index e7e6f19a8..abb0f1f22 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -223,6 +223,8 @@ async def _on_route(self, route: Route) -> None: for route_handler in route_handlers: if not route_handler.matches(route.request.url): continue + if route_handler not in self._routes: + continue if route_handler.will_expire: self._routes.remove(route_handler) try: @@ -369,6 +371,11 @@ async def unroute( ) await self._update_interception_patterns() + async def unroute_all( + self, behavior: Literal["default", "ignoreErrors", "wait"] = None + ) -> None: + pass + async def _record_into_har( self, har: Union[Path, str], diff --git a/playwright/_impl/_element_handle.py b/playwright/_impl/_element_handle.py index 6c585bb0d..558cf3ac9 100644 --- a/playwright/_impl/_element_handle.py +++ b/playwright/_impl/_element_handle.py @@ -298,6 +298,7 @@ async def screenshot( scale: Literal["css", "device"] = None, mask: Sequence["Locator"] = None, maskColor: str = None, + style: str = None, ) -> bytes: params = locals_to_params(locals()) if "path" in params: diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 55955d089..a9cc92aba 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -523,6 +523,7 @@ async def screenshot( scale: Literal["css", "device"] = None, mask: Sequence["Locator"] = None, maskColor: str = None, + style: str = None, ) -> bytes: params = locals_to_params(locals()) return await self._with_element( diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index cfa571f74..cca6f2fc5 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -240,6 +240,8 @@ async def _on_route(self, route: Route) -> None: for route_handler in route_handlers: if not route_handler.matches(route.request.url): continue + if route_handler not in self._routes: + continue if route_handler.will_expire: self._routes.remove(route_handler) try: @@ -593,6 +595,11 @@ async def unroute( ) await self._update_interception_patterns() + async def unroute_all( + self, behavior: Literal["default", "ignoreErrors", "wait"] = None + ) -> None: + pass + async def route_from_har( self, har: Union[Path, str], @@ -639,6 +646,7 @@ async def screenshot( scale: Literal["css", "device"] = None, mask: Sequence["Locator"] = None, maskColor: str = None, + style: str = None, ) -> bytes: params = locals_to_params(locals()) if "path" in params: diff --git a/playwright/_impl/_set_input_files_helpers.py b/playwright/_impl/_set_input_files_helpers.py index a5db6c1da..793144313 100644 --- a/playwright/_impl/_set_input_files_helpers.py +++ b/playwright/_impl/_set_input_files_helpers.py @@ -62,12 +62,14 @@ async def convert_input_files( assert isinstance(item, (str, Path)) last_modified_ms = int(os.path.getmtime(item) * 1000) stream: WritableStream = from_channel( - await context._channel.send( - "createTempFile", - { - "name": os.path.basename(item), - "lastModifiedMs": last_modified_ms, - }, + await context._connection.wrap_api_call( + lambda: context._channel.send( + "createTempFile", + { + "name": os.path.basename(cast(str, item)), + "lastModifiedMs": last_modified_ms, + }, + ) ) ) await stream.copy(item) diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index d8276a125..cc3fe02f2 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -2769,7 +2769,8 @@ async def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """ElementHandle.screenshot @@ -2820,6 +2821,10 @@ async def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -2838,6 +2843,7 @@ async def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) @@ -4709,8 +4715,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -6245,8 +6256,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -9856,6 +9872,30 @@ async def unroute( ) ) + async def unroute_all( + self, + *, + behavior: typing.Optional[Literal["default", "ignoreErrors", "wait"]] = None + ) -> None: + """Page.unroute_all + + Removes all routes created with `page.route()` and `page.route_from_har()`. + + Parameters + ---------- + behavior : Union["default", "ignoreErrors", "wait", None] + Specifies wether to wait for already running handlers and what to do if they throw errors: + - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may + result in unhandled error + - `'wait'` - wait for current handler calls (if any) to finish + - `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers + after unrouting are silently caught + """ + + return mapping.from_maybe_impl( + await self._impl_obj.unroute_all(behavior=behavior) + ) + async def route_from_har( self, har: typing.Union[pathlib.Path, str], @@ -9924,7 +9964,8 @@ async def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """Page.screenshot @@ -9973,6 +10014,10 @@ async def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -9993,6 +10038,7 @@ async def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) @@ -10362,8 +10408,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -13640,6 +13691,30 @@ async def unroute( ) ) + async def unroute_all( + self, + *, + behavior: typing.Optional[Literal["default", "ignoreErrors", "wait"]] = None + ) -> None: + """BrowserContext.unroute_all + + Removes all routes created with `browser_context.route()` and `browser_context.route_from_har()`. + + Parameters + ---------- + behavior : Union["default", "ignoreErrors", "wait", None] + Specifies wether to wait for already running handlers and what to do if they throw errors: + - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may + result in unhandled error + - `'wait'` - wait for current handler calls (if any) to finish + - `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers + after unrouting are silently caught + """ + + return mapping.from_maybe_impl( + await self._impl_obj.unroute_all(behavior=behavior) + ) + async def route_from_har( self, har: typing.Union[pathlib.Path, str], @@ -14690,8 +14765,10 @@ async def launch( "msedge", "msedge-beta", "msedge-dev", "msedge-canary". Read more about using [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge). args : Union[Sequence[str], None] + **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + Additional arguments to pass to the browser instance. The list of Chromium flags can be found - [here](http://peter.sh/experiments/chromium-command-line-switches/). + [here](https://peter.sh/experiments/chromium-command-line-switches/). ignore_default_args : Union[Sequence[str], bool, None] If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. @@ -14845,8 +14922,10 @@ async def launch_persistent_context( resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. args : Union[Sequence[str], None] + **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + Additional arguments to pass to the browser instance. The list of Chromium flags can be found - [here](http://peter.sh/experiments/chromium-command-line-switches/). + [here](https://peter.sh/experiments/chromium-command-line-switches/). ignore_default_args : Union[Sequence[str], bool, None] If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. @@ -16144,8 +16223,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -16806,8 +16890,13 @@ def filter( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -17510,7 +17599,8 @@ async def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """Locator.screenshot @@ -17585,6 +17675,10 @@ async def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -17603,6 +17697,7 @@ async def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 09a308c2c..5fbf96d50 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -2803,7 +2803,8 @@ def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """ElementHandle.screenshot @@ -2854,6 +2855,10 @@ def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -2873,6 +2878,7 @@ def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) ) @@ -4799,8 +4805,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -6365,8 +6376,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -9922,6 +9938,30 @@ def unroute( ) ) + def unroute_all( + self, + *, + behavior: typing.Optional[Literal["default", "ignoreErrors", "wait"]] = None + ) -> None: + """Page.unroute_all + + Removes all routes created with `page.route()` and `page.route_from_har()`. + + Parameters + ---------- + behavior : Union["default", "ignoreErrors", "wait", None] + Specifies wether to wait for already running handlers and what to do if they throw errors: + - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may + result in unhandled error + - `'wait'` - wait for current handler calls (if any) to finish + - `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers + after unrouting are silently caught + """ + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.unroute_all(behavior=behavior)) + ) + def route_from_har( self, har: typing.Union[pathlib.Path, str], @@ -9992,7 +10032,8 @@ def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """Page.screenshot @@ -10041,6 +10082,10 @@ def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -10062,6 +10107,7 @@ def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) ) @@ -10442,8 +10488,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -13698,6 +13749,30 @@ def unroute( ) ) + def unroute_all( + self, + *, + behavior: typing.Optional[Literal["default", "ignoreErrors", "wait"]] = None + ) -> None: + """BrowserContext.unroute_all + + Removes all routes created with `browser_context.route()` and `browser_context.route_from_har()`. + + Parameters + ---------- + behavior : Union["default", "ignoreErrors", "wait", None] + Specifies wether to wait for already running handlers and what to do if they throw errors: + - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may + result in unhandled error + - `'wait'` - wait for current handler calls (if any) to finish + - `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers + after unrouting are silently caught + """ + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.unroute_all(behavior=behavior)) + ) + def route_from_har( self, har: typing.Union[pathlib.Path, str], @@ -14756,8 +14831,10 @@ def launch( "msedge", "msedge-beta", "msedge-dev", "msedge-canary". Read more about using [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge). args : Union[Sequence[str], None] + **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + Additional arguments to pass to the browser instance. The list of Chromium flags can be found - [here](http://peter.sh/experiments/chromium-command-line-switches/). + [here](https://peter.sh/experiments/chromium-command-line-switches/). ignore_default_args : Union[Sequence[str], bool, None] If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. @@ -14913,8 +14990,10 @@ def launch_persistent_context( resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. args : Union[Sequence[str], None] + **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + Additional arguments to pass to the browser instance. The list of Chromium flags can be found - [here](http://peter.sh/experiments/chromium-command-line-switches/). + [here](https://peter.sh/experiments/chromium-command-line-switches/). ignore_default_args : Union[Sequence[str], bool, None] If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. @@ -16238,8 +16317,13 @@ def locator( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -16902,8 +16986,13 @@ def filter( Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. has : Union[Locator, None] - Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer - one. For example, `article` that has `text=Playwright` matches `
Playwright
`. + Narrows down the results of the method to those which contain elements matching this relative locator. For example, + `article` that has `text=Playwright` matches `
Playwright
`. + + Inner locator **must be relative** to the outer locator and is queried starting with the outer locator match, not + the document root. For example, you can find `content` that has `div` in + `
Playwright
`. However, looking for `content` that has `article + div` will fail, because the inner locator must be relative and should not use any elements outside the `content`. Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. has_not : Union[Locator, None] @@ -17626,7 +17715,8 @@ def screenshot( caret: typing.Optional[Literal["hide", "initial"]] = None, scale: typing.Optional[Literal["css", "device"]] = None, mask: typing.Optional[typing.Sequence["Locator"]] = None, - mask_color: typing.Optional[str] = None + mask_color: typing.Optional[str] = None, + style: typing.Optional[str] = None ) -> bytes: """Locator.screenshot @@ -17701,6 +17791,10 @@ def screenshot( mask_color : Union[str, None] Specify the color of the overlay box for masked elements, in [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + style : Union[str, None] + Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + the Shadow DOM and applies to the inner frames. Returns ------- @@ -17720,6 +17814,7 @@ def screenshot( scale=scale, mask=mapping.to_impl(mask), maskColor=mask_color, + style=style, ) ) ) diff --git a/setup.py b/setup.py index bbf63928c..b7d008079 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ InWheel = None from wheel.bdist_wheel import bdist_wheel as BDistWheelCommand -driver_version = "1.40.0-beta-1700587209000" +driver_version = "1.41.0-alpha-1702670966000" def extractall(zip: zipfile.ZipFile, path: str) -> None: diff --git a/tests/async/conftest.py b/tests/async/conftest.py index 490f4440a..442d059f4 100644 --- a/tests/async/conftest.py +++ b/tests/async/conftest.py @@ -100,6 +100,17 @@ async def launch(**kwargs: Any) -> BrowserContext: await context.close() +@pytest.fixture(scope="session") +async def default_same_site_cookie_value(browser_name: str) -> str: + if browser_name == "chromium": + return "Lax" + if browser_name == "firefox": + return "None" + if browser_name == "webkit": + return "None" + raise Exception(f"Invalid browser_name: {browser_name}") + + @pytest.fixture async def context( context_factory: "Callable[..., asyncio.Future[BrowserContext]]", diff --git a/tests/async/test_asyncio.py b/tests/async/test_asyncio.py index 084d9eb41..1d4423afb 100644 --- a/tests/async/test_asyncio.py +++ b/tests/async/test_asyncio.py @@ -17,7 +17,7 @@ import pytest -from playwright.async_api import Page, async_playwright +from playwright.async_api import async_playwright from tests.server import Server from tests.utils import TARGET_CLOSED_ERROR_MESSAGE @@ -67,21 +67,3 @@ async def test_cancel_pending_protocol_call_on_playwright_stop(server: Server) - with pytest.raises(Exception) as exc_info: await pending_task assert TARGET_CLOSED_ERROR_MESSAGE in str(exc_info.value) - - -async def test_should_collect_stale_handles(page: Page, server: Server) -> None: - page.on("request", lambda _: None) - response = await page.goto(server.PREFIX + "/title.html") - assert response - for i in range(1000): - await page.evaluate( - """async () => { - const response = await fetch('/'); - await response.text(); - }""" - ) - with pytest.raises(Exception) as exc_info: - await response.all_headers() - assert "The object has been collected to prevent unbounded heap growth." in str( - exc_info.value - ) diff --git a/tests/async/test_browsercontext.py b/tests/async/test_browsercontext.py index 23fbd27de..cedaecf17 100644 --- a/tests/async/test_browsercontext.py +++ b/tests/async/test_browsercontext.py @@ -13,7 +13,6 @@ # limitations under the License. import asyncio -import re from typing import Any, List from urllib.parse import urlparse @@ -26,8 +25,6 @@ JSHandle, Page, Playwright, - Request, - Route, ) from tests.server import Server from tests.utils import TARGET_CLOSED_ERROR_MESSAGE @@ -474,114 +471,6 @@ def logme(t: JSHandle) -> int: assert result == 17 -async def test_route_should_intercept(context: BrowserContext, server: Server) -> None: - intercepted = [] - - def handle(route: Route, request: Request) -> None: - intercepted.append(True) - assert "empty.html" in request.url - assert request.headers["user-agent"] - assert request.method == "GET" - assert request.post_data is None - assert request.is_navigation_request() - assert request.resource_type == "document" - assert request.frame == page.main_frame - assert request.frame.url == "about:blank" - asyncio.create_task(route.continue_()) - - await context.route("**/empty.html", lambda route, request: handle(route, request)) - page = await context.new_page() - response = await page.goto(server.EMPTY_PAGE) - assert response - assert response.ok - assert intercepted == [True] - await context.close() - - -async def test_route_should_unroute(context: BrowserContext, server: Server) -> None: - page = await context.new_page() - - intercepted: List[int] = [] - - def handler(route: Route, request: Request, ordinal: int) -> None: - intercepted.append(ordinal) - asyncio.create_task(route.continue_()) - - await context.route("**/*", lambda route, request: handler(route, request, 1)) - await context.route( - "**/empty.html", lambda route, request: handler(route, request, 2) - ) - await context.route( - "**/empty.html", lambda route, request: handler(route, request, 3) - ) - - def handler4(route: Route, request: Request) -> None: - handler(route, request, 4) - - await context.route(re.compile("empty.html"), handler4) - - await page.goto(server.EMPTY_PAGE) - assert intercepted == [4] - - intercepted = [] - await context.unroute(re.compile("empty.html"), handler4) - await page.goto(server.EMPTY_PAGE) - assert intercepted == [3] - - intercepted = [] - await context.unroute("**/empty.html") - await page.goto(server.EMPTY_PAGE) - assert intercepted == [1] - - -async def test_route_should_yield_to_page_route( - context: BrowserContext, server: Server -) -> None: - await context.route( - "**/empty.html", - lambda route, request: asyncio.create_task( - route.fulfill(status=200, body="context") - ), - ) - - page = await context.new_page() - await page.route( - "**/empty.html", - lambda route, request: asyncio.create_task( - route.fulfill(status=200, body="page") - ), - ) - - response = await page.goto(server.EMPTY_PAGE) - assert response - assert response.ok - assert await response.text() == "page" - - -async def test_route_should_fall_back_to_context_route( - context: BrowserContext, server: Server -) -> None: - await context.route( - "**/empty.html", - lambda route, request: asyncio.create_task( - route.fulfill(status=200, body="context") - ), - ) - - page = await context.new_page() - await page.route( - "**/non-empty.html", - lambda route, request: asyncio.create_task( - route.fulfill(status=200, body="page") - ), - ) - - response = await page.goto(server.EMPTY_PAGE) - assert response - assert response.ok - assert await response.text() == "context" - - async def test_auth_should_fail_without_credentials( context: BrowserContext, server: Server ) -> None: diff --git a/tests/async/test_browsercontext_request_fallback.py b/tests/async/test_browsercontext_request_fallback.py index b198a4ebd..9abb14649 100644 --- a/tests/async/test_browsercontext_request_fallback.py +++ b/tests/async/test_browsercontext_request_fallback.py @@ -15,9 +15,7 @@ import asyncio from typing import Any, Callable, Coroutine, cast -import pytest - -from playwright.async_api import BrowserContext, Error, Page, Request, Route +from playwright.async_api import BrowserContext, Page, Request, Route from tests.server import Server @@ -96,61 +94,6 @@ async def test_should_chain_once( assert body == b"fulfilled one" -async def test_should_not_chain_fulfill( - page: Page, context: BrowserContext, server: Server -) -> None: - failed = [False] - - def handler(route: Route) -> None: - failed[0] = True - - await context.route("**/empty.html", handler) - await context.route( - "**/empty.html", - lambda route: asyncio.create_task(route.fulfill(status=200, body="fulfilled")), - ) - await context.route( - "**/empty.html", lambda route: asyncio.create_task(route.fallback()) - ) - - response = await page.goto(server.EMPTY_PAGE) - assert response - body = await response.body() - assert body == b"fulfilled" - assert not failed[0] - - -async def test_should_not_chain_abort( - page: Page, - context: BrowserContext, - server: Server, - is_webkit: bool, - is_firefox: bool, -) -> None: - failed = [False] - - def handler(route: Route) -> None: - failed[0] = True - - await context.route("**/empty.html", handler) - await context.route( - "**/empty.html", lambda route: asyncio.create_task(route.abort()) - ) - await context.route( - "**/empty.html", lambda route: asyncio.create_task(route.fallback()) - ) - - with pytest.raises(Error) as excinfo: - await page.goto(server.EMPTY_PAGE) - if is_webkit: - assert "Blocked by Web Inspector" in excinfo.value.message - elif is_firefox: - assert "NS_ERROR_FAILURE" in excinfo.value.message - else: - assert "net::ERR_FAILED" in excinfo.value.message - assert not failed[0] - - async def test_should_fall_back_after_exception( page: Page, context: BrowserContext, server: Server ) -> None: @@ -352,48 +295,3 @@ def _handler2(route: Route) -> None: assert post_data_buffer == ["\x00\x01\x02\x03\x04"] assert server_request.method == b"POST" assert server_request.post_body == b"\x00\x01\x02\x03\x04" - - -async def test_should_chain_fallback_into_page( - context: BrowserContext, page: Page, server: Server -) -> None: - intercepted = [] - - def _handler1(route: Route) -> None: - intercepted.append(1) - asyncio.create_task(route.fallback()) - - await context.route("**/empty.html", _handler1) - - def _handler2(route: Route) -> None: - intercepted.append(2) - asyncio.create_task(route.fallback()) - - await context.route("**/empty.html", _handler2) - - def _handler3(route: Route) -> None: - intercepted.append(3) - asyncio.create_task(route.fallback()) - - await context.route("**/empty.html", _handler3) - - def _handler4(route: Route) -> None: - intercepted.append(4) - asyncio.create_task(route.fallback()) - - await page.route("**/empty.html", _handler4) - - def _handler5(route: Route) -> None: - intercepted.append(5) - asyncio.create_task(route.fallback()) - - await page.route("**/empty.html", _handler5) - - def _handler6(route: Route) -> None: - intercepted.append(6) - asyncio.create_task(route.fallback()) - - await page.route("**/empty.html", _handler6) - - await page.goto(server.EMPTY_PAGE) - assert intercepted == [6, 5, 4, 3, 2, 1] diff --git a/tests/async/test_browsercontext_route.py b/tests/async/test_browsercontext_route.py new file mode 100644 index 000000000..5a21b52ee --- /dev/null +++ b/tests/async/test_browsercontext_route.py @@ -0,0 +1,516 @@ +# 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 +import re +from typing import Awaitable, Callable, List + +import pytest + +from playwright.async_api import ( + Browser, + BrowserContext, + Error, + Page, + Request, + Route, + expect, +) +from tests.server import Server, TestServerRequest +from tests.utils import must + + +async def test_route_should_intercept(context: BrowserContext, server: Server) -> None: + intercepted = [] + + def handle(route: Route, request: Request) -> None: + intercepted.append(True) + assert "empty.html" in request.url + assert request.headers["user-agent"] + assert request.method == "GET" + assert request.post_data is None + assert request.is_navigation_request() + assert request.resource_type == "document" + assert request.frame == page.main_frame + assert request.frame.url == "about:blank" + asyncio.create_task(route.continue_()) + + await context.route("**/empty.html", lambda route, request: handle(route, request)) + page = await context.new_page() + response = await page.goto(server.EMPTY_PAGE) + assert response + assert response.ok + assert intercepted == [True] + await context.close() + + +async def test_route_should_unroute(context: BrowserContext, server: Server) -> None: + page = await context.new_page() + + intercepted: List[int] = [] + + def handler(route: Route, request: Request, ordinal: int) -> None: + intercepted.append(ordinal) + asyncio.create_task(route.continue_()) + + await context.route("**/*", lambda route, request: handler(route, request, 1)) + await context.route( + "**/empty.html", lambda route, request: handler(route, request, 2) + ) + await context.route( + "**/empty.html", lambda route, request: handler(route, request, 3) + ) + + def handler4(route: Route, request: Request) -> None: + handler(route, request, 4) + + await context.route(re.compile("empty.html"), handler4) + + await page.goto(server.EMPTY_PAGE) + assert intercepted == [4] + + intercepted = [] + await context.unroute(re.compile("empty.html"), handler4) + await page.goto(server.EMPTY_PAGE) + assert intercepted == [3] + + intercepted = [] + await context.unroute("**/empty.html") + await page.goto(server.EMPTY_PAGE) + assert intercepted == [1] + + +async def test_route_should_yield_to_page_route( + context: BrowserContext, server: Server +) -> None: + await context.route( + "**/empty.html", + lambda route, request: asyncio.create_task( + route.fulfill(status=200, body="context") + ), + ) + + page = await context.new_page() + await page.route( + "**/empty.html", + lambda route, request: asyncio.create_task( + route.fulfill(status=200, body="page") + ), + ) + + response = await page.goto(server.EMPTY_PAGE) + assert response + assert response.ok + assert await response.text() == "page" + + +async def test_route_should_fall_back_to_context_route( + context: BrowserContext, server: Server +) -> None: + await context.route( + "**/empty.html", + lambda route, request: asyncio.create_task( + route.fulfill(status=200, body="context") + ), + ) + + page = await context.new_page() + await page.route( + "**/non-empty.html", + lambda route, request: asyncio.create_task( + route.fulfill(status=200, body="page") + ), + ) + + response = await page.goto(server.EMPTY_PAGE) + assert response + assert response.ok + assert await response.text() == "context" + + +async def test_should_support_set_cookie_header( + context_factory: "Callable[..., Awaitable[BrowserContext]]", + default_same_site_cookie_value: str, +) -> None: + context = await context_factory() + page = await context.new_page() + await page.route( + "https://example.com/", + lambda route: route.fulfill( + headers={ + "Set-Cookie": "name=value; domain=.example.com; Path=/", + }, + content_type="text/html", + body="done", + ), + ) + await page.goto("https://example.com") + cookies = await context.cookies() + assert len(cookies) == 1 + assert cookies[0] == { + "sameSite": default_same_site_cookie_value, + "name": "name", + "value": "value", + "domain": ".example.com", + "path": "/", + "expires": -1, + "httpOnly": False, + "secure": False, + } + + +@pytest.mark.skip_browser("webkit") +async def test_should_ignore_secure_set_cookie_header_for_insecure_request( + context_factory: "Callable[..., Awaitable[BrowserContext]]", +) -> None: + context = await context_factory() + page = await context.new_page() + await page.route( + "http://example.com/", + lambda route: route.fulfill( + headers={ + "Set-Cookie": "name=value; domain=.example.com; Path=/; Secure", + }, + content_type="text/html", + body="done", + ), + ) + await page.goto("http://example.com") + cookies = await context.cookies() + assert len(cookies) == 0 + + +async def test_should_use_set_cookie_header_in_future_requests( + context_factory: "Callable[..., Awaitable[BrowserContext]]", + server: Server, + default_same_site_cookie_value: str, +) -> None: + context = await context_factory() + page = await context.new_page() + + await page.route( + server.EMPTY_PAGE, + lambda route: route.fulfill( + headers={ + "Set-Cookie": "name=value", + }, + content_type="text/html", + body="done", + ), + ) + await page.goto(server.EMPTY_PAGE) + assert await context.cookies() == [ + { + "sameSite": default_same_site_cookie_value, + "name": "name", + "value": "value", + "domain": "localhost", + "path": "/", + "expires": -1, + "httpOnly": False, + "secure": False, + } + ] + + cookie = "" + + def _handle_request(request: TestServerRequest) -> None: + nonlocal cookie + cookie = request.getHeader("cookie") + request.finish() + + server.set_route("/foo.html", _handle_request) + await page.goto(server.PREFIX + "/foo.html") + assert cookie == "name=value" + + +async def test_should_work_with_ignore_https_errors( + browser: Browser, https_server: Server +) -> None: + context = await browser.new_context(ignore_https_errors=True) + page = await context.new_page() + + await page.route("**/*", lambda route: route.continue_()) + response = await page.goto(https_server.EMPTY_PAGE) + assert must(response).status == 200 + await context.close() + + +async def test_should_support_the_times_parameter_with_route_matching( + context: BrowserContext, page: Page, server: Server +) -> None: + intercepted: List[int] = [] + + async def _handle_request(route: Route) -> None: + intercepted.append(1) + await route.continue_() + + await context.route("**/empty.html", _handle_request, times=1) + await page.goto(server.EMPTY_PAGE) + await page.goto(server.EMPTY_PAGE) + await page.goto(server.EMPTY_PAGE) + assert len(intercepted) == 1 + + +async def test_should_work_if_handler_with_times_parameter_was_removed_from_another_handler( + context: BrowserContext, page: Page, server: Server +) -> None: + intercepted = [] + + async def _handler(route: Route) -> None: + intercepted.append("first") + await route.continue_() + + await context.route("**/*", _handler, times=1) + + async def _handler2(route: Route) -> None: + intercepted.append("second") + await context.unroute("**/*", _handler) + await route.fallback() + + await context.route("**/*", _handler2) + await page.goto(server.EMPTY_PAGE) + assert intercepted == ["second"] + intercepted.clear() + await page.goto(server.EMPTY_PAGE) + assert intercepted == ["second"] + + +async def test_should_support_async_handler_with_times( + context: BrowserContext, page: Page, server: Server +) -> None: + async def _handler(route: Route) -> None: + await asyncio.sleep(0.1) + await route.fulfill( + body="intercepted", + content_type="text/html", + ) + + await context.route("**/empty.html", _handler, times=1) + await page.goto(server.EMPTY_PAGE) + await expect(page.locator("body")).to_have_text("intercepted") + await page.goto(server.EMPTY_PAGE) + await expect(page.locator("body")).not_to_have_text("intercepted") + + +async def test_should_override_post_body_with_empty_string( + context: BrowserContext, server: Server, page: Page +) -> None: + await context.route( + "**/empty.html", + lambda route: route.continue_( + post_data="", + ), + ) + + req = await asyncio.gather( + server.wait_for_request("/empty.html"), + page.set_content( + """ + + """ + % server.EMPTY_PAGE + ), + ) + + assert req[0].post_body is None + + +async def test_should_chain_fallback( + context: BrowserContext, page: Page, server: Server +) -> None: + intercepted: List[int] = [] + + async def _handler1(route: Route) -> None: + intercepted.append(1) + await route.fallback() + + await context.route("**/empty.html", _handler1) + + async def _handler2(route: Route) -> None: + intercepted.append(2) + await route.fallback() + + await context.route("**/empty.html", _handler2) + + async def _handler3(route: Route) -> None: + intercepted.append(3) + await route.fallback() + + await context.route("**/empty.html", _handler3) + await page.goto(server.EMPTY_PAGE) + assert intercepted == [3, 2, 1] + + +async def test_should_chain_fallback_with_dynamic_url( + context: BrowserContext, page: Page, server: Server +) -> None: + intercepted: List[int] = [] + + async def _handler1(route: Route) -> None: + intercepted.append(1) + await route.fallback(url=server.EMPTY_PAGE) + + await context.route("**/bar", _handler1) + + async def _handler2(route: Route) -> None: + intercepted.append(2) + await route.fallback(url="http://localhost/bar") + + await context.route("**/foo", _handler2) + + async def _handler3(route: Route) -> None: + intercepted.append(3) + await route.fallback(url="http://localhost/foo") + + await context.route("**/empty.html", _handler3) + await page.goto(server.EMPTY_PAGE) + assert intercepted == [3, 2, 1] + + +async def test_should_not_chain_fulfill( + page: Page, context: BrowserContext, server: Server +) -> None: + failed = [False] + + def handler(route: Route) -> None: + failed[0] = True + + await context.route("**/empty.html", handler) + await context.route( + "**/empty.html", + lambda route: asyncio.create_task(route.fulfill(status=200, body="fulfilled")), + ) + await context.route( + "**/empty.html", lambda route: asyncio.create_task(route.fallback()) + ) + + response = await page.goto(server.EMPTY_PAGE) + assert response + body = await response.body() + assert body == b"fulfilled" + assert not failed[0] + + +async def test_should_not_chain_abort( + page: Page, + context: BrowserContext, + server: Server, + is_webkit: bool, + is_firefox: bool, +) -> None: + failed = [False] + + def handler(route: Route) -> None: + failed[0] = True + + await context.route("**/empty.html", handler) + await context.route( + "**/empty.html", lambda route: asyncio.create_task(route.abort()) + ) + await context.route( + "**/empty.html", lambda route: asyncio.create_task(route.fallback()) + ) + + with pytest.raises(Error) as excinfo: + await page.goto(server.EMPTY_PAGE) + if is_webkit: + assert "Blocked by Web Inspector" in excinfo.value.message + elif is_firefox: + assert "NS_ERROR_FAILURE" in excinfo.value.message + else: + assert "net::ERR_FAILED" in excinfo.value.message + assert not failed[0] + + +async def test_should_chain_fallback_into_page( + context: BrowserContext, page: Page, server: Server +) -> None: + intercepted = [] + + def _handler1(route: Route) -> None: + intercepted.append(1) + asyncio.create_task(route.fallback()) + + await context.route("**/empty.html", _handler1) + + def _handler2(route: Route) -> None: + intercepted.append(2) + asyncio.create_task(route.fallback()) + + await context.route("**/empty.html", _handler2) + + def _handler3(route: Route) -> None: + intercepted.append(3) + asyncio.create_task(route.fallback()) + + await context.route("**/empty.html", _handler3) + + def _handler4(route: Route) -> None: + intercepted.append(4) + asyncio.create_task(route.fallback()) + + await page.route("**/empty.html", _handler4) + + def _handler5(route: Route) -> None: + intercepted.append(5) + asyncio.create_task(route.fallback()) + + await page.route("**/empty.html", _handler5) + + def _handler6(route: Route) -> None: + intercepted.append(6) + asyncio.create_task(route.fallback()) + + await page.route("**/empty.html", _handler6) + + await page.goto(server.EMPTY_PAGE) + assert intercepted == [6, 5, 4, 3, 2, 1] + + +async def test_should_fall_back_async( + page: Page, context: BrowserContext, server: Server +) -> None: + intercepted = [] + + async def _handler1(route: Route) -> None: + intercepted.append(1) + await asyncio.sleep(0.1) + await route.fallback() + + await context.route("**/empty.html", _handler1) + + async def _handler2(route: Route) -> None: + intercepted.append(2) + await asyncio.sleep(0.1) + await route.fallback() + + await context.route("**/empty.html", _handler2) + + async def _handler3(route: Route) -> None: + intercepted.append(3) + await asyncio.sleep(0.1) + await route.fallback() + + await context.route("**/empty.html", _handler3) + + await page.goto(server.EMPTY_PAGE) + assert intercepted == [3, 2, 1] diff --git a/tests/async/test_expect_misc.py b/tests/async/test_expect_misc.py index 414909b67..9c6a8aa01 100644 --- a/tests/async/test_expect_misc.py +++ b/tests/async/test_expect_misc.py @@ -14,7 +14,7 @@ import pytest -from playwright.async_api import Page, expect +from playwright.async_api import Page, TimeoutError, expect from tests.server import Server @@ -72,3 +72,9 @@ async def test_to_be_in_viewport_should_report_intersection_even_if_fully_covere """ ) await expect(page.locator("h1")).to_be_in_viewport() + + +async def test_should_have_timeout_error_name(page: Page) -> None: + with pytest.raises(TimeoutError) as exc_info: + await page.wait_for_selector("#not-found", timeout=1) + assert exc_info.value.name == "TimeoutError" diff --git a/tests/async/test_interception.py b/tests/async/test_page_route.py similarity index 98% rename from tests/async/test_interception.py rename to tests/async/test_page_route.py index 01f932360..8e0b74130 100644 --- a/tests/async/test_interception.py +++ b/tests/async/test_page_route.py @@ -1010,21 +1010,28 @@ async def handle_request(route: Route) -> None: assert len(intercepted) == 1 -async def test_context_route_should_support_times_parameter( +async def test_should_work_if_handler_with_times_parameter_was_removed_from_another_handler( context: BrowserContext, page: Page, server: Server ) -> None: intercepted = [] - async def handle_request(route: Route) -> None: + async def handler(route: Route) -> None: + intercepted.append("first") await route.continue_() - intercepted.append(True) - await context.route("**/empty.html", handle_request, times=1) + await page.route("**/*", handler, times=1) + async def handler2(route: Route) -> None: + intercepted.append("second") + await page.unroute("**/*", handler) + await route.fallback() + + await page.route("**/*", handler2) await page.goto(server.EMPTY_PAGE) + assert intercepted == ["second"] + intercepted.clear() await page.goto(server.EMPTY_PAGE) - await page.goto(server.EMPTY_PAGE) - assert len(intercepted) == 1 + assert intercepted == ["second"] async def test_should_fulfill_with_global_fetch_result( diff --git a/tests/sync/test_sync.py b/tests/sync/test_sync.py index 3f27a4140..fbd94b932 100644 --- a/tests/sync/test_sync.py +++ b/tests/sync/test_sync.py @@ -344,21 +344,3 @@ def test_call_sync_method_after_playwright_close_with_own_loop( p.start() p.join() assert p.exitcode == 0 - - -def test_should_collect_stale_handles(page: Page, server: Server) -> None: - page.on("request", lambda request: None) - response = page.goto(server.PREFIX + "/title.html") - assert response - for i in range(1000): - page.evaluate( - """async () => { - const response = await fetch('/'); - await response.text(); - }""" - ) - with pytest.raises(Exception) as exc_info: - response.all_headers() - assert "The object has been collected to prevent unbounded heap growth." in str( - exc_info.value - ) From a2ec2bd9641ec5516c72f290c5068f81c0cf8568 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 12 Jan 2024 18:29:43 +0100 Subject: [PATCH 2/6] more --- playwright/_impl/_browser_context.py | 47 ++- playwright/_impl/_har_router.py | 4 +- playwright/_impl/_helper.py | 42 +++ playwright/_impl/_network.py | 67 +++- playwright/_impl/_page.py | 44 ++- tests/async/test_browsercontext_route.py | 4 +- tests/async/test_har.py | 41 ++- tests/async/test_unroute_behavior.py | 451 +++++++++++++++++++++++ 8 files changed, 661 insertions(+), 39 deletions(-) create mode 100644 tests/async/test_unroute_behavior.py diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index abb0f1f22..c05b427f2 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -196,6 +196,7 @@ def __init__( self.Events.Close, lambda context: self._closed_future.set_result(True) ) self._close_reason: Optional[str] = None + self._har_routers: List[HarRouter] = [] self._set_event_to_subscription_mapping( { BrowserContext.Events.Console: "console", @@ -219,8 +220,12 @@ def _on_page(self, page: Page) -> None: async def _on_route(self, route: Route) -> None: route._context = self + page = route.request._safe_page() route_handlers = self._routes.copy() for route_handler in route_handlers: + # If the page or the context was closed we stall all requests right away. + if (page and page._close_was_called) or self._close_was_called: + return if not route_handler.matches(route.request.url): continue if route_handler not in self._routes: @@ -238,7 +243,12 @@ async def _on_route(self, route: Route) -> None: ) if handled: return - await route._internal_continue(is_internal=True) + try: + # If the page is closed or unrouteAll() was called without waiting and interception disabled, + # the method will throw an error - silence it. + await route._internal_continue(is_internal=True) + except Exception: + pass def _on_binding(self, binding_call: BindingCall) -> None: func = self._bindings.get(binding_call._initializer["name"]) @@ -363,18 +373,37 @@ async def route( async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None ) -> None: - self._routes = list( - filter( - lambda r: r.matcher.match != url or (handler and r.handler != handler), - self._routes, - ) - ) + removed = [] + remaining = [] + for route in self._routes: + if route.matcher.match != url or (handler and route.handler != handler): + remaining.append(route) + else: + removed.append(route) + await self._unroute_internal(removed, remaining, "default") + + async def _unroute_internal( + self, + removed: List[RouteHandler], + remaining: List[RouteHandler], + behavior: Literal["default", "ignoreErrors", "wait"] = None, + ) -> None: + self._routes = remaining await self._update_interception_patterns() + if behavior is None or behavior == "default": + return + await asyncio.gather(*map(lambda router: router.stop(behavior), removed)) # type: ignore + + def _dispose_har_routers(self) -> None: + for router in self._har_routers: + router.dispose() + self._har_routers = [] async def unroute_all( self, behavior: Literal["default", "ignoreErrors", "wait"] = None ) -> None: - pass + await self._unroute_internal(self._routes, [], behavior) + self._dispose_har_routers() async def _record_into_har( self, @@ -426,6 +455,7 @@ async def route_from_har( not_found_action=notFound or "abort", url_matcher=url, ) + self._har_routers.append(router) await router.add_context_route(self) async def _update_interception_patterns(self) -> None: @@ -457,6 +487,7 @@ def _on_close(self) -> None: if self._browser: self._browser._contexts.remove(self) + self._dispose_har_routers() self.emit(BrowserContext.Events.Close, self) async def close(self, reason: str = None) -> None: diff --git a/playwright/_impl/_har_router.py b/playwright/_impl/_har_router.py index a96ba70bf..3e56fd019 100644 --- a/playwright/_impl/_har_router.py +++ b/playwright/_impl/_har_router.py @@ -102,16 +102,14 @@ async def add_context_route(self, context: "BrowserContext") -> None: url=self._options_url_match or "**/*", handler=lambda route, _: asyncio.create_task(self._handle(route)), ) - context.once("close", lambda _: self._dispose()) async def add_page_route(self, page: "Page") -> None: await page.route( url=self._options_url_match or "**/*", handler=lambda route, _: asyncio.create_task(self._handle(route)), ) - page.once("close", lambda _: self._dispose()) - def _dispose(self) -> None: + def dispose(self) -> None: asyncio.create_task( self._local_utils._channel.send("harClose", {"harId": self._har_id}) ) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index b68ad6f0b..1ab42d783 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -30,6 +30,7 @@ List, Optional, Pattern, + Set, TypeVar, Union, cast, @@ -257,6 +258,15 @@ def monotonic_time() -> int: return math.floor(time.monotonic() * 1000) +class RouteHandlerInvocation: + complete: "asyncio.Future" + route: "Route" + + def __init__(self, complete: "asyncio.Future", route: "Route") -> None: + self.complete = complete + self.route = route + + class RouteHandler: def __init__( self, @@ -270,11 +280,29 @@ def __init__( self._times = times if times else math.inf self._handled_count = 0 self._is_sync = is_sync + self._ignore_exception = False + self._active_invocations: Set[RouteHandlerInvocation] = set() def matches(self, request_url: str) -> bool: return self.matcher.matches(request_url) async def handle(self, route: "Route") -> bool: + handler_invocation = RouteHandlerInvocation( + asyncio.get_running_loop().create_future(), route + ) + self._active_invocations.add(handler_invocation) + try: + return await self._handle_internal(route) + except Exception as e: + # If the handler was stopped (without waiting for completion), we ignore all exceptions. + if self._ignore_exception: + return False + raise e + finally: + handler_invocation.complete.set_result(None) + self._active_invocations.remove(handler_invocation) + + async def _handle_internal(self, route: "Route") -> bool: handled_future = route._start_handling() handler_task = [] @@ -297,6 +325,20 @@ def impl() -> None: [handled, *_] = await asyncio.gather(handled_future, *handler_task) return handled + async def stop(self, behavior: Literal["ignoreErrors", "wait"]) -> None: + # When a handler is manually unrouted or its page/context is closed we either + # - wait for the current handler invocations to finish + # - or do not wait, if the user opted out of it, but swallow all exceptions + # that happen after the unroute/close. + if behavior == "ignoreErrors": + self._ignore_exception = True + else: + tasks = [] + for activation in self._active_invocations: + if not activation.route._did_throw: + tasks.append(activation.complete) + await asyncio.gather(*tasks) + @property def will_expire(self) -> bool: return self._handled_count + 1 >= self._times diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 102767cf6..03aa53588 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -267,6 +267,9 @@ def _target_closed_future(self) -> asyncio.Future: return asyncio.Future() return page._closed_or_crashed_future + def _safe_page(self) -> "Optional[Page]": + return cast("Frame", from_channel(self._initializer["frame"]))._page + class Route(ChannelOwner): def __init__( @@ -275,6 +278,7 @@ def __init__( super().__init__(parent, type, guid, initializer) self._handling_future: Optional[asyncio.Future["bool"]] = None self._context: "BrowserContext" = cast("BrowserContext", None) + self._did_throw = False def _start_handling(self) -> "asyncio.Future[bool]": self._handling_future = asyncio.Future() @@ -298,17 +302,17 @@ def request(self) -> Request: return from_channel(self._initializer["request"]) async def abort(self, errorCode: str = None) -> None: - self._check_not_handled() - await self._race_with_page_close( - self._channel.send( - "abort", - { - "errorCode": errorCode, - "requestUrl": self.request._initializer["url"], - }, + await self._handle_route( + lambda: self._race_with_page_close( + self._channel.send( + "abort", + { + "errorCode": errorCode, + "requestUrl": self.request._initializer["url"], + }, + ) ) ) - self._report_handled(True) async def fulfill( self, @@ -320,7 +324,22 @@ async def fulfill( contentType: str = None, response: "APIResponse" = None, ) -> None: - self._check_not_handled() + await self._handle_route( + lambda: self._inner_fulfill( + status, headers, body, json, path, contentType, response + ) + ) + + async def _inner_fulfill( + self, + status: int = None, + headers: Dict[str, str] = None, + body: Union[str, bytes] = None, + json: Any = None, + path: Union[str, Path] = None, + contentType: str = None, + response: "APIResponse" = None, + ) -> None: params = locals_to_params(locals()) if json is not None: @@ -375,7 +394,15 @@ async def fulfill( params["requestUrl"] = self.request._initializer["url"] await self._race_with_page_close(self._channel.send("fulfill", params)) - self._report_handled(True) + + async def _handle_route(self, callback: Callable) -> None: + self._check_not_handled() + try: + await callback() + self._report_handled(True) + except Exception as e: + self._did_throw = True + raise e async def fetch( self, @@ -418,10 +445,12 @@ async def continue_( postData: Union[Any, str, bytes] = None, ) -> None: overrides = cast(FallbackOverrideParameters, locals_to_params(locals())) - self._check_not_handled() - self.request._apply_fallback_overrides(overrides) - await self._internal_continue() - self._report_handled(True) + + async def _inner() -> None: + self.request._apply_fallback_overrides(overrides) + await self._internal_continue() + + return await self._handle_route(_inner) def _internal_continue( self, is_internal: bool = False @@ -458,11 +487,11 @@ async def continue_route() -> None: return continue_route() async def _redirected_navigation_request(self, url: str) -> None: - self._check_not_handled() - await self._race_with_page_close( - self._channel.send("redirectNavigationRequest", {"url": url}) + await self._handle_route( + lambda: self._race_with_page_close( + self._channel.send("redirectNavigationRequest", {"url": url}) + ) ) - self._report_handled(True) async def _race_with_page_close(self, future: Coroutine) -> None: fut = asyncio.create_task(future) diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index cca6f2fc5..ac6a55002 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -152,6 +152,8 @@ def __init__( self._video: Optional[Video] = None self._opener = cast("Page", from_nullable_channel(initializer.get("opener"))) self._close_reason: Optional[str] = None + self._close_was_called = False + self._har_routers: List[HarRouter] = [] self._channel.on( "bindingCall", @@ -238,6 +240,9 @@ async def _on_route(self, route: Route) -> None: route._context = self.context route_handlers = self._routes.copy() for route_handler in route_handlers: + # If the page was closed we stall all requests right away. + if self._close_was_called or self.context._close_was_called: + return if not route_handler.matches(route.request.url): continue if route_handler not in self._routes: @@ -274,6 +279,7 @@ def _on_close(self) -> None: 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) def _on_crash(self) -> None: @@ -587,18 +593,42 @@ async def route( async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None ) -> None: - self._routes = list( - filter( - lambda r: r.matcher.match != url or (handler and r.handler != handler), - self._routes, + removed = [] + remaining = [] + for route in self._routes: + if route.matcher.match != url or (handler and route.handler != handler): + remaining.append(route) + else: + removed.append(route) + await self._unroute_internal(removed, remaining, "default") + + async def _unroute_internal( + self, + removed: List[RouteHandler], + remaining: List[RouteHandler], + behavior: Literal["default", "ignoreErrors", "wait"] = None, + ) -> None: + self._routes = remaining + await self._update_interception_patterns() + if behavior is None or behavior == "default": + return + await asyncio.gather( + *map( + lambda route: route.stop(behavior), # type: ignore + removed, ) ) - await self._update_interception_patterns() + + def _dispose_har_routers(self) -> None: + for router in self._har_routers: + router.dispose() + self._har_routers = [] async def unroute_all( self, behavior: Literal["default", "ignoreErrors", "wait"] = None ) -> None: - pass + await self._unroute_internal(self._routes, [], behavior) + self._dispose_har_routers() async def route_from_har( self, @@ -624,6 +654,7 @@ async def route_from_har( not_found_action=notFound or "abort", url_matcher=url, ) + self._har_routers.append(router) await router.add_page_route(self) async def _update_interception_patterns(self) -> None: @@ -675,6 +706,7 @@ async def title(self) -> str: async def close(self, runBeforeUnload: bool = None, reason: str = None) -> None: self._close_reason = reason + self._close_was_called = True try: await self._channel.send("close", locals_to_params(locals())) if self._owned_context: diff --git a/tests/async/test_browsercontext_route.py b/tests/async/test_browsercontext_route.py index 5a21b52ee..5f4140b63 100644 --- a/tests/async/test_browsercontext_route.py +++ b/tests/async/test_browsercontext_route.py @@ -227,7 +227,7 @@ async def test_should_use_set_cookie_header_in_future_requests( def _handle_request(request: TestServerRequest) -> None: nonlocal cookie - cookie = request.getHeader("cookie") + cookie = must(request.getHeader("cookie")) request.finish() server.set_route("/foo.html", _handle_request) @@ -331,7 +331,7 @@ async def test_should_override_post_body_with_empty_string( ), ) - assert req[0].post_body is None + assert req[0].post_body == "" async def test_should_chain_fallback( diff --git a/tests/async/test_har.py b/tests/async/test_har.py index 31a34f8fa..bb9923fb8 100644 --- a/tests/async/test_har.py +++ b/tests/async/test_har.py @@ -18,12 +18,13 @@ import re import zipfile from pathlib import Path -from typing import cast +from typing import Awaitable, Callable, cast import pytest from playwright.async_api import Browser, BrowserContext, Error, Page, Route, expect from tests.server import Server +from tests.utils import must async def test_should_work(browser: Browser, server: Server, tmpdir: Path) -> None: @@ -647,6 +648,44 @@ async def test_should_update_har_zip_for_context( ) +async def test_page_unroute_all_should_stop_page_route_from_har( + context_factory: Callable[[], Awaitable[BrowserContext]], + server: Server, + asset: Path, +) -> None: + har_path = asset / "har-fulfill.har" + context1 = await context_factory() + page1 = await context1.new_page() + # The har file contains requests for another domain, so the router + # is expected to abort all requests. + await page1.route_from_har(har_path, not_found="abort") + with pytest.raises(Error) as exc_info: + await page1.goto(server.EMPTY_PAGE) + assert exc_info.value + await page1.unroute_all(behavior="wait") + response = must(await page1.goto(server.EMPTY_PAGE)) + assert response.ok + + +async def test_context_unroute_call_should_stop_context_route_from_har( + context_factory: Callable[[], Awaitable[BrowserContext]], + server: Server, + asset: Path, +) -> None: + har_path = asset / "har-fulfill.har" + context1 = await context_factory() + page1 = await context1.new_page() + # The har file contains requests for another domain, so the router + # is expected to abort all requests. + await context1.route_from_har(har_path, not_found="abort") + with pytest.raises(Error) as exc_info: + await page1.goto(server.EMPTY_PAGE) + assert exc_info.value + await context1.unroute_all(behavior="wait") + response = must(await page1.goto(server.EMPTY_PAGE)) + assert must(response).ok + + async def test_should_update_har_zip_for_page( browser: Browser, server: Server, tmpdir: Path ) -> None: diff --git a/tests/async/test_unroute_behavior.py b/tests/async/test_unroute_behavior.py new file mode 100644 index 000000000..8a9b46b3b --- /dev/null +++ b/tests/async/test_unroute_behavior.py @@ -0,0 +1,451 @@ +# 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 +import re + +from playwright.async_api import BrowserContext, Error, Page, Route +from tests.server import Server +from tests.utils import must + + +async def test_context_unroute_should_not_wait_for_pending_handlers_to_complete( + page: Page, context: BrowserContext, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.continue_() + + await context.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await context.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + await context.unroute( + re.compile(".*"), + _handler2, + ) + route_barrier_future.set_result(None) + await navigation_task + assert second_handler_called + + +async def test_context_unroute_all_removes_all_handlers( + page: Page, context: BrowserContext, server: Server +) -> None: + await context.route( + "**/*", + lambda route: route.abort(), + ) + await context.route( + "**/empty.html", + lambda route: route.abort(), + ) + await context.unroute_all() + await page.goto(server.EMPTY_PAGE) + + +async def test_context_unroute_all_should_not_wait_for_pending_handlers_to_complete( + page: Page, context: BrowserContext, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await context.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await context.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + nonlocal did_unroute + await context.unroute_all(behavior="wait") + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + assert did_unroute is False + route_barrier_future.set_result(None) + await unroute_task + assert did_unroute + await navigation_task + assert second_handler_called is False + + +async def test_context_unroute_all_should_not_wait_for_pending_handlers_to_complete_if_behavior_is_ignore_errors( + page: Page, context: BrowserContext, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await context.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + raise Exception("Handler error") + + await context.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + await context.unroute_all(behavior="ignoreErrors") + nonlocal did_unroute + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + await unroute_task + assert did_unroute + route_barrier_future.set_result(None) + try: + await navigation_task + except Error: + pass + # The error in the unrouted handler should be silently caught and remaining handler called. + assert not second_handler_called + + +async def test_page_close_should_not_wait_for_active_route_handlers_on_the_owning_context( + page: Page, context: BrowserContext, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await context.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + await page.route( + re.compile(".*"), + lambda route: route.fallback(), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + await route_future + await page.close() + + +async def test_context_close_should_not_wait_for_active_route_handlers_on_the_owned_pages( + page: Page, context: BrowserContext, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + await page.route(re.compile(".*"), lambda route: route.fallback()) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + await route_future + await context.close() + + +async def test_page_unroute_should_not_wait_for_pending_handlers_to_complete( + page: Page, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.continue_() + + await page.route( + re.compile(".*"), + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await page.route( + re.compile(".*"), + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + await page.unroute( + re.compile(".*"), + _handler2, + ) + route_barrier_future.set_result(None) + await navigation_task + assert second_handler_called + + +async def test_page_unroute_all_removes_all_routes(page: Page, server: Server) -> None: + await page.route( + "**/*", + lambda route: route.abort(), + ) + await page.route( + "**/empty.html", + lambda route: route.abort(), + ) + await page.unroute_all() + response = must(await page.goto(server.EMPTY_PAGE)) + assert response.ok + + +async def test_page_unroute_should_wait_for_pending_handlers_to_complete( + page: Page, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await page.route( + "**/*", + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + await route.fallback() + + await page.route( + "**/*", + _handler2, + ) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + await page.unroute_all(behavior="wait") + nonlocal did_unroute + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + assert did_unroute is False + route_barrier_future.set_result(None) + await unroute_task + assert did_unroute + await navigation_task + assert second_handler_called is False + + +async def test_page_unroute_all_should_not_wait_for_pending_handlers_to_complete_if_behavior_is_ignore_errors( + page: Page, server: Server +) -> None: + second_handler_called = False + + async def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + await route.abort() + + await page.route(re.compile(".*"), _handler1) + route_future: "asyncio.Future[Route]" = asyncio.Future() + route_barrier_future: "asyncio.Future[None]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await route_barrier_future + raise Exception("Handler error") + + await page.route(re.compile(".*"), _handler2) + navigation_task = asyncio.create_task(page.goto(server.EMPTY_PAGE)) + await route_future + did_unroute = False + + async def _unroute_promise() -> None: + await page.unroute_all(behavior="ignoreErrors") + nonlocal did_unroute + did_unroute = True + + unroute_task = asyncio.create_task(_unroute_promise()) + await asyncio.sleep(0.5) + await unroute_task + assert did_unroute + route_barrier_future.set_result(None) + try: + await navigation_task + except Error: + pass + # The error in the unrouted handler should be silently caught. + assert not second_handler_called + + +async def test_page_close_does_not_wait_for_active_route_handlers( + page: Page, server: Server +) -> None: + second_handler_called = False + + def _handler1(route: Route) -> None: + nonlocal second_handler_called + second_handler_called = True + + await page.route( + "**/*", + _handler1, + ) + route_future: "asyncio.Future[Route]" = asyncio.Future() + + async def _handler2(route: Route) -> None: + route_future.set_result(route) + await asyncio.Future() + + await page.route( + "**/*", + _handler2, + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + await route_future + await page.close() + await asyncio.sleep(0.5) + assert not second_handler_called + + +async def test_route_continue_should_not_throw_if_page_has_been_closed( + page: Page, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + route = await route_future + await page.close() + # Should not throw. + await route.continue_() + + +async def test_route_fallback_should_not_throw_if_page_has_been_closed( + page: Page, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + re.compile(".*"), + lambda route: route_future.set_result(route), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + route = await route_future + await page.close() + # Should not throw. + await route.fallback() + + +async def test_route_fulfill_should_not_throw_if_page_has_been_closed( + page: Page, server: Server +) -> None: + route_future: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + "**/*", + lambda route: route_future.set_result(route), + ) + + async def _goto_ignore_exceptions() -> None: + try: + await page.goto(server.EMPTY_PAGE) + except Error: + pass + + asyncio.create_task(_goto_ignore_exceptions()) + route = await route_future + await page.close() + # Should not throw. + await route.fulfill() From 9df606f177cc340666d82f85d5e7b5aaff627949 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 12 Jan 2024 23:50:41 +0100 Subject: [PATCH 3/6] fix tests --- tests/async/test_browsercontext_route.py | 2 +- tests/async/test_har.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/async/test_browsercontext_route.py b/tests/async/test_browsercontext_route.py index 5f4140b63..d629be467 100644 --- a/tests/async/test_browsercontext_route.py +++ b/tests/async/test_browsercontext_route.py @@ -331,7 +331,7 @@ async def test_should_override_post_body_with_empty_string( ), ) - assert req[0].post_body == "" + assert req[0].post_body == b"" async def test_should_chain_fallback( diff --git a/tests/async/test_har.py b/tests/async/test_har.py index bb9923fb8..7e02776f1 100644 --- a/tests/async/test_har.py +++ b/tests/async/test_har.py @@ -651,9 +651,9 @@ async def test_should_update_har_zip_for_context( async def test_page_unroute_all_should_stop_page_route_from_har( context_factory: Callable[[], Awaitable[BrowserContext]], server: Server, - asset: Path, + assetdir: Path, ) -> None: - har_path = asset / "har-fulfill.har" + har_path = assetdir / "har-fulfill.har" context1 = await context_factory() page1 = await context1.new_page() # The har file contains requests for another domain, so the router @@ -670,9 +670,9 @@ async def test_page_unroute_all_should_stop_page_route_from_har( async def test_context_unroute_call_should_stop_context_route_from_har( context_factory: Callable[[], Awaitable[BrowserContext]], server: Server, - asset: Path, + assetdir: Path, ) -> None: - har_path = asset / "har-fulfill.har" + har_path = assetdir / "har-fulfill.har" context1 = await context_factory() page1 = await context1.new_page() # The har file contains requests for another domain, so the router From 84be1a1358cb0bfdbc2f67f908ef028b273174f4 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 15 Jan 2024 14:46:53 +0100 Subject: [PATCH 4/6] more --- README.md | 4 +-- playwright/async_api/_generated.py | 37 ++++++++++++----------- playwright/sync_api/_generated.py | 37 ++++++++++++----------- setup.py | 2 +- tests/async/test_browsercontext.py | 7 ++++- tests/sync/test_unroute_behavior.py | 46 +++++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 40 deletions(-) create mode 100644 tests/sync/test_unroute_behavior.py diff --git a/README.md b/README.md index 3cee8be65..d89a1f0e3 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 121.0.6167.16 | ✅ | ✅ | ✅ | +| Chromium 121.0.6167.57 | ✅ | ✅ | ✅ | | WebKit 17.4 | ✅ | ✅ | ✅ | -| Firefox 120.0.1 | ✅ | ✅ | ✅ | +| Firefox 121.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index cc3fe02f2..59a92a296 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -3003,9 +3003,8 @@ async def wait_for_element_state( Depending on the `state` parameter, this method waits for one of the [actionability](https://playwright.dev/python/docs/actionability) checks to pass. This method throws when the element is detached while waiting, unless waiting for the `\"hidden\"` state. - `\"visible\"` Wait until the element is [visible](https://playwright.dev/python/docs/actionability#visible). - - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or - [not attached](https://playwright.dev/python/docs/actionability#attached). Note that waiting for hidden does not throw when the element - detaches. + - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or not attached. Note that + waiting for hidden does not throw when the element detaches. - `\"stable\"` Wait until the element is both [visible](https://playwright.dev/python/docs/actionability#visible) and [stable](https://playwright.dev/python/docs/actionability#stable). - `\"enabled\"` Wait until the element is [enabled](https://playwright.dev/python/docs/actionability#enabled). @@ -15402,14 +15401,14 @@ async def start( **Usage** ```py - await context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto(\"https://playwright.dev\") await context.tracing.stop(path = \"trace.zip\") ``` ```py - context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto(\"https://playwright.dev\") context.tracing.stop(path = \"trace.zip\") @@ -15418,8 +15417,9 @@ async def start( Parameters ---------- name : Union[str, None] - If specified, the trace is going to be saved into the file with the given name inside the `tracesDir` folder - specified in `browser_type.launch()`. + If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the + `tracesDir` folder specified in `browser_type.launch()`. To specify the final trace zip file name, you need + to pass `path` option to `tracing.stop()` instead. title : Union[str, None] Trace name to be shown in the Trace Viewer. snapshots : Union[bool, None] @@ -15454,7 +15454,7 @@ async def start_chunk( **Usage** ```py - await context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto(\"https://playwright.dev\") @@ -15470,7 +15470,7 @@ async def start_chunk( ``` ```py - context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto(\"https://playwright.dev\") @@ -15490,8 +15490,9 @@ async def start_chunk( title : Union[str, None] Trace name to be shown in the Trace Viewer. name : Union[str, None] - If specified, the trace is going to be saved into the file with the given name inside the `tracesDir` folder - specified in `browser_type.launch()`. + If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the + `tracesDir` folder specified in `browser_type.launch()`. To specify the final trace zip file name, you need + to pass `path` option to `tracing.stop_chunk()` instead. """ return mapping.from_maybe_impl( @@ -19388,8 +19389,8 @@ async def to_contain_text( ) -> None: """LocatorAssertions.to_contain_text - Ensures the `Locator` points to an element that contains the given text. You can use regular expressions for the - value as well. + Ensures the `Locator` points to an element that contains the given text. All nested elements will be considered + when computing the text content of the element. You can use regular expressions for the value as well. **Details** @@ -20134,8 +20135,8 @@ async def to_have_text( ) -> None: """LocatorAssertions.to_have_text - Ensures the `Locator` points to an element with the given text. You can use regular expressions for the value as - well. + Ensures the `Locator` points to an element with the given text. All nested elements will be considered when + computing the text content of the element. You can use regular expressions for the value as well. **Details** @@ -20283,7 +20284,8 @@ async def to_be_attached( ) -> None: """LocatorAssertions.to_be_attached - Ensures that `Locator` points to an [attached](https://playwright.dev/python/docs/actionability#attached) DOM node. + Ensures that `Locator` points to an element that is + [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot. **Usage** @@ -20664,8 +20666,7 @@ async def to_be_visible( ) -> None: """LocatorAssertions.to_be_visible - Ensures that `Locator` points to an [attached](https://playwright.dev/python/docs/actionability#attached) and - [visible](https://playwright.dev/python/docs/actionability#visible) DOM node. + Ensures that `Locator` points to an attached and [visible](https://playwright.dev/python/docs/actionability#visible) DOM node. To check that at least one element from the list is visible, use `locator.first()`. diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 5fbf96d50..d64175f4f 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -3043,9 +3043,8 @@ def wait_for_element_state( Depending on the `state` parameter, this method waits for one of the [actionability](https://playwright.dev/python/docs/actionability) checks to pass. This method throws when the element is detached while waiting, unless waiting for the `\"hidden\"` state. - `\"visible\"` Wait until the element is [visible](https://playwright.dev/python/docs/actionability#visible). - - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or - [not attached](https://playwright.dev/python/docs/actionability#attached). Note that waiting for hidden does not throw when the element - detaches. + - `\"hidden\"` Wait until the element is [not visible](https://playwright.dev/python/docs/actionability#visible) or not attached. Note that + waiting for hidden does not throw when the element detaches. - `\"stable\"` Wait until the element is both [visible](https://playwright.dev/python/docs/actionability#visible) and [stable](https://playwright.dev/python/docs/actionability#stable). - `\"enabled\"` Wait until the element is [enabled](https://playwright.dev/python/docs/actionability#enabled). @@ -15476,14 +15475,14 @@ def start( **Usage** ```py - await context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto(\"https://playwright.dev\") await context.tracing.stop(path = \"trace.zip\") ``` ```py - context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto(\"https://playwright.dev\") context.tracing.stop(path = \"trace.zip\") @@ -15492,8 +15491,9 @@ def start( Parameters ---------- name : Union[str, None] - If specified, the trace is going to be saved into the file with the given name inside the `tracesDir` folder - specified in `browser_type.launch()`. + If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the + `tracesDir` folder specified in `browser_type.launch()`. To specify the final trace zip file name, you need + to pass `path` option to `tracing.stop()` instead. title : Union[str, None] Trace name to be shown in the Trace Viewer. snapshots : Union[bool, None] @@ -15530,7 +15530,7 @@ def start_chunk( **Usage** ```py - await context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto(\"https://playwright.dev\") @@ -15546,7 +15546,7 @@ def start_chunk( ``` ```py - context.tracing.start(name=\"trace\", screenshots=True, snapshots=True) + context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto(\"https://playwright.dev\") @@ -15566,8 +15566,9 @@ def start_chunk( title : Union[str, None] Trace name to be shown in the Trace Viewer. name : Union[str, None] - If specified, the trace is going to be saved into the file with the given name inside the `tracesDir` folder - specified in `browser_type.launch()`. + If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the + `tracesDir` folder specified in `browser_type.launch()`. To specify the final trace zip file name, you need + to pass `path` option to `tracing.stop_chunk()` instead. """ return mapping.from_maybe_impl( @@ -19544,8 +19545,8 @@ def to_contain_text( ) -> None: """LocatorAssertions.to_contain_text - Ensures the `Locator` points to an element that contains the given text. You can use regular expressions for the - value as well. + Ensures the `Locator` points to an element that contains the given text. All nested elements will be considered + when computing the text content of the element. You can use regular expressions for the value as well. **Details** @@ -20312,8 +20313,8 @@ def to_have_text( ) -> None: """LocatorAssertions.to_have_text - Ensures the `Locator` points to an element with the given text. You can use regular expressions for the value as - well. + Ensures the `Locator` points to an element with the given text. All nested elements will be considered when + computing the text content of the element. You can use regular expressions for the value as well. **Details** @@ -20465,7 +20466,8 @@ def to_be_attached( ) -> None: """LocatorAssertions.to_be_attached - Ensures that `Locator` points to an [attached](https://playwright.dev/python/docs/actionability#attached) DOM node. + Ensures that `Locator` points to an element that is + [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot. **Usage** @@ -20852,8 +20854,7 @@ def to_be_visible( ) -> None: """LocatorAssertions.to_be_visible - Ensures that `Locator` points to an [attached](https://playwright.dev/python/docs/actionability#attached) and - [visible](https://playwright.dev/python/docs/actionability#visible) DOM node. + Ensures that `Locator` points to an attached and [visible](https://playwright.dev/python/docs/actionability#visible) DOM node. To check that at least one element from the list is visible, use `locator.first()`. diff --git a/setup.py b/setup.py index b7d008079..7f40b41a8 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ InWheel = None from wheel.bdist_wheel import bdist_wheel as BDistWheelCommand -driver_version = "1.41.0-alpha-1702670966000" +driver_version = "1.41.0-beta-1705101589000" def extractall(zip: zipfile.ZipFile, path: str) -> None: diff --git a/tests/async/test_browsercontext.py b/tests/async/test_browsercontext.py index cedaecf17..97c365273 100644 --- a/tests/async/test_browsercontext.py +++ b/tests/async/test_browsercontext.py @@ -612,12 +612,17 @@ async def test_should_fail_with_correct_credentials_and_mismatching_port( async def test_offline_should_work_with_initial_option( - browser: Browser, server: Server + browser: Browser, + server: Server, + browser_name: str, ) -> None: context = await browser.new_context(offline=True) page = await context.new_page() + frame_navigated_task = asyncio.create_task(page.wait_for_event("framenavigated")) with pytest.raises(Error) as exc_info: await page.goto(server.EMPTY_PAGE) + if browser_name == "firefox": + await frame_navigated_task assert exc_info.value await context.set_offline(False) response = await page.goto(server.EMPTY_PAGE) diff --git a/tests/sync/test_unroute_behavior.py b/tests/sync/test_unroute_behavior.py new file mode 100644 index 000000000..12ae9e22d --- /dev/null +++ b/tests/sync/test_unroute_behavior.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from playwright.sync_api import BrowserContext, Page +from tests.server import Server +from tests.utils import must + + +def test_context_unroute_all_removes_all_handlers( + page: Page, context: BrowserContext, server: Server +) -> None: + context.route( + "**/*", + lambda route: route.abort(), + ) + context.route( + "**/empty.html", + lambda route: route.abort(), + ) + context.unroute_all() + page.goto(server.EMPTY_PAGE) + + +def test_page_unroute_all_removes_all_routes(page: Page, server: Server) -> None: + page.route( + "**/*", + lambda route: route.abort(), + ) + page.route( + "**/empty.html", + lambda route: route.abort(), + ) + page.unroute_all() + response = must(page.goto(server.EMPTY_PAGE)) + assert response.ok From 0040547dc3170d3ec42db0b13d6f24da60f56e1d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 15 Jan 2024 16:35:39 +0100 Subject: [PATCH 5/6] test fixes --- tests/async/test_keyboard.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/tests/async/test_keyboard.py b/tests/async/test_keyboard.py index 8e8a162c9..9f8db104e 100644 --- a/tests/async/test_keyboard.py +++ b/tests/async/test_keyboard.py @@ -519,27 +519,16 @@ async def test_should_support_macos_shortcuts( ) -async def test_should_press_the_meta_key( - page: Page, server: Server, is_firefox: bool, is_mac: bool -) -> None: +async def test_should_press_the_meta_key(page: Page) -> None: lastEvent = await captureLastKeydown(page) await page.keyboard.press("Meta") v = await lastEvent.json_value() metaKey = v["metaKey"] key = v["key"] code = v["code"] - if is_firefox and not is_mac: - assert key == "OS" - else: - assert key == "Meta" - - if is_firefox: - assert code == "MetaLeft" - - if is_firefox and not is_mac: - assert metaKey is False - else: - assert metaKey + assert key == "Meta" + assert code == "MetaLeft" + assert metaKey async def test_should_work_after_a_cross_origin_navigation( From 50f796ebd4fd39390f2cb3c7607843ea508d5e31 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 16 Jan 2024 11:56:21 +0100 Subject: [PATCH 6/6] fix tracing --- playwright/_impl/_helper.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 1ab42d783..615cd5264 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. import asyncio -import inspect import math import os import re @@ -25,7 +24,6 @@ TYPE_CHECKING, Any, Callable, - Coroutine, Dict, List, Optional, @@ -304,26 +302,19 @@ async def handle(self, route: "Route") -> bool: async def _handle_internal(self, route: "Route") -> bool: handled_future = route._start_handling() - handler_task = [] - - def impl() -> None: - self._handled_count += 1 - result = cast( - Callable[["Route", "Request"], Union[Coroutine, Any]], self.handler - )(route, route.request) - if inspect.iscoroutine(result): - handler_task.append(asyncio.create_task(result)) - - # As with event handlers, each route handler is a potentially blocking context - # so it needs a fiber. + + self._handled_count += 1 if self._is_sync: - g = greenlet(impl) + # As with event handlers, each route handler is a potentially blocking context + # so it needs a fiber. + g = greenlet(lambda: self.handler(route, route.request)) # type: ignore g.switch() else: - impl() - - [handled, *_] = await asyncio.gather(handled_future, *handler_task) - return handled + coro_or_future = self.handler(route, route.request) # type: ignore + if coro_or_future: + # separate task so that we get a proper stack trace for exceptions / tracing api_name extraction + await asyncio.ensure_future(coro_or_future) + return await handled_future async def stop(self, behavior: Literal["ignoreErrors", "wait"]) -> None: # When a handler is manually unrouted or its page/context is closed we either