diff --git a/Sources/XCSiftCore/OutputParser.swift b/Sources/XCSiftCore/OutputParser.swift index 01fdf5d..a402d02 100644 --- a/Sources/XCSiftCore/OutputParser.swift +++ b/Sources/XCSiftCore/OutputParser.swift @@ -28,7 +28,7 @@ public struct StreamingOutputParser { var executables: [Executable] = [] var seenExecutablePaths: Set = [] var buildTime: String? - var testTimeAccumulator: Double = 0 + var swiftTestingTimeAccumulator: Double = 0 var seenTestNames: Set = [] var seenWarnings: Set = [] var warningCount = 0 @@ -37,8 +37,10 @@ public struct StreamingOutputParser { var seenPassedTestNames: Set = [] 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? @@ -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( @@ -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 { @@ -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``. diff --git a/Sources/XCSiftCore/XcodebuildSymbols.swift b/Sources/XCSiftCore/XcodebuildSymbols.swift index 4f63ca2..b35cdfa 100644 --- a/Sources/XCSiftCore/XcodebuildSymbols.swift +++ b/Sources/XCSiftCore/XcodebuildSymbols.swift @@ -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 = "✓" diff --git a/Tests/XCSiftCoreTests/ParsingTests.swift b/Tests/XCSiftCoreTests/ParsingTests.swift index 8ed82c4..f078903 100644 --- a/Tests/XCSiftCoreTests/ParsingTests.swift +++ b/Tests/XCSiftCoreTests/ParsingTests.swift @@ -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() {