Created
June 1, 2026 21:32
-
-
Save cdsap/03287598ec71bf4b54acf274998ee6e1 to your computer and use it in GitHub Desktop.
Post-process iOS-source JUnit XML for Develocity GENERIC dialect import
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Post-process iOS-source JUnit XML for Develocity GENERIC dialect import. | |
| Handles output shapes from xcresultparser, Fastlane scan (trainer), and | |
| Swift Testing (swift test --xunit-output). For each <testsuite>: | |
| 1. Add a timestamp attribute (synthesised from xcresult startTime if available) | |
| 2. Strip the .xctest suffix from suite names (xcresultparser only) | |
| 3. Reshape <failure> elements to FQCN-prefixed form with synthetic stack frame | |
| 4. Append <system-out/> | |
| """ | |
| import argparse | |
| import datetime | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import xml.etree.ElementTree as ET | |
| def synthesize_timestamp(xcresult_path): | |
| if xcresult_path and os.path.exists(xcresult_path): | |
| out = subprocess.check_output([ | |
| "xcrun", "xcresulttool", "get", "test-results", "summary", | |
| "--path", xcresult_path, | |
| ]) | |
| data = json.loads(out) | |
| start = data.get("startTime") or data.get("finishTime") | |
| if start is not None: | |
| ts = float(start) | |
| return ( | |
| datetime.datetime.fromtimestamp(ts, datetime.UTC) | |
| .strftime("%Y-%m-%dT%H:%M:%S.") | |
| + f"{int((ts * 1000) % 1000):03d}Z" | |
| ) | |
| now = datetime.datetime.now(datetime.UTC) | |
| return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z" | |
| # Match either "Description (File.swift:N)" (xcresultparser) or | |
| # "File.swift:N" / "Modules/Sub/File.swift:N" (Fastlane). Both forms appear in | |
| # the wild; either captures the source location and the leftover message. | |
| _SOURCE_LOC = re.compile(r"([A-Za-z0-9_/.-]+\.swift):(\d+)") | |
| def reshape_failure(case, failure_elem, fqcn): | |
| classname = case.get("classname", "TestCase") | |
| method = case.get("name", "test").rstrip("()") | |
| raw_body = (failure_elem.text or "").strip() | |
| raw_msg_attr = failure_elem.get("message", "") | |
| m = _SOURCE_LOC.search(raw_body) or _SOURCE_LOC.search(raw_msg_attr) | |
| file = m.group(1) if m else f"{classname}.swift" | |
| line = m.group(2) if m else "0" | |
| # If the body is just "file:line" (Fastlane shape), use the message attribute | |
| # as the human-readable message. Otherwise (xcresultparser), strip the | |
| # trailing "(File.swift:N)" tail from the body. | |
| if _SOURCE_LOC.fullmatch(raw_body): | |
| msg = raw_msg_attr | |
| else: | |
| msg = re.sub(r"\s*\([^()]+:\d+\)\s*$", "", raw_body) or raw_msg_attr | |
| failure_elem.set("message", f"{fqcn}: {msg}") | |
| failure_elem.set("type", fqcn) | |
| failure_elem.text = ( | |
| f"{fqcn}: {msg}\n" | |
| f" at {classname}.{method}({file}:{line})" | |
| ) | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("xml", help="JUnit XML produced by the converter") | |
| parser.add_argument("--xcresult", help="Original .xcresult bundle (for timestamp)") | |
| parser.add_argument("--out", help="Output path (default: overwrite input)") | |
| parser.add_argument( | |
| "--fqcn", default="XCTest.XCTAssertionFailure", | |
| help="FQCN to use as the failure type. Use SwiftTesting.ExpectationFailure for swift test output." | |
| ) | |
| args = parser.parse_args() | |
| ts = synthesize_timestamp(args.xcresult) | |
| tree = ET.parse(args.xml) | |
| root = tree.getroot() | |
| # Unescape double-escaped HTML entities (Fastlane writes &quot; for ") | |
| for el in root.iter(): | |
| for k, v in list(el.attrib.items()): | |
| if "&quot;" in v or """ in v: | |
| el.set(k, v.replace("&quot;", '"').replace(""", '"')) | |
| if el.text and ("&quot;" in el.text or """ in el.text): | |
| el.text = el.text.replace("&quot;", '"').replace(""", '"') | |
| # Inject required root attributes if the converter omitted them. | |
| if "errors" not in root.attrib: | |
| root.set("errors", "0") | |
| for suite in root.findall("testsuite"): | |
| name = suite.get("name", "") | |
| if name.endswith(".xctest"): | |
| suite.set("name", name[: -len(".xctest")]) | |
| suite.set("timestamp", ts) | |
| # Split Module.Class when both <testsuite name> and the child | |
| # <testcase classname> are the same dotted string. Fastlane writes | |
| # them this way; Develocity's reducer rejects it. | |
| cases = suite.findall("testcase") | |
| if "." in name and cases and all(c.get("classname", "") == name for c in cases): | |
| module, _, klass = name.partition(".") | |
| suite.set("name", module) | |
| for case in cases: | |
| case.set("classname", klass) | |
| # Inject testcase time= placeholders BEFORE summing for the suite total. | |
| suite_time = 0.0 | |
| for case in suite.findall("testcase"): | |
| t = case.get("time") | |
| if t is None: | |
| case.set("time", "0.001") | |
| suite_time += 0.001 | |
| else: | |
| suite_time += float(t or 0) | |
| # Inject suite errors= / time= (load-bearing for the Tests reducer). | |
| if "errors" not in suite.attrib: | |
| suite.set("errors", "0") | |
| if "time" not in suite.attrib: | |
| suite.set("time", f"{suite_time:.3f}") | |
| for case in suite.findall("testcase"): | |
| f = case.find("failure") | |
| if f is not None: | |
| reshape_failure(case, f, args.fqcn) | |
| if suite.find("system-out") is None: | |
| ET.SubElement(suite, "system-out") | |
| # Root time= = sum of suite times (after we filled them in above). | |
| if "time" not in root.attrib: | |
| root.set("time", f"{sum(float(s.get('time', '0')) for s in root.findall('testsuite')):.3f}") | |
| ET.indent(tree, space=" ") | |
| tree.write(args.out or args.xml, encoding="UTF-8", xml_declaration=True) | |
| print(f"Post-processed → {args.out or args.xml} (timestamp={ts})", file=sys.stderr) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment