Skip to content

Instantly share code, notes, and snippets.

@cdsap
Created June 1, 2026 21:32
Show Gist options
  • Select an option

  • Save cdsap/03287598ec71bf4b54acf274998ee6e1 to your computer and use it in GitHub Desktop.

Select an option

Save cdsap/03287598ec71bf4b54acf274998ee6e1 to your computer and use it in GitHub Desktop.
Post-process iOS-source JUnit XML for Develocity GENERIC dialect import
#!/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 &amp;quot; for ")
for el in root.iter():
for k, v in list(el.attrib.items()):
if "&amp;quot;" in v or "&quot;" in v:
el.set(k, v.replace("&amp;quot;", '"').replace("&quot;", '"'))
if el.text and ("&amp;quot;" in el.text or "&quot;" in el.text):
el.text = el.text.replace("&amp;quot;", '"').replace("&quot;", '"')
# 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