diff --git a/changelog/12365.bugfix.rst b/changelog/12365.bugfix.rst new file mode 100644 index 00000000000..966881cdd4f --- /dev/null +++ b/changelog/12365.bugfix.rst @@ -0,0 +1,3 @@ +The ``--junitxml`` report no longer includes ANSI escape codes from +Pygments-highlighted assertion diffs in failure messages or details, or from +captured output written to ```` and ````. diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 06bb9fd5dae..5de5d94f51b 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -43,6 +43,15 @@ _JunitDurationReport = Literal["total", "call"] _JunitFamily = Literal["legacy", "xunit1", "xunit2"] +# SGR (Select Graphic Rendition) sequences pre-baked into assertion diffs by Pygments +# when color is enabled. These are invalid in XML and must be stripped before writing. +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[\d;]*m") + + +def strip_ansi_escapes(text: str) -> str: + """Remove ANSI SGR escape sequences from *text*.""" + return _ANSI_ESCAPE_RE.sub("", text) + def bin_xml_escape(arg: object) -> str: r"""Visually escape invalid XML characters. @@ -163,6 +172,8 @@ def to_xml(self) -> ET.Element: def _add_simple(self, tag: str, message: str, data: str | None = None) -> None: node = ET.Element(tag, message=message) + if data is not None: + data = strip_ansi_escapes(data) node.text = bin_xml_escape(data) self.append(node) @@ -194,7 +205,7 @@ def _prepare_content(self, content: str, header: str) -> str: def _write_content(self, report: TestReport, content: str, jheader: str) -> None: tag = ET.Element(jheader) - tag.text = bin_xml_escape(content) + tag.text = bin_xml_escape(strip_ansi_escapes(content)) self.append(tag) def append_pass(self, report: TestReport) -> None: @@ -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 = bin_xml_escape(strip_ansi_escapes(message)) self._add_simple("failure", message, str(report.longrepr)) def append_collect_error(self, report: CollectReport) -> None: @@ -236,7 +247,9 @@ 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(strip_ansi_escapes(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..70529910258 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1772,8 +1772,28 @@ def test_esc(my_setup): _, dom = run_and_parse() node = dom.get_first_by_tag("testcase") snode = node.get_first_by_tag("error") - assert "#x1B[31mred#x1B[m" in snode["message"] - assert "#x1B[31mred#x1B[m" in snode.text + assert "error: red" in snode["message"] + assert "#x1B" not in snode["message"] + assert "error: red" in snode.text + assert "#x1B" not in snode.text + + +def test_ansi_escapes_stripped_from_captured_output(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import sys + + def test_color(): + sys.stdout.write("out: \033[31mred\033[0m") + sys.stderr.write("err: \033[32mgreen\033[0m") + """ + ) + xmlf = pytester.path.joinpath("junit.xml") + pytester.runpytest(f"--junitxml={xmlf}", "-o", "junit_logging=all") + text = xmlf.read_text(encoding="utf-8") + assert "#x1B" not in text + assert "out: red" in text + assert "err: green" in text @parametrize_families @@ -1860,6 +1880,27 @@ def test_no_message_quiet(pytester: Pytester) -> None: result.stdout.no_fnmatch_line("* generated xml file: *") +def test_junitxml_strips_ansi_from_failure_details( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """ANSI escapes from Pygments-highlighted diffs must not appear in junitxml (#12365).""" + pytest.importorskip("pygments") + monkeypatch.setenv("PY_COLORS", "1") + pytester.makepyfile( + """ + def test_foo(): + assert [1, 2, 3] == [3, 2, 1] + """ + ) + xml_path = pytester.path.joinpath("junit.xml") + result = pytester.runpytest(f"--junitxml={xml_path}", "-vv") + assert result.ret == 1 + xml_text = xml_path.read_text(encoding="utf-8") + assert "\x1b[" not in xml_text + assert "#x1B" not in xml_text + assert "assert [1, 2, 3] == [3, 2, 1]" in xml_text + + @pytest.mark.parametrize( ("name", "value"), [