Skip to content
Closed
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
1 change: 1 addition & 0 deletions changelog/12223.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``message`` attribute of ``<failure>`` and ``<error>`` elements in ``--junitxml`` reports now keeps whole lines of the original text, up to 500 characters, before XML escaping. When text is dropped, the attribute notes that the full report is in the element text. The element body still contains the full report.
40 changes: 38 additions & 2 deletions src/_pytest/junitxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,38 @@ def repl(matchobj: re.Match[str]) -> str:
return re.sub(illegal_xml_re, repl, str(arg))


# Whole-line budget for the raw ``message`` attribute of <failure> and <error>.
# Applied before XML escaping so a cut cannot split an escape. The complete
# report is still written to the element body.
_MESSAGE_BUDGET = 500


def _shorten_message(msg: str) -> str:
"""Keep whole lines of ``msg`` within ``_MESSAGE_BUDGET``.

A single line longer than the budget is cut. When anything is dropped, the
result notes that the element body still has the full report.
"""
lines = msg.splitlines() or [""]
kept: list[str] = []
size = 0
for line in lines:
# The extra ``+ 1`` accounts for the newline ``str.join`` will insert.
if kept and size + len(line) + 1 > _MESSAGE_BUDGET:
break
kept.append(line)
size += len(line) + 1
out = "\n".join(kept)
cut = len(out) > _MESSAGE_BUDGET
if cut:
out = out[:_MESSAGE_BUDGET] + "..."
rest = len(lines) - len(kept)
if not cut and not rest:
return out
note = f"+{rest} more lines" if rest else "truncated"
return f"{out}\n[{note}; full report in element text]"


def merge_family(left, right) -> None:
result = {}
for kl, vl in left.items():
Expand Down Expand Up @@ -213,7 +245,7 @@ def append_failure(self, report: TestReport) -> None:
message = reprcrash.message
else:
message = str(report.longrepr)
message = bin_xml_escape(message)
message = bin_xml_escape(_shorten_message(message))
self._add_simple("failure", message, str(report.longrepr))

def append_collect_error(self, report: CollectReport) -> None:
Expand All @@ -236,7 +268,11 @@ def append_error(self, report: TestReport) -> None:
msg = f'failed on teardown with "{reason}"'
else:
msg = f'failed on setup with "{reason}"'
self._add_simple("error", bin_xml_escape(msg), str(report.longrepr))
self._add_simple(
"error",
bin_xml_escape(_shorten_message(msg)),
str(report.longrepr),
)

def append_skipped(self, report: TestReport) -> None:
if hasattr(report, "wasxfail"):
Expand Down
129 changes: 129 additions & 0 deletions testing/test_junitxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from _pytest.junitxml import _JunitDurationReport
from _pytest.junitxml import _JunitFamily
from _pytest.junitxml import _JunitLogging
from _pytest.junitxml import _MESSAGE_BUDGET
from _pytest.junitxml import _shorten_message
from _pytest.junitxml import bin_xml_escape
from _pytest.junitxml import LogXML
from _pytest.monkeypatch import MonkeyPatch
Expand Down Expand Up @@ -205,6 +207,38 @@ def test_node_repr(self, document: DomDocument) -> None:
assert item.toxml() == '<item name="a"/>'


class TestShortenFailureMessage:
def test_keeps_short_multiline_message(self) -> None:
msg = (
"Regex pattern did not match.\n"
" Expected regex: 'expected'\n"
" Actual message: 'actual'"
)
assert _shorten_message(msg) == msg

def test_cuts_on_line_boundary(self) -> None:
line = "x" * 100
msg = "\n".join([line] * 10)
out = _shorten_message(msg)
content, note = out.rsplit("\n", 1)
assert note == "[+6 more lines; full report in element text]"
assert content.split("\n") == [line] * 4
assert not content.endswith("...")

def test_cuts_overlong_first_line(self) -> None:
out = _shorten_message("z" * 5000)
assert out == (
"z" * _MESSAGE_BUDGET + "...\n[truncated; full report in element text]"
)

def test_shorten_before_xml_escape_keeps_escape_intact(self) -> None:
raw = ("a" * (_MESSAGE_BUDGET - 1)) + "\x1b" + ("b" * 50)
escaped = bin_xml_escape(_shorten_message(raw))
assert "#x1B" in escaped
assert "#x1..." not in escaped
assert escaped.endswith("[truncated; full report in element text]")


parametrize_families = pytest.mark.parametrize("xunit_family", ["xunit1", "xunit2"])


Expand Down Expand Up @@ -655,6 +689,101 @@ def test_fail():
fnode = tnode.get_first_by_tag("failure")
fnode.assert_attr(message="AssertionError: An error\nassert 0")

@pytest.mark.parametrize("verbosity", ["-q", "-vv"])
def test_failure_message_is_truncated(
self, pytester: Pytester, run_and_parse: RunAndParse, verbosity: str
) -> None:
"""The message attribute is bounded while the body keeps the full report (#12223)."""
pytester.makepyfile(
"""
def test_fail():
assert ["x"] * 500 == ["x\\n"] * 500
"""
)
result, dom = run_and_parse(verbosity)
assert result.ret == 1
fnode = dom.get_first_by_tag("failure")
message = fnode["message"]
assert message.startswith("AssertionError: assert ['x', 'x', ")
if verbosity == "-q":
assert "full report in element text" not in message
else:
note = message.splitlines()[-1]
assert note.startswith("[+")
assert note.endswith(" more lines; full report in element text]")
assert len(fnode.text) > 10 * len(message)
assert "Full diff:" in fnode.text
assert "'x\\n'," in fnode.text

def test_failure_message_cuts_on_line_boundary(
self, pytester: Pytester, run_and_parse: RunAndParse
) -> None:
pytester.makepyfile(
"""
def test_fail():
text = "\\n".join(f"row-{i:03d}-" + ("x" * 40) for i in range(30))
raise ValueError(text)
"""
)
result, dom = run_and_parse()
assert result.ret == 1
fnode = dom.get_first_by_tag("failure")
message = fnode["message"]
content, note = message.rsplit("\n", 1)
assert note.startswith("[+")
assert note.endswith(" more lines; full report in element text]")
assert not content.endswith("...")
assert content.startswith("ValueError: row-000-")
for line in content.splitlines():
assert "row-" in line
assert not line.endswith("...")
assert "row-029-" in fnode.text

def test_raises_match_message_is_not_shortened(
self, pytester: Pytester, run_and_parse: RunAndParse
) -> None:
pytester.makepyfile(
"""
import re
import pytest

def test_fail():
with pytest.raises(ValueError, match=re.compile("expected")):
raise ValueError("actual")
"""
)
result, dom = run_and_parse()
assert result.ret == 1
message = dom.get_first_by_tag("failure")["message"]
assert "Regex pattern did not match." in message
assert "Expected regex:" in message
assert "Actual message:" in message
assert "full report in element text" not in message

def test_error_message_is_truncated(
self, pytester: Pytester, run_and_parse: RunAndParse
) -> None:
pytester.makepyfile(
"""
import pytest

@pytest.fixture
def arg():
raise ValueError("a" * 2000)
def test_function(arg):
pass
"""
)
result, dom = run_and_parse()
assert result.ret
fnode = dom.get_first_by_tag("error")
prefix = 'failed on setup with "ValueError: '
raw = prefix + "a" * 2000 + '"'
assert fnode["message"] == (
raw[:_MESSAGE_BUDGET] + "...\n[truncated; full report in element text]"
)
assert "a" * 2000 in fnode.text

@parametrize_families
def test_failure_escape(
self, pytester: Pytester, run_and_parse: RunAndParse, xunit_family: _JunitFamily
Expand Down
Loading