Skip to content
4 changes: 4 additions & 0 deletions Sources/Testing/Issues/Issue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ public struct Issue: Sendable {

/// The kind of issue this value represents.
public var kind: Kind

/// Whether or not the issue is recorded after its associated test has
/// finished.
var isLate: Bool = false

/// An enumeration representing the level of severity of a recorded issue.
///
Expand Down
59 changes: 59 additions & 0 deletions Sources/Testing/Running/Runner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,13 @@ extension Runner {
public func run() async {
await Self._run(self)
}

/// An enumeration describing a test or test case that has already finished
/// running.
private enum _FinishedItem: Sendable, Equatable, Hashable {
case test(Test.ID)
case testCase(Test.ID, Test.Case.ID)
}

/// Run the tests in a runner's plan with a given configuration.
///
Expand All @@ -454,6 +461,58 @@ extension Runner {
#endif
_ = Event.installFallbackEventHandler()

// Track whether or not an issue is recorded after its test ends.
let finishedItems = Allocated(Mutex<Set<_FinishedItem>>([]))
runner.configuration.eventHandler = { [oldEventHandler = runner.configuration.eventHandler] event, context in
defer {
oldEventHandler(event, context)
}

guard let testID = event.testID, let testCaseID = event.testCaseID else {
return
}

switch event.kind {
case .testEnded:
finishedItems.value.withLock { finishedItems in
_ = finishedItems.insert(.test(testID))
}
case .testCaseEnded:
finishedItems.value.withLock { finishedItems in
_ = finishedItems.insert(.testCase(testID, testCaseID))
}
case .issueRecorded(let issue):
guard !issue.isLate else { break }

let shouldRecordIssue = finishedItems.value.withLock { finishedItems in
let testCaseFinished = finishedItems.contains(
.testCase(testID, testCaseID)
)
let testFinished = finishedItems.contains(.test(testID))
return testCaseFinished || testFinished
}

guard shouldRecordIssue else {
break
}

var lateRecordedIssue = Issue(
kind: .apiMisused,
comments: [
"""
An issue was recorded after its associated test ended. Ensure \
asynchronous work has completed before your test ends.
"""
],
sourceContext: issue.sourceContext
)
lateRecordedIssue.isLate = true
Event.post(.issueRecorded(lateRecordedIssue))
default:
break
}
}

// Track whether or not any issues were recorded across the entire run.
let issueRecorded = Atomic(false)
runner.configuration.eventHandler = { [eventHandler = runner.configuration.eventHandler] event, context in
Expand Down
60 changes: 60 additions & 0 deletions Tests/TestingTests/EventRecorderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,66 @@ struct EventRecorderTests {
)
}

@Test("An additional late issue is recorded for issues recorded after test case end")
func lateIssueRecordedAfterTestCaseEnd() async throws {
var configuration = Configuration()
let recordedIssues = Mutex<[Issue]>([])
configuration.eventHandler = { event, _ in
guard case let .issueRecorded(issue) = event.kind else { return }
recordedIssues.withLock {
$0.append(issue)
}
}

let recordIssueLateTask = Mutex<Task<Void, Never>?>(nil)
await Test {
recordIssueLateTask.withLock {
$0 = Task {
try? await Task.sleep(for: .milliseconds(10))
Issue.record("Late")
}
}
}.run(configuration: configuration)

let lateTask = recordIssueLateTask.withLock { $0 }
await lateTask?.value

let issues = recordedIssues.rawValue
#expect(issues.count == 2)
let lateIssue = try #require(issues.first)
let originalIssue = try #require(issues.last)
guard case .unconditional = originalIssue.kind else {
Issue.record(
"Unexpected issue kind \(originalIssue.kind)"
)
return
}

guard case .apiMisused = lateIssue.kind else {
Issue.record(
"Unexpected issue kind \(originalIssue.kind)"
)
return
}

let lateIssueFirstComment = try #require(
lateIssue.comments.first,
"Late issue should have a comment"
)

#expect(lateIssueFirstComment.rawValue ==
"""
An issue was recorded after its associated test ended. Ensure \
asynchronous work has completed before your test ends.
"""
)

let originalIssueSnapshot = Issue.Snapshot(snapshotting: originalIssue)
let lateIssueSnapshot = Issue.Snapshot(snapshotting: lateIssue)
#expect(originalIssueSnapshot.sourceContext == lateIssueSnapshot.sourceContext)
#expect(originalIssueSnapshot.sourceLocation == lateIssueSnapshot.sourceLocation)
}

@Test("JUnitXMLRecorder counts issues without associated tests")
func junitRecorderCountsIssuesWithoutTests() async throws {
let issue = Issue(kind: .unconditional)
Expand Down