Created
January 24, 2025 06:29
-
-
Save swhitty/49b2a78ac167cb06729de30a98a09dde to your computer and use it in GitHub Desktop.
Script to convert Xcode 16 results JSON in to Junit XML
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/swift | |
import Foundation | |
let resultsJSON = CommandLine.arguments.count > 1 ? URL(filePath: CommandLine.arguments[1]) : nil | |
guard let resultsJSON else { | |
print("usage: ./junit.swift <test_results.json>") | |
exit(70) | |
} | |
let results = try JSONDecoder().decode(TestResults.self, from: Data(contentsOf: resultsJSON)) | |
print(makeJunit(from: results)) | |
func makeJunit(from results: TestResults) -> String { | |
let suites = results.childNodes(ofType: .testSuite) | |
let allTests = results.childNodes(ofType: .testCase) | |
let allFailed = allTests.filter { $0.node.result == "Failed" } | |
var lines = [String]() | |
lines.append(#"<testsuites name="All tests" tests="\#(allTests.count)" failures="\#(allFailed.count)">"#) | |
for (path, suite) in suites { | |
let fullName = [path, suite.name].compactMap { $0 }.joined(separator: ".") | |
let tests = suite.childNodes(ofType: .testCase) | |
let failed = tests.filter { $0.node.result == "Failed" } | |
lines.append(#" <testsuite name="\#(fullName)" tests="\#(tests.count)" failures="\#(failed.count)">"#) | |
for (_, test) in tests { | |
let duration = test.duration?.dropLast() ?? "" | |
let failures = test.childNodes(ofType: .testFailure) | |
if !failures.isEmpty { | |
lines.append(#" <testcase classname="\#(fullName)" name="\#(test.name)" time="\#(duration)">"#) | |
if let details = test.details { | |
lines.append(#" <failure message="\#(details)" />"#) | |
} | |
for (_, failure) in failures { | |
let message = failure.name | |
.replacingOccurrences(of: "&", with: "&") | |
.replacingOccurrences(of: "\"", with: """) | |
.replacingOccurrences(of: "<", with: "<") | |
.replacingOccurrences(of: ">", with: ">") | |
lines.append(#" <failure message="\#(message)" />"#) | |
} | |
lines.append(#" </testcase>"#) | |
} | |
if test.result == "Passed" { | |
lines.append(#" <testcase classname="\#(fullName)" name="\#(test.name)" time="\#(duration)" />"#) | |
} | |
} | |
lines.append(#" </testsuite>"#) | |
} | |
lines.append(#"</testsuites>"#) | |
return lines.joined(separator: "\n") | |
} | |
struct TestResults: Decodable { | |
var testNodes: [Node] | |
struct Node: Decodable { | |
var name: String | |
var nodeType: NodeType | |
var result: String | |
var children: [Node]? | |
var details: String? | |
var duration: String? | |
var nodeIdentifier: String? | |
} | |
enum NodeType: String, Decodable { | |
case testPlan = "Test Plan" | |
case testBundle = "Unit test bundle" | |
case testSuite = "Test Suite" | |
case testCase = "Test Case" | |
case testFailure = "Failure Message" | |
case testRepetition = "Repetition" | |
case unknown = "unknown" | |
init(from decoder: Decoder) throws { | |
let value = try decoder.singleValueContainer().decode(String.self) | |
self = .init(rawValue: value) ?? .unknown | |
} | |
} | |
} | |
extension TestResults { | |
func childNodes(ofType type: NodeType) -> [(path: String?, node: TestResults.Node)] { | |
testNodes.flatMap { $0.childNodes(ofType: type) } | |
} | |
} | |
extension TestResults.Node { | |
func childNodes(ofType type: TestResults.NodeType, path: String? = nil) -> [(path: String?, node: TestResults.Node)] { | |
var nodes = [(path: String?, node: TestResults.Node)]() | |
for child in (children ?? []) { | |
if child.nodeType == type { | |
nodes.append((path: path, node: child)) | |
} | |
let newPath = [path, child.name].compactMap { $0 }.joined(separator: ".") | |
nodes.append(contentsOf: child.childNodes(ofType: type, path: newPath)) | |
} | |
return nodes | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment