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 d79a43fcb..c3b69a61f 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 431e08d24..a0110e782 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -23,6 +23,13 @@ struct AttachmentTests { attachment.attach() } + @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) @@ -397,6 +404,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 } @@ -449,10 +503,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 }