Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: brush up exception messages to include apiName & call log #2371

Merged
merged 2 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions playwright/_impl/_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from pyee.asyncio import AsyncIOEventEmitter

import playwright
from playwright._impl._errors import TargetClosedError
from playwright._impl._errors import TargetClosedError, rewrite_error
from playwright._impl._greenlets import EventGreenlet
from playwright._impl._helper import Error, ParsedMessagePayload, parse_error
from playwright._impl._transport import Transport
Expand Down Expand Up @@ -374,11 +374,12 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
return
error = msg.get("error")
if error and not msg.get("result"):
parsed_error = parse_error(error["error"]) # type: ignore
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._stack = "".join(
traceback.format_list(callback.stack_trace)[-10:]
)
parsed_error._message += format_call_log(msg.get("log")) # type: ignore
callback.future.set_exception(parsed_error)
else:
result = self._replace_guids_with_channels(msg.get("result"))
Expand Down Expand Up @@ -504,9 +505,12 @@ async def wrap_api_call(
return await cb()
task = asyncio.current_task(self._loop)
st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack())
self._api_zone.set(_extract_stack_trace_information_from_stack(st, is_internal))
parsed_st = _extract_stack_trace_information_from_stack(st, is_internal)
self._api_zone.set(parsed_st)
try:
return await cb()
except Exception as error:
raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None
finally:
self._api_zone.set(None)

Expand All @@ -517,9 +521,12 @@ def wrap_api_call_sync(
return cb()
task = asyncio.current_task(self._loop)
st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack())
self._api_zone.set(_extract_stack_trace_information_from_stack(st, is_internal))
parsed_st = _extract_stack_trace_information_from_stack(st, is_internal)
self._api_zone.set(parsed_st)
try:
return cb()
except Exception as error:
raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None
finally:
self._api_zone.set(None)

Expand All @@ -546,7 +553,7 @@ class ParsedStackTrace(TypedDict):

def _extract_stack_trace_information_from_stack(
st: List[inspect.FrameInfo], is_internal: bool
) -> Optional[ParsedStackTrace]:
) -> ParsedStackTrace:
playwright_module_path = str(Path(playwright.__file__).parents[0])
last_internal_api_name = ""
api_name = ""
Expand Down
8 changes: 8 additions & 0 deletions playwright/_impl/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,11 @@ class TimeoutError(Error):
class TargetClosedError(Error):
def __init__(self, message: str = None) -> None:
super().__init__(message or "Target page, context or browser has been closed")


def rewrite_error(error: Exception, message: str) -> Exception:
rewritten_exc = type(error)(message)
if isinstance(rewritten_exc, Error) and isinstance(error, Error):
rewritten_exc._name = error.name
rewritten_exc._stack = error.stack
return rewritten_exc
12 changes: 5 additions & 7 deletions playwright/_impl/_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,26 +210,24 @@ def serialize_error(ex: Exception, tb: Optional[TracebackType]) -> ErrorPayload:
)


def parse_error(error: ErrorPayload) -> Error:
def parse_error(error: ErrorPayload, log: Optional[str] = None) -> Error:
base_error_class = Error
if error.get("name") == "TimeoutError":
base_error_class = TimeoutError
if error.get("name") == "TargetClosedError":
base_error_class = TargetClosedError
exc = base_error_class(cast(str, patch_error_message(error.get("message"))))
if not log:
log = ""
exc = base_error_class(patch_error_message(error["message"]) + log)
exc._name = error["name"]
exc._stack = error["stack"]
return exc


def patch_error_message(message: Optional[str]) -> Optional[str]:
if message is None:
return None

def patch_error_message(message: str) -> str:
match = re.match(r"(\w+)(: expected .*)", message)
if match:
message = to_snake_case(match.group(1)) + match.group(2)
assert message is not None
message = message.replace(
"Pass { acceptDownloads: true }", "Pass { accept_downloads: True }"
)
Expand Down
4 changes: 2 additions & 2 deletions tests/async/test_browsercontext.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async def test_page_event_should_not_allow_device_scale_factor_with_null_viewpor
await browser.new_context(no_viewport=True, device_scale_factor=1)
assert (
exc_info.value.message
== '"deviceScaleFactor" option is not supported with null "viewport"'
== 'Browser.new_context: "deviceScaleFactor" option is not supported with null "viewport"'
)


Expand All @@ -137,7 +137,7 @@ async def test_page_event_should_not_allow_is_mobile_with_null_viewport(
await browser.new_context(no_viewport=True, is_mobile=True)
assert (
exc_info.value.message
== '"isMobile" option is not supported with null "viewport"'
== 'Browser.new_context: "isMobile" option is not supported with null "viewport"'
)


Expand Down
2 changes: 1 addition & 1 deletion tests/async/test_frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async def test_frame_element_throw_when_detached(
except Error as e:
error = e
assert error
assert error.message == "Frame has been detached."
assert error.message == "Frame.frame_element: Frame has been detached."


async def test_evaluate_throw_for_detached_frames(
Expand Down
6 changes: 3 additions & 3 deletions tests/async/test_keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,15 +424,15 @@ async def testEnterKey(key: str, expectedKey: str, expectedCode: str) -> None:
async def test_should_throw_unknown_keys(page: Page, server: Server) -> None:
with pytest.raises(Error) as exc:
await page.keyboard.press("NotARealKey")
assert exc.value.message == 'Unknown key: "NotARealKey"'
assert exc.value.message == 'Keyboard.press: Unknown key: "NotARealKey"'

with pytest.raises(Error) as exc:
await page.keyboard.press("ё")
assert exc.value.message == 'Unknown key: "ё"'
assert exc.value.message == 'Keyboard.press: Unknown key: "ё"'

with pytest.raises(Error) as exc:
await page.keyboard.press("😊")
assert exc.value.message == 'Unknown key: "😊"'
assert exc.value.message == 'Keyboard.press: Unknown key: "😊"'


async def test_should_type_emoji(page: Page, server: Server) -> None:
Expand Down
17 changes: 17 additions & 0 deletions tests/async/test_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import os
import re
import traceback
from typing import Callable
from urllib.parse import urlparse

Expand Down Expand Up @@ -1083,3 +1084,19 @@ async def test_locator_all_should_work(page: Page) -> None:
for p in await page.locator("p").all():
texts.append(await p.text_content())
assert texts == ["A", "B", "C"]


async def test_locator_click_timeout_error_should_contain_call_log(page: Page) -> None:
with pytest.raises(Error) as exc_info:
await page.get_by_role("button", name="Hello Python").click(timeout=42)
formatted_exception = "".join(
traceback.format_exception(type(exc_info.value), value=exc_info.value, tb=None)
)
assert "Locator.click: Timeout 42ms exceeded." in formatted_exception
assert (
'waiting for get_by_role("button", name="Hello Python")' in formatted_exception
)
assert (
"During handling of the above exception, another exception occurred"
not in formatted_exception
)
5 changes: 4 additions & 1 deletion tests/async/test_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,10 @@ async def test_set_extra_http_headers_should_throw_for_non_string_header_values(
except Error as exc:
error = exc
assert error
assert error.message == "headers[0].value: expected string, got number"
assert (
error.message
== "Page.set_extra_http_headers: headers[0].value: expected string, got number"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't lowercase page.set_extra_http_headers be more pythonic?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure! Page = the class / interface name, matching with the docs, so probably not too bad.

)


async def test_response_server_addr(page: Page, server: Server) -> None:
Expand Down
11 changes: 8 additions & 3 deletions tests/async/test_queryselector.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ async def test_selectors_register_should_handle_errors(
await selectors.register("$", dummy_selector_engine_script)
assert (
exc.value.message
== "Selector engine name may only contain [a-zA-Z0-9_] characters"
== "Selectors.register: Selector engine name may only contain [a-zA-Z0-9_] characters"
)

# Selector names are case-sensitive.
Expand All @@ -195,11 +195,16 @@ async def test_selectors_register_should_handle_errors(

with pytest.raises(Error) as exc:
await selectors.register("dummy", dummy_selector_engine_script)
assert exc.value.message == '"dummy" selector engine has been already registered'
assert (
exc.value.message
== 'Selectors.register: "dummy" selector engine has been already registered'
)

with pytest.raises(Error) as exc:
await selectors.register("css", dummy_selector_engine_script)
assert exc.value.message == '"css" is a predefined selector engine'
assert (
exc.value.message == 'Selectors.register: "css" is a predefined selector engine'
)


async def test_should_work_with_layout_selectors(page: Page) -> None:
Expand Down
17 changes: 17 additions & 0 deletions tests/sync/test_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import os
import re
import traceback
from typing import Callable
from urllib.parse import urlparse

Expand Down Expand Up @@ -937,3 +938,19 @@ def test_locator_all_should_work(page: Page) -> None:
for p in page.locator("p").all():
texts.append(p.text_content())
assert texts == ["A", "B", "C"]


def test_locator_click_timeout_error_should_contain_call_log(page: Page) -> None:
with pytest.raises(Error) as exc_info:
page.get_by_role("button", name="Hello Python").click(timeout=42)
formatted_exception = "".join(
traceback.format_exception(type(exc_info.value), value=exc_info.value, tb=None)
)
assert "Locator.click: Timeout 42ms exceeded." in formatted_exception
assert (
'waiting for get_by_role("button", name="Hello Python")' in formatted_exception
)
assert (
"During handling of the above exception, another exception occurred"
not in formatted_exception
)
11 changes: 8 additions & 3 deletions tests/sync/test_queryselector.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def test_selectors_register_should_handle_errors(
selectors.register("$", dummy_selector_engine_script)
assert (
exc.value.message
== "Selector engine name may only contain [a-zA-Z0-9_] characters"
== "Selectors.register: Selector engine name may only contain [a-zA-Z0-9_] characters"
)

# Selector names are case-sensitive.
Expand All @@ -165,11 +165,16 @@ def test_selectors_register_should_handle_errors(

with pytest.raises(Error) as exc:
selectors.register("dummy", dummy_selector_engine_script)
assert exc.value.message == '"dummy" selector engine has been already registered'
assert (
exc.value.message
== 'Selectors.register: "dummy" selector engine has been already registered'
)

with pytest.raises(Error) as exc:
selectors.register("css", dummy_selector_engine_script)
assert exc.value.message == '"css" is a predefined selector engine'
assert (
exc.value.message == 'Selectors.register: "css" is a predefined selector engine'
)


def test_should_work_with_layout_selectors(page: Page) -> None:
Expand Down
Loading