Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 130 additions & 34 deletions Sources/OutputParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -896,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)
}
Expand Down Expand Up @@ -1310,41 +1348,60 @@ 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" 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[..<secondsRange.lowerBound])
duration = Double(durationStr)
}
// Swift Testing: Test {name} recorded an issue / failed after
// Supports: any leading symbol (✘, 􀢄, ◇, etc.), with or without quotes
// Formats:
// - Test "Human readable name" recorded an issue at file:line:col: message
// - Test functionName() recorded an issue at file:line:col: message
// - Test "name" failed after 0.123 seconds [with N issues]
// - Test name() failed after 0.123 seconds [with N issues]
if let testStart = line.range(of: "Test ") {
// Skip "Test run with" (summary line) and "Test Case" (XCTest format)
let beforeTest = line[..<testStart.lowerBound]
let afterTestStr = line[testStart.upperBound...]

if !afterTestStr.hasPrefix("run with ") && !afterTestStr.hasPrefix("Case ") {
if let (testName, nameEnd) = extractSwiftTestingName(from: line, after: testStart.upperBound) {
let afterName = line[nameEnd...]

// Pattern: recorded an issue at file:line:col: message
if let issueAt = afterName.range(of: " recorded an issue at ") {
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: testName, message: message, file: file, line: lineNum)
}
}

// Track duration for slow test detection
let normalizedTest = normalizeTestName(test)
if let dur = duration {
failedTestDurations[normalizedTest] = dur
// 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[..<secondsRange.lowerBound])
duration = Double(durationStr)
}

// Track duration for slow test detection
let normalizedTest = normalizeTestName(testName)
if let dur = duration {
failedTestDurations[normalizedTest] = dur
}

return FailedTest(
test: testName,
message: "Test failed",
file: nil,
line: nil,
duration: duration
)
}
}
}

return FailedTest(test: test, message: "Test failed", file: nil, line: nil, duration: duration)
}

// Pattern: ❌ testname (message)
Expand Down Expand Up @@ -1476,6 +1533,45 @@ class OutputParser {
return
}

// Pattern: Test run with N test(s) in M suite(s) failed after X seconds with Y issue(s).
// Swift Testing failure summary format - TEST time
if let testRunRange = line.range(of: "Test run with "),
let failedAfterRange = line.range(of: " failed after ", range: testRunRange.upperBound ..< line.endIndex)
{
let beforeFailed = line[testRunRange.upperBound ..< failedAfterRange.lowerBound]
let totalCountStr = beforeFailed.split(separator: " ").first
if let totalCountStr = totalCountStr, let totalCount = Int(totalCountStr) {
swiftTestingExecutedCount = totalCount
}

// Extract TEST time and accumulate
let afterFailed = line[failedAfterRange.upperBound...]
if let secondsRange = afterFailed.range(of: " seconds", options: .backwards) {
accumulateTestTime(String(afterFailed[..<secondsRange.lowerBound]))

// Extract issue count (if present) from the trailing "with Y issue(s)"
let afterSeconds = afterFailed[secondsRange.upperBound...]
if let withRange = afterSeconds.range(of: " with "),
let issueRange = afterSeconds.range(
of: " issue",
range: withRange.upperBound ..< afterSeconds.endIndex
)
{
let issueCountStr = afterSeconds[withRange.upperBound ..< issueRange.lowerBound]
if let issueCount = Int(issueCountStr.trimmingCharacters(in: .whitespaces)) {
if let totalCount = swiftTestingExecutedCount {
swiftTestingFailedCount = min(issueCount, totalCount)
} else {
swiftTestingFailedCount = issueCount
}
}
}
} else {
accumulateTestTime(String(afterFailed))
}
return
}

// Pattern: Test run with N tests in N suites passed after X seconds.
// Swift Testing output format (passed-only case) - TEST time
if let testRunRange = line.range(of: "Test run with "),
Expand Down
62 changes: 62 additions & 0 deletions Tests/ParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,68 @@ final class ParsingTests: XCTestCase {
XCTAssertEqual(result.summary.testTime, "0.050s")
}

func testSwiftTesting() {
let parser = OutputParser()
let input = """
􀟈 Test shouldPass() started.
􀟈 Test shouldFail() started.
􁁛 Test shouldPass() passed after 0.001 seconds.
􀢄 Test shouldFail() recorded an issue at xcsift_problemsTests.swift:9:5: Expectation failed: Bool(false)
􀢄 Test shouldFail() failed after 0.001 seconds with 1 issue.
􀢄 Test run with 2 tests in 0 suites failed after 0.001 seconds with 1 issue.

"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "failed")
XCTAssertEqual(result.summary.passedTests, 1)
XCTAssertEqual(result.summary.failedTests, 1)
XCTAssertEqual(result.failedTests.count, 1)
XCTAssertEqual(result.failedTests[0].test, "shouldFail()")
XCTAssertEqual(result.failedTests[0].message, "Expectation failed: Bool(false)")
XCTAssertEqual(result.failedTests[0].file, "xcsift_problemsTests.swift")
XCTAssertEqual(result.failedTests[0].line, 9)
XCTAssertEqual(result.failedTests[0].duration, 0.001)
}

func testSwiftTestingWithQuotes() {
let parser = OutputParser()
let input = """
✘ Test "Food truck exists" recorded an issue at FoodTruckTests.swift:15:5: Assertion failed
✘ Test "Food truck exists" failed after 0.002 seconds with 1 issue.
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "failed")
XCTAssertEqual(result.failedTests.count, 1)
XCTAssertEqual(result.failedTests[0].test, "Food truck exists")
XCTAssertEqual(result.failedTests[0].message, "Assertion failed")
XCTAssertEqual(result.failedTests[0].file, "FoodTruckTests.swift")
XCTAssertEqual(result.failedTests[0].line, 15)
}

func testSwiftTestingMixedFormats() {
// Test that both quoted and unquoted formats work together
let parser = OutputParser()
let input = """
✘ Test "Human readable test" recorded an issue at Tests.swift:10:3: First failure
✘ Test "Human readable test" failed after 0.001 seconds with 1 issue.
􀢄 Test functionTest() recorded an issue at Tests.swift:20:5: Second failure
􀢄 Test functionTest() failed after 0.002 seconds with 1 issue.
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "failed")
XCTAssertEqual(result.failedTests.count, 2)
XCTAssertEqual(result.failedTests[0].test, "Human readable test")
XCTAssertEqual(result.failedTests[0].message, "First failure")
XCTAssertEqual(result.failedTests[1].test, "functionTest()")
XCTAssertEqual(result.failedTests[1].message, "Second failure")
}

// MARK: - Test Duration Parsing

func testParseDurationFromXCTestPassed() {
Expand Down