diff --git a/Package.swift b/Package.swift index f840a21cb..b109c0447 100644 --- a/Package.swift +++ b/Package.swift @@ -51,6 +51,7 @@ let package = Package( name: "TestingTests", dependencies: [ "Testing", + "_Testing_CoreGraphics", "_Testing_Foundation", ], swiftSettings: .packageSettings @@ -91,6 +92,14 @@ let package = Package( ), // Cross-import overlays (not supported by Swift Package Manager) + .target( + name: "_Testing_CoreGraphics", + dependencies: [ + "Testing", + ], + path: "Sources/Overlays/_Testing_CoreGraphics", + swiftSettings: .packageSettings + ), .target( name: "_Testing_Foundation", dependencies: [ diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift new file mode 100644 index 000000000..14df843c6 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +public import CoreGraphics +private import ImageIO + +/// A protocol describing images that can be converted to instances of +/// ``Testing/Attachment``. +/// +/// Instances of types conforming to this protocol do not themselves conform to +/// ``Testing/Attachable``. Instead, the testing library provides additional +/// initializers on ``Testing/Attachment`` that take instances of such types and +/// handle converting them to image data when needed. +/// +/// The following system-provided image types conform to this protocol and can +/// be attached to a test: +/// +/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) +/// +/// You do not generally need to add your own conformances to this protocol. If +/// you have an image in another format that needs to be attached to a test, +/// first convert it to an instance of one of the types above. +@_spi(Experimental) +public protocol AttachableAsCGImage { + /// An instance of `CGImage` representing this image. + /// + /// - Throws: Any error that prevents the creation of an image. + var attachableCGImage: CGImage { get throws } + + /// The orientation of the image. + /// + /// The value of this property is the raw value of an instance of + /// `CGImagePropertyOrientation`. The default value of this property is + /// `.up`. + /// + /// This property is not part of the public interface of the testing + /// library. It may be removed in a future update. + var _attachmentOrientation: UInt32 { get } + + /// The scale factor of the image. + /// + /// The value of this property is typically greater than `1.0` when an image + /// originates from a Retina Display screenshot or similar. The default value + /// of this property is `1.0`. + /// + /// This property is not part of the public interface of the testing + /// library. It may be removed in a future update. + var _attachmentScaleFactor: CGFloat { get } + + /// Make a copy of this instance to pass to an attachment. + /// + /// - Returns: A copy of `self`, or `self` if no copy is needed. + /// + /// Several system image types do not conform to `Sendable`; use this + /// function to make copies of such images that will not be shared outside + /// of an attachment and so can be generally safely stored. + /// + /// The default implementation of this function when `Self` conforms to + /// `Sendable` simply returns `self`. + /// + /// This function is not part of the public interface of the testing library. + /// It may be removed in a future update. + func _makeCopyForAttachment() -> Self +} + +extension AttachableAsCGImage { + public var _attachmentOrientation: UInt32 { + CGImagePropertyOrientation.up.rawValue + } + + public var _attachmentScaleFactor: CGFloat { + 1.0 + } +} + +extension AttachableAsCGImage where Self: Sendable { + public func _makeCopyForAttachment() -> Self { + self + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift new file mode 100644 index 000000000..f93afb7f7 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift @@ -0,0 +1,147 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +@_spi(ForSwiftTestingOnly) @_spi(Experimental) public import Testing + +public import UniformTypeIdentifiers + +extension Attachment { + /// Initialize an instance of this type that encloses the given image. + /// + /// - Parameters: + /// - attachableValue: The value that will be attached to the output of + /// the test run. + /// - preferredName: The preferred name of the attachment when writing it + /// to a test report or to disk. If `nil`, the testing library attempts + /// to derive a reasonable filename for the attached value. + /// - contentType: The image format with which to encode `attachableValue`. + /// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), + /// the result is undefined. Pass `nil` to let the testing library decide + /// which image format to use. + /// - encodingQuality: The encoding quality to use when encoding the image. + /// If the image format used for encoding (specified by the `contentType` + /// argument) does not support variable-quality encoding, the value of + /// this argument is ignored. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// This is the designated initializer for this type when attaching an image + /// that conforms to ``AttachableAsCGImage``. + fileprivate init( + attachableValue: T, + named preferredName: String?, + contentType: (any Sendable)?, + encodingQuality: Float, + sourceLocation: SourceLocation + ) where AttachableValue == _AttachableImageContainer { + var imageContainer = _AttachableImageContainer(image: attachableValue, encodingQuality: encodingQuality) + + // Update the preferred name to include an extension appropriate for the + // given content type. (Note the `else` branch duplicates the logic in + // `preferredContentType(forEncodingQuality:)` but will go away once our + // minimum deployment targets include the UniformTypeIdentifiers framework.) + var preferredName = preferredName ?? Self.defaultPreferredName + if #available(_uttypesAPI, *) { + let contentType: UTType = contentType + .map { $0 as! UTType } + .flatMap { contentType in + if UTType.image.conforms(to: contentType) { + // This type is an abstract base type of .image (or .image itself.) + // We'll infer the concrete type based on other arguments. + return nil + } + return contentType + } ?? .preferred(forEncodingQuality: encodingQuality) + preferredName = (preferredName as NSString).appendingPathExtension(for: contentType) + imageContainer.contentType = contentType + } else { + // The caller can't provide a content type, so we'll pick one for them. + let ext = if encodingQuality < 1.0 { + "jpg" + } else { + "png" + } + if (preferredName as NSString).pathExtension.caseInsensitiveCompare(ext) != .orderedSame { + preferredName = (preferredName as NSString).appendingPathExtension(ext) ?? preferredName + } + } + + self.init(imageContainer, named: preferredName, sourceLocation: sourceLocation) + } + + /// Initialize an instance of this type that encloses the given image. + /// + /// - Parameters: + /// - attachableValue: The value that will be attached to the output of + /// the test run. + /// - preferredName: The preferred name of the attachment when writing it + /// to a test report or to disk. If `nil`, the testing library attempts + /// to derive a reasonable filename for the attached value. + /// - contentType: The image format with which to encode `attachableValue`. + /// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), + /// the result is undefined. Pass `nil` to let the testing library decide + /// which image format to use. + /// - encodingQuality: The encoding quality to use when encoding the image. + /// If the image format used for encoding (specified by the `contentType` + /// argument) does not support variable-quality encoding, the value of + /// this argument is ignored. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// The following system-provided image types conform to the + /// ``AttachableAsCGImage`` protocol and can be attached to a test: + /// + /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) + @_spi(Experimental) + @available(_uttypesAPI, *) + public init( + _ attachableValue: T, + named preferredName: String? = nil, + as contentType: UTType?, + encodingQuality: Float = 1.0, + sourceLocation: SourceLocation = #_sourceLocation + ) where AttachableValue == _AttachableImageContainer { + self.init(attachableValue: attachableValue, named: preferredName, contentType: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation) + } + + /// Initialize an instance of this type that encloses the given image. + /// + /// - Parameters: + /// - attachableValue: The value that will be attached to the output of + /// the test run. + /// - preferredName: The preferred name of the attachment when writing it + /// to a test report or to disk. If `nil`, the testing library attempts + /// to derive a reasonable filename for the attached value. + /// - encodingQuality: The encoding quality to use when encoding the image. + /// If the image format used for encoding (specified by the `contentType` + /// argument) does not support variable-quality encoding, the value of + /// this argument is ignored. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// The following system-provided image types conform to the + /// ``AttachableAsCGImage`` protocol and can be attached to a test: + /// + /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) + @_spi(Experimental) + public init( + _ attachableValue: T, + named preferredName: String? = nil, + encodingQuality: Float = 1.0, + sourceLocation: SourceLocation = #_sourceLocation + ) where AttachableValue == _AttachableImageContainer { + self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation) + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsCGImage.swift new file mode 100644 index 000000000..944798d39 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsCGImage.swift @@ -0,0 +1,20 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +public import CoreGraphics + +@_spi(Experimental) +extension CGImage: AttachableAsCGImage { + public var attachableCGImage: CGImage { + self + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentError.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentError.swift new file mode 100644 index 000000000..fdd0b3f3e --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentError.swift @@ -0,0 +1,41 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +/// A type representing an error that can occur when attaching an image. +@_spi(ForSwiftTestingOnly) +public enum ImageAttachmentError: Error, CustomStringConvertible { + /// The specified content type did not conform to `.image`. + case contentTypeDoesNotConformToImage + + /// The image could not be converted to an instance of `CGImage`. + case couldNotCreateCGImage + + /// The image destination could not be created. + case couldNotCreateImageDestination + + /// The image could not be converted. + case couldNotConvertImage + + @_spi(ForSwiftTestingOnly) + public var description: String { + switch self { + case .contentTypeDoesNotConformToImage: + "The specified type does not represent an image format." + case .couldNotCreateCGImage: + "Could not create the corresponding Core Graphics image." + case .couldNotCreateImageDestination: + "Could not create the Core Graphics image destination to encode this image." + case .couldNotConvertImage: + "Could not convert the image to the specified format." + } + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift new file mode 100644 index 000000000..5e8fcd227 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift @@ -0,0 +1,163 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +@_spi(Experimental) public import Testing +private import CoreGraphics + +private import ImageIO +import UniformTypeIdentifiers + +/// ## Why can't images directly conform to Attachable? +/// +/// Three reasons: +/// +/// 1. Several image classes are not marked `Sendable`, which means that as far +/// as Swift is concerned, they cannot be safely passed to Swift Testing's +/// event handler (primarily because `Event` is `Sendable`.) So we would have +/// to eagerly serialize them, which is unnecessarily expensive if we know +/// they're actually concurrency-safe. +/// 2. We would have no place to store metadata such as the encoding quality +/// (although in the future we may introduce a "metadata" associated type to +/// `Attachable` that could store that info.) +/// 3. `Attachable` has a requirement with `Self` in non-parameter, non-return +/// position. As far as Swift is concerned, a non-final class cannot satisfy +/// such a requirement, and all image types we care about are non-final +/// classes. Thus, the compiler will steadfastly refuse to allow non-final +/// classes to conform to the `Attachable` protocol. We could get around this +/// by changing the signature of `withUnsafeBufferPointer()` so that the +/// generic parameter to `Attachment` is not `Self`, but that would defeat +/// much of the purpose of making `Attachment` generic in the first place. +/// (And no, the language does not let us write `where T: Self` anywhere +/// useful.) + +/// A wrapper type for image types such as `CGImage` and `NSImage` that can be +/// attached indirectly. +/// +/// You do not need to use this type directly. Instead, initialize an instance +/// of ``Attachment`` using an instance of an image type that conforms to +/// ``AttachableAsCGImage``. The following system-provided image types conform +/// to the ``AttachableAsCGImage`` protocol and can be attached to a test: +/// +/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) +@_spi(Experimental) +public struct _AttachableImageContainer: Sendable where Image: AttachableAsCGImage { + /// The underlying image. + /// + /// `CGImage` and `UIImage` are sendable, but `NSImage` is not. `NSImage` + /// instances can be created from closures that are run at rendering time. + /// The AppKit cross-import overlay is responsible for ensuring that any + /// instances of this type it creates hold "safe" `NSImage` instances. + nonisolated(unsafe) var image: Image + + /// The encoding quality to use when encoding the represented image. + public var encodingQuality: Float + + /// Storage for ``contentType``. + private var _contentType: (any Sendable)? + + /// The content type to use when encoding the image. + /// + /// This property should eventually move up to ``Attachment``. It is not part + /// of the public interface of the testing library. + @available(_uttypesAPI, *) + var contentType: UTType? { + get { + _contentType as? UTType + } + set { + _contentType = newValue + } + } + + init(image: Image, encodingQuality: Float) { + self.image = image._makeCopyForAttachment() + self.encodingQuality = encodingQuality + } +} + +// MARK: - + +@available(_uttypesAPI, *) +extension UTType { + /// Determine the preferred content type to encode this image as for a given + /// encoding quality. + /// + /// - Parameters: + /// - encodingQuality: The encoding quality to use when encoding the image. + /// + /// - Returns: The type to encode this image as. + static func preferred(forEncodingQuality encodingQuality: Float) -> Self { + // If the caller wants lossy encoding, use JPEG. + if encodingQuality < 1.0 { + return .jpeg + } + + // Lossless encoding implies PNG. + return .png + } +} + +extension _AttachableImageContainer: AttachableContainer { + public var attachableValue: Image { + image + } + + public func withUnsafeBufferPointer(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + let data = NSMutableData() + + // Convert the image to a CGImage. + let attachableCGImage = try image.attachableCGImage + + // Get the type to encode as. (Note the `else` branches duplicate the logic + // in `preferredContentType(forEncodingQuality:)` but will go away once our + // minimum deployment targets include the UniformTypeIdentifiers framework.) + let typeIdentifier: CFString + if #available(_uttypesAPI, *), let contentType { + guard contentType.conforms(to: .image) else { + throw ImageAttachmentError.contentTypeDoesNotConformToImage + } + typeIdentifier = contentType.identifier as CFString + } else if encodingQuality < 1.0 { + typeIdentifier = kUTTypeJPEG + } else { + typeIdentifier = kUTTypePNG + } + + // Create the image destination. + guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, typeIdentifier, 1, nil) else { + throw ImageAttachmentError.couldNotCreateImageDestination + } + + // Configure the properties of the image conversion operation. + let orientation = image._attachmentOrientation + let scaleFactor = image._attachmentScaleFactor + let properties: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: CGFloat(encodingQuality), + kCGImagePropertyOrientation: orientation, + kCGImagePropertyDPIWidth: 72.0 * scaleFactor, + kCGImagePropertyDPIHeight: 72.0 * scaleFactor, + ] + + // Perform the image conversion. + CGImageDestinationAddImage(dest, attachableCGImage, properties as CFDictionary) + guard CGImageDestinationFinalize(dest) else { + throw ImageAttachmentError.couldNotConvertImage + } + + // Pass the bits of the image out to the body. Note that we have an + // NSMutableData here so we have to use slightly different API than we would + // with an instance of Data. + return try withExtendedLifetime(data) { + try body(UnsafeRawBufferPointer(start: data.bytes, count: data.length)) + } + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/ReexportTesting.swift b/Sources/Overlays/_Testing_CoreGraphics/ReexportTesting.swift new file mode 100644 index 000000000..3faa622d7 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/ReexportTesting.swift @@ -0,0 +1,11 @@ +// +// 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 +// + +@_exported public import Testing diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 431e08d24..a4a5b5104 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -14,6 +14,13 @@ private import _TestingInternals import Foundation @_spi(Experimental) import _Testing_Foundation #endif +#if canImport(CoreGraphics) +import CoreGraphics +@_spi(Experimental) @_spi(ForSwiftTestingOnly) import _Testing_CoreGraphics +#endif +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif @Suite("Attachment Tests") struct AttachmentTests { @@ -446,6 +453,87 @@ extension AttachmentTests { } } +extension AttachmentTests { + @Suite("Image tests") + struct ImageTests { + enum ImageTestError: Error { + case couldNotCreateCGContext + case couldNotCreateCGGradient + case couldNotCreateCGImage + } + +#if canImport(CoreGraphics) + static let cgImage = Result { + let size = CGSize(width: 32.0, height: 32.0) + let rgb = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedFirst.rawValue + guard let context = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: Int(size.width) * 4, + space: rgb, + bitmapInfo: bitmapInfo + ) else { + throw ImageTestError.couldNotCreateCGContext + } + guard let gradient = CGGradient( + colorsSpace: rgb, + colors: [ + CGColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), + CGColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0), + CGColor(red: 0.0, green: 0.0, blue: 1.0, alpha: 1.0), + ] as CFArray, + locations: nil + ) else { + throw ImageTestError.couldNotCreateCGGradient + } + context.drawLinearGradient( + gradient, + start: .zero, + end: CGPoint(x: size.width, y: size.height), + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation] + ) + guard let image = context.makeImage() else { + throw ImageTestError.couldNotCreateCGImage + } + return image + } + + @available(_uttypesAPI, *) + @Test func attachCGImage() throws { + let image = try Self.cgImage.get() + let attachment = Attachment(image, named: "diamond") + #expect(attachment.attachableValue === image) + try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in + #expect(buffer.count > 32) + } + attachment.attach() + } + + @available(_uttypesAPI, *) + @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [.png as UTType?, .jpeg, .gif, .image, .data, nil]) + func attachCGImage(quality: Float, type: UTType?) throws { + let image = try Self.cgImage.get() + let attachment = Attachment(image, named: "diamond", as: type, encodingQuality: quality) + #expect(attachment.attachableValue === image) + try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } + + @available(_uttypesAPI, *) + @Test func cannotAttachCGImageWithNonImageType() async { + #expect(throws: ImageAttachmentError.contentTypeDoesNotConformToImage) { + let attachment = Attachment(try Self.cgImage.get(), named: "diamond", as: .mp3) + try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { _ in } + } + } +#endif + } +} + // MARK: - Fixtures struct MyAttachable: Attachable, ~Copyable {