Skip to content

Promote attachments to API #973

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 10, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Documentation/ABI/JSON.md
Original file line number Diff line number Diff line change
@@ -188,19 +188,24 @@ sufficient information to display the event in a human-readable format.
"kind": <event-kind>,
"instant": <instant>, ; when the event occurred
["issue": <issue>,] ; the recorded issue (if "kind" is "issueRecorded")
["attachment": <attachment>,] ; the attachment (if kind is "valueAttached")
"messages": <array:message>,
["testID": <test-id>,]
}

<event-kind> ::= "runStarted" | "testStarted" | "testCaseStarted" |
"issueRecorded" | "testCaseEnded" | "testEnded" | "testSkipped" |
"runEnded" ; additional event kinds may be added in the future
"runEnded" | "valueAttached"; additional event kinds may be added in the future

<issue> ::= {
"isKnown": <bool>, ; is this a known issue or not?
["sourceLocation": <source-location>,] ; where the issue occurred, if known
}

<attachment> ::= {
"path": <string>, ; the absolute path to the attachment on disk
}

<message> ::= {
"symbol": <message-symbol>,
"text": <string>, ; the human-readable text of this message
Original file line number Diff line number Diff line change
@@ -42,9 +42,9 @@ extension Attachment {
contentType: (any Sendable)?,
encodingQuality: Float,
sourceLocation: SourceLocation
) where AttachableValue == _AttachableImageContainer<T> {
let imageContainer = _AttachableImageContainer(image: attachableValue, encodingQuality: encodingQuality, contentType: contentType)
self.init(imageContainer, named: preferredName, sourceLocation: sourceLocation)
) where AttachableValue == _AttachableImageWrapper<T> {
let imageWrapper = _AttachableImageWrapper(image: attachableValue, encodingQuality: encodingQuality, contentType: contentType)
self.init(imageWrapper, named: preferredName, sourceLocation: sourceLocation)
}

/// Initialize an instance of this type that encloses the given image.
@@ -79,7 +79,7 @@ extension Attachment {
as contentType: UTType?,
encodingQuality: Float = 1.0,
sourceLocation: SourceLocation = #_sourceLocation
) where AttachableValue == _AttachableImageContainer<T> {
) where AttachableValue == _AttachableImageWrapper<T> {
self.init(attachableValue: attachableValue, named: preferredName, contentType: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation)
}

@@ -109,7 +109,7 @@ extension Attachment {
named preferredName: String? = nil,
encodingQuality: Float = 1.0,
sourceLocation: SourceLocation = #_sourceLocation
) where AttachableValue == _AttachableImageContainer<T> {
) where AttachableValue == _AttachableImageWrapper<T> {
self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation)
}
}
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
//

#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
@_spi(Experimental) public import Testing
public import Testing
private import CoreGraphics

private import ImageIO
@@ -48,7 +48,7 @@ import UniformTypeIdentifiers
///
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
@_spi(Experimental)
public struct _AttachableImageContainer<Image>: Sendable where Image: AttachableAsCGImage {
public struct _AttachableImageWrapper<Image>: Sendable where Image: AttachableAsCGImage {
/// The underlying image.
///
/// `CGImage` and `UIImage` are sendable, but `NSImage` is not. `NSImage`
@@ -127,8 +127,8 @@ public struct _AttachableImageContainer<Image>: Sendable where Image: Attachable

// MARK: -

extension _AttachableImageContainer: AttachableContainer {
public var attachableValue: Image {
extension _AttachableImageWrapper: AttachableWrapper {
public var wrappedValue: Image {
image
}

Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
//

#if canImport(Foundation)
@_spi(Experimental) public import Testing
public import Testing
public import Foundation

// This implementation is necessary to let the compiler disambiguate when a type
@@ -18,7 +18,9 @@ public import Foundation
// (which explicitly document what happens when a type conforms to both
// protocols.)

@_spi(Experimental)
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
extension Attachable where Self: Encodable & NSSecureCoding {
@_documentation(visibility: private)
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
//

#if canImport(Foundation)
@_spi(Experimental) public import Testing
public import Testing
private import Foundation

/// A common implementation of ``withUnsafeBytes(for:_:)`` that is used when a
@@ -53,7 +53,10 @@ func withUnsafeBytes<E, R>(encoding attachableValue: borrowing E, for attachment
// Implement the protocol requirements generically for any encodable value by
// encoding to JSON. This lets developers provide trivial conformance to the
// protocol for types that already support Codable.
@_spi(Experimental)

/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
extension Attachable where Self: Encodable {
/// Encode this value into a buffer using either [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder)
/// or [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder),
@@ -86,6 +89,10 @@ extension Attachable where Self: Encodable {
/// _and_ [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding),
/// the default implementation of this function uses the value's conformance
/// to `Encodable`.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
try _Testing_Foundation.withUnsafeBytes(encoding: self, for: attachment, body)
}
Original file line number Diff line number Diff line change
@@ -9,13 +9,16 @@
//

#if canImport(Foundation)
@_spi(Experimental) public import Testing
public import Testing
public import Foundation

// As with Encodable, implement the protocol requirements for
// NSSecureCoding-conformant classes by default. The implementation uses
// NSKeyedArchiver for encoding.
@_spi(Experimental)

/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
extension Attachable where Self: NSSecureCoding {
/// Encode this object using [`NSKeyedArchiver`](https://developer.apple.com/documentation/foundation/nskeyedarchiver)
/// into a buffer, then call a function and pass that buffer to it.
@@ -46,6 +49,10 @@ extension Attachable where Self: NSSecureCoding {
/// _and_ [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding),
/// the default implementation of this function uses the value's conformance
/// to `Encodable`.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
let format = try EncodingFormat(for: attachment)

Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
//

#if canImport(Foundation)
@_spi(Experimental) public import Testing
public import Testing
public import Foundation

#if !SWT_NO_PROCESS_SPAWNING && os(Windows)
@@ -32,8 +32,7 @@ extension URL {
}
}

@_spi(Experimental)
extension Attachment where AttachableValue == _AttachableURLContainer {
extension Attachment where AttachableValue == _AttachableURLWrapper {
#if SWT_TARGET_OS_APPLE
/// An operation queue to use for asynchronously reading data from disk.
private static let _operationQueue = OperationQueue()
@@ -51,6 +50,10 @@ extension Attachment where AttachableValue == _AttachableURLContainer {
/// attachment.
///
/// - Throws: Any error that occurs attempting to read from `url`.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public init(
contentsOf url: URL,
named preferredName: String? = nil,
@@ -91,8 +94,8 @@ extension Attachment where AttachableValue == _AttachableURLContainer {
}
#endif

let urlContainer = _AttachableURLContainer(url: url, data: data, isCompressedDirectory: isDirectory)
self.init(urlContainer, named: preferredName, sourceLocation: sourceLocation)
let urlWrapper = _AttachableURLWrapper(url: url, data: data, isCompressedDirectory: isDirectory)
self.init(urlWrapper, named: preferredName, sourceLocation: sourceLocation)
}
}

Original file line number Diff line number Diff line change
@@ -9,11 +9,16 @@
//

#if canImport(Foundation)
@_spi(Experimental) public import Testing
public import Testing
public import Foundation

@_spi(Experimental)
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
extension Data: Attachable {
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
try withUnsafeBytes(body)
}
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
//

#if canImport(Foundation)
@_spi(Experimental) import Testing
import Testing
import Foundation

/// An enumeration describing the encoding formats we support for `Encodable`
Original file line number Diff line number Diff line change
@@ -9,16 +9,15 @@
//

#if canImport(Foundation)
@_spi(Experimental) public import Testing
public import Testing
public import Foundation

/// A wrapper type representing file system objects and URLs that can be
/// attached indirectly.
///
/// You do not need to use this type directly. Instead, initialize an instance
/// of ``Attachment`` using a file URL.
@_spi(Experimental)
public struct _AttachableURLContainer: Sendable {
public struct _AttachableURLWrapper: Sendable {
/// The underlying URL.
var url: URL

@@ -31,8 +30,8 @@ public struct _AttachableURLContainer: Sendable {

// MARK: -

extension _AttachableURLContainer: AttachableContainer {
public var attachableValue: URL {
extension _AttachableURLWrapper: AttachableWrapper {
public var wrappedValue: URL {
url
}

2 changes: 1 addition & 1 deletion Sources/Overlays/_Testing_Foundation/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
# See http://swift.org/CONTRIBUTORS.txt for Swift project authors

add_library(_Testing_Foundation
Attachments/_AttachableURLContainer.swift
Attachments/_AttachableURLWrapper.swift
Attachments/EncodingFormat.swift
Attachments/Attachment+URL.swift
Attachments/Attachable+NSSecureCoding.swift
8 changes: 3 additions & 5 deletions Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ extension ABI {
case testStarted
case testCaseStarted
case issueRecorded
case valueAttached = "_valueAttached"
case valueAttached
case testCaseEnded
case testEnded
case testSkipped
@@ -50,9 +50,7 @@ extension ABI {
///
/// The value of this property is `nil` unless the value of the
/// ``kind-swift.property`` property is ``Kind-swift.enum/valueAttached``.
///
/// - Warning: Attachments are not yet part of the JSON schema.
var _attachment: EncodedAttachment<V>?
var attachment: EncodedAttachment<V>?

/// Human-readable messages associated with this event that can be presented
/// to the user.
@@ -82,7 +80,7 @@ extension ABI {
issue = EncodedIssue(encoding: recordedIssue, in: eventContext)
case let .valueAttached(attachment):
kind = .valueAttached
_attachment = EncodedAttachment(encoding: attachment, in: eventContext)
self.attachment = EncodedAttachment(encoding: attachment, in: eventContext)
case .testCaseEnded:
if eventContext.test?.isParameterized == false {
return nil
26 changes: 18 additions & 8 deletions Sources/Testing/Attachments/Attachable.swift
Original file line number Diff line number Diff line change
@@ -24,9 +24,12 @@
/// A type should conform to this protocol if it can be represented as a
/// sequence of bytes that would be diagnostically useful if a test fails. If a
/// type cannot conform directly to this protocol (such as a non-final class or
/// a type declared in a third-party module), you can create a container type
/// that conforms to ``AttachableContainer`` to act as a proxy.
@_spi(Experimental)
/// a type declared in a third-party module), you can create a wrapper type that
/// conforms to ``AttachableWrapper`` to act as a proxy.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public protocol Attachable: ~Copyable {
/// An estimate of the number of bytes of memory needed to store this value as
/// an attachment.
@@ -42,6 +45,10 @@ public protocol Attachable: ~Copyable {
///
/// - Complexity: O(1) unless `Self` conforms to `Collection`, in which case
/// up to O(_n_) where _n_ is the length of the collection.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
var estimatedAttachmentByteCount: Int? { get }

/// Call a function and pass a buffer representing this instance to it.
@@ -64,6 +71,10 @@ public protocol Attachable: ~Copyable {
/// the buffer to contain an image in PNG format, JPEG format, etc., but it
/// would not be idiomatic for the buffer to contain a textual description of
/// the image.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
borrowing func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R

/// Generate a preferred name for the given attachment.
@@ -80,6 +91,10 @@ public protocol Attachable: ~Copyable {
/// when adding `attachment` to a test report or persisting it to storage. The
/// default implementation of this function returns `suggestedName` without
/// any changes.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String
}

@@ -119,28 +134,24 @@ extension Attachable where Self: StringProtocol {

// Implement the protocol requirements for byte arrays and buffers so that
// developers can attach raw data when needed.
@_spi(Experimental)
extension Array<UInt8>: Attachable {
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
try withUnsafeBytes(body)
}
}

@_spi(Experimental)
extension ContiguousArray<UInt8>: Attachable {
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
try withUnsafeBytes(body)
}
}

@_spi(Experimental)
extension ArraySlice<UInt8>: Attachable {
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
try withUnsafeBytes(body)
}
}

@_spi(Experimental)
extension String: Attachable {
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
var selfCopy = self
@@ -150,7 +161,6 @@ extension String: Attachable {
}
}

@_spi(Experimental)
extension Substring: Attachable {
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
var selfCopy = self
Original file line number Diff line number Diff line change
@@ -21,11 +21,22 @@
/// A type can conform to this protocol if it represents another type that
/// cannot directly conform to ``Attachable``, such as a non-final class or a
/// type declared in a third-party module.
@_spi(Experimental)
public protocol AttachableContainer<AttachableValue>: Attachable, ~Copyable {
/// The type of the attachable value represented by this type.
associatedtype AttachableValue
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public protocol AttachableWrapper<Wrapped>: Attachable, ~Copyable {
/// The type of the underlying value represented by this type.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
associatedtype Wrapped

/// The attachable value represented by this instance.
var attachableValue: AttachableValue { get }
/// The underlying value represented by this instance.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
var wrappedValue: Wrapped { get }
}
99 changes: 70 additions & 29 deletions Sources/Testing/Attachments/Attachment.swift
Original file line number Diff line number Diff line change
@@ -18,7 +18,10 @@ private import _TestingInternals
/// of some type that conforms to ``Attachable``. Initialize an instance of
/// ``Attachment`` with that value and, optionally, a preferred filename to use
/// when writing to disk.
@_spi(Experimental)
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public struct Attachment<AttachableValue>: ~Copyable where AttachableValue: Attachable & ~Copyable {
/// Storage for ``attachableValue-7dyjv``.
fileprivate var _attachableValue: AttachableValue
@@ -51,6 +54,10 @@ public struct Attachment<AttachableValue>: ~Copyable where AttachableValue: Atta
/// testing library may substitute a different filename as needed. If the
/// value of this property has not been explicitly set, the testing library
/// will attempt to generate its own value.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public var preferredName: String {
let suggestedName = if let _preferredName, !_preferredName.isEmpty {
_preferredName
@@ -90,22 +97,26 @@ extension Attachment where AttachableValue: ~Copyable {
/// - sourceLocation: The source location of the call to this initializer.
/// This value is used when recording issues associated with the
/// attachment.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public init(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) {
self._attachableValue = attachableValue
self._preferredName = preferredName
self.sourceLocation = sourceLocation
}
}

@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
@_spi(ForToolsIntegrationOnly)
extension Attachment where AttachableValue == AnyAttachable {
/// Create a type-erased attachment from an instance of ``Attachment``.
///
/// - Parameters:
/// - attachment: The attachment to type-erase.
fileprivate init(_ attachment: Attachment<some Attachable & Sendable & Copyable>) {
self.init(
_attachableValue: AnyAttachable(attachableValue: attachment.attachableValue),
_attachableValue: AnyAttachable(wrappedValue: attachment.attachableValue),
fileSystemPath: attachment.fileSystemPath,
_preferredName: attachment._preferredName,
sourceLocation: attachment.sourceLocation
@@ -114,7 +125,7 @@ extension Attachment where AttachableValue == AnyAttachable {
}
#endif

/// A type-erased container type that represents any attachable value.
/// A type-erased wrapper type that represents any attachable value.
///
/// This type is not generally visible to developers. It is used when posting
/// events of kind ``Event/Kind/valueAttached(_:)``. Test tools authors who use
@@ -125,93 +136,103 @@ extension Attachment where AttachableValue == AnyAttachable {
// Swift's type system requires that this type be at least as visible as
// `Event.Kind.valueAttached(_:)`, otherwise it would be declared private.
// }
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
public struct AnyAttachable: AttachableContainer, Copyable, Sendable {
@_spi(ForToolsIntegrationOnly)
public struct AnyAttachable: AttachableWrapper, Copyable, Sendable {
#if !SWT_NO_LAZY_ATTACHMENTS
public typealias AttachableValue = any Attachable & Sendable /* & Copyable rdar://137614425 */
public typealias Wrapped = any Attachable & Sendable /* & Copyable rdar://137614425 */
#else
public typealias AttachableValue = [UInt8]
public typealias Wrapped = [UInt8]
#endif

public var attachableValue: AttachableValue
public var wrappedValue: Wrapped

init(attachableValue: AttachableValue) {
self.attachableValue = attachableValue
init(wrappedValue: Wrapped) {
self.wrappedValue = wrappedValue
}

public var estimatedAttachmentByteCount: Int? {
attachableValue.estimatedAttachmentByteCount
wrappedValue.estimatedAttachmentByteCount
}

public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
func open<T>(_ attachableValue: T, for attachment: borrowing Attachment<Self>) throws -> R where T: Attachable & Sendable & Copyable {
func open<T>(_ wrappedValue: T, for attachment: borrowing Attachment<Self>) throws -> R where T: Attachable & Sendable & Copyable {
let temporaryAttachment = Attachment<T>(
_attachableValue: attachableValue,
_attachableValue: wrappedValue,
fileSystemPath: attachment.fileSystemPath,
_preferredName: attachment._preferredName,
sourceLocation: attachment.sourceLocation
)
return try temporaryAttachment.withUnsafeBytes(body)
}
return try open(attachableValue, for: attachment)
return try open(wrappedValue, for: attachment)
}

public borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
func open<T>(_ attachableValue: T, for attachment: borrowing Attachment<Self>) -> String where T: Attachable & Sendable & Copyable {
func open<T>(_ wrappedValue: T, for attachment: borrowing Attachment<Self>) -> String where T: Attachable & Sendable & Copyable {
let temporaryAttachment = Attachment<T>(
_attachableValue: attachableValue,
_attachableValue: wrappedValue,
fileSystemPath: attachment.fileSystemPath,
_preferredName: attachment._preferredName,
sourceLocation: attachment.sourceLocation
)
return temporaryAttachment.preferredName
}
return open(attachableValue, for: attachment)
return open(wrappedValue, for: attachment)
}
}

// MARK: - Describing an attachment

extension Attachment where AttachableValue: ~Copyable {
@_documentation(visibility: private)
public var description: String {
let typeInfo = TypeInfo(describing: AttachableValue.self)
return #""\#(preferredName)": instance of '\#(typeInfo.unqualifiedName)'"#
}
}

extension Attachment: CustomStringConvertible {
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public var description: String {
#""\#(preferredName)": \#(String(describingForTest: attachableValue))"#
}
}

// MARK: - Getting an attachable value from an attachment

@_spi(Experimental)
extension Attachment where AttachableValue: ~Copyable {
/// The value of this attachment.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
@_disfavoredOverload public var attachableValue: AttachableValue {
_read {
yield _attachableValue
}
}
}

@_spi(Experimental)
extension Attachment where AttachableValue: AttachableContainer & ~Copyable {
extension Attachment where AttachableValue: AttachableWrapper & ~Copyable {
/// The value of this attachment.
///
/// When the attachable value's type conforms to ``AttachableContainer``, the
/// value of this property equals the container's underlying attachable value.
/// When the attachable value's type conforms to ``AttachableWrapper``, the
/// value of this property equals the wrapper's underlying attachable value.
/// To access the attachable value as an instance of `T` (where `T` conforms
/// to ``AttachableContainer``), specify the type explicitly:
/// to ``AttachableWrapper``), specify the type explicitly:
///
/// ```swift
/// let attachableValue = attachment.attachableValue as T
/// ```
public var attachableValue: AttachableValue.AttachableValue {
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public var attachableValue: AttachableValue.Wrapped {
_read {
yield attachableValue.attachableValue
yield attachableValue.wrappedValue
}
}
}
@@ -235,6 +256,10 @@ extension Attachment where AttachableValue: Sendable & Copyable {
/// disk.
///
/// An attachment can only be attached once.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
@_documentation(visibility: private)
public static func record(_ attachment: consuming Self, sourceLocation: SourceLocation = #_sourceLocation) {
var attachmentCopy = Attachment<AnyAttachable>(attachment)
@@ -263,6 +288,10 @@ extension Attachment where AttachableValue: Sendable & Copyable {
/// attaches it to the current test.
///
/// An attachment can only be attached once.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
@_documentation(visibility: private)
public static func record(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) {
record(Self(attachableValue, named: preferredName, sourceLocation: sourceLocation), sourceLocation: sourceLocation)
@@ -286,12 +315,16 @@ extension Attachment where AttachableValue: ~Copyable {
/// disk.
///
/// An attachment can only be attached once.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public static func record(_ attachment: consuming Self, sourceLocation: SourceLocation = #_sourceLocation) {
do {
let attachmentCopy = try attachment.withUnsafeBytes { buffer in
let attachableContainer = AnyAttachable(attachableValue: Array(buffer))
let attachableWrapper = AnyAttachable(wrappedValue: Array(buffer))
return Attachment<AnyAttachable>(
_attachableValue: attachableContainer,
_attachableValue: attachableWrapper,
fileSystemPath: attachment.fileSystemPath,
_preferredName: attachment.preferredName, // invokes preferredName(for:basedOn:)
sourceLocation: sourceLocation
@@ -325,6 +358,10 @@ extension Attachment where AttachableValue: ~Copyable {
/// attaches it to the current test.
///
/// An attachment can only be attached once.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
public static func record(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) {
record(Self(attachableValue, named: preferredName, sourceLocation: sourceLocation), sourceLocation: sourceLocation)
}
@@ -349,6 +386,10 @@ extension Attachment where AttachableValue: ~Copyable {
/// test report or to a file on disk. This function calls the
/// ``Attachable/withUnsafeBytes(for:_:)`` function on this attachment's
/// ``attachableValue-2tnj5`` property.
///
/// @Metadata {
/// @Available(Swift, introduced: 6.2)
/// }
@inlinable public borrowing func withUnsafeBytes<R>(_ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
try attachableValue.withUnsafeBytes(for: self, body)
}
@@ -382,7 +423,7 @@ extension Attachment where AttachableValue: ~Copyable {
/// This function is provided as a convenience to allow tools authors to write
/// attachments to persistent storage the same way that Swift Package Manager
/// does. You are not required to use this function.
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
@_spi(ForToolsIntegrationOnly)
public borrowing func write(toFileInDirectoryAtPath directoryPath: String) throws -> String {
try write(
toFileInDirectoryAtPath: directoryPath,
2 changes: 1 addition & 1 deletion Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ add_library(Testing
ABI/Encoded/ABI.EncodedMessage.swift
ABI/Encoded/ABI.EncodedTest.swift
Attachments/Attachable.swift
Attachments/AttachableContainer.swift
Attachments/AttachableWrapper.swift
Attachments/Attachment.swift
Events/Clock.swift
Events/Event.swift
1 change: 0 additions & 1 deletion Sources/Testing/Events/Event.swift
Original file line number Diff line number Diff line change
@@ -102,7 +102,6 @@ public struct Event: Sendable {
///
/// - Parameters:
/// - attachment: The attachment that was created.
@_spi(Experimental)
indirect case valueAttached(_ attachment: Attachment<AnyAttachable>)

/// A test ended.
1 change: 0 additions & 1 deletion Sources/Testing/Events/Recorder/Event.Symbol.swift
Original file line number Diff line number Diff line change
@@ -44,7 +44,6 @@ extension Event {
case details

/// The symbol to use when describing an instance of ``Attachment``.
@_spi(Experimental)
case attachment
}
}
1 change: 0 additions & 1 deletion Sources/Testing/Issues/Issue.swift
Original file line number Diff line number Diff line change
@@ -65,7 +65,6 @@ public struct Issue: Sendable {
///
/// - Parameters:
/// - error: The error which was associated with this issue.
@_spi(Experimental)
case valueAttachmentFailed(_ error: any Error)

/// An issue occurred due to misuse of the testing library.
1 change: 0 additions & 1 deletion Sources/Testing/Running/Configuration.swift
Original file line number Diff line number Diff line change
@@ -237,7 +237,6 @@ public struct Configuration: Sendable {
/// The value of this property must refer to a directory on the local file
/// system that already exists and which the current user can write to. If it
/// is a relative path, it is resolved to an absolute path automatically.
@_spi(Experimental)
public var attachmentsPath: String? {
get {
_attachmentsPath
32 changes: 32 additions & 0 deletions Sources/Testing/Testing.docc/Attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Attachments

<!--
This source file is part of the Swift.org open source project
Copyright (c) 2024–2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception
See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
-->

Attach values to tests to help diagnose issues and gather feedback.

## Overview

Attach values such as strings and files to tests. Implement the ``Attachable``
protocol to create your own attachable types.

## Topics

### Attaching values to tests

- ``Attachment``
- ``Attachable``
- ``AttachableWrapper``

<!-- TODO: set up DocC content for overlays if possible
### Attaching files to tests
- ``/Foundation/URL/init(contentsOf:named:sourceLocation:)``
-->
4 changes: 4 additions & 0 deletions Sources/Testing/Testing.docc/Documentation.md
Original file line number Diff line number Diff line change
@@ -69,3 +69,7 @@ their problems.
### Test customization

- <doc:Traits>

### Data collection

- <doc:Attachments>
60 changes: 60 additions & 0 deletions Sources/Testing/Testing.docc/MigratingFromXCTest.md
Original file line number Diff line number Diff line change
@@ -742,6 +742,66 @@ suite serially:

For more information, see <doc:Parallelization>.

### Attach values

In XCTest, you can create an instance of [`XCTAttachment`](https://developer.apple.com/documentation/xctest/xctattachment)
representing arbitrary data, files, property lists, encodable objects, images,
and other types of information that would be useful to have available if a test
fails. Swift Testing has an ``Attachment`` type that serves much the same
purpose.

To attach a value from a test to the output of a test run, that value must
conform to the ``Attachable`` protocol. The testing library provides default
conformances for various standard library and Foundation types.

If you want to attach a value of another type, and that type already conforms to
[`Encodable`](https://developer.apple.com/documentation/swift/encodable) or to
[`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding),
the testing library automatically provides a default implementation when you
import Foundation:

@Row {
@Column {
```swift
// Before
import Foundation

class Tortilla: NSSecureCoding { /* ... */ }

func testTortillaIntegrity() async {
let tortilla = Tortilla(diameter: .large)
...
let attachment = XCTAttachment(
archivableObject: tortilla
)
self.add(attachment)
}
```
}
@Column {
```swift
// After
import Foundation

struct Tortilla: Codable, Attachable { /* ... */ }

@Test func tortillaIntegrity() async {
let tortilla = Tortilla(diameter: .large)
...
Attachment.record(tortilla)
}
```
}
}

If you have a type that does not (or cannot) conform to `Encodable` or
`NSSecureCoding`, or if you want fine-grained control over how it is serialized
when attaching it to a test, you can provide your own implementation of
``Attachable/withUnsafeBytes(for:_:)``.

<!-- NOTE: not discussing attaching to activities here since there is not yet an
equivalent interface in Swift Testing. -->

## See Also

- <doc:DefiningTests>
4 changes: 2 additions & 2 deletions Tests/TestingTests/AttachmentTests.swift
Original file line number Diff line number Diff line change
@@ -8,11 +8,11 @@
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing
@testable @_spi(ForToolsIntegrationOnly) import Testing
private import _TestingInternals
#if canImport(Foundation)
import Foundation
@_spi(Experimental) import _Testing_Foundation
import _Testing_Foundation
#endif
#if canImport(CoreGraphics)
import CoreGraphics