diff --git a/Sources/Testing/Issues/Issue.swift b/Sources/Testing/Issues/Issue.swift index 9688ff929..38988afc4 100644 --- a/Sources/Testing/Issues/Issue.swift +++ b/Sources/Testing/Issues/Issue.swift @@ -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. /// diff --git a/Sources/Testing/Running/Runner.swift b/Sources/Testing/Running/Runner.swift index 849e06b26..8a4e6ce2b 100644 --- a/Sources/Testing/Running/Runner.swift +++ b/Sources/Testing/Running/Runner.swift @@ -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. /// @@ -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>([])) + 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 diff --git a/Tests/TestingTests/EventRecorderTests.swift b/Tests/TestingTests/EventRecorderTests.swift index 7c21e022d..91bb0c84b 100644 --- a/Tests/TestingTests/EventRecorderTests.swift +++ b/Tests/TestingTests/EventRecorderTests.swift @@ -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?>(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)