diff --git a/README.md b/README.md index e597e4778..3bd3ec79d 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 110.0.5481.38 | ✅ | ✅ | ✅ | +| Chromium 111.0.5563.19 | ✅ | ✅ | ✅ | | WebKit 16.4 | ✅ | ✅ | ✅ | -| Firefox 108.0.2 | ✅ | ✅ | ✅ | +| Firefox 109.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index ddbf3576a..b701555da 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -185,7 +185,7 @@ class ExpectedTextValue(TypedDict, total=False): class FrameExpectOptions(TypedDict, total=False): expressionArg: Any expectedText: Optional[List[ExpectedTextValue]] - expectedNumber: Optional[int] + expectedNumber: Optional[float] expectedValue: Optional[Any] useInnerText: Optional[bool] isNot: bool diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index 2253bdabd..6f1948edd 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -618,6 +618,25 @@ async def not_to_be_focused( __tracebackhide__ = True await self._not.to_be_focused(timeout) + async def to_be_in_viewport( + self, + ratio: float = None, + timeout: float = None, + ) -> None: + __tracebackhide__ = True + await self._expect_impl( + "to.be.in.viewport", + FrameExpectOptions(timeout=timeout, expectedNumber=ratio), + None, + "Locator expected to be in viewport", + ) + + async def not_to_be_in_viewport( + self, ratio: float = None, timeout: float = None + ) -> None: + __tracebackhide__ = True + await self._not.to_be_in_viewport(ratio=ratio, timeout=timeout) + class APIResponseAssertions: def __init__(self, response: APIResponse, is_not: bool = False) -> None: diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 2cad9af7b..4c1262e5f 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -191,7 +191,11 @@ async def _on_route(self, route: Route) -> None: handled = await route_handler.handle(route) finally: if len(self._routes) == 0: - asyncio.create_task(self._disable_interception()) + asyncio.create_task( + self._connection.wrap_api_call( + lambda: self._update_interception_patterns(), True + ) + ) if handled: return await route._internal_continue(is_internal=True) @@ -304,10 +308,7 @@ async def route( times, ), ) - if len(self._routes) == 1: - await self._channel.send( - "setNetworkInterceptionEnabled", dict(enabled=True) - ) + await self._update_interception_patterns() async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -318,8 +319,7 @@ async def unroute( self._routes, ) ) - if len(self._routes) == 0: - await self._disable_interception() + await self._update_interception_patterns() async def _record_into_har( self, @@ -360,8 +360,11 @@ async def route_from_har( ) await router.add_context_route(self) - async def _disable_interception(self) -> None: - await self._channel.send("setNetworkInterceptionEnabled", dict(enabled=False)) + async def _update_interception_patterns(self) -> None: + patterns = RouteHandler.prepare_interception_patterns(self._routes) + await self._channel.send( + "setNetworkInterceptionPatterns", {"patterns": patterns} + ) def expect_event( self, diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index a9857591c..065ce101b 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -291,6 +291,28 @@ def impl() -> None: def will_expire(self) -> bool: return self._handled_count + 1 >= self._times + @staticmethod + def prepare_interception_patterns( + handlers: List["RouteHandler"], + ) -> List[Dict[str, str]]: + patterns = [] + all = False + for handler in handlers: + if isinstance(handler.matcher.match, str): + patterns.append({"glob": handler.matcher.match}) + elif isinstance(handler.matcher._regex_obj, re.Pattern): + patterns.append( + { + "regexSource": handler.matcher._regex_obj.pattern, + "regexFlags": escape_regex_flags(handler.matcher._regex_obj), + } + ) + else: + all = True + if all: + return [{"glob": "**/*"}] + return patterns + def is_safe_close_error(error: Exception) -> bool: message = str(error) diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index e233e6727..c70691c07 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -643,7 +643,7 @@ async def _expect( { "selector": self._selector, "expression": expression, - **options, + **({k: v for k, v in options.items() if v is not None}), }, ) if result.get("received"): diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 894e802c7..bdc960647 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -241,6 +241,11 @@ async def _actual_headers(self) -> "RawHeaders": self._all_headers_future.set_result(RawHeaders(headers)) return await self._all_headers_future + def _target_closed_future(self) -> asyncio.Future: + if not hasattr(self.frame, "_page"): + return asyncio.Future() + return self.frame._page._closed_or_crashed_future + class Route(ChannelOwner): def __init__( @@ -348,10 +353,11 @@ async def fetch( method: str = None, headers: Dict[str, str] = None, postData: Union[Any, str, bytes] = None, + maxRedirects: int = None, ) -> "APIResponse": page = self.request.frame._page return await page.context.request._inner_fetch( - self.request, url, method, headers, postData + self.request, url, method, headers, postData, maxRedirects=maxRedirects ) async def fallback( @@ -419,30 +425,22 @@ async def _redirected_navigation_request(self, url: str) -> None: self._report_handled(True) async def _race_with_page_close(self, future: Coroutine) -> None: - if hasattr(self.request.frame, "_page"): - page = self.request.frame._page - # When page closes or crashes, we catch any potential rejects from this Route. - # Note that page could be missing when routing popup's initial request that - # does not have a Page initialized just yet. - fut = asyncio.create_task(future) - # Rewrite the user's stack to the new task which runs in the background. - setattr( - fut, - "__pw_stack__", - getattr( - asyncio.current_task(self._loop), "__pw_stack__", inspect.stack() - ), - ) - await asyncio.wait( - [fut, page._closed_or_crashed_future], - return_when=asyncio.FIRST_COMPLETED, - ) - if fut.done() and fut.exception(): - raise cast(BaseException, fut.exception()) - if page._closed_or_crashed_future.done(): - await asyncio.gather(fut, return_exceptions=True) - else: - await future + fut = asyncio.create_task(future) + # Rewrite the user's stack to the new task which runs in the background. + setattr( + fut, + "__pw_stack__", + getattr(asyncio.current_task(self._loop), "__pw_stack__", inspect.stack()), + ) + target_closed_future = self.request._target_closed_future() + await asyncio.wait( + [fut, target_closed_future], + return_when=asyncio.FIRST_COMPLETED, + ) + if fut.done() and fut.exception(): + raise cast(BaseException, fut.exception()) + if target_closed_future.done(): + await asyncio.gather(fut, return_exceptions=True) class Response(ChannelOwner): @@ -522,7 +520,20 @@ async def security_details(self) -> Optional[SecurityDetails]: return await self._channel.send("securityDetails") async def finished(self) -> None: - await self._finished_future + async def on_finished() -> None: + await self._request._target_closed_future() + raise Error("Target closed") + + on_finished_task = asyncio.create_task(on_finished()) + await asyncio.wait( + cast( + List[Union[asyncio.Task, asyncio.Future]], + [self._finished_future, on_finished_task], + ), + return_when=asyncio.FIRST_COMPLETED, + ) + if on_finished_task.done(): + await on_finished_task async def body(self) -> bytes: binary = await self._channel.send("body") diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index a40fe4d10..22a8f72c6 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -254,7 +254,11 @@ async def _on_route(self, route: Route) -> None: handled = await route_handler.handle(route) finally: if len(self._routes) == 0: - asyncio.create_task(self._disable_interception()) + asyncio.create_task( + self._connection.wrap_api_call( + lambda: self._update_interception_patterns(), True + ) + ) if handled: return await self._browser_context._on_route(route) @@ -594,10 +598,7 @@ async def route( times, ), ) - if len(self._routes) == 1: - await self._channel.send( - "setNetworkInterceptionEnabled", dict(enabled=True) - ) + await self._update_interception_patterns() async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -608,8 +609,7 @@ async def unroute( self._routes, ) ) - if len(self._routes) == 0: - await self._disable_interception() + await self._update_interception_patterns() async def route_from_har( self, @@ -629,8 +629,11 @@ async def route_from_har( ) await router.add_page_route(self) - async def _disable_interception(self) -> None: - await self._channel.send("setNetworkInterceptionEnabled", dict(enabled=False)) + async def _update_interception_patterns(self) -> None: + patterns = RouteHandler.prepare_interception_patterns(self._routes) + await self._channel.send( + "setNetworkInterceptionPatterns", {"patterns": patterns} + ) async def screenshot( self, diff --git a/playwright/_impl/_str_utils.py b/playwright/_impl/_str_utils.py index 45c80184f..769f530de 100644 --- a/playwright/_impl/_str_utils.py +++ b/playwright/_impl/_str_utils.py @@ -48,5 +48,13 @@ def escape_for_text_selector( def escape_for_attribute_selector(value: str, exact: bool = None) -> str: - suffix = "" if exact else "i" - return '"' + value.replace('"', '\\"') + '"' + suffix + # TODO: this should actually be + # cssEscape(value).replace(/\\ /g, ' ') + # However, our attribute selectors do not conform to CSS parsing spec, + # so we escape them differently. + return ( + '"' + + value.replace("\\", "\\\\").replace('"', '\\"') + + '"' + + ("s" if exact else "i") + ) diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 0595f5751..e8b8757ee 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -743,7 +743,8 @@ async def fetch( url: typing.Optional[str] = None, method: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, - post_data: typing.Optional[typing.Union[typing.Any, str, bytes]] = None + post_data: typing.Optional[typing.Union[typing.Any, str, bytes]] = None, + max_redirects: typing.Optional[int] = None ) -> "APIResponse": """Route.fetch @@ -772,6 +773,12 @@ def handle(route): page.route(\"https://dog.ceo/api/breeds/list/all\", handle) ``` + **Details** + + Note that `headers` option will apply to the fetched request as well as any redirects initiated by it. If you want + to only apply `headers` to the original request, but not to redirects, look into `route.continue_()` + instead. + Parameters ---------- url : Union[str, None] @@ -784,6 +791,9 @@ def handle(route): Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. + max_redirects : Union[int, None] + Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is + exceeded. Defaults to `20`. Pass `0` to not follow redirects. Returns ------- @@ -796,6 +806,7 @@ def handle(route): method=method, headers=mapping.to_impl(headers), postData=mapping.to_impl(post_data), + maxRedirects=max_redirects, ) ) @@ -964,6 +975,12 @@ def handle(route, request): page.route(\"**/*\", handle) ``` + **Details** + + Note that any overrides such as `url` or `headers` only apply to the request being routed. If this request results + in a redirect, overrides will not be applied to the new redirected request. If you want to propagate a header + through redirects, use the combination of `route.fetch()` and `route.fulfill()` instead. + Parameters ---------- url : Union[str, None] @@ -1508,6 +1525,8 @@ async def tap(self, x: float, y: float) -> None: Dispatches a `touchstart` and `touchend` event with a single touch at the position (`x`,`y`). + **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. + Parameters ---------- x : float @@ -6858,6 +6877,8 @@ async def register( ) -> None: """Selectors.register + Selectors must be registered before creating the page. + **Usage** An example of registering selector engine that queries elements based on a tag name: @@ -6887,8 +6908,8 @@ async def run(playwright): # Use the selector prefixed with its name. button = await page.query_selector('tag=button') - # Combine it with other selector engines. - await page.locator('tag=div >> text=\"Click me\"').click() + # Combine it with built-in locators. + await page.locator('tag=div').get_by_text('Click me').click() # Can use it in any methods supporting selectors. button_count = await page.locator('tag=button').count() print(button_count) @@ -6925,8 +6946,8 @@ def run(playwright): # Use the selector prefixed with its name. button = page.locator('tag=button') - # Combine it with other selector engines. - page.locator('tag=div >> text=\"Click me\"').click() + # Combine it with built-in locators. + page.locator('tag=div').get_by_text('Click me').click() # Can use it in any methods supporting selectors. button_count = page.locator('tag=button').count() print(button_count) @@ -10090,7 +10111,7 @@ async def tap( When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. - **NOTE** `page.tap()` requires that the `hasTouch` option of the browser context be set to true. + **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. Parameters ---------- @@ -15369,7 +15390,7 @@ async def dispatch_event( ) -> None: """Locator.dispatch_event - Programmaticaly dispatch an event on the matching element. + Programmatically dispatch an event on the matching element. **Usage** @@ -16871,7 +16892,7 @@ async def press( ) -> None: """Locator.press - Focuses the mathing element and presses a combintation of the keys. + Focuses the matching element and presses a combination of the keys. **Usage** @@ -17815,7 +17836,8 @@ async def delete( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -17892,7 +17914,8 @@ async def head( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -17981,7 +18004,8 @@ async def get( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18058,7 +18082,8 @@ async def patch( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18135,7 +18160,8 @@ async def put( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18251,7 +18277,8 @@ async def post( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18358,7 +18385,8 @@ async def fetch( If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) or [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST)). If not specified, GET method is used. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -19966,6 +19994,79 @@ async def not_to_be_focused( await self._impl_obj.not_to_be_focused(timeout=timeout) ) + async def to_be_in_viewport( + self, + *, + ratio: typing.Optional[float] = None, + timeout: typing.Optional[float] = None + ) -> None: + """LocatorAssertions.to_be_in_viewport + + Ensures the `Locator` points to an element that intersects viewport, according to the + [intersection observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). + + **Usage** + + ```py + from playwright.async_api import expect + + locator = page.get_by_role(\"button\") + # Make sure at least some part of element intersects viewport. + await expect(locator).to_be_in_viewport() + # Make sure element is fully outside of viewport. + await expect(locator).not_to_be_in_viewport() + # Make sure that at least half of the element intersects viewport. + await expect(locator).to_be_in_viewport(ratio=0.5) + ``` + + ```py + from playwright.sync_api import expect + + locator = page.get_by_role(\"button\") + # Make sure at least some part of element intersects viewport. + expect(locator).to_be_in_viewport() + # Make sure element is fully outside of viewport. + expect(locator).not_to_be_in_viewport() + # Make sure that at least half of the element intersects viewport. + expect(locator).to_be_in_viewport(ratio=0.5) + ``` + + Parameters + ---------- + ratio : Union[float, None] + The minimal ratio of the element to intersect viewport. If equals to `0`, then element should intersect viewport at + any positive ratio. Defaults to `0`. + timeout : Union[float, None] + Time to retry the assertion for. + """ + __tracebackhide__ = True + + return mapping.from_maybe_impl( + await self._impl_obj.to_be_in_viewport(ratio=ratio, timeout=timeout) + ) + + async def not_to_be_in_viewport( + self, + *, + ratio: typing.Optional[float] = None, + timeout: typing.Optional[float] = None + ) -> None: + """LocatorAssertions.not_to_be_in_viewport + + The opposite of `locator_assertions.to_be_in_viewport()`. + + Parameters + ---------- + ratio : Union[float, None] + timeout : Union[float, None] + Time to retry the assertion for. + """ + __tracebackhide__ = True + + return mapping.from_maybe_impl( + await self._impl_obj.not_to_be_in_viewport(ratio=ratio, timeout=timeout) + ) + mapping.register(LocatorAssertionsImpl, LocatorAssertions) diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 5aa50a668..c84abe680 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -753,7 +753,8 @@ def fetch( url: typing.Optional[str] = None, method: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, - post_data: typing.Optional[typing.Union[typing.Any, str, bytes]] = None + post_data: typing.Optional[typing.Union[typing.Any, str, bytes]] = None, + max_redirects: typing.Optional[int] = None ) -> "APIResponse": """Route.fetch @@ -782,6 +783,12 @@ def handle(route): page.route(\"https://dog.ceo/api/breeds/list/all\", handle) ``` + **Details** + + Note that `headers` option will apply to the fetched request as well as any redirects initiated by it. If you want + to only apply `headers` to the original request, but not to redirects, look into `route.continue_()` + instead. + Parameters ---------- url : Union[str, None] @@ -794,6 +801,9 @@ def handle(route): Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. + max_redirects : Union[int, None] + Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is + exceeded. Defaults to `20`. Pass `0` to not follow redirects. Returns ------- @@ -807,6 +817,7 @@ def handle(route): method=method, headers=mapping.to_impl(headers), postData=mapping.to_impl(post_data), + maxRedirects=max_redirects, ) ) ) @@ -978,6 +989,12 @@ def handle(route, request): page.route(\"**/*\", handle) ``` + **Details** + + Note that any overrides such as `url` or `headers` only apply to the request being routed. If this request results + in a redirect, overrides will not be applied to the new redirected request. If you want to propagate a header + through redirects, use the combination of `route.fetch()` and `route.fulfill()` instead. + Parameters ---------- url : Union[str, None] @@ -1506,6 +1523,8 @@ def tap(self, x: float, y: float) -> None: Dispatches a `touchstart` and `touchend` event with a single touch at the position (`x`,`y`). + **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. + Parameters ---------- x : float @@ -6972,6 +6991,8 @@ def register( ) -> None: """Selectors.register + Selectors must be registered before creating the page. + **Usage** An example of registering selector engine that queries elements based on a tag name: @@ -7001,8 +7022,8 @@ async def run(playwright): # Use the selector prefixed with its name. button = await page.query_selector('tag=button') - # Combine it with other selector engines. - await page.locator('tag=div >> text=\"Click me\"').click() + # Combine it with built-in locators. + await page.locator('tag=div').get_by_text('Click me').click() # Can use it in any methods supporting selectors. button_count = await page.locator('tag=button').count() print(button_count) @@ -7039,8 +7060,8 @@ def run(playwright): # Use the selector prefixed with its name. button = page.locator('tag=button') - # Combine it with other selector engines. - page.locator('tag=div >> text=\"Click me\"').click() + # Combine it with built-in locators. + page.locator('tag=div').get_by_text('Click me').click() # Can use it in any methods supporting selectors. button_count = page.locator('tag=button').count() print(button_count) @@ -10160,7 +10181,7 @@ def tap( When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. - **NOTE** `page.tap()` requires that the `hasTouch` option of the browser context be set to true. + **NOTE** `page.tap()` the method will throw if `hasTouch` option of the browser context is false. Parameters ---------- @@ -15463,7 +15484,7 @@ def dispatch_event( ) -> None: """Locator.dispatch_event - Programmaticaly dispatch an event on the matching element. + Programmatically dispatch an event on the matching element. **Usage** @@ -16997,7 +17018,7 @@ def press( ) -> None: """Locator.press - Focuses the mathing element and presses a combintation of the keys. + Focuses the matching element and presses a combination of the keys. **Usage** @@ -17959,7 +17980,8 @@ def delete( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18038,7 +18060,8 @@ def head( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18129,7 +18152,8 @@ def get( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18208,7 +18232,8 @@ def patch( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18287,7 +18312,8 @@ def put( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18405,7 +18431,8 @@ def post( params : Union[Dict[str, Union[bool, float, str]], None] Query parameters to be sent with the URL. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -18514,7 +18541,8 @@ def fetch( If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) or [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST)). If not specified, GET method is used. headers : Union[Dict[str, str], None] - Allows to set HTTP headers. + Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by + it. data : Union[Any, bytes, str, None] Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` @@ -20162,6 +20190,81 @@ def not_to_be_focused(self, *, timeout: typing.Optional[float] = None) -> None: self._sync(self._impl_obj.not_to_be_focused(timeout=timeout)) ) + def to_be_in_viewport( + self, + *, + ratio: typing.Optional[float] = None, + timeout: typing.Optional[float] = None + ) -> None: + """LocatorAssertions.to_be_in_viewport + + Ensures the `Locator` points to an element that intersects viewport, according to the + [intersection observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). + + **Usage** + + ```py + from playwright.async_api import expect + + locator = page.get_by_role(\"button\") + # Make sure at least some part of element intersects viewport. + await expect(locator).to_be_in_viewport() + # Make sure element is fully outside of viewport. + await expect(locator).not_to_be_in_viewport() + # Make sure that at least half of the element intersects viewport. + await expect(locator).to_be_in_viewport(ratio=0.5) + ``` + + ```py + from playwright.sync_api import expect + + locator = page.get_by_role(\"button\") + # Make sure at least some part of element intersects viewport. + expect(locator).to_be_in_viewport() + # Make sure element is fully outside of viewport. + expect(locator).not_to_be_in_viewport() + # Make sure that at least half of the element intersects viewport. + expect(locator).to_be_in_viewport(ratio=0.5) + ``` + + Parameters + ---------- + ratio : Union[float, None] + The minimal ratio of the element to intersect viewport. If equals to `0`, then element should intersect viewport at + any positive ratio. Defaults to `0`. + timeout : Union[float, None] + Time to retry the assertion for. + """ + __tracebackhide__ = True + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.to_be_in_viewport(ratio=ratio, timeout=timeout)) + ) + + def not_to_be_in_viewport( + self, + *, + ratio: typing.Optional[float] = None, + timeout: typing.Optional[float] = None + ) -> None: + """LocatorAssertions.not_to_be_in_viewport + + The opposite of `locator_assertions.to_be_in_viewport()`. + + Parameters + ---------- + ratio : Union[float, None] + timeout : Union[float, None] + Time to retry the assertion for. + """ + __tracebackhide__ = True + + return mapping.from_maybe_impl( + self._sync( + self._impl_obj.not_to_be_in_viewport(ratio=ratio, timeout=timeout) + ) + ) + mapping.register(LocatorAssertionsImpl, LocatorAssertions) diff --git a/setup.py b/setup.py index 626396466..da75b45a3 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.30.0" +driver_version = "1.31.0-beta-1676906983000" def extractall(zip: zipfile.ZipFile, path: str) -> None: diff --git a/tests/async/test_expect_misc.py b/tests/async/test_expect_misc.py new file mode 100644 index 000000000..414909b67 --- /dev/null +++ b/tests/async/test_expect_misc.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from playwright.async_api import Page, expect +from tests.server import Server + + +async def test_to_be_in_viewport_should_work(page: Page, server: Server) -> None: + await page.set_content( + """ +
+
foo
+ """ + ) + await expect(page.locator("#big")).to_be_in_viewport() + await expect(page.locator("#small")).not_to_be_in_viewport() + await page.locator("#small").scroll_into_view_if_needed() + await expect(page.locator("#small")).to_be_in_viewport() + await expect(page.locator("#small")).to_be_in_viewport(ratio=1) + + +async def test_to_be_in_viewport_should_respect_ratio_option( + page: Page, server: Server +) -> None: + await page.set_content( + """ + +
+ """ + ) + await expect(page.locator("div")).to_be_in_viewport() + await expect(page.locator("div")).to_be_in_viewport(ratio=0.1) + await expect(page.locator("div")).to_be_in_viewport(ratio=0.2) + + await expect(page.locator("div")).to_be_in_viewport(ratio=0.25) + # In this test, element's ratio is 0.25. + await expect(page.locator("div")).not_to_be_in_viewport(ratio=0.26) + + await expect(page.locator("div")).not_to_be_in_viewport(ratio=0.3) + await expect(page.locator("div")).not_to_be_in_viewport(ratio=0.7) + await expect(page.locator("div")).not_to_be_in_viewport(ratio=0.8) + + +async def test_to_be_in_viewport_should_have_good_stack( + page: Page, server: Server +) -> None: + with pytest.raises(AssertionError) as exc_info: + await expect(page.locator("body")).not_to_be_in_viewport(timeout=100) + assert 'unexpected value "viewport ratio' in str(exc_info.value) + + +async def test_to_be_in_viewport_should_report_intersection_even_if_fully_covered_by_other_element( + page: Page, server: Server +) -> None: + await page.set_content( + """ +

hello

+
frame.remove()")) with pytest.raises(Error) as exc_info: await navigation_task - assert "frame was detached" in exc_info.value.message + if browser_name == "chromium": + assert ("frame was detached" in exc_info.value.message) or ( + "net::ERR_ABORTED" in exc_info.value.message + ) + else: + assert "frame was detached" in exc_info.value.message async def test_frame_goto_should_continue_after_client_redirect(page, server): diff --git a/tests/async/test_page.py b/tests/async/test_page.py index fe2a5b45f..54abccb9d 100644 --- a/tests/async/test_page.py +++ b/tests/async/test_page.py @@ -563,13 +563,12 @@ async def test_set_content_should_respect_default_navigation_timeout(page, serve async def test_set_content_should_await_resources_to_load(page, server): - img_path = "/img.png" img_route = asyncio.Future() - await page.route(img_path, lambda route, request: img_route.set_result(route)) + await page.route("**/img.png", lambda route, request: img_route.set_result(route)) loaded = [] async def load(): - await page.set_content(f'') + await page.set_content(f'') loaded.append(True) content_promise = asyncio.create_task(load()) diff --git a/tests/async/test_page_network_response.py b/tests/async/test_page_network_response.py new file mode 100644 index 000000000..52dd6e64a --- /dev/null +++ b/tests/async/test_page_network_response.py @@ -0,0 +1,72 @@ +# 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 pytest + +from playwright.async_api import Page +from tests.server import HttpRequestWithPostBody, Server + + +async def test_should_reject_response_finished_if_page_closes( + page: Page, server: Server +) -> None: + await page.goto(server.EMPTY_PAGE) + + def handle_get(request: HttpRequestWithPostBody): + # In Firefox, |fetch| will be hanging until it receives |Content-Type| header + # from server. + request.setHeader("Content-Type", "text/plain; charset=utf-8") + request.write(b"hello ") + + server.set_route("/get", handle_get) + # send request and wait for server response + [page_response, _] = await asyncio.gather( + page.wait_for_event("response"), + page.evaluate("() => fetch('./get', { method: 'GET' })"), + ) + + finish_coroutine = page_response.finished() + await page.close() + with pytest.raises(Exception) as exc_info: + await finish_coroutine + error = exc_info.value + assert "closed" in error.message + + +async def test_should_reject_response_finished_if_context_closes( + page: Page, server: Server +) -> None: + await page.goto(server.EMPTY_PAGE) + + def handle_get(request: HttpRequestWithPostBody): + # In Firefox, |fetch| will be hanging until it receives |Content-Type| header + # from server. + request.setHeader("Content-Type", "text/plain; charset=utf-8") + request.write(b"hello ") + + server.set_route("/get", handle_get) + # send request and wait for server response + [page_response, _] = await asyncio.gather( + page.wait_for_event("response"), + page.evaluate("() => fetch('./get', { method: 'GET' })"), + ) + + finish_coroutine = page_response.finished() + await page.context.close() + with pytest.raises(Exception) as exc_info: + await finish_coroutine + error = exc_info.value + assert "closed" in error.message diff --git a/tests/async/test_page_request_intercept.py b/tests/async/test_page_request_intercept.py index 9b41d9630..bda57cf44 100644 --- a/tests/async/test_page_request_intercept.py +++ b/tests/async/test_page_request_intercept.py @@ -18,6 +18,22 @@ from tests.server import Server +async def test_should_not_follow_redirects_when_max_redirects_is_set_to_0_in_route_fetch( + server: Server, page: Page +): + server.set_redirect("/foo", "/empty.html") + + async def handle(route: Route): + response = await route.fetch(max_redirects=0) + assert response.headers["location"] == "/empty.html" + assert response.status == 302 + await route.fulfill(body="hello") + + await page.route("**/*", lambda route: handle(route)) + await page.goto(server.PREFIX + "/foo") + assert "hello" in await page.content() + + async def test_should_intercept_with_url_override(server: Server, page: Page): async def handle(route: Route): response = await route.fetch(url=server.PREFIX + "/one-style.html") diff --git a/tests/async/test_resource_timing.py b/tests/async/test_resource_timing.py index a948b9f6c..17ea0e10b 100644 --- a/tests/async/test_resource_timing.py +++ b/tests/async/test_resource_timing.py @@ -32,7 +32,7 @@ async def test_should_work(page, server): @flaky async def test_should_work_for_subresource(page, server, is_win, is_mac, is_webkit): - if is_webkit and is_win: + if is_webkit and (is_mac or is_win): pytest.skip() requests = [] page.on("requestfinished", lambda request: requests.append(request)) diff --git a/tests/async/test_selectors_get_by.py b/tests/async/test_selectors_get_by.py index 46a3ef1bc..88cb50947 100644 --- a/tests/async/test_selectors_get_by.py +++ b/tests/async/test_selectors_get_by.py @@ -12,7 +12,80 @@ # See the License for the specific language governing permissions and # limitations under the License. -from playwright.async_api import Page +from playwright.async_api import Page, expect + + +async def test_get_by_escaping(page: Page) -> None: + await page.set_content( + """ + """ + ) + await page.eval_on_selector( + "input", + """input => { + input.setAttribute('placeholder', 'hello my\\nwo"rld'); + input.setAttribute('title', 'hello my\\nwo"rld'); + input.setAttribute('alt', 'hello my\\nwo"rld'); + }""", + ) + await expect(page.get_by_text('hello my\nwo"rld')).to_have_attribute("id", "label") + await expect(page.get_by_text('hello my wo"rld')).to_have_attribute( + "id", "label" + ) + await expect(page.get_by_label('hello my\nwo"rld')).to_have_attribute( + "id", "control" + ) + await expect(page.get_by_placeholder('hello my\nwo"rld')).to_have_attribute( + "id", "control" + ) + await expect(page.get_by_alt_text('hello my\nwo"rld')).to_have_attribute( + "id", "control" + ) + await expect(page.get_by_title('hello my\nwo"rld')).to_have_attribute( + "id", "control" + ) + + await page.set_content( + """ + """ + ) + await page.eval_on_selector( + "input", + """input => { + input.setAttribute('placeholder', 'hello my\\nworld'); + input.setAttribute('title', 'hello my\\nworld'); + input.setAttribute('alt', 'hello my\\nworld'); + }""", + ) + await expect(page.get_by_text("hello my\nworld")).to_have_attribute("id", "label") + await expect(page.get_by_text("hello my world")).to_have_attribute( + "id", "label" + ) + await expect(page.get_by_label("hello my\nworld")).to_have_attribute( + "id", "control" + ) + await expect(page.get_by_placeholder("hello my\nworld")).to_have_attribute( + "id", "control" + ) + await expect(page.get_by_alt_text("hello my\nworld")).to_have_attribute( + "id", "control" + ) + await expect(page.get_by_title("hello my\nworld")).to_have_attribute( + "id", "control" + ) + + await page.set_content("""
Text here
""") + await expect(page.get_by_title("my title", exact=True)).to_have_count( + 1, timeout=500 + ) + await expect(page.get_by_title("my t\\itle", exact=True)).to_have_count( + 0, timeout=500 + ) + await expect(page.get_by_title("my t\\\\itle", exact=True)).to_have_count( + 0, timeout=500 + ) async def test_get_by_role_escaping( @@ -70,3 +143,21 @@ async def test_get_by_role_escaping( ).evaluate_all("els => els.map(e => e.outerHTML)") == [ """he llo 56""", ] + + assert await page.get_by_role("button", name="Click me", exact=True).evaluate_all( + "els => els.map(e => e.outerHTML)" + ) == [ + "", + ] + assert ( + await page.get_by_role("button", name="Click \\me", exact=True).evaluate_all( + "els => els.map(e => e.outerHTML)" + ) + == [] + ) + assert ( + await page.get_by_role("button", name="Click \\\\me", exact=True).evaluate_all( + "els => els.map(e => e.outerHTML)" + ) + == [] + ) diff --git a/tests/server.py b/tests/server.py index fd3f6e36f..9b486af3c 100644 --- a/tests/server.py +++ b/tests/server.py @@ -42,7 +42,7 @@ def find_free_port() -> int: return s.getsockname()[1] -class HttpRequestWitPostBody(http.Request): +class HttpRequestWithPostBody(http.Request): post_body = None @@ -162,20 +162,20 @@ class MyHttpFactory(http.HTTPFactory): self.listen(MyHttpFactory()) - async def wait_for_request(self, path: str) -> HttpRequestWitPostBody: + async def wait_for_request(self, path: str) -> HttpRequestWithPostBody: if path in self.request_subscribers: return await self.request_subscribers[path] - future: asyncio.Future["HttpRequestWitPostBody"] = asyncio.Future() + future: asyncio.Future["HttpRequestWithPostBody"] = asyncio.Future() self.request_subscribers[path] = future return await future @contextlib.contextmanager def expect_request( self, path: str - ) -> Generator[ExpectResponse[HttpRequestWitPostBody], None, None]: + ) -> Generator[ExpectResponse[HttpRequestWithPostBody], None, None]: future = asyncio.create_task(self.wait_for_request(path)) - cb_wrapper: ExpectResponse[HttpRequestWitPostBody] = ExpectResponse() + cb_wrapper: ExpectResponse[HttpRequestWithPostBody] = ExpectResponse() def done_cb(task: asyncio.Task) -> None: cb_wrapper._value = future.result() diff --git a/tests/sync/test_expect_misc.py b/tests/sync/test_expect_misc.py new file mode 100644 index 000000000..042929fde --- /dev/null +++ b/tests/sync/test_expect_misc.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from playwright.sync_api import Page, expect +from tests.server import Server + + +def test_to_be_in_viewport_should_work(page: Page) -> None: + page.set_content( + """ +
+
foo
+ """ + ) + expect(page.locator("#big")).to_be_in_viewport() + expect(page.locator("#small")).not_to_be_in_viewport() + page.locator("#small").scroll_into_view_if_needed() + expect(page.locator("#small")).to_be_in_viewport() + expect(page.locator("#small")).to_be_in_viewport(ratio=1) + + +def test_to_be_in_viewport_should_respect_ratio_option( + page: Page, server: Server +) -> None: + page.set_content( + """ + +
+ """ + ) + expect(page.locator("div")).to_be_in_viewport() + expect(page.locator("div")).to_be_in_viewport(ratio=0.1) + expect(page.locator("div")).to_be_in_viewport(ratio=0.2) + + expect(page.locator("div")).to_be_in_viewport(ratio=0.25) + # In this test, element's ratio is 0.25. + expect(page.locator("div")).not_to_be_in_viewport(ratio=0.26) + + expect(page.locator("div")).not_to_be_in_viewport(ratio=0.3) + expect(page.locator("div")).not_to_be_in_viewport(ratio=0.7) + expect(page.locator("div")).not_to_be_in_viewport(ratio=0.8) + + +def test_to_be_in_viewport_should_have_good_stack(page: Page, server: Server) -> None: + with pytest.raises(AssertionError) as exc_info: + expect(page.locator("body")).not_to_be_in_viewport(timeout=100) + assert 'unexpected value "viewport ratio' in str(exc_info.value) + + +def test_to_be_in_viewport_should_report_intersection_even_if_fully_covered_by_other_element( + page: Page, server: Server +) -> None: + page.set_content( + """ +

hello

+
None: + page.goto(server.EMPTY_PAGE) + + def handle_get(request: http.Request) -> None: + # In Firefox, |fetch| will be hanging until it receives |Content-Type| header + # from server. + request.setHeader("Content-Type", "text/plain; charset=utf-8") + request.write(b"hello ") + + server.set_route("/get", handle_get) + # send request and wait for server response + with page.expect_response("**/*") as response_info: + page.evaluate("() => fetch('./get', { method: 'GET' })") + page_response = response_info.value + page.close() + with pytest.raises(Error) as exc_info: + page_response.finished() + error = exc_info.value + assert "closed" in error.message + + +def test_should_reject_response_finished_if_context_closes( + page: Page, server: Server +) -> None: + page.goto(server.EMPTY_PAGE) + + def handle_get(request: http.Request) -> None: + # In Firefox, |fetch| will be hanging until it receives |Content-Type| header + # from server. + request.setHeader("Content-Type", "text/plain; charset=utf-8") + request.write(b"hello ") + + server.set_route("/get", handle_get) + # send request and wait for server response + with page.expect_response("**/*") as response_info: + page.evaluate("() => fetch('./get', { method: 'GET' })") + page_response = response_info.value + + page.context.close() + with pytest.raises(Error) as exc_info: + page_response.finished() + error = exc_info.value + assert "closed" in error.message diff --git a/tests/sync/test_page_request_fallback.py b/tests/sync/test_page_request_fallback.py index b77ffb234..09a3c9845 100644 --- a/tests/sync/test_page_request_fallback.py +++ b/tests/sync/test_page_request_fallback.py @@ -345,6 +345,6 @@ def handler(route: Route) -> None: ) with server.expect_request("/sleep.zzz") as server_request: - page.evaluate("() => fetch('/sleep.zzz', { method: 'POST', body: 'birdy' })"), + page.evaluate("() => fetch('/sleep.zzz', { method: 'POST', body: 'birdy' })") assert post_data == ['{"foo": "bar"}'] assert server_request.value.post_body == b'{"foo": "bar"}' diff --git a/tests/sync/test_request_intercept.py b/tests/sync/test_request_intercept.py index dc714e832..8df41c0c2 100644 --- a/tests/sync/test_request_intercept.py +++ b/tests/sync/test_request_intercept.py @@ -20,6 +20,22 @@ from tests.server import Server +def test_should_not_follow_redirects_when_max_redirects_is_set_to_0_in_route_fetch( + server: Server, page: Page +) -> None: + server.set_redirect("/foo", "/empty.html") + + def handle(route: Route) -> None: + response = route.fetch(max_redirects=0) + assert response.headers["location"] == "/empty.html" + assert response.status == 302 + route.fulfill(body="hello") + + page.route("**/*", lambda route: handle(route)) + page.goto(server.PREFIX + "/foo") + assert "hello" in page.content() + + def test_should_fulfill_intercepted_response(page: Page, server: Server) -> None: def handle(route: Route) -> None: response = page.request.fetch(route.request) diff --git a/tests/sync/test_resource_timing.py b/tests/sync/test_resource_timing.py index 447ab97a2..dcfcc48df 100644 --- a/tests/sync/test_resource_timing.py +++ b/tests/sync/test_resource_timing.py @@ -37,7 +37,7 @@ def test_should_work(page: Page, server: Server) -> None: def test_should_work_for_subresource( page: Page, server: Server, is_win: bool, is_mac: bool, is_webkit: bool ) -> None: - if is_webkit and is_mac: + if is_webkit and (is_mac or is_win): pytest.skip() requests = [] page.on("requestfinished", lambda request: requests.append(request))