Introduce/improve check_js_errors and improve test_no_implicit_target (#874)

Until now, we didn't have a nice way to check that we expect a specific JS error in the web page.
This PR improves check_js_errors() so that now you can pass a list of error messages that you expect.
It is tricky because we need to handle (and test!) all various combinations of cases:

- errors expected and found / expected but not found
- unexpected errors found / not found

Moreover, JS exceptions now are logged in the special category console.js_error, which means that the printed text is also available using e.g. self.console.js_error.text or self.console.all.text. However, this should never be required and it's preferred to use self.check_js_errors to check for exceptions. This fixes #795 .

Finally, use the new logic to improve test_no_implicit_target.
This commit is contained in:
Antonio Cuni
2022-10-24 16:24:52 +02:00
committed by GitHub
parent f9194cc833
commit 58f7c2137d
6 changed files with 280 additions and 106 deletions

View File

@@ -165,7 +165,15 @@ function parseConfig(configText: string, configType = "toml") {
catch (err) {
const errMessage: string = err.toString();
showError(`<p>config supplied: ${configText} is an invalid TOML and cannot be parsed: ${errMessage}</p>`);
throw err;
// we cannot easily just "throw err" here, because for some reason
// playwright gets confused by it and cannot print it
// correctly. It is just displayed as an empty error.
// If you print err in JS, you get something like this:
// n {message: '...', offset: 19, line: 2, column: 19}
// I think that 'n' is the minified name?
// The workaround is to re-wrap the message into SyntaxError(), so that
// it's correctly handled by playwright.
throw SyntaxError(errMessage);
}
}
else if (configType === "json") {

View File

@@ -28,9 +28,9 @@ class PyScriptTest:
- self.console collects all the JS console.* messages. Look at the doc
of ConsoleMessageCollection for more details.
- self.check_errors() checks that no JS errors have been thrown
- self.check_js_errors() checks that no JS errors have been thrown
- after each test, self.check_errors() is automatically run to ensure
- after each test, self.check_js_errors() is automatically run to ensure
that no JS error passes uncaught.
- self.wait_for_console waits until the specified message appears in the
@@ -113,47 +113,74 @@ class PyScriptTest:
page.set_default_timeout(60000)
self.console = ConsoleMessageCollection(self.logger)
self._page_errors = []
page.on("console", self.console.add_message)
self._js_errors = []
page.on("console", self._on_console)
page.on("pageerror", self._on_pageerror)
def teardown_method(self):
# we call check_errors on teardown: this means that if there are still
# we call check_js_errors on teardown: this means that if there are still
# non-cleared errors, the test will fail. If you expect errors in your
# page and they should not cause the test to fail, you should call
# self.check_errors() in the test itself.
self.check_errors()
# self.check_js_errors() in the test itself.
self.check_js_errors()
def _on_console(self, msg):
self.console.add_message(msg.type, msg.text)
def _on_pageerror(self, error):
self.logger.log("JS exception", error.stack, color="red")
self._page_errors.append(error)
self.console.add_message("js_error", error.stack)
self._js_errors.append(error)
def check_errors(self):
def check_js_errors(self, *expected_messages):
"""
Check whether JS errors were reported.
If it finds a single JS error, raise JsError.
If it finds multiple JS errors, raise JsMultipleErrors.
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,
equivalent to this:
if expected_message in actual_error_message:
...
If an error was expected but not found, it raises
DidNotRaiseJsError().
If there are MORE errors other than the expected ones, it raises JsErrors.
Upon return, all the errors are cleared, so a subsequent call to
check_errors will not raise, unless NEW JS errors have been reported
check_js_errors will not raise, unless NEW JS errors have been reported
in the meantime.
"""
exc = None
if len(self._page_errors) == 1:
# if there is a single error, wrap it
exc = JsError(self._page_errors[0])
elif len(self._page_errors) >= 2:
exc = JsMultipleErrors(self._page_errors)
self._page_errors = []
if exc:
raise exc
expected_messages = list(expected_messages)
js_errors = self._js_errors[:]
def clear_errors(self):
for i, msg in enumerate(expected_messages):
for j, error in enumerate(js_errors):
if msg is not None and error is not None and msg in error.message:
# we matched one expected message with an error, remove both
expected_messages[i] = None
js_errors[j] = None
# if everything is find, now expected_messages and js_errors contains
# only Nones. If they contain non-None elements, it means that we
# either have messages which are expected-but-not-found or errors
# which are found-but-not-expected.
expected_messages = [msg for msg in expected_messages if msg is not None]
js_errors = [err for err in js_errors if err is not None]
self.clear_js_errors()
if expected_messages:
# expected-but-not-found
raise JsErrorsDidNotRaise(expected_messages, js_errors)
if js_errors:
# found-but-not-expected
raise JsErrors(js_errors)
def clear_js_errors(self):
"""
Clear all JS errors.
"""
self._page_errors = []
self._js_errors = []
def writefile(self, filename, content):
"""
@@ -168,7 +195,7 @@ class PyScriptTest:
url = f"{self.http_server}/{path}"
self.page.goto(url, timeout=0)
def wait_for_console(self, text, *, timeout=None, check_errors=True):
def wait_for_console(self, text, *, timeout=None, check_js_errors=True):
"""
Wait until the given message appear in the console.
@@ -179,7 +206,7 @@ class PyScriptTest:
timeout is expressed in milliseconds. If it's None, it will use
playwright's own default value, which is 30 seconds).
If check_errors is True (the default), it also checks that no JS
If check_js_errors is True (the default), it also checks that no JS
errors were raised during the waiting.
"""
pred = lambda msg: msg.text == text
@@ -192,24 +219,24 @@ class PyScriptTest:
# the JsError will shadow the TimeoutError but this is correct,
# because it's very likely that the console message never appeared
# precisely because of the exception in JS.
if check_errors:
self.check_errors()
if check_js_errors:
self.check_js_errors()
def wait_for_pyscript(self, *, timeout=None, check_errors=True):
def wait_for_pyscript(self, *, timeout=None, check_js_errors=True):
"""
Wait until pyscript has been fully loaded.
Timeout is expressed in milliseconds. If it's None, it will use
playwright's own default value, which is 30 seconds).
If check_errors is True (the default), it also checks that no JS
If check_js_errors is True (the default), it also checks that no JS
errors were raised during the waiting.
"""
# this is printed by runtime.ts:Runtime.initialize
self.wait_for_console(
"[pyscript/main] PyScript page fully initialized",
timeout=timeout,
check_errors=check_errors,
check_js_errors=check_js_errors,
)
# We still don't know why this wait is necessary, but without it
# events aren't being triggered in the tests.
@@ -251,9 +278,9 @@ class PyScriptTest:
# ============== Helpers and utility functions ==============
class JsError(Exception):
class JsErrors(Exception):
"""
Represent an exception which happened in JS.
Represent one or more exceptions which happened in JS.
It's a thin wrapper around playwright.sync_api.Error, with two important
differences:
@@ -265,9 +292,15 @@ class JsError(Exception):
playwright.sync_api.Error
"""
def __init__(self, error):
super().__init__(self.format_playwright_error(error))
self.error = error
def __init__(self, errors):
n = len(errors)
assert n != 0
lines = [f"JS errors found: {n}"]
for err in errors:
lines.append(self.format_playwright_error(err))
msg = "\n".join(lines)
super().__init__(msg)
self.errors = errors
@staticmethod
def format_playwright_error(error):
@@ -278,17 +311,24 @@ class JsError(Exception):
return error.stack or str(error)
class JsMultipleErrors(Exception):
class JsErrorsDidNotRaise(Exception):
"""
This is raised in case we get multiple JS errors in the page
Exception raised by check_js_errors when the expected JS error messages
are not found.
"""
def __init__(self, errors):
lines = ["Multiple JS errors found:"]
for err in errors:
lines.append(JsError.format_playwright_error(err))
def __init__(self, expected_messages, errors):
lines = ["The following JS errors were expected but could not be found:"]
for msg in expected_messages:
lines.append(" - " + msg)
if errors:
lines.append("---")
lines.append("The following JS errors were raised but not expected:")
for err in errors:
lines.append(JsErrors.format_playwright_error(err))
msg = "\n".join(lines)
super().__init__(msg)
self.expected_messages = expected_messages
self.errors = errors
@@ -307,9 +347,18 @@ class ConsoleMessageCollection:
console.error.*
console.warning.*
console.all.* same as above, but considering all messages, no filters
console.js_error.* this is a special category which does not exist in the
browser: it prints uncaught JS exceptions
console.all.* same as the individual categories but considering
all messages which were sent to the console
"""
@dataclass
class Message:
type: str # 'log', 'info', 'debug', etc.
text: str
class View:
"""
Filter console messages by the given msg_type
@@ -337,8 +386,9 @@ class ConsoleMessageCollection:
return "\n".join(self.lines)
_COLORS = {
"error": "red",
"warning": "brown",
"error": "darkred",
"js_error": "red",
}
def __init__(self, logger):
@@ -350,10 +400,12 @@ class ConsoleMessageCollection:
self.info = self.View(self, "info")
self.error = self.View(self, "error")
self.warning = self.View(self, "warning")
self.js_error = self.View(self, "js_error")
def add_message(self, msg):
# log the message: pytest will capute the output and display the
def add_message(self, type, text):
# log the message: pytest will capture the output and display the
# messages if the test fails.
msg = self.Message(type=type, text=text)
category = f"console.{msg.type}"
color = self._COLORS.get(msg.type)
self.logger.log(category, msg.text, color=color)
@@ -389,7 +441,7 @@ class Logger:
def log(self, category, text, *, color=None):
delta = time.time() - self.start_time
text = self.colorize_prefix(text, color="teal")
line = f"[{delta:6.2f} {category:15}] {text}"
line = f"[{delta:6.2f} {category:16}] {text}"
if color:
line = Color.set(color, line)
print(line)

View File

@@ -1,9 +1,10 @@
import re
import textwrap
import pytest
from playwright import sync_api
from .support import JsError, JsMultipleErrors, PyScriptTest
from .support import JsErrors, JsErrorsDidNotRaise, PyScriptTest
class TestSupport(PyScriptTest):
@@ -75,7 +76,7 @@ class TestSupport(PyScriptTest):
assert self.console.log.lines == ["my log 1", "my log 2"]
assert self.console.debug.lines == ["my debug"]
def test_check_errors(self):
def test_check_js_errors_simple(self):
doc = """
<html>
<body>
@@ -85,18 +86,68 @@ class TestSupport(PyScriptTest):
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(JsError) as exc:
self.check_errors()
with pytest.raises(JsErrors) as exc:
self.check_js_errors()
# check that the exception message contains the error message and the
# stack trace
msg = str(exc.value)
assert "Error: this is an error" in msg
assert f"at {self.http_server}/mytest.html" in msg
expected = textwrap.dedent(
f"""
JS errors found: 1
Error: this is an error
at {self.http_server}/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
#
# after a call to check_errors, the errors are cleared
self.check_errors()
# after a call to check_js_errors, the errors are cleared
self.check_js_errors()
#
# JS exceptions are also available in self.console.js_error
assert self.console.js_error.lines[0].startswith("Error: this is an error")
def test_check_errors_multiple(self):
def test_check_js_errors_expected(self):
doc = """
<html>
<body>
<script>throw new Error('this is an error');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
self.check_js_errors("this is an error")
def test_check_js_errors_expected_but_didnt_raise(self):
doc = """
<html>
<body>
<script>throw new Error('this is an error 2');</script>
<script>throw new Error('this is an error 4');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(JsErrorsDidNotRaise) as exc:
self.check_js_errors(
"this is an error 1",
"this is an error 2",
"this is an error 3",
"this is an error 4",
)
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
The following JS errors were expected but could not be found:
- this is an error 1
- this is an error 3
"""
).strip()
assert re.search(expected, msg)
def test_check_js_errors_multiple(self):
doc = """
<html>
<body>
@@ -107,15 +158,82 @@ class TestSupport(PyScriptTest):
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(JsMultipleErrors) as exc:
self.check_errors()
assert "error 1" in str(exc.value)
assert "error 2" in str(exc.value)
with pytest.raises(JsErrors) as exc:
self.check_js_errors()
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
JS errors found: 2
Error: error 1
at http://fake_server/mytest.html:.*
Error: error 2
at http://fake_server/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
#
# check that errors are cleared
self.check_errors()
self.check_js_errors()
def test_clear_errors(self):
def test_check_js_errors_some_expected_but_others_not(self):
doc = """
<html>
<body>
<script>throw new Error('expected 1');</script>
<script>throw new Error('NOT expected 2');</script>
<script>throw new Error('expected 3');</script>
<script>throw new Error('NOT expected 4');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(JsErrors) as exc:
self.check_js_errors("expected 1", "expected 3")
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
JS errors found: 2
Error: NOT expected 2
at http://fake_server/mytest.html:.*
Error: NOT expected 4
at http://fake_server/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
def test_check_js_errors_expected_not_found_but_other_errors(self):
doc = """
<html>
<body>
<script>throw new Error('error 1');</script>
<script>throw new Error('error 2');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(JsErrorsDidNotRaise) as exc:
self.check_js_errors("this is not going to be found")
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
The following JS errors were expected but could not be found:
- this is not going to be found
---
The following JS errors were raised but not expected:
Error: error 1
at http://fake_server/mytest.html:.*
Error: error 2
at http://fake_server/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
def test_clear_js_errors(self):
doc = """
<html>
<body>
@@ -125,10 +243,10 @@ class TestSupport(PyScriptTest):
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
self.clear_errors()
# self.check_errors does not raise, because the errors have been
self.clear_js_errors()
# self.check_js_errors does not raise, because the errors have been
# cleared
self.check_errors()
self.check_js_errors()
def test_wait_for_console(self):
"""
@@ -177,19 +295,19 @@ class TestSupport(PyScriptTest):
self.writefile("mytest.html", doc)
# "Page loaded!" will never appear, of course.
self.goto("mytest.html")
with pytest.raises(JsError) as exc:
with pytest.raises(JsErrors) as exc:
self.wait_for_console("Page loaded!", timeout=200)
assert "this is an error" in str(exc.value)
assert isinstance(exc.value.__context__, sync_api.TimeoutError)
#
# if we use check_errors=False, the error are ignored, but we get the
# if we use check_js_errors=False, the error are ignored, but we get the
# Timeout anyway
self.goto("mytest.html")
with pytest.raises(sync_api.TimeoutError):
self.wait_for_console("Page loaded!", timeout=200, check_errors=False)
# we still got a JsError, so we need to manually clear it, else the
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
# test fails at teardown
self.clear_errors()
self.clear_js_errors()
def test_wait_for_console_exception_2(self):
"""
@@ -210,13 +328,13 @@ class TestSupport(PyScriptTest):
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(JsError) as exc:
with pytest.raises(JsErrors) as exc:
self.wait_for_console("Page loaded!", timeout=200)
assert "this is an error" in str(exc.value)
#
# with check_errors=False, the Error is ignored and the
# with check_js_errors=False, the Error is ignored and the
# wait_for_console succeeds
self.goto("mytest.html")
self.wait_for_console("Page loaded!", timeout=200, check_errors=False)
self.wait_for_console("Page loaded!", timeout=200, check_js_errors=False)
# clear the errors, else the test fails at teardown
self.clear_errors()
self.clear_js_errors()

View File

@@ -95,17 +95,9 @@ class TestOutput(PyScriptTest):
self.page.locator("text=Click me").click()
text = self.page.text_content("body")
assert "hello" not in text
# currently the test infrastructure doesn't allow to easily assert that
# js exceptions were raised this is a workaround but we need a better fix.
# Antonio promised to write it
assert len(self._page_errors) == 1
console_text = self._page_errors
assert (
self.check_js_errors(
"Implicit target not allowed here. Please use display(..., target=...)"
in console_text[0].message
)
self._page_errors = []
def test_explicit_target_pyscript_tag(self):
self.pyscript_run(

View File

@@ -5,7 +5,7 @@ import tempfile
import pytest
import requests
from .support import JsError, PyScriptTest
from .support import PyScriptTest
URL = "https://github.com/pyodide/pyodide/releases/download/0.20.0/pyodide-build-0.20.0.tar.bz2"
TAR_NAME = "pyodide-build-0.20.0.tar.bz2"
@@ -105,29 +105,32 @@ class TestConfig(PyScriptTest):
assert version == "0.20.0"
def test_invalid_json_config(self):
with pytest.raises(JsError) as exc:
self.pyscript_run(
snippet="""
<py-config type="json">
[[
</py-config>
"""
)
msg = str(exc.value)
assert "SyntaxError" in msg
# we need wait_for_pyscript=False because we bail out very soon,
# before being able to write 'PyScript page fully initialized'
self.pyscript_run(
"""
<py-config type="json">
[[
</py-config>
""",
wait_for_pyscript=False,
)
self.page.wait_for_selector(".py-error")
self.check_js_errors("Unexpected end of JSON input")
def test_invalid_toml_config(self):
with pytest.raises(JsError) as exc:
self.pyscript_run(
snippet="""
<py-config>
[[
</py-config>
"""
)
msg = str(exc)
assert "<ExceptionInfo JsError" in msg
# we need wait_for_pyscript=False because we bail out very soon,
# before being able to write 'PyScript page fully initialized'
self.pyscript_run(
"""
<py-config>
[[
</py-config>
""",
wait_for_pyscript=False,
)
self.page.wait_for_selector(".py-error")
self.check_js_errors("SyntaxError: Expected DoubleQuote")
def test_multiple_py_config(self):
self.pyscript_run(

View File

@@ -109,6 +109,7 @@ class TestExamples(PyScriptTest):
assert self.page.title() == "Bokeh Example"
wait_for_render(self.page, "*", '<div.*?class=\\"bk\\".*?>')
@pytest.mark.skip("flaky, see issue 759")
def test_d3(self):
self.goto("examples/d3.html")
self.wait_for_pyscript()