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))