From 7b11d3937ffdb4cd3315a81c04f4b6ebaa69656e Mon Sep 17 00:00:00 2001 From: cherith <315844584+Cherith1222@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:31:31 +0800 Subject: [PATCH 1/2] junitxml: bound the failure/error message attribute Truncate the escaped message attribute of and elements to 1000 characters plus '...', keeping the full report in the element body. Fixes #12223. Drafted with AI assistance (Cursor); not yet reviewed by the account owner. --- changelog/12223.bugfix.rst | 1 + src/_pytest/junitxml.py | 19 +++++++++++++-- testing/test_junitxml.py | 47 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 changelog/12223.bugfix.rst diff --git a/changelog/12223.bugfix.rst b/changelog/12223.bugfix.rst new file mode 100644 index 00000000000..df0e5a5c8cc --- /dev/null +++ b/changelog/12223.bugfix.rst @@ -0,0 +1 @@ +The ``message`` attribute of ```` and ```` elements in ``--junitxml`` reports is now truncated to 1000 characters (followed by ``...``) after XML escaping, so verbose assertion output (e.g. with ``-vv``) no longer produces huge attributes; the full report is still written to the element body. diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 06bb9fd5dae..a5442a85385 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -69,6 +69,17 @@ def repl(matchobj: re.Match[str]) -> str: return re.sub(illegal_xml_re, repl, str(arg)) +# Maximum length of the escaped message attribute of and +# elements; the complete report is still written to the element body. +_MAX_FAILURE_MESSAGE_LENGTH = 1000 + + +def _truncate_failure_message(message: str) -> str: + if len(message) <= _MAX_FAILURE_MESSAGE_LENGTH: + return message + return message[:_MAX_FAILURE_MESSAGE_LENGTH] + "..." + + def merge_family(left, right) -> None: result = {} for kl, vl in left.items(): @@ -213,7 +224,7 @@ def append_failure(self, report: TestReport) -> None: message = reprcrash.message else: message = str(report.longrepr) - message = bin_xml_escape(message) + message = _truncate_failure_message(bin_xml_escape(message)) self._add_simple("failure", message, str(report.longrepr)) def append_collect_error(self, report: CollectReport) -> None: @@ -236,7 +247,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", + _truncate_failure_message(bin_xml_escape(msg)), + str(report.longrepr), + ) def append_skipped(self, report: TestReport) -> None: if hasattr(report, "wasxfail"): diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 3b51495ac6b..b836c41cda8 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -655,6 +655,53 @@ 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 len(message) < 1000 + assert not message.endswith("...") + else: + assert len(message) == 1000 + len("...") + assert message.endswith("...") + assert len(fnode.text) > 10 * len(message) + assert "Full diff:" in fnode.text + assert "'x\\n'," in fnode.text + + 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: ' + assert fnode["message"] == prefix + "a" * (1000 - len(prefix)) + "..." + assert "a" * 2000 in fnode.text + @parametrize_families def test_failure_escape( self, pytester: Pytester, run_and_parse: RunAndParse, xunit_family: _JunitFamily From cebdc96258c3459feafcca3c3dce56a6bd9015b3 Mon Sep 17 00:00:00 2001 From: cherith <315844584+Cherith1222@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:50:06 +0800 Subject: [PATCH 2/2] junitxml: shorten the message attribute before XML escaping Keep whole lines of the raw failure and error text up to 500 characters, then escape. When text is dropped, note that the full report is in the element text. AI-assisted (Cursor). Not yet reviewed by a human. --- changelog/12223.bugfix.rst | 2 +- src/_pytest/junitxml.py | 39 ++++++++++++---- testing/test_junitxml.py | 92 +++++++++++++++++++++++++++++++++++--- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/changelog/12223.bugfix.rst b/changelog/12223.bugfix.rst index df0e5a5c8cc..e67e7436b66 100644 --- a/changelog/12223.bugfix.rst +++ b/changelog/12223.bugfix.rst @@ -1 +1 @@ -The ``message`` attribute of ```` and ```` elements in ``--junitxml`` reports is now truncated to 1000 characters (followed by ``...``) after XML escaping, so verbose assertion output (e.g. with ``-vv``) no longer produces huge attributes; the full report is still written to the element body. +The ``message`` attribute of ```` and ```` 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. diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index a5442a85385..77463318b57 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -69,15 +69,36 @@ def repl(matchobj: re.Match[str]) -> str: return re.sub(illegal_xml_re, repl, str(arg)) -# Maximum length of the escaped message attribute of and -# elements; the complete report is still written to the element body. -_MAX_FAILURE_MESSAGE_LENGTH = 1000 +# Whole-line budget for the raw ``message`` attribute of and . +# 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 _truncate_failure_message(message: str) -> str: - if len(message) <= _MAX_FAILURE_MESSAGE_LENGTH: - return message - return message[:_MAX_FAILURE_MESSAGE_LENGTH] + "..." +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: @@ -224,7 +245,7 @@ def append_failure(self, report: TestReport) -> None: message = reprcrash.message else: message = str(report.longrepr) - message = _truncate_failure_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: @@ -249,7 +270,7 @@ def append_error(self, report: TestReport) -> None: msg = f'failed on setup with "{reason}"' self._add_simple( "error", - _truncate_failure_message(bin_xml_escape(msg)), + bin_xml_escape(_shorten_message(msg)), str(report.longrepr), ) diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index b836c41cda8..bfcb417ef29 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -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 @@ -205,6 +207,38 @@ def test_node_repr(self, document: DomDocument) -> None: assert item.toxml() == '' +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"]) @@ -672,15 +706,60 @@ def test_fail(): message = fnode["message"] assert message.startswith("AssertionError: assert ['x', 'x', ") if verbosity == "-q": - assert len(message) < 1000 - assert not message.endswith("...") + assert "full report in element text" not in message else: - assert len(message) == 1000 + len("...") - assert message.endswith("...") + 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: @@ -699,7 +778,10 @@ def test_function(arg): assert result.ret fnode = dom.get_first_by_tag("error") prefix = 'failed on setup with "ValueError: ' - assert fnode["message"] == prefix + "a" * (1000 - len(prefix)) + "..." + 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