Skip to content

Commit eb147ae

Browse files
authored
Add separate test_time field and aggregate test counts (#38)
1 parent 26d61b6 commit eb147ae

File tree

7 files changed

+232
-73
lines changed

7 files changed

+232
-73
lines changed

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ The codebase follows a modular architecture:
231231
- Included with `--warnings` flag (no separate flag needed)
232232
- **Linker Error Parsing**: Captures undefined symbols, missing frameworks/libraries, architecture mismatches, and duplicate symbols (with structured conflicting file paths)
233233
- **Test Failure Detection**: XCUnit assertion failures and general test failures
234-
- **Build Time Extraction**: Captures build duration from output
234+
- **Build Time Extraction**: Captures build duration (`build_time`) and test execution time (`test_time`) separately
235235
- **File/Line Mapping**: Extracts precise source locations for navigation
236236
- **Executable Target Detection**: Parses `RegisterWithLaunchServices` lines to extract executable targets (path, name, target)
237237
- **Build Info**: Unified per-target phases, timing, and dependencies
@@ -242,7 +242,7 @@ The codebase follows a modular architecture:
242242
- Supports xcodebuild phase detection from "(in target 'X' from project 'Y')" patterns
243243
- Supports SPM phase detection from "[N/M] Compiling/Linking TARGET" patterns
244244
- Parses "Build target X (Ys)" and "** BUILD SUCCEEDED ** [Xs]" patterns
245-
- Total build time always in `summary.build_time` (not duplicated in build_info)
245+
- Build time in `summary.build_time`, test execution time in `summary.test_time` (not duplicated in build_info)
246246
- xcodebuild phases: `CompileSwiftSources`, `SwiftCompilation`, `CompileC`, `Link`, `CopySwiftLibs`, `PhaseScriptExecution`, `LinkAssetCatalog`, `ProcessInfoPlistFile`
247247
- SPM phases: `Compiling`, `Linking`
248248
- **Dependency Graph**: Extracts target dependencies from xcodebuild "Target dependency graph" output
@@ -520,7 +520,7 @@ The tool outputs structured data optimized for coding agents in two formats:
520520
- Groups phases by target with per-target timing
521521
- Parses target dependencies from xcodebuild "Target dependency graph" output
522522
- **Slowest targets**: Top 5 targets sorted by duration (descending)
523-
- Total build time is in `summary.build_time` (not duplicated in build_info)
523+
- Build time is in `summary.build_time`, test execution time is in `summary.test_time` (not duplicated in build_info)
524524
- xcodebuild phases: `CompileSwiftSources`, `SwiftCompilation`, `CompileC`, `Link`, `CopySwiftLibs`, `PhaseScriptExecution`, `LinkAssetCatalog`, `ProcessInfoPlistFile`
525525
- SPM phases: `Compiling`, `Linking`
526526
- Empty fields are omitted (targets without phases won't have `phases` field, targets without dependencies won't have `depends_on` field, no `slowest_targets` when empty)
@@ -546,6 +546,7 @@ summary:
546546
failed_tests: 0
547547
passed_tests: null
548548
build_time: null
549+
test_time: null
549550
coverage_percent: null
550551
errors[1]{file,line,message}:
551552
main.swift,15,"use of undeclared identifier \"unknown\""

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ xcodebuild build 2>&1 | xcsift --config ~/my-config.toml # Use custom config
190190
"linker_errors": 0,
191191
"passed_tests": 28,
192192
"build_time": "3.2",
193+
"test_time": "5.0",
193194
"coverage_percent": 85.5
194195
},
195196
"errors": [
@@ -328,7 +329,7 @@ xcodebuild build 2>&1 | xcsift --config ~/my-config.toml # Use custom config
328329
```
329330
- Groups phases by target with per-target timing
330331
- **Slowest targets**: Top 5 targets sorted by duration (descending)
331-
- Total build time is always in `summary.build_time` (not duplicated in build_info)
332+
- Build time is in `summary.build_time`, test execution time is in `summary.test_time`
332333
- Parses xcodebuild timing from "Build target X (Ys)" patterns
333334
- xcodebuild phases: `CompileSwiftSources`, `SwiftCompilation`, `CompileC`, `Link`, `CopySwiftLibs`, `PhaseScriptExecution`, `LinkAssetCatalog`, `ProcessInfoPlistFile`
334335
- SPM phases: `Compiling`, `Linking`

Sources/Models.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ struct BuildSummary: Codable {
261261
let linkerErrors: Int
262262
let passedTests: Int?
263263
let buildTime: String?
264+
let testTime: String?
264265
let coveragePercent: Double?
265266
let slowTests: Int?
266267
let flakyTests: Int?
@@ -273,6 +274,7 @@ struct BuildSummary: Codable {
273274
case linkerErrors = "linker_errors"
274275
case passedTests = "passed_tests"
275276
case buildTime = "build_time"
277+
case testTime = "test_time"
276278
case coveragePercent = "coverage_percent"
277279
case slowTests = "slow_tests"
278280
case flakyTests = "flaky_tests"
@@ -286,6 +288,7 @@ struct BuildSummary: Codable {
286288
linkerErrors: Int = 0,
287289
passedTests: Int?,
288290
buildTime: String?,
291+
testTime: String? = nil,
289292
coveragePercent: Double?,
290293
slowTests: Int? = nil,
291294
flakyTests: Int? = nil,
@@ -297,6 +300,7 @@ struct BuildSummary: Codable {
297300
self.linkerErrors = linkerErrors
298301
self.passedTests = passedTests
299302
self.buildTime = buildTime
303+
self.testTime = testTime
300304
self.coveragePercent = coveragePercent
301305
self.slowTests = slowTests
302306
self.flakyTests = flakyTests
@@ -317,6 +321,9 @@ struct BuildSummary: Codable {
317321
if let buildTime = buildTime {
318322
try container.encode(buildTime, forKey: .buildTime)
319323
}
324+
if let testTime = testTime {
325+
try container.encode(testTime, forKey: .testTime)
326+
}
320327
if let coveragePercent = coveragePercent {
321328
try container.encode(coveragePercent, forKey: .coveragePercent)
322329
}

Sources/OutputParser.swift

Lines changed: 114 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)