diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index 9ad9798f4..1c95b65e3 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -1,7 +1,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2024–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 @@ -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 @@ -22,10 +22,12 @@ public import Foundation /// @Available(Swift, introduced: 6.2) /// @Available(Xcode, introduced: 26.0) /// } +@available(swift, deprecated: 100000.0, message: "Use 'Attachment.init(encoding:as:named:sourceLocation:)' instead.") 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..06b2f3b32 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -1,7 +1,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2024–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 @@ -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. @@ -58,6 +20,7 @@ func withUnsafeBytes(encoding attachableValue: borrowing E, for attachment /// @Available(Swift, introduced: 6.2) /// @Available(Xcode, introduced: 26.0) /// } +@available(swift, deprecated: 100000.0, message: "Use 'Attachment.init(encoding:as:named:sourceLocation:)' instead.") extension Attachable where Self: Encodable { /// Encode this value into a buffer using either [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder) /// or [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder), @@ -96,7 +59,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..3f8176802 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -1,7 +1,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2024–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 @@ -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 @@ -20,6 +20,7 @@ public import Foundation /// @Available(Swift, introduced: 6.2) /// @Available(Xcode, introduced: 26.0) /// } +@available(swift, deprecated: 100000.0, message: "Use 'Attachment.init(encoding:as:named:sourceLocation:)' instead.") extension Attachable where Self: NSSecureCoding { /// Encode this object using [`NSKeyedArchiver`](https://developer.apple.com/documentation/foundation/nskeyedarchiver) /// into a buffer, then call a function and pass that buffer to it. @@ -56,28 +57,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..86247b96e --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/AttachableEncodingFormat.swift @@ -0,0 +1,140 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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) +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. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.5) +/// } +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`. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.5) + /// } + public static func propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) -> Self { + .init(kind: .propertyListFormat(format)) + } + + /// An instance of this type representing the JSON format. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.5) + /// } + 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 { + lazy var pathExtension = (suggestedName as NSString).pathExtension + + // Leave ".xml" as the path extension when explicitly specified. + if kind == .propertyListFormat(.xml), + pathExtension.caseInsensitiveCompare("xml") == .orderedSame { + return suggestedName + } + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) + return (suggestedName as NSString).appendingPathExtension(for: contentType) +#else + 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 + } +} + +/// @Metadata { +/// @Available(Swift, introduced: 6.5) +/// } +extension AttachableEncodingFormat: Equatable {} + +/// @Metadata { +/// @Available(Swift, introduced: 6.5) +/// } +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..6349c3d11 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+Encodable&NSSecureCoding.swift @@ -0,0 +1,289 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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) +public import Testing +public import Foundation +#if canImport(Combine) +public import Combine +#endif + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif + +/// @Metadata { +/// @Available(Swift, introduced: 6.5) +/// } +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`: 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. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.5) + /// } + public init( + encoding encodableValue: T, + as encodingFormat: AttachableEncodingFormat? = nil, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableEncodableWrapper { + let encodingFormat = try Self._encodingFormat(encodingFormat, forPreferredName: preferredName, default: .json) + switch encodingFormat.kind { + case let .propertyListFormat(propertyListFormat): + let plistEncoder = PropertyListEncoder() + plistEncoder.outputFormat = propertyListFormat + try self.init(encoding: encodableValue, using: plistEncoder, named: preferredName, sourceLocation: sourceLocation) + 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. + let jsonEncoder = JSONEncoder() + try self.init(encoding: encodableValue, using: jsonEncoder, 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) + /// ``` + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.5) + /// } + public init( + encoding encodableValue: T, + using encoder: E, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableEncodableWrapper, E: TopLevelEncoder & Sendable, 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, E: PropertyListEncoder & Sendable { + 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, E: JSONEncoder & Sendable { + 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. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.5) + /// } + public init( + encoding encodableValue: T, + as propertyListFormat: PropertyListSerialization.PropertyListFormat? = nil, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws where AttachableValue == _AttachableNSSecureCodingWrapper { + // 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 = _AttachableNSSecureCodingWrapper(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..a2c7478e0 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableEncodableWrapper.swift @@ -0,0 +1,103 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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. +public struct _AttachableEncodableWrapper where T: Encodable { + /// 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:_:)``. We + /// must pass `encodableValue` as an existential box instead of an instance of + /// `T` because otherwise `_encode` captures a reference to the generic type + /// `T` which is not guaranteed to conform to `SendableMetatype`. + private var _encode: @Sendable (_ encodableValue: any Encodable, _ body: (UnsafeRawBufferPointer) throws -> Void) throws -> Void + +#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 E: TopLevelEncoder & Sendable, E.Output: ContiguousBytes { + _encodableValue = encodableValue + if let plistEncoder = encoder as? PropertyListEncoder { + _encodingFormat = .propertyListFormat(plistEncoder.outputFormat) + } else if encoder is JSONEncoder { + _encodingFormat = .json + } + _encode = { encodableValue, body in + let buffer = try encoder.encode(encodableValue) + try buffer.withUnsafeBytes(body) + } + } +#else + init(encoding encodableValue: T, using encoder: E) where E: PropertyListEncoder & Sendable { + _encodableValue = encodableValue + _encodingFormat = .propertyListFormat(encoder.outputFormat) + _encode = { encodableValue, body in + let buffer = try encoder.encode(encodableValue) + try buffer.withUnsafeBytes(body) + } + } + + init(encoding encodableValue: T, using encoder: E) where E: JSONEncoder & Sendable { + _encodableValue = encodableValue + _encodingFormat = .json + _encode = { encodableValue, body in + let buffer = try encoder.encode(encodableValue) + try buffer.withUnsafeBytes(body) + } + } +#endif +} + +extension _AttachableEncodableWrapper: Sendable where T: 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(_encodableValue) { 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/Attachments/_AttachableNSSecureCodingWrapper.swift b/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableNSSecureCodingWrapper.swift new file mode 100644 index 000000000..94e58e3b6 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableNSSecureCodingWrapper.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +public import Foundation + +/// A wrapper type representing values that can be attached using their +/// conformances to the `NSSecureCoding` protocol. +/// +/// You do not need to use this type directly. Instead, initialize an instance +/// of ``Attachment`` using the encodable value. +public struct _AttachableNSSecureCodingWrapper where T: NSSecureCoding { + /// The underlying encodable value. + private var _encodableValue: T + + /// The encoding format used when encoding the value. + private var _propertyListFormat: PropertyListSerialization.PropertyListFormat + + /// 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) { + _encodableValue = encodableValue + _propertyListFormat = propertyListFormat + } +} + +extension _AttachableNSSecureCodingWrapper: Sendable where T: Sendable {} + +// MARK: - + +extension _AttachableNSSecureCodingWrapper: AttachableWrapper { + public var wrappedValue: T { + _encodableValue + } + + public func withUnsafeBytes(for attachment: borrowing Attachment<_AttachableNSSecureCodingWrapper>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + var data = try NSKeyedArchiver.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) + } + + public borrowing func preferredName(for attachment: borrowing Attachment<_AttachableNSSecureCodingWrapper>, basedOn suggestedName: String) -> String { + let encodingFormat = AttachableEncodingFormat.propertyListFormat(_propertyListFormat) + return encodingFormat.preferredName(basedOn: suggestedName) + } +} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt index d5922df06..cdce0137a 100644 --- a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt +++ b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt @@ -8,8 +8,11 @@ include(ModuleABIName) add_library(_Testing_Foundation + Attachments/_AttachableEncodableWrapper.swift + Attachments/_AttachableNSSecureCodingWrapper.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/Sources/Testing/Support/CustomIssueRepresentable.swift b/Sources/Testing/Support/CustomIssueRepresentable.swift index 4ac482deb..0f5f77837 100644 --- a/Sources/Testing/Support/CustomIssueRepresentable.swift +++ b/Sources/Testing/Support/CustomIssueRepresentable.swift @@ -47,6 +47,10 @@ protocol CustomIssueRepresentable: Error { struct SystemError: Error, CustomStringConvertible, CustomIssueRepresentable { var description: String + init(description: String) { + self.description = description + } + static var domain: String { "org.swift.testing.SystemError" } diff --git a/Sources/Testing/Testing.docc/Attachments.md b/Sources/Testing/Testing.docc/Attachments.md index 9e28633e4..8806673a3 100644 --- a/Sources/Testing/Testing.docc/Attachments.md +++ b/Sources/Testing/Testing.docc/Attachments.md @@ -41,9 +41,8 @@ these types automatically conform to ``Attachable``. You can also attach an instance of [`String`](https://developer.apple.com/documentation/swift/string) or [`Substring`](https://developer.apple.com/documentation/swift/substring). The -testing library treats attached strings as UTF-8 text -files. If you want to save a string as an attachment using a different encoding, -convert it to [`Data`](https://developer.apple.com/documentation/foundation/data) +testing library treats attached strings as UTF-8 text files. If you want to save +a string as an attachment using a different encoding, convert it to [`Data`](https://developer.apple.com/documentation/foundation/data) using [`data(using:allowLossyConversion:)`](https://developer.apple.com/documentation/swift/stringprotocol/data(using:allowlossyconversion:)) and attach the resulting data instead of the original string. @@ -52,29 +51,55 @@ and attach the resulting data instead of the original string. If you have a value you want to save as an attachment that conforms to either [`Encodable`](https://developer.apple.com/documentation/swift/encodable) or [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), -you can extend it to add conformance to ``Attachable``. When you import the -[Foundation](https://developer.apple.com/documentation/foundation) module, the -testing library automatically provides a default implementation of -``Attachable`` to types that also conform to [`Encodable`](https://developer.apple.com/documentation/swift/encodable) -or [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding). +you can create an instance of ``Attachment`` from it when you import the +[Foundation](https://developer.apple.com/documentation/foundation) module. ```swift import Testing import Foundation -struct SalesReport { ... } -extension SalesReport: Encodable, Attachable {} +struct SalesReport: Encodable { ... } @Test func `sales report adds up`() async throws { let salesReport = await generateSalesReport() try salesReport.validate() - Attachment.record(salesReport, named: "sales report.json") + let attachment = try Attachment(encoding: salesReport) + Attachment.record(attachment) } ``` -- Important: The testing library provides these default implementations only if - your test target imports the [Foundation](https://developer.apple.com/documentation/foundation) - module. +By default, the testing library encodes values as JSON if they conform to [`Encodable`](https://developer.apple.com/documentation/swift/encodable) +or as binary property lists if they conform to [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding). +You can customize the resulting encoded data further by passing an encoding +format or a top-level encoder. + +| Type Conforms To | Customize With | +|-|-| +| [`Encodable`](https://developer.apple.com/documentation/swift/encodable) | ``AttachableEncodingFormat``, [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder), [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder), or [`TopLevelEncoder`](https://developer.apple.com/documentation/combine/toplevelencoder) | +| [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding) | [`PropertyListSerialization.PropertyListFormat`](https://developer.apple.com/documentation/foundation/propertylistserialization/propertylistformat) | + +For example, to attach a value to your test as an XML property list: + +```swift +import Testing +import Foundation + +struct SalesReport: Encodable { ... } + +@Test func `sales report adds up`() async throws { + let salesReport = await generateSalesReport() + try salesReport.validate() + let attachment = try Attachment( + encoding: salesReport, + as: .propertyListFormat(.xml) + ) + Attachment.record(attachment) +} +``` + +- Important: The testing library provides these ``Attachment`` initializers only +if your test target imports the [Foundation](https://developer.apple.com/documentation/foundation) +module. ### Attach transferable values @@ -88,8 +113,7 @@ module. import Testing import CoreTransferable -struct SalesReport { ... } -extension SalesReport: Encodable, Attachable {} +struct SalesReport: Transferable { ... } @Test func `sales report adds up`() async throws { let salesReport = await generateSalesReport() @@ -181,7 +205,7 @@ extension SalesReport: Attachable { _ body: (UnsafeRawBufferPointer) throws -> R ) throws -> R { let bytes = try salesReport.convertToCSV() // might fail to convert to CSV - try bytes.withUnsafeBytes { buffer in // rethrows any error from `body` + return try bytes.withUnsafeBytes { buffer in // rethrows errors from `body` try body(buffer) } } diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 4e7d6ef45..1a84122fd 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 @@ -460,6 +460,11 @@ struct AttachmentTests { let decodedStringValue = try args.decode(Data(bytes)) #expect(decodedStringValue == "stringly speaking") } + + if let ext = args.pathExtension { + let preferredExt = (attachment.preferredName as NSString).pathExtension + #expect(preferredExt.caseInsensitiveCompare(ext) == .orderedSame) + } } if args.forSecureCoding { @@ -490,6 +495,74 @@ 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) + #expect(bytes[0] == UInt8(ascii: "{")) + } + } + + @Test("Attach Codable-conformant value using Attachment.init(encoding:as:) and .propertyListFormat(.xml)") + func attachCodableWithInitEncodingAsXMLByFormat() async throws { + let attachableValue = MyCodableAttachable(string: "stringly speaking") + let attachment = try Attachment(encoding: attachableValue, as: .propertyListFormat(.xml)) + try attachment.withUnsafeBytes { bytes in + #expect(!bytes.isEmpty) + #expect(bytes[0] == UInt8(ascii: "<")) + } + let ext = (attachment.preferredName as NSString).pathExtension + #expect(ext.caseInsensitiveCompare("plist") == .orderedSame) + } + + @Test("Attach Codable-conformant value using Attachment.init(encoding:as:) and '.xml' path extension") + func attachCodableWithInitEncodingAsXMLByPathExtension() async throws { + let attachableValue = MyCodableAttachable(string: "stringly speaking") + let attachment = try Attachment(encoding: attachableValue, named: "example.xml") + try attachment.withUnsafeBytes { bytes in + #expect(!bytes.isEmpty) + #expect(bytes[0] == UInt8(ascii: "<")) + } + let ext = (attachment.preferredName as NSString).pathExtension + print(attachment.preferredName) + #expect(ext.caseInsensitiveCompare("xml") == .orderedSame) + } + + @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) + #expect(bytes[0] == UInt8(ascii: "b")) + } + } + + @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) + #expect(bytes[0] == UInt8(ascii: "{")) + } + } + +#if !SWT_NO_GLOBAL_ACTORS + @Test("Attach value with isolated Codable conformance") + @MainActor + func attachValueWithIsolatedCodableConformance() throws { + let attachableValue = MyIsolatedCodable(string: "stringly speaking") + let attachment = try Attachment(encoding: attachableValue, using: JSONEncoder()) + try attachment.withUnsafeBytes { bytes in + #expect(!bytes.isEmpty) + #expect(bytes[0] == UInt8(ascii: "{")) + } + } +#endif #endif #endif @@ -1152,6 +1225,12 @@ struct MyBadTransferable: Transferable, Equatable { struct MyCodableAttachable: Codable, Attachable, Sendable { var string: String } + +#if !SWT_NO_GLOBAL_ACTORS +struct MyIsolatedCodable: @MainActor Codable { + var string: String +} +#endif #endif final class MySecureCodingAttachable: NSObject, NSSecureCoding, Attachable, Sendable {