Skip to content

Commit 1f6778e

Browse files
committed
[WIP] Add tools-specific Issue kind that can be used by third-party test libraries.
This PR adds a new `Issue` kind, `.recordedByTool`, that takes a custom payload provided by a third-party tool or library (e.g. Nimble). This case can then be used to distinguish issues specific to tools while also providing sufficient infrastructural support to allow those tools to distinguish issues they created at later stages of the testing workflow. (If this sounds abstract, it is—the proposed API is meant to be used in a fairly arbitrary fashion by an open set of third-party tools and libraries.) Resolves #490.
1 parent 0e2d9cb commit 1f6778e

File tree

4 files changed

+134
-2
lines changed

4 files changed

+134
-2
lines changed

Diff for: Sources/Testing/EntryPoints/ABIv0/Encoded/ABIv0.EncodedIssue.swift

+37-1
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,49 @@ extension ABIv0 {
2222
/// The location in source where this issue occurred, if available.
2323
var sourceLocation: SourceLocation?
2424

25+
/// Any tool-specific context about the issue including the name of the tool
26+
/// that recorded it.
27+
///
28+
/// When decoding using `JSONDecoder`, the value of this property is set to
29+
/// `nil`. Tools that need access to their context values should not use
30+
/// ``ABIv0/EncodedIssue`` to decode issues.
31+
var toolContext: (any Issue.Kind.ToolContext)?
32+
2533
init(encoding issue: borrowing Issue) {
2634
isKnown = issue.isKnown
2735
sourceLocation = issue.sourceLocation
36+
if case let .recordedByTool(toolContext) = issue.kind {
37+
self.toolContext = toolContext
38+
}
2839
}
2940
}
3041
}
3142

3243
// MARK: - Codable
3344

34-
extension ABIv0.EncodedIssue: Codable {}
45+
extension ABIv0.EncodedIssue: Codable {
46+
private enum CodingKeys: String, CodingKey {
47+
case isKnown
48+
case sourceLocation
49+
case toolContext
50+
}
51+
52+
func encode(to encoder: any Encoder) throws {
53+
var container = encoder.container(keyedBy: CodingKeys.self)
54+
try container.encode(isKnown, forKey: .isKnown)
55+
try container.encode(sourceLocation, forKey: .sourceLocation)
56+
if let toolContext {
57+
func encodeToolContext(_ toolContext: some Issue.Kind.ToolContext) throws {
58+
try container.encode(toolContext, forKey: .toolContext)
59+
}
60+
try encodeToolContext(toolContext)
61+
}
62+
}
63+
64+
init(from decoder: any Decoder) throws {
65+
let container = try decoder.container(keyedBy: CodingKeys.self)
66+
isKnown = try container.decode(Bool.self, forKey: .isKnown)
67+
sourceLocation = try container.decode(SourceLocation.self, forKey: .sourceLocation)
68+
toolContext = nil // not decoded
69+
}
70+
}

Diff for: Sources/Testing/Issues/Issue+Recording.swift

+26
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,32 @@ extension Issue {
115115
let issue = Issue(kind: .unconditional, comments: Array(comment), sourceContext: sourceContext)
116116
return issue.record()
117117
}
118+
119+
/// Record an issue on behalf of a tool or library.
120+
///
121+
/// - Parameters:
122+
/// - comment: A comment describing the expectation.
123+
/// - toolContext: Any tool-specific context about the issue including the
124+
/// name of the tool that recorded it.
125+
/// - sourceLocation: The source location to which the issue should be
126+
/// attributed.
127+
///
128+
/// - Returns: The issue that was recorded.
129+
///
130+
/// Test authors do not generally need to use this function. Rather, a tool
131+
/// or library based on the testing library can use it to record a
132+
/// domain-specific issue and to propagatre additional information about that
133+
/// issue to other layers of the testing library's infrastructure.
134+
@_spi(Experimental)
135+
@discardableResult public static func record(
136+
_ comment: Comment? = nil,
137+
context toolContext: some Issue.Kind.ToolContext,
138+
sourceLocation: SourceLocation = #_sourceLocation
139+
) -> Self {
140+
let sourceContext = SourceContext(backtrace: .current(), sourceLocation: sourceLocation)
141+
let issue = Issue(kind: .recordedByTool(toolContext), comments: Array(comment), sourceContext: sourceContext)
142+
return issue.record()
143+
}
118144
}
119145

120146
// MARK: - Recording issues for errors

Diff for: Sources/Testing/Issues/Issue.swift

+36-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,33 @@ public struct Issue: Sendable {
6666
/// An issue due to a failure in the underlying system, not due to a failure
6767
/// within the tests being run.
6868
case system
69+
70+
/// A protocol describing additional context provided by an external tool or
71+
/// library that recorded an issue of kind
72+
/// ``Issue/Kind/recordedByTool(_:)``.
73+
///
74+
/// Test authors do not generally need to use this protocol. Rather, a tool
75+
/// or library based on the testing library can use it to propagate
76+
/// additional information about an issue to other layers of the testing
77+
/// library's infrastructure.
78+
///
79+
/// A tool or library may conform as many types as it needs to this
80+
/// protocol. Instances of types conforming to this protocol must be
81+
/// encodable as JSON so that they can be included in event streams produced
82+
/// by the testing library.
83+
public protocol ToolContext: Sendable, Encodable {
84+
/// The human-readable name of the tool that recorded the issue.
85+
var toolName: String { get }
86+
}
87+
88+
/// An issue recorded by an external tool or library that uses the testing
89+
/// library.
90+
///
91+
/// - Parameters:
92+
/// - toolContext: Any tool-specific context about the issue including the
93+
/// name of the tool that recorded it.
94+
@_spi(Experimental)
95+
indirect case recordedByTool(_ toolContext: any ToolContext)
6996
}
7097

7198
/// The kind of issue this value represents.
@@ -135,7 +162,11 @@ extension Issue: CustomStringConvertible, CustomDebugStringConvertible {
135162
let joinedComments = comments.lazy
136163
.map(\.rawValue)
137164
.joined(separator: "\n")
138-
return "\(kind): \(joinedComments)"
165+
if case let .recordedByTool(toolContext) = kind {
166+
return "\(joinedComments) (from '\(toolContext.toolName)')"
167+
} else {
168+
return "\(kind): \(joinedComments)"
169+
}
139170
}
140171

141172
public var debugDescription: String {
@@ -172,6 +203,8 @@ extension Issue.Kind: CustomStringConvertible {
172203
"An API was misused"
173204
case .system:
174205
"A system failure occurred"
206+
case let .recordedByTool(toolContext):
207+
"'\(toolContext.toolName)' recorded an issue"
175208
}
176209
}
177210
}
@@ -310,6 +343,8 @@ extension Issue.Kind {
310343
.apiMisused
311344
case .system:
312345
.system
346+
case .recordedByTool:
347+
.unconditional // TBD
313348
}
314349
}
315350

Diff for: Tests/TestingTests/IssueTests.swift

+35
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,41 @@ final class IssueTests: XCTestCase {
994994
}.run(configuration: configuration)
995995
}
996996

997+
func testFailBecauseOfToolSpecificIssue() async throws {
998+
struct ToolContext: Issue.Kind.ToolContext {
999+
var value: Int
1000+
var toolName: String {
1001+
"Swift Testing Itself"
1002+
}
1003+
}
1004+
1005+
var configuration = Configuration()
1006+
configuration.eventHandler = { event, _ in
1007+
guard case let .issueRecorded(issue) = event.kind else {
1008+
return
1009+
}
1010+
XCTAssertFalse(issue.isKnown)
1011+
guard case let .recordedByTool(toolContext) = issue.kind else {
1012+
XCTFail("Unexpected issue kind \(issue.kind)")
1013+
return
1014+
}
1015+
guard let toolContext = toolContext as? ToolContext else {
1016+
XCTFail("Unexpected tool context \(toolContext)")
1017+
return
1018+
}
1019+
XCTAssertEqual(toolContext.toolName, "Swift Testing Itself")
1020+
XCTAssertEqual(toolContext.value, 12345)
1021+
1022+
XCTAssertEqual(String(describingForTest: issue), "Something went wrong (from 'Swift Testing Itself')")
1023+
XCTAssertEqual(String(describingForTest: issue.kind), "'Swift Testing Itself' recorded an issue")
1024+
}
1025+
1026+
await Test {
1027+
let toolContext = ToolContext(value: 12345)
1028+
Issue.record("Something went wrong", context: toolContext)
1029+
}.run(configuration: configuration)
1030+
}
1031+
9971032
func testErrorPropertyValidForThrownErrors() async throws {
9981033
var configuration = Configuration()
9991034
configuration.eventHandler = { event, _ in

0 commit comments

Comments
 (0)