diff --git a/Package.swift b/Package.swift index f7fb5dc5f..b5dc49d65 100644 --- a/Package.swift +++ b/Package.swift @@ -96,6 +96,7 @@ let package = Package( dependencies: [ "Testing", ], + path: "Sources/Overlays/_Testing_Foundation", swiftSettings: .packageSettings ), ], @@ -124,6 +125,7 @@ extension Array where Element == PackageDescription.SwiftSetting { availabilityMacroSettings + [ .unsafeFlags(["-require-explicit-sendable"]), .enableUpcomingFeature("ExistentialAny"), + //.enableExperimentalFeature("SuppressedAssociatedTypes"), .enableExperimentalFeature("AccessLevelOnImport"), .enableUpcomingFeature("InternalImportsByDefault"), @@ -146,6 +148,7 @@ extension Array where Element == PackageDescription.SwiftSetting { private static var availabilityMacroSettings: Self { [ .enableExperimentalFeature("AvailabilityMacro=_mangledTypeNameAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0"), + .enableExperimentalFeature("AvailabilityMacro=_uttypesAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0"), .enableExperimentalFeature("AvailabilityMacro=_backtraceAsyncAPI:macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0"), .enableExperimentalFeature("AvailabilityMacro=_clockAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0"), .enableExperimentalFeature("AvailabilityMacro=_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"), diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Test.Attachable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Test.Attachable.swift new file mode 100644 index 000000000..4c771562e --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Test.Attachable.swift @@ -0,0 +1,21 @@ +// +// 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) public import Testing +public import Foundation + +@_spi(Experimental) +extension Data: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try withUnsafeBytes(body) + } +} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift new file mode 100644 index 000000000..fb3a85413 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift @@ -0,0 +1,84 @@ +// +// 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 `Test.Attachable`. +enum EncodingFormat { + /// A property list format. + /// + /// - Parameters: + /// - format: The corresponding property list format. + case propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) + + /// The JSON format. + case json + + /// 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` + + /// 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 Test.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/Overlays/_Testing_Foundation/Attachments/Test.Attachable+Encodable&NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+Encodable&NSSecureCoding.swift new file mode 100644 index 000000000..4b1b95f22 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+Encodable&NSSecureCoding.swift @@ -0,0 +1,22 @@ +// +// 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) public import Testing +public import Foundation + +@_spi(Experimental) +extension Test.Attachable where Self: Encodable & NSSecureCoding { + @_documentation(visibility: private) + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try _Testing_Foundation.withUnsafeBufferPointer(encoding: self, for: attachment, body) + } +} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+Encodable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+Encodable.swift new file mode 100644 index 000000000..0bafb011e --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+Encodable.swift @@ -0,0 +1,97 @@ +// +// 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) public import Testing +private import Foundation + +/// A common implementation of ``withUnsafeBufferPointer(for:_:)`` that is +/// used when a type conforms to `Encodable`, whether or not it also conforms +/// to `NSSecureCoding`. +/// +/// - Parameters: +/// - 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 withUnsafeBufferPointer(encoding attachableValue: borrowing E, for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R where E: Test.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. +@_spi(Experimental) +extension Test.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), + /// then call a function and pass that buffer to it. + /// + /// - Parameters: + /// - 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. + /// + /// The testing library uses this function when writing an attachment to a + /// test report or to a file on disk. The encoding used depends on the path + /// extension specified by the value of `attachment`'s ``Testing/Test/Attachment/preferredName`` + /// property: + /// + /// | 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) | + /// + /// OpenStep-style property lists are not supported. If a value conforms to + /// _both_ [`Encodable`](https://developer.apple.com/documentation/swift/encodable) + /// _and_ [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), + /// the default implementation of this function uses the value's conformance + /// to `Encodable`. + /// + /// - Note: On Apple platforms, if the attachment's preferred name includes + /// 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) + /// or to [`UTType.json`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/json). + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try _Testing_Foundation.withUnsafeBufferPointer(encoding: self, for: attachment, body) + } +} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+NSSecureCoding.swift new file mode 100644 index 000000000..6a79a37ff --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachable+NSSecureCoding.swift @@ -0,0 +1,78 @@ +// +// 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) public import Testing +public import Foundation + +// As with Encodable, implement the protocol requirements for +// NSSecureCoding-conformant classes by default. The implementation uses +// NSKeyedArchiver for encoding. +@_spi(Experimental) +extension Test.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. + /// + /// - Parameters: + /// - 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. + /// + /// The testing library uses this function when writing an attachment to a + /// test report or to a file on disk. The encoding used depends on the path + /// extension specified by the value of `attachment`'s ``Testing/Test/Attachment/preferredName`` + /// property: + /// + /// | 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) | + /// + /// OpenStep-style property lists are not supported. If a value conforms to + /// _both_ [`Encodable`](https://developer.apple.com/documentation/swift/encodable) + /// _and_ [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), + /// the default implementation of this function uses the value's conformance + /// to `Encodable`. + /// + /// - Note: On Apple platforms, if the attachment's preferred name includes + /// 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 Test.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) + } +} +#endif diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachment+URL.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachment+URL.swift new file mode 100644 index 000000000..5f56ad727 --- /dev/null +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Test.Attachment+URL.swift @@ -0,0 +1,158 @@ +// +// 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) public import Testing +public import Foundation + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) +private import UniformTypeIdentifiers +#endif + +#if !SWT_NO_FILE_IO +extension URL { + /// The file system path of the URL, equivalent to `path`. + var fileSystemPath: String { +#if os(Windows) + // BUG: `path` includes a leading slash which makes it invalid on Windows. + // SEE: https://github.com/swiftlang/swift-foundation/pull/964 + let path = path + if path.starts(with: /\/[A-Za-z]:\//) { + return String(path.dropFirst()) + } +#endif + return path + } +} + +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) +@available(_uttypesAPI, *) +extension UTType { + /// A type that represents a `.tgz` archive, or `nil` if the system does not + /// recognize that content type. + fileprivate static let tgz = UTType("org.gnu.gnu-zip-tar-archive") +} +#endif + +@_spi(Experimental) +extension Test.Attachment where AttachableValue == Data { + /// Initialize an instance of this type with the contents of the given URL. + /// + /// - Parameters: + /// - url: The URL containing the attachment's data. + /// - preferredName: The preferred name of the attachment when writing it + /// to a test report or to disk. If `nil`, the name of the attachment is + /// derived from the last path component of `url`. + /// - sourceLocation: The source location of the attachment. + /// + /// - Throws: Any error that occurs attempting to read from `url`. + public init( + contentsOf url: URL, + named preferredName: String? = nil + ) async throws { + guard url.isFileURL else { + // TODO: network URLs? + throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "Attaching downloaded files is not supported"]) + } + + // FIXME: use NSFileCoordinator on Darwin? + + let url = url.resolvingSymlinksInPath() + let isDirectory = try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory! + + // Determine the preferred name of the attachment if one was not provided. + var preferredName = if let preferredName { + preferredName + } else if case let lastPathComponent = url.lastPathComponent, !lastPathComponent.isEmpty { + lastPathComponent + } else { + Self.defaultPreferredName + } + + if isDirectory { + // Ensure the preferred name of the archive has an appropriate extension. + preferredName = { +#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers) + if #available(_uttypesAPI, *), let tgz = UTType.tgz { + return (preferredName as NSString).appendingPathExtension(for: tgz) + } +#endif + return (preferredName as NSString).appendingPathExtension("tgz") ?? preferredName + }() + + try await self.init(Data(compressedContentsOfDirectoryAt: url), named: preferredName) + } else { + // Load the file. + try self.init(Data(contentsOf: url, options: [.mappedIfSafe]), named: preferredName) + } + } +} + +// MARK: - Attaching directories + +extension Data { + /// Initialize an instance of this type by compressing the contents of a + /// directory. + /// + /// - Parameters: + /// - directoryURL: A URL referring to the directory to attach. + /// + /// - Throws: Any error encountered trying to compress the directory, or if + /// directories cannot be compressed on this platform. + /// + /// This initializer asynchronously compresses the contents of `directoryURL` + /// into an archive (currently of `.tgz` format, although this is subject to + /// change) and stores a mapped copy of that archive. + init(compressedContentsOfDirectoryAt directoryURL: URL) async throws { + let temporaryName = "\(UUID().uuidString).tgz" + let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(temporaryName) + +#if !SWT_NO_PROCESS_SPAWNING +#if os(Windows) + let tarPath = #"C:\Windows\System32\tar.exe"# +#else + let tarPath = "/usr/bin/tar" +#endif + let sourcePath = directoryURL.fileSystemPath + let destinationPath = temporaryURL.fileSystemPath + defer { + try? FileManager().removeItem(at: temporaryURL) + } + + try await withCheckedThrowingContinuation { continuation in + do { + _ = try Process.run( + URL(fileURLWithPath: tarPath, isDirectory: false), + arguments: ["--create", "--gzip", "--directory", sourcePath, "--file", destinationPath, "."] + ) { process in + let terminationReason = process.terminationReason + let terminationStatus = process.terminationStatus + if terminationReason == .exit && terminationStatus == EXIT_SUCCESS { + continuation.resume() + } else { + let error = CocoaError(.fileWriteUnknown, userInfo: [ + NSLocalizedDescriptionKey: "The directory at '\(sourcePath)' could not be compressed.", + ]) + continuation.resume(throwing: error) + } + } + } catch { + continuation.resume(throwing: error) + } + } + + try self.init(contentsOf: temporaryURL, options: [.mappedIfSafe]) +#else + throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."]) +#endif + } +} +#endif +#endif diff --git a/Sources/_Testing_Foundation/Events/Clock+Date.swift b/Sources/Overlays/_Testing_Foundation/Events/Clock+Date.swift similarity index 100% rename from Sources/_Testing_Foundation/Events/Clock+Date.swift rename to Sources/Overlays/_Testing_Foundation/Events/Clock+Date.swift diff --git a/Sources/_Testing_Foundation/ReexportTesting.swift b/Sources/Overlays/_Testing_Foundation/ReexportTesting.swift similarity index 91% rename from Sources/_Testing_Foundation/ReexportTesting.swift rename to Sources/Overlays/_Testing_Foundation/ReexportTesting.swift index d06def5b8..3faa622d7 100644 --- a/Sources/_Testing_Foundation/ReexportTesting.swift +++ b/Sources/Overlays/_Testing_Foundation/ReexportTesting.swift @@ -8,4 +8,4 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@_exported import Testing +@_exported public import Testing diff --git a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedAttachment.swift b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedAttachment.swift index 525f8718f..7f52e0059 100644 --- a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedAttachment.swift +++ b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedAttachment.swift @@ -21,7 +21,7 @@ extension ABIv0 { /// The path where the attachment was written. var path: String? - init(encoding attachment: borrowing Test.Attachment, in eventContext: borrowing Event.Context) { + init(encoding attachment: borrowing Test.Attachment, in eventContext: borrowing Event.Context) { path = attachment.fileSystemPath } } diff --git a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift index fd9dc464a..5b67d350f 100644 --- a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift +++ b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift @@ -80,7 +80,7 @@ extension ABIv0 { case let .issueRecorded(recordedIssue): kind = .issueRecorded issue = EncodedIssue(encoding: recordedIssue, in: eventContext) - case let .valueAttached(attachment): + case let .valueAttached(attachment, _): kind = .valueAttached _attachment = EncodedAttachment(encoding: attachment, in: eventContext) case .testCaseEnded: diff --git a/Sources/Testing/Attachments/Test.Attachable.swift b/Sources/Testing/Attachments/Test.Attachable.swift index 0053bec62..b7f6694eb 100644 --- a/Sources/Testing/Attachments/Test.Attachable.swift +++ b/Sources/Testing/Attachments/Test.Attachable.swift @@ -15,14 +15,18 @@ extension Test { /// /// To attach an attachable value to a test report or test run output, use it /// to initialize a new instance of ``Test/Attachment``, then call - /// ``Test/Attachment/attach()``. An attachment can only be attached once. + /// ``Test/Attachment/attach(sourceLocation:)``. An attachment can only be + /// attached once. /// /// The testing library provides default conformances to this protocol for a /// variety of standard library types. Most user-defined types do not need to /// conform to this protocol. /// /// A type should conform to this protocol if it can be represented as a - /// sequence of bytes that would be diagnostically useful if a test fails. + /// sequence of bytes that would be diagnostically useful if a test fails. If + /// a type cannot conform directly to this protocol (such as a non-final class + /// or a type declared in a third-party module), you can create a container + /// type that conforms to ``Test/AttachableContainer`` to act as a proxy. public protocol Attachable: ~Copyable { /// An estimate of the number of bytes of memory needed to store this value /// as an attachment. @@ -61,7 +65,35 @@ extension Test { /// the buffer to contain an image in PNG format, JPEG format, etc., but it /// would not be idiomatic for the buffer to contain a textual description /// of the image. - borrowing func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R + borrowing func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R + } + + /// A protocol describing a type that can be attached to a test report or + /// written to disk when a test is run and which contains another value that + /// it stands in for. + /// + /// To attach an attachable value to a test report or test run output, use it + /// to initialize a new instance of ``Test/Attachment``, then call + /// ``Test/Attachment/attach(sourceLocation:)``. An attachment can only be + /// attached once. + /// + /// A type can conform to this protocol if it represents another type that + /// cannot directly conform to ``Test/Attachable``, such as a non-final class + /// or a type declared in a third-party module. Unlike ``Test/Attachable``, + /// types that conform to ``Test/AttachableContainer`` must also conform to + /// both [`Sendable`](https://developer.apple.com/documentation/swift/sendable) + /// and [`Copyable`](https://developer.apple.com/documentation/swift/copyable). + public protocol AttachableContainer: Attachable, ~Copyable { +#if hasFeature(SuppressedAssociatedTypes) + /// The type of the attachable value represented by this type. + associatedtype AttachableValue: ~Copyable +#else + /// The type of the attachable value represented by this type. + associatedtype AttachableValue +#endif + + /// The attachable value represented by this instance. + var attachableValue: AttachableValue { get } } } @@ -81,12 +113,8 @@ extension Test.Attachable where Self: Collection, Element == UInt8 { // We do not provide an implementation of withUnsafeBufferPointer(for:_:) here // because there is no way in the standard library to statically detect if a // collection can provide contiguous storage (_HasContiguousBytes is not API.) - // If withContiguousBytesIfAvailable(_:) fails, we don't want to make a + // If withContiguousStorageIfAvailable(_:) fails, we don't want to make a // (potentially expensive!) copy of the collection. - // - // The planned Foundation cross-import overlay can provide a default - // implementation for collection types that conform to Foundation's - // ContiguousBytes protocol. } extension Test.Attachable where Self: StringProtocol { @@ -103,56 +131,56 @@ extension Test.Attachable where Self: StringProtocol { // developers can attach raw data when needed. @_spi(Experimental) extension Array: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } @_spi(Experimental) extension ContiguousArray: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } @_spi(Experimental) extension ArraySlice: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } @_spi(Experimental) extension UnsafeBufferPointer: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try body(.init(self)) } } @_spi(Experimental) extension UnsafeMutableBufferPointer: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try body(.init(self)) } } @_spi(Experimental) extension UnsafeRawBufferPointer: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try body(self) } } @_spi(Experimental) extension UnsafeMutableRawBufferPointer: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try body(.init(self)) } } @_spi(Experimental) extension String: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { var selfCopy = self return try selfCopy.withUTF8 { utf8 in try body(UnsafeRawBufferPointer(utf8)) @@ -162,7 +190,7 @@ extension String: Test.Attachable { @_spi(Experimental) extension Substring: Test.Attachable { - public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { var selfCopy = self return try selfCopy.withUTF8 { utf8 in try body(UnsafeRawBufferPointer(utf8)) diff --git a/Sources/Testing/Attachments/Test.Attachment.swift b/Sources/Testing/Attachments/Test.Attachment.swift index 36da9f8c6..b3b6ada93 100644 --- a/Sources/Testing/Attachments/Test.Attachment.swift +++ b/Sources/Testing/Attachments/Test.Attachment.swift @@ -20,30 +20,13 @@ extension Test { /// value of some type that conforms to ``Test/Attachable``. Initialize an /// instance of ``Test/Attachment`` with that value and, optionally, a /// preferred filename to use when writing to disk. - public struct Attachment: Sendable { -#if !SWT_NO_LAZY_ATTACHMENTS - /// Storage for ``attachableValue``. - private var _attachableValue: any Attachable & Sendable /* & Copyable rdar://137614425 */ - - /// The value of this attachment. - /// - /// The type of this property's value may not match the type of the value - /// originally used to create this attachment. - public var attachableValue: any Attachable & Sendable /* & Copyable rdar://137614425 */ { - _attachableValue - } -#else - /// Storage for ``attachableValue``. - private var _attachableValue: _AttachableProxy - - /// The value of this attachment. - /// - /// The type of this property's value may not match the type of the value - /// originally used to create this attachment. - public var attachableValue: some Test.Attachable & Sendable & Copyable { - _attachableValue - } -#endif + /// + /// Although it is not a constraint of `AttachableValue`, instances of this + /// type can only be created with attachable values that conform to + /// ``Test/Attachable``. + public struct Attachment: ~Copyable where AttachableValue: Test.Attachable & ~Copyable { + /// Storage for ``attachableValue-29ppv``. + fileprivate var _attachableValue: AttachableValue /// The path to which the this attachment was written, if any. /// @@ -71,19 +54,16 @@ extension Test { /// value of this property has not been explicitly set, the testing library /// will attempt to generate its own value. public var preferredName: String - - /// The source location where the attachment was initialized. - /// - /// The value of this property is used when recording issues associated with - /// the attachment. - public var sourceLocation: SourceLocation } } -// MARK: - +extension Test.Attachment: Copyable where AttachableValue: Copyable {} +extension Test.Attachment: Sendable where AttachableValue: Sendable {} + +// MARK: - Initializing an attachment -extension Test.Attachment { #if !SWT_NO_LAZY_ATTACHMENTS +extension Test.Attachment where AttachableValue: ~Copyable { /// Initialize an instance of this type that encloses the given attachable /// value. /// @@ -93,98 +73,179 @@ extension Test.Attachment { /// - preferredName: The preferred name of the attachment when writing it /// to a test report or to disk. If `nil`, the testing library attempts /// to derive a reasonable filename for the attached value. - /// - sourceLocation: The source location of the call to this initializer. - /// This value is used when recording issues associated with the - /// attachment. - public init( - _ attachableValue: some Test.Attachable & Sendable & Copyable, - named preferredName: String? = nil, - sourceLocation: SourceLocation = #_sourceLocation - ) { - let preferredName = preferredName ?? Self.defaultPreferredName - self.init(_attachableValue: attachableValue, preferredName: preferredName, sourceLocation: sourceLocation) + public init(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil) { + self._attachableValue = attachableValue + self.preferredName = preferredName ?? Self.defaultPreferredName } -#endif +} - /// Attach this instance to the current test. +@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +extension Test.Attachment where AttachableValue == Test.AnyAttachable { + /// Create a type-erased attachment from an instance of ``Test/Attachment``. /// - /// An attachment can only be attached once. - public consuming func attach() { - Event.post(.valueAttached(self)) + /// - Parameters: + /// - attachment: The attachment to type-erase. + package init(_ attachment: Test.Attachment) { + self.init( + _attachableValue: Test.AnyAttachable(attachableValue: attachment.attachableValue), + fileSystemPath: attachment.fileSystemPath, + preferredName: attachment.preferredName + ) } } +#endif -// MARK: - Non-sendable and move-only attachments +extension Test { + /// A type-erased container type that represents any attachable value. + /// + /// This type is not generally visible to developers. It is used when posting + /// events of kind ``Event/Kind/valueAttached(_:sourceLocation:)``. Test tools + /// authors who use `@_spi(ForToolsIntegrationOnly)` will see instances of + /// this type when handling those events. + /// + /// @Comment { + /// Swift's type system requires that this type be at least as visible as + /// `Event.Kind.valueAttached(_:sourceLocation:)`, otherwise it would be + /// declared as `private`. + /// } + @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + public struct AnyAttachable: Test.AttachableContainer, Copyable, Sendable { +#if !SWT_NO_LAZY_ATTACHMENTS + public typealias AttachableValue = any Test.Attachable & Sendable /* & Copyable rdar://137614425 */ +#else + public typealias AttachableValue = [UInt8] +#endif + + public var attachableValue: AttachableValue + + fileprivate init(attachableValue: AttachableValue) { + self.attachableValue = attachableValue + } + + public var estimatedAttachmentByteCount: Int? { + attachableValue.estimatedAttachmentByteCount + } -/// A type that stands in for an attachable type that is not also sendable. -private struct _AttachableProxy: Test.Attachable, Sendable { - /// The result of `withUnsafeBufferPointer(for:_:)` from the original - /// attachable value. - var encodedValue = [UInt8]() + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + func open(_ attachableValue: T, for attachment: borrowing Test.Attachment) throws -> R where T: Test.Attachable & Sendable & Copyable { + let temporaryAttachment = Test.Attachment( + _attachableValue: attachableValue, + fileSystemPath: attachment.fileSystemPath, + preferredName: attachment.preferredName + ) + return try attachableValue.withUnsafeBufferPointer(for: temporaryAttachment, body) + } + return try open(attachableValue, for: attachment) + } + } +} - var estimatedAttachmentByteCount: Int? +// MARK: - Getting an attachable value from an attachment - func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - try encodedValue.withUnsafeBufferPointer(for: attachment, body) +@_spi(Experimental) +extension Test.Attachment where AttachableValue: ~Copyable { + /// The value of this attachment. + @_disfavoredOverload public var attachableValue: AttachableValue { + _read { + yield _attachableValue + } } } -extension Test.Attachment { - /// Initialize an instance of this type that encloses the given attachable - /// value. +@_spi(Experimental) +extension Test.Attachment where AttachableValue: Test.AttachableContainer & ~Copyable { + /// The value of this attachment. /// - /// - Parameters: - /// - attachableValue: The value that will be attached to the output of - /// the test run. - /// - preferredName: The preferred name of the attachment when writing it - /// to a test report or to disk. If `nil`, the testing library attempts - /// to derive a reasonable filename for the attached value. - /// - sourceLocation: The source location of the call to this initializer. - /// This value is used when recording issues associated with the - /// attachment. - /// - /// When attaching a value of a type that does not conform to both `Sendable` - /// and `Copyable`, the testing library encodes it as data immediately. If the - /// value cannot be encoded and an error is thrown, that error is recorded as - /// an issue in the current test and the resulting instance of - /// ``Test/Attachment`` is empty. + /// When the attachable value's type conforms to ``Test/AttachableContainer``, + /// the value of this property equals the container's underlying attachable + /// value. To access the attachable value as an instance of `T` (where `T` + /// conforms to ``Test/AttachableContainer``), specify the type explicitly: + /// + /// ```swift + /// let attachableValue = attachment.attachableValue as T + /// ``` + public var attachableValue: AttachableValue.AttachableValue { + _read { + yield attachableValue.attachableValue + } + } +} + +// MARK: - Attaching an attachment to a test (etc.) + #if !SWT_NO_LAZY_ATTACHMENTS - @_disfavoredOverload +extension Test.Attachment where AttachableValue: Sendable & Copyable { + /// Attach this instance to the current test. + /// + /// - Parameters: + /// - sourceLocation: The source location of the call to this function. + /// + /// An attachment can only be attached once. + @_documentation(visibility: private) + public consuming func attach(sourceLocation: SourceLocation = #_sourceLocation) { + let attachmentCopy = Test.Attachment(self) + Event.post(.valueAttached(attachmentCopy, sourceLocation: sourceLocation)) + } +} #endif - public init( - _ attachableValue: borrowing some Test.Attachable & ~Copyable, - named preferredName: String? = nil, - sourceLocation: SourceLocation = #_sourceLocation - ) { - let preferredName = preferredName ?? Self.defaultPreferredName - var proxyAttachable = _AttachableProxy() - proxyAttachable.estimatedAttachmentByteCount = attachableValue.estimatedAttachmentByteCount - - // BUG: the borrow checker thinks that withErrorRecording() is consuming - // attachableValue, so get around it with an additional do/catch clause. + +extension Test.Attachment where AttachableValue: ~Copyable { + /// Attach this instance to the current test. + /// + /// - Parameters: + /// - sourceLocation: The source location of the call to this function. + /// + /// When attaching a value of a type that does not conform to both + /// [`Sendable`](https://developer.apple.com/documentation/swift/sendable) and + /// [`Copyable`](https://developer.apple.com/documentation/swift/copyable), + /// the testing library encodes it as data immediately. If the value cannot be + /// encoded and an error is thrown, that error is recorded as an issue in the + /// current test and the attachment is not written to the test report or to + /// disk. + /// + /// An attachment can only be attached once. + public consuming func attach(sourceLocation: SourceLocation = #_sourceLocation) { do { - let proxyAttachment = Self(_attachableValue: proxyAttachable, preferredName: preferredName, sourceLocation: sourceLocation) - proxyAttachable.encodedValue = try attachableValue.withUnsafeBufferPointer(for: proxyAttachment) { buffer in - [UInt8](buffer) + let attachmentCopy = try attachableValue.withUnsafeBufferPointer(for: self) { buffer in + let attachmentCopy = Test.Attachment(_attachableValue: Array(buffer), fileSystemPath: fileSystemPath, preferredName: preferredName) + return Test.Attachment(attachmentCopy) } - proxyAttachable.estimatedAttachmentByteCount = proxyAttachable.encodedValue.count + Event.post(.valueAttached(attachmentCopy, sourceLocation: sourceLocation)) } catch { - Issue.withErrorRecording(at: sourceLocation) { - // TODO: define new issue kind .valueAttachmentFailed(any Error) - // (but only use it if the caught error isn't ExpectationFailedError, - // SystemError, or APIMisuseError. We need a protocol for these things.) - throw error - } + let sourceContext = SourceContext(backtrace: .current(), sourceLocation: sourceLocation) + Issue(kind: .valueAttachmentFailed(error), comments: [], sourceContext: sourceContext).record() } + } +} + +// MARK: - Getting the serialized form of an attachable value (generically) - self.init(_attachableValue: proxyAttachable, preferredName: preferredName, sourceLocation: sourceLocation) +extension Test.Attachment where AttachableValue: ~Copyable { + /// Call a function and pass a buffer representing the value of this + /// instance's ``attachableValue-29ppv`` property to it. + /// + /// - Parameters: + /// - 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. + /// + /// The testing library uses this function when writing an attachment to a + /// test report or to a file on disk. This function calls the + /// ``Test/Attachable/withUnsafeBufferPointer(for:_:)`` function on this + /// attachment's ``attachableValue-29ppv`` property. + @inlinable public borrowing func withUnsafeBufferPointer(_ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try attachableValue.withUnsafeBufferPointer(for: self, body) } } #if !SWT_NO_FILE_IO // MARK: - Writing -extension Test.Attachment { +extension Test.Attachment where AttachableValue: ~Copyable { /// Write the attachment's contents to a file in the specified directory. /// /// - Parameters: @@ -210,8 +271,8 @@ extension Test.Attachment { /// This function is provided as a convenience to allow tools authors to write /// attachments to persistent storage the same way that Swift Package Manager /// does. You are not required to use this function. - @_spi(ForToolsIntegrationOnly) - public func write(toFileInDirectoryAtPath directoryPath: String) throws -> String { + @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + public borrowing func write(toFileInDirectoryAtPath directoryPath: String) throws -> String { try write( toFileInDirectoryAtPath: directoryPath, appending: String(UInt64.random(in: 0 ..< .max), radix: 36) @@ -238,7 +299,7 @@ extension Test.Attachment { /// /// If the argument `suffix` always produces the same string, the result of /// this function is undefined. - func write(toFileInDirectoryAtPath directoryPath: String, usingPreferredName: Bool = true, appending suffix: @autoclosure () -> String) throws -> String { + borrowing func write(toFileInDirectoryAtPath directoryPath: String, usingPreferredName: Bool = true, appending suffix: @autoclosure () -> String) throws -> String { let result: String let preferredName = usingPreferredName ? preferredName : Self.defaultPreferredName @@ -278,6 +339,8 @@ extension Test.Attachment { } } + // There should be no code path that leads to this call where the attachable + // value is nil. try attachableValue.withUnsafeBufferPointer(for: self) { buffer in try file!.write(buffer) } @@ -296,33 +359,42 @@ extension Configuration { /// function does nothing. /// - context: The context associated with the event. /// + /// - Returns: Whether or not to continue handling the event. + /// /// This function is called automatically by ``handleEvent(_:in:)``. You do /// not need to call it elsewhere. It automatically persists the attachment /// associated with `event` and modifies `event` to include the path where the /// attachment was stored. - func handleValueAttachedEvent(_ event: inout Event, in eventContext: borrowing Event.Context) { + func handleValueAttachedEvent(_ event: inout Event, in eventContext: borrowing Event.Context) -> Bool { guard let attachmentsPath else { // If there is no path to which attachments should be written, there's - // nothing to do. - return + // nothing to do here. The event handler may still want to handle it. + return true } - guard case let .valueAttached(attachment) = event.kind else { + guard case let .valueAttached(attachment, sourceLocation) = event.kind else { preconditionFailure("Passed the wrong kind of event to \(#function) (expected valueAttached, got \(event.kind)). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") } if attachment.fileSystemPath != nil { // Somebody already persisted this attachment. This isn't necessarily a // logic error in the testing library, but it probably means we shouldn't - // persist it again. - return + // persist it again. Suppress the event. + return false } - // Write the attachment. If an error occurs, record it as an issue in the - // current test. - Issue.withErrorRecording(at: attachment.sourceLocation, configuration: self) { + do { + // Write the attachment. var attachment = attachment attachment.fileSystemPath = try attachment.write(toFileInDirectoryAtPath: attachmentsPath) - event.kind = .valueAttached(attachment) + + // Update the event before returning and continuing to handle it. + event.kind = .valueAttached(attachment, sourceLocation: sourceLocation) + return true + } catch { + // Record the error as an issue and suppress the event. + let sourceContext = SourceContext(backtrace: .current(), sourceLocation: sourceLocation) + Issue(kind: .valueAttachmentFailed(error), comments: [], sourceContext: sourceContext).record() + return false } } } diff --git a/Sources/Testing/Events/Event.swift b/Sources/Testing/Events/Event.swift index 54f4eea31..3fe1b6b87 100644 --- a/Sources/Testing/Events/Event.swift +++ b/Sources/Testing/Events/Event.swift @@ -102,8 +102,10 @@ public struct Event: Sendable { /// /// - Parameters: /// - attachment: The attachment that was created. + /// - sourceLocation: The source location of the function call that caused + /// this event. @_spi(Experimental) - indirect case valueAttached(_ attachment: Test.Attachment) + indirect case valueAttached(_ attachment: Test.Attachment, sourceLocation: SourceLocation) /// A test ended. /// diff --git a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift index ab1f56702..91671da57 100644 --- a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift @@ -459,7 +459,7 @@ extension Event.HumanReadableOutputRecorder { } return CollectionOfOne(primaryMessage) + additionalMessages - case let .valueAttached(attachment): + case let .valueAttached(attachment, _): var result = [ Message( symbol: .attachment, diff --git a/Sources/Testing/Issues/Issue.swift b/Sources/Testing/Issues/Issue.swift index 9f9029459..69f92eaf6 100644 --- a/Sources/Testing/Issues/Issue.swift +++ b/Sources/Testing/Issues/Issue.swift @@ -60,6 +60,14 @@ public struct Issue: Sendable { /// A known issue was expected, but was not recorded. case knownIssueNotRecorded + /// An issue due to an `Error` being thrown while attempting to save an + /// attachment to a test report or to disk. + /// + /// - Parameters: + /// - error: The error which was associated with this issue. + @_spi(Experimental) + case valueAttachmentFailed(_ error: any Error) + /// An issue occurred due to misuse of the testing library. case apiMisused @@ -216,6 +224,8 @@ extension Issue.Kind: CustomStringConvertible { return "Time limit was exceeded: \(TimeValue(timeLimitComponents))" case .knownIssueNotRecorded: return "Known issue was not recorded" + case let .valueAttachmentFailed(error): + return "Caught error while saving attachment: \(error)" case .apiMisused: return "An API was misused" case .system: @@ -355,7 +365,7 @@ extension Issue.Kind { .expectationFailed(Expectation.Snapshot(snapshotting: expectation)) case .confirmationMiscounted: .unconditional - case let .errorCaught(error): + case let .errorCaught(error), let .valueAttachmentFailed(error): .errorCaught(ErrorSnapshot(snapshotting: error)) case let .timeLimitExceeded(timeLimitComponents: timeLimitComponents): .timeLimitExceeded(timeLimitComponents: timeLimitComponents) diff --git a/Sources/Testing/Running/Configuration+EventHandling.swift b/Sources/Testing/Running/Configuration+EventHandling.swift index 03931b790..025f07d2c 100644 --- a/Sources/Testing/Running/Configuration+EventHandling.swift +++ b/Sources/Testing/Running/Configuration+EventHandling.swift @@ -27,7 +27,10 @@ extension Configuration { #if !SWT_NO_FILE_IO if case .valueAttached = event.kind { var eventCopy = copy event - handleValueAttachedEvent(&eventCopy, in: context) + guard handleValueAttachedEvent(&eventCopy, in: contextCopy) else { + // The attachment could not be handled, so suppress this event. + return + } return eventHandler(eventCopy, contextCopy) } #endif diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 85299c588..24f0c6944 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -11,6 +11,11 @@ @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing private import _TestingInternals +#if canImport(Foundation) +import Foundation +@_spi(Experimental) import _Testing_Foundation +#endif + @Suite("Attachment Tests") struct AttachmentTests { @Test func saveValue() { @@ -135,7 +140,7 @@ struct AttachmentTests { var configuration = Configuration() configuration.attachmentsPath = try temporaryDirectory() configuration.eventHandler = { event, _ in - guard case let .valueAttached(attachment) = event.kind else { + guard case let .valueAttached(attachment, _) = event.kind else { return } valueAttached() @@ -165,7 +170,7 @@ struct AttachmentTests { await confirmation("Attachment detected") { valueAttached in var configuration = Configuration() configuration.eventHandler = { event, _ in - guard case let .valueAttached(attachment) = event.kind else { + guard case let .valueAttached(attachment, _) = event.kind else { return } @@ -184,11 +189,12 @@ struct AttachmentTests { await confirmation("Attachment detected") { valueAttached in var configuration = Configuration() configuration.eventHandler = { event, _ in - guard case let .valueAttached(attachment) = event.kind else { + guard case let .valueAttached(attachment, _) = event.kind else { return } #expect(attachment.preferredName == "loremipsum") + #expect(attachment.attachableValue is MySendableAttachable) valueAttached() } @@ -200,14 +206,14 @@ struct AttachmentTests { } @Test func issueRecordedWhenAttachingNonSendableValueThatThrows() async { - await confirmation("Attachment detected") { valueAttached in + await confirmation("Attachment detected", expectedCount: 0) { valueAttached in await confirmation("Issue recorded") { issueRecorded in var configuration = Configuration() configuration.eventHandler = { event, _ in if case .valueAttached = event.kind { valueAttached() } else if case let .issueRecorded(issue) = event.kind, - case let .errorCaught(error) = issue.kind, + case let .valueAttachmentFailed(error) = issue.kind, error is MyError { issueRecorded() } @@ -221,15 +227,190 @@ struct AttachmentTests { } } } + +#if canImport(Foundation) +#if !SWT_NO_FILE_IO + @Test func attachContentsOfFileURL() async throws { + let data = try #require("".data(using: .utf8)) + let temporaryFileName = "\(UUID().uuidString).html" + let temporaryPath = try appendPathComponent(temporaryFileName, to: temporaryDirectory()) + let temporaryURL = URL(fileURLWithPath: temporaryPath, isDirectory: false) + try data.write(to: temporaryURL) + defer { + try? FileManager.default.removeItem(at: temporaryURL) + } + + await confirmation("Attachment detected") { valueAttached in + var configuration = Configuration() + configuration.eventHandler = { event, _ in + guard case let .valueAttached(attachment, _) = event.kind else { + return + } + + #expect(attachment.preferredName == temporaryFileName) + #expect(throws: Never.self) { + try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in + #expect(buffer.count == data.count) + } + } + valueAttached() + } + + await Test { + let attachment = try await Test.Attachment(contentsOf: temporaryURL) + attachment.attach() + }.run(configuration: configuration) + } + } + +#if !SWT_NO_PROCESS_SPAWNING + @Test func attachContentsOfDirectoryURL() async throws { + let temporaryDirectoryName = UUID().uuidString + let temporaryPath = try appendPathComponent(temporaryDirectoryName, to: temporaryDirectory()) + let temporaryURL = URL(fileURLWithPath: temporaryPath, isDirectory: false) + try FileManager.default.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + + let fileData = try #require("Hello world".data(using: .utf8)) + try fileData.write(to: temporaryURL.appendingPathComponent("loremipsum.txt"), options: [.atomic]) + + await confirmation("Attachment detected") { valueAttached in + var configuration = Configuration() + configuration.eventHandler = { event, _ in + guard case let .valueAttached(attachment, _) = event.kind else { + return + } + + #expect(attachment.preferredName == "\(temporaryDirectoryName).tgz") + valueAttached() + } + + await Test { + let attachment = try await Test.Attachment(contentsOf: temporaryURL) + attachment.attach() + }.run(configuration: configuration) + } + } +#endif + + @Test func attachUnsupportedContentsOfURL() async throws { + let url = try #require(URL(string: "https://www.example.com")) + await #expect(throws: CocoaError.self) { + _ = try await Test.Attachment(contentsOf: url) + } + } +#endif + + struct CodableAttachmentArguments: Sendable, CustomTestArgumentEncodable, CustomTestStringConvertible { + var forSecureCoding: Bool + var pathExtension: String? + var firstCharacter: Character + var decode: @Sendable (Data) throws -> String + + @Sendable static func decodeWithJSONDecoder(_ data: Data) throws -> String { + try JSONDecoder().decode(MyCodableAttachable.self, from: data).string + } + + @Sendable static func decodeWithPropertyListDecoder(_ data: Data) throws -> String { + try PropertyListDecoder().decode(MyCodableAttachable.self, from: data).string + } + + @Sendable static func decodeWithNSKeyedUnarchiver(_ data: Data) throws -> String { + let result = try NSKeyedUnarchiver.unarchivedObject(ofClass: MySecureCodingAttachable.self, from: data) + return try #require(result).string + } + + static func all() -> [Self] { + var result = [Self]() + + for forSecureCoding in [false, true] { + let decode = forSecureCoding ? decodeWithNSKeyedUnarchiver : decodeWithPropertyListDecoder + result += [ + Self( + forSecureCoding: forSecureCoding, + firstCharacter: forSecureCoding ? "b" : "{", + decode: forSecureCoding ? decodeWithNSKeyedUnarchiver : decodeWithJSONDecoder + ) + ] + + result += [ + Self(forSecureCoding: forSecureCoding, pathExtension: "xml", firstCharacter: "<", decode: decode), + Self(forSecureCoding: forSecureCoding, pathExtension: "plist", firstCharacter: "b", decode: decode), + ] + + if !forSecureCoding { + result += [ + Self(forSecureCoding: forSecureCoding, pathExtension: "json", firstCharacter: "{", decode: decodeWithJSONDecoder), + ] + } + } + + return result + } + + func encodeTestArgument(to encoder: some Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(pathExtension) + try container.encode(forSecureCoding) + try container.encode(firstCharacter.asciiValue!) + } + + var testDescription: String { + "(forSecureCoding: \(forSecureCoding), extension: \(String(describingForTest: pathExtension)))" + } + } + + @Test("Attach Codable- and NSSecureCoding-conformant values", .serialized, arguments: CodableAttachmentArguments.all()) + func attachCodable(args: CodableAttachmentArguments) async throws { + var name = "loremipsum" + if let ext = args.pathExtension { + name = "\(name).\(ext)" + } + + let attachmentCopy: Test.Attachment + if args.forSecureCoding { + let attachableValue = MySecureCodingAttachable(string: "stringly speaking") + let attachment = Test.Attachment(attachableValue, named: name) + attachmentCopy = Test.Attachment(attachment) + } else { + let attachableValue = MyCodableAttachable(string: "stringly speaking") + let attachment = Test.Attachment(attachableValue, named: name) + attachmentCopy = Test.Attachment(attachment) + } + + try attachmentCopy.withUnsafeBufferPointer { bytes in + #expect(bytes.first == args.firstCharacter.asciiValue) + let decodedStringValue = try args.decode(Data(bytes)) + #expect(decodedStringValue == "stringly speaking") + } + } + + @Test("Attach NSSecureCoding-conformant value but with a JSON type") + func attachNSSecureCodingAsJSON() async throws { + let attachableValue = MySecureCodingAttachable(string: "stringly speaking") + let attachment = Test.Attachment(attachableValue, named: "loremipsum.json") + #expect(throws: CocoaError.self) { + try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { _ in } + } + } + + @Test("Attach NSSecureCoding-conformant value but with a nonsensical type") + func attachNSSecureCodingAsNonsensical() async throws { + let attachableValue = MySecureCodingAttachable(string: "stringly speaking") + let attachment = Test.Attachment(attachableValue, named: "loremipsum.gif") + #expect(throws: CocoaError.self) { + try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { _ in } + } + } +#endif } extension AttachmentTests { @Suite("Built-in conformances") struct BuiltInConformances { - func test(_ value: borrowing some Test.Attachable & ~Copyable) throws { + func test(_ value: some Test.Attachable) throws { #expect(value.estimatedAttachmentByteCount == 6) let attachment = Test.Attachment(value) - try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in + try attachment.withUnsafeBufferPointer { buffer in #expect(buffer.elementsEqual("abc123".utf8)) #expect(buffer.count == 6) } @@ -296,7 +477,7 @@ struct MyAttachable: Test.Attachable, ~Copyable { var string: String var errorToThrow: (any Error)? - func withUnsafeBufferPointer(for attachment: borrowing Testing.Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { if let errorToThrow { throw errorToThrow } @@ -314,7 +495,8 @@ extension MyAttachable: Sendable {} struct MySendableAttachable: Test.Attachable, Sendable { var string: String - func withUnsafeBufferPointer(for attachment: borrowing Testing.Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + #expect(attachment.attachableValue.string == string) var string = string return try string.withUTF8 { buffer in try body(.init(buffer)) @@ -325,10 +507,52 @@ struct MySendableAttachable: Test.Attachable, Sendable { struct MySendableAttachableWithDefaultByteCount: Test.Attachable, Sendable { var string: String - func withUnsafeBufferPointer(for attachment: borrowing Testing.Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { var string = string return try string.withUTF8 { buffer in try body(.init(buffer)) } } } + +#if canImport(Foundation) +struct MyCodableAttachable: Codable, Test.Attachable, Sendable { + var string: String +} + +final class MySecureCodingAttachable: NSObject, NSSecureCoding, Test.Attachable, Sendable { + let string: String + + init(string: String) { + self.string = string + } + + static var supportsSecureCoding: Bool { + true + } + + func encode(with coder: NSCoder) { + coder.encode(string, forKey: "string") + } + + required init?(coder: NSCoder) { + string = (coder.decodeObject(of: NSString.self, forKey: "string") as? String) ?? "" + } +} + +final class MyCodableAndSecureCodingAttachable: NSObject, Codable, NSSecureCoding, Test.Attachable, Sendable { + let string: String + + static var supportsSecureCoding: Bool { + true + } + + func encode(with coder: NSCoder) { + coder.encode(string, forKey: "string") + } + + required init?(coder: NSCoder) { + string = (coder.decodeObject(of: NSString.self, forKey: "string") as? String) ?? "" + } +} +#endif diff --git a/cmake/modules/shared/AvailabilityDefinitions.cmake b/cmake/modules/shared/AvailabilityDefinitions.cmake index 0685fcecd..2124a32be 100644 --- a/cmake/modules/shared/AvailabilityDefinitions.cmake +++ b/cmake/modules/shared/AvailabilityDefinitions.cmake @@ -9,6 +9,7 @@ # Settings which define commonly-used OS availability macros. add_compile_options( "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_mangledTypeNameAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0\">" + "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_uttypesAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_backtraceAsyncAPI:macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_clockAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0\">"