diff --git a/changelog/5013.feature.rst b/changelog/5013.feature.rst new file mode 100644 index 000000000..08f82efeb --- /dev/null +++ b/changelog/5013.feature.rst @@ -0,0 +1 @@ +Messages from crash reports are displayed within test summaries now, truncated to the terminal width. diff --git a/changelog/5013.trivial.rst b/changelog/5013.trivial.rst new file mode 100644 index 000000000..fff4eaf3f --- /dev/null +++ b/changelog/5013.trivial.rst @@ -0,0 +1 @@ +pytest now depends on `wcwidth `__ to properly track unicode character sizes for more precise terminal output. diff --git a/setup.py b/setup.py index a924d4aba..795a8c75f 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ INSTALL_REQUIRES = [ 'pathlib2>=2.2.0;python_version<"3.6"', 'colorama;sys_platform=="win32"', "pluggy>=0.9", + "wcwidth", ] diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 78867c2d8..1d70fb153 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -1,3 +1,4 @@ +# coding=utf8 """ support for skip/xfail functions and markers. """ from __future__ import absolute_import from __future__ import division diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 7f348e823..771e6a835 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -1,3 +1,4 @@ +# encoding: utf-8 """ terminal reporting of the full testing process. This is a good source for looking at the various reporting hooks. @@ -887,10 +888,13 @@ class TerminalReporter(object): def show_simple(stat, lines): failed = self.stats.get(stat, []) + if not failed: + return + termwidth = self.writer.fullwidth + config = self.config for rep in failed: - verbose_word = rep._get_verbose_word(self.config) - pos = _get_pos(self.config, rep) - lines.append("%s %s" % (verbose_word, pos)) + line = _get_line_with_reprcrash_message(config, rep, termwidth) + lines.append(line) def show_xfailed(lines): xfailed = self.stats.get("xfailed", []) @@ -927,10 +931,6 @@ class TerminalReporter(object): else: lines.append("%s [%d] %s: %s" % (verbose_word, num, fspath, reason)) - def _get_pos(config, rep): - nodeid = config.cwd_relative_nodeid(rep.nodeid) - return nodeid - REPORTCHAR_ACTIONS = { "x": show_xfailed, "X": show_xpassed, @@ -954,6 +954,56 @@ class TerminalReporter(object): self.write_line(line) +def _get_pos(config, rep): + nodeid = config.cwd_relative_nodeid(rep.nodeid) + return nodeid + + +def _get_line_with_reprcrash_message(config, rep, termwidth): + """Get summary line for a report, trying to add reprcrash message.""" + from wcwidth import wcswidth + + verbose_word = rep._get_verbose_word(config) + pos = _get_pos(config, rep) + + line = "%s %s" % (verbose_word, pos) + len_line = wcswidth(line) + ellipsis, len_ellipsis = "...", 3 + if len_line > termwidth - len_ellipsis: + # No space for an additional message. + return line + + try: + msg = rep.longrepr.reprcrash.message + except AttributeError: + pass + else: + # Only use the first line. + i = msg.find("\n") + if i != -1: + msg = msg[:i] + len_msg = wcswidth(msg) + + sep, len_sep = " - ", 3 + max_len_msg = termwidth - len_line - len_sep + if max_len_msg >= len_ellipsis: + if len_msg > max_len_msg: + max_len_msg -= len_ellipsis + msg = msg[:max_len_msg] + while wcswidth(msg) > max_len_msg: + msg = msg[:-1] + if six.PY2: + # on python 2 systems with narrow unicode compilation, trying to + # get a single character out of a multi-byte unicode character such as + # u'😄' will result in a High Surrogate (U+D83D) character, which is + # rendered as u'�'; in this case we just strip that character out as it + # serves no purpose being rendered + msg = msg.rstrip(u"\uD83D") + msg += ellipsis + line += sep + msg + return line + + def _folded_skips(skipped): d = {} for event in skipped: diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 4fb7bc02b..b0c682900 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -876,7 +876,9 @@ class TestInvocationVariants(object): _fail, _sep, testid = line.partition(" ") break result = testdir.runpytest(testid, "-rf") - result.stdout.fnmatch_lines([line, "*1 failed*"]) + result.stdout.fnmatch_lines( + ["FAILED test_doctest_id.txt::test_doctest_id.txt", "*1 failed*"] + ) def test_core_backward_compatibility(self): """Test backward compatibility for get_plugin_manager function. See #787.""" diff --git a/testing/test_skipping.py b/testing/test_skipping.py index cef0fe6ee..fb0822f8f 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1,3 +1,4 @@ +# coding=utf8 from __future__ import absolute_import from __future__ import division from __future__ import print_function @@ -1173,6 +1174,6 @@ def test_summary_list_after_errors(testdir): [ "=* FAILURES *=", "*= short test summary info =*", - "FAILED test_summary_list_after_errors.py::test_fail", + "FAILED test_summary_list_after_errors.py::test_fail - assert 0", ] ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index d602f03ec..f269fccd2 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1,3 +1,4 @@ +# encoding: utf-8 """ terminal reporting of the full testing process. """ @@ -17,6 +18,7 @@ import pytest from _pytest.main import EXIT_NOTESTSCOLLECTED from _pytest.reports import BaseReport from _pytest.terminal import _folded_skips +from _pytest.terminal import _get_line_with_reprcrash_message from _pytest.terminal import _plugin_nameversions from _pytest.terminal import build_summary_stats_line from _pytest.terminal import getreportopt @@ -758,12 +760,18 @@ class TestTerminalFunctional(object): result.stdout.fnmatch_lines(["collected 3 items", "hello from hook: 3 items"]) -def test_fail_extra_reporting(testdir): - testdir.makepyfile("def test_this(): assert 0") +def test_fail_extra_reporting(testdir, monkeypatch): + monkeypatch.setenv("COLUMNS", "80") + testdir.makepyfile("def test_this(): assert 0, 'this_failed' * 100") result = testdir.runpytest() assert "short test summary" not in result.stdout.str() result = testdir.runpytest("-rf") - result.stdout.fnmatch_lines(["*test summary*", "FAIL*test_fail_extra_reporting*"]) + result.stdout.fnmatch_lines( + [ + "*test summary*", + "FAILED test_fail_extra_reporting.py::test_this - AssertionError: this_failedt...", + ] + ) def test_fail_reporting_on_pass(testdir): @@ -1607,3 +1615,72 @@ def test_skip_reasons_folding(): assert fspath == path assert lineno == lineno assert reason == message + + +def test_line_with_reprcrash(monkeypatch): + import _pytest.terminal + from wcwidth import wcswidth + + mocked_verbose_word = "FAILED" + + mocked_pos = "some::nodeid" + + def mock_get_pos(*args): + return mocked_pos + + monkeypatch.setattr(_pytest.terminal, "_get_pos", mock_get_pos) + + class config(object): + pass + + class rep(object): + def _get_verbose_word(self, *args): + return mocked_verbose_word + + class longrepr: + class reprcrash: + pass + + def check(msg, width, expected): + __tracebackhide__ = True + if msg: + rep.longrepr.reprcrash.message = msg + actual = _get_line_with_reprcrash_message(config, rep(), width) + + assert actual == expected + if actual != "%s %s" % (mocked_verbose_word, mocked_pos): + assert len(actual) <= width + assert wcswidth(actual) <= width + + # AttributeError with message + check(None, 80, "FAILED some::nodeid") + + check("msg", 80, "FAILED some::nodeid - msg") + check("msg", 3, "FAILED some::nodeid") + + check("msg", 24, "FAILED some::nodeid") + check("msg", 25, "FAILED some::nodeid - msg") + + check("some longer msg", 24, "FAILED some::nodeid") + check("some longer msg", 25, "FAILED some::nodeid - ...") + check("some longer msg", 26, "FAILED some::nodeid - s...") + + check("some\nmessage", 25, "FAILED some::nodeid - ...") + check("some\nmessage", 26, "FAILED some::nodeid - some") + check("some\nmessage", 80, "FAILED some::nodeid - some") + + # Test unicode safety. + check(u"😄😄😄😄😄\n2nd line", 25, u"FAILED some::nodeid - ...") + check(u"😄😄😄😄😄\n2nd line", 26, u"FAILED some::nodeid - ...") + check(u"😄😄😄😄😄\n2nd line", 27, u"FAILED some::nodeid - 😄...") + check(u"😄😄😄😄😄\n2nd line", 28, u"FAILED some::nodeid - 😄...") + check(u"😄😄😄😄😄\n2nd line", 29, u"FAILED some::nodeid - 😄😄...") + + # NOTE: constructed, not sure if this is supported. + # It would fail if not using u"" in Python 2 for mocked_pos. + mocked_pos = u"nodeid::😄::withunicode" + check(u"😄😄😄😄😄\n2nd line", 29, u"FAILED nodeid::😄::withunicode") + check(u"😄😄😄😄😄\n2nd line", 40, u"FAILED nodeid::😄::withunicode - 😄😄...") + check(u"😄😄😄😄😄\n2nd line", 41, u"FAILED nodeid::😄::withunicode - 😄😄...") + check(u"😄😄😄😄😄\n2nd line", 42, u"FAILED nodeid::😄::withunicode - 😄😄😄...") + check(u"😄😄😄😄😄\n2nd line", 80, u"FAILED nodeid::😄::withunicode - 😄😄😄😄😄")