From 5ecf93f72959dbaf10522c74d1f6f430bdc1ffb1 Mon Sep 17 00:00:00 2001 From: Jeremy Bannister Date: Tue, 3 Feb 2026 18:19:15 +0100 Subject: [PATCH 1/2] feat: support Swift Testing --- Sources/OutputParser.swift | 85 +++++++++++++++++++++++++++++++++++++- Tests/ParsingTests.swift | 25 +++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/Sources/OutputParser.swift b/Sources/OutputParser.swift index edaec18..057d6d5 100644 --- a/Sources/OutputParser.swift +++ b/Sources/OutputParser.swift @@ -696,7 +696,8 @@ class OutputParser { || line.contains("✘") || line.contains("✓") || line.contains("❌") || line.contains("Build succeeded") || line.contains("Build failed") || line.contains("Executed") || line.contains("] Testing ") || line.contains("BUILD SUCCEEDED") || line.contains("BUILD FAILED") || line.contains("TEST FAILED") - || line.contains("Build complete!") || line.hasPrefix("RegisterWithLaunchServices") + || line.contains("Build complete!") || line.contains("recorded an issue") + || line.hasPrefix("RegisterWithLaunchServices") || line.hasPrefix("Validate") || line.contains("Fatal error") || (line.hasPrefix("/") && line.contains(".swift:")) // runtime warnings @@ -1325,6 +1326,25 @@ class OutputParser { } } + // Pattern: Test name() recorded an issue at file:line:column: message (Swift Testing, no quotes, leading glyphs) + if let testStart = line.range(of: "Test "), + let issueAt = line.range(of: " recorded an issue at ", range: testStart.upperBound ..< line.endIndex) + { + let afterTest = line[testStart.upperBound...] + if !afterTest.hasPrefix("run with ") { + let test = String(line[testStart.upperBound ..< issueAt.lowerBound]).trimmingCharacters(in: .whitespaces) + let afterIssue = String(line[issueAt.upperBound...]) + + // Parse file:line:column: message + let parts = afterIssue.split(separator: ":", maxSplits: 3, omittingEmptySubsequences: false) + if parts.count >= 4, let lineNum = Int(parts[1]) { + let file = String(parts[0]) + let message = String(parts[3]).trimmingCharacters(in: .whitespaces) + return FailedTest(test: test, message: message, file: file, line: lineNum) + } + } + } + // Pattern: ✘ Test "name" failed after 0.123 seconds with N issues. if line.hasPrefix("✘ Test \""), let failedAfter = line.range(of: "\" failed after ") { let startIndex = line.index(line.startIndex, offsetBy: 8) @@ -1347,6 +1367,33 @@ class OutputParser { return FailedTest(test: test, message: "Test failed", file: nil, line: nil, duration: duration) } + // Pattern: Test name() failed after 0.123 seconds with N issues. (Swift Testing, no quotes, leading glyphs) + if let testStart = line.range(of: "Test "), + let failedAfter = line.range(of: " failed after ", range: testStart.upperBound ..< line.endIndex) + { + let afterTest = line[testStart.upperBound...] + if !afterTest.hasPrefix("run with ") { + let test = String(line[testStart.upperBound ..< failedAfter.lowerBound]) + .trimmingCharacters(in: .whitespaces) + + // Extract duration from "failed after X.XXX seconds" + var duration: Double? = nil + let afterStr = line[failedAfter.upperBound...] + if let secondsRange = afterStr.range(of: " seconds") { + let durationStr = String(afterStr[.. Date: Wed, 4 Feb 2026 01:02:23 +0500 Subject: [PATCH 2/2] refactor: Unify Swift Testing parsing patterns - Add extractSwiftTestingName() helper function that handles both: - Quoted format: Test "Human readable name" ... - Unquoted format: Test functionName() ... - Replace 4 separate parsing blocks (~80 lines) with 1 universal block that uses the helper function - Add tests for quoted format and mixed format scenarios - Remove code duplication while maintaining full backward compatibility Co-Authored-By: Claude Opus 4.5 --- Sources/OutputParser.swift | 167 ++++++++++++++++++++----------------- Tests/ParsingTests.swift | 37 ++++++++ 2 files changed, 127 insertions(+), 77 deletions(-) diff --git a/Sources/OutputParser.swift b/Sources/OutputParser.swift index 057d6d5..568f136 100644 --- a/Sources/OutputParser.swift +++ b/Sources/OutputParser.swift @@ -897,6 +897,43 @@ class OutputParser { return testName } + // MARK: - Swift Testing Name Extraction + + /// Extracts test name from Swift Testing output line. + /// Handles both formats: + /// - With displayName: `Test "Human readable name" ...` + /// - Without displayName: `Test functionName() ...` + /// + /// Returns: (testName, endIndex) or nil if not found + private func extractSwiftTestingName( + from line: String, + after startIndex: String.Index + ) -> (name: String, endIndex: String.Index)? { + let afterTest = line[startIndex...] + + // Format 1: With quotes (displayName) + if afterTest.hasPrefix("\"") { + let nameStart = line.index(after: startIndex) + if let quoteEnd = line[nameStart...].firstIndex(of: "\"") { + let name = String(line[nameStart ..< quoteEnd]) + return (name, line.index(after: quoteEnd)) + } + } + + // Format 2: Without quotes (function name) + // Find end markers: " recorded", " failed", " passed", " started" + let endMarkers = [" recorded", " failed", " passed", " started"] + for marker in endMarkers { + if let markerRange = afterTest.range(of: marker) { + let name = String(line[startIndex ..< markerRange.lowerBound]) + .trimmingCharacters(in: .whitespaces) + return (name, markerRange.lowerBound) + } + } + + return nil + } + private func hasSeenSimilarTest(_ normalizedTestName: String) -> Bool { return seenTestNames.contains(normalizedTestName) } @@ -1311,86 +1348,59 @@ class OutputParser { return FailedTest(test: test, message: message, file: nil, line: nil, duration: duration) } - // Pattern: ✘ Test "name" recorded an issue at file:line:column: message - if line.hasPrefix("✘ Test \""), let issueAt = line.range(of: "\" recorded an issue at ") { - let startIndex = line.index(line.startIndex, offsetBy: 8) - let test = String(line[startIndex ..< issueAt.lowerBound]) - let afterIssue = String(line[issueAt.upperBound...]) - - // Parse file:line:column: message - let parts = afterIssue.split(separator: ":", maxSplits: 3, omittingEmptySubsequences: false) - if parts.count >= 4, let lineNum = Int(parts[1]) { - let file = String(parts[0]) - let message = String(parts[3]).trimmingCharacters(in: .whitespaces) - return FailedTest(test: test, message: message, file: file, line: lineNum) - } - } - - // Pattern: Test name() recorded an issue at file:line:column: message (Swift Testing, no quotes, leading glyphs) - if let testStart = line.range(of: "Test "), - let issueAt = line.range(of: " recorded an issue at ", range: testStart.upperBound ..< line.endIndex) - { - let afterTest = line[testStart.upperBound...] - if !afterTest.hasPrefix("run with ") { - let test = String(line[testStart.upperBound ..< issueAt.lowerBound]).trimmingCharacters(in: .whitespaces) - let afterIssue = String(line[issueAt.upperBound...]) - - // Parse file:line:column: message - let parts = afterIssue.split(separator: ":", maxSplits: 3, omittingEmptySubsequences: false) - if parts.count >= 4, let lineNum = Int(parts[1]) { - let file = String(parts[0]) - let message = String(parts[3]).trimmingCharacters(in: .whitespaces) - return FailedTest(test: test, message: message, file: file, line: lineNum) - } - } - } - - // Pattern: ✘ Test "name" failed after 0.123 seconds with N issues. - if line.hasPrefix("✘ Test \""), let failedAfter = line.range(of: "\" failed after ") { - let startIndex = line.index(line.startIndex, offsetBy: 8) - let test = String(line[startIndex ..< failedAfter.lowerBound]) - - // Extract duration from "failed after X.XXX seconds" - var duration: Double? = nil - let afterStr = line[failedAfter.upperBound...] - if let secondsRange = afterStr.range(of: " seconds") { - let durationStr = String(afterStr[..= 4, let lineNum = Int(parts[1]) { + let file = String(parts[0]) + let message = String(parts[3]).trimmingCharacters(in: .whitespaces) + return FailedTest(test: testName, message: message, file: file, line: lineNum) + } + } - // Pattern: Test name() failed after 0.123 seconds with N issues. (Swift Testing, no quotes, leading glyphs) - if let testStart = line.range(of: "Test "), - let failedAfter = line.range(of: " failed after ", range: testStart.upperBound ..< line.endIndex) - { - let afterTest = line[testStart.upperBound...] - if !afterTest.hasPrefix("run with ") { - let test = String(line[testStart.upperBound ..< failedAfter.lowerBound]) - .trimmingCharacters(in: .whitespaces) + // Pattern: failed after X.XXX seconds [with N issue(s)] + if let failedAfter = afterName.range(of: " failed after ") { + var duration: Double? = nil + let afterFailed = line[failedAfter.upperBound...] + if let secondsRange = afterFailed.range(of: " seconds") { + let durationStr = String(afterFailed[..