diff --git a/.gitignore b/.gitignore
index 5870122..43abdb5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,6 +92,9 @@ iOSInjectionProject/
# Compiled app, ready for notarization
product/
+AgentNotes/
+TestResults/
+CLAUDE.md
.scannerwork/
diff --git a/.swiftformat b/.swiftformat
index 53a1ccf..27ca9dd 100644
--- a/.swiftformat
+++ b/.swiftformat
@@ -1,69 +1,135 @@
---swiftversion 5.9
+# old format options:
+# --disable wrapMultilineStatementBraces
+# --disable redundantSelf
+# --disable preferForLoop
+--swiftversion 6.2
+
+--acronyms ID,URL,UUID
--allman false
---assetliterals visual-width
---beforemarks
---binarygrouping none
---categorymark "MARK: %c"
---classthreshold 0
---closingparen balanced
-# --commas always
---conflictmarkers reject
---decimalgrouping none
---elseposition same-line
---emptybraces no-space
---enumthreshold 0
---exponentcase lowercase
---exponentgrouping disabled
---extensionacl on-extension
---extensionlength 0
---extensionmark "MARK: - %t + %c"
---fractiongrouping disabled
+--allow-partial-wrapping true
+--anonymous-for-each convert
+--asset-literals visual-width
+--async-capturing
+--before-marks
+--binary-grouping none
+--blank-line-after-switch-case multiline-only
+--call-site-paren default
+--category-mark "MARK: %c"
+--class-threshold 0
+--closing-paren balanced
+--closure-void remove
+--complex-attributes preserve
+--computed-var-attributes preserve
+--conditional-assignment always
+--conflict-markers reject
+--date-format system
+--decimal-grouping none
+--default-test-suite-attributes
+--doc-comments preserve
+--else-position same-line
+--empty-braces no-space
+--enum-namespaces always
+--enum-threshold 0
+--equatable-macro none
+--exponent-case lowercase
+--exponent-grouping disabled
+--extension-acl on-extension
+--extension-mark "MARK: - %t + %c"
+--extension-threshold 0
+--file-macro "#file"
+--fraction-grouping disabled
--fragment false
---funcattributes preserve
---groupedextension "MARK: %c"
---guardelse auto
---header ignore
---hexgrouping none
---hexliteralcase uppercase
+--func-attributes preserve
+--generic-types
+--group-blank-lines true
+--grouped-extension "MARK: %c"
+--guard-else auto
+--header "// new header text"
+--hex-grouping none
+--hex-literal-case uppercase
--ifdef indent
---importgrouping alpha
+--import-grouping alpha
--indent 4
---indentcase false
---lifecycle
+--indent-case false
+--indent-strings false
+--inferred-types always
+--init-coder-nil false
+--language-mode 0
+--lifecycle
+--line-after-marks true
+--line-between-guards false
--linebreaks lf
---markextensions always
---marktypes always
---maxwidth none
---modifierorder
---nevertrailing compactMap,map,flatMap
---nospaceoperators
---nowrapoperators
---octalgrouping none
---operatorfunc spaced
---organizetypes actor,class,enum,struct
---patternlet inline
+--mark-categories true
+--mark-class-threshold 0
+--mark-enum-threshold 0
+--mark-extension-threshold 0
+--mark-extensions always
+--mark-struct-threshold 0
+--mark-types always
+--markdown-files ignore
+--max-width none
+--modifier-order
+--never-trailing compactMap,flatMap,map
+--nil-init remove
+--no-space-operators
+--no-wrap-operators
+--non-complex-attributes
+--octal-grouping none
+--operator-func spaced
+--organization-mode visibility
+--organize-types actor,class,enum,struct
+--pattern-let hoist
+--prefer-synthesized-init-for-internal-structs never
+--preserve-acronyms
+--preserve-decls
+--preserved-property-types Package
+--property-types inferred
--ranges spaced
---redundanttype inferred
+--redundant-async tests-only
+--redundant-throws tests-only
--self remove
---selfrequired
+--self-required
--semicolons never
---shortoptionals always
---smarttabs enabled
---stripunusedargs closure-only
---structthreshold 0
---tabwidth unspecified
---trailingclosures
---trimwhitespace always
---typeattributes preserve
---typemark "MARK: - %t"
---varattributes preserve
---voidtype void
---wraparguments before-first
---wrapcollections before-first
---wrapconditions preserve
---wrapparameters before-first
---wrapreturntype preserve
---xcodeindentation disabled
---yodaswap always
---disable redundantReturn,wrapMultilineStatementBraces,trailingCommas,preferKeyPath
+--short-optionals always
+--single-line-for-each ignore
+--smart-tabs enabled
+--some-any true
+--sort-swiftui-properties none
+--sorted-patterns
+--stored-var-attributes preserve
+--strip-unused-args closure-only
+--struct-threshold 0
+--swift-version 6.2
+--tab-width unspecified
+--throw-capturing
+--timezone system
+--trailing-closures
+--trailing-commas always
+--trim-whitespace always
+--type-attributes preserve
+--type-blank-lines remove
+--type-body-marks preserve
+--type-delimiter space-after
+--type-mark "MARK: - %t"
+--type-marks
+--type-order
+--url-macro none
+--visibility-marks
+--visibility-order
+--void-type Void
+--wrap-arguments before-first
+--wrap-collections before-first
+--wrap-conditions preserve
+--wrap-effects preserve
+--wrap-enum-cases always
+--wrap-parameters before-first
+--wrap-return-type preserve
+--wrap-string-interpolation default
+--wrap-ternary default
+--wrap-type-aliases preserve
+--xcode-indentation disabled
+--xctest-symbols
+--yoda-swap always
+--disable docComments,docCommentsBeforeModifiers,fileHeader,opaqueGenericParameters,preferKeyPath,redundantReturn,trailingCommas,wrapMultilineStatementBraces
+--enable isEmpty,privateStateVariables,propertyTypes,unusedPrivateDeclarations
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/xcresultparser.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/xcresultparser.xcscheme
index 884a90a..e131527 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/xcresultparser.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/xcresultparser.xcscheme
@@ -82,6 +82,10 @@
argument = "/Users/alex/xcodebuild_result.xcresult"
isEnabled = "NO">
+
+
@@ -120,7 +124,7 @@
+ isEnabled = "YES">
+ isEnabled = "NO">
More on converting code coverage data
@@ -87,7 +93,7 @@ You should see the tool respond like this:
```
Error: Missing expected argument ''
-OVERVIEW: xcresultparser 1.9.4
+OVERVIEW: xcresultparser 2.0.0-beta
Interpret binary .xcresult files and print summary in different formats: txt,
xml, html or colored cli output.
@@ -126,7 +132,7 @@ OPTIONS:
'warnings-and-errors'.
-s, --summary-fields
The fields in the summary. Default is all:
- errors|warnings|analyzerWarnings|tests|failed|skipped
+ errors|warnings|analyzerWarnings|tests|failed|skipped|duration|date
-c, --coverage Whether to print coverage data.
-x, --exclude-coverage-not-in-project
Omit elements with file pathes, which do not contain
@@ -202,6 +208,8 @@ Create an xml file in generic code coverage xml format, but only for two of the
xcresultparser -c -o xml test.xcresult -t foo -t baz > sonarCoverage.xml
```
+If one of the targets passed with `-t/--coverage-targets` does not exist in the result bundle, the command now exits with an error.
+
### Cobertura XML output
Create xml file in [Cobertura](https://cobertura.github.io/cobertura/) format:
```
diff --git a/notarize.sh b/Scripts/notarize.sh
old mode 100644
new mode 100755
similarity index 98%
rename from notarize.sh
rename to Scripts/notarize.sh
index b90e0e5..c42cd55
--- a/notarize.sh
+++ b/Scripts/notarize.sh
@@ -24,6 +24,7 @@ usage()
## Default values for this app, so I can invoke this script without parameters
productName="xcresultparser"
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
while [ "$1" != "" ]; do
case $1 in
@@ -55,6 +56,8 @@ then
exit 1
fi
+cd "$ROOT_DIR"
+
# build the project for M1 and Intel:
swift build -c release --arch arm64 --arch x86_64
diff --git a/runSonar.sh b/Scripts/runSonar.sh
old mode 100644
new mode 100755
similarity index 100%
rename from runSonar.sh
rename to Scripts/runSonar.sh
diff --git a/Scripts/run_regression_benchmark.sh b/Scripts/run_regression_benchmark.sh
new file mode 100755
index 0000000..93fbcaa
--- /dev/null
+++ b/Scripts/run_regression_benchmark.sh
@@ -0,0 +1,211 @@
+#!/usr/bin/env bash
+set -u
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+TEST_DIR="$ROOT_DIR/TestResults"
+NEW_BIN="$TEST_DIR/xcresultparser"
+OLD_BIN="$TEST_DIR/lastVersion/xcresultparser"
+NEW_OUT_DIR="$TEST_DIR/newResults"
+OLD_OUT_DIR="$TEST_DIR/oldResults"
+CSV_FILE="$TEST_DIR/timings.csv"
+MD_FILE="$TEST_DIR/timings.md"
+CHART_CSV_FILE="$TEST_DIR/timings_chart.csv"
+RAW_CSV_FILE="$TEST_DIR/.timings_raw.csv"
+
+TEST_BUNDLE="$ROOT_DIR/Tests/XcresultparserTests/TestAssets/test.xcresult"
+ERROR_BUNDLE="$ROOT_DIR/Tests/XcresultparserTests/TestAssets/resultWithCompileError.xcresult"
+SUFFIX=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --bundle)
+ TEST_BUNDLE="$2"
+ shift 2
+ ;;
+ --error-bundle)
+ ERROR_BUNDLE="$2"
+ shift 2
+ ;;
+ --suffix)
+ SUFFIX="$2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$ERROR_BUNDLE" ]]; then
+ ERROR_BUNDLE="$TEST_BUNDLE"
+fi
+if [[ -n "$SUFFIX" ]]; then
+ NEW_OUT_DIR="$TEST_DIR/newResults_${SUFFIX}"
+ OLD_OUT_DIR="$TEST_DIR/oldResults_${SUFFIX}"
+ CSV_FILE="$TEST_DIR/timings_${SUFFIX}.csv"
+ MD_FILE="$TEST_DIR/timings_${SUFFIX}.md"
+ CHART_CSV_FILE="$TEST_DIR/timings_chart_${SUFFIX}.csv"
+ RAW_CSV_FILE="$TEST_DIR/.timings_raw_${SUFFIX}.csv"
+fi
+
+mkdir -p "$NEW_OUT_DIR" "$OLD_OUT_DIR"
+rm -f "$NEW_OUT_DIR"/* "$OLD_OUT_DIR"/* "$CSV_FILE" "$MD_FILE" "$CHART_CSV_FILE" "$RAW_CSV_FILE"
+
+if [[ ! -x "$NEW_BIN" ]]; then
+ echo "Missing executable: $NEW_BIN" >&2
+ exit 1
+fi
+if [[ ! -x "$OLD_BIN" ]]; then
+ echo "Missing executable: $OLD_BIN" >&2
+ exit 1
+fi
+
+echo "version,case,binary,bundle,seconds,exit_code,bytes,sha256" > "$RAW_CSV_FILE"
+
+run_case() {
+ local version_label="$1"
+ local bin="$2"
+ local out_dir="$3"
+ local case_name="$4"
+ local bundle="$5"
+ shift 5
+ local args=("$@")
+
+ local out_file="$out_dir/${case_name}.out"
+ local err_file="$out_dir/${case_name}.stderr"
+ local time_file="$out_dir/${case_name}.time"
+
+ local rc=0
+ /usr/bin/time -p -o "$time_file" "$bin" "${args[@]}" "$bundle" > "$out_file" 2> "$err_file" || rc=$?
+
+ local seconds
+ seconds=$(awk '/^real /{print $2}' "$time_file" | tail -n 1)
+ [[ -z "$seconds" ]] && seconds="NA"
+
+ local bytes
+ bytes=$(wc -c < "$out_file" | tr -d ' ')
+
+ local checksum
+ checksum=$(shasum -a 256 "$out_file" | awk '{print $1}')
+
+ echo "$version_label,$case_name,$bin,$bundle,$seconds,$rc,$bytes,$checksum" >> "$RAW_CSV_FILE"
+}
+
+run_version() {
+ local version_label="$1"
+ local bin="$2"
+ local out_dir="$3"
+
+ run_case "$version_label" "$bin" "$out_dir" "txt" "$TEST_BUNDLE" -o txt
+ run_case "$version_label" "$bin" "$out_dir" "cli" "$TEST_BUNDLE" -o cli
+ run_case "$version_label" "$bin" "$out_dir" "html" "$TEST_BUNDLE" -o html
+ run_case "$version_label" "$bin" "$out_dir" "md" "$TEST_BUNDLE" -o md
+ run_case "$version_label" "$bin" "$out_dir" "junit" "$TEST_BUNDLE" -o junit
+ run_case "$version_label" "$bin" "$out_dir" "xml_tests" "$TEST_BUNDLE" -o xml
+ run_case "$version_label" "$bin" "$out_dir" "xml_coverage" "$TEST_BUNDLE" -c -o xml
+ run_case "$version_label" "$bin" "$out_dir" "cobertura" "$TEST_BUNDLE" -o cobertura
+ run_case "$version_label" "$bin" "$out_dir" "warnings" "$TEST_BUNDLE" -o warnings
+ run_case "$version_label" "$bin" "$out_dir" "errors" "$ERROR_BUNDLE" -o errors
+ run_case "$version_label" "$bin" "$out_dir" "warnings_and_errors" "$ERROR_BUNDLE" -o warnings-and-errors
+}
+
+run_version "new" "$NEW_BIN" "$NEW_OUT_DIR"
+run_version "old" "$OLD_BIN" "$OLD_OUT_DIR"
+
+awk -F',' '
+ NR==1 {next}
+ {
+ version=$1
+ key=$2
+ sec=$5
+ bytes=$7
+ if (version=="new") {
+ new_sec[key]=sec
+ new_bytes[key]=bytes
+ } else if (version=="old") {
+ old_sec[key]=sec
+ old_bytes[key]=bytes
+ }
+ keys[key]=1
+ }
+ END {
+ print "case,seconds_old,seconds_new,bytes_old,bytes_new"
+ for (k in keys) {
+ printf "%s,%s,%s,%s,%s\n", k, old_sec[k], new_sec[k], old_bytes[k], new_bytes[k]
+ }
+ }
+' "$RAW_CSV_FILE" | sort > "$CSV_FILE"
+
+cat > "$MD_FILE" <<'HEADER'
+# Regression Timing And Output Comparison
+
+| Case | New (s) | Old (s) | Delta (s) | New/Old |
+|---|---:|---:|---:|---:|
+HEADER
+
+awk -F',' '
+ NR==1 {next}
+ {
+ key=$1
+ os=$2+0
+ ns=$3+0
+ delta=ns-os
+ ratio=(os==0)?0:(ns/os)
+ printf "| %s | %.3f | %.3f | %.3f | %.3f |\n", key, ns, os, delta, ratio
+ }
+' "$CSV_FILE" >> "$MD_FILE"
+
+CASE_ORDER="txt cli html md junit xml_tests xml_coverage cobertura warnings errors warnings_and_errors"
+
+awk -F',' -v order="$CASE_ORDER" '
+ BEGIN {
+ split(order, ordered, " ")
+ }
+ NR==1 {next}
+ {
+ key=$1
+ old_sec=$2
+ new_sec=$3
+ value["old",key]=old_sec
+ value["new",key]=new_sec
+ }
+ END {
+ printf "version"
+ for (i=1; i<=length(ordered); i++) {
+ if (ordered[i] != "") {
+ printf ",%s", ordered[i]
+ }
+ }
+ printf "\n"
+ versions[1]="new"
+ versions[2]="old"
+ for (v=1; v<=2; v++) {
+ ver=versions[v]
+ printf "%s", ver
+ for (i=1; i<=length(ordered); i++) {
+ key=ordered[i]
+ if (key != "") {
+ printf ",%s", value[ver,key]
+ }
+ }
+ printf "\n"
+ }
+ }
+' "$CSV_FILE" > "$CHART_CSV_FILE"
+
+{
+ echo ""
+ echo "## Timing Matrix"
+ echo ""
+ echo "| Version | txt | cli | html | md | junit | xml_tests | xml_coverage | cobertura | warnings | errors | warnings_and_errors |"
+ echo "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"
+ awk -F',' 'NR>1 {printf "| %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s |\n", $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12}' "$CHART_CSV_FILE"
+} >> "$MD_FILE"
+
+echo "Created:"
+echo "- $CSV_FILE"
+echo "- $MD_FILE"
+echo "- $CHART_CSV_FILE"
+echo "- $NEW_OUT_DIR"
+echo "- $OLD_OUT_DIR"
diff --git a/testAndRunSonar.sh b/Scripts/testAndRunSonar.sh
old mode 100644
new mode 100755
similarity index 100%
rename from testAndRunSonar.sh
rename to Scripts/testAndRunSonar.sh
diff --git a/Sources/xcresultparser/CoberturaCoverageConverter.swift b/Sources/xcresultparser/CoberturaCoverageConverter.swift
index e24b6bd..4880fd0 100644
--- a/Sources/xcresultparser/CoberturaCoverageConverter.swift
+++ b/Sources/xcresultparser/CoberturaCoverageConverter.swift
@@ -48,7 +48,7 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
dtd.name = "coverage"
// dtd.systemID = "http://cobertura.sourceforge.net/xml/coverage-04.dtd"
dtd.systemID =
- "https://github.com/cobertura/cobertura/blob/master/cobertura/src/site/htdocs/xml/coverage-04.dtd"
+ "https://github.com/cobertura/cobertura/blob/master/cobertura/src/site/htdocs/xml/coverage-04.dtd"
let rootElement = makeRootElement()
@@ -67,8 +67,11 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
// Get the xccov results as a JSON.
let coverageJson = try getCoverageDataAsJSON()
- var fileInfo: [FileInfo] = []
+ var fileInfo = [FileInfo]()
for (fileName, value) in coverageJson.files {
+ guard isTargetIncluded(forFile: fileName) else {
+ continue
+ }
guard !isPathExcluded(fileName) else {
continue
}
@@ -88,7 +91,6 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
fileLines.append(line)
}
-
let fileInfoInst = FileInfo(path: relativePath ?? fileName, lines: fileLines)
fileInfo.append(fileInfoInst)
}
@@ -129,7 +131,7 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
classElement.addAttribute(XMLNode.nodeAttribute(withName: "name", stringValue: className))
classElement.addAttribute(XMLNode.nodeAttribute(withName: "filename", stringValue: file.path))
- let fileLineCoverage = Float(file.lines.filter { $0.coverage > 0 }.count) / Float(file.lines.count)
+ let fileLineCoverage = Float(file.lines.count(where: { $0.coverage > 0 })) / Float(file.lines.count)
classElement.addAttribute(XMLNode.nodeAttribute(withName: "line-rate", stringValue: "\(fileLineCoverage)"))
classElement.addAttribute(XMLNode.nodeAttribute(withName: "branch-rate", stringValue: "1.0"))
classElement.addAttribute(XMLNode.nodeAttribute(withName: "complexity", stringValue: "0.0"))
@@ -163,19 +165,18 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
}
private func makeRootElement() -> XMLElement {
- // TODO: some of these values are B.S. - figure out how to calculate, or better to omit if we don't know?
- let testAction = invocationRecord.actions.first { $0.schemeCommandName == "Test" }
- let timeStamp = (testAction?.startedTime.timeIntervalSince1970) ?? Date().timeIntervalSince1970
+ // Cobertura requires these attributes; branch/complexity values are placeholders for now.
+ let timeStamp = startTime ?? Date().timeIntervalSince1970
let rootElement = XMLElement(name: "coverage")
rootElement.addAttribute(
- XMLNode.nodeAttribute(withName: "line-rate", stringValue: "\(codeCoverage.lineCoverage)")
+ XMLNode.nodeAttribute(withName: "line-rate", stringValue: "\(lineCoverage)")
)
rootElement.addAttribute(XMLNode.nodeAttribute(withName: "branch-rate", stringValue: "1.0"))
rootElement.addAttribute(
- XMLNode.nodeAttribute(withName: "lines-covered", stringValue: "\(codeCoverage.coveredLines)")
+ XMLNode.nodeAttribute(withName: "lines-covered", stringValue: "\(coveredLines)")
)
rootElement.addAttribute(
- XMLNode.nodeAttribute(withName: "lines-valid", stringValue: "\(codeCoverage.executableLines)")
+ XMLNode.nodeAttribute(withName: "lines-valid", stringValue: "\(executableLines)")
)
rootElement.addAttribute(XMLNode.nodeAttribute(withName: "timestamp", stringValue: "\(timeStamp)"))
rootElement.addAttribute(XMLNode.nodeAttribute(withName: "version", stringValue: "diff_coverage 0.1"))
@@ -186,17 +187,11 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
return rootElement
}
- // this ised to be fetched online from http://cobertura.sourceforge.net/xml/coverage-04.dtd
- // that broke, when the URL changed to:
- // https://github.com/cobertura/cobertura/blob/master/cobertura/src/site/htdocs/xml/coverage-04.dtd
- // In case we couldn't download the data, we had a file as fallback. However that file could never be read
- // because as command line tool this is not a bundle and thus there is no file to be found in the bundle
- // IMO all that was overengineered for the followong 60 lines string...
- // ...which will probably never ever change!
+ // Keep DTD inline to avoid runtime fetches and bundle/file lookup issues.
// Helper methods for creating valid Cobertura XML structure
private func createValidPackageName(from pathComponents: [Substring]) -> String {
// Use original simple logic: join all path components except the filename with dots
- return pathComponents[0.. String {
@@ -215,7 +210,7 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
let totalLines = packageFiles.reduce(0) { $0 + $1.lines.count }
let coveredLines = packageFiles.reduce(0) { total, file in
- total + file.lines.filter { $0.coverage > 0 }.count
+ total + file.lines.count(where: { $0.coverage > 0 })
}
return totalLines > 0 ? Float(coveredLines) / Float(totalLines) : 0.0
@@ -227,7 +222,7 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
conforming SGML systems and applications as defined in
ISO 8879, provided this notice is included in all copies.
-->
-
+
@@ -238,47 +233,47 @@ public class CoberturaCoverageConverter: CoverageConverter, XmlSerializable {
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
diff --git a/Sources/xcresultparser/CoverageConverter.swift b/Sources/xcresultparser/CoverageConverter.swift
index 16d6855..592aded 100644
--- a/Sources/xcresultparser/CoverageConverter.swift
+++ b/Sources/xcresultparser/CoverageConverter.swift
@@ -6,7 +6,25 @@
//
import Foundation
-import XCResultKit
+
+public enum CoverageConverterError: LocalizedError, Equatable {
+ case couldNotLoadCoverageReport
+ case unknownCoverageTargets(requested: [String], available: [String])
+ case notImplemented
+
+ public var errorDescription: String? {
+ switch self {
+ case .couldNotLoadCoverageReport:
+ return "Could not load coverage report from xcresult archive."
+ case let .unknownCoverageTargets(requested, available):
+ let requestedList = requested.joined(separator: ", ")
+ let availableList = available.joined(separator: ", ")
+ return "Unknown coverage target(s): \(requestedList). Available targets: \(availableList)"
+ case .notImplemented:
+ return "xmlString(quiet:) must be implemented by a CoverageConverter subclass."
+ }
+ }
+}
/// Convert coverage data in a xcresult archive to xml (exact format determined by subclass)
///
@@ -24,50 +42,109 @@ import XCResultKit
/// Read the Readme for further info on this.
///
public class CoverageConverter {
- let resultFile: XCResultFile
+ let resultFileURL: URL
let projectRoot: String
- let codeCoverage: CodeCoverage
- let invocationRecord: ActionsInvocationRecord
let coverageTargets: Set
+ let coverageReport: CoverageReport
+ let filesForIncludedTargets: Set
let excludedPaths: Set
let strictPathnames: Bool
+ let startTime: Double?
// MARK: - Dependencies
- let shell = DependencyFactory.createShell()
+ let xcresultToolClient: XCResultToolProviding
+ let xccovClient: XCCovProviding
- public init?(
+ public convenience init(
with url: URL,
projectRoot: String = "",
coverageTargets: [String] = [],
excludedPaths: [String] = [],
strictPathnames: Bool
- ) {
- resultFile = XCResultFile(url: url)
- guard let record = resultFile.getCodeCoverage() else {
- return nil
+ ) throws {
+ try self.init(
+ with: url,
+ projectRoot: projectRoot,
+ coverageTargets: coverageTargets,
+ excludedPaths: excludedPaths,
+ strictPathnames: strictPathnames,
+ xcResultToolClient: XCResultToolClient(),
+ xcCovClient: XCCovClient()
+ )
+ }
+
+ init(
+ with url: URL,
+ projectRoot: String = "",
+ coverageTargets: [String] = [],
+ excludedPaths: [String] = [],
+ strictPathnames: Bool,
+ xcResultToolClient: XCResultToolProviding,
+ xcCovClient: XCCovProviding
+ ) throws {
+ let report: CoverageReport
+ do {
+ report = try xcCovClient.getCoverageReport(path: url)
+ } catch {
+ throw CoverageConverterError.couldNotLoadCoverageReport
}
+
+ self.xcresultToolClient = xcResultToolClient
+ xccovClient = xcCovClient
+ resultFileURL = url
+ coverageReport = report
self.projectRoot = projectRoot
self.strictPathnames = projectRoot.isEmpty ? false : strictPathnames
- codeCoverage = record
- guard let invocationRecord = resultFile.getInvocationRecord() else {
- return nil
+ let targetSelection = CoverageTargetSelection(
+ with: coverageTargets,
+ from: report.targets.map(\.name)
+ )
+ let selectedCoverageTargets = targetSelection.selectedTargets
+ self.coverageTargets = selectedCoverageTargets
+ if !targetSelection.unmatchedRequested.isEmpty {
+ throw CoverageConverterError.unknownCoverageTargets(
+ requested: targetSelection.unmatchedRequested.sorted(),
+ available: targetSelection.availableTargets.sorted()
+ )
}
- self.invocationRecord = invocationRecord
- self.coverageTargets = record.targets(filteredBy: coverageTargets)
+ let includedTargetFiles = report.targets
+ .filter { selectedCoverageTargets.contains($0.name) }
+ .flatMap { $0.files.map(\.path) }
+ filesForIncludedTargets = CoverageConverter.normalizedFilePaths(
+ for: includedTargetFiles,
+ projectRoot: projectRoot
+ )
self.excludedPaths = Set(excludedPaths)
+ startTime = if let summary = try? xcResultToolClient.getTestSummary(path: url) {
+ summary.startTime
+ } else {
+ nil
+ }
}
public func xmlString(quiet: Bool) throws -> String {
- fatalError("xmlString is not implemented")
+ throw CoverageConverterError.notImplemented
}
public var targetsInfo: String {
- return codeCoverage.targets.reduce("") { rslt, item in
+ return coverageReport.targets.reduce("") { rslt, item in
return "\(rslt)\n\(item.name)"
}
}
+ var lineCoverage: Double {
+ coverageReport.lineCoverage
+ }
+
+ var coveredLines: Int {
+ coverageReport.coveredLines
+ }
+
+ var executableLines: Int {
+ coverageReport.executableLines
+ }
+
func writeToStdErrorLn(_ str: String) {
writeToStdError("\(str)\n")
}
@@ -82,14 +159,15 @@ public class CoverageConverter {
// Use the xccov commandline tool to get results as JSON.
func getCoverageDataAsJSON() throws -> FileCoverage {
- var arguments = ["xccov", "view"]
- if resultFile.url.pathExtension == "xcresult" {
- arguments.append("--archive")
+ try xccovClient.getCoverageData(path: resultFileURL)
+ }
+
+ func isTargetIncluded(forFile file: String) -> Bool {
+ if filesForIncludedTargets.contains(file) {
+ return true
}
- arguments.append("--json")
- arguments.append(resultFile.url.path)
- let coverageData = try shell.execute(program: "/usr/bin/xcrun", with: arguments)
- return try JSONDecoder().decode(FileCoverage.self, from: coverageData)
+ let normalized = CoverageConverter.normalizedFilePaths(for: [file], projectRoot: projectRoot)
+ return !filesForIncludedTargets.isDisjoint(with: normalized)
}
func isPathExcluded(_ path: String) -> Bool {
@@ -99,36 +177,36 @@ public class CoverageConverter {
return false
}
- // MARK: - unused and only here for reference
-
- // This method was replaced by getCoverageDataAsJSON()
- // Instead of requiring to get the coverage data for each single code file
- // we now can obtain all information for all targets and all files in one call to xccov
- // That is of course much faster, than calling xccov for each file, as we needed to in older times
- // It is not used at the moment, but is left here just to cover this xccov function
+ // Maintained to support the public API used by tests and existing consumers.
func coverageForFile(path: String) throws -> String {
- var arguments = ["xccov", "view"]
- if resultFile.url.pathExtension == "xcresult" {
- arguments.append("--archive")
- }
- arguments.append("--file")
- arguments.append(path)
- arguments.append(resultFile.url.path)
- let coverageData = try shell.execute(program: "/usr/bin/xcrun", with: arguments)
- return String(decoding: coverageData, as: UTF8.self)
+ try xccovClient.getCoverageForFile(path: resultFileURL, filePath: path)
}
- // This method was replaced by going through all files in all targets
- // That allows us to filter by targets easier
- // It is not used at the moment, but is left here just to cover this xccov function
+ // Maintained to support the public API used by tests and existing consumers.
func coverageFileList() throws -> [String] {
- var arguments = ["xccov", "view"]
- if resultFile.url.pathExtension == "xcresult" {
- arguments.append("--archive")
+ try xccovClient.getCoverageFileList(path: resultFileURL)
+ }
+
+ static func normalizedFilePaths(for paths: [String], projectRoot: String) -> Set {
+ var result = Set()
+ for path in paths {
+ result.insert(path)
+ guard !projectRoot.isEmpty else {
+ continue
+ }
+ if path.hasPrefix(projectRoot) {
+ var relative = String(path.dropFirst(projectRoot.count))
+ if relative.hasPrefix("/") {
+ relative.removeFirst()
+ }
+ if !relative.isEmpty {
+ result.insert(relative)
+ }
+ } else if !path.hasPrefix("/") {
+ let joined = (projectRoot as NSString).appendingPathComponent(path)
+ result.insert(joined)
+ }
}
- arguments.append("--file-list")
- arguments.append(resultFile.url.path)
- let filelistData = try shell.execute(program: "/usr/bin/xcrun", with: arguments)
- return String(decoding: filelistData, as: UTF8.self).components(separatedBy: "\n")
+ return result
}
}
diff --git a/Sources/xcresultparser/DataProviders/JunitXML/JunitXMLDataProviding.swift b/Sources/xcresultparser/DataProviders/JunitXML/JunitXMLDataProviding.swift
new file mode 100644
index 0000000..4dc286e
--- /dev/null
+++ b/Sources/xcresultparser/DataProviders/JunitXML/JunitXMLDataProviding.swift
@@ -0,0 +1,58 @@
+//
+// JunitXMLDataProviding.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 20.02.26.
+//
+
+import Foundation
+
+protocol JunitXMLDataProviding {
+ var metrics: JunitInvocationMetrics { get }
+ var testActions: [JunitTestAction] { get }
+}
+
+struct JunitInvocationMetrics {
+ let testsCount: Int
+ let testsFailedCount: Int
+}
+
+struct JunitTestAction {
+ let startedTime: Date
+ let endedTime: Date
+ let testPlanRunSummaries: [JunitTestPlanRunSummary]
+ let failureSummaries: [JunitFailureSummary]
+}
+
+struct JunitTestPlanRunSummary {
+ let name: String?
+ let testableSummaries: [JunitTestableSummary]
+}
+
+struct JunitTestableSummary {
+ let tests: [JunitTestGroup]
+}
+
+struct JunitTestGroup {
+ let identifier: String?
+ let name: String?
+ let duration: Double
+ let subtests: [JunitTest]
+ let subtestGroups: [JunitTestGroup]
+}
+
+struct JunitTest {
+ let identifier: String?
+ let name: String?
+ let duration: Double?
+ let isFailed: Bool
+ let isSkipped: Bool
+}
+
+struct JunitFailureSummary {
+ let message: String
+ let testCaseName: String
+ let issueType: String
+ let producingTarget: String?
+ let documentLocation: String?
+}
diff --git a/Sources/xcresultparser/DataProviders/JunitXML/XCResultToolJunitXMLDataProvider.swift b/Sources/xcresultparser/DataProviders/JunitXML/XCResultToolJunitXMLDataProvider.swift
new file mode 100644
index 0000000..6c1b97c
--- /dev/null
+++ b/Sources/xcresultparser/DataProviders/JunitXML/XCResultToolJunitXMLDataProvider.swift
@@ -0,0 +1,269 @@
+//
+// XCResultToolJunitXMLDataProvider.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 20.02.26.
+//
+
+import Foundation
+
+struct XCResultToolJunitXMLDataProvider: JunitXMLDataProviding {
+ private let summary: XCSummary
+ private let tests: XCTests
+
+ init(url: URL, client: XCResultToolProviding = XCResultToolClient()) throws {
+ summary = try client.getTestSummary(path: url)
+ tests = try client.getTests(path: url)
+ }
+
+ var metrics: JunitInvocationMetrics {
+ JunitInvocationMetrics(
+ testsCount: summary.totalTestCount,
+ testsFailedCount: summary.failedTests
+ )
+ }
+
+ var testActions: [JunitTestAction] {
+ let start = Date(timeIntervalSince1970: summary.startTime ?? 0)
+ let end = Date(timeIntervalSince1970: summary.finishTime ?? summary.startTime ?? 0)
+ let failureMessageDetails = failureMessageDetailsByTestIdentifier()
+
+ let summaries = mapPlanRunSummaries(
+ from: tests,
+ fallbackName: summary.title
+ )
+
+ let failureSummaries = summary.testFailures.map { failure in
+ let matchingFailureMessage = bestFailureMessage(
+ for: failure,
+ in: failureMessageDetails[failure.testIdentifierString] ?? []
+ )
+ return JunitFailureSummary(
+ message: failure.failureText,
+ testCaseName: failure.testIdentifierString.replacingOccurrences(of: "/", with: "."),
+ issueType: "Uncategorized",
+ producingTarget: nil,
+ documentLocation: matchingFailureMessage?.documentLocation
+ )
+ }
+
+ return [
+ JunitTestAction(
+ startedTime: start,
+ endedTime: end,
+ testPlanRunSummaries: summaries,
+ failureSummaries: failureSummaries
+ )
+ ]
+ }
+
+ private func mapPlanRunSummaries(from tests: XCTests, fallbackName: String?) -> [JunitTestPlanRunSummary] {
+ let configuredNodes = tests.testPlanConfigurations.map { config in
+ (
+ name: config.configurationName,
+ nodes: nodes(for: config, in: tests.testNodes)
+ )
+ }
+
+ if !configuredNodes.isEmpty {
+ return configuredNodes.map { entry in
+ JunitTestPlanRunSummary(
+ name: entry.name,
+ testableSummaries: [
+ JunitTestableSummary(
+ tests: entry.nodes.compactMap {
+ mapGroup(node: $0, parentPath: nil, currentTestClassName: nil)
+ }
+ )
+ ]
+ )
+ }
+ }
+
+ return [
+ JunitTestPlanRunSummary(
+ name: fallbackName,
+ testableSummaries: [
+ JunitTestableSummary(
+ tests: tests.testNodes.compactMap {
+ mapGroup(node: $0, parentPath: nil, currentTestClassName: nil)
+ }
+ )
+ ]
+ )
+ ]
+ }
+
+ private func nodes(for configuration: XCConfiguration, in roots: [XCTestNode]) -> [XCTestNode] {
+ var matches = [XCTestNode]()
+ for root in roots {
+ matches.append(contentsOf: findConfigurationChildren(in: root, configuration: configuration))
+ }
+ return matches.isEmpty ? roots : matches
+ }
+
+ private func findConfigurationChildren(in node: XCTestNode, configuration: XCConfiguration) -> [XCTestNode] {
+ if node.nodeType == .testPlanConfiguration,
+ node.name == configuration.configurationName || node.nodeIdentifier == configuration.configurationId {
+ return node.children ?? []
+ }
+
+ let children = node.children ?? []
+ return children.flatMap { findConfigurationChildren(in: $0, configuration: configuration) }
+ }
+
+ private func mapGroup(
+ node: XCTestNode,
+ parentPath: String?,
+ currentTestClassName: String?
+ ) -> JunitTestGroup? {
+ guard node.nodeType != .testCase else {
+ return nil
+ }
+
+ let groupName = mappedGroupName(for: node)
+ let currentPath = appendName(groupName, to: parentPath)
+ let nextTestClassName: String? = if node.nodeType == .testSuite {
+ groupName
+ } else {
+ currentTestClassName
+ }
+ let children = node.children ?? []
+ let tests = children.mapTests(
+ testClassName: nextTestClassName,
+ mapTest: mapTest(node:testClassName:),
+ mapArgumentTest: { mappedArgumentTest in
+ JunitTest(
+ identifier: mappedArgumentTest.identifier,
+ name: mappedArgumentTest.name,
+ duration: mappedArgumentTest.duration,
+ isFailed: mappedArgumentTest.result == .failed,
+ isSkipped: mappedArgumentTest.result == .skipped
+ )
+ }
+ )
+
+ let groups = children.compactMap { child in
+ mapGroup(
+ node: child,
+ parentPath: currentPath,
+ currentTestClassName: nextTestClassName
+ )
+ }
+
+ let duration = node.durationInSeconds ?? tests.compactMap(\.duration).reduce(0, +) + groups.reduce(0) { $0 + $1.duration }
+ return JunitTestGroup(
+ identifier: mappedGroupIdentifier(for: node, fallback: groupName),
+ name: groupName,
+ duration: duration,
+ subtests: tests,
+ subtestGroups: groups
+ )
+ }
+
+ private func mapTest(node: XCTestNode, testClassName: String?) -> JunitTest {
+ let result = node.result ?? .unknown
+ let identifier: String = if let testClassName {
+ "\(testClassName)/\(node.name)"
+ } else {
+ node.name
+ }
+ return JunitTest(
+ identifier: identifier,
+ name: node.name,
+ duration: node.durationInSeconds,
+ isFailed: result == .failed,
+ isSkipped: result == .skipped
+ )
+ }
+
+ private func mappedGroupName(for node: XCTestNode) -> String {
+ switch node.nodeType {
+ case .unitTestBundle, .uiTestBundle:
+ return node.name.hasSuffix(".xctest") ? node.name : "\(node.name).xctest"
+ default:
+ return node.name
+ }
+ }
+
+ private func mappedGroupIdentifier(for node: XCTestNode, fallback: String) -> String {
+ switch node.nodeType {
+ case .unitTestBundle, .uiTestBundle:
+ return node.name.hasSuffix(".xctest") ? node.name : "\(node.name).xctest"
+ case .testSuite:
+ return node.name
+ default:
+ return node.nodeIdentifier ?? fallback
+ }
+ }
+
+ private func appendName(_ name: String, to parentPath: String?) -> String {
+ guard let parentPath, !parentPath.isEmpty else {
+ return name
+ }
+ return "\(parentPath)/\(name)"
+ }
+
+ private func failureMessageDetailsByTestIdentifier() -> [String: [FailureMessageDetail]] {
+ var result = [String: [FailureMessageDetail]]()
+ for node in tests.testNodes {
+ collectFailureMessages(
+ in: node,
+ currentTestIdentifier: nil,
+ currentTestClassName: nil,
+ into: &result
+ )
+ }
+ return result
+ }
+
+ private func collectFailureMessages(
+ in node: XCTestNode,
+ currentTestIdentifier: String?,
+ currentTestClassName: String?,
+ into result: inout [String: [FailureMessageDetail]]
+ ) {
+ var currentIdentifier = currentTestIdentifier
+ var currentClassName = currentTestClassName
+ if node.nodeType == .testSuite {
+ currentClassName = node.name
+ }
+ if node.nodeType == .testCase {
+ currentIdentifier = testIdentifierString(for: node, testClassName: currentClassName)
+ }
+
+ if node.nodeType == .failureMessage,
+ let currentIdentifier,
+ let detail = FailureMessageDetail(from: node.name) {
+ result[currentIdentifier, default: []].append(detail)
+ }
+
+ for child in node.children ?? [] {
+ collectFailureMessages(
+ in: child,
+ currentTestIdentifier: currentIdentifier,
+ currentTestClassName: currentClassName,
+ into: &result
+ )
+ }
+ }
+
+ private func testIdentifierString(for node: XCTestNode, testClassName: String?) -> String {
+ if let testClassName, !testClassName.isEmpty {
+ return "\(testClassName)/\(node.name)"
+ }
+ return node.name
+ }
+
+ private func bestFailureMessage(
+ for failure: XCTestFailure,
+ in candidates: [FailureMessageDetail]
+ ) -> FailureMessageDetail? {
+ candidates.first {
+ $0.message == failure.failureText ||
+ $0.message.contains(failure.failureText) ||
+ failure.failureText.contains($0.message)
+ } ?? candidates.first
+ }
+}
+
diff --git a/Sources/xcresultparser/Extensions/String+MD5.swift b/Sources/xcresultparser/Extensions/String+MD5.swift
index 432d0c4..39eae0c 100644
--- a/Sources/xcresultparser/Extensions/String+MD5.swift
+++ b/Sources/xcresultparser/Extensions/String+MD5.swift
@@ -9,7 +9,11 @@ import Foundation
extension String {
func md5() -> String {
- let digest = Insecure.MD5.hash(data: Data(utf8))
+ return Data(utf8).md5()
+ }
+
+ func sha256() -> String {
+ let digest = SHA256.hash(data: Data(utf8))
return digest.map { String(format: "%02hhx", $0) }.joined()
}
}
diff --git a/Sources/xcresultparser/IssuesJSON.swift b/Sources/xcresultparser/IssuesJSON.swift
index f385c04..ebe2599 100644
--- a/Sources/xcresultparser/IssuesJSON.swift
+++ b/Sources/xcresultparser/IssuesJSON.swift
@@ -5,7 +5,6 @@
//
import Foundation
-import XCResultKit
/// Output some infos about warnings and issues
///
@@ -13,24 +12,33 @@ import XCResultKit
/// [Gitlab Code Climate Support](https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool)
///
public struct IssuesJSON {
- let resultFile: XCResultFile
let projectRoot: String
let checkName: String
- let invocationRecord: ActionsInvocationRecord
+ let buildResults: XCBuildResults
let excludedPaths: Set
- public init?(
+ public init(
with url: URL,
projectRoot: String = "",
excludedPaths: [String] = []
- ) {
- resultFile = XCResultFile(url: url)
- guard let invocationRecord = resultFile.getInvocationRecord(),
- let checkdata = try? Data(contentsOf: url.appendingPathComponent("Info.plist")) else {
- return nil
- }
- self.invocationRecord = invocationRecord
- checkName = checkdata.md5()
+ ) throws {
+ try self.init(
+ with: url,
+ projectRoot: projectRoot,
+ excludedPaths: excludedPaths,
+ xcResultToolClient: XCResultToolClient()
+ )
+ }
+
+ init(
+ with url: URL,
+ projectRoot: String = "",
+ excludedPaths: [String] = [],
+ xcResultToolClient: XCResultToolProviding
+ ) throws {
+ buildResults = try xcResultToolClient.getBuildResults(path: url)
+ let checkdata = try? Data(contentsOf: url.appendingPathComponent("Info.plist"))
+ checkName = checkdata?.md5() ?? ""
self.projectRoot = projectRoot
self.excludedPaths = Set(excludedPaths)
}
@@ -40,7 +48,7 @@ public struct IssuesJSON {
encoder.outputFormatting = .prettyPrinted
let jsonData: Data
if format == .errors {
- let errors = invocationRecord.issues.errorSummaries
+ let errors = buildResults.errors
.compactMap {
Issue(
issueSummary: $0,
@@ -52,7 +60,7 @@ public struct IssuesJSON {
}
jsonData = try encoder.encode(errors)
} else {
- let warnings = invocationRecord.issues.warningSummaries
+ let warnings = buildResults.warnings
.compactMap {
Issue(
issueSummary: $0,
@@ -62,7 +70,7 @@ public struct IssuesJSON {
excludedPaths: excludedPaths
)
}
- let analyzerWarnings = invocationRecord.issues.analyzerWarningSummaries
+ let analyzerWarnings = buildResults.analyzerWarnings
.compactMap {
Issue(
issueSummary: $0,
@@ -74,7 +82,7 @@ public struct IssuesJSON {
}
var combined = warnings + analyzerWarnings
if format == .warningsAndErrors {
- let errors = invocationRecord.issues.errorSummaries
+ let errors = buildResults.errors
.compactMap {
Issue(
issueSummary: $0,
diff --git a/Sources/xcresultparser/JunitXML.swift b/Sources/xcresultparser/JunitXML.swift
index 049197a..7ecdec9 100644
--- a/Sources/xcresultparser/JunitXML.swift
+++ b/Sources/xcresultparser/JunitXML.swift
@@ -6,7 +6,6 @@
//
import Foundation
-import XCResultKit
public enum TestReportFormat {
case junit, sonar
@@ -47,11 +46,11 @@ public struct JunitXML: XmlSerializable {
// MARK: - Properties
- private let resultFile: XCResultFile
+ private let dataProvider: JunitXMLDataProviding
private let projectRoot: URL?
- private let invocationRecord: ActionsInvocationRecord
private let testReportFormat: TestReportFormat
private let relativePathNames: Bool
+ private let shell: Commandline
private let nodeNames: NodeNames
@@ -64,33 +63,45 @@ public struct JunitXML: XmlSerializable {
// MARK: - Initializer
- public init?(
+ public init(
with url: URL,
projectRoot: String = "",
format: TestReportFormat = .junit,
relativePathNames: Bool = true
- ) {
- resultFile = XCResultFile(url: url)
- guard let record = resultFile.getInvocationRecord() else {
- return nil
- }
+ ) throws {
+ let dataProvider = try XCResultToolJunitXMLDataProvider(url: url)
+ self.init(
+ dataProvider: dataProvider,
+ projectRoot: projectRoot,
+ format: format,
+ relativePathNames: relativePathNames
+ )
+ }
+ init(
+ dataProvider: JunitXMLDataProviding,
+ projectRoot: String = "",
+ format: TestReportFormat = .junit,
+ relativePathNames: Bool = true,
+ shell: Commandline = Shell()
+ ) {
+ self.dataProvider = dataProvider
var isDirectory: ObjCBool = false
- if SharedInstances.fileManager.fileExists(atPath: projectRoot, isDirectory: &isDirectory),
- isDirectory.boolValue == true {
- self.projectRoot = URL(fileURLWithPath: projectRoot)
+ self.projectRoot = if SharedInstances.fileManager.fileExists(atPath: projectRoot, isDirectory: &isDirectory),
+ isDirectory.boolValue == true {
+ URL(fileURLWithPath: projectRoot)
} else {
- self.projectRoot = nil
+ nil
}
- invocationRecord = record
testReportFormat = format
- if testReportFormat == .sonar {
- nodeNames = NodeNames.sonarNodeNames
+ nodeNames = if testReportFormat == .sonar {
+ NodeNames.sonarNodeNames
} else {
- nodeNames = NodeNames.defaultNodeNames
+ NodeNames.defaultNodeNames
}
self.relativePathNames = relativePathNames
+ self.shell = shell
}
func createRootElement() -> XMLElement {
@@ -107,28 +118,21 @@ public struct JunitXML: XmlSerializable {
xml.characterEncoding = "UTF-8"
if testReportFormat != .sonar {
- let metrics = invocationRecord.metrics
- let testsCount = metrics.testsCount ?? 0
- testsuites.addAttribute(name: "tests", stringValue: String(testsCount))
- let testsFailedCount = metrics.testsFailedCount ?? 0
- testsuites.addAttribute(name: "failures", stringValue: String(testsFailedCount))
+ let metrics = dataProvider.metrics
+ testsuites.addAttribute(name: "tests", stringValue: String(metrics.testsCount))
+ testsuites.addAttribute(name: "failures", stringValue: String(metrics.testsFailedCount))
testsuites.addAttribute(name: "errors", stringValue: "0") // apparently Jenkins needs this?!
}
- let testActions = invocationRecord.actions.filter { $0.schemeCommandName == "Test" }
+ let testActions = dataProvider.testActions
guard !testActions.isEmpty else {
return xml.xmlString(options: [.nodePrettyPrint, .nodeCompactEmptyElement])
}
var overallTestSuiteDuration = 0.0
for testAction in testActions {
- guard let testsId = testAction.actionResult.testsRef?.id,
- let testPlanRun = resultFile.getTestPlanRunSummaries(id: testsId) else {
- continue
- }
-
- let testPlanRunSummaries = testPlanRun.summaries
- let failureSummaries = testAction.actionResult.issues.testFailureSummaries
+ let testPlanRunSummaries = testAction.testPlanRunSummaries
+ let failureSummaries = testAction.failureSummaries
if testReportFormat != .sonar {
let startDate = testAction.startedTime
@@ -164,38 +168,21 @@ public struct JunitXML: XmlSerializable {
// only used in unit testing
static func resetCachedPathnames() {
- ActionTestSummaryGroup.resetCachedPathnames()
+ JunitTestGroup.resetCachedPathnames()
}
// MARK: - Private interface
- // The XMLElement produced by this function is not allowed in the junit XML format and thus unused.
- // It is kept in case it serves another format.
- private func runDestinationXML(_ destination: ActionRunDestinationRecord) -> XMLElement {
- let properties = XMLElement(name: "properties")
- if !destination.displayName.isEmpty {
- properties.addChild(TestrunProperty(name: "destination", value: destination.displayName).xmlNode)
- }
- if !destination.targetArchitecture.isEmpty {
- properties.addChild(TestrunProperty(name: "architecture", value: destination.targetArchitecture).xmlNode)
- }
- let record = destination.targetSDKRecord
- if !record.name.isEmpty {
- properties.addChild(TestrunProperty(name: "sdk", value: record.name).xmlNode)
- }
- return properties
- }
-
private func createTestSuite(
- _ group: ActionTestSummaryGroup,
- failureSummaries: [TestFailureIssueSummary],
+ _ group: JunitTestGroup,
+ failureSummaries: [JunitFailureSummary],
configurationName: String,
testDirectory: String = ""
) -> [XMLElement] {
guard group.identifierString.hasSuffix(".xctest") || group.subtestGroups.isEmpty else {
return group.subtestGroups.reduce([XMLElement]()) {
rslt,
- subGroup in
+ subGroup in
return rslt + createTestSuite(
subGroup,
failureSummaries: failureSummaries,
@@ -221,7 +208,8 @@ public struct JunitXML: XmlSerializable {
let node = subGroup.sonarFileXML(
projectRoot: projectRoot,
configurationName: configurationName,
- relativePathNames: relativePathNames
+ relativePathNames: relativePathNames,
+ shell: shell
)
let testcases = createTestCases(
for: subGroup.nameString, tests: subGroup.subtests, failureSummaries: failureSummaries
@@ -244,18 +232,19 @@ public struct JunitXML: XmlSerializable {
}
private func createTestSuiteFinally(
- _ group: ActionTestSummaryGroup,
- tests: [ActionTestMetadata],
- failureSummaries: [TestFailureIssueSummary],
+ _ group: JunitTestGroup,
+ tests: [JunitTest],
+ failureSummaries: [JunitFailureSummary],
testDirectory: String = "",
configurationName: String
) -> XMLElement {
let node = testReportFormat == .sonar ?
- group.sonarFileXML(
- projectRoot: projectRoot,
- configurationName: configurationName,
- relativePathNames: relativePathNames
- ) : group.testSuiteXML(numFormatter: numFormatter)
+ group.sonarFileXML(
+ projectRoot: projectRoot,
+ configurationName: configurationName,
+ relativePathNames: relativePathNames,
+ shell: shell
+ ) : group.testSuiteXML(numFormatter: numFormatter)
for thisTest in tests {
let testcase = createTestCase(
@@ -269,7 +258,7 @@ public struct JunitXML: XmlSerializable {
}
private func createTestCases(
- for name: String, tests: [ActionTestMetadata], failureSummaries: [TestFailureIssueSummary]
+ for name: String, tests: [JunitTest], failureSummaries: [JunitFailureSummary]
) -> [XMLElement] {
var combined = [XMLElement]()
for thisTest in tests {
@@ -284,7 +273,7 @@ public struct JunitXML: XmlSerializable {
}
private func createTestCase(
- test: ActionTestMetadata, classname: String, failureSummaries: [TestFailureIssueSummary]
+ test: JunitTest, classname: String, failureSummaries: [JunitFailureSummary]
) -> XMLElement {
let testcase = test.xmlNode(
classname: classname,
@@ -324,7 +313,7 @@ extension XMLElement {
}
}
-extension ActionTestMetadata {
+extension JunitTest {
func xmlNode(
classname: String,
numFormatter: NumberFormatter,
@@ -351,7 +340,7 @@ extension ActionTestMetadata {
return testcase
}
- func failureSummaries(in summaries: [TestFailureIssueSummary]) -> [TestFailureIssueSummary] {
+ func failureSummaries(in summaries: [JunitFailureSummary]) -> [JunitFailureSummary] {
return summaries.filter { summary in
return summary.testCaseName == identifier?.replacingOccurrences(of: "/", with: ".") ||
summary.testCaseName == "-[\(identifier?.replacingOccurrences(of: "/", with: " ") ?? "")]"
@@ -359,7 +348,8 @@ extension ActionTestMetadata {
}
}
-private extension ActionTestSummaryGroup {
+private extension JunitTestGroup {
+ private static let cacheLock = NSLock()
private static var cachedPathnames = [String: String]()
struct TestMetrics {
@@ -371,6 +361,10 @@ private extension ActionTestSummaryGroup {
return identifier ?? ""
}
+ var nameString: String {
+ return name ?? "No-name"
+ }
+
func testSuiteXML(numFormatter: NumberFormatter) -> XMLElement {
let testsuite = XMLElement(name: "testsuite")
testsuite.addAttribute(name: "name", stringValue: nameString)
@@ -382,11 +376,11 @@ private extension ActionTestSummaryGroup {
return testsuite
}
- func sonarFileXML(projectRoot: URL?, configurationName: String, relativePathNames: Bool = true) -> XMLElement {
+ func sonarFileXML(projectRoot: URL?, configurationName: String, relativePathNames: Bool = true, shell: Commandline) -> XMLElement {
let testsuite = XMLElement(name: "file")
testsuite.addAttribute(
name: "path",
- stringValue: classPath(in: projectRoot, relativePathNames: relativePathNames)
+ stringValue: classPath(in: projectRoot, relativePathNames: relativePathNames, shell: shell)
)
testsuite.addAttribute(name: "configuration", stringValue: configurationName)
return testsuite
@@ -394,22 +388,41 @@ private extension ActionTestSummaryGroup {
// only used in unit testing
static func resetCachedPathnames() {
+ cacheLock.lock()
+ defer { cacheLock.unlock() }
cachedPathnames.removeAll()
}
+ static func resolvePathFromCachedClassMap(for fileName: String) -> String? {
+ guard !fileName.contains("/") else {
+ return fileName
+ }
+ cacheLock.lock()
+ defer { cacheLock.unlock() }
+ let candidates = cachedPathnames.values.filter { $0.hasSuffix("/\(fileName)") || $0 == fileName }
+ guard !candidates.isEmpty else {
+ return nil
+ }
+ return candidates.max { lhs, rhs in
+ lhs.components(separatedBy: "est").count < rhs.components(separatedBy: "est").count
+ }
+ }
+
// MARK: - Private interface
- private func classPath(in projectRootUrl: URL?, relativePathNames: Bool = true) -> String {
+ private func classPath(in projectRootUrl: URL?, relativePathNames: Bool = true, shell: Commandline) -> String {
guard let projectRootUrl else {
return identifierString
}
+ Self.cacheLock.lock()
+ defer { Self.cacheLock.unlock() }
if Self.cachedPathnames.isEmpty {
- cacheAllClassNames(in: projectRootUrl, relativePathNames: relativePathNames)
+ cacheAllClassNames(in: projectRootUrl, relativePathNames: relativePathNames, shell: shell)
}
return Self.cachedPathnames[identifierString] ?? identifierString
}
- private func cacheAllClassNames(in projectRootUrl: URL, relativePathNames: Bool = true) {
+ private func cacheAllClassNames(in projectRootUrl: URL, relativePathNames: Bool = true, shell: Commandline) {
let program = "/usr/bin/grep"
let grepPathArgument = relativePathNames ? "." : projectRootUrl.path
let arguments = [
@@ -421,7 +434,7 @@ private extension ActionTestSummaryGroup {
"^(?:public )?(?:final )?(?:public )?(?:(class|\\@implementation|struct) )[a-zA-Z0-9_]+",
grepPathArgument
]
- guard let filelistData = try? DependencyFactory.createShell().execute(program: program, with: arguments, at: projectRootUrl) else {
+ guard let filelistData = try? shell.execute(program: program, with: arguments, at: projectRootUrl) else {
return
}
let trimCharacterSet = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ":"))
@@ -478,34 +491,34 @@ private extension String {
}
}
-private extension TestFailureIssueSummary {
+private extension JunitFailureSummary {
func failureXML(projectRoot: URL? = nil) -> XMLElement {
let failure = XMLElement(name: "failure")
var value = message
- if let loc = documentLocationInCreatingWorkspace?.url {
- if let url = URL(string: loc) {
+ if let loc = documentLocation {
+ if loc.contains("://"), let url = URL(string: loc) {
let relative = relativePart(of: url, relativeTo: projectRoot)
if let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let line = comps.fragment?.components(separatedBy: "&").first(
- where: { $0.starts(with: "StartingLineNumber") }),
+ where: { $0.starts(with: "StartingLineNumber") }
+ ),
let num = line.components(separatedBy: "=").last {
value += " (\(relative):\(num))"
} else {
value += " (\(loc))"
}
} else {
- value += " (\(loc))"
+ value += " (\(resolvedDocumentLocation(loc, projectRoot: projectRoot)))"
}
}
if !value.isEmpty {
let textNode = XMLNode(kind: .text)
textNode.objectValue = value
failure.addChild(textNode)
- let shortMessage: String
- if let producingTarget {
- shortMessage = "\(issueType) in \(producingTarget): \(testCaseName)"
+ let shortMessage = if let producingTarget {
+ "\(issueType) in \(producingTarget): \(testCaseName)"
} else {
- shortMessage = "\(issueType): \(testCaseName)"
+ "\(issueType): \(testCaseName)"
}
failure.addAttribute(name: "message", stringValue: shortMessage)
failure.addAttribute(name: "type", stringValue: issueType)
@@ -513,6 +526,22 @@ private extension TestFailureIssueSummary {
return failure
}
+ private func resolvedDocumentLocation(_ location: String, projectRoot: URL?) -> String {
+ guard projectRoot != nil else {
+ return location
+ }
+ let components = location.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
+ guard components.count == 2 else {
+ return location
+ }
+ let file = String(components[0])
+ let linePart = String(components[1])
+ guard let resolvedPath = JunitTestGroup.resolvePathFromCachedClassMap(for: file) else {
+ return location
+ }
+ return "\(resolvedPath):\(linePart)"
+ }
+
private func relativePart(of url: URL, relativeTo projectRoot: URL?) -> String {
guard let projectRoot else {
return url.path
diff --git a/Sources/xcresultparser/Models/CodeClimate/IntermediateObjects/IssueLocationInfo.swift b/Sources/xcresultparser/Models/CodeClimate/IntermediateObjects/IssueLocationInfo.swift
index 432b910..85629ca 100644
--- a/Sources/xcresultparser/Models/CodeClimate/IntermediateObjects/IssueLocationInfo.swift
+++ b/Sources/xcresultparser/Models/CodeClimate/IntermediateObjects/IssueLocationInfo.swift
@@ -5,9 +5,8 @@
//
import Foundation
-import XCResultKit
-/// Helper object to convert from InvocationRecoord.DocumentLocation to Code Climate objects
+/// Helper object to convert from xcresult issue source URL to Code Climate objects
///
struct IssueLocationInfo {
let filePath: String
@@ -16,12 +15,11 @@ struct IssueLocationInfo {
let startColumn: Int
let endColumn: Int
- init?(with documentLocation: DocumentLocation?) {
- guard let documentLocation,
- let url = URL(string: documentLocation.url) else {
+ init?(with sourceURL: String?) {
+ guard let sourceURL,
+ let url = URL(string: sourceURL) else {
return nil
}
- // documentLocation.concreteTypeName: "DVTTextDocumentLocation"
filePath = url.path
guard let fragment = url.fragment else {
startLine = 0
diff --git a/Sources/xcresultparser/Models/CodeClimate/Issue.swift b/Sources/xcresultparser/Models/CodeClimate/Issue.swift
index 3220513..dc2326e 100644
--- a/Sources/xcresultparser/Models/CodeClimate/Issue.swift
+++ b/Sources/xcresultparser/Models/CodeClimate/Issue.swift
@@ -5,7 +5,6 @@
//
import Foundation
-import XCResultKit
struct Issue: Codable {
/// Required. A description of the code quality violation.
@@ -37,13 +36,13 @@ struct Issue: Codable {
extension Issue {
init?(
- issueSummary: IssueSummary,
+ issueSummary: XCIssue,
severity: IssueSeverity,
checkName: String,
projectRoot: String = "",
excludedPaths: Set = []
) {
- let issueLocationInfo = IssueLocationInfo(with: issueSummary.documentLocationInCreatingWorkspace)
+ let issueLocationInfo = IssueLocationInfo(with: issueSummary.sourceURL)
if let filePath = issueLocationInfo?.filePath,
excludedPaths.isPathExcluded(filePath) {
return nil
@@ -53,7 +52,7 @@ extension Issue {
self.severity = severity
engineName = "Xcode Result Bundle Tool"
location = IssueLocation(issueLocationInfo: issueLocationInfo, projectRoot: projectRoot)
- fingerprint = "\(issueSummary.issueType)-\(issueSummary.message)-\(location.fingerprint)".md5()
+ fingerprint = "\(issueSummary.issueType)-\(issueSummary.message)-\(location.fingerprint)".sha256()
type = .issue
categories = []
content = IssueContent(body: "\(issueSummary.issueType) โข \(issueSummary.message)")
diff --git a/Sources/xcresultparser/Models/Coverage/CoverageReport.swift b/Sources/xcresultparser/Models/Coverage/CoverageReport.swift
new file mode 100644
index 0000000..c8c4cd4
--- /dev/null
+++ b/Sources/xcresultparser/Models/Coverage/CoverageReport.swift
@@ -0,0 +1,40 @@
+//
+// CoverageReport.swift
+//
+// Created by Codex on 21.02.26.
+//
+
+import Foundation
+
+struct CoverageReport: Decodable {
+ let coveredLines: Int
+ let executableLines: Int
+ let lineCoverage: Double
+ let targets: [CoverageTarget]
+}
+
+struct CoverageTarget: Decodable {
+ let name: String
+ let lineCoverage: Double
+ let executableLines: Int
+ let coveredLines: Int
+ let files: [CoverageReportFile]
+}
+
+struct CoverageReportFile: Decodable {
+ let name: String
+ let path: String
+ let lineCoverage: Double
+ let executableLines: Int
+ let coveredLines: Int
+ let functions: [CoverageReportFunction]
+}
+
+struct CoverageReportFunction: Decodable {
+ let name: String
+ let lineNumber: Int
+ let lineCoverage: Double
+ let executableLines: Int
+ let coveredLines: Int
+ let executionCount: Int
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCActivities.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCActivities.swift
new file mode 100644
index 0000000..b7cd17a
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCActivities.swift
@@ -0,0 +1,14 @@
+//
+// XCActivities.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results activities
+
+struct XCActivities: Codable {
+ let testIdentifier: String
+ let testName: String
+ let testRuns: [XCTestRunActivities]
+ let testIdentifierURL: String?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCActivityNode.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCActivityNode.swift
new file mode 100644
index 0000000..41a3aa2
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCActivityNode.swift
@@ -0,0 +1,16 @@
+//
+// XCActivityNode.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results activities
+
+struct XCActivityNode: Codable {
+ let title: String
+ let isAssociatedWithFailure: Bool
+
+ let startTime: Double? // Date as a UNIX timestamp (seconds since midnight UTC on January 1, 1970)
+ let attachments: [XCAttachment]?
+ let childActivities: [XCActivityNode]?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCArgument.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCArgument.swift
new file mode 100644
index 0000000..f8f8e43
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCArgument.swift
@@ -0,0 +1,11 @@
+//
+// XCArgument.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results activities
+
+struct XCArgument: Codable {
+ let value: String
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCAttachment.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCAttachment.swift
new file mode 100644
index 0000000..ecc6bc0
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCAttachment.swift
@@ -0,0 +1,15 @@
+//
+// XCAttachment.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results activities
+
+struct XCAttachment: Codable {
+ let name: String
+ let uuid: String
+ let timestamp: Double? // Date as a UNIX timestamp (seconds since midnight UTC on January 1, 1970)
+ let payloadId: String?
+ let lifetime: String?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCBug.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCBug.swift
new file mode 100644
index 0000000..fbd2a3b
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCBug.swift
@@ -0,0 +1,13 @@
+//
+// XCBug.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results test-details
+
+struct XCBug: Codable {
+ let url: String?
+ let identifier: String?
+ let title: String?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCBuildResults.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCBuildResults.swift
new file mode 100644
index 0000000..42b72c2
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCBuildResults.swift
@@ -0,0 +1,38 @@
+//
+// XCBuildResults.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get build-results --path example.xcresult
+
+struct XCBuildResults: Codable {
+ let destination: XCDevice
+ let startTime: Double // Date as a UNIX timestamp (seconds since midnight UTC on January 1, 1970)
+ let endTime: Double // Date as a UNIX timestamp (seconds since midnight UTC on January 1, 1970)
+ let analyzerWarnings: [XCIssue]
+ let warnings: [XCIssue]
+ let errors: [XCIssue]
+ let status: String?
+ let analyzerWarningCount: Int
+ let errorCount: Int
+ let warningCount: Int
+ let actionTitle: String?
+}
+
+extension XCBuildResults {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ destination = try values.decode(XCDevice.self, forKey: .destination)
+ startTime = try values.decode(Double.self, forKey: .startTime)
+ endTime = try values.decode(Double.self, forKey: .endTime)
+ analyzerWarnings = try values.decode([XCIssue].self, forKey: .analyzerWarnings)
+ warnings = try values.decode([XCIssue].self, forKey: .warnings)
+ errors = try values.decode([XCIssue].self, forKey: .errors)
+ status = try? values.decode(String.self, forKey: .status)
+ analyzerWarningCount = (try? values.decode(Int.self, forKey: .analyzerWarningCount)) ?? 0
+ errorCount = (try? values.decode(Int.self, forKey: .errorCount)) ?? 0
+ warningCount = (try? values.decode(Int.self, forKey: .warningCount)) ?? 0
+ actionTitle = try? values.decode(String.self, forKey: .actionTitle)
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCCommonFailureInsight.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCCommonFailureInsight.swift
new file mode 100644
index 0000000..1bcfbf5
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCCommonFailureInsight.swift
@@ -0,0 +1,14 @@
+//
+// XCCommonFailureInsight.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results insights
+
+struct XCCommonFailureInsight: Codable {
+ let failuresCount: Int
+ let impact: String
+ let description: String
+ let associatedTestIdentifiers: [String]
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCConfiguration.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCConfiguration.swift
new file mode 100644
index 0000000..56c7668
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCConfiguration.swift
@@ -0,0 +1,16 @@
+//
+// XCConfiguration.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results metrics
+// xcrun xcresulttool get test-results summary
+// xcrun xcresulttool get test-results activities
+// xcrun xcresulttool get test-results tests
+
+/// Testplan configuration
+struct XCConfiguration: Codable {
+ let configurationId: String // e.g. "1"
+ let configurationName: String // e.g. "Test Scheme Action"
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCContent.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCContent.swift
new file mode 100644
index 0000000..25d5225
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCContent.swift
@@ -0,0 +1,19 @@
+//
+// XCContent.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get content-availability --path example.xcresult
+
+struct XCContent: Codable {
+ let hasCoverage: Bool
+ let hasDiagnostics: Bool
+ let hasTestResults: Bool
+ let logs: [XCLogType]
+}
+
+enum XCLogType: String, Codable {
+ case build
+ case action
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCDestination.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCDestination.swift
new file mode 100644
index 0000000..2c3829d
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCDestination.swift
@@ -0,0 +1,11 @@
+//
+// XCDestination.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+
+struct XCDestination: Codable {
+ let deviceName: String
+ let configurationName: String
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCDevice.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCDevice.swift
new file mode 100644
index 0000000..c69b95d
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCDevice.swift
@@ -0,0 +1,24 @@
+//
+// XCDevice.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results metrics
+// xcrun xcresulttool get test-results summary
+// xcrun xcresulttool get test-results activities
+// xcrun xcresulttool get test-results tests
+
+struct XCDevice: Codable {
+ let deviceId: String // e.g. 00008103-000959DC213B001E",
+ let deviceName: String // e.g. "My Mac",
+
+ // only required in 'xcrun xcresulttool get test-results activities'
+ let architecture: String? // e.g. "arm64",
+ let modelName: String? // e.g. "iMac",
+ let osVersion: String? // e.g. "26.0.1",
+
+ // optional
+ let platform: String? // e.g. "macOS"
+ let osBuildNumber: String? // e.g. "25A362",
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCDeviceAndConfigurationSummary.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCDeviceAndConfigurationSummary.swift
new file mode 100644
index 0000000..5a4e19a
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCDeviceAndConfigurationSummary.swift
@@ -0,0 +1,16 @@
+//
+// XCDeviceAndConfigurationSummary.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results summary
+
+struct XCDeviceAndConfigurationSummary: Codable {
+ let device: XCDevice
+ let testPlanConfiguration: XCConfiguration
+ let passedTests: Int
+ let failedTests: Int
+ let skippedTests: Int
+ let expectedFailures: Int
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCFailureDistributionInsight.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCFailureDistributionInsight.swift
new file mode 100644
index 0000000..5029ce3
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCFailureDistributionInsight.swift
@@ -0,0 +1,39 @@
+//
+// XCFailureDistributionInsight.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results insights
+
+struct XCFailureDistributionInsight: Codable {
+ let title: String
+ let impact: Int
+ let distributionPercent: Double
+ let associatedTestIdentifiers: [String]
+
+ // Optional
+ let bug: String?
+ let tag: String?
+ let destinations: [XCDestination]?
+}
+
+extension XCFailureDistributionInsight {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ title = try values.decode(String.self, forKey: .title)
+ distributionPercent = try values.decode(Double.self, forKey: .distributionPercent)
+ associatedTestIdentifiers = try values.decode([String].self, forKey: .associatedTestIdentifiers)
+ impact = if let intImpact = try? values.decode(Int.self, forKey: .impact) {
+ intImpact
+ } else if let stringImpact = try? values.decode(String.self, forKey: .impact),
+ let parsedImpact = Int(stringImpact) {
+ parsedImpact
+ } else {
+ 0
+ }
+ bug = try? values.decode(String.self, forKey: .bug)
+ tag = try? values.decode(String.self, forKey: .tag)
+ destinations = try? values.decode([XCDestination].self, forKey: .destinations)
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCInsightSummary.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCInsightSummary.swift
new file mode 100644
index 0000000..1f2787d
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCInsightSummary.swift
@@ -0,0 +1,13 @@
+//
+// XCInsightSummary.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results summary
+
+struct XCInsightSummary: Codable {
+ let impact: String
+ let category: String
+ let text: String
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCInsights.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCInsights.swift
new file mode 100644
index 0000000..75f1094
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCInsights.swift
@@ -0,0 +1,22 @@
+//
+// XCInsights.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results insights
+
+struct XCInsights: Codable {
+ let commonFailureInsights: [XCCommonFailureInsight]
+ let longestTestRunsInsights: [XCLongestTestRunsInsight]
+ let failureDistributionInsights: [XCFailureDistributionInsight]
+}
+
+extension XCInsights {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ commonFailureInsights = (try? values.decode([XCCommonFailureInsight].self, forKey: .commonFailureInsights)) ?? []
+ longestTestRunsInsights = (try? values.decode([XCLongestTestRunsInsight].self, forKey: .longestTestRunsInsights)) ?? []
+ failureDistributionInsights = (try? values.decode([XCFailureDistributionInsight].self, forKey: .failureDistributionInsights)) ?? []
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCIssue.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCIssue.swift
new file mode 100644
index 0000000..93fbc4f
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCIssue.swift
@@ -0,0 +1,14 @@
+//
+// XCIssue.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+
+struct XCIssue: Codable {
+ let issueType: String
+ let message: String
+ let targetName: String?
+ let sourceURL: String?
+ let className: String?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCLongestTestRunsInsight.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCLongestTestRunsInsight.swift
new file mode 100644
index 0000000..32748cc
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCLongestTestRunsInsight.swift
@@ -0,0 +1,19 @@
+//
+// XCLongestTestRunsInsight.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results insights
+
+struct XCLongestTestRunsInsight: Codable {
+ let title: String
+ let impact: String
+ let associatedTestIdentifiers: [String]
+ let targetName: String
+ let testPlanConfigurationName: String
+ let deviceName: String
+ let osNameAndVersion: String
+ let durationOfSlowTests: Double
+ let meanTime: String
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCMetric.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCMetric.swift
new file mode 100644
index 0000000..95482ea
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCMetric.swift
@@ -0,0 +1,21 @@
+//
+// XCMetric.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results metrics
+
+struct XCMetric: Codable {
+ let displayName: String
+ let unitOfMeasurement: String
+ let measurements: [Double]
+ let identifier: String?
+ let baselineName: String?
+ let baselineAverage: Double?
+ let maxRegression: Double?
+ let maxPercentRegression: Double?
+ let maxStandardDeviation: Double?
+ let maxPercentRelativeStandardDeviation: Double?
+ let polarity: String?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCStatistic.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCStatistic.swift
new file mode 100644
index 0000000..9da64d2
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCStatistic.swift
@@ -0,0 +1,12 @@
+//
+// XCStatistic.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results summary
+
+struct XCStatistic: Codable {
+ let title: String
+ let subtitle: String
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCSummary.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCSummary.swift
new file mode 100644
index 0000000..1820c9a
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCSummary.swift
@@ -0,0 +1,49 @@
+//
+// XCSummary.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results summary
+
+struct XCSummary: Codable {
+ let title: String
+ // Description of the Test Plan, OS, and environment that was used during testing
+ let environmentDescription: String
+ let topInsights: [XCInsightSummary]
+ let result: XCTestResult
+ let totalTestCount: Int
+ let passedTests: Int
+ let failedTests: Int
+ let skippedTests: Int
+ let expectedFailures: Int
+ let statistics: [XCStatistic]
+ let devicesAndConfigurations: [XCDeviceAndConfigurationSummary]
+ let testFailures: [XCTestFailure]
+
+ // Optional:
+ // Date as a UNIX timestamp (seconds since midnight UTC on January 1, 1970)
+ let startTime: Double?
+ // Date as a UNIX timestamp (seconds since midnight UTC on January 1, 1970)
+ let finishTime: Double?
+}
+
+extension XCSummary {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ title = try values.decode(String.self, forKey: .title)
+ environmentDescription = try values.decode(String.self, forKey: .environmentDescription)
+ topInsights = try values.decode([XCInsightSummary].self, forKey: .topInsights)
+ result = try values.decode(XCTestResult.self, forKey: .result)
+ totalTestCount = try values.decode(Int.self, forKey: .totalTestCount)
+ passedTests = try values.decode(Int.self, forKey: .passedTests)
+ failedTests = try values.decode(Int.self, forKey: .failedTests)
+ skippedTests = try values.decode(Int.self, forKey: .skippedTests)
+ expectedFailures = try values.decode(Int.self, forKey: .expectedFailures)
+ statistics = try values.decode([XCStatistic].self, forKey: .statistics)
+ devicesAndConfigurations = (try? values.decode([XCDeviceAndConfigurationSummary].self, forKey: .devicesAndConfigurations)) ?? []
+ testFailures = (try? values.decode([XCTestFailure].self, forKey: .testFailures)) ?? []
+ startTime = try? values.decode(Double.self, forKey: .startTime)
+ finishTime = try? values.decode(Double.self, forKey: .finishTime)
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestDetails.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestDetails.swift
new file mode 100644
index 0000000..af3a99c
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestDetails.swift
@@ -0,0 +1,62 @@
+//
+// XCTestDetails.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results test-details
+
+struct XCTestDetails: Codable {
+ let testIdentifier: String
+ let testName: String
+ let testDescription: String
+ let duration: String
+ let testPlanConfigurations: [XCConfiguration]
+ let devices: [XCDevice]
+ let testRuns: [XCTestNode]
+ let testResult: XCTestResult
+ let hasPerformanceMetrics: Bool
+ let hasMediaAttachments: Bool
+
+ // Human-readable duration with optional components of days, hours, minutes and seconds
+ let testIdentifierURL: String?
+ // Time interval in seconds
+ let durationInSeconds: Double?
+ // Date as a UNIX timestamp (seconds since midnight UTC on January 1, 1970)
+ let startTime: Double?
+ let arguments: [XCArgument]
+ let tags: [String]
+ let bugs: [XCBug]
+ let functionName: String?
+}
+
+extension XCTestDetails {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ testIdentifier = try values.decode(String.self, forKey: .testIdentifier)
+ testName = try values.decode(String.self, forKey: .testName)
+ testDescription = try values.decode(String.self, forKey: .testDescription)
+ duration = try values.decode(String.self, forKey: .duration)
+ testPlanConfigurations = try values.decode([XCConfiguration].self, forKey: .testPlanConfigurations)
+ devices = try values.decode([XCDevice].self, forKey: .devices)
+ testRuns = try values.decode([XCTestNode].self, forKey: .testRuns)
+ testResult = try values.decode(XCTestResult.self, forKey: .testResult)
+ hasPerformanceMetrics = if let performanceFlag = try? values.decode(Bool.self, forKey: .hasPerformanceMetrics) {
+ performanceFlag
+ } else {
+ (try? values.decode(String.self, forKey: .hasPerformanceMetrics)) == "true"
+ }
+ hasMediaAttachments = if let mediaFlag = try? values.decode(Bool.self, forKey: .hasMediaAttachments) {
+ mediaFlag
+ } else {
+ (try? values.decode(String.self, forKey: .hasMediaAttachments)) == "true"
+ }
+ testIdentifierURL = try? values.decode(String.self, forKey: .testIdentifierURL)
+ durationInSeconds = try? values.decode(Double.self, forKey: .durationInSeconds)
+ startTime = try? values.decode(Double.self, forKey: .startTime)
+ arguments = (try? values.decode([XCArgument].self, forKey: .arguments)) ?? []
+ tags = (try? values.decode([String].self, forKey: .tags)) ?? []
+ bugs = (try? values.decode([XCBug].self, forKey: .bugs)) ?? []
+ functionName = try? values.decode(String.self, forKey: .functionName)
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestFailure.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestFailure.swift
new file mode 100644
index 0000000..dab86fe
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestFailure.swift
@@ -0,0 +1,35 @@
+//
+// XCTestFailure.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results summary
+
+import Foundation
+
+struct XCTestFailure: Codable {
+ let testName: String
+ let targetName: String
+ let failureText: String
+ let testIdentifier: Int64
+ let testIdentifierString: String
+ let testIdentifierURL: URL?
+}
+
+extension XCTestFailure {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ testName = try values.decode(String.self, forKey: .testName)
+ targetName = try values.decode(String.self, forKey: .targetName)
+ failureText = try values.decode(String.self, forKey: .failureText)
+ testIdentifier = try values.decode(Int64.self, forKey: .testIdentifier)
+ testIdentifierString = try values.decode(String.self, forKey: .testIdentifierString)
+ let testIdentifierURLString = try? values.decode(String.self, forKey: .testIdentifierURL)
+ testIdentifierURL = if let testIdentifierURLString {
+ URL(string: testIdentifierURLString)
+ } else {
+ nil
+ }
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestNode+Extensions.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestNode+Extensions.swift
new file mode 100644
index 0000000..d982d02
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestNode+Extensions.swift
@@ -0,0 +1,135 @@
+//
+// XCTestNode+Extensions.swift
+// xcresultparser
+//
+
+import Foundation
+
+extension XCTestNode {
+ struct MappedArgumentTest {
+ let identifier: String
+ let name: String
+ let duration: TimeInterval?
+ let result: XCTestResult
+ }
+
+ func mapArgumentTest(argument: XCTestNode, testClassName: String?) -> MappedArgumentTest {
+ let baseIdentifier: String = if let testClassName {
+ "\(testClassName)/\(name)"
+ } else {
+ name
+ }
+ return MappedArgumentTest(
+ identifier: baseIdentifier.formatWithParameter(argument.name),
+ name: name.formatWithParameter(argument.name),
+ duration: argument.durationInSeconds ?? durationInSeconds,
+ result: argument.result ?? result ?? .unknown
+ )
+ }
+}
+
+extension [XCTestNode] {
+ func mapTests(
+ testClassName: String?,
+ mapTest: (XCTestNode, String?) -> TestType,
+ mapArgumentTest: (XCTestNode.MappedArgumentTest) -> TestType
+ ) -> [TestType] {
+ var tests = [TestType]()
+ for node in self where node.nodeType == .testCase {
+ let argumentNodes = (node.children ?? []).filter { $0.nodeType == .arguments }
+ if argumentNodes.isEmpty {
+ tests.append(mapTest(node, testClassName))
+ } else {
+ for argument in argumentNodes {
+ tests.append(mapArgumentTest(node.mapArgumentTest(argument: argument, testClassName: testClassName)))
+ }
+ }
+ }
+ return tests
+ }
+}
+
+private extension String {
+ func formatWithParameter(_ parameterValue: String) -> String {
+ guard let openParenIndex = firstIndex(of: "("),
+ let closeParenIndex = lastIndex(of: ")"),
+ openParenIndex < closeParenIndex else {
+ return "\(self) [\(parameterValue)]"
+ }
+
+ let signature = String(self[index(after: openParenIndex) ..< closeParenIndex])
+ let labels = parameterLabels(from: signature)
+ guard !labels.isEmpty else {
+ return "\(self) [\(parameterValue)]"
+ }
+
+ let values = splitValues(parameterValue)
+ let parameterized: String
+ if labels.count == values.count {
+ parameterized = zip(labels, values)
+ .map { "\($0): \($1)" }
+ .joined(separator: ", ")
+ } else if labels.count == 1 {
+ parameterized = "\(labels[0]): \(parameterValue.trimmingCharacters(in: .whitespacesAndNewlines))"
+ } else {
+ return "\(self) [\(parameterValue)]"
+ }
+
+ return "\(self[.. [String] {
+ var labels = [String]()
+ var token = ""
+
+ for character in signature {
+ if character == "," {
+ token = ""
+ continue
+ }
+ if character == ":" {
+ let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let label = trimmed.split(whereSeparator: \.isWhitespace).last, !label.isEmpty {
+ labels.append(String(label))
+ }
+ token = ""
+ continue
+ }
+ token.append(character)
+ }
+
+ return labels
+ }
+
+ private func splitValues(_ raw: String) -> [String] {
+ var values = [String]()
+ var current = ""
+ var isInQuotes = false
+
+ for character in raw {
+ if character == "\"" {
+ isInQuotes.toggle()
+ current.append(character)
+ continue
+ }
+
+ if character == ",", !isInQuotes {
+ let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ values.append(trimmed)
+ }
+ current = ""
+ continue
+ }
+
+ current.append(character)
+ }
+
+ let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ values.append(trimmed)
+ }
+
+ return values
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestNode.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestNode.swift
new file mode 100644
index 0000000..6642f41
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestNode.swift
@@ -0,0 +1,48 @@
+//
+// XCTestNode.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results tests
+
+import Foundation
+
+struct XCTestNode: Codable {
+ let name: String // e.g. "xcresultparser",
+ let nodeType: XCTestNodeType // e.g. "Test Plan",
+ let children: [XCTestNode]?
+ let result: XCTestResult? // e.g. "Passed"
+ let nodeIdentifier: String? // e.g. "0"
+ let nodeIdentifierURL: URL? // e.g. "test://com.apple.xcode/Xcresultparser/XcresultparserTests/XcresultparserTests"
+ let duration: String?
+ let durationInSeconds: TimeInterval? // e.g. 19
+ let details: String?
+ let tags: [String]
+ // let duration: String // left out because we can format `durationInSeconds` as String ourselves ;-)
+}
+
+extension XCTestNode {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+
+ // mandatory values:
+ name = try values.decode(String.self, forKey: .name)
+ nodeType = try values.decode(XCTestNodeType.self, forKey: .nodeType)
+
+ // optional values:
+ result = try? values.decode(XCTestResult.self, forKey: .result)
+ nodeIdentifier = try? values.decode(String.self, forKey: .nodeIdentifier)
+ let nodeIdentifierURLString = try? values.decode(String.self, forKey: .nodeIdentifierURL)
+ nodeIdentifierURL = if let nodeIdentifierURLString {
+ URL(string: nodeIdentifierURLString)
+ } else {
+ nil
+ }
+ children = (try? values.decode([XCTestNode].self, forKey: .children)) ?? [XCTestNode]()
+ duration = try? values.decode(String.self, forKey: .duration)
+ durationInSeconds = try? values.decode(TimeInterval.self, forKey: .durationInSeconds)
+ details = try? values.decode(String.self, forKey: .details)
+ tags = (try? values.decode([String].self, forKey: .tags)) ?? []
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestNodeType.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestNodeType.swift
new file mode 100644
index 0000000..f3e593f
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestNodeType.swift
@@ -0,0 +1,35 @@
+//
+// XCTestNodeType.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results tests
+
+enum XCTestNodeType: String, Codable {
+ case testPlan = "Test Plan"
+ case testPlanConfiguration = "Test Plan Configuration"
+ case unitTestBundle = "Unit test bundle"
+ case uiTestBundle = "UI test bundle"
+ case testSuite = "Test Suite"
+ case testCase = "Test Case"
+ case arguments = "Arguments"
+ case repetition = "Repetition"
+ case failureMessage = "Failure Message"
+ case device = "Device"
+ case testCaseRun = "Test Case Run"
+ case sourceCodeReference = "Source Code Reference"
+ case attachment = "Attachment"
+ case expression = "Expression"
+ case testValue = "Test Value"
+ case runtimeWarning = "Runtime Warning"
+ case unknown
+}
+
+extension XCTestNodeType {
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let value = try container.decode(String.self)
+ self = XCTestNodeType(rawValue: value) ?? .unknown
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestResult.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestResult.swift
new file mode 100644
index 0000000..008d8f4
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestResult.swift
@@ -0,0 +1,24 @@
+//
+// XCTestResult.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 02.11.25.
+//
+// xcrun xcresulttool get test-results summary
+// xcrun xcresulttool get test-results tests
+
+enum XCTestResult: String, Codable {
+ case passed = "Passed"
+ case failed = "Failed"
+ case skipped = "Skipped"
+ case expectedFailure = "Expected Failure"
+ case unknown
+}
+
+extension XCTestResult {
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let value = try container.decode(String.self)
+ self = XCTestResult(rawValue: value) ?? .unknown
+ }
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestRunActivities.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestRunActivities.swift
new file mode 100644
index 0000000..ee82767
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestRunActivities.swift
@@ -0,0 +1,14 @@
+//
+// XCTestRunActivities.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results activities
+
+struct XCTestRunActivities: Codable {
+ let device: XCDevice
+ let testPlanConfiguration: XCConfiguration
+ let activities: [XCActivityNode]
+ let arguments: [XCArgument]?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestRunWithMetrics.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestRunWithMetrics.swift
new file mode 100644
index 0000000..1f32c65
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestRunWithMetrics.swift
@@ -0,0 +1,13 @@
+//
+// XCTestRunWithMetrics.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results metrics
+
+struct XCTestRunWithMetrics: Codable {
+ let testPlanConfiguration: XCConfiguration
+ let device: XCDevice
+ let metrics: [XCMetric]
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTestWithMetrics.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTestWithMetrics.swift
new file mode 100644
index 0000000..84951fd
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTestWithMetrics.swift
@@ -0,0 +1,13 @@
+//
+// XCTestWithMetrics.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results metrics
+
+struct XCTestWithMetrics: Codable {
+ let testIdentifier: String
+ let testRuns: [XCTestRunWithMetrics]
+ let testIdentifierURL: String?
+}
diff --git a/Sources/xcresultparser/Models/XCResultToolModels/XCTests.swift b/Sources/xcresultparser/Models/XCResultToolModels/XCTests.swift
new file mode 100644
index 0000000..f300f86
--- /dev/null
+++ b/Sources/xcresultparser/Models/XCResultToolModels/XCTests.swift
@@ -0,0 +1,13 @@
+//
+// XCTests.swift
+// Xcresultparser
+//
+// Created by Alex da Franca on 06.12.25.
+//
+// xcrun xcresulttool get test-results tests
+
+struct XCTests: Codable {
+ let testPlanConfigurations: [XCConfiguration]
+ let devices: [XCDevice]
+ let testNodes: [XCTestNode]
+}
diff --git a/Sources/xcresultparser/OutputFormatting/CoverageReportFormat.swift b/Sources/xcresultparser/OutputFormatting/CoverageReportFormat.swift
index 4c6fdd8..9d76363 100644
--- a/Sources/xcresultparser/OutputFormatting/CoverageReportFormat.swift
+++ b/Sources/xcresultparser/OutputFormatting/CoverageReportFormat.swift
@@ -7,11 +7,11 @@
public enum CoverageReportFormat: String {
case methods, classes, targets, totals
public init(string: String?) {
- if let input = string?.lowercased(),
- let fmt = CoverageReportFormat(rawValue: input) {
- self = fmt
+ self = if let input = string?.lowercased(),
+ let fmt = CoverageReportFormat(rawValue: input) {
+ fmt
} else {
- self = .methods
+ .methods
}
}
}
diff --git a/Sources/xcresultparser/OutputFormatting/Formatters/HTML/HTMLResultFormatter.swift b/Sources/xcresultparser/OutputFormatting/Formatters/HTML/HTMLResultFormatter.swift
index 12aea39..74381e3 100644
--- a/Sources/xcresultparser/OutputFormatting/Formatters/HTML/HTMLResultFormatter.swift
+++ b/Sources/xcresultparser/OutputFormatting/Formatters/HTML/HTMLResultFormatter.swift
@@ -144,6 +144,14 @@ public struct HTMLResultFormatter: XCResultFormatting {
return node
}
+ private func htmlEncoded(_ string: String) -> String {
+ string
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ .replacingOccurrences(of: "\"", with: """)
+ }
+
// swiftlint:disable:next function_body_length
private func htmlDocStart(with title: String) -> String {
"""
@@ -151,7 +159,7 @@ public struct HTMLResultFormatter: XCResultFormatting {
- \(title)
+ \(htmlEncoded(title))