From 86e3db9305c2e4b761683a79d732546b4d78b646 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 22 Jun 2026 12:19:45 -0400 Subject: [PATCH] Add `Attachment.init(encoding:as:...)` --- .../Attachable+Encodable+NSSecureCoding.swift | 5 +- .../Attachments/Attachable+Encodable.swift | 43 +-- .../Attachable+NSSecureCoding.swift | 26 +- .../AttachableEncodingFormat.swift | 115 ++++++++ .../Attachment+Encodable&NSSecureCoding.swift | 264 ++++++++++++++++++ .../Attachments/EncodingFormat.swift | 56 ---- .../_AttachableEncodableWrapper.swift | 158 +++++++++++ .../_Testing_Foundation/CMakeLists.txt | 4 +- Tests/TestingTests/AttachmentTests.swift | 29 +- 9 files changed, 577 insertions(+), 123 deletions(-) create mode 100644 Sources/Overlays/_Testing_Foundation/Attachments/AttachableEncodingFormat.swift create mode 100644 Sources/Overlays/_Testing_Foundation/Attachments/Attachment+Encodable&NSSecureCoding.swift delete mode 100644 Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift create mode 100644 Sources/Overlays/_Testing_Foundation/Attachments/_AttachableEncodableWrapper.swift diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index 9ad9798f4..cead770cc 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -9,7 +9,7 @@ // #if canImport(Foundation) && !SWT_NO_CODABLE -public import Testing +@_spi(ForToolsIntegrationOnly) public import Testing public import Foundation // This implementation is necessary to let the compiler disambiguate when a type @@ -25,7 +25,8 @@ public import Foundation extension Attachable where Self: Encodable & NSSecureCoding { @_documentation(visibility: private) public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - try _Testing_Foundation.withUnsafeBytes(encoding: self, for: attachment, body) + let attachment = try Attachment(encoding: attachment.attachableValue, as: nil as AttachableEncodingFormat?, named: attachment.preferredName, sourceLocation: attachment.sourceLocation) + return try attachment.withUnsafeBytes(body) } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift index da19c62fd..1cb72360c 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -9,47 +9,9 @@ // #if canImport(Foundation) && !SWT_NO_CODABLE -public import Testing +@_spi(ForToolsIntegrationOnly) public import Testing private import Foundation -/// A common implementation of ``withUnsafeBytes(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. -func withUnsafeBytes(encoding attachableValue: borrowing E, for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R where E: Attachable & Encodable { - let format = try EncodingFormat(for: attachment) - - let data: Data - switch format { - case let .propertyListFormat(propertyListFormat): - let plistEncoder = PropertyListEncoder() - plistEncoder.outputFormat = propertyListFormat - data = try plistEncoder.encode(attachableValue) - case .default: - // The default format is JSON. - fallthrough - case .json: - // We cannot use our own JSON encoding wrapper here because that would - // 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) - } - - return try data.withUnsafeBytes(body) -} - // 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. @@ -96,7 +58,8 @@ extension Attachable where Self: Encodable { /// @Available(Xcode, introduced: 26.0) /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - try _Testing_Foundation.withUnsafeBytes(encoding: self, for: attachment, body) + let attachment = try Attachment(encoding: attachment.attachableValue, named: attachment.preferredName, sourceLocation: attachment.sourceLocation) + return try attachment.withUnsafeBytes(body) } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift index 95002be0a..b9139693a 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -9,7 +9,7 @@ // #if canImport(Foundation) -public import Testing +@_spi(ForToolsIntegrationOnly) public import Testing public import Foundation // As with Encodable, implement the protocol requirements for @@ -56,28 +56,8 @@ extension Attachable where Self: NSSecureCoding { /// @Available(Xcode, introduced: 26.0) /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - let format = try EncodingFormat(for: attachment) - - var data = try NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: true) - switch format { - case .default: - // The default format is just what NSKeyedArchiver produces. - break - case let .propertyListFormat(propertyListFormat): - // BUG: Foundation does not offer a variant of - // NSKeyedArchiver.archivedData(withRootObject:requiringSecureCoding:) - // that is Swift-safe (throws errors instead of exceptions) and lets the - // caller specify the output format. Work around this issue by decoding - // the archive re-encoding it manually. - if propertyListFormat != .binary { - let plist = try PropertyListSerialization.propertyList(from: data, format: nil) - data = try PropertyListSerialization.data(fromPropertyList: plist, format: propertyListFormat, options: 0) - } - case .json: - throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "An instance of \(type(of: self)) cannot be encoded as JSON. Specify a property list format instead."]) - } - - return try data.withUnsafeBytes(body) + let attachment = try Attachment(encoding: self, named: attachment.preferredName, sourceLocation: attachment.sourceLocation) + return try attachment.withUnsafeBytes(body) } } #endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/AttachableEncodingFormat.swift b/Sources/Overlays/_Testing_Foundation/Attachments/AttachableEncodingFormat.swift new file mode 100644 index 000000000..a1c16bdff --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/AttachableEncodingFormat.swift @@ -0,0 +1,115 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if canImport(Foundation) +import Testing +public import Foundation + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif + +/// An enumeration describing the encoding formats that you can use when +/// attaching a value that conforms to [`Encodable`](https://developer.apple.com/documentation/swift/encodable). +/// +/// Pass an instance of this type to ``Testing/Attachment/init(encoding:as:named:sourceLocation:)`` +/// to specify what encoder and format to use when the testing library saves the +/// resulting attachment. +/// +/// If you want to attach a value that conforms to [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), +/// use [`PropertyListFormat`](https://developer.apple.com/documentation/foundation/propertylistserialization/propertylistformat) +/// instead. +@_spi(Experimental) +public struct AttachableEncodingFormat: Sendable { + /// An enumeration describing the various kinds of encoding format the testing + /// library supports. + enum Kind: Sendable { + /// A property list format. + /// + /// - Parameters: + /// - format: The corresponding property list format. + case propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) + + /// The JSON format. + case json + } + + /// The kind of encoding format represented by this instance. + var kind: Kind + + /// Create an instance of this type representing a property list format. + /// + /// - Parameters: + /// - format: The corresponding property list format. + /// + /// - Returns: An instance of this type representing `format`. + public static func propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) -> Self { + .init(kind: .propertyListFormat(format)) + } + + /// An instance of this type representing the JSON format. + public static var json: Self { + .init(kind: .json) + } + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) + /// The content type corresponding to this instance. + var contentType: UTType { + switch kind { + case .propertyListFormat(.binary): + .binaryPropertyList + case .propertyListFormat(.xml): + .xmlPropertyList + case .propertyListFormat: + .propertyList + case .json: + .json + } + } +#else + /// The preferred path extension corresponding to this instance. + var preferredPathExtension: String { + switch kind { + case .propertyListFormat: + "plist" + case .json: + "json" + } + } +#endif + + /// Construct an attachment name based on a suggested name and this encoding + /// format. + /// + /// - Parameters: + /// - suggestedName: A suggested name to use as the basis of the preferred + /// name. + /// + /// - Returns: The preferred name for an attachment. The result may or may not + /// equal `suggestedName`. + func preferredName(basedOn suggestedName: String) -> String { +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) + return (suggestedName as NSString).appendingPathExtension(for: contentType) +#else + let pathExtension = (suggestedName as NSString).pathExtension + guard pathExtension.isEmpty else { + // The developer specified a path extension. This path extension may + // reflect some file format that uses Encodable for serialization, so use + // it verbatim. + return suggestedName + } + return (suggestedName as NSString).appendingPathExtension(preferredPathExtension) ?? suggestedName +#endif + } +} + +extension AttachableEncodingFormat: Equatable {} +extension AttachableEncodingFormat.Kind: Equatable {} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+Encodable&NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+Encodable&NSSecureCoding.swift new file mode 100644 index 000000000..7b14d13c7 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+Encodable&NSSecureCoding.swift @@ -0,0 +1,264 @@ +// +// 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) +public import Testing +public import Foundation +#if canImport(Combine) +public import Combine +#endif + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif + +@_spi(Experimental) +extension Attachment { +#if !SWT_NO_CODABLE + /// Derive an instance of `AttachableEncodingFormat` from the arguments to one + /// of the initializers in this file. + /// + /// - Parameters: + /// - encodingFormat: An explicit instance of `AttachableEncodingFormat`, if + /// passed by the initializer's caller. + /// - preferredName: The preferred name of the attachment. + /// - default: The value to return if neither `encodingFormat` nor + /// `preferredName` produces a useful value. + /// + /// - Throws: If `preferredName` implies a format that + /// `AttachableEncodingFormat` can't represent (e.g. "MP3 track" or "GIF + /// image") or if it represents the OpenStep property list format. + /// + /// - Returns: An instance of `AttachableEncodingFormat` to use when later + /// saving an attachment. + private static func _encodingFormat( + _ encodingFormat: AttachableEncodingFormat?, + forPreferredName preferredName: String?, + `default`: @autoclosure() -> AttachableEncodingFormat + ) throws -> AttachableEncodingFormat { + if let encodingFormat { + // The caller explicitly supplied an encoding format. + if encodingFormat == .propertyListFormat(.openStep) { + throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "The OpenStep property list format is not supported."]) + } + return encodingFormat + } + + if let preferredName, + case let ext = (preferredName as NSString).pathExtension, + !ext.isEmpty { + // Check if the path extension is one we directly associate with a + // particular encoding format. + if ext.caseInsensitiveCompare("plist") == .orderedSame { + return .propertyListFormat(.binary) + } else if ext.caseInsensitiveCompare("xml") == .orderedSame { + return .propertyListFormat(.xml) + } else if ext.caseInsensitiveCompare("json") == .orderedSame { + return .json + } + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) + // There is some other path extension. Check with Launch Services for a + // Uniform Type Identifier that conforms to one we support. + if let contentType = UTType(filenameExtension: ext) { + if contentType.conforms(to: .binaryPropertyList) { + return .propertyListFormat(.binary) + } else if contentType.conforms(to: .xmlPropertyList) { + return .propertyListFormat(.xml) + } else if contentType.conforms(to: .json) { + return .json + } + } +#endif + + // The path extension is unknown to us. + throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "The path extension '.\(ext)' cannot be used to attach an instance of \(type(of: self)) to a test."]) + } + + return `default`() + } + + /// Initialize an instance of this type representing a value that conforms to + /// the [`Encodable`](https://developer.apple.com/documentation/swift/encodable) + /// protocol. + /// + /// - Parameters: + /// - encodableValue: The value to encode and attach. + /// - encodingFormat: The encoding format to use to encode `encodableValue`. + /// - preferredName: The preferred name of the attachment when writing it to + /// a test report or to disk. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// - Throws: If an appropriate encoder could not be found given the + /// `encodingFormat` and `preferredName` arguments. + /// + /// Use this initializer to create an instance of ``Attachment`` from a value + /// that conforms to the [`Encodable`](https://developer.apple.com/documentation/swift/encodable) + /// protocol: + /// + /// ```swift + /// let menu = FoodTruck.currentMenu + /// let attachment = try Attachment(encoding: menu, as: .json) + /// Attachment.record(attachment) + /// ``` + /// + /// The encoding that the testing library uses depends on the `encodingFormat` + /// argument. If the value of that argument is `nil`, the testing library + /// derives the format from the path extension you specify in `preferredName`. + /// + /// | Extension | Encoding Used | Encoder Used | + /// |-|-|-| + /// | `".xml"` | XML property list | [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder) | + /// | `".plist"` | Binary property list | [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder) | + /// | None, `".json"` | JSON | [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder) | + /// + /// - Important: OpenStep-style property lists are not supported. + /// + /// If the values of both the `encodingFormat` and `preferredName` arguments + /// are `nil`, the testing library encodes `encodableValue` as JSON. + public init( + encoding encodableValue: T, + as encodingFormat: AttachableEncodingFormat? = nil, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableEncodableWrapper, T: Encodable { + let encodingFormat = try Self._encodingFormat(encodingFormat, forPreferredName: preferredName, default: .json) + let wrapper = _AttachableEncodableWrapper(encoding: encodableValue, as: encodingFormat) + self.init(wrapper, named: preferredName, sourceLocation: sourceLocation) + } + +#if canImport(Combine) + /// Initialize an instance of this type representing a value that conforms to + /// the [`Encodable`](https://developer.apple.com/documentation/swift/encodable) + /// protocol. + /// + /// - Parameters: + /// - encodableValue: The value to encode and attach. + /// - encoder: The encoder to use to encode `encodableValue`. + /// - preferredName: The preferred name of the attachment when writing it to + /// a test report or to disk. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// - Throws: If `encoder` cannot be used to encode `encodableValue`. + /// + /// Use this initializer to create an instance of ``Attachment`` from a value + /// that conforms to the [`Encodable`](https://developer.apple.com/documentation/swift/encodable) + /// protocol: + /// + /// ```swift + /// let menu = FoodTruck.currentMenu + /// let encoder = JSONEncoder() + /// let attachment = try Attachment(encoding: menu, using: encoder) + /// Attachment.record(attachment) + /// ``` + public init( + encoding encodableValue: T, + using encoder: E, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableEncodableWrapper, T: Encodable, E: TopLevelEncoder, E.Output: ContiguousBytes { + let wrapper = _AttachableEncodableWrapper(encoding: encodableValue, using: encoder) + self.init(wrapper, named: preferredName, sourceLocation: sourceLocation) + } +#else + public init( + encoding encodableValue: T, + using encoder: E, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableEncodableWrapper, T: Encodable, E: PropertyListEncoder { + let wrapper = _AttachableEncodableWrapper(encoding: encodableValue, using: encoder) + self.init(wrapper, named: preferredName, sourceLocation: sourceLocation) + } + + public init( + encoding encodableValue: T, + using encoder: E, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableEncodableWrapper, T: Encodable, E: JSONEncoder { + let wrapper = _AttachableEncodableWrapper(encoding: encodableValue, using: encoder) + self.init(wrapper, named: preferredName, sourceLocation: sourceLocation) + } +#endif +#endif + + /// Initialize an instance of this type representing a value that conforms to + /// the [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding) + /// protocol. + /// + /// - Parameters: + /// - encodableValue: The value to encode and attach. + /// - propertyListFormat: The property list format to use to encode + /// `encodableValue`. + /// - preferredName: The preferred name of the attachment when writing it to + /// a test report or to disk. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// - Throws: If an appropriate encoder could not be found given the + /// `propertyListFormat` and `preferredName` arguments. + /// + /// Use this initializer to create an instance of ``Attachment`` from a value + /// that conforms to the [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding) + /// protocol: + /// + /// ```swift + /// let menu = FoodTruck.currentMenu + /// let attachment = try Attachment(encoding: menu, as: .xml) + /// Attachment.record(attachment) + /// ``` + /// + /// The encoding that the testing library uses depends on the + /// `propertyListFormat` argument. If the value of that argument is `nil`, the + /// testing library derives the format from the path extension you specify in + /// `preferredName`. + /// + /// | Extension | Encoding Used | Encoder Used | + /// |-|-|-| + /// | `".xml"` | XML property list | [`NSKeyedArchiver`](https://developer.apple.com/documentation/foundation/nskeyedarchiver) | + /// | None, `".plist"` | Binary property list | [`NSKeyedArchiver`](https://developer.apple.com/documentation/foundation/nskeyedarchiver) | + /// + /// - Important: OpenStep-style property lists are not supported. + /// + /// If the values of both the `propertyListFormat` and `preferredName` + /// arguments are `nil`, the testing library encodes `encodableValue` as a + /// binary property list. + public init( + encoding encodableValue: T, + as propertyListFormat: PropertyListSerialization.PropertyListFormat? = nil, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableEncodableWrapper, T: NSSecureCoding { + // Convert the property list format to an instance of + // AttachableEncodingFormat. This is a bit roundabout, but it allows us to + // reuse logic in EncodingFormat to translate to/from path extensions and + // UTTypes. + let encodingFormat = try Self._encodingFormat( + propertyListFormat.map { .propertyListFormat($0) }, + forPreferredName: preferredName, + default: .propertyListFormat(.binary) + ) + switch encodingFormat.kind { + case let .propertyListFormat(propertyListFormat): + // This format is supported. (The OpenStep case was handled above). + let wrapper = _AttachableEncodableWrapper(encoding: encodableValue, as: propertyListFormat) + self.init(wrapper, named: preferredName, sourceLocation: sourceLocation) + case .json: + throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "An instance of \(T.self) cannot be encoded as JSON. Specify a property list format instead."]) + } + } +} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift deleted file mode 100644 index 49499a8c2..000000000 --- a/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift +++ /dev/null @@ -1,56 +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) -import Testing -import Foundation - -/// 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 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/_AttachableEncodableWrapper.swift b/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableEncodableWrapper.swift new file mode 100644 index 000000000..0cadf1fd6 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableEncodableWrapper.swift @@ -0,0 +1,158 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 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) && !SWT_NO_CODABLE +public import Testing +import Foundation +#if canImport(Combine) +import Combine +#endif + +/// A wrapper type representing values that can be attached using their +/// conformances to the `Encodable` protocol. +/// +/// You do not need to use this type directly. Instead, initialize an instance +/// of ``Attachment`` using the encodable value. +@_spi(Experimental) +public struct _AttachableEncodableWrapper { + /// The underlying encodable value. + private var _encodableValue: T + + /// The encoding format used when encoding the value. + private var _encodingFormat: AttachableEncodingFormat? + + /// A function that encodes `_encodableValue` and passes its encoded form to + /// another function, `body`. + /// + /// This function provides the implementation of ``withBytes(for:_:)``. It + /// must be annotated `nonisolated(unsafe)` instead of `@Sendable` because it + /// captures a reference to the generic type `T` which is not guaranteed to + /// conform to `SendableMetatype`. + private nonisolated(unsafe) var _encode: (_ body: (UnsafeRawBufferPointer) throws -> Void) throws -> Void + + /// Initialize an instance of this type representing a given encodable value + /// and encoding it using the given encoding format. + /// + /// - Parameters: + /// - encodableValue: The value to encode and attach. + /// - encodingFormat: The encoding format to use. + init(encoding encodableValue: T, as encodingFormat: AttachableEncodingFormat) where T: Encodable, E == Void { + _encodableValue = encodableValue + _encodingFormat = encodingFormat + _encode = { body in + let data: Data + switch encodingFormat.kind { + case let .propertyListFormat(propertyListFormat): + let plistEncoder = PropertyListEncoder() + plistEncoder.outputFormat = propertyListFormat + data = try plistEncoder.encode(encodableValue) + case .json: + // We cannot use our own JSON encoding wrapper here because that would + // 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(encodableValue) + } + + return try data.withUnsafeBytes(body) + } + } + +#if canImport(Combine) + /// Initialize an instance of this type representing a given encodable value + /// and encoding it using the given encoder. + /// + /// - Parameters: + /// - encodableValue: The value to encode and attach. + /// - encoder: The encoder to use. + init(encoding encodableValue: T, using encoder: E) where T: Encodable, E: TopLevelEncoder, E.Output: ContiguousBytes { + _encodableValue = encodableValue + if let plistEncoder = encoder as? PropertyListEncoder { + _encodingFormat = .propertyListFormat(plistEncoder.outputFormat) + } else if encoder is JSONEncoder { + _encodingFormat = .json + } + _encode = { body in + let buffer = try encoder.encode(encodableValue) + try buffer.withUnsafeBytes(body) + } + } +#else + init(encoding encodableValue: T, using encoder: E) where T: Encodable, E: PropertyListEncoder { + _encodableValue = encodableValue + _encodingFormat = .propertyListFormat(encoder.outputFormat) + _encode = { body in + let buffer = try encoder.encode(encodableValue) + try buffer.withUnsafeBytes(body) + } + } + + init(encoding encodableValue: T, using encoder: E) where T: Encodable, E: JSONEncoder { + _encodableValue = encodableValue + _encodingFormat = .json + _encode = { body in + let buffer = try encoder.encode(encodableValue) + try buffer.withUnsafeBytes(body) + } + } +#endif + + /// Initialize an instance of this type representing a given encodable value + /// and encoding it using the given encoding format. + /// + /// - Parameters: + /// - encodableValue: The value to encode and attach. + /// - propertyListFormat: The property list format to use. + init(encoding encodableValue: T, as propertyListFormat: PropertyListSerialization.PropertyListFormat) where T: NSSecureCoding, E: NSKeyedArchiver { + _encodableValue = encodableValue + _encodingFormat = .propertyListFormat(propertyListFormat) + _encode = { body in + var data = try E.archivedData(withRootObject: encodableValue, requiringSecureCoding: true) + + // BUG: Foundation does not offer a variant of + // NSKeyedArchiver.archivedData(withRootObject:requiringSecureCoding:) + // that is Swift-safe (throws errors instead of exceptions) and lets the + // caller specify the output format. Work around this issue by decoding + // the archive re-encoding it manually. + if propertyListFormat != .binary { + let plist = try PropertyListSerialization.propertyList(from: data, format: nil) + data = try PropertyListSerialization.data(fromPropertyList: plist, format: propertyListFormat, options: 0) + } + + return try data.withUnsafeBytes(body) + } + } +} + +extension _AttachableEncodableWrapper: Sendable where T: Sendable, E: Sendable {} + +// MARK: - + +extension _AttachableEncodableWrapper: AttachableWrapper { + public var wrappedValue: T { + _encodableValue + } + + public func withUnsafeBytes(for attachment: borrowing Attachment<_AttachableEncodableWrapper>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + var result: R! + try _encode { buffer in + result = try body(buffer) + } + return result + } + + public borrowing func preferredName(for attachment: borrowing Attachment<_AttachableEncodableWrapper>, basedOn suggestedName: String) -> String { + guard let encodingFormat = _encodingFormat else { + return suggestedName + } + return encodingFormat.preferredName(basedOn: suggestedName) + } +} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt index d5922df06..7f4fb4f12 100644 --- a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt +++ b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt @@ -8,8 +8,10 @@ include(ModuleABIName) add_library(_Testing_Foundation + Attachments/_AttachableEncodableWrapper.swift Attachments/_AttachableURLWrapper.swift - Attachments/EncodingFormat.swift + Attachments/AttachableEncodingFormat.swift + Attachments/Attachment+Encodable&NSSecureCoding.swift Attachments/Attachment+URL.swift Attachments/Attachable+NSSecureCoding.swift Attachments/Data+Attachable.swift diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 64d4dda5f..951df8d9a 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -16,7 +16,7 @@ import _Testing_AppKit #endif #if canImport(Foundation) && canImport(_Testing_Foundation) import Foundation -import _Testing_Foundation +@_spi(Experimental) import _Testing_Foundation #endif #if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) import CoreGraphics @@ -490,6 +490,33 @@ struct AttachmentTests { try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } } } + + @Test("Attach Codable-conformant value using Attachment.init(encoding:as:) and .json") + func attachCodableWithInitEncodingAsJSON() async throws { + let attachableValue = MyCodableAttachable(string: "stringly speaking") + let attachment = try Attachment(encoding: attachableValue, as: .json) + try attachment.withUnsafeBytes { bytes in + #expect(!bytes.isEmpty) + } + } + + @Test("Attach Codable-conformant value using Attachment.init(encoding:using:) and PropertyListEncoder") + func attachCodableWithInitEncodingUsingPropertyListEncoder() async throws { + let attachableValue = MyCodableAttachable(string: "stringly speaking") + let attachment = try Attachment(encoding: attachableValue, using: PropertyListEncoder()) + try attachment.withUnsafeBytes { bytes in + #expect(!bytes.isEmpty) + } + } + + @Test("Attach Codable-conformant value using Attachment.init(encoding:using:) and JSONEncoder") + func attachCodableWithInitEncodingUsingJSONEncoder() async throws { + let attachableValue = MyCodableAttachable(string: "stringly speaking") + let attachment = try Attachment(encoding: attachableValue, using: JSONEncoder()) + try attachment.withUnsafeBytes { bytes in + #expect(!bytes.isEmpty) + } + } #endif #endif