Strip ANSI escape sequences from test output before XML escaping in the JUnit XML reporter.

Python 3.14 enabled colored tracebacks by default. When tests fail in environments with colored output, ANSI escape sequences in tracebacks cause XML 1.0 serialization to escape control characters as literal escape strings (e.g. `\x1b[35m`), corrupting stack traces for XML report consumers.

Match and strip ANSI escape sequences in `_escape_xml_attr` and `_escape_cdata` before XML escaping.

PiperOrigin-RevId: 967995361
diff --git a/absl/testing/tests/xml_reporter_test.py b/absl/testing/tests/xml_reporter_test.py
index c55e2c9..1603d8d 100644
--- a/absl/testing/tests/xml_reporter_test.py
+++ b/absl/testing/tests/xml_reporter_test.py
@@ -326,6 +326,14 @@
       error_values = sys.exc_info()
       return error_values
 
+  def get_ansi_escape_sample_failure(self):
+    try:
+      esc = chr(27)
+      raise AssertionError(f'{esc}[35mcolored failure message{esc}[0m')
+    except AssertionError:
+      error_values = sys.exc_info()
+      return error_values
+
   def test_with_failing_test(self):
     start_time = 10
     end_time = 20
@@ -661,6 +669,25 @@
 
     self._assert_valid_xml(self.xml_stream.getvalue())
 
+  def test_with_ansi_escape_error(self):
+    start_time = 100
+    end_time = 200
+    result = self._make_result((start_time, start_time, end_time, end_time))
+
+    test = MockTest('__main__.MockTest.failing_test')
+    result.startTestRun()
+    result.startTest(test)
+    result.addError(test, self.get_ansi_escape_sample_failure())
+    result.stopTest(test)
+    result.stopTestRun()
+    result.printErrors()
+
+    xml_val = self.xml_stream.getvalue()
+    self._assert_valid_xml(xml_val)
+    self.assertIn('colored failure message', xml_val)
+    self.assertNotIn('\\x1b[35m', xml_val)
+    self.assertNotIn('\x1b', xml_val)
+
   def test_with_expected_failure_test(self):
     start_time = 100
     end_time = 200
@@ -1084,6 +1111,22 @@
         '"Hi" <'>	
',
     )
 
+  def test_escape_xml_attr_strips_ansi(self):
+    self.assertEqual(
+        xml_reporter._escape_xml_attr(
+            '\x1b[35mHello\x1b[0m \x1b[1;32mWorld\x1b[0m'
+        ),
+        'Hello World',
+    )
+
+  def test_escape_cdata_strips_ansi(self):
+    self.assertEqual(
+        xml_reporter._escape_cdata(
+            'File "\x1b[35mfoo_test.py\x1b[0m", line \x1b[32m42\x1b[0m'
+        ),
+        'File "foo_test.py", line 42',
+    )
+
 
 class XmlReporterFixtureTest(absltest.TestCase):
 
diff --git a/absl/testing/xml_reporter.py b/absl/testing/xml_reporter.py
index e03d845..3a9df41 100644
--- a/absl/testing/xml_reporter.py
+++ b/absl/testing/xml_reporter.py
@@ -52,11 +52,18 @@
 _CLASS_OR_MODULE_LEVEL_TEST_DESC_REGEX = re.compile(r'^(\w+) \((\S+)\)$')
 
 
+# Pattern matching ANSI escape sequences (CSI and OSC sequences).
+_ANSI_ESCAPE_PATTERN = re.compile(
+    r'\x1b(?:\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1b\\))'
+)
+
+
 # NOTE: while saxutils.quoteattr() theoretically does the same thing; it
 # seems to often end up being too smart for it's own good not escaping properly.
 # This function is much more reliable.
 def _escape_xml_attr(content):
   """Escapes xml attributes."""
+  content = _ANSI_ESCAPE_PATTERN.sub('', str(content))
   # Note: saxutils doesn't escape the quotes.
   return saxutils.escape(content, _escape_xml_attr_conversions)
 
@@ -73,6 +80,7 @@
   Returns:
     An escaped version of the input string.
   """
+  s = _ANSI_ESCAPE_PATTERN.sub('', str(s))
   for char, escaped in _control_character_conversions.items():
     s = s.replace(char, escaped)
   return s.replace(']]>', ']] >')