Skip to content

Commit 651e4fb

Browse files
authored
Attachments! [Take 2] (#796)
This PR introduces a new experimental feature, attachments. With this feature, you can "attach" values that conform to a new `Test.Attachable` protocol to a test. With the right command-line incantation (TBD), Swift Testing will automatically write attachments to disk for you. > [!NOTE] > This PR does not teach Xcode or VS Code how to handle attachments produced by > Swift Testing, nor does it add the necessary command-line arguments to the > `swift test` command-line tool. This PR is one of a series that I'll be posting to build out this feature. As always, keep in mind that symbols marked `@_spi(Experimental)` are subject to change or removal without notice. Resolves #714. Resolves rdar://88648735. ### 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 73d4948 commit 651e4fb

16 files changed

+1015
-1
lines changed

Sources/Testing/ABI/EntryPoints/EntryPoint.swift

+17
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ public struct __CommandLineArguments_v0: Sendable {
275275

276276
/// The value of the `--repeat-until` argument.
277277
public var repeatUntil: String?
278+
279+
/// The value of the `--experimental-attachments-path` argument.
280+
public var experimentalAttachmentsPath: String?
278281
}
279282

280283
extension __CommandLineArguments_v0: Codable {
@@ -295,6 +298,7 @@ extension __CommandLineArguments_v0: Codable {
295298
case skip
296299
case repetitions
297300
case repeatUntil
301+
case experimentalAttachmentsPath
298302
}
299303
}
300304

@@ -355,6 +359,11 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum
355359
if let xunitOutputIndex = args.firstIndex(of: "--xunit-output"), !isLastArgument(at: xunitOutputIndex) {
356360
result.xunitOutput = args[args.index(after: xunitOutputIndex)]
357361
}
362+
363+
// Attachment output
364+
if let attachmentsPathIndex = args.firstIndex(of: "--experimental-attachments-path"), !isLastArgument(at: attachmentsPathIndex) {
365+
result.experimentalAttachmentsPath = args[args.index(after: attachmentsPathIndex)]
366+
}
358367
#endif
359368

360369
if args.contains("--list-tests") {
@@ -464,6 +473,14 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr
464473
}
465474
}
466475

476+
// Attachment output.
477+
if let attachmentsPath = args.experimentalAttachmentsPath {
478+
guard fileExists(atPath: attachmentsPath) else {
479+
throw _EntryPointError.invalidArgument("--experimental-attachments-path", value: attachmentsPath)
480+
}
481+
configuration.attachmentsPath = attachmentsPath
482+
}
483+
467484
#if canImport(Foundation)
468485
// Event stream output (experimental)
469486
if let eventStreamOutputPath = args.eventStreamOutputPath {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
extension ABIv0 {
12+
/// A type implementing the JSON encoding of ``Test/Attachment`` for the ABI
13+
/// entry point and event stream output.
14+
///
15+
/// This type is not part of the public interface of the testing library. It
16+
/// assists in converting values to JSON; clients that consume this JSON are
17+
/// expected to write their own decoders.
18+
///
19+
/// - Warning: Attachments are not yet part of the JSON schema.
20+
struct EncodedAttachment: Sendable {
21+
/// The path where the attachment was written.
22+
var path: String?
23+
24+
init(encoding attachment: borrowing Test.Attachment, in eventContext: borrowing Event.Context) {
25+
path = attachment.fileSystemPath
26+
}
27+
}
28+
}
29+
30+
// MARK: - Codable
31+
32+
extension ABIv0.EncodedAttachment: Codable {}

Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift

+12
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ extension ABIv0 {
2727
case testStarted
2828
case testCaseStarted
2929
case issueRecorded
30+
case valueAttached = "_valueAttached"
3031
case testCaseEnded
3132
case testEnded
3233
case testSkipped
@@ -45,6 +46,14 @@ extension ABIv0 {
4546
/// ``kind-swift.property`` property is ``Kind-swift.enum/issueRecorded``.
4647
var issue: EncodedIssue?
4748

49+
/// The value that was attached, if any.
50+
///
51+
/// The value of this property is `nil` unless the value of the
52+
/// ``kind-swift.property`` property is ``Kind-swift.enum/valueAttached``.
53+
///
54+
/// - Warning: Attachments are not yet part of the JSON schema.
55+
var _attachment: EncodedAttachment?
56+
4857
/// Human-readable messages associated with this event that can be presented
4958
/// to the user.
5059
var messages: [EncodedMessage]
@@ -71,6 +80,9 @@ extension ABIv0 {
7180
case let .issueRecorded(recordedIssue):
7281
kind = .issueRecorded
7382
issue = EncodedIssue(encoding: recordedIssue, in: eventContext)
83+
case let .valueAttached(attachment):
84+
kind = .valueAttached
85+
_attachment = EncodedAttachment(encoding: attachment, in: eventContext)
7486
case .testCaseEnded:
7587
if eventContext.test?.isParameterized == false {
7688
return nil

Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedMessage.swift

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ extension ABIv0 {
3030
case difference
3131
case warning
3232
case details
33+
case attachment = "_attachment"
3334

3435
init(encoding symbol: Event.Symbol) {
3536
self = switch symbol {
@@ -51,6 +52,8 @@ extension ABIv0 {
5152
.warning
5253
case .details:
5354
.details
55+
case .attachment:
56+
.attachment
5457
}
5558
}
5659
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
@_spi(Experimental)
12+
extension Test {
13+
/// A protocol describing a type that can be attached to a test report or
14+
/// written to disk when a test is run.
15+
///
16+
/// To attach an attachable value to a test report or test run output, use it
17+
/// to initialize a new instance of ``Test/Attachment``, then call
18+
/// ``Test/Attachment/attach()``. An attachment can only be attached once.
19+
///
20+
/// The testing library provides default conformances to this protocol for a
21+
/// variety of standard library types. Most user-defined types do not need to
22+
/// conform to this protocol.
23+
///
24+
/// A type should conform to this protocol if it can be represented as a
25+
/// sequence of bytes that would be diagnostically useful if a test fails.
26+
public protocol Attachable: ~Copyable {
27+
/// An estimate of the number of bytes of memory needed to store this value
28+
/// as an attachment.
29+
///
30+
/// The testing library uses this property to determine if an attachment
31+
/// should be held in memory or should be immediately persisted to storage.
32+
/// Larger attachments are more likely to be persisted, but the algorithm
33+
/// the testing library uses is an implementation detail and is subject to
34+
/// change.
35+
///
36+
/// The value of this property is approximately equal to the number of bytes
37+
/// that will actually be needed, or `nil` if the value cannot be computed
38+
/// efficiently. The default implementation of this property returns `nil`.
39+
///
40+
/// - Complexity: O(1) unless `Self` conforms to `Collection`, in which case
41+
/// up to O(_n_) where _n_ is the length of the collection.
42+
var estimatedAttachmentByteCount: Int? { get }
43+
44+
/// Call a function and pass a buffer representing this instance to it.
45+
///
46+
/// - Parameters:
47+
/// - attachment: The attachment that is requesting a buffer (that is, the
48+
/// attachment containing this instance.)
49+
/// - body: A function to call. A temporary buffer containing a data
50+
/// representation of this instance is passed to it.
51+
///
52+
/// - Returns: Whatever is returned by `body`.
53+
///
54+
/// - Throws: Whatever is thrown by `body`, or any error that prevented the
55+
/// creation of the buffer.
56+
///
57+
/// The testing library uses this function when writing an attachment to a
58+
/// test report or to a file on disk. The format of the buffer is
59+
/// implementation-defined, but should be "idiomatic" for this type: for
60+
/// example, if this type represents an image, it would be appropriate for
61+
/// the buffer to contain an image in PNG format, JPEG format, etc., but it
62+
/// would not be idiomatic for the buffer to contain a textual description
63+
/// of the image.
64+
borrowing func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R
65+
}
66+
}
67+
68+
// MARK: - Default implementations
69+
70+
extension Test.Attachable where Self: ~Copyable {
71+
public var estimatedAttachmentByteCount: Int? {
72+
nil
73+
}
74+
}
75+
76+
extension Test.Attachable where Self: Collection, Element == UInt8 {
77+
public var estimatedAttachmentByteCount: Int? {
78+
count
79+
}
80+
81+
// We do not provide an implementation of withUnsafeBufferPointer(for:_:) here
82+
// because there is no way in the standard library to statically detect if a
83+
// collection can provide contiguous storage (_HasContiguousBytes is not API.)
84+
// If withContiguousBytesIfAvailable(_:) fails, we don't want to make a
85+
// (potentially expensive!) copy of the collection.
86+
//
87+
// The planned Foundation cross-import overlay can provide a default
88+
// implementation for collection types that conform to Foundation's
89+
// ContiguousBytes protocol.
90+
}
91+
92+
extension Test.Attachable where Self: StringProtocol {
93+
public var estimatedAttachmentByteCount: Int? {
94+
// NOTE: utf8.count may be O(n) for foreign strings.
95+
// SEE: https://github.com/swiftlang/swift/blob/main/stdlib/public/core/StringUTF8View.swift
96+
utf8.count
97+
}
98+
}
99+
100+
// MARK: - Default conformances
101+
102+
// Implement the protocol requirements for byte arrays and buffers so that
103+
// developers can attach raw data when needed.
104+
@_spi(Experimental)
105+
extension Array<UInt8>: Test.Attachable {
106+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
107+
try withUnsafeBytes(body)
108+
}
109+
}
110+
111+
@_spi(Experimental)
112+
extension ContiguousArray<UInt8>: Test.Attachable {
113+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
114+
try withUnsafeBytes(body)
115+
}
116+
}
117+
118+
@_spi(Experimental)
119+
extension ArraySlice<UInt8>: Test.Attachable {
120+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
121+
try withUnsafeBytes(body)
122+
}
123+
}
124+
125+
@_spi(Experimental)
126+
extension UnsafeBufferPointer<UInt8>: Test.Attachable {
127+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
128+
try body(.init(self))
129+
}
130+
}
131+
132+
@_spi(Experimental)
133+
extension UnsafeMutableBufferPointer<UInt8>: Test.Attachable {
134+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
135+
try body(.init(self))
136+
}
137+
}
138+
139+
@_spi(Experimental)
140+
extension UnsafeRawBufferPointer: Test.Attachable {
141+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
142+
try body(self)
143+
}
144+
}
145+
146+
@_spi(Experimental)
147+
extension UnsafeMutableRawBufferPointer: Test.Attachable {
148+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
149+
try body(.init(self))
150+
}
151+
}
152+
153+
@_spi(Experimental)
154+
extension String: Test.Attachable {
155+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
156+
var selfCopy = self
157+
return try selfCopy.withUTF8 { utf8 in
158+
try body(UnsafeRawBufferPointer(utf8))
159+
}
160+
}
161+
}
162+
163+
@_spi(Experimental)
164+
extension Substring: Test.Attachable {
165+
public func withUnsafeBufferPointer<R>(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
166+
var selfCopy = self
167+
return try selfCopy.withUTF8 { utf8 in
168+
try body(UnsafeRawBufferPointer(utf8))
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)