Skip to content

Commit 2f45665

Browse files
arobengrynspanstmontgomery
authored
Add withKnownIssue comments to known Issues (#1014)
Add withKnownIssue comments to known Issues ### Motivation: The Comment passed to withKnownIssue() is currently only used when a known issue does not occur. When a known issue does occur, it is marked isKnown but does not contain any record of the withKnownIssue() comment, making it harder to understand why the issue was considered to be known, especially if there are multiple nested withKnownIssue() calls. ### Modifications: When an issue is marked "known" by a `withKnownIssue()` call, the recorded issue will now have a new `knownIssueContext` property containing the comment passed to `withKnownIssue()` (if any). This comment will be included in the `messages` array of the `ABI.EncodedEvent` that represents the issue. If the issue is recorded within multiple nested `withKnownIssue()` calls, `knownIssueContext` corresponds to the innermost matching call. The `Issue.isKnown` setter is now deprecated and a no-op. When an error is thrown within a `withKnownIssue()` call, the `Issue` passed to the issue matcher used to have the known issue comment in `Issue.comments`. That has now been removed; Issues for thrown errors now have no comments at all when passed to the issue matcher. This matches Issues for thrown errors that occur outside of `withKnownIssue()` calls. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. --------- Co-authored-by: Jonathan Grynspan <[email protected]> Co-authored-by: Stuart Montgomery <[email protected]>
1 parent 32e231c commit 2f45665

File tree

7 files changed

+402
-50
lines changed

7 files changed

+402
-50
lines changed

Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift

+16-3
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,20 @@ extension Event.HumanReadableOutputRecorder {
9393
/// - Parameters:
9494
/// - comments: The comments that should be formatted.
9595
///
96-
/// - Returns: A formatted string representing `comments`, or `nil` if there
97-
/// are none.
96+
/// - Returns: An array of formatted messages representing `comments`, or an
97+
/// empty array if there are none.
9898
private func _formattedComments(_ comments: [Comment]) -> [Message] {
99-
comments.map { Message(symbol: .details, stringValue: $0.rawValue) }
99+
comments.map(_formattedComment)
100+
}
101+
102+
/// Get a string representing a single comment, formatted for output.
103+
///
104+
/// - Parameters:
105+
/// - comment: The comment that should be formatted.
106+
///
107+
/// - Returns: A formatted message representing `comment`.
108+
private func _formattedComment(_ comment: Comment) -> Message {
109+
Message(symbol: .details, stringValue: comment.rawValue)
100110
}
101111

102112
/// Get a string representing the comments attached to a test, formatted for
@@ -449,6 +459,9 @@ extension Event.HumanReadableOutputRecorder {
449459
additionalMessages.append(Message(symbol: .difference, stringValue: differenceDescription))
450460
}
451461
additionalMessages += _formattedComments(issue.comments)
462+
if let knownIssueComment = issue.knownIssueContext?.comment {
463+
additionalMessages.append(_formattedComment(knownIssueComment))
464+
}
452465

453466
if verbosity > 0, case let .expectationFailed(expectation) = issue.kind {
454467
let expression = expectation.evaluatedExpression

Sources/Testing/ExitTests/ExitTest.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,11 @@ extension ExitTest {
930930
sourceLocation: issue.sourceLocation
931931
)
932932
var issueCopy = Issue(kind: issueKind, comments: comments, sourceContext: sourceContext)
933-
issueCopy.isKnown = issue.isKnown
933+
if issue.isKnown {
934+
// The known issue comment, if there was one, is already included in
935+
// the `comments` array above.
936+
issueCopy.knownIssueContext = Issue.KnownIssueContext()
937+
}
934938
issueCopy.record()
935939
}
936940
}

Sources/Testing/Issues/Issue+Recording.swift

+2-10
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,6 @@
99
//
1010

1111
extension Issue {
12-
/// The known issue matcher, as set by `withKnownIssue()`, associated with the
13-
/// current task.
14-
///
15-
/// If there is no call to `withKnownIssue()` executing on the current task,
16-
/// the value of this property is `nil`.
17-
@TaskLocal
18-
static var currentKnownIssueMatcher: KnownIssueMatcher?
19-
2012
/// Record this issue by wrapping it in an ``Event`` and passing it to the
2113
/// current event handler.
2214
///
@@ -38,9 +30,9 @@ extension Issue {
3830

3931
// If this issue matches via the known issue matcher, set a copy of it to be
4032
// known and record the copy instead.
41-
if !isKnown, let issueMatcher = Self.currentKnownIssueMatcher, issueMatcher(self) {
33+
if !isKnown, let context = KnownIssueScope.current?.matcher(self) {
4234
var selfCopy = self
43-
selfCopy.isKnown = true
35+
selfCopy.knownIssueContext = context
4436
return selfCopy.record(configuration: configuration)
4537
}
4638

Sources/Testing/Issues/Issue.swift

+21-1
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,29 @@ public struct Issue: Sendable {
129129
@_spi(ForToolsIntegrationOnly)
130130
public var sourceContext: SourceContext
131131

132+
/// A type representing a
133+
/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)`` call
134+
/// that matched an issue.
135+
@_spi(ForToolsIntegrationOnly)
136+
public struct KnownIssueContext: Sendable {
137+
/// The comment that was passed to
138+
/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)``.
139+
public var comment: Comment?
140+
}
141+
142+
/// A ``KnownIssueContext-swift.struct`` representing the
143+
/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)`` call
144+
/// that matched this issue, if any.
145+
@_spi(ForToolsIntegrationOnly)
146+
public var knownIssueContext: KnownIssueContext? = nil
147+
132148
/// Whether or not this issue is known to occur.
133149
@_spi(ForToolsIntegrationOnly)
134-
public var isKnown: Bool = false
150+
public var isKnown: Bool {
151+
get { knownIssueContext != nil }
152+
@available(*, deprecated, message: "Setting this property has no effect.")
153+
set {}
154+
}
135155

136156
/// Initialize an issue instance with the specified details.
137157
///

Sources/Testing/Issues/KnownIssue.swift

+67-33
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,80 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11-
/// Combine an instance of ``KnownIssueMatcher`` with any previously-set one.
12-
///
13-
/// - Parameters:
14-
/// - issueMatcher: A function to invoke when an issue occurs that is used to
15-
/// determine if the issue is known to occur.
16-
/// - matchCounter: The counter responsible for tracking the number of matches
17-
/// found with `issueMatcher`.
18-
///
19-
/// - Returns: A new instance of ``Configuration`` or `nil` if there was no
20-
/// current configuration set.
21-
private func _combineIssueMatcher(_ issueMatcher: @escaping KnownIssueMatcher, matchesCountedBy matchCounter: Locked<Int>) -> KnownIssueMatcher {
22-
let oldIssueMatcher = Issue.currentKnownIssueMatcher
23-
return { issue in
24-
if issueMatcher(issue) || true == oldIssueMatcher?(issue) {
25-
matchCounter.increment()
26-
return true
11+
/// A type that represents an active
12+
/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)``
13+
/// call and any parent calls.
14+
///
15+
/// A stack of these is stored in `KnownIssueScope.current`.
16+
struct KnownIssueScope: Sendable {
17+
/// A function which determines if an issue matches a known issue scope or
18+
/// any of its ancestor scopes.
19+
///
20+
/// - Parameters:
21+
/// - issue: The issue being matched.
22+
///
23+
/// - Returns: A known issue context containing information about the known
24+
/// issue, if the issue is considered "known" by this known issue scope or any
25+
/// ancestor scope, or `nil` otherwise.
26+
typealias Matcher = @Sendable (_ issue: Issue) -> Issue.KnownIssueContext?
27+
28+
/// The matcher function for this known issue scope.
29+
var matcher: Matcher
30+
31+
/// The number of issues this scope and its ancestors have matched.
32+
let matchCounter: Locked<Int>
33+
34+
/// Create a new ``KnownIssueScope`` by combining a new issue matcher with
35+
/// any already-active scope.
36+
///
37+
/// - Parameters:
38+
/// - parent: The context that should be checked next if `issueMatcher`
39+
/// fails to match an issue. Defaults to ``KnownIssueScope.current``.
40+
/// - issueMatcher: A function to invoke when an issue occurs that is used
41+
/// to determine if the issue is known to occur.
42+
/// - context: The context to be associated with issues matched by
43+
/// `issueMatcher`.
44+
init(parent: KnownIssueScope? = .current, issueMatcher: @escaping KnownIssueMatcher, context: Issue.KnownIssueContext) {
45+
let matchCounter = Locked(rawValue: 0)
46+
self.matchCounter = matchCounter
47+
matcher = { issue in
48+
let matchedContext = if issueMatcher(issue) {
49+
context
50+
} else {
51+
parent?.matcher(issue)
52+
}
53+
if matchedContext != nil {
54+
matchCounter.increment()
55+
}
56+
return matchedContext
2757
}
28-
return false
2958
}
59+
60+
/// The active known issue scope for the current task, if any.
61+
///
62+
/// If there is no call to
63+
/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)``
64+
/// executing on the current task, the value of this property is `nil`.
65+
@TaskLocal
66+
static var current: KnownIssueScope?
3067
}
3168

3269
/// Check if an error matches using an issue-matching function, and throw it if
3370
/// it does not.
3471
///
3572
/// - Parameters:
3673
/// - error: The error to test.
37-
/// - issueMatcher: A function to which `error` is passed (after boxing it in
38-
/// an instance of ``Issue``) to determine if it is known to occur.
74+
/// - scope: The known issue scope that is processing the error.
3975
/// - comment: An optional comment to apply to any issues generated by this
4076
/// function.
4177
/// - sourceLocation: The source location to which the issue should be
4278
/// attributed.
43-
private func _matchError(_ error: any Error, using issueMatcher: KnownIssueMatcher, comment: Comment?, sourceLocation: SourceLocation) throws {
79+
private func _matchError(_ error: any Error, in scope: KnownIssueScope, comment: Comment?, sourceLocation: SourceLocation) throws {
4480
let sourceContext = SourceContext(backtrace: Backtrace(forFirstThrowOf: error), sourceLocation: sourceLocation)
45-
var issue = Issue(kind: .errorCaught(error), comments: Array(comment), sourceContext: sourceContext)
46-
if issueMatcher(issue) {
81+
var issue = Issue(kind: .errorCaught(error), comments: [], sourceContext: sourceContext)
82+
if let context = scope.matcher(issue) {
4783
// It's a known issue, so mark it as such before recording it.
48-
issue.isKnown = true
84+
issue.knownIssueContext = context
4985
issue.record()
5086
} else {
5187
// Rethrow the error, allowing the caller to catch it or for it to propagate
@@ -184,18 +220,17 @@ public func withKnownIssue(
184220
guard precondition() else {
185221
return try body()
186222
}
187-
let matchCounter = Locked(rawValue: 0)
188-
let issueMatcher = _combineIssueMatcher(issueMatcher, matchesCountedBy: matchCounter)
223+
let scope = KnownIssueScope(issueMatcher: issueMatcher, context: Issue.KnownIssueContext(comment: comment))
189224
defer {
190225
if !isIntermittent {
191-
_handleMiscount(by: matchCounter, comment: comment, sourceLocation: sourceLocation)
226+
_handleMiscount(by: scope.matchCounter, comment: comment, sourceLocation: sourceLocation)
192227
}
193228
}
194-
try Issue.$currentKnownIssueMatcher.withValue(issueMatcher) {
229+
try KnownIssueScope.$current.withValue(scope) {
195230
do {
196231
try body()
197232
} catch {
198-
try _matchError(error, using: issueMatcher, comment: comment, sourceLocation: sourceLocation)
233+
try _matchError(error, in: scope, comment: comment, sourceLocation: sourceLocation)
199234
}
200235
}
201236
}
@@ -304,18 +339,17 @@ public func withKnownIssue(
304339
guard await precondition() else {
305340
return try await body()
306341
}
307-
let matchCounter = Locked(rawValue: 0)
308-
let issueMatcher = _combineIssueMatcher(issueMatcher, matchesCountedBy: matchCounter)
342+
let scope = KnownIssueScope(issueMatcher: issueMatcher, context: Issue.KnownIssueContext(comment: comment))
309343
defer {
310344
if !isIntermittent {
311-
_handleMiscount(by: matchCounter, comment: comment, sourceLocation: sourceLocation)
345+
_handleMiscount(by: scope.matchCounter, comment: comment, sourceLocation: sourceLocation)
312346
}
313347
}
314-
try await Issue.$currentKnownIssueMatcher.withValue(issueMatcher) {
348+
try await KnownIssueScope.$current.withValue(scope) {
315349
do {
316350
try await body()
317351
} catch {
318-
try _matchError(error, using: issueMatcher, comment: comment, sourceLocation: sourceLocation)
352+
try _matchError(error, in: scope, comment: comment, sourceLocation: sourceLocation)
319353
}
320354
}
321355
}

Tests/TestingTests/EventRecorderTests.swift

+61
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,35 @@ struct EventRecorderTests {
521521
recorder.record(Event(.runEnded, testID: nil, testCaseID: nil), in: context)
522522
}
523523
}
524+
525+
@Test(
526+
"HumanReadableOutputRecorder includes known issue comment in messages array",
527+
arguments: [
528+
("recordWithoutKnownIssueComment()", ["#expect comment"]),
529+
("recordWithKnownIssueComment()", ["#expect comment", "withKnownIssue comment"]),
530+
("throwWithoutKnownIssueComment()", []),
531+
("throwWithKnownIssueComment()", ["withKnownIssue comment"]),
532+
]
533+
)
534+
func knownIssueComments(testName: String, expectedComments: [String]) async throws {
535+
var configuration = Configuration()
536+
let recorder = Event.HumanReadableOutputRecorder()
537+
let messages = Locked<[Event.HumanReadableOutputRecorder.Message]>(rawValue: [])
538+
configuration.eventHandler = { event, context in
539+
guard case .issueRecorded = event.kind else { return }
540+
messages.withLock {
541+
$0.append(contentsOf: recorder.record(event, in: context))
542+
}
543+
}
544+
545+
await runTestFunction(named: testName, in: PredictablyFailingKnownIssueTests.self, configuration: configuration)
546+
547+
// The first message is something along the lines of "Test foo recorded a
548+
// known issue" and includes a source location, so is inconvenient to
549+
// include in our expectation here.
550+
let actualComments = messages.rawValue.dropFirst().map(\.stringValue)
551+
#expect(actualComments == expectedComments)
552+
}
524553
}
525554

526555
// MARK: - Fixtures
@@ -663,3 +692,35 @@ struct EventRecorderTests {
663692
#expect(arg > 0)
664693
}
665694
}
695+
696+
@Suite(.hidden) struct PredictablyFailingKnownIssueTests {
697+
@Test(.hidden)
698+
func recordWithoutKnownIssueComment() {
699+
withKnownIssue {
700+
#expect(Bool(false), "#expect comment")
701+
}
702+
}
703+
704+
@Test(.hidden)
705+
func recordWithKnownIssueComment() {
706+
withKnownIssue("withKnownIssue comment") {
707+
#expect(Bool(false), "#expect comment")
708+
}
709+
}
710+
711+
@Test(.hidden)
712+
func throwWithoutKnownIssueComment() {
713+
withKnownIssue {
714+
struct TheError: Error {}
715+
throw TheError()
716+
}
717+
}
718+
719+
@Test(.hidden)
720+
func throwWithKnownIssueComment() {
721+
withKnownIssue("withKnownIssue comment") {
722+
struct TheError: Error {}
723+
throw TheError()
724+
}
725+
}
726+
}

0 commit comments

Comments
 (0)