Skip to content

Commit ab954ac

Browse files
authored
feat: add Response assertions (#1125)
* feat: add Response assertions * roll
1 parent c9030ff commit ab954ac

24 files changed

+280
-119
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H
44

55
| | Linux | macOS | Windows |
66
| :--- | :---: | :---: | :---: |
7-
| Chromium <!-- GEN:chromium-version -->99.0.4812.0<!-- GEN:stop --> ||||
7+
| Chromium <!-- GEN:chromium-version -->99.0.4837.0<!-- GEN:stop --> ||||
88
| WebKit <!-- GEN:webkit-version -->15.4<!-- GEN:stop --> ||||
9-
| Firefox <!-- GEN:firefox-version -->95.0<!-- GEN:stop --> ||||
9+
| Firefox <!-- GEN:firefox-version -->96.0.1<!-- GEN:stop --> ||||
1010

1111
## Documentation
1212

playwright/_impl/_assertions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from urllib.parse import urljoin
1717

1818
from playwright._impl._api_structures import ExpectedTextValue, FrameExpectOptions
19+
from playwright._impl._fetch import APIResponse
1920
from playwright._impl._locator import Locator
2021
from playwright._impl._page import Page
2122
from playwright._impl._str_utils import escape_regex_flags
@@ -532,6 +533,37 @@ async def not_to_be_focused(
532533
await self._not.to_be_focused(timeout)
533534

534535

536+
class APIResponseAssertions:
537+
def __init__(self, response: APIResponse, is_not: bool = False) -> None:
538+
self._loop = response._loop
539+
self._dispatcher_fiber = response._dispatcher_fiber
540+
self._is_not = is_not
541+
self._actual = response
542+
543+
@property
544+
def _not(self) -> "APIResponseAssertions":
545+
return APIResponseAssertions(self._actual, not self._is_not)
546+
547+
async def to_be_ok(
548+
self,
549+
) -> None:
550+
__tracebackhide__ = True
551+
if self._is_not is not self._actual.ok:
552+
return
553+
message = f"Response status expected to be within [200..299] range, was '{self._actual.status}'"
554+
if self._is_not:
555+
message = message.replace("expected to", "expected not to")
556+
log_list = await self._actual._fetch_log()
557+
log = "\n".join(log_list).strip()
558+
if log:
559+
message += f"\n Call log:\n{log}"
560+
raise AssertionError(message)
561+
562+
async def not_to_be_ok(self) -> None:
563+
__tracebackhide__ = True
564+
await self._not.to_be_ok()
565+
566+
535567
def expected_regex(
536568
pattern: Pattern, match_substring: bool, normalize_white_space: bool
537569
) -> ExpectedTextValue:

playwright/_impl/_browser.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import json
1717
from pathlib import Path
1818
from types import SimpleNamespace
19-
from typing import TYPE_CHECKING, Any, Dict, List, Union
19+
from typing import TYPE_CHECKING, Any, Dict, List, Union, cast
2020

2121
from playwright._impl._api_structures import (
2222
Geolocation,
@@ -115,10 +115,11 @@ async def new_context(
115115
await normalize_context_params(self._connection._is_sync, params)
116116

117117
channel = await self._channel.send("newContext", params)
118-
context = from_channel(channel)
118+
context = cast(BrowserContext, from_channel(channel))
119119
self._contexts.append(context)
120120
context._browser = self
121121
context._options = params
122+
context._tracing._local_utils = self._local_utils
122123
return context
123124

124125
async def new_page(

playwright/_impl/_browser_context.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
locals_to_params,
4848
to_impl,
4949
)
50-
from playwright._impl._local_utils import LocalUtils
5150
from playwright._impl._network import Request, Response, Route, serialize_headers
5251
from playwright._impl._page import BindingCall, Page, Worker
5352
from playwright._impl._tracing import Tracing
@@ -83,11 +82,10 @@ def __init__(
8382
self._options: Dict[str, Any] = {}
8483
self._background_pages: Set[Page] = set()
8584
self._service_workers: Set[Worker] = set()
86-
self._tracing = Tracing(self)
85+
self._tracing = cast(Tracing, from_channel(initializer["tracing"]))
8786
self._request: APIRequestContext = from_channel(
8887
initializer["APIRequestContext"]
8988
)
90-
_local_utils: LocalUtils
9189
self._channel.on(
9290
"bindingCall",
9391
lambda params: self._on_binding(from_channel(params["binding"])),

playwright/_impl/_browser_type.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ async def launch(
8989
browser = cast(
9090
Browser, from_channel(await self._channel.send("launch", params))
9191
)
92-
browser._utils = self._playwright._utils
92+
browser._local_utils = self._playwright._utils
9393
return browser
9494

9595
async def launch_persistent_context(
@@ -143,11 +143,12 @@ async def launch_persistent_context(
143143
params = locals_to_params(locals())
144144
await normalize_context_params(self._connection._is_sync, params)
145145
normalize_launch_params(params)
146-
context = from_channel(
147-
await self._channel.send("launchPersistentContext", params)
146+
context = cast(
147+
BrowserContext,
148+
from_channel(await self._channel.send("launchPersistentContext", params)),
148149
)
149150
context._options = params
150-
context._local_utils = self._playwright._utils
151+
context.tracing._local_utils = self._playwright._utils
151152
return context
152153

153154
async def connect_over_cdp(
@@ -160,7 +161,7 @@ async def connect_over_cdp(
160161
params = locals_to_params(locals())
161162
response = await self._channel.send_return_as_dict("connectOverCDP", params)
162163
browser = cast(Browser, from_channel(response["browser"]))
163-
browser._utils = self._playwright._utils
164+
browser._local_utils = self._playwright._utils
164165

165166
default_context = cast(
166167
Optional[BrowserContext],
@@ -210,7 +211,7 @@ async def connect(
210211
assert pre_launched_browser
211212
browser = cast(Browser, from_channel(pre_launched_browser))
212213
browser._should_close_connection_on_close = True
213-
browser._utils = self._playwright._utils
214+
browser._local_utils = self._playwright._utils
214215

215216
def handle_transport_close() -> None:
216217
for context in browser.contexts:

playwright/_impl/_fetch.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
object_to_array,
4242
)
4343
from playwright._impl._network import serialize_headers
44+
from playwright._impl._tracing import Tracing
4445

4546
if typing.TYPE_CHECKING:
4647
from playwright._impl._playwright import Playwright
@@ -72,14 +73,20 @@ async def new_context(
7273
)
7374
if "extraHTTPHeaders" in params:
7475
params["extraHTTPHeaders"] = serialize_headers(params["extraHTTPHeaders"])
75-
return from_channel(await self.playwright._channel.send("newRequest", params))
76+
context = cast(
77+
APIRequestContext,
78+
from_channel(await self.playwright._channel.send("newRequest", params)),
79+
)
80+
context._tracing._local_utils = self.playwright._utils
81+
return context
7682

7783

7884
class APIRequestContext(ChannelOwner):
7985
def __init__(
8086
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
8187
) -> None:
8288
super().__init__(parent, type, guid, initializer)
89+
self._tracing: Tracing = from_channel(initializer["tracing"])
8390

8491
async def dispose(self) -> None:
8592
await self._channel.send("dispose")
@@ -400,6 +407,14 @@ async def dispose(self) -> None:
400407
def _fetch_uid(self) -> str:
401408
return self._initializer["fetchUid"]
402409

410+
async def _fetch_log(self) -> List[str]:
411+
return await self._request._channel.send(
412+
"fetchLog",
413+
{
414+
"fetchUid": self._fetch_uid(),
415+
},
416+
)
417+
403418

404419
def is_json_content_type(headers: network.HeadersArray = None) -> bool:
405420
if not headers:

playwright/_impl/_object_factory.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from playwright._impl._playwright import Playwright
3333
from playwright._impl._selectors import Selectors
3434
from playwright._impl._stream import Stream
35+
from playwright._impl._tracing import Tracing
3536

3637

3738
class DummyObject(ChannelOwner):
@@ -82,6 +83,8 @@ def create_remote_object(
8283
return Route(parent, type, guid, initializer)
8384
if type == "Stream":
8485
return Stream(parent, type, guid, initializer)
86+
if type == "Tracing":
87+
return Tracing(parent, type, guid, initializer)
8588
if type == "WebSocket":
8689
return WebSocket(parent, type, guid, initializer)
8790
if type == "Worker":

playwright/_impl/_tracing.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,20 @@
1313
# limitations under the License.
1414

1515
import pathlib
16-
from typing import TYPE_CHECKING, Optional, Union, cast
16+
from typing import Dict, Optional, Union, cast
1717

1818
from playwright._impl._artifact import Artifact
19-
from playwright._impl._connection import from_nullable_channel
19+
from playwright._impl._connection import ChannelOwner, from_nullable_channel
2020
from playwright._impl._helper import locals_to_params
21+
from playwright._impl._local_utils import LocalUtils
2122

22-
if TYPE_CHECKING: # pragma: no cover
23-
from playwright._impl._browser_context import BrowserContext
2423

25-
26-
class Tracing:
27-
def __init__(self, context: "BrowserContext") -> None:
28-
self._context = context
29-
self._channel = context._channel
30-
self._loop = context._loop
31-
self._dispatcher_fiber = context._channel._connection._dispatcher_fiber
24+
class Tracing(ChannelOwner):
25+
def __init__(
26+
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
27+
) -> None:
28+
super().__init__(parent, type, guid, initializer)
29+
_local_utils: LocalUtils
3230

3331
async def start(
3432
self,
@@ -54,7 +52,7 @@ async def stop(self, path: Union[pathlib.Path, str] = None) -> None:
5452
await self._channel.send("tracingStop")
5553

5654
async def _do_stop_chunk(self, file_path: Union[pathlib.Path, str] = None) -> None:
57-
is_local = not self._channel._connection.is_remote
55+
is_local = not self._connection.is_remote
5856

5957
mode = "doNotSave"
6058
if file_path:
@@ -88,4 +86,4 @@ async def _do_stop_chunk(self, file_path: Union[pathlib.Path, str] = None) -> No
8886

8987
# Add local sources to the remote trace if necessary.
9088
if result.get("sourceEntries", []):
91-
await self._context._local_utils.zip(file_path, result["sourceEntries"])
89+
await self._local_utils.zip(file_path, result["sourceEntries"])

playwright/async_api/__init__.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
import playwright._impl._api_structures
2424
import playwright._impl._api_types
2525
import playwright.async_api._generated
26+
from playwright._impl._assertions import (
27+
APIResponseAssertions as APIResponseAssertionsImpl,
28+
)
2629
from playwright._impl._assertions import LocatorAssertions as LocatorAssertionsImpl
2730
from playwright._impl._assertions import PageAssertions as PageAssertionsImpl
2831
from playwright.async_api._context_manager import PlaywrightContextManager
@@ -31,6 +34,7 @@
3134
APIRequest,
3235
APIRequestContext,
3336
APIResponse,
37+
APIResponseAssertions,
3438
Browser,
3539
BrowserContext,
3640
BrowserType,
@@ -83,23 +87,30 @@ def async_playwright() -> PlaywrightContextManager:
8387

8488

8589
@overload
86-
def expect(page_or_locator: Page) -> PageAssertions:
90+
def expect(actual: Page) -> PageAssertions:
91+
...
92+
93+
94+
@overload
95+
def expect(actual: Locator) -> LocatorAssertions:
8796
...
8897

8998

9099
@overload
91-
def expect(page_or_locator: Locator) -> LocatorAssertions:
100+
def expect(actual: APIResponse) -> APIResponseAssertions:
92101
...
93102

94103

95104
def expect(
96-
page_or_locator: Union[Page, Locator]
97-
) -> Union[PageAssertions, LocatorAssertions]:
98-
if isinstance(page_or_locator, Page):
99-
return PageAssertions(PageAssertionsImpl(page_or_locator._impl_obj))
100-
elif isinstance(page_or_locator, Locator):
101-
return LocatorAssertions(LocatorAssertionsImpl(page_or_locator._impl_obj))
102-
raise ValueError(f"Unsupported type: {type(page_or_locator)}")
105+
actual: Union[Page, Locator, APIResponse]
106+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
107+
if isinstance(actual, Page):
108+
return PageAssertions(PageAssertionsImpl(actual._impl_obj))
109+
elif isinstance(actual, Locator):
110+
return LocatorAssertions(LocatorAssertionsImpl(actual._impl_obj))
111+
elif isinstance(actual, APIResponse):
112+
return APIResponseAssertions(APIResponseAssertionsImpl(actual._impl_obj))
113+
raise ValueError(f"Unsupported type: {type(actual)}")
103114

104115

105116
__all__ = [

playwright/async_api/_generated.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
ViewportSize,
4444
)
4545
from playwright._impl._api_types import Error
46+
from playwright._impl._assertions import (
47+
APIResponseAssertions as APIResponseAssertionsImpl,
48+
)
4649
from playwright._impl._assertions import LocatorAssertions as LocatorAssertionsImpl
4750
from playwright._impl._assertions import PageAssertions as PageAssertionsImpl
4851
from playwright._impl._async_base import (
@@ -15597,3 +15600,41 @@ async def not_to_be_focused(self, *, timeout: float = None) -> NoneType:
1559715600

1559815601

1559915602
mapping.register(LocatorAssertionsImpl, LocatorAssertions)
15603+
15604+
15605+
class APIResponseAssertions(AsyncBase):
15606+
async def to_be_ok(self) -> NoneType:
15607+
"""APIResponseAssertions.to_be_ok
15608+
15609+
Ensures the response status code is within [200..299] range.
15610+
15611+
```py
15612+
from playwright.async_api import expect
15613+
15614+
# ...
15615+
await expect(response).to_be_ok()
15616+
```
15617+
"""
15618+
__tracebackhide__ = True
15619+
15620+
return mapping.from_maybe_impl(
15621+
await self._async(
15622+
"api_response_assertions.to_be_ok", self._impl_obj.to_be_ok()
15623+
)
15624+
)
15625+
15626+
async def not_to_be_ok(self) -> NoneType:
15627+
"""APIResponseAssertions.not_to_be_ok
15628+
15629+
The opposite of `a_pi_response_assertions.to_be_ok()`.
15630+
"""
15631+
__tracebackhide__ = True
15632+
15633+
return mapping.from_maybe_impl(
15634+
await self._async(
15635+
"api_response_assertions.not_to_be_ok", self._impl_obj.not_to_be_ok()
15636+
)
15637+
)
15638+
15639+
15640+
mapping.register(APIResponseAssertionsImpl, APIResponseAssertions)

0 commit comments

Comments
 (0)