@@ -9,12 +9,15 @@ class OutputParser {
99 private var executables : [ Executable ] = [ ]
1010 private var seenExecutablePaths : Set < String > = [ ]
1111 private var buildTime : String ?
12+ private var testTimeAccumulator : Double = 0
1213 private var seenTestNames : Set < String > = [ ]
1314 private var seenWarnings : Set < String > = [ ]
1415 private var seenErrors : Set < String > = [ ]
1516 private var seenLinkerErrors : Set < String > = [ ]
16- private var executedTestsCount : Int ?
17- private var summaryFailedTestsCount : Int ?
17+ private var xctestExecutedCount : Int ?
18+ private var xctestFailedCount : Int ?
19+ private var swiftTestingExecutedCount : Int ?
20+ private var swiftTestingFailedCount : Int ?
1821 private var passedTestsCount : Int = 0
1922 private var seenPassedTestNames : Set < String > = [ ]
2023 private var parallelTestsTotalCount : Int ?
@@ -372,18 +375,40 @@ class OutputParser {
372375 let status =
373376 finalErrors. isEmpty && failedTests. isEmpty && linkerErrors. isEmpty ? " success " : " failed "
374377
375- let summaryFailedCount = summaryFailedTestsCount ?? failedTests. count
376- let computedPassedTests : Int ? = {
377- // Priority 1: Use parallel test total count if available
378- // This is the authoritative count from [N/TOTAL] Testing lines
378+ // Aggregate test counts from both XCTest and Swift Testing
379+ let totalExecuted : Int ? = {
380+ // Priority 1: Use parallel test total count if available (Swift Testing parallel mode)
379381 if let parallelTotal = parallelTestsTotalCount {
380- return max ( parallelTotal - summaryFailedCount, 0 )
382+ // Add XCTest count if available
383+ if let xctest = xctestExecutedCount {
384+ return parallelTotal + xctest
385+ }
386+ return parallelTotal
387+ }
388+
389+ // Priority 2: Sum XCTest and Swift Testing counts from summary lines
390+ let xctest = xctestExecutedCount ?? 0
391+ let swiftTesting = swiftTestingExecutedCount ?? 0
392+ if xctest > 0 || swiftTesting > 0 {
393+ return xctest + swiftTesting
381394 }
382- // Priority 2: Use executed tests count from summary line
383- if let executed = executedTestsCount {
384- return max ( executed - summaryFailedCount, 0 )
395+ return nil
396+ } ( )
397+
398+ let totalFailed : Int = {
399+ let xctestFailed = xctestFailedCount ?? 0
400+ let swiftTestingFailed = swiftTestingFailedCount ?? 0
401+ let aggregated = xctestFailed + swiftTestingFailed
402+ // Fall back to parsed failed tests if no summary counts
403+ return aggregated > 0 ? aggregated : failedTests. count
404+ } ( )
405+
406+ let computedPassedTests : Int ? = {
407+ // Use aggregated counts from summary lines
408+ if let executed = totalExecuted {
409+ return max ( executed - totalFailed, 0 )
385410 }
386- // Priority 3 : Use counted passed tests
411+ // Fallback : Use individually counted passed tests
387412 if passedTestsCount > 0 {
388413 return passedTestsCount
389414 }
@@ -399,13 +424,20 @@ class OutputParser {
399424 // Detect flaky tests (tests that both passed and failed in the same run)
400425 let flakyTests = detectFlakyTests ( )
401426
427+ // Format accumulated test time (nil if no tests ran)
428+ let formattedTestTime : String ? =
429+ testTimeAccumulator > 0
430+ ? String ( format: " %.3f " , testTimeAccumulator)
431+ : nil
432+
402433 let summary = BuildSummary (
403434 errors: finalErrors. count,
404435 warnings: finalWarnings. count,
405- failedTests: failedTests . count ,
436+ failedTests: totalFailed ,
406437 linkerErrors: linkerErrors. count,
407438 passedTests: computedPassedTests,
408439 buildTime: buildTime,
440+ testTime: formattedTestTime,
409441 coveragePercent: coverage? . lineCoverage,
410442 slowTests: slowTests. isEmpty ? nil : slowTests. count,
411443 flakyTests: flakyTests. isEmpty ? nil : flakyTests. count,
@@ -526,9 +558,12 @@ class OutputParser {
526558 executables = [ ]
527559 seenExecutablePaths = [ ]
528560 buildTime = nil
561+ testTimeAccumulator = 0
529562 seenTestNames = [ ]
530- executedTestsCount = nil
531- summaryFailedTestsCount = nil
563+ xctestExecutedCount = nil
564+ xctestFailedCount = nil
565+ swiftTestingExecutedCount = nil
566+ swiftTestingFailedCount = nil
532567 passedTestsCount = 0
533568 seenPassedTestNames = [ ]
534569 currentLinkerArchitecture = nil
@@ -670,8 +705,8 @@ class OutputParser {
670705 }
671706 } else if parsePassedTest ( line) {
672707 return
673- } else if let time = parseBuildTime ( line ) {
674- buildTime = time
708+ } else {
709+ parseBuildAndTestTime ( line )
675710 }
676711 }
677712
@@ -1231,120 +1266,137 @@ class OutputParser {
12311266 return nil
12321267 }
12331268
1234- private func parseBuildTime ( _ line: String ) -> String ? {
1269+ private func parseBuildAndTestTime ( _ line: String ) {
12351270 // Pattern: ** BUILD SUCCEEDED ** [45.2s] or ** BUILD FAILED ** [15.3s]
1236- // xcodebuild format with bracket timing - highest priority
1271+ // xcodebuild format with bracket timing - BUILD time
12371272 if line. contains ( " ** BUILD SUCCEEDED ** " ) || line. contains ( " ** BUILD FAILED ** " ) {
1238- // Extract time from square brackets
12391273 if let bracketStart = line. range ( of: " [ " , options: . backwards) ,
12401274 let bracketEnd = line. range ( of: " ] " , options: . backwards) ,
12411275 bracketStart. lowerBound < bracketEnd. lowerBound
12421276 {
1243- return String ( line [ bracketStart. upperBound ..< bracketEnd. lowerBound] )
1277+ buildTime = String ( line [ bracketStart. upperBound ..< bracketEnd. lowerBound] )
12441278 }
1279+ return
12451280 }
12461281
1247- // Pattern: Build complete! (12.34s) - SPM format
1282+ // Pattern: Build complete! (12.34s) - SPM format - BUILD time
12481283 if line. hasPrefix ( " Build complete! " ) {
12491284 if let parenStart = line. range ( of: " ( " ) ,
12501285 let parenEnd = line. range ( of: " ) " ) ,
12511286 parenStart. lowerBound < parenEnd. lowerBound
12521287 {
1253- return String ( line [ parenStart. upperBound ..< parenEnd. lowerBound] )
1288+ buildTime = String ( line [ parenStart. upperBound ..< parenEnd. lowerBound] )
12541289 }
1290+ return
12551291 }
12561292
1257- // Pattern: Build succeeded in time
1293+ // Pattern: Build succeeded in time - BUILD time
12581294 if line. hasPrefix ( " Build succeeded in " ) {
1259- return String ( line. dropFirst ( 19 ) )
1295+ buildTime = String ( line. dropFirst ( 19 ) )
1296+ return
12601297 }
12611298
1262- // Pattern: Build failed after time
1299+ // Pattern: Build failed after time - BUILD time
12631300 if line. hasPrefix ( " Build failed after " ) {
1264- return String ( line. dropFirst ( 19 ) )
1301+ buildTime = String ( line. dropFirst ( 19 ) )
1302+ return
12651303 }
12661304
12671305 // Pattern: Executed N tests, with N failures (N unexpected) in time (seconds) seconds
1268- if line. hasPrefix ( " Executed " ) , let withRange = line. range ( of: " , with " ) {
1269- let afterExecuted = line [ line. index ( line. startIndex, offsetBy: 9 ) ..< withRange. lowerBound]
1270- // Extract test count (skip "s" suffix)
1306+ // This is XCTest output format (may have leading whitespace/tab) - TEST time
1307+ let trimmedLine = line. trimmingCharacters ( in: . whitespaces)
1308+ if trimmedLine. hasPrefix ( " Executed " ) , let withRange = trimmedLine. range ( of: " , with " ) {
1309+ let afterExecuted = trimmedLine [
1310+ trimmedLine. index ( trimmedLine. startIndex, offsetBy: 9 ) ..< withRange. lowerBound
1311+ ]
12711312 let testCountStr = afterExecuted. split ( separator: " " ) . first
12721313 if let testCountStr = testCountStr, let total = Int ( testCountStr) {
1273- executedTestsCount = total
1314+ xctestExecutedCount = total
12741315 }
12751316
12761317 // Extract failures count
1277- let afterWith = line [ withRange. upperBound... ]
1278- let failuresStr = afterWith. split ( separator: " " ) . first
1279- if let failuresStr = failuresStr, let failures = Int ( failuresStr) {
1280- summaryFailedTestsCount = failures
1318+ let afterWith = String ( trimmedLine [ withRange. upperBound... ] )
1319+ if let failureRange = afterWith. range ( of: " failure " ) {
1320+ let beforeFailure = afterWith [ ..< failureRange. lowerBound]
1321+ let words = beforeFailure. split ( separator: " " )
1322+ if let lastWord = words. last, let failures = Int ( lastWord) {
1323+ xctestFailedCount = failures
1324+ }
12811325 }
12821326
1283- // Extract time - look for " in " followed by time
1284- if let inRange = line. range ( of: " in " , range: withRange. upperBound ..< line. endIndex) {
1285- let afterIn = line [ inRange. upperBound... ]
1286- // Format: "time (seconds) seconds" or "time seconds"
1327+ // Extract TEST time and accumulate
1328+ if let inRange = trimmedLine. range ( of: " in " , range: withRange. upperBound ..< trimmedLine. endIndex) {
1329+ let afterIn = trimmedLine [ inRange. upperBound... ]
12871330 if let parenStart = afterIn. range ( of: " ( " ) {
1288- return String ( afterIn [ ..< parenStart. lowerBound] )
1331+ accumulateTestTime ( String ( afterIn [ ..< parenStart. lowerBound] ) )
12891332 } else if let secondsRange = afterIn. range ( of: " seconds " , options: . backwards) {
1290- return String ( afterIn [ ..< secondsRange. lowerBound] )
1333+ accumulateTestTime ( String ( afterIn [ ..< secondsRange. lowerBound] ) )
12911334 }
12921335 }
1336+ return
12931337 }
12941338
12951339 // Pattern: ✘ Test run with N test(s) failed, N test(s) passed after X seconds.
1296- // Swift Testing failure summary format (check this BEFORE the passed-only pattern)
1340+ // Swift Testing failure summary format - TEST time
12971341 if let testRunRange = line. range ( of: " Test run with " ) ,
12981342 let failedRange = line. range ( of: " failed, " , range: testRunRange. upperBound ..< line. endIndex) ,
12991343 let passedRange = line. range ( of: " passed after " , range: failedRange. upperBound ..< line. endIndex)
13001344 {
1301- // Extract failed count
13021345 let beforeFailed = line [ testRunRange. upperBound ..< failedRange. lowerBound]
13031346 let failedCountStr = beforeFailed. split ( separator: " " ) . first
13041347 if let failedCountStr = failedCountStr, let failedCount = Int ( failedCountStr) {
1305- summaryFailedTestsCount = failedCount
1348+ swiftTestingFailedCount = failedCount
13061349 }
13071350
1308- // Extract passed count (for executedTestsCount calculation)
13091351 let beforePassed = line [ failedRange. upperBound ..< passedRange. lowerBound]
13101352 let passedCountStr = beforePassed. split ( separator: " " ) . first
13111353 if let passedCountStr = passedCountStr, let passedCount = Int ( passedCountStr) ,
1312- let failedCount = summaryFailedTestsCount
1354+ let failedCount = swiftTestingFailedCount
13131355 {
1314- executedTestsCount = passedCount + failedCount
1356+ swiftTestingExecutedCount = passedCount + failedCount
13151357 }
13161358
1317- // Extract time
1359+ // Extract TEST time and accumulate
13181360 let afterPassed = line [ passedRange. upperBound... ]
13191361 if let secondsRange = afterPassed. range ( of: " seconds " , options: . backwards) {
1320- return String ( afterPassed [ ..< secondsRange. lowerBound] )
1362+ accumulateTestTime ( String ( afterPassed [ ..< secondsRange. lowerBound] ) )
1363+ } else {
1364+ accumulateTestTime ( String ( afterPassed) )
13211365 }
1322- return String ( afterPassed ) . trimmingCharacters ( in : CharacterSet ( charactersIn : " . " ) )
1366+ return
13231367 }
13241368
13251369 // Pattern: Test run with N tests in N suites passed after X seconds.
1326- // Note: Swift Testing output may have a Unicode checkmark prefix (e.g., " Test run with...")
1370+ // Swift Testing output format (passed-only case) - TEST time
13271371 if let testRunRange = line. range ( of: " Test run with " ) ,
13281372 let passedAfter = line. range ( of: " passed after " )
13291373 {
13301374 let afterPrefix = line [ testRunRange. upperBound ..< passedAfter. lowerBound]
1331- // Extract test count
13321375 let testCountStr = afterPrefix. split ( separator: " " ) . first
13331376 if let testCountStr = testCountStr, let total = Int ( testCountStr) {
1334- executedTestsCount = total
1335- summaryFailedTestsCount = 0 // All tests passed
1336- }
1337-
1338- // Extract time
1339- let afterPassed = line [ passedAfter. upperBound... ]
1340- if let secondsRange = afterPassed. range ( of: " seconds " , options: . backwards) {
1341- return String ( afterPassed [ ..< secondsRange. lowerBound] )
1377+ swiftTestingExecutedCount = total
1378+ swiftTestingFailedCount = 0
1379+
1380+ // Only accumulate test time if there were actual tests
1381+ if total > 0 {
1382+ let afterPassed = line [ passedAfter. upperBound... ]
1383+ if let secondsRange = afterPassed. range ( of: " seconds " , options: . backwards) {
1384+ accumulateTestTime ( String ( afterPassed [ ..< secondsRange. lowerBound] ) )
1385+ } else {
1386+ accumulateTestTime ( String ( afterPassed) )
1387+ }
1388+ }
13421389 }
1343- // Without " seconds" suffix
1344- return String ( afterPassed) . trimmingCharacters ( in: CharacterSet ( charactersIn: " . " ) )
13451390 }
1391+ }
13461392
1347- return nil
1393+ /// Parses time string and adds to accumulator
1394+ private func accumulateTestTime( _ timeString: String ) {
1395+ // Remove any trailing characters like "." and whitespace
1396+ let cleaned = timeString. trimmingCharacters ( in: CharacterSet ( charactersIn: " . \t " ) )
1397+ if let time = Double ( cleaned) {
1398+ testTimeAccumulator += time
1399+ }
13481400 }
13491401
13501402 // MARK: - Build Phase Parsing
0 commit comments