Skip to content

Commit c6cbd3d

Browse files
committed
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 multiple comments in its `comments` array: 1. The comment passed to `withKnownIssue()`, if any 2. The Issue's own comment(s) If the issue is recorded within multiple nested `withKnownIssue()` calls, only the comment of the innermost matching call is added to the Issue. ### 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.
1 parent f27305a commit c6cbd3d

File tree

3 files changed

+279
-35
lines changed

3 files changed

+279
-35
lines changed

Sources/Testing/Issues/Issue+Recording.swift

+15-4
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,24 @@
99
//
1010

1111
extension Issue {
12-
/// The known issue matcher, as set by `withKnownIssue()`, associated with the
12+
/// The known issue context, as set by `withKnownIssue()`, associated with the
1313
/// current task.
1414
///
1515
/// If there is no call to `withKnownIssue()` executing on the current task,
1616
/// the value of this property is `nil`.
1717
@TaskLocal
18-
static var currentKnownIssueMatcher: KnownIssueMatcher?
18+
static var currentKnownIssueContext: KnownIssueContext?
19+
20+
/// Mark that an issue is a known issue.
21+
/// - Parameter comment: The comment passed to the invocation of
22+
/// ``withKnownIssue`` that caused this issue to be considered "known".
23+
mutating func markAsKnown(comment: Comment?) {
24+
precondition(!isKnown)
25+
isKnown = true
26+
if let comment {
27+
comments.insert(comment, at: 0)
28+
}
29+
}
1930

2031
/// Record this issue by wrapping it in an ``Event`` and passing it to the
2132
/// current event handler.
@@ -38,9 +49,9 @@ extension Issue {
3849

3950
// If this issue matches via the known issue matcher, set a copy of it to be
4051
// known and record the copy instead.
41-
if !isKnown, let issueMatcher = Self.currentKnownIssueMatcher, issueMatcher(self) {
52+
if !isKnown, let context = Self.currentKnownIssueContext, let match = context.match(self) {
4253
var selfCopy = self
43-
selfCopy.isKnown = true
54+
selfCopy.markAsKnown(comment: match.comment)
4455
return selfCopy.record(configuration: configuration)
4556
}
4657

Sources/Testing/Issues/KnownIssue.swift

+53-30
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,49 @@
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 can be used to determine if an issue is a known issue.
12+
///
13+
/// A stack of these is stored in `Issue.currentKnownIssueContext`. The stack
14+
/// is mutated by calls to `withKnownIssue()`.
15+
struct KnownIssueContext: Sendable {
16+
/// Determine if an issue is known to this context or any of its ancestor
17+
/// contexts.
18+
///
19+
/// Returns `nil` if the issue is not known.
20+
var match: @Sendable (Issue) -> Match?
21+
/// The number of issues this context and its ancestors have matched.
22+
let matchCounter: Locked<Int>
23+
24+
struct Match {
25+
/// The comment that was passed to the `withKnownIssue()` call that matched the issue.
26+
var comment: Comment?
27+
}
28+
29+
/// Create a new ``KnownIssueContext`` by combining a new issue matcher with
30+
/// any previously-set context.
31+
///
32+
/// - Parameters:
33+
/// - parent: The context that should be checked next if `issueMatcher`
34+
/// fails to match an issue.
35+
/// - issueMatcher: A function to invoke when an issue occurs that is used
36+
/// to determine if the issue is known to occur.
37+
/// - comment: Any comment to be associated with issues matched by
38+
/// `issueMatcher`.
39+
/// - Returns: A new instance of ``KnownIssueContext``.
40+
init(parent: KnownIssueContext?, issueMatcher: @escaping KnownIssueMatcher, comment: Comment?) {
41+
let matchCounter = Locked(rawValue: 0)
42+
self.matchCounter = matchCounter
43+
match = { issue in
44+
let match = if issueMatcher(issue) {
45+
Match(comment: comment)
46+
} else {
47+
parent?.match(issue)
48+
}
49+
if match != nil {
50+
matchCounter.increment()
51+
}
52+
return match
2753
}
28-
return false
2954
}
3055
}
3156

@@ -40,12 +65,12 @@ private func _combineIssueMatcher(_ issueMatcher: @escaping KnownIssueMatcher, m
4065
/// function.
4166
/// - sourceLocation: The source location to which the issue should be
4267
/// attributed.
43-
private func _matchError(_ error: any Error, using issueMatcher: KnownIssueMatcher, comment: Comment?, sourceLocation: SourceLocation) throws {
68+
private func _matchError(_ error: any Error, using issueContext: KnownIssueContext, comment: Comment?, sourceLocation: SourceLocation) throws {
4469
let sourceContext = SourceContext(backtrace: Backtrace(forFirstThrowOf: error), sourceLocation: sourceLocation)
4570
var issue = Issue(kind: .errorCaught(error), comments: Array(comment), sourceContext: sourceContext)
46-
if issueMatcher(issue) {
71+
if let match = issueContext.match(issue) {
4772
// It's a known issue, so mark it as such before recording it.
48-
issue.isKnown = true
73+
issue.markAsKnown(comment: match.comment)
4974
issue.record()
5075
} else {
5176
// Rethrow the error, allowing the caller to catch it or for it to propagate
@@ -184,18 +209,17 @@ public func withKnownIssue(
184209
guard precondition() else {
185210
return try body()
186211
}
187-
let matchCounter = Locked(rawValue: 0)
188-
let issueMatcher = _combineIssueMatcher(issueMatcher, matchesCountedBy: matchCounter)
212+
let issueContext = KnownIssueContext(parent: Issue.currentKnownIssueContext, issueMatcher: issueMatcher, comment: comment)
189213
defer {
190214
if !isIntermittent {
191-
_handleMiscount(by: matchCounter, comment: comment, sourceLocation: sourceLocation)
215+
_handleMiscount(by: issueContext.matchCounter, comment: comment, sourceLocation: sourceLocation)
192216
}
193217
}
194-
try Issue.$currentKnownIssueMatcher.withValue(issueMatcher) {
218+
try Issue.$currentKnownIssueContext.withValue(issueContext) {
195219
do {
196220
try body()
197221
} catch {
198-
try _matchError(error, using: issueMatcher, comment: comment, sourceLocation: sourceLocation)
222+
try _matchError(error, using: issueContext, comment: comment, sourceLocation: sourceLocation)
199223
}
200224
}
201225
}
@@ -304,18 +328,17 @@ public func withKnownIssue(
304328
guard await precondition() else {
305329
return try await body()
306330
}
307-
let matchCounter = Locked(rawValue: 0)
308-
let issueMatcher = _combineIssueMatcher(issueMatcher, matchesCountedBy: matchCounter)
331+
let issueContext = KnownIssueContext(parent: Issue.currentKnownIssueContext, issueMatcher: issueMatcher, comment: comment)
309332
defer {
310333
if !isIntermittent {
311-
_handleMiscount(by: matchCounter, comment: comment, sourceLocation: sourceLocation)
334+
_handleMiscount(by: issueContext.matchCounter, comment: comment, sourceLocation: sourceLocation)
312335
}
313336
}
314-
try await Issue.$currentKnownIssueMatcher.withValue(issueMatcher) {
337+
try await Issue.$currentKnownIssueContext.withValue(issueContext) {
315338
do {
316339
try await body()
317340
} catch {
318-
try _matchError(error, using: issueMatcher, comment: comment, sourceLocation: sourceLocation)
341+
try _matchError(error, using: issueContext, comment: comment, sourceLocation: sourceLocation)
319342
}
320343
}
321344
}

Tests/TestingTests/KnownIssueTests.swift

+211-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ final class KnownIssueTests: XCTestCase {
3737
await fulfillment(of: [issueRecorded], timeout: 0.0)
3838
}
3939

40-
func testKnownIssueWithComment() async {
40+
func testKnownIssueNotRecordedWithComment() async {
4141
let issueRecorded = expectation(description: "Issue recorded")
4242

4343
var configuration = Configuration()
@@ -62,6 +62,216 @@ final class KnownIssueTests: XCTestCase {
6262
await fulfillment(of: [issueRecorded], timeout: 0.0)
6363
}
6464

65+
func testKnownIssueRecordedWithComment() async {
66+
let issueRecorded = expectation(description: "Issue recorded")
67+
68+
var configuration = Configuration()
69+
configuration.eventHandler = { event, _ in
70+
guard case let .issueRecorded(issue) = event.kind else {
71+
return
72+
}
73+
issueRecorded.fulfill()
74+
75+
guard case .unconditional = issue.kind else {
76+
return
77+
}
78+
79+
XCTAssertEqual(issue.comments, ["With Known Issue Comment", "Issue Comment"])
80+
XCTAssertTrue(issue.isKnown)
81+
}
82+
83+
await Test {
84+
withKnownIssue("With Known Issue Comment") {
85+
Issue.record("Issue Comment")
86+
}
87+
}.run(configuration: configuration)
88+
89+
await fulfillment(of: [issueRecorded], timeout: 0.0)
90+
}
91+
92+
func testThrownKnownIssueRecordedWithComment() async {
93+
let issueRecorded = expectation(description: "Issue recorded")
94+
95+
var configuration = Configuration()
96+
configuration.eventHandler = { event, _ in
97+
guard case let .issueRecorded(issue) = event.kind else {
98+
return
99+
}
100+
issueRecorded.fulfill()
101+
102+
guard case .unconditional = issue.kind else {
103+
return
104+
}
105+
106+
XCTAssertEqual(issue.comments, ["With Known Issue Comment"])
107+
XCTAssertTrue(issue.isKnown)
108+
}
109+
110+
struct E: Error {}
111+
112+
await Test {
113+
withKnownIssue("With Known Issue Comment") {
114+
throw E()
115+
}
116+
}.run(configuration: configuration)
117+
118+
await fulfillment(of: [issueRecorded], timeout: 0.0)
119+
}
120+
121+
func testKnownIssueRecordedWithNoComment() async {
122+
let issueRecorded = expectation(description: "Issue recorded")
123+
124+
var configuration = Configuration()
125+
configuration.eventHandler = { event, _ in
126+
guard case let .issueRecorded(issue) = event.kind else {
127+
return
128+
}
129+
issueRecorded.fulfill()
130+
131+
guard case .unconditional = issue.kind else {
132+
return
133+
}
134+
135+
XCTAssertEqual(issue.comments, ["Issue Comment"])
136+
XCTAssertTrue(issue.isKnown)
137+
}
138+
139+
await Test {
140+
withKnownIssue {
141+
Issue.record("Issue Comment")
142+
}
143+
}.run(configuration: configuration)
144+
145+
await fulfillment(of: [issueRecorded], timeout: 0.0)
146+
}
147+
148+
func testKnownIssueRecordedWithInnermostMatchingComment() async {
149+
let issueRecorded = expectation(description: "Issue recorded")
150+
151+
var configuration = Configuration()
152+
configuration.eventHandler = { event, _ in
153+
guard case let .issueRecorded(issue) = event.kind else {
154+
return
155+
}
156+
issueRecorded.fulfill()
157+
158+
guard case .unconditional = issue.kind else {
159+
return
160+
}
161+
162+
XCTAssertEqual(issue.comments, ["Inner Contains B", "Issue B"])
163+
XCTAssertTrue(issue.isKnown)
164+
}
165+
166+
await Test {
167+
withKnownIssue("Contains A", isIntermittent: true) {
168+
withKnownIssue("Outer Contains B", isIntermittent: true) {
169+
withKnownIssue("Inner Contains B") {
170+
withKnownIssue("Contains C", isIntermittent: true) {
171+
Issue.record("Issue B")
172+
} matching: { issue in
173+
issue.comments.contains { $0.rawValue.contains("C") }
174+
}
175+
} matching: { issue in
176+
issue.comments.contains { $0.rawValue.contains("B") }
177+
}
178+
} matching: { issue in
179+
issue.comments.contains { $0.rawValue.contains("B") }
180+
}
181+
} matching: { issue in
182+
issue.comments.contains { $0.rawValue.contains("A") }
183+
}
184+
}.run(configuration: configuration)
185+
186+
await fulfillment(of: [issueRecorded], timeout: 0.0)
187+
}
188+
189+
func testThrownKnownIssueRecordedWithInnermostMatchingComment() async {
190+
let issueRecorded = expectation(description: "Issue recorded")
191+
192+
var configuration = Configuration()
193+
configuration.eventHandler = { event, _ in
194+
guard case let .issueRecorded(issue) = event.kind else {
195+
return
196+
}
197+
issueRecorded.fulfill()
198+
199+
guard case .unconditional = issue.kind else {
200+
return
201+
}
202+
203+
XCTAssertEqual(issue.comments, ["Inner Is B", "B"])
204+
XCTAssertTrue(issue.isKnown)
205+
}
206+
207+
struct A: Error {}
208+
struct B: Error {}
209+
struct C: Error {}
210+
211+
await Test {
212+
try withKnownIssue("Is A", isIntermittent: true) {
213+
try withKnownIssue("Outer Is B", isIntermittent: true) {
214+
try withKnownIssue("Inner Is B") {
215+
try withKnownIssue("Is C", isIntermittent: true) {
216+
throw B()
217+
} matching: { issue in
218+
issue.error is C
219+
}
220+
} matching: { issue in
221+
issue.error is B
222+
}
223+
} matching: { issue in
224+
issue.error is B
225+
}
226+
} matching: { issue in
227+
issue.error is A
228+
}
229+
}.run(configuration: configuration)
230+
231+
await fulfillment(of: [issueRecorded], timeout: 0.0)
232+
}
233+
234+
func testKnownIssueRecordedWithNoCommentOnInnermostMatch() async {
235+
let issueRecorded = expectation(description: "Issue recorded")
236+
237+
var configuration = Configuration()
238+
configuration.eventHandler = { event, _ in
239+
guard case let .issueRecorded(issue) = event.kind else {
240+
return
241+
}
242+
issueRecorded.fulfill()
243+
244+
guard case .unconditional = issue.kind else {
245+
return
246+
}
247+
248+
XCTAssertEqual(issue.comments, ["Issue B"])
249+
XCTAssertTrue(issue.isKnown)
250+
}
251+
252+
await Test {
253+
withKnownIssue("Contains A", isIntermittent: true) {
254+
withKnownIssue("Outer Contains B", isIntermittent: true) {
255+
withKnownIssue { // No comment here on the withKnownIssue that will actually match.
256+
withKnownIssue("Contains C", isIntermittent: true) {
257+
Issue.record("Issue B")
258+
} matching: { issue in
259+
issue.comments.contains { $0.rawValue.contains("C") }
260+
}
261+
} matching: { issue in
262+
issue.comments.contains { $0.rawValue.contains("B") }
263+
}
264+
} matching: { issue in
265+
issue.comments.contains { $0.rawValue.contains("B") }
266+
}
267+
} matching: { issue in
268+
issue.comments.contains { $0.rawValue.contains("A") }
269+
}
270+
}.run(configuration: configuration)
271+
272+
await fulfillment(of: [issueRecorded], timeout: 0.0)
273+
}
274+
65275
func testIssueIsKnownPropertyIsSetCorrectlyWithCustomIssueMatcher() async {
66276
struct MyError: Error {}
67277

0 commit comments

Comments
 (0)