From 4a39dfa156e66c3d31b8f88eb1745149c49d2648 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 14 Nov 2024 11:50:15 -0500 Subject: [PATCH 1/3] Add an associated `AttachmentMetadata` type to `Attachable`. This PR adds a new associated type to `Attachable` that can be used to supply additional metadata to an attachment that is specific to that type. Metadata is always optional and the default type is `Never` (i.e. by default there is no metadata.) The `Encodable` and `NSSecureCoding` conformances in the Foundation cross-import overlay have been updated such that this type equals a new structure that describes the format to use as well as options to pass to the JSON encoder (if one is used) and user info to pass to the plist or JSON encoders (if used.) We must use this type even for types that conform only to `NSSecureCoding`, otherwise we get compile-time errors about the type being ambiguous if a type conforms to both protocols and to `Attachable`. --- .../Attachable+Encodable+NSSecureCoding.swift | 2 + .../Attachments/Attachable+Encodable.swift | 21 +- .../Attachable+NSSecureCoding.swift | 4 +- .../EncodableAttachmentMetadata.swift | 194 ++++++++++++++++++ .../Attachments/EncodingFormat.swift | 84 -------- Sources/Testing/Attachments/Attachable.swift | 18 ++ Sources/Testing/Attachments/Attachment.swift | 19 +- Tests/TestingTests/AttachmentTests.swift | 60 ++++++ 8 files changed, 314 insertions(+), 88 deletions(-) create mode 100644 Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift delete mode 100644 Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index 80c75b5e9..bfc2c7393 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -20,6 +20,8 @@ public import Foundation @_spi(Experimental) extension Attachable where Self: Encodable & NSSecureCoding { + public typealias AttachmentMetadata = EncodableAttachmentMetadata + @_documentation(visibility: private) public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try _Testing_Foundation.withUnsafeBufferPointer(encoding: self, for: attachment, body) diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift index 3e26f7ead..95e367c59 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -28,13 +28,16 @@ private import Foundation /// - Throws: Whatever is thrown by `body`, or any error that prevented the /// creation of the buffer. func withUnsafeBufferPointer(encoding attachableValue: borrowing E, for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R where E: Attachable & Encodable { - let format = try EncodingFormat(for: attachment) + let format = try EncodableAttachmentMetadata.Format(for: attachment) let data: Data switch format { case let .propertyListFormat(propertyListFormat): let plistEncoder = PropertyListEncoder() plistEncoder.outputFormat = propertyListFormat + if let metadata = attachment.metadata as? EncodableAttachmentMetadata { + plistEncoder.userInfo = metadata.userInfo + } data = try plistEncoder.encode(attachableValue) case .default: // The default format is JSON. @@ -44,7 +47,19 @@ func withUnsafeBufferPointer(encoding attachableValue: borrowing E, for at // 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 as? EncodableAttachmentMetadata { + 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(attachableValue) } return try data.withUnsafeBytes(body) @@ -55,6 +70,8 @@ func withUnsafeBufferPointer(encoding attachableValue: borrowing E, for at // protocol for types that already support Codable. @_spi(Experimental) extension Attachable where Self: Encodable { + public typealias 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. diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift index 622787384..f543c85b0 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -17,6 +17,8 @@ public import Foundation // NSKeyedArchiver for encoding. @_spi(Experimental) extension Attachable where Self: NSSecureCoding { + public typealias 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. /// @@ -51,7 +53,7 @@ extension Attachable where Self: NSSecureCoding { /// some other path extension, that path extension must represent a type /// that conforms to [`UTType.propertyList`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/propertylist). public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - let format = try EncodingFormat(for: attachment) + let format = try EncodableAttachmentMetadata.Format(for: attachment) var data = try NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: true) switch format { diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift new file mode 100644 index 000000000..0c9611a7d --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift @@ -0,0 +1,194 @@ +// +// 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) +@_spi(Experimental) 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). +@_spi(Experimental) +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). +@_spi(Experimental) + 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: - + +@_spi(Experimental) +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 of this type representing the content type or media + /// type of the specified attachment. + /// + /// - Parameters: + /// - attachment: The attachment that will be encoded. + /// + /// - Throws: If the attachment's content type or media type is unsupported. + init(for attachment: borrowing Attachment) throws { + if let metadata = attachment.metadata { + if let format = metadata as? Self { + self = format + return + } else if let metadata = metadata as? EncodableAttachmentMetadata { + self = metadata.format + return + } else if let propertyListFormat = metadata as? PropertyListSerialization.PropertyListFormat { + self = .propertyListFormat(propertyListFormat) + return + } + } + + let ext = (attachment.preferredName 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 { + self = .default + } else if contentType.conforms(to: .json) { + self = .json + } else if contentType.conforms(to: .xml) { + self = .propertyListFormat(.xml) + } else if contentType.conforms(to: .binaryPropertyList) || contentType == .propertyList { + self = .propertyListFormat(.binary) + } else if contentType.conforms(to: .propertyList) { + self = .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."]) + } + return + } +#endif + + if ext.isEmpty { + // No path extension? No problem! Default data. + self = .default + } else if ext.caseInsensitiveCompare("plist") == .orderedSame { + self = .propertyListFormat(.binary) + } else if ext.caseInsensitiveCompare("xml") == .orderedSame { + self = .propertyListFormat(.xml) + } else if ext.caseInsensitiveCompare("json") == .orderedSame { + self = .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/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift deleted file mode 100644 index b60a54882..000000000 --- a/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// 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) -@_spi(Experimental) import Testing -import Foundation - -#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) -private import UniformTypeIdentifiers -#endif - -/// An enumeration describing the encoding formats we support for `Encodable` -/// and `NSSecureCoding` types that conform to `Attachable`. -enum EncodingFormat { - /// 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. - case propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) - - /// The JSON format. - case json - - /// Initialize an instance of this type representing the content type or media - /// type of the specified attachment. - /// - /// - Parameters: - /// - attachment: The attachment that will be encoded. - /// - /// - Throws: If the attachment's content type or media type is unsupported. - init(for attachment: borrowing Attachment) throws { - let ext = (attachment.preferredName 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 { - self = .default - } else if contentType.conforms(to: .json) { - self = .json - } else if contentType.conforms(to: .xml) { - self = .propertyListFormat(.xml) - } else if contentType.conforms(to: .binaryPropertyList) || contentType == .propertyList { - self = .propertyListFormat(.binary) - } else if contentType.conforms(to: .propertyList) { - self = .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."]) - } - return - } -#endif - - if ext.isEmpty { - // No path extension? No problem! Default data. - self = .default - } else if ext.caseInsensitiveCompare("plist") == .orderedSame { - self = .propertyListFormat(.binary) - } else if ext.caseInsensitiveCompare("xml") == .orderedSame { - self = .propertyListFormat(.xml) - } else if ext.caseInsensitiveCompare("json") == .orderedSame { - self = .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 990f80dee..4cec00e05 100644 --- a/Sources/Testing/Attachments/Attachable.swift +++ b/Sources/Testing/Attachments/Attachable.swift @@ -27,6 +27,24 @@ /// that conforms to ``AttachableContainer`` to act as a proxy. @_spi(Experimental) 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``. Metadata is always optional; if your attachable + /// value _must_ include some value, consider adding it as a property of that + /// type instead of adding it as 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. + /// + /// 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 034f5e03c..dbbecb49c 100644 --- a/Sources/Testing/Attachments/Attachment.swift +++ b/Sources/Testing/Attachments/Attachment.swift @@ -23,6 +23,12 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// Storage for ``attachableValue-7dyjv``. fileprivate var _attachableValue: AttachableValue + /// Metadata associated with this attachment. + /// + /// The value of this property is `nil` if you passed `nil` when initializing + /// this instance, or if you set it to `nil` at a later time. + public 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 @@ -78,12 +84,19 @@ 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: 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. - public init(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { + public init( + _ attachableValue: consuming AttachableValue, + named preferredName: String? = nil, + metadata: AttachableValue.AttachmentMetadata? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) { self._attachableValue = attachableValue self.preferredName = preferredName ?? Self.defaultPreferredName + self.metadata = metadata self.sourceLocation = sourceLocation } } @@ -97,6 +110,7 @@ extension Attachment where AttachableValue == AnyAttachable { fileprivate init(_ attachment: Attachment) { self.init( _attachableValue: AnyAttachable(attachableValue: attachment.attachableValue), + metadata: attachment.metadata, fileSystemPath: attachment.fileSystemPath, preferredName: attachment.preferredName, sourceLocation: attachment.sourceLocation @@ -118,6 +132,8 @@ extension Attachment where AttachableValue == AnyAttachable { // } @_spi(Experimental) @_spi(ForToolsIntegrationOnly) public struct AnyAttachable: AttachableContainer, Copyable, Sendable { + public typealias AttachmentMetadata = any Sendable /* & Copyable rdar://137614425 */ + #if !SWT_NO_LAZY_ATTACHMENTS public typealias AttachableValue = any Attachable & Sendable /* & Copyable rdar://137614425 */ #else @@ -138,6 +154,7 @@ public struct AnyAttachable: AttachableContainer, Copyable, Sendable { func open(_ attachableValue: T, for attachment: borrowing Attachment) throws -> R where T: Attachable & Sendable & Copyable { let temporaryAttachment = Attachment( _attachableValue: attachableValue, + metadata: attachment.metadata as? T.AttachmentMetadata, fileSystemPath: attachment.fileSystemPath, preferredName: attachment.preferredName, sourceLocation: attachment.sourceLocation diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 4013882a1..df12a7c73 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -44,6 +44,13 @@ 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) + } + #if !SWT_NO_FILE_IO func compare(_ attachableValue: borrowing MySendableAttachable, toContentsOfFileAtPath filePath: String) throws { let file = try FileHandle(forReadingAtPath: filePath) @@ -424,6 +431,53 @@ struct AttachmentTests { try attachment.attachableValue.withUnsafeBufferPointer(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.withUnsafeBufferPointer { 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.withUnsafeBufferPointer { 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.withUnsafeBufferPointer { _ in } + } + } #endif } @@ -557,10 +611,16 @@ extension AttachmentTests { // MARK: - Fixtures struct MyAttachable: Attachable, ~Copyable { + typealias AttachmentMetadata = Int + var string: String var errorToThrow: (any Error)? + var expectedMetadata: Int? func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + if let expectedMetadata { + #expect(expectedMetadata == attachment.metadata) + } if let errorToThrow { throw errorToThrow } From a19f7408e94e40a502287cec6a19689cd4433db9 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 11 Dec 2024 15:17:03 -0500 Subject: [PATCH 2/3] Allow non-optional metadata, adopt in CGImage overlay --- .../Attachment+AttachableAsCGImage.swift | 128 ++++-------------- .../Attachments/ImageAttachmentMetadata.swift | 81 +++++++++++ .../_AttachableImageContainer.swift | 69 +--------- .../Attachable+Encodable+NSSecureCoding.swift | 4 +- .../Attachments/Attachable+Encodable.swift | 15 +- .../Attachable+NSSecureCoding.swift | 5 +- .../Attachments/Data+Attachable.swift | 2 + .../EncodableAttachmentMetadata.swift | 14 +- Sources/Testing/Attachments/Attachable.swift | 9 +- Sources/Testing/Attachments/Attachment.swift | 57 ++++++-- Tests/TestingTests/AttachmentTests.swift | 19 +-- 11 files changed, 196 insertions(+), 207 deletions(-) create mode 100644 Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift index f93afb7f7..81cf2ddb5 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift @@ -13,6 +13,10 @@ public import UniformTypeIdentifiers +#if canImport(CoreServices_Private) +private import CoreServices_Private +#endif + extension Attachment { /// Initialize an instance of this type that encloses the given image. /// @@ -22,126 +26,42 @@ 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. + /// - 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. /// - /// This is the designated initializer for this type when attaching an image - /// that conforms to ``AttachableAsCGImage``. - fileprivate init( - attachableValue: T, + /// 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?, - contentType: (any Sendable)?, - encodingQuality: Float, - sourceLocation: SourceLocation + metadata: ImageAttachmentMetadata? = nil, + sourceLocation: SourceLocation = #_sourceLocation ) where AttachableValue == _AttachableImageContainer { - var imageContainer = _AttachableImageContainer(image: attachableValue, encodingQuality: encodingQuality) + var preferredName = preferredName ?? Self.defaultPreferredName + var metadata = metadata ?? ImageAttachmentMetadata() // Update the preferred name to include an extension appropriate for the // given content type. (Note the `else` branch duplicates the logic in // `preferredContentType(forEncodingQuality:)` but will go away once our // minimum deployment targets include the UniformTypeIdentifiers framework.) - var preferredName = preferredName ?? Self.defaultPreferredName if #available(_uttypesAPI, *) { - let contentType: UTType = contentType - .map { $0 as! UTType } - .flatMap { contentType in - if UTType.image.conforms(to: contentType) { - // This type is an abstract base type of .image (or .image itself.) - // We'll infer the concrete type based on other arguments. - return nil - } - return contentType - } ?? .preferred(forEncodingQuality: encodingQuality) - preferredName = (preferredName as NSString).appendingPathExtension(for: contentType) - imageContainer.contentType = contentType + preferredName = (preferredName as NSString).appendingPathExtension(for: metadata.contentType) } else { +#if canImport(CoreServices_Private) // The caller can't provide a content type, so we'll pick one for them. - let ext = if encodingQuality < 1.0 { - "jpg" - } else { - "png" - } - if (preferredName as NSString).pathExtension.caseInsensitiveCompare(ext) != .orderedSame { - preferredName = (preferredName as NSString).appendingPathExtension(ext) ?? preferredName - } + preferredName = _UTTypeCreateSuggestedFilename(preferredName as CFString, metadata.typeIdentifier)?.takeRetainedValue() ?? preferredName +#endif } - self.init(imageContainer, 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. - /// - 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) - @available(_uttypesAPI, *) - public init( - _ attachableValue: T, - named preferredName: String? = nil, - as contentType: UTType?, - encodingQuality: Float = 1.0, - sourceLocation: SourceLocation = #_sourceLocation - ) where AttachableValue == _AttachableImageContainer { - 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, - sourceLocation: SourceLocation = #_sourceLocation - ) where AttachableValue == _AttachableImageContainer { - self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation) + let imageContainer = _AttachableImageContainer(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..46c4c93b4 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift @@ -0,0 +1,81 @@ +// +// 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, *) + public var contentType: UTType { + get { + if let contentType = _contentType as? UTType { + return contentType + } else if encodingQuality < 1.0 { + return .jpeg + } else { + return .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'." + ) + _contentType = newValue + } + } + + public var typeIdentifier: CFString { + if #available(_uttypesAPI, *) { + contentType.identifier as CFString + } else if encodingQuality < 1.0 { + kUTTypeJPEG + } else { + 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/_AttachableImageContainer.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift index 5e8fcd227..4f843244a 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift @@ -24,10 +24,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,82 +54,28 @@ public struct _AttachableImageContainer: Sendable where Image: Attachable /// 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. - public var encodingQuality: Float - - /// Storage for ``contentType``. - private var _contentType: (any Sendable)? - - /// The content type to use when encoding the image. - /// - /// This property should eventually move up to ``Attachment``. It is not part - /// of the public interface of the testing library. - @available(_uttypesAPI, *) - var contentType: UTType? { - get { - _contentType as? UTType - } - set { - _contentType = newValue - } - } - - init(image: Image, encodingQuality: Float) { + init(_ image: borrowing Image) { self.image = image._makeCopyForAttachment() - self.encodingQuality = encodingQuality } } // MARK: - -@available(_uttypesAPI, *) -extension UTType { - /// Determine the preferred content type to encode this image as for a given - /// encoding quality. - /// - /// - Parameters: - /// - encodingQuality: The encoding quality to use when encoding the image. - /// - /// - Returns: The type to encode this image as. - static func preferred(forEncodingQuality encodingQuality: Float) -> Self { - // If the caller wants lossy encoding, use JPEG. - if encodingQuality < 1.0 { - return .jpeg - } - - // Lossless encoding implies PNG. - return .png - } -} - extension _AttachableImageContainer: AttachableContainer { public var attachableValue: Image { image } + public typealias AttachmentMetadata = ImageAttachmentMetadata + public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { let data = NSMutableData() // Convert the image to a CGImage. let attachableCGImage = try image.attachableCGImage - // Get the type to encode as. (Note the `else` branches duplicate the logic - // in `preferredContentType(forEncodingQuality:)` but will go away once our - // minimum deployment targets include the UniformTypeIdentifiers framework.) - let typeIdentifier: CFString - if #available(_uttypesAPI, *), let contentType { - guard contentType.conforms(to: .image) else { - throw ImageAttachmentError.contentTypeDoesNotConformToImage - } - typeIdentifier = contentType.identifier as CFString - } else if encodingQuality < 1.0 { - typeIdentifier = kUTTypeJPEG - } else { - typeIdentifier = kUTTypePNG - } - // Create the image destination. - guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, typeIdentifier, 1, nil) else { + guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, attachment.metadata.typeIdentifier, 1, nil) else { throw ImageAttachmentError.couldNotCreateImageDestination } @@ -140,7 +83,7 @@ extension _AttachableImageContainer: AttachableContainer { 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, diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index bfc2c7393..87cdaf2b0 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -19,9 +19,7 @@ public import Foundation // protocols.) @_spi(Experimental) -extension Attachable where Self: Encodable & NSSecureCoding { - public typealias AttachmentMetadata = EncodableAttachmentMetadata - +extension Attachable where Self: Encodable & NSSecureCoding, AttachmentMetadata == EncodableAttachmentMetadata? { @_documentation(visibility: private) public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try _Testing_Foundation.withUnsafeBufferPointer(encoding: self, for: attachment, body) diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift index 95e367c59..d8b33b45c 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -27,7 +27,11 @@ private import Foundation /// /// - Throws: Whatever is thrown by `body`, or any error that prevented the /// creation of the buffer. -func withUnsafeBufferPointer(encoding attachableValue: borrowing E, for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R where E: Attachable & Encodable { +func withUnsafeBufferPointer( + encoding attachableValue: borrowing E, + for attachment: borrowing Attachment, + _ body: (UnsafeRawBufferPointer) throws -> R +) throws -> R where E: Attachable & Encodable, E.AttachmentMetadata == EncodableAttachmentMetadata? { let format = try EncodableAttachmentMetadata.Format(for: attachment) let data: Data @@ -35,7 +39,7 @@ func withUnsafeBufferPointer(encoding attachableValue: borrowing E, for at case let .propertyListFormat(propertyListFormat): let plistEncoder = PropertyListEncoder() plistEncoder.outputFormat = propertyListFormat - if let metadata = attachment.metadata as? EncodableAttachmentMetadata { + if let metadata = attachment.metadata { plistEncoder.userInfo = metadata.userInfo } data = try plistEncoder.encode(attachableValue) @@ -48,7 +52,7 @@ func withUnsafeBufferPointer(encoding attachableValue: borrowing E, for at // create a visible external dependency on Foundation in the main testing // library target. let jsonEncoder = JSONEncoder() - if let metadata = attachment.metadata as? EncodableAttachmentMetadata { + if let metadata = attachment.metadata { jsonEncoder.userInfo = metadata.userInfo if let options = metadata.jsonEncodingOptions { @@ -70,8 +74,11 @@ func withUnsafeBufferPointer(encoding attachableValue: borrowing E, for at // protocol for types that already support Codable. @_spi(Experimental) extension Attachable where Self: Encodable { - public typealias AttachmentMetadata = EncodableAttachmentMetadata + public typealias AttachmentMetadata = EncodableAttachmentMetadata? +} +@_spi(Experimental) +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. diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift index f543c85b0..04152db0d 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -17,8 +17,11 @@ public import Foundation // NSKeyedArchiver for encoding. @_spi(Experimental) extension Attachable where Self: NSSecureCoding { - public typealias AttachmentMetadata = EncodableAttachmentMetadata + public typealias AttachmentMetadata = EncodableAttachmentMetadata? +} +@_spi(Experimental) +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. /// diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift index f931e5824..f53dc8677 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift @@ -14,6 +14,8 @@ public import Foundation @_spi(Experimental) extension Data: Attachable { + public typealias AttachmentMetadata = Never? + public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift index 0c9611a7d..3e7d21493 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift @@ -138,18 +138,10 @@ extension EncodableAttachmentMetadata.Format { /// - attachment: The attachment that will be encoded. /// /// - Throws: If the attachment's content type or media type is unsupported. - init(for attachment: borrowing Attachment) throws { + init(for attachment: borrowing Attachment) throws where A: Attachable, A.AttachmentMetadata == EncodableAttachmentMetadata? { if let metadata = attachment.metadata { - if let format = metadata as? Self { - self = format - return - } else if let metadata = metadata as? EncodableAttachmentMetadata { - self = metadata.format - return - } else if let propertyListFormat = metadata as? PropertyListSerialization.PropertyListFormat { - self = .propertyListFormat(propertyListFormat) - return - } + self = metadata.format + return } let ext = (attachment.preferredName as NSString).pathExtension diff --git a/Sources/Testing/Attachments/Attachable.swift b/Sources/Testing/Attachments/Attachable.swift index 4cec00e05..8a365376a 100644 --- a/Sources/Testing/Attachments/Attachable.swift +++ b/Sources/Testing/Attachments/Attachable.swift @@ -33,17 +33,16 @@ public protocol Attachable: ~Copyable { /// 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``. Metadata is always optional; if your attachable - /// value _must_ include some value, consider adding it as a property of that - /// type instead of adding it as metadata. + /// ``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. /// - /// By default, this type is equal to [`Never`](https://developer.apple.com/documentation/swift/never), + /// 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 + 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 dbbecb49c..dd58a8781 100644 --- a/Sources/Testing/Attachments/Attachment.swift +++ b/Sources/Testing/Attachments/Attachment.swift @@ -25,9 +25,9 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// Metadata associated with this attachment. /// - /// The value of this property is `nil` if you passed `nil` when initializing - /// this instance, or if you set it to `nil` at a later time. - public var metadata: AttachableValue.AttachmentMetadata? + /// The type of this property depends on the type of the attachment's + /// ``attachableValue-7dyjv`` property. + public var metadata: AttachableValue.AttachmentMetadata /// The path to which the this attachment was written, if any. /// @@ -73,7 +73,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. @@ -84,14 +83,14 @@ 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: Optional metadata to include with `attachableValue`. + /// - 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? = nil, + metadata: AttachableValue.AttachmentMetadata, sourceLocation: SourceLocation = #_sourceLocation ) { self._attachableValue = attachableValue @@ -99,8 +98,33 @@ extension Attachment where AttachableValue: ~Copyable { 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. + public init( + _ attachableValue: consuming AttachableValue, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) where AttachableValue.AttachmentMetadata == M? { + self._attachableValue = attachableValue + self.preferredName = preferredName ?? Self.defaultPreferredName + self.metadata = nil + self.sourceLocation = sourceLocation + } } +#if !SWT_NO_LAZY_ATTACHMENTS @_spi(Experimental) @_spi(ForToolsIntegrationOnly) extension Attachment where AttachableValue == AnyAttachable { /// Create a type-erased attachment from an instance of ``Attachment``. @@ -151,10 +175,19 @@ public struct AnyAttachable: AttachableContainer, Copyable, Sendable { } public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { +#if !SWT_NO_LAZY_ATTACHMENTS func open(_ attachableValue: 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. + // This should *probably* be represented as an "API misuse" error, not a + // system error. + throw SystemError(description: "The metadata associated with \(attachableValue) was not of expected type '\(T.AttachmentMetadata.self)' (was '\(type(of: attachment.metadata))' instead).") + } + let temporaryAttachment = Attachment( _attachableValue: attachableValue, - metadata: attachment.metadata as? T.AttachmentMetadata, + metadata: attachment.metadata as! T.AttachmentMetadata, fileSystemPath: attachment.fileSystemPath, preferredName: attachment.preferredName, sourceLocation: attachment.sourceLocation @@ -162,6 +195,9 @@ public struct AnyAttachable: AttachableContainer, Copyable, Sendable { return try temporaryAttachment.withUnsafeBufferPointer(body) } return try open(attachableValue, for: attachment) +#else + return try attachableValue.withUnsafeBytes(body) +#endif } } @@ -249,7 +285,12 @@ extension Attachment where AttachableValue: ~Copyable { do { let attachmentCopy = try withUnsafeBufferPointer { buffer in let attachableContainer = AnyAttachable(attachableValue: Array(buffer)) - return Attachment(_attachableValue: attachableContainer, fileSystemPath: fileSystemPath, preferredName: preferredName, sourceLocation: sourceLocation) +#if !SWT_NO_LAZY_ATTACHMENTS + let metadata = metadata +#else + let metadata: Never? = nil +#endif + return Attachment(_attachableValue: attachableContainer, metadata: metadata, fileSystemPath: fileSystemPath, preferredName: preferredName, sourceLocation: sourceLocation) } Event.post(.valueAttached(attachmentCopy)) } catch { diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index df12a7c73..a7530b0bf 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -49,6 +49,8 @@ struct AttachmentTests { 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 @@ -484,7 +486,7 @@ struct AttachmentTests { 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.withUnsafeBufferPointer { buffer in @@ -587,23 +589,24 @@ extension AttachmentTests { } @available(_uttypesAPI, *) - @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [.png as UTType?, .jpeg, .gif, .image, .data, 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, .data]) + 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.withUnsafeBufferPointer(for: attachment) { buffer in #expect(buffer.count > 32) } } +#if !SWT_NO_EXIT_TESTS @available(_uttypesAPI, *) @Test func cannotAttachCGImageWithNonImageType() async { - #expect(throws: ImageAttachmentError.contentTypeDoesNotConformToImage) { - let attachment = Attachment(try Self.cgImage.get(), named: "diamond", as: .mp3) - try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { _ in } + #expect(exitsWith: .failure) { + _ = Attachment(try Self.cgImage.get(), named: "diamond", metadata: .init(contentType: .mp3)) } } +#endif #endif } } @@ -611,7 +614,7 @@ extension AttachmentTests { // MARK: - Fixtures struct MyAttachable: Attachable, ~Copyable { - typealias AttachmentMetadata = Int + typealias AttachmentMetadata = Int? var string: String var errorToThrow: (any Error)? From 59b4f5d8183ff9079aa54ad49a407216ea836161 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 11 Dec 2024 16:41:52 -0500 Subject: [PATCH 3/3] More WIP stuff --- .../Attachment+AttachableAsCGImage.swift | 24 +------ .../Attachments/ImageAttachmentMetadata.swift | 22 +++++- .../_AttachableImageContainer.swift | 27 ++++++- .../Attachable+Encodable+NSSecureCoding.swift | 7 +- .../Attachments/Attachable+Encodable.swift | 71 ++++++++++++++----- .../Attachable+NSSecureCoding.swift | 17 +++-- .../EncodableAttachmentMetadata.swift | 35 ++++----- Sources/Testing/Attachments/Attachable.swift | 20 ++++++ Sources/Testing/Attachments/Attachment.swift | 23 +++--- Tests/TestingTests/AttachmentTests.swift | 4 +- 10 files changed, 168 insertions(+), 82 deletions(-) diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift index 81cf2ddb5..6ed4488ab 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift @@ -11,12 +11,6 @@ #if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) @_spi(ForSwiftTestingOnly) @_spi(Experimental) public import Testing -public import UniformTypeIdentifiers - -#if canImport(CoreServices_Private) -private import CoreServices_Private -#endif - extension Attachment { /// Initialize an instance of this type that encloses the given image. /// @@ -41,25 +35,9 @@ extension Attachment { public init( _ attachableValue: T, named preferredName: String?, - metadata: ImageAttachmentMetadata? = nil, + metadata: ImageAttachmentMetadata = .init(), sourceLocation: SourceLocation = #_sourceLocation ) where AttachableValue == _AttachableImageContainer { - var preferredName = preferredName ?? Self.defaultPreferredName - var metadata = metadata ?? ImageAttachmentMetadata() - - // Update the preferred name to include an extension appropriate for the - // given content type. (Note the `else` branch duplicates the logic in - // `preferredContentType(forEncodingQuality:)` but will go away once our - // minimum deployment targets include the UniformTypeIdentifiers framework.) - if #available(_uttypesAPI, *) { - preferredName = (preferredName as NSString).appendingPathExtension(for: metadata.contentType) - } else { -#if canImport(CoreServices_Private) - // The caller can't provide a content type, so we'll pick one for them. - preferredName = _UTTypeCreateSuggestedFilename(preferredName as CFString, metadata.typeIdentifier)?.takeRetainedValue() ?? preferredName -#endif - } - let imageContainer = _AttachableImageContainer(attachableValue) self.init(imageContainer, named: preferredName, metadata: metadata, sourceLocation: sourceLocation) } diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift index 46c4c93b4..85a8f60df 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentMetadata.swift @@ -50,17 +50,35 @@ public struct ImageAttachmentMetadata: Sendable { } } set { + lazy var newValueDescription = newValue.localizedDescription ?? newValue.identifier 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'." + "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`. + @available(_uttypesAPI, *) + var computedContentType: UTType { + if let contentType = _contentType as? UTType, contentType != .image { + return contentType + } else if encodingQuality < 1.0 { + return .jpeg + } else { + return .png + } + } + + /// The type identifier (as a `CFString`) corresponding to this instance's + /// ``contentType`` property. + /// + /// The value of this property is used by ImageIO when serializing an image. public var typeIdentifier: CFString { if #available(_uttypesAPI, *) { - contentType.identifier as CFString + computedContentType.identifier as CFString } else if encodingQuality < 1.0 { kUTTypeJPEG } else { diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift index 4f843244a..f479b4f0a 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift @@ -13,7 +13,11 @@ 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? /// @@ -75,7 +79,8 @@ extension _AttachableImageContainer: AttachableContainer { let attachableCGImage = try image.attachableCGImage // Create the image destination. - guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, attachment.metadata.typeIdentifier, 1, nil) else { + let typeIdentifier = attachment.metadata.typeIdentifier + guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, typeIdentifier, 1, nil) else { throw ImageAttachmentError.couldNotCreateImageDestination } @@ -102,5 +107,23 @@ extension _AttachableImageContainer: AttachableContainer { try body(UnsafeRawBufferPointer(start: data.bytes, count: data.length)) } } + + public borrowing func makePreferredName(from suggestedName: String, for attachment: borrowing Attachment) -> String { + let preferredName = attachment.preferredName + + if #available(_uttypesAPI, *) { + let metadata = attachment.metadata + let contentType = metadata.contentType + if contentType != .image { + return (preferredName as NSString).appendingPathExtension(for: metadata.contentType) + } else if metadata.encodingQuality < 1.0 { + return (preferredName as NSString).appendingPathExtension(for: .jpeg) + } else { + return (preferredName as NSString).appendingPathExtension(for: .png) + } + } + + return preferredName + } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index 87cdaf2b0..03ef4d783 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -22,7 +22,12 @@ public import Foundation extension Attachable where Self: Encodable & NSSecureCoding, AttachmentMetadata == EncodableAttachmentMetadata? { @_documentation(visibility: private) public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - try _Testing_Foundation.withUnsafeBufferPointer(encoding: self, for: attachment, body) + try _Testing_Foundation.withUnsafeBufferPointer(for: attachment, body) + } + + @_documentation(visibility: private) + public func makePreferredName(from suggestedName: String, for attachment: borrowing Attachment) -> String { + _Testing_Foundation.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 d8b33b45c..86ef52b8c 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -12,27 +12,28 @@ @_spi(Experimental) 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 ``withUnsafeBufferPointer(for:_:)`` that is /// used when a type conforms to `Encodable`, whether or not it also conforms /// to `NSSecureCoding`. /// -/// - Parameters: -/// - attachableValue: The value to encode. -/// - attachment: The attachment that is requesting a buffer (that is, the -/// attachment containing this instance.) -/// - body: A function to call. A temporary buffer containing a data -/// representation of this instance is passed to it. -/// -/// - Returns: Whatever is returned by `body`. -/// -/// - Throws: Whatever is thrown by `body`, or any error that prevented the -/// creation of the buffer. +/// For more information, see ``Testing/Attachable/withUnsafeBufferPointer(for:_:)``. func withUnsafeBufferPointer( - encoding attachableValue: borrowing E, for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R ) throws -> R where E: Attachable & Encodable, E.AttachmentMetadata == EncodableAttachmentMetadata? { - let format = try EncodableAttachmentMetadata.Format(for: attachment) + let format: EncodableAttachmentMetadata.Format = if let metadata = attachment.metadata { + metadata.format + } else { + try .infer(fromFileName: attachment.preferredName) + } let data: Data switch format { @@ -42,7 +43,7 @@ func withUnsafeBufferPointer( if let metadata = attachment.metadata { plistEncoder.userInfo = metadata.userInfo } - data = try plistEncoder.encode(attachableValue) + data = try plistEncoder.encode(attachment.attachableValue) case .default: // The default format is JSON. fallthrough @@ -63,12 +64,39 @@ func withUnsafeBufferPointer( jsonEncoder.keyEncodingStrategy = options.keyEncodingStrategy } } - data = try jsonEncoder.encode(attachableValue) + data = try jsonEncoder.encode(attachment.attachableValue) } return try data.withUnsafeBytes(body) } +/// A common implementation of ``makePreferredName(from:for:)`` that is used +/// when a type conforms to `Encodable`, whether or not it also conforms to +/// `NSSecureCoding`. +/// +/// For more information, see ``Testing/Attachable/makePreferredName(from:for:)``. +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. @@ -95,9 +123,10 @@ extension Attachable where Self: Encodable, AttachmentMetadata == EncodableAttac /// 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 | /// |-|-|-| @@ -116,7 +145,11 @@ extension Attachable where Self: Encodable, AttachmentMetadata == EncodableAttac /// that conforms to [`UTType.propertyList`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/propertylist) /// or to [`UTType.json`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/json). public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - try _Testing_Foundation.withUnsafeBufferPointer(encoding: self, for: attachment, body) + try _Testing_Foundation.withUnsafeBufferPointer(for: attachment, body) + } + + public func makePreferredName(from suggestedName: String, for attachment: borrowing Attachment) -> String { + _Testing_Foundation.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 04152db0d..842325570 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -37,9 +37,10 @@ extension Attachable where Self: NSSecureCoding, AttachmentMetadata == 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 | /// |-|-|-| @@ -56,7 +57,11 @@ extension Attachable where Self: NSSecureCoding, AttachmentMetadata == Encodable /// some other path extension, that path extension must represent a type /// that conforms to [`UTType.propertyList`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/propertylist). public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - let format = try EncodableAttachmentMetadata.Format(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 { @@ -79,5 +84,9 @@ extension Attachable where Self: NSSecureCoding, AttachmentMetadata == Encodable return try data.withUnsafeBytes(body) } + + public func makePreferredName(from suggestedName: String, for attachment: borrowing Attachment) -> String { + _Testing_Foundation.makePreferredName(from: suggestedName, for: attachment, defaultFormat: .propertyListFormat(.binary)) + } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift index 3e7d21493..15ea403c7 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/EncodableAttachmentMetadata.swift @@ -131,20 +131,16 @@ extension EncodableAttachmentMetadata.JSONEncodingOptions { // MARK: - extension EncodableAttachmentMetadata.Format { - /// Initialize an instance of this type representing the content type or media - /// type of the specified attachment. + /// Initialize an instance by inferring it from the given file name. /// /// - Parameters: - /// - attachment: The attachment that will be encoded. + /// - 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. - init(for attachment: borrowing Attachment) throws where A: Attachable, A.AttachmentMetadata == EncodableAttachmentMetadata? { - if let metadata = attachment.metadata { - self = metadata.format - return - } - - let ext = (attachment.preferredName as NSString).pathExtension + 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 @@ -152,32 +148,31 @@ extension EncodableAttachmentMetadata.Format { // JSONEncoder below. if #available(_uttypesAPI, *), let contentType = UTType(filenameExtension: ext) { if contentType == .data { - self = .default + return .default } else if contentType.conforms(to: .json) { - self = .json + return .json } else if contentType.conforms(to: .xml) { - self = .propertyListFormat(.xml) + return .propertyListFormat(.xml) } else if contentType.conforms(to: .binaryPropertyList) || contentType == .propertyList { - self = .propertyListFormat(.binary) + return .propertyListFormat(.binary) } else if contentType.conforms(to: .propertyList) { - self = .propertyListFormat(.openStep) + 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."]) } - return } #endif if ext.isEmpty { // No path extension? No problem! Default data. - self = .default + return .default } else if ext.caseInsensitiveCompare("plist") == .orderedSame { - self = .propertyListFormat(.binary) + return .propertyListFormat(.binary) } else if ext.caseInsensitiveCompare("xml") == .orderedSame { - self = .propertyListFormat(.xml) + return .propertyListFormat(.xml) } else if ext.caseInsensitiveCompare("json") == .orderedSame { - self = .json + 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."]) } diff --git a/Sources/Testing/Attachments/Attachable.swift b/Sources/Testing/Attachments/Attachable.swift index 8a365376a..72e63e518 100644 --- a/Sources/Testing/Attachments/Attachable.swift +++ b/Sources/Testing/Attachments/Attachable.swift @@ -81,6 +81,22 @@ public protocol Attachable: ~Copyable { /// would not be idiomatic for the buffer to contain a textual description of /// the image. borrowing func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R + + /// Generate a preferred name for the given attachment. + /// + /// - Parameters: + /// - suggestedName: A suggested name to use as the basis of the preferred + /// name. This string was provided by the developer when they initialized + /// `attachment`. + /// - attachment: The attachment that needs to be named. + /// + /// - Returns: The preferred name for `attachment`. + /// + /// The testing library uses this function to determine the best name to use + /// when adding `attachment` to a test report or persisting it to storage. The + /// default implementation of this function returns `suggestedName` without + /// any changes. + borrowing func makePreferredName(from suggestedName: String, for attachment: borrowing Attachment) -> String } // MARK: - Default implementations @@ -89,6 +105,10 @@ extension Attachable where Self: ~Copyable { public var estimatedAttachmentByteCount: Int? { nil } + + public borrowing func makePreferredName(from suggestedName: String, for attachment: borrowing Attachment) -> String { + suggestedName + } } extension Attachable where Self: Collection, Element == UInt8 { diff --git a/Sources/Testing/Attachments/Attachment.swift b/Sources/Testing/Attachments/Attachment.swift index dd58a8781..98b58b0d6 100644 --- a/Sources/Testing/Attachments/Attachment.swift +++ b/Sources/Testing/Attachments/Attachment.swift @@ -27,7 +27,7 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// /// The type of this property depends on the type of the attachment's /// ``attachableValue-7dyjv`` property. - public var metadata: AttachableValue.AttachmentMetadata + public internal(set) var metadata: AttachableValue.AttachmentMetadata /// The path to which the this attachment was written, if any. /// @@ -42,6 +42,9 @@ public struct Attachment: ~Copyable where AttachableValue: Atta @_spi(ForToolsIntegrationOnly) public var fileSystemPath: String? + /// The preferred name provided when this instance was created. + fileprivate var _preferredName: String? + /// The default preferred name to use if the developer does not supply one. @_spi(ForSwiftTestingOnly) public static var defaultPreferredName: String { @@ -55,7 +58,9 @@ public struct Attachment: ~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. - public var preferredName: String + public var preferredName: String { + attachableValue.makePreferredName(from: _preferredName ?? Self.defaultPreferredName, for: self) + } /// The source location of this instance. /// @@ -94,7 +99,7 @@ extension Attachment where AttachableValue: ~Copyable { sourceLocation: SourceLocation = #_sourceLocation ) { self._attachableValue = attachableValue - self.preferredName = preferredName ?? Self.defaultPreferredName + self._preferredName = preferredName self.metadata = metadata self.sourceLocation = sourceLocation } @@ -118,7 +123,7 @@ extension Attachment where AttachableValue: ~Copyable { sourceLocation: SourceLocation = #_sourceLocation ) where AttachableValue.AttachmentMetadata == M? { self._attachableValue = attachableValue - self.preferredName = preferredName ?? Self.defaultPreferredName + self._preferredName = preferredName self.metadata = nil self.sourceLocation = sourceLocation } @@ -136,7 +141,7 @@ extension Attachment where AttachableValue == AnyAttachable { _attachableValue: AnyAttachable(attachableValue: attachment.attachableValue), metadata: attachment.metadata, fileSystemPath: attachment.fileSystemPath, - preferredName: attachment.preferredName, + _preferredName: attachment._preferredName, sourceLocation: attachment.sourceLocation ) } @@ -187,9 +192,9 @@ public struct AnyAttachable: AttachableContainer, Copyable, Sendable { let temporaryAttachment = Attachment( _attachableValue: attachableValue, - metadata: attachment.metadata as! T.AttachmentMetadata, + metadata: metadata, fileSystemPath: attachment.fileSystemPath, - preferredName: attachment.preferredName, + _preferredName: attachment._preferredName, sourceLocation: attachment.sourceLocation ) return try temporaryAttachment.withUnsafeBufferPointer(body) @@ -290,7 +295,7 @@ extension Attachment where AttachableValue: ~Copyable { #else let metadata: Never? = nil #endif - return Attachment(_attachableValue: attachableContainer, metadata: metadata, fileSystemPath: fileSystemPath, preferredName: preferredName, sourceLocation: sourceLocation) + return Attachment(_attachableValue: attachableContainer, metadata: metadata, fileSystemPath: fileSystemPath, _preferredName: _preferredName, sourceLocation: sourceLocation) } Event.post(.valueAttached(attachmentCopy)) } catch { @@ -383,7 +388,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 a7530b0bf..6a562d8bc 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -589,7 +589,7 @@ extension AttachmentTests { } @available(_uttypesAPI, *) - @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [UTType.png, .jpeg, .gif, .image, .data]) + @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", metadata: .init(encodingQuality: quality, contentType: type)) @@ -602,7 +602,7 @@ extension AttachmentTests { #if !SWT_NO_EXIT_TESTS @available(_uttypesAPI, *) @Test func cannotAttachCGImageWithNonImageType() async { - #expect(exitsWith: .failure) { + await #expect(exitsWith: .failure) { _ = Attachment(try Self.cgImage.get(), named: "diamond", metadata: .init(contentType: .mp3)) } }