Make sure that tests fail in case there is an unhandled Python error (#1456)

Before this PR, the following test passed:

    def test_pyscript_hello(self):
        self.pyscript_run(
            """
            <script type="py">
                raise Exception("hello")
            </script>
            """)

What happens is that we intercept the Python exception and display a nice banner on the DOM, but the test itself passes. This is error prone: if we have Python exceptions on the page, the test should fail by default, and we should have a way to silence it in case those exceptions are expected.

This PR treats Python errors as we treat JS errors: unhandled exceptions cause the test to fail, but you can silence them by calling self.check_py_errors(), exactly as you can call self.check_js_errors().
This commit is contained in:
Antonio Cuni
2023-05-09 15:39:19 +02:00
committed by GitHub
parent 73e0271c23
commit 82e5b64bad
7 changed files with 107 additions and 71 deletions

View File

@@ -208,6 +208,7 @@ class PyScriptTest:
self.console = ConsoleMessageCollection(self.logger) self.console = ConsoleMessageCollection(self.logger)
self._js_errors = [] self._js_errors = []
self._py_errors = []
page.on("console", self._on_console) page.on("console", self._on_console)
page.on("pageerror", self._on_pageerror) page.on("pageerror", self._on_pageerror)
@@ -243,17 +244,26 @@ class PyScriptTest:
# page and they should not cause the test to fail, you should call # page and they should not cause the test to fail, you should call
# self.check_js_errors() in the test itself. # self.check_js_errors() in the test itself.
self.check_js_errors() self.check_js_errors()
self.check_py_errors()
def _on_console(self, msg): def _on_console(self, msg):
if msg.type == "error" and "Traceback (most recent call last)" in msg.text:
# this is a Python traceback, let's record it as a py_error
self._py_errors.append(msg.text)
self.console.add_message(msg.type, msg.text) self.console.add_message(msg.type, msg.text)
def _on_pageerror(self, error): def _on_pageerror(self, error):
self.console.add_message("js_error", error.stack) # apparently, playwright Error.stack contains all the info that we
self._js_errors.append(error) # want: exception name, message and stacktrace. The docs say that
# error.stack is optional, so fallback to the standard repr if it's
# unavailable.
error_msg = error.stack or str(error)
self.console.add_message("js_error", error_msg)
self._js_errors.append(error_msg)
def check_js_errors(self, *expected_messages): def _check_page_errors(self, kind, expected_messages):
""" """
Check whether JS errors were reported. Check whether the page raised any 'JS' or 'Python' error.
expected_messages is a list of strings of errors that you expect they expected_messages is a list of strings of errors that you expect they
were raised in the page. They are checked using a simple 'in' check, were raised in the page. They are checked using a simple 'in' check,
@@ -261,40 +271,62 @@ class PyScriptTest:
if expected_message in actual_error_message: if expected_message in actual_error_message:
... ...
If an error was expected but not found, it raises If an error was expected but not found, it raises PageErrorsDidNotRaise.
DidNotRaiseJsError().
If there are MORE errors other than the expected ones, it raises JsErrors. If there are MORE errors other than the expected ones, it raises PageErrors.
Upon return, all the errors are cleared, so a subsequent call to Upon return, all the errors are cleared, so a subsequent call to
check_js_errors will not raise, unless NEW JS errors have been reported check_{js,py}_errors will not raise, unless NEW errors have been reported
in the meantime. in the meantime.
""" """
assert kind in ("JS", "Python")
if kind == "JS":
actual_errors = self._js_errors[:]
else:
actual_errors = self._py_errors[:]
expected_messages = list(expected_messages) expected_messages = list(expected_messages)
js_errors = self._js_errors[:]
for i, msg in enumerate(expected_messages): for i, msg in enumerate(expected_messages):
for j, error in enumerate(js_errors): for j, error in enumerate(actual_errors):
if msg is not None and error is not None and msg in error.message: if msg is not None and error is not None and msg in error:
# we matched one expected message with an error, remove both # we matched one expected message with an error, remove both
expected_messages[i] = None expected_messages[i] = None
js_errors[j] = None actual_errors[j] = None
# if everything is find, now expected_messages and js_errors contains # if everything is find, now expected_messages and actual_errors contains
# only Nones. If they contain non-None elements, it means that we # only Nones. If they contain non-None elements, it means that we
# either have messages which are expected-but-not-found or errors # either have messages which are expected-but-not-found or
# which are found-but-not-expected. # found-but-not-expected.
expected_messages = [msg for msg in expected_messages if msg is not None] not_found = [msg for msg in expected_messages if msg is not None]
js_errors = [err for err in js_errors if err is not None] unexpected = [err for err in actual_errors if err is not None]
self.clear_js_errors()
if expected_messages: if kind == "JS":
self.clear_js_errors()
else:
self.clear_py_errors()
if not_found:
# expected-but-not-found # expected-but-not-found
raise JsErrorsDidNotRaise(expected_messages, js_errors) raise PageErrorsDidNotRaise(kind, not_found, unexpected)
if unexpected:
if js_errors:
# found-but-not-expected # found-but-not-expected
raise JsErrors(js_errors) raise PageErrors(kind, unexpected)
def check_js_errors(self, *expected_messages):
"""
Check whether JS errors were reported.
See the docstring for _check_page_errors for more details.
"""
self._check_page_errors("JS", expected_messages)
def check_py_errors(self, *expected_messages):
"""
Check whether Python errors were reported.
See the docstring for _check_page_errors for more details.
"""
self._check_page_errors("Python", expected_messages)
def clear_js_errors(self): def clear_js_errors(self):
""" """
@@ -302,6 +334,9 @@ class PyScriptTest:
""" """
self._js_errors = [] self._js_errors = []
def clear_py_errors(self):
self._py_errors = []
def writefile(self, filename, content): def writefile(self, filename, content):
""" """
Very thin helper to write a file in the tmpdir Very thin helper to write a file in the tmpdir
@@ -614,54 +649,37 @@ def wait_for_render(page, selector, pattern):
assert py_rendered # nosec assert py_rendered # nosec
class JsErrors(Exception): class PageErrors(Exception):
""" """
Represent one or more exceptions which happened in JS. Represent one or more exceptions which happened in JS or Python.
It's a thin wrapper around playwright.sync_api.Error, with two important
differences:
1. it has a better name: if you see JsError in a traceback, it's
immediately obvious that it's a JS exception.
2. Show also the JS stacktrace by default, contrarily to
playwright.sync_api.Error
""" """
def __init__(self, errors): def __init__(self, kind, errors):
assert kind in ("JS", "Python")
n = len(errors) n = len(errors)
assert n != 0 assert n != 0
lines = [f"JS errors found: {n}"] lines = [f"{kind} errors found: {n}"]
for err in errors: lines += errors
lines.append(self.format_playwright_error(err))
msg = "\n".join(lines) msg = "\n".join(lines)
super().__init__(msg) super().__init__(msg)
self.errors = errors self.errors = errors
@staticmethod
def format_playwright_error(error):
# apparently, playwright Error.stack contains all the info that we
# want: exception name, message and stacktrace. The docs say that
# error.stack is optional, so fallback to the standard repr if it's
# unavailable.
return error.stack or str(error)
class PageErrorsDidNotRaise(Exception):
class JsErrorsDidNotRaise(Exception):
""" """
Exception raised by check_js_errors when the expected JS error messages Exception raised by check_{js,py}_errors when the expected JS or Python
are not found. error messages are not found.
""" """
def __init__(self, expected_messages, errors): def __init__(self, kind, expected_messages, errors):
lines = ["The following JS errors were expected but could not be found:"] assert kind in ("JS", "Python")
lines = [f"The following {kind} errors were expected but could not be found:"]
for msg in expected_messages: for msg in expected_messages:
lines.append(" - " + msg) lines.append(" - " + msg)
if errors: if errors:
lines.append("---") lines.append("---")
lines.append("The following JS errors were raised but not expected:") lines.append(f"The following {kind} errors were raised but not expected:")
for err in errors: lines += errors
lines.append(JsErrors.format_playwright_error(err))
msg = "\n".join(lines) msg = "\n".join(lines)
super().__init__(msg) super().__init__(msg)
self.expected_messages = expected_messages self.expected_messages = expected_messages

View File

@@ -3,7 +3,12 @@ import textwrap
import pytest import pytest
from .support import JsErrors, JsErrorsDidNotRaise, PyScriptTest, with_execution_thread from .support import (
PageErrors,
PageErrorsDidNotRaise,
PyScriptTest,
with_execution_thread,
)
@with_execution_thread(None) @with_execution_thread(None)
@@ -104,7 +109,7 @@ class TestSupport(PyScriptTest):
""" """
self.writefile("mytest.html", doc) self.writefile("mytest.html", doc)
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(JsErrors) as exc: with pytest.raises(PageErrors) as exc:
self.check_js_errors() self.check_js_errors()
# check that the exception message contains the error message and the # check that the exception message contains the error message and the
# stack trace # stack trace
@@ -147,7 +152,7 @@ class TestSupport(PyScriptTest):
""" """
self.writefile("mytest.html", doc) self.writefile("mytest.html", doc)
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(JsErrorsDidNotRaise) as exc: with pytest.raises(PageErrorsDidNotRaise) as exc:
self.check_js_errors( self.check_js_errors(
"this is an error 1", "this is an error 1",
"this is an error 2", "this is an error 2",
@@ -176,7 +181,7 @@ class TestSupport(PyScriptTest):
""" """
self.writefile("mytest.html", doc) self.writefile("mytest.html", doc)
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(JsErrors) as exc: with pytest.raises(PageErrors) as exc:
self.check_js_errors() self.check_js_errors()
# #
msg = str(exc.value) msg = str(exc.value)
@@ -207,7 +212,7 @@ class TestSupport(PyScriptTest):
""" """
self.writefile("mytest.html", doc) self.writefile("mytest.html", doc)
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(JsErrors) as exc: with pytest.raises(PageErrors) as exc:
self.check_js_errors("expected 1", "expected 3") self.check_js_errors("expected 1", "expected 3")
# #
msg = str(exc.value) msg = str(exc.value)
@@ -233,7 +238,7 @@ class TestSupport(PyScriptTest):
""" """
self.writefile("mytest.html", doc) self.writefile("mytest.html", doc)
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(JsErrorsDidNotRaise) as exc: with pytest.raises(PageErrorsDidNotRaise) as exc:
self.check_js_errors("this is not going to be found") self.check_js_errors("this is not going to be found")
# #
msg = str(exc.value) msg = str(exc.value)
@@ -348,7 +353,7 @@ class TestSupport(PyScriptTest):
self.writefile("mytest.html", doc) self.writefile("mytest.html", doc)
# "Page loaded!" will never appear, of course. # "Page loaded!" will never appear, of course.
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(JsErrors) as exc: with pytest.raises(PageErrors) as exc:
self.wait_for_console("Page loaded!", timeout=200) self.wait_for_console("Page loaded!", timeout=200)
assert "this is an error" in str(exc.value) assert "this is an error" in str(exc.value)
assert isinstance(exc.value.__context__, TimeoutError) assert isinstance(exc.value.__context__, TimeoutError)
@@ -358,7 +363,7 @@ class TestSupport(PyScriptTest):
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(TimeoutError): with pytest.raises(TimeoutError):
self.wait_for_console("Page loaded!", timeout=200, check_js_errors=False) self.wait_for_console("Page loaded!", timeout=200, check_js_errors=False)
# we still got a JsErrors, so we need to manually clear it, else the # we still got a PageErrors, so we need to manually clear it, else the
# test fails at teardown # test fails at teardown
self.clear_js_errors() self.clear_js_errors()
@@ -381,7 +386,7 @@ class TestSupport(PyScriptTest):
""" """
self.writefile("mytest.html", doc) self.writefile("mytest.html", doc)
self.goto("mytest.html") self.goto("mytest.html")
with pytest.raises(JsErrors) as exc: with pytest.raises(PageErrors) as exc:
self.wait_for_console("Page loaded!", timeout=200) self.wait_for_console("Page loaded!", timeout=200)
assert "this is an error" in str(exc.value) assert "this is an error" in str(exc.value)
# #

View File

@@ -2,7 +2,7 @@ import re
import pytest import pytest
from .support import JsErrors, PyScriptTest, skip_worker from .support import PageErrors, PyScriptTest, skip_worker
class TestBasic(PyScriptTest): class TestBasic(PyScriptTest):
@@ -76,6 +76,8 @@ class TestBasic(PyScriptTest):
""" """
) )
assert "hello pyscript" in self.console.log.lines assert "hello pyscript" in self.console.log.lines
self.check_py_errors("Exception: this is an error")
#
# check that we sent the traceback to the console # check that we sent the traceback to the console
tb_lines = self.console.error.lines[-1].splitlines() tb_lines = self.console.error.lines[-1].splitlines()
assert tb_lines[0] == "[pyexec] Python exception:" assert tb_lines[0] == "[pyexec] Python exception:"
@@ -107,6 +109,8 @@ class TestBasic(PyScriptTest):
"Exception: this is an error inside handler", match_substring=True "Exception: this is an error inside handler", match_substring=True
) )
self.check_py_errors("Exception: this is an error inside handler")
## error in console ## error in console
tb_lines = self.console.error.lines[-1].splitlines() tb_lines = self.console.error.lines[-1].splitlines()
assert tb_lines[0] == "[pyexec] Python exception:" assert tb_lines[0] == "[pyexec] Python exception:"
@@ -178,20 +182,21 @@ class TestBasic(PyScriptTest):
self.pyscript_run( self.pyscript_run(
""" """
<py-config> <py-config>
packages = ["nonexistendright"] packages = ["i-dont-exist"]
</py-config> </py-config>
""", """,
wait_for_pyscript=False, wait_for_pyscript=False,
) )
expected_alert_banner_msg = ( expected_alert_banner_msg = (
"(PY1001): Unable to install package(s) 'nonexistendright'. " "(PY1001): Unable to install package(s) 'i-dont-exist'. "
"Unable to find package in PyPI. Please make sure you have " "Unable to find package in PyPI. Please make sure you have "
"entered a correct package name." "entered a correct package name."
) )
alert_banner = self.page.wait_for_selector(".alert-banner") alert_banner = self.page.wait_for_selector(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text() assert expected_alert_banner_msg in alert_banner.inner_text()
self.check_py_errors("Can't fetch metadata for 'i-dont-exist'")
@skip_worker("FIXME: the banner doesn't appear") @skip_worker("FIXME: the banner doesn't appear")
def test_no_python_wheel(self): def test_no_python_wheel(self):
@@ -211,6 +216,7 @@ class TestBasic(PyScriptTest):
alert_banner = self.page.wait_for_selector(".alert-banner") alert_banner = self.page.wait_for_selector(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text() assert expected_alert_banner_msg in alert_banner.inner_text()
self.check_py_errors("Can't find a pure Python 3 wheel for 'opsdroid'")
def test_dynamically_add_py_script_tag(self): def test_dynamically_add_py_script_tag(self):
self.pyscript_run( self.pyscript_run(
@@ -240,7 +246,7 @@ class TestBasic(PyScriptTest):
assert self.console.log.lines[-1] == "hello from foo" assert self.console.log.lines[-1] == "hello from foo"
def test_py_script_src_not_found(self): def test_py_script_src_not_found(self):
with pytest.raises(JsErrors) as exc: with pytest.raises(PageErrors) as exc:
self.pyscript_run( self.pyscript_run(
""" """
<py-script src="foo.py"></py-script> <py-script src="foo.py"></py-script>
@@ -249,9 +255,7 @@ class TestBasic(PyScriptTest):
assert "Failed to load resource" in self.console.error.lines[0] assert "Failed to load resource" in self.console.error.lines[0]
error_msgs = str(exc.value) error_msgs = str(exc.value)
expected_msg = "(PY0404): Fetching from URL foo.py failed with error 404" expected_msg = "(PY0404): Fetching from URL foo.py failed with error 404"
assert expected_msg in error_msgs assert expected_msg in error_msgs
assert self.assert_banner_message(expected_msg) assert self.assert_banner_message(expected_msg)

View File

@@ -124,6 +124,7 @@ class TestDisplay(PyScriptTest):
""" """
) )
self.page.locator("text=Click me").click() self.page.locator("text=Click me").click()
self.check_py_errors("Implicit target not allowed here")
## error in console ## error in console
tb_lines = self.console.error.lines[-1].splitlines() tb_lines = self.console.error.lines[-1].splitlines()
assert tb_lines[0] == "[pyexec] Python exception:" assert tb_lines[0] == "[pyexec] Python exception:"

View File

@@ -191,4 +191,5 @@ class TestEventHandler(PyScriptTest):
f"match banner text '{banner_text}'" f"match banner text '{banner_text}'"
) )
assert any(msg in line for line in self.console.error.lines) assert msg in self.console.error.lines[-1]
self.check_py_errors(msg)

View File

@@ -183,6 +183,8 @@ class TestPyRepl(PyScriptTest):
tb_lines = err_pre.inner_text().splitlines() tb_lines = err_pre.inner_text().splitlines()
assert tb_lines[0] == "Traceback (most recent call last):" assert tb_lines[0] == "Traceback (most recent call last):"
assert tb_lines[-1] == "Exception: this is an error" assert tb_lines[-1] == "Exception: this is an error"
#
self.check_py_errors("this is an error")
@skip_worker("FIXME: display()") @skip_worker("FIXME: display()")
def test_multiple_repls(self): def test_multiple_repls(self):
@@ -228,6 +230,8 @@ class TestPyRepl(PyScriptTest):
out_div = self.page.wait_for_selector("#py-internal-0-repl-output") out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert "hello world" not in out_div.inner_text() assert "hello world" not in out_div.inner_text()
assert "ZeroDivisionError" in out_div.inner_text() assert "ZeroDivisionError" in out_div.inner_text()
#
self.check_py_errors("ZeroDivisionError")
@skip_worker("FIXME: js.document") @skip_worker("FIXME: js.document")
def test_hide_previous_error_after_successful_run(self): def test_hide_previous_error_after_successful_run(self):
@@ -253,6 +257,8 @@ class TestPyRepl(PyScriptTest):
# test runner can be too fast, the line below should wait for output to change # test runner can be too fast, the line below should wait for output to change
out_div = self.page.wait_for_selector("#py-internal-0-repl-output") out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "hello" assert out_div.inner_text() == "hello"
#
self.check_py_errors("this is an error")
def test_output_attribute_does_not_exist(self): def test_output_attribute_does_not_exist(self):
""" """

View File

@@ -1,6 +1,6 @@
from playwright.sync_api import expect from playwright.sync_api import expect
from .support import PyScriptTest from .support import PyScriptTest, skip_worker
class TestStyle(PyScriptTest): class TestStyle(PyScriptTest):
@@ -24,6 +24,7 @@ class TestStyle(PyScriptTest):
expect(self.page.locator("py-script")).to_be_hidden() expect(self.page.locator("py-script")).to_be_hidden()
expect(self.page.locator("py-repl")).to_be_hidden() expect(self.page.locator("py-repl")).to_be_hidden()
@skip_worker("FIXME: display()")
def test_pyscript_defined(self): def test_pyscript_defined(self):
"""Test elements have visibility that should""" """Test elements have visibility that should"""
self.pyscript_run( self.pyscript_run(