diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift index ed1e6a2ee..5f15f9cbe 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift @@ -11,8 +11,6 @@ #if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) @_spi(Experimental) public import Testing -public import UniformTypeIdentifiers - extension Attachment { /// Initialize an instance of this type that encloses the given image. /// @@ -22,47 +20,9 @@ extension Attachment { /// - preferredName: The preferred name of the attachment when writing it /// to a test report or to disk. If `nil`, the testing library attempts /// to derive a reasonable filename for the attached value. - /// - contentType: The image format with which to encode `attachableValue`. - /// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. Pass `nil` to let the testing library decide - /// which image format to use. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// If the image format used for encoding (specified by the `contentType` - /// argument) does not support variable-quality encoding, the value of - /// this argument is ignored. - /// - sourceLocation: The source location of the call to this initializer. - /// This value is used when recording issues associated with the - /// attachment. - /// - /// This is the designated initializer for this type when attaching an image - /// that conforms to ``AttachableAsCGImage``. - fileprivate init( - attachableValue: T, - named preferredName: String?, - contentType: (any Sendable)?, - encodingQuality: Float, - sourceLocation: SourceLocation - ) where AttachableValue == _AttachableImageWrapper { - 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. - /// - /// - Parameters: - /// - attachableValue: The value that will be attached to the output of - /// the test run. - /// - preferredName: The preferred name of the attachment when writing it - /// to a test report or to disk. If `nil`, the testing library attempts - /// to derive a reasonable filename for the attached value. - /// - contentType: The image format with which to encode `attachableValue`. - /// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. Pass `nil` to let the testing library decide - /// which image format to use. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// If the image format used for encoding (specified by the `contentType` - /// argument) does not support variable-quality encoding, the value of - /// this argument is ignored. + /// - metadata: Optional metadata such as the image format to use when + /// encoding `image`. If `nil`, the testing library will infer the format + /// and other metadata. /// - sourceLocation: The source location of the call to this initializer. /// This value is used when recording issues associated with the /// attachment. @@ -72,45 +32,14 @@ extension Attachment { /// /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) @_spi(Experimental) - @available(_uttypesAPI, *) public init( _ attachableValue: T, - named preferredName: String? = nil, - as contentType: UTType?, - encodingQuality: Float = 1.0, - sourceLocation: SourceLocation = #_sourceLocation - ) where AttachableValue == _AttachableImageWrapper { - self.init(attachableValue: attachableValue, named: preferredName, contentType: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation) - } - - /// Initialize an instance of this type that encloses the given image. - /// - /// - Parameters: - /// - attachableValue: The value that will be attached to the output of - /// the test run. - /// - preferredName: The preferred name of the attachment when writing it - /// to a test report or to disk. If `nil`, the testing library attempts - /// to derive a reasonable filename for the attached value. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// If the image format used for encoding (specified by the `contentType` - /// argument) does not support variable-quality encoding, the value of - /// this argument is ignored. - /// - sourceLocation: The source location of the call to this initializer. - /// This value is used when recording issues associated with the - /// attachment. - /// - /// The following system-provided image types conform to the - /// ``AttachableAsCGImage`` protocol and can be attached to a test: - /// - /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) - @_spi(Experimental) - public init( - _ attachableValue: T, - named preferredName: String? = nil, - encodingQuality: Float = 1.0, + named preferredName: String?, + metadata: ImageAttachmentMetadata = .init(), sourceLocation: SourceLocation = #_sourceLocation ) where AttachableValue == _AttachableImageWrapper { - self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation) + let imageContainer = _AttachableImageWrapper(attachableValue) + self.init(imageContainer, named: preferredName, metadata: metadata, sourceLocation: sourceLocation) } } #endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift new file mode 100644 index 000000000..a43603cd7 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift @@ -0,0 +1,98 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +@_spi(Experimental) public import Testing +private import CoreGraphics + +public import UniformTypeIdentifiers + +/// A type defining metadata used when attaching an image to a test. +/// +/// The following system-provided image types can be attached to a test: +/// +/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) +@_spi(Experimental) +public struct ImageAttachmentMetadata: Sendable { + /// The encoding quality to use when encoding the represented image. + /// + /// If the image format used for encoding (specified by the ``contentType`` + /// property) does not support variable-quality encoding, the value of this + /// property is ignored. + public var encodingQuality: Float + + /// Storage for ``contentType``. + private var _contentType: (any Sendable)? + + /// The content type to use when encoding the image. + /// + /// The testing library uses this property to determine which image format to + /// encode the associated image as when it is attached to a test. + /// + /// If the value of this property does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), + /// the result is undefined. + @available(_uttypesAPI, *) + var contentType: UTType { + get { + if let contentType = _contentType as? UTType { + return contentType + } else { + return encodingQuality < 1.0 ? .jpeg : .png + } + } + set { + lazy var newValueDescription = newValue.localizedDescription ?? newValue.identifier + precondition( + newValue.conforms(to: .image), + "An image cannot be attached as an instance of type '\(newValueDescription)'. Use a type that conforms to 'public.image' instead." + ) + _contentType = newValue + } + } + + /// The content type to use when encoding the image, substituting a concrete + /// type for `UTType.image`. + /// + /// This property is not part of the public interface of the testing library. + @available(_uttypesAPI, *) + var computedContentType: UTType { + if let contentType = _contentType as? UTType, contentType != .image { + contentType + } else { + encodingQuality < 1.0 ? .jpeg : .png + } + } + + /// The type identifier (as a `CFString`) corresponding to this instance's + /// ``computedContentType`` property. + /// + /// The value of this property is used by ImageIO when serializing an image. + /// + /// This property is not part of the public interface of the testing library. + /// It is used by ImageIO below. + var typeIdentifier: CFString { + if #available(_uttypesAPI, *) { + computedContentType.identifier as CFString + } else { + encodingQuality < 1.0 ? kUTTypeJPEG : kUTTypePNG + } + } + + public init(encodingQuality: Float = 1.0) { + self.encodingQuality = encodingQuality + } + + @available(_uttypesAPI, *) + public init(encodingQuality: Float = 1.0, contentType: UTType) { + self.encodingQuality = encodingQuality + self.contentType = contentType + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift index 7aa1fd139..202679e01 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift @@ -13,7 +13,11 @@ public import Testing private import CoreGraphics private import ImageIO -import UniformTypeIdentifiers +private import UniformTypeIdentifiers + +#if canImport(CoreServices_Private) +private import CoreServices_Private +#endif /// ## Why can't images directly conform to Attachable? /// @@ -24,10 +28,7 @@ import UniformTypeIdentifiers /// event handler (primarily because `Event` is `Sendable`.) So we would have /// to eagerly serialize them, which is unnecessarily expensive if we know /// they're actually concurrency-safe. -/// 2. We would have no place to store metadata such as the encoding quality -/// (although in the future we may introduce a "metadata" associated type to -/// `Attachable` that could store that info.) -/// 3. `Attachable` has a requirement with `Self` in non-parameter, non-return +/// 2. `Attachable` has a requirement with `Self` in non-parameter, non-return /// position. As far as Swift is concerned, a non-final class cannot satisfy /// such a requirement, and all image types we care about are non-final /// classes. Thus, the compiler will steadfastly refuse to allow non-final @@ -57,71 +58,8 @@ public struct _AttachableImageWrapper: Sendable where Image: AttachableAs /// instances of this type it creates hold "safe" `NSImage` instances. nonisolated(unsafe) var image: Image - /// The encoding quality to use when encoding the represented image. - var encodingQuality: Float - - /// Storage for ``contentType``. - private var _contentType: (any Sendable)? - - /// The content type to use when encoding the image. - /// - /// The testing library uses this property to determine which image format to - /// encode the associated image as when it is attached to a test. - /// - /// If the value of this property does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. - @available(_uttypesAPI, *) - var contentType: UTType { - get { - if let contentType = _contentType as? UTType { - return contentType - } else { - return encodingQuality < 1.0 ? .jpeg : .png - } - } - set { - precondition( - newValue.conforms(to: .image), - "An image cannot be attached as an instance of type '\(newValue.identifier)'. Use a type that conforms to 'public.image' instead." - ) - _contentType = newValue - } - } - - /// The content type to use when encoding the image, substituting a concrete - /// type for `UTType.image`. - /// - /// This property is not part of the public interface of the testing library. - @available(_uttypesAPI, *) - var computedContentType: UTType { - if let contentType = _contentType as? UTType, contentType != .image { - contentType - } else { - encodingQuality < 1.0 ? .jpeg : .png - } - } - - /// The type identifier (as a `CFString`) corresponding to this instance's - /// ``computedContentType`` property. - /// - /// The value of this property is used by ImageIO when serializing an image. - /// - /// This property is not part of the public interface of the testing library. - /// It is used by ImageIO below. - var typeIdentifier: CFString { - if #available(_uttypesAPI, *) { - computedContentType.identifier as CFString - } else { - encodingQuality < 1.0 ? kUTTypeJPEG : kUTTypePNG - } - } - - init(image: Image, encodingQuality: Float, contentType: (any Sendable)?) { + init(_ image: borrowing Image) { self.image = image._makeCopyForAttachment() - self.encodingQuality = encodingQuality - if #available(_uttypesAPI, *), let contentType = contentType as? UTType { - self.contentType = contentType - } } } @@ -132,6 +70,8 @@ extension _AttachableImageWrapper: AttachableWrapper { image } + public typealias AttachmentMetadata = ImageAttachmentMetadata + public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { let data = NSMutableData() @@ -139,6 +79,7 @@ extension _AttachableImageWrapper: AttachableWrapper { let attachableCGImage = try image.attachableCGImage // Create the image destination. + let typeIdentifier = attachment.metadata.typeIdentifier guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, typeIdentifier, 1, nil) else { throw ImageAttachmentError.couldNotCreateImageDestination } @@ -147,7 +88,7 @@ extension _AttachableImageWrapper: AttachableWrapper { let orientation = image._attachmentOrientation let scaleFactor = image._attachmentScaleFactor let properties: [CFString: Any] = [ - kCGImageDestinationLossyCompressionQuality: CGFloat(encodingQuality), + kCGImageDestinationLossyCompressionQuality: CGFloat(attachment.metadata.encodingQuality), kCGImagePropertyOrientation: orientation, kCGImagePropertyDPIWidth: 72.0 * scaleFactor, kCGImagePropertyDPIHeight: 72.0 * scaleFactor, @@ -169,7 +110,7 @@ extension _AttachableImageWrapper: AttachableWrapper { public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { if #available(_uttypesAPI, *) { - return (suggestedName as NSString).appendingPathExtension(for: computedContentType) + return (suggestedName as NSString).appendingPathExtension(for: attachment.metadata.computedContentType) } return suggestedName diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index 46a1e11e6..2e17c236a 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -21,10 +21,15 @@ public import Foundation /// @Metadata { /// @Available(Swift, introduced: 6.2) /// } -extension Attachable where Self: Encodable & NSSecureCoding { +extension Attachable where Self: Encodable & NSSecureCoding, AttachmentMetadata == EncodableAttachmentMetadata? { @_documentation(visibility: private) public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try _Testing_Foundation.withUnsafeBytes(encoding: self, for: attachment, body) } + + @_documentation(visibility: private) + public func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { + makePreferredName(from: suggestedName, for: attachment, defaultFormat: .json) + } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift index 683888801..8239c99e7 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -11,6 +11,13 @@ #if canImport(Foundation) public import Testing private import Foundation +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) +private import UniformTypeIdentifiers +#endif + +#if canImport(CoreServices_Private) +private import CoreServices_Private +#endif /// A common implementation of ``withUnsafeBytes(for:_:)`` that is used when a /// type conforms to `Encodable`, whether or not it also conforms to @@ -27,15 +34,26 @@ private import Foundation /// /// - Throws: Whatever is thrown by `body`, or any error that prevented the /// creation of the buffer. -func withUnsafeBytes(encoding attachableValue: borrowing E, for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R where E: Attachable & Encodable { - let format = try EncodingFormat(for: attachment) +func withUnsafeBytes( + encoding attachableValue: borrowing E, + for attachment: borrowing Attachment, + _ body: (UnsafeRawBufferPointer) throws -> R +) throws -> R where E: Attachable & Encodable, E.AttachmentMetadata == EncodableAttachmentMetadata? { + let format: EncodableAttachmentMetadata.Format = if let metadata = attachment.metadata { + metadata.format + } else { + try .infer(fromFileName: attachment.preferredName) + } let data: Data switch format { case let .propertyListFormat(propertyListFormat): let plistEncoder = PropertyListEncoder() plistEncoder.outputFormat = propertyListFormat - data = try plistEncoder.encode(attachableValue) + if let metadata = attachment.metadata { + plistEncoder.userInfo = metadata.userInfo + } + data = try plistEncoder.encode(attachment.attachableValue) case .default: // The default format is JSON. fallthrough @@ -44,12 +62,51 @@ func withUnsafeBytes(encoding attachableValue: borrowing E, for attachment // require it be exported with (at least) package visibility which would // create a visible external dependency on Foundation in the main testing // library target. - data = try JSONEncoder().encode(attachableValue) + let jsonEncoder = JSONEncoder() + if let metadata = attachment.metadata { + jsonEncoder.userInfo = metadata.userInfo + + if let options = metadata.jsonEncodingOptions { + jsonEncoder.outputFormatting = options.outputFormatting + jsonEncoder.dateEncodingStrategy = options.dateEncodingStrategy + jsonEncoder.dataEncodingStrategy = options.dataEncodingStrategy + jsonEncoder.nonConformingFloatEncodingStrategy = options.nonConformingFloatEncodingStrategy + jsonEncoder.keyEncodingStrategy = options.keyEncodingStrategy + } + } + data = try jsonEncoder.encode(attachment.attachableValue) } return try data.withUnsafeBytes(body) } +/// A common implementation of ``preferredName(for:basedOn:)`` that is used when +/// a type conforms to `Encodable`, whether or not it also conforms to +/// `NSSecureCoding`. +/// +/// For more information, see ``Testing/Attachable/preferredName(for:basedOn:)``. +func makePreferredName( + from suggestedName: String, + for attachment: Attachment, + defaultFormat: EncodableAttachmentMetadata.Format +) -> String where E: Attachable, E.AttachmentMetadata == EncodableAttachmentMetadata? { +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) + if #available(_uttypesAPI, *) { + let format = attachment.metadata?.format ?? defaultFormat + switch format { + case .propertyListFormat: + return (suggestedName as NSString).appendingPathExtension(for: .propertyList) + case .json: + return (suggestedName as NSString).appendingPathExtension(for: .json) + default: + return suggestedName + } + } +#endif + + return suggestedName +} + // 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. @@ -58,6 +115,13 @@ func withUnsafeBytes(encoding attachableValue: borrowing E, for attachment /// @Available(Swift, introduced: 6.2) /// } extension Attachable where Self: Encodable { + public typealias AttachmentMetadata = EncodableAttachmentMetadata? +} + +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } +extension Attachable where Self: Encodable, AttachmentMetadata == EncodableAttachmentMetadata? { /// 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), /// then call a function and pass that buffer to it. @@ -74,9 +138,10 @@ extension Attachable where Self: Encodable { /// creation of the buffer. /// /// The testing library uses this function when writing an attachment to a - /// test report or to a file on disk. The encoding used depends on the path - /// extension specified by the value of `attachment`'s ``Testing/Attachment/preferredName`` - /// property: + /// test report or to a file on disk. If you do not provide any metadata when + /// you attach this object to a test, the testing library infers the encoding + /// format from the path extension on the `attachment`'s + /// ``Testing/Attachment/preferredName`` property: /// /// | Extension | Encoding Used | Encoder Used | /// |-|-|-| @@ -96,5 +161,9 @@ extension Attachable where Self: Encodable { public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try _Testing_Foundation.withUnsafeBytes(encoding: self, for: attachment, body) } + + public func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { + makePreferredName(from: suggestedName, for: attachment, defaultFormat: .json) + } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift index 4acbf4960..cc8d5aebb 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -20,6 +20,13 @@ public import Foundation /// @Available(Swift, introduced: 6.2) /// } extension Attachable where Self: NSSecureCoding { + public typealias AttachmentMetadata = EncodableAttachmentMetadata? +} + +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } +extension Attachable where Self: NSSecureCoding, AttachmentMetadata == EncodableAttachmentMetadata? { /// 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. /// @@ -35,9 +42,10 @@ extension Attachable where Self: NSSecureCoding { /// creation of the buffer. /// /// The testing library uses this function when writing an attachment to a - /// test report or to a file on disk. The encoding used depends on the path - /// extension specified by the value of `attachment`'s ``Testing/Attachment/preferredName`` - /// property: + /// test report or to a file on disk. If you do not provide any metadata when + /// you attach this object to a test, the testing library infers the encoding + /// format from the path extension on the `attachment`'s + /// ``Testing/Attachment/preferredName`` property: /// /// | Extension | Encoding Used | Encoder Used | /// |-|-|-| @@ -54,7 +62,11 @@ extension Attachable where Self: NSSecureCoding { /// @Available(Swift, introduced: 6.2) /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - let format = try EncodingFormat(for: attachment) + let format: EncodableAttachmentMetadata.Format = if let metadata = attachment.metadata { + metadata.format + } else { + try .infer(fromFileName: attachment.preferredName) + } var data = try NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: true) switch format { @@ -77,5 +89,9 @@ extension Attachable where Self: NSSecureCoding { return try data.withUnsafeBytes(body) } + + public func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { + makePreferredName(from: suggestedName, for: attachment, defaultFormat: .propertyListFormat(.binary)) + } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift index ce7b719a9..5f9fd1c02 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift @@ -16,6 +16,11 @@ public import Foundation /// @Available(Swift, introduced: 6.2) /// } extension Data: Attachable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + public typealias AttachmentMetadata = Never? + /// @Metadata { /// @Available(Swift, introduced: 6.2) /// } diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift new file mode 100644 index 000000000..1f0264a17 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift @@ -0,0 +1,178 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// + +#if canImport(Foundation) +import Testing +public import Foundation + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) +private import UniformTypeIdentifiers +#endif + +/// An enumeration describing the encoding formats supported by default when +/// encoding a value that conforms to ``Testing/Attachable`` and either +/// [`Encodable`](https://developer.apple.com/documentation/swift/encodable) +/// or [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding). +public struct EncodableAttachmentMetadata: Sendable { + /// An enumeration describing the encoding formats supported by default when + /// encoding a value that conforms to ``Testing/Attachable`` and either + /// [`Encodable`](https://developer.apple.com/documentation/swift/encodable) + /// or [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding). + public enum Format: Sendable { + /// The encoding format to use by default. + /// + /// The specific format this case corresponds to depends on if we are encoding + /// an `Encodable` value or an `NSSecureCoding` value. + case `default` + + /// A property list format. + /// + /// - Parameters: + /// - format: The corresponding property list format. + /// + /// OpenStep-style property lists are not supported. + case propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) + + /// The JSON format. + case json + } + + /// The format the attachable value should be encoded as. + public var format: Format + + /// A type describing the various JSON encoding options to use if + /// [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder) + /// is used to encode the attachable value. + public struct JSONEncodingOptions: Sendable { + /// The output format to produce. + public var outputFormatting: JSONEncoder.OutputFormatting + + /// The strategy to use in encoding dates. + public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy + + /// The strategy to use in encoding binary data. + public var dataEncodingStrategy: JSONEncoder.DataEncodingStrategy + + /// The strategy to use in encoding non-conforming numbers. + public var nonConformingFloatEncodingStrategy: JSONEncoder.NonConformingFloatEncodingStrategy + + /// The strategy to use for encoding keys. + public var keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy + } + + /// JSON encoding options to use if [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder) + /// is used to encode the attachable value. + /// + /// The default value of this property is `nil`, meaning that the default + /// options are used when encoding an attachable value as JSON. If an + /// attachable value is encoded in a format other than JSON, the value of this + /// property is ignored. + public var jsonEncodingOptions: JSONEncodingOptions? + + /// A user info dictionary to provide to the property list encoder or JSON + /// encoder when encoding the attachable value. + /// + /// The value of this property is ignored when encoding an attachable value + /// that conforms to [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding) + /// but does not conform to [`Encodable`](https://developer.apple.com/documentation/swift/encodable). + public var userInfo: [CodingUserInfoKey: any Sendable] + + public init(format: Format, jsonEncodingOptions: JSONEncodingOptions? = nil, userInfo: [CodingUserInfoKey: any Sendable] = [:]) { + self.format = format + self.jsonEncodingOptions = jsonEncodingOptions + self.userInfo = userInfo + } +} + +// MARK: - + +extension EncodableAttachmentMetadata.JSONEncodingOptions { + public init( + outputFormatting: JSONEncoder.OutputFormatting? = nil, + dateEncodingStrategy: JSONEncoder.DateEncodingStrategy? = nil, + dataEncodingStrategy: JSONEncoder.DataEncodingStrategy? = nil, + nonConformingFloatEncodingStrategy: JSONEncoder.NonConformingFloatEncodingStrategy? = nil, + keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy? = nil + ) { + self = .default + self.outputFormatting = outputFormatting ?? self.outputFormatting + self.dateEncodingStrategy = dateEncodingStrategy ?? self.dateEncodingStrategy + self.dataEncodingStrategy = dataEncodingStrategy ?? self.dataEncodingStrategy + self.nonConformingFloatEncodingStrategy = nonConformingFloatEncodingStrategy ?? self.nonConformingFloatEncodingStrategy + self.keyEncodingStrategy = keyEncodingStrategy ?? self.keyEncodingStrategy + } + + /// An instance of this type representing the default JSON encoding options + /// used by [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder). + public static let `default`: Self = { + // Get the default values from a real JSONEncoder for max authenticity! + let encoder = JSONEncoder() + + return Self( + outputFormatting: encoder.outputFormatting, + dateEncodingStrategy: encoder.dateEncodingStrategy, + dataEncodingStrategy: encoder.dataEncodingStrategy, + nonConformingFloatEncodingStrategy: encoder.nonConformingFloatEncodingStrategy, + keyEncodingStrategy: encoder.keyEncodingStrategy + ) + }() +} + +// MARK: - + +extension EncodableAttachmentMetadata.Format { + /// Initialize an instance by inferring it from the given file name. + /// + /// - Parameters: + /// - fileName: The file name to infer the format from. + /// + /// - Returns: The encoding format inferred from `fileName`. + /// + /// - Throws: If the attachment's content type or media type is unsupported. + static func infer(fromFileName fileName: String) throws -> Self { + let ext = (fileName as NSString).pathExtension + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) + // If the caller explicitly wants to encode their data as either XML or as a + // property list, use PropertyListEncoder. Otherwise, we'll fall back to + // JSONEncoder below. + if #available(_uttypesAPI, *), let contentType = UTType(filenameExtension: ext) { + if contentType == .data { + return .default + } else if contentType.conforms(to: .json) { + return .json + } else if contentType.conforms(to: .xml) { + return .propertyListFormat(.xml) + } else if contentType.conforms(to: .binaryPropertyList) || contentType == .propertyList { + return .propertyListFormat(.binary) + } else if contentType.conforms(to: .propertyList) { + return .propertyListFormat(.openStep) + } else { + let contentTypeDescription = contentType.localizedDescription ?? contentType.identifier + throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "The content type '\(contentTypeDescription)' cannot be used to attach an instance of \(type(of: self)) to a test."]) + } + } +#endif + + if ext.isEmpty { + // No path extension? No problem! Default data. + return .default + } else if ext.caseInsensitiveCompare("plist") == .orderedSame { + return .propertyListFormat(.binary) + } else if ext.caseInsensitiveCompare("xml") == .orderedSame { + return .propertyListFormat(.xml) + } else if ext.caseInsensitiveCompare("json") == .orderedSame { + return .json + } else { + throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "The path extension '.\(ext)' cannot be used to attach an instance of \(type(of: self)) to a test."]) + } + } +} +#endif diff --git a/Sources/Testing/Attachments/Attachable.swift b/Sources/Testing/Attachments/Attachable.swift index be466940b..63145126d 100644 --- a/Sources/Testing/Attachments/Attachable.swift +++ b/Sources/Testing/Attachments/Attachable.swift @@ -31,6 +31,23 @@ /// @Available(Swift, introduced: 6.2) /// } public protocol Attachable: ~Copyable { + /// A type containing additional metadata about an instance of this attachable + /// type that a developer can optionally include when creating an attachment. + /// + /// Instances of this type can contain metadata that is not contained directly + /// in the attachable value itself. An instance of this type can be passed to + /// the initializers of ``Attachment`` and then accessed later via + /// ``Attachment/metadata``. + /// + /// When implementing ``withUnsafeBufferPointer(for:_:)``, you can access the + /// attachment's ``Attachment/metadata`` property to get the metadata that was + /// passed when the attachment was created. + /// + /// This type can be [`Optional`](https://developer.apple.com/documentation/swift/optional). + /// By default, this type is equal to [`Never?`](https://developer.apple.com/documentation/swift/never), + /// meaning that an attachable value has no metadata associated with it. + associatedtype AttachmentMetadata: Sendable & Copyable = Never? + /// An estimate of the number of bytes of memory needed to store this value as /// an attachment. /// diff --git a/Sources/Testing/Attachments/Attachment.swift b/Sources/Testing/Attachments/Attachment.swift index 366e288d1..75f89639f 100644 --- a/Sources/Testing/Attachments/Attachment.swift +++ b/Sources/Testing/Attachments/Attachment.swift @@ -26,6 +26,12 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// Storage for ``attachableValue-7dyjv``. fileprivate var _attachableValue: AttachableValue + /// Metadata associated with this attachment. + /// + /// The type of this property depends on the type of the attachment's + /// ``attachableValue-7dyjv`` property. + public internal(set) var metadata: AttachableValue.AttachmentMetadata + /// The path to which the this attachment was written, if any. /// /// If a developer sets the ``Configuration/attachmentsPath`` property of the @@ -83,7 +89,6 @@ extension Attachment: Sendable where AttachableValue: Sendable {} // MARK: - Initializing an attachment -#if !SWT_NO_LAZY_ATTACHMENTS extension Attachment where AttachableValue: ~Copyable { /// Initialize an instance of this type that encloses the given attachable /// value. @@ -94,6 +99,32 @@ extension Attachment where AttachableValue: ~Copyable { /// - preferredName: The preferred name of the attachment when writing it to /// a test report or to disk. If `nil`, the testing library attempts to /// derive a reasonable filename for the attached value. + /// - metadata: Metadata to include with `attachableValue`. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + public init( + _ attachableValue: consuming AttachableValue, + named preferredName: String? = nil, + metadata: AttachableValue.AttachmentMetadata, + sourceLocation: SourceLocation = #_sourceLocation + ) { + self._attachableValue = attachableValue + self._preferredName = preferredName + self.metadata = metadata + self.sourceLocation = sourceLocation + } + + /// Initialize an instance of this type that encloses the given attachable + /// value. + /// + /// - Parameters: + /// - attachableValue: The value that will be attached to the output of the + /// test run. + /// - preferredName: The preferred name of the attachment when writing it to + /// a test report or to disk. If `nil`, the testing library attempts to + /// derive a reasonable filename for the attached value. + /// - metadata: Optional metadata to include with `attachableValue`. /// - sourceLocation: The source location of the call to this initializer. /// This value is used when recording issues associated with the /// attachment. @@ -101,13 +132,19 @@ extension Attachment where AttachableValue: ~Copyable { /// @Metadata { /// @Available(Swift, introduced: 6.2) /// } - public init(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { + public init( + _ attachableValue: consuming AttachableValue, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) where AttachableValue.AttachmentMetadata == M? { self._attachableValue = attachableValue self._preferredName = preferredName + self.metadata = nil self.sourceLocation = sourceLocation } } +#if !SWT_NO_LAZY_ATTACHMENTS @_spi(ForToolsIntegrationOnly) extension Attachment where AttachableValue == AnyAttachable { /// Create a type-erased attachment from an instance of ``Attachment``. @@ -117,6 +154,7 @@ extension Attachment where AttachableValue == AnyAttachable { fileprivate init(_ attachment: Attachment) { self.init( _attachableValue: AnyAttachable(wrappedValue: attachment.attachableValue), + metadata: attachment.metadata, fileSystemPath: attachment.fileSystemPath, _preferredName: attachment._preferredName, sourceLocation: attachment.sourceLocation @@ -138,6 +176,8 @@ extension Attachment where AttachableValue == AnyAttachable { /// } @_spi(ForToolsIntegrationOnly) public struct AnyAttachable: AttachableWrapper, Copyable, Sendable { + public typealias AttachmentMetadata = any Sendable /* & Copyable rdar://137614425 */ + #if !SWT_NO_LAZY_ATTACHMENTS public typealias Wrapped = any Attachable & Sendable /* & Copyable rdar://137614425 */ #else @@ -155,29 +195,28 @@ public struct AnyAttachable: AttachableWrapper, Copyable, Sendable { } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { +#if !SWT_NO_LAZY_ATTACHMENTS func open(_ wrappedValue: T, for attachment: borrowing Attachment) throws -> R where T: Attachable & Sendable & Copyable { + guard let metadata = attachment.metadata as? T.AttachmentMetadata else { + // If the types don't match, it's because somebody assigned a bad value + // to the type-erased attachment's metadata property after-the-fact. + throw APIMisuseError(description: "The metadata associated with \(wrappedValue) was not of expected type '\(T.AttachmentMetadata.self)' (was '\(type(of: attachment.metadata))' instead).") + } + let temporaryAttachment = Attachment( _attachableValue: wrappedValue, + metadata: metadata, fileSystemPath: attachment.fileSystemPath, _preferredName: attachment._preferredName, sourceLocation: attachment.sourceLocation ) return try temporaryAttachment.withUnsafeBytes(body) } - return try open(wrappedValue, for: attachment) - } - public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { - func open(_ wrappedValue: T, for attachment: borrowing Attachment) -> String where T: Attachable & Sendable & Copyable { - let temporaryAttachment = Attachment( - _attachableValue: wrappedValue, - fileSystemPath: attachment.fileSystemPath, - _preferredName: attachment._preferredName, - sourceLocation: attachment.sourceLocation - ) - return temporaryAttachment.preferredName - } - return open(wrappedValue, for: attachment) + return try open(wrappedValue, for: attachment) +#else + return try wrappedValue.withUnsafeBytes(body) +#endif } } @@ -274,6 +313,7 @@ extension Attachment where AttachableValue: Sendable & Copyable { /// - preferredName: The preferred name of the attachment when writing it to /// a test report or to disk. If `nil`, the testing library attempts to /// derive a reasonable filename for the attached value. + /// - metadata: Metadata to include with `attachableValue`. /// - sourceLocation: The source location of the call to this function. /// /// When attaching a value of a type that does not conform to both @@ -293,7 +333,47 @@ extension Attachment where AttachableValue: Sendable & Copyable { /// @Available(Swift, introduced: 6.2) /// } @_documentation(visibility: private) - public static func record(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { + public static func record( + _ attachableValue: consuming AttachableValue, + named preferredName: String? = nil, + metadata: AttachableValue.AttachmentMetadata, + sourceLocation: SourceLocation = #_sourceLocation + ) { + record(Self(attachableValue, named: preferredName, metadata: metadata, sourceLocation: sourceLocation), sourceLocation: sourceLocation) + } + + /// Attach a value to the current test. + /// + /// - Parameters: + /// - attachableValue: The value to attach. + /// - preferredName: The preferred name of the attachment when writing it to + /// a test report or to disk. If `nil`, the testing library attempts to + /// derive a reasonable filename for the attached value. + /// - metadata: Metadata to include with `attachableValue`. + /// - sourceLocation: The source location of the call to this function. + /// + /// When attaching a value of a type that does not conform to both + /// [`Sendable`](https://developer.apple.com/documentation/swift/sendable) and + /// [`Copyable`](https://developer.apple.com/documentation/swift/copyable), + /// the testing library encodes it as data immediately. If the value cannot be + /// encoded and an error is thrown, that error is recorded as an issue in the + /// current test and the attachment is not written to the test report or to + /// disk. + /// + /// This function creates a new instance of ``Attachment`` and immediately + /// 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 + ) where AttachableValue.AttachmentMetadata == M? { record(Self(attachableValue, named: preferredName, sourceLocation: sourceLocation), sourceLocation: sourceLocation) } } @@ -323,10 +403,16 @@ extension Attachment where AttachableValue: ~Copyable { do { let attachmentCopy = try attachment.withUnsafeBytes { buffer in let attachableWrapper = AnyAttachable(wrappedValue: Array(buffer)) +#if !SWT_NO_LAZY_ATTACHMENTS + let metadata = attachment.metadata +#else + let metadata: Never? = nil +#endif return Attachment( _attachableValue: attachableWrapper, + metadata: metadata, fileSystemPath: attachment.fileSystemPath, - _preferredName: attachment.preferredName, // invokes preferredName(for:basedOn:) + _preferredName: attachment.preferredName, sourceLocation: sourceLocation ) } @@ -344,6 +430,42 @@ extension Attachment where AttachableValue: ~Copyable { /// - preferredName: The preferred name of the attachment when writing it to /// a test report or to disk. If `nil`, the testing library attempts to /// derive a reasonable filename for the attached value. + /// - metadata: Metadata to include with `attachableValue`. + /// - sourceLocation: The source location of the call to this function. + /// + /// When attaching a value of a type that does not conform to both + /// [`Sendable`](https://developer.apple.com/documentation/swift/sendable) and + /// [`Copyable`](https://developer.apple.com/documentation/swift/copyable), + /// the testing library encodes it as data immediately. If the value cannot be + /// encoded and an error is thrown, that error is recorded as an issue in the + /// current test and the attachment is not written to the test report or to + /// disk. + /// + /// This function creates a new instance of ``Attachment`` and immediately + /// 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, + metadata: AttachableValue.AttachmentMetadata, + sourceLocation: SourceLocation = #_sourceLocation + ) { + record(Self(attachableValue, named: preferredName, metadata: metadata, sourceLocation: sourceLocation), sourceLocation: sourceLocation) + } + + /// Attach a value to the current test. + /// + /// - Parameters: + /// - attachableValue: The value to attach. + /// - preferredName: The preferred name of the attachment when writing it to + /// a test report or to disk. If `nil`, the testing library attempts to + /// derive a reasonable filename for the attached value. + /// - metadata: Metadata to include with `attachableValue`. /// - sourceLocation: The source location of the call to this function. /// /// When attaching a value of a type that does not conform to both @@ -362,7 +484,11 @@ extension Attachment where AttachableValue: ~Copyable { /// @Metadata { /// @Available(Swift, introduced: 6.2) /// } - public static func record(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { + public static func record( + _ attachableValue: consuming AttachableValue, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) where AttachableValue.AttachmentMetadata == M? { record(Self(attachableValue, named: preferredName, sourceLocation: sourceLocation), sourceLocation: sourceLocation) } } @@ -454,7 +580,7 @@ extension Attachment where AttachableValue: ~Copyable { borrowing func write(toFileInDirectoryAtPath directoryPath: String, usingPreferredName: Bool = true, appending suffix: @autoclosure () -> String) throws -> String { let result: String - let preferredName = usingPreferredName ? preferredName : Self.defaultPreferredName + let preferredName = try usingPreferredName ? preferredName : Self.defaultPreferredName var file: FileHandle? do { diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index be940371e..3bcdf52a8 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -44,6 +44,15 @@ struct AttachmentTests { #expect(attachment.description.contains("'MyAttachable'")) } + @Test func metadata() throws { + let metadataValue = Int.random(in: 0 ..< .max) + let attachableValue = MyAttachable(string: "", expectedMetadata: metadataValue) + let attachment = Attachment(attachableValue, named: "AttachmentTests.saveValue.html", metadata: metadataValue) + #expect(attachment.metadata == metadataValue) + + #expect(String.AttachmentMetadata.self == Never?.self) + } + #if !SWT_NO_FILE_IO func compare(_ attachableValue: borrowing MySendableAttachable, toContentsOfFileAtPath filePath: String) throws { let file = try FileHandle(forReadingAtPath: filePath) @@ -406,9 +415,9 @@ struct AttachmentTests { let attachment = Attachment(attachableValue, named: name) try open(attachment) } else { - let attachableValue = MyCodableAttachable(string: "stringly speaking") - let attachment = Attachment(attachableValue, named: name) - try open(attachment) +// let attachableValue = MyCodableAttachable(string: "stringly speaking") +// let attachment = Attachment(attachableValue, named: name) +// try open(attachment) } } @@ -429,13 +438,60 @@ struct AttachmentTests { try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } } } + + @Test("Attach Codable-conformant type with metadata") + func codableWithMetadata() async throws { + let attachableValue = MyCodableAttachable(string: "abc123") + let attachment = Attachment( + attachableValue, + metadata: .init( + format: .propertyListFormat(.xml) + ) + ) + try attachment.withUnsafeBytes { bytes in + var format = PropertyListSerialization.PropertyListFormat.binary + let object = try PropertyListSerialization.propertyList(from: Data(bytes), format: &format) + #expect(format == .xml) + let dict = try #require(object as? [String: Any]) + let string = try #require(dict["string"] as? String) + #expect(string == "abc123") + } + } + + @Test("Attach NSSecureCoding-conformant type with metadata") + func secureCodingWithMetadata() async throws { + let attachableValue = MySecureCodingAttachable(string: "abc123") + let attachment = Attachment( + attachableValue, + metadata: .init( + format: .propertyListFormat(.xml) + ) + ) + try attachment.withUnsafeBytes { bytes in + var format = PropertyListSerialization.PropertyListFormat.binary + _ = try PropertyListSerialization.propertyList(from: Data(bytes), format: &format) + #expect(format == .xml) + + let object = try #require(try NSKeyedUnarchiver.unarchivedObject(ofClass: MySecureCodingAttachable.self, from: Data(bytes))) + #expect(object.string == "abc123") + } + + #expect(throws: CocoaError.self) { + let attachableValue = MySecureCodingAttachable(string: "abc123") + let attachment = Attachment( + attachableValue, + metadata: .init(format: .json) + ) + try attachment.withUnsafeBytes { _ in } + } + } #endif } extension AttachmentTests { @Suite("Built-in conformances") struct BuiltInConformances { - func test(_ value: some Attachable) throws { + func test(_ value: A) throws where A: Attachable, A.AttachmentMetadata == Never? { #expect(value.estimatedAttachmentByteCount == 6) let attachment = Attachment(value) try attachment.withUnsafeBytes { buffer in @@ -538,15 +594,15 @@ extension AttachmentTests { } @available(_uttypesAPI, *) - @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [.png as UTType?, .jpeg, .gif, .image, nil]) - func attachCGImage(quality: Float, type: UTType?) throws { + @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [UTType.png, .jpeg, .gif, .image]) + func attachCGImage(quality: Float, type: UTType) throws { let image = try Self.cgImage.get() - let attachment = Attachment(image, named: "diamond", as: type, encodingQuality: quality) + let attachment = Attachment(image, named: "diamond", metadata: .init(encodingQuality: quality, contentType: type)) #expect(attachment.attachableValue === image) try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in #expect(buffer.count > 32) } - if let ext = type?.preferredFilenameExtension { + if let ext = type.preferredFilenameExtension { #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) } } @@ -555,8 +611,7 @@ extension AttachmentTests { @available(_uttypesAPI, *) @Test func cannotAttachCGImageWithNonImageType() async { await #expect(processExitsWith: .failure) { - let attachment = Attachment(try Self.cgImage.get(), named: "diamond", as: .mp3) - try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } + _ = Attachment(try Self.cgImage.get(), named: "diamond", metadata: .init(contentType: .mp3)) } } #endif @@ -567,10 +622,16 @@ extension AttachmentTests { // MARK: - Fixtures struct MyAttachable: Attachable, ~Copyable { + typealias AttachmentMetadata = Int? + var string: String var errorToThrow: (any Error)? + var expectedMetadata: Int? func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + if let expectedMetadata { + #expect(expectedMetadata == attachment.metadata) + } if let errorToThrow { throw errorToThrow }