Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions Sources/XCSiftCore/OutputParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public struct StreamingOutputParser {
var executables: [Executable] = []
var seenExecutablePaths: Set<String> = []
var buildTime: String?
var testTimeAccumulator: Double = 0
var swiftTestingTimeAccumulator: Double = 0
var seenTestNames: Set<String> = []
var seenWarnings: Set<WarningKey> = []
var warningCount = 0
Expand All @@ -37,8 +37,10 @@ public struct StreamingOutputParser {
var seenPassedTestNames: Set<String> = []
var xctestBundleExecutedCount: Int = 0
var xctestBundleFailedCount: Int = 0
var xctestBundleDuration: Double = 0
var xctestFallbackExecutedCount: Int?
var xctestFallbackFailedCount: Int?
var xctestFallbackDuration: Double = 0
var sawBundleLevelXCTestSummary: Bool = false
var swiftTestingExecutedCount: Int?
var swiftTestingFailedCount: Int?
Expand Down Expand Up @@ -228,9 +230,10 @@ public struct StreamingOutputParser {

let flakyTests = detectFlakyTests()

let totalTestTime = state.swiftTestingTimeAccumulator + resolvedXCTestDuration()
let formattedTestTime: String? =
state.testTimeAccumulator > 0
? String(format: "%.3fs", state.testTimeAccumulator)
totalTestTime > 0
? String(format: "%.3fs", totalTestTime)
: nil

let summary = BuildSummary(
Expand Down Expand Up @@ -345,20 +348,22 @@ public struct StreamingOutputParser {
}

case .testSuiteCompleted(let suiteName, let executed, let failed, let duration):
if suiteName.hasSuffix(".xctest") || suiteName == XcodebuildSymbols.selectedTestsSuite {
// "Selected tests"/"All tests" wrap the bundles, so they repeat totals instead of adding to them
if suiteName.hasSuffix(".xctest") {
state.xctestBundleExecutedCount += executed
state.xctestBundleFailedCount += failed
state.xctestBundleDuration += duration
state.sawBundleLevelXCTestSummary = true
} else {
state.xctestFallbackExecutedCount = executed
state.xctestFallbackFailedCount = failed
state.xctestFallbackDuration = duration
}
state.testTimeAccumulator += duration

case .swiftTestingCompleted(let executed, let failed, let duration):
state.swiftTestingExecutedCount = (state.swiftTestingExecutedCount ?? 0) + executed
state.swiftTestingFailedCount = (state.swiftTestingFailedCount ?? 0) + failed
state.testTimeAccumulator += duration
state.swiftTestingTimeAccumulator += duration

case .parallelTestScheduled(let index, let total):
if let previousIndex = state.lastParallelTestSchedulingIndex {
Expand Down Expand Up @@ -476,6 +481,10 @@ public struct StreamingOutputParser {
private func resolvedXCTestFailedCount() -> Int? {
state.sawBundleLevelXCTestSummary ? state.xctestBundleFailedCount : state.xctestFallbackFailedCount
}

private func resolvedXCTestDuration() -> Double {
state.sawBundleLevelXCTestSummary ? state.xctestBundleDuration : state.xctestFallbackDuration
}
}

/// Parses a complete xcodebuild or SPM output string into a structured ``BuildResult``.
Expand Down
1 change: 0 additions & 1 deletion Sources/XCSiftCore/XcodebuildSymbols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ enum XcodebuildSymbols {
static let testSuiteStartedSuffix = "' started"
static let testSuitePassedMarker = " passed"
static let testSuiteFailedMarker = " failed"
static let selectedTestsSuite = "Selected tests"

// Swift Testing symbols (macOS Private Use Area + Linux fallback)
static let swiftTestingPass = "✓"
Expand Down
74 changes: 74 additions & 0 deletions Tests/XCSiftCoreTests/ParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,80 @@ final class ParsingTests: XCTestCase {
XCTAssertEqual(result.summary.failedTests, 0)
}

/// Issue #83: `swift test --filter` prints the same totals for the nested suite, the `.xctest`
/// bundle and the `Selected tests` wrapper. Only one of them may count.
func testSelectedTestsWrapperDoesNotDoubleCountBundleTotals() {
let parser = OutputParser()
let input = """
Test Suite 'Selected tests' started at 2026-08-18 11:16:21.129.
Test Suite 'xcsiftPackageTests.xctest' started at 2026-08-18 11:16:21.130.
Test Suite 'PluginFilesTests' started at 2026-08-18 11:16:21.130.
Test Suite 'PluginFilesTests' passed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.002) seconds
Test Suite 'xcsiftPackageTests.xctest' passed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.002) seconds
Test Suite 'Selected tests' passed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.003) seconds
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.summary.passedTests, 4)
XCTAssertEqual(result.summary.failedTests, 0)
}

/// Issue #83: the wrapper repeats the failure count too, so a filtered run with failures
/// must not report more failures than XCTest ran.
func testSelectedTestsWrapperDoesNotDoubleCountFailures() {
let parser = OutputParser()
let input = """
Test Suite 'FeatureTests' failed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 1 failure (0 unexpected) in 0.002 (0.002) seconds
Test Suite 'MyPackageTests.xctest' failed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 1 failure (0 unexpected) in 0.002 (0.002) seconds
Test Suite 'Selected tests' failed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 1 failure (0 unexpected) in 0.002 (0.003) seconds
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.summary.failedTests, 1)
XCTAssertEqual(result.summary.passedTests, 3)
}

/// Issue #83: every suite level repeats the same duration, so test_time must count one level only.
func testXCTestSuiteDurationsAreNotDoubleCountedAcrossSuiteLevels() {
let parser = OutputParser()
let input = """
Test Suite 'PluginFilesTests' passed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 0 failures (0 unexpected) in 1.500 (1.510) seconds
Test Suite 'xcsiftPackageTests.xctest' passed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 0 failures (0 unexpected) in 1.500 (1.510) seconds
Test Suite 'Selected tests' passed at 2026-08-18 11:16:21.132.
Executed 4 tests, with 0 failures (0 unexpected) in 1.500 (1.520) seconds
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.summary.testTime, "1.500s")
}

/// Separate bundles hold disjoint tests, so their durations still add up.
func testXCTestBundleDurationsAccumulateAcrossBundles() {
let parser = OutputParser()
let input = """
Test Suite 'UnitTests.xctest' passed at 2026-01-15 12:00:00.001.
Executed 2 tests, with 0 failures in 0.100 seconds
Test Suite 'UITests.xctest' passed at 2026-01-15 12:00:00.002.
Executed 3 tests, with 0 failures in 0.200 seconds
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.summary.testTime, "0.300s")
XCTAssertEqual(result.summary.passedTests, 5)
}

/// Tests that test_time is accumulated correctly when both XCTest and Swift Testing are present
/// Regression test for fix where test times are summed across multiple test bundles
func testCombinedTestTimeAccumulation() {
Expand Down