@@ -1261,6 +1261,159 @@ def make_test_id(pkg, test):
12611261 return 1 if any_failed else 0
12621262
12631263
1264+ def JUnitXML2SubUnit (xml_files , output_stream ):
1265+ """Convert JUnit XML test reports to a subunit v2 byte stream.
1266+
1267+ Reads each path in ``xml_files`` (in the supplied order) and emits
1268+ one subunit packet pair per ``<testcase>`` element. The packet pair
1269+ is ``inprogress`` followed by the terminal status, with synthetic
1270+ timestamps spaced by the testcase's ``time`` attribute so consumers
1271+ can recover the recorded duration.
1272+
1273+ Test IDs are formed as ``<classname>::<name>`` from the testcase's
1274+ ``classname`` and ``name`` attributes. Maven Surefire and Gradle
1275+ both populate ``classname`` with the fully-qualified Java class
1276+ (e.g. ``com.example.FooTest``), so the resulting ID is
1277+ ``com.example.FooTest::testBar``.
1278+
1279+ Mapping rules:
1280+ * ``<failure>`` child → status ``fail`` (an assertion failure).
1281+ * ``<error>`` child → status ``fail`` (an unexpected exception);
1282+ subunit doesn't distinguish these and the JUnit author intent is
1283+ identical for our purposes (both mean "did not pass").
1284+ * ``<skipped>`` child → status ``skip``.
1285+ * Otherwise → status ``success``.
1286+
1287+ The body text and ``message``/``type`` attributes of the failure
1288+ element are folded into a single ``text/plain`` attachment on the
1289+ terminal packet, mirroring how ``GoJSON2SubUnit`` attaches captured
1290+ stdout to its results.
1291+
1292+ Per-class ``<system-out>`` / ``<system-err>`` blocks aren't attributed
1293+ to individual testcases by the JUnit schema (they cover the whole
1294+ suite). They're dropped — preserving them would require attaching
1295+ them to a synthetic suite-level packet, and most consumers don't
1296+ surface that.
1297+
1298+ :param xml_files: Iterable of file paths containing JUnit XML.
1299+ :param output_stream: A binary stream to write subunit v2 bytes to.
1300+ :return: 0 if no testcase failed or errored, 1 otherwise. Files that
1301+ fail to parse are reported on stderr and counted as a failure so
1302+ the broken XML doesn't get silently swallowed.
1303+ """
1304+ import datetime
1305+ import xml .etree .ElementTree as ET
1306+
1307+ output = StreamResultToBytes (output_stream )
1308+ UTF8_TEXT = "text/plain; charset=UTF8"
1309+ any_failed = False
1310+ # Synthetic timestamps. We don't know when the JUnit run actually
1311+ # happened, but spacing the inprogress/terminal packets by each
1312+ # testcase's recorded `time` attribute lets consumers compute the
1313+ # right duration without making up wall-clock data.
1314+ clock = datetime .datetime (2000 , 1 , 1 , tzinfo = datetime .timezone .utc )
1315+
1316+ def parse_time (value ):
1317+ if value is None :
1318+ return 0.0
1319+ try :
1320+ return float (value )
1321+ except (TypeError , ValueError ):
1322+ return 0.0
1323+
1324+ def iter_testsuites (root ):
1325+ # JUnit XML files come in two shapes: a single ``<testsuite>``
1326+ # at the root, or a ``<testsuites>`` wrapper containing many.
1327+ if root .tag == "testsuite" :
1328+ yield root
1329+ elif root .tag == "testsuites" :
1330+ for ts in root .findall ("testsuite" ):
1331+ yield ts
1332+ # Anything else is silently ignored — a non-JUnit document.
1333+
1334+ for path in xml_files :
1335+ try :
1336+ tree = ET .parse (path )
1337+ except (OSError , ET .ParseError ) as exc :
1338+ sys .stderr .write ("JUnitXML2SubUnit: failed to parse {}: {}\n " .format (path , exc ))
1339+ any_failed = True
1340+ continue
1341+
1342+ root = tree .getroot ()
1343+ for suite in iter_testsuites (root ):
1344+ for case in suite .findall ("testcase" ):
1345+ classname = case .get ("classname" ) or ""
1346+ name = case .get ("name" ) or ""
1347+ if not name :
1348+ # Without a name there's no usable test_id; skip
1349+ # rather than emit a malformed ID.
1350+ continue
1351+ test_id = "{}::{}" .format (classname , name ) if classname else name
1352+ duration = parse_time (case .get ("time" ))
1353+
1354+ failure = case .find ("failure" )
1355+ error = case .find ("error" )
1356+ skipped = case .find ("skipped" )
1357+
1358+ if failure is not None or error is not None :
1359+ status = "fail"
1360+ detail = failure if failure is not None else error
1361+ file_bytes = _format_junit_detail (detail )
1362+ any_failed = True
1363+ elif skipped is not None :
1364+ status = "skip"
1365+ file_bytes = _format_junit_detail (skipped )
1366+ else :
1367+ status = "success"
1368+ file_bytes = None
1369+
1370+ start_ts = clock
1371+ end_ts = clock + datetime .timedelta (seconds = duration )
1372+ clock = end_ts
1373+
1374+ output .status (
1375+ test_id = test_id ,
1376+ test_status = "inprogress" ,
1377+ timestamp = start_ts ,
1378+ )
1379+ output .status (
1380+ test_id = test_id ,
1381+ test_status = status ,
1382+ eof = True ,
1383+ file_name = "junit detail" if file_bytes else None ,
1384+ file_bytes = file_bytes ,
1385+ mime_type = UTF8_TEXT if file_bytes else None ,
1386+ timestamp = end_ts ,
1387+ )
1388+
1389+ return 1 if any_failed else 0
1390+
1391+
1392+ def _format_junit_detail (element ):
1393+ """Serialise a ``<failure>``/``<error>``/``<skipped>`` body to bytes.
1394+
1395+ JUnit elements carry the message and exception type as attributes and
1396+ the stack trace as element text. Combine both into a single
1397+ text/plain blob so the consumer sees everything on one packet. Returns
1398+ ``None`` when there's nothing to attach (an empty ``<skipped/>``).
1399+ """
1400+ parts = []
1401+ msg = element .get ("message" )
1402+ typ = element .get ("type" )
1403+ if typ and msg :
1404+ parts .append ("{}: {}" .format (typ , msg ))
1405+ elif typ :
1406+ parts .append (typ )
1407+ elif msg :
1408+ parts .append (msg )
1409+ body = (element .text or "" ).strip ()
1410+ if body :
1411+ parts .append (body )
1412+ if not parts :
1413+ return None
1414+ return ("\n " .join (parts ) + "\n " ).encode ("utf-8" )
1415+
1416+
12641417def tag_stream (original , filtered , tags ):
12651418 """Alter tags on a stream.
12661419
0 commit comments