Skip to content

Commit 850fa34

Browse files
Support Swift Testing (#56)
1 parent d8b38dc commit 850fa34

File tree

2 files changed

+192
-34
lines changed

2 files changed

+192
-34
lines changed

Sources/OutputParser.swift

Lines changed: 130 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,8 @@ class OutputParser {
696696
|| line.contains("") || line.contains("") || line.contains("") || line.contains("Build succeeded")
697697
|| line.contains("Build failed") || line.contains("Executed") || line.contains("] Testing ")
698698
|| line.contains("BUILD SUCCEEDED") || line.contains("BUILD FAILED") || line.contains("TEST FAILED")
699-
|| line.contains("Build complete!") || line.hasPrefix("RegisterWithLaunchServices")
699+
|| line.contains("Build complete!") || line.contains("recorded an issue")
700+
|| line.hasPrefix("RegisterWithLaunchServices")
700701
|| line.hasPrefix("Validate") || line.contains("Fatal error")
701702
|| (line.hasPrefix("/") && line.contains(".swift:")) // runtime warnings
702703

@@ -896,6 +897,43 @@ class OutputParser {
896897
return testName
897898
}
898899

900+
// MARK: - Swift Testing Name Extraction
901+
902+
/// Extracts test name from Swift Testing output line.
903+
/// Handles both formats:
904+
/// - With displayName: `Test "Human readable name" ...`
905+
/// - Without displayName: `Test functionName() ...`
906+
///
907+
/// Returns: (testName, endIndex) or nil if not found
908+
private func extractSwiftTestingName(
909+
from line: String,
910+
after startIndex: String.Index
911+
) -> (name: String, endIndex: String.Index)? {
912+
let afterTest = line[startIndex...]
913+
914+
// Format 1: With quotes (displayName)
915+
if afterTest.hasPrefix("\"") {
916+
let nameStart = line.index(after: startIndex)
917+
if let quoteEnd = line[nameStart...].firstIndex(of: "\"") {
918+
let name = String(line[nameStart ..< quoteEnd])
919+
return (name, line.index(after: quoteEnd))
920+
}
921+
}
922+
923+
// Format 2: Without quotes (function name)
924+
// Find end markers: " recorded", " failed", " passed", " started"
925+
let endMarkers = [" recorded", " failed", " passed", " started"]
926+
for marker in endMarkers {
927+
if let markerRange = afterTest.range(of: marker) {
928+
let name = String(line[startIndex ..< markerRange.lowerBound])
929+
.trimmingCharacters(in: .whitespaces)
930+
return (name, markerRange.lowerBound)
931+
}
932+
}
933+
934+
return nil
935+
}
936+
899937
private func hasSeenSimilarTest(_ normalizedTestName: String) -> Bool {
900938
return seenTestNames.contains(normalizedTestName)
901939
}
@@ -1310,41 +1348,60 @@ class OutputParser {
13101348
return FailedTest(test: test, message: message, file: nil, line: nil, duration: duration)
13111349
}
13121350

1313-
// Pattern: ✘ Test "name" recorded an issue at file:line:column: message
1314-
if line.hasPrefix("✘ Test \""), let issueAt = line.range(of: "\" recorded an issue at ") {
1315-
let startIndex = line.index(line.startIndex, offsetBy: 8)
1316-
let test = String(line[startIndex ..< issueAt.lowerBound])
1317-
let afterIssue = String(line[issueAt.upperBound...])
1318-
1319-
// Parse file:line:column: message
1320-
let parts = afterIssue.split(separator: ":", maxSplits: 3, omittingEmptySubsequences: false)
1321-
if parts.count >= 4, let lineNum = Int(parts[1]) {
1322-
let file = String(parts[0])
1323-
let message = String(parts[3]).trimmingCharacters(in: .whitespaces)
1324-
return FailedTest(test: test, message: message, file: file, line: lineNum)
1325-
}
1326-
}
1327-
1328-
// Pattern: ✘ Test "name" failed after 0.123 seconds with N issues.
1329-
if line.hasPrefix("✘ Test \""), let failedAfter = line.range(of: "\" failed after ") {
1330-
let startIndex = line.index(line.startIndex, offsetBy: 8)
1331-
let test = String(line[startIndex ..< failedAfter.lowerBound])
1332-
1333-
// Extract duration from "failed after X.XXX seconds"
1334-
var duration: Double? = nil
1335-
let afterStr = line[failedAfter.upperBound...]
1336-
if let secondsRange = afterStr.range(of: " seconds") {
1337-
let durationStr = String(afterStr[..<secondsRange.lowerBound])
1338-
duration = Double(durationStr)
1339-
}
1351+
// Swift Testing: Test {name} recorded an issue / failed after
1352+
// Supports: any leading symbol (✘, 􀢄, ◇, etc.), with or without quotes
1353+
// Formats:
1354+
// - Test "Human readable name" recorded an issue at file:line:col: message
1355+
// - Test functionName() recorded an issue at file:line:col: message
1356+
// - Test "name" failed after 0.123 seconds [with N issues]
1357+
// - Test name() failed after 0.123 seconds [with N issues]
1358+
if let testStart = line.range(of: "Test ") {
1359+
// Skip "Test run with" (summary line) and "Test Case" (XCTest format)
1360+
let beforeTest = line[..<testStart.lowerBound]
1361+
let afterTestStr = line[testStart.upperBound...]
1362+
1363+
if !afterTestStr.hasPrefix("run with ") && !afterTestStr.hasPrefix("Case ") {
1364+
if let (testName, nameEnd) = extractSwiftTestingName(from: line, after: testStart.upperBound) {
1365+
let afterName = line[nameEnd...]
1366+
1367+
// Pattern: recorded an issue at file:line:col: message
1368+
if let issueAt = afterName.range(of: " recorded an issue at ") {
1369+
let afterIssue = String(line[issueAt.upperBound...])
1370+
1371+
// Parse file:line:column: message
1372+
let parts = afterIssue.split(separator: ":", maxSplits: 3, omittingEmptySubsequences: false)
1373+
if parts.count >= 4, let lineNum = Int(parts[1]) {
1374+
let file = String(parts[0])
1375+
let message = String(parts[3]).trimmingCharacters(in: .whitespaces)
1376+
return FailedTest(test: testName, message: message, file: file, line: lineNum)
1377+
}
1378+
}
13401379

1341-
// Track duration for slow test detection
1342-
let normalizedTest = normalizeTestName(test)
1343-
if let dur = duration {
1344-
failedTestDurations[normalizedTest] = dur
1380+
// Pattern: failed after X.XXX seconds [with N issue(s)]
1381+
if let failedAfter = afterName.range(of: " failed after ") {
1382+
var duration: Double? = nil
1383+
let afterFailed = line[failedAfter.upperBound...]
1384+
if let secondsRange = afterFailed.range(of: " seconds") {
1385+
let durationStr = String(afterFailed[..<secondsRange.lowerBound])
1386+
duration = Double(durationStr)
1387+
}
1388+
1389+
// Track duration for slow test detection
1390+
let normalizedTest = normalizeTestName(testName)
1391+
if let dur = duration {
1392+
failedTestDurations[normalizedTest] = dur
1393+
}
1394+
1395+
return FailedTest(
1396+
test: testName,
1397+
message: "Test failed",
1398+
file: nil,
1399+
line: nil,
1400+
duration: duration
1401+
)
1402+
}
1403+
}
13451404
}
1346-
1347-
return FailedTest(test: test, message: "Test failed", file: nil, line: nil, duration: duration)
13481405
}
13491406

13501407
// Pattern: ❌ testname (message)
@@ -1476,6 +1533,45 @@ class OutputParser {
14761533
return
14771534
}
14781535

1536+
// Pattern: Test run with N test(s) in M suite(s) failed after X seconds with Y issue(s).
1537+
// Swift Testing failure summary format - TEST time
1538+
if let testRunRange = line.range(of: "Test run with "),
1539+
let failedAfterRange = line.range(of: " failed after ", range: testRunRange.upperBound ..< line.endIndex)
1540+
{
1541+
let beforeFailed = line[testRunRange.upperBound ..< failedAfterRange.lowerBound]
1542+
let totalCountStr = beforeFailed.split(separator: " ").first
1543+
if let totalCountStr = totalCountStr, let totalCount = Int(totalCountStr) {
1544+
swiftTestingExecutedCount = totalCount
1545+
}
1546+
1547+
// Extract TEST time and accumulate
1548+
let afterFailed = line[failedAfterRange.upperBound...]
1549+
if let secondsRange = afterFailed.range(of: " seconds", options: .backwards) {
1550+
accumulateTestTime(String(afterFailed[..<secondsRange.lowerBound]))
1551+
1552+
// Extract issue count (if present) from the trailing "with Y issue(s)"
1553+
let afterSeconds = afterFailed[secondsRange.upperBound...]
1554+
if let withRange = afterSeconds.range(of: " with "),
1555+
let issueRange = afterSeconds.range(
1556+
of: " issue",
1557+
range: withRange.upperBound ..< afterSeconds.endIndex
1558+
)
1559+
{
1560+
let issueCountStr = afterSeconds[withRange.upperBound ..< issueRange.lowerBound]
1561+
if let issueCount = Int(issueCountStr.trimmingCharacters(in: .whitespaces)) {
1562+
if let totalCount = swiftTestingExecutedCount {
1563+
swiftTestingFailedCount = min(issueCount, totalCount)
1564+
} else {
1565+
swiftTestingFailedCount = issueCount
1566+
}
1567+
}
1568+
}
1569+
} else {
1570+
accumulateTestTime(String(afterFailed))
1571+
}
1572+
return
1573+
}
1574+
14791575
// Pattern: Test run with N tests in N suites passed after X seconds.
14801576
// Swift Testing output format (passed-only case) - TEST time
14811577
if let testRunRange = line.range(of: "Test run with "),

Tests/ParsingTests.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,68 @@ final class ParsingTests: XCTestCase {
779779
XCTAssertEqual(result.summary.testTime, "0.050s")
780780
}
781781

782+
func testSwiftTesting() {
783+
let parser = OutputParser()
784+
let input = """
785+
􀟈 Test shouldPass() started.
786+
􀟈 Test shouldFail() started.
787+
􁁛 Test shouldPass() passed after 0.001 seconds.
788+
􀢄 Test shouldFail() recorded an issue at xcsift_problemsTests.swift:9:5: Expectation failed: Bool(false)
789+
􀢄 Test shouldFail() failed after 0.001 seconds with 1 issue.
790+
􀢄 Test run with 2 tests in 0 suites failed after 0.001 seconds with 1 issue.
791+
792+
"""
793+
794+
let result = parser.parse(input: input)
795+
796+
XCTAssertEqual(result.status, "failed")
797+
XCTAssertEqual(result.summary.passedTests, 1)
798+
XCTAssertEqual(result.summary.failedTests, 1)
799+
XCTAssertEqual(result.failedTests.count, 1)
800+
XCTAssertEqual(result.failedTests[0].test, "shouldFail()")
801+
XCTAssertEqual(result.failedTests[0].message, "Expectation failed: Bool(false)")
802+
XCTAssertEqual(result.failedTests[0].file, "xcsift_problemsTests.swift")
803+
XCTAssertEqual(result.failedTests[0].line, 9)
804+
XCTAssertEqual(result.failedTests[0].duration, 0.001)
805+
}
806+
807+
func testSwiftTestingWithQuotes() {
808+
let parser = OutputParser()
809+
let input = """
810+
✘ Test "Food truck exists" recorded an issue at FoodTruckTests.swift:15:5: Assertion failed
811+
✘ Test "Food truck exists" failed after 0.002 seconds with 1 issue.
812+
"""
813+
814+
let result = parser.parse(input: input)
815+
816+
XCTAssertEqual(result.status, "failed")
817+
XCTAssertEqual(result.failedTests.count, 1)
818+
XCTAssertEqual(result.failedTests[0].test, "Food truck exists")
819+
XCTAssertEqual(result.failedTests[0].message, "Assertion failed")
820+
XCTAssertEqual(result.failedTests[0].file, "FoodTruckTests.swift")
821+
XCTAssertEqual(result.failedTests[0].line, 15)
822+
}
823+
824+
func testSwiftTestingMixedFormats() {
825+
// Test that both quoted and unquoted formats work together
826+
let parser = OutputParser()
827+
let input = """
828+
✘ Test "Human readable test" recorded an issue at Tests.swift:10:3: First failure
829+
✘ Test "Human readable test" failed after 0.001 seconds with 1 issue.
830+
􀢄 Test functionTest() recorded an issue at Tests.swift:20:5: Second failure
831+
􀢄 Test functionTest() failed after 0.002 seconds with 1 issue.
832+
"""
833+
834+
let result = parser.parse(input: input)
835+
836+
XCTAssertEqual(result.status, "failed")
837+
XCTAssertEqual(result.failedTests.count, 2)
838+
XCTAssertEqual(result.failedTests[0].test, "Human readable test")
839+
XCTAssertEqual(result.failedTests[0].message, "First failure")
840+
XCTAssertEqual(result.failedTests[1].test, "functionTest()")
841+
XCTAssertEqual(result.failedTests[1].message, "Second failure")
842+
}
843+
782844
// MARK: - Test Duration Parsing
783845

784846
func testParseDurationFromXCTestPassed() {

0 commit comments

Comments
 (0)