diff --git a/Package.swift b/Package.swift index 031968574..5d08997e0 100644 --- a/Package.swift +++ b/Package.swift @@ -125,6 +125,7 @@ let package = Package( name: "TestingTests", dependencies: [ "Testing", + "_Testing_AppKit", "_Testing_CoreGraphics", "_Testing_Foundation", "MemorySafeTestingTests", @@ -190,6 +191,15 @@ let package = Package( ), // Cross-import overlays (not supported by Swift Package Manager) + .target( + name: "_Testing_AppKit", + dependencies: [ + "Testing", + "_Testing_CoreGraphics", + ], + path: "Sources/Overlays/_Testing_AppKit", + swiftSettings: .packageSettings + ), .target( name: "_Testing_CoreGraphics", dependencies: [ diff --git a/Sources/Overlays/_Testing_AppKit/Attachments/NSImage+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_AppKit/Attachments/NSImage+AttachableAsCGImage.swift new file mode 100644 index 000000000..529dfc724 --- /dev/null +++ b/Sources/Overlays/_Testing_AppKit/Attachments/NSImage+AttachableAsCGImage.swift @@ -0,0 +1,95 @@ +// +// 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(AppKit) +public import AppKit +@_spi(ForSwiftTestingOnly) @_spi(Experimental) public import _Testing_CoreGraphics + +@_spi(Experimental) +extension NSImage: AttachableAsCGImage { + public var attachableCGImage: CGImage { + get throws { + let ctm = AffineTransform(scale: _attachmentScaleFactor) as NSAffineTransform + guard let result = cgImage(forProposedRect: nil, context: nil, hints: [.ctm: ctm]) else { + throw ImageAttachmentError.couldNotCreateCGImage + } + return result + } + } + + public var _attachmentScaleFactor: CGFloat { + let maxRepWidth = representations.lazy + .map { CGFloat($0.pixelsWide) / $0.size.width } + .filter { $0 > 0.0 } + .max() + return maxRepWidth ?? 1.0 + } + + /// Get the base address of the loaded image containing `class`. + /// + /// - Parameters: + /// - class: The class to look for. + /// + /// - Returns: The base address of the image containing `class`, or `nil` if + /// no image was found (for instance, if the class is generic or dynamically + /// generated.) + /// + /// "Image" in this context refers to a binary/executable image. + private static func _baseAddressOfImage(containing `class`: AnyClass) -> UnsafeRawPointer? { + let classAsAddress = Unmanaged.passUnretained(`class` as AnyObject).toOpaque() + + var info = Dl_info() + guard 0 != dladdr(classAsAddress, &info) else { + return nil + } + return .init(info.dli_fbase) + } + + /// The base address of the image containing AppKit's symbols, if known. + private static nonisolated(unsafe) let _appKitBaseAddress = _baseAddressOfImage(containing: NSImageRep.self) + + public func _makeCopyForAttachment() -> Self { + // If this image is of an NSImage subclass, we cannot reliably make a deep + // copy of it because we don't know what its `init(data:)` implementation + // might do. Try to make a copy (using NSCopying), but if that doesn't work + // then just return `self` verbatim. + // + // Third-party NSImage subclasses are presumably rare in the wild, so + // hopefully this case doesn't pop up too often. + guard isMember(of: NSImage.self) else { + return self.copy() as? Self ?? self + } + + // Check whether the image contains any representations that we don't think + // are safe. If it does, then make a "safe" copy. + let allImageRepsAreSafe = representations.allSatisfy { imageRep in + // NSCustomImageRep includes an arbitrary rendering block that may not be + // concurrency-safe in Swift. + if imageRep is NSCustomImageRep { + return false + } + + // Treat all other classes declared in AppKit as safe. We can't reason + // about classes declared in other modules, so treat them all as if they + // are unsafe. + return Self._baseAddressOfImage(containing: type(of: imageRep)) == Self._appKitBaseAddress + } + if !allImageRepsAreSafe, let safeCopy = tiffRepresentation.flatMap(Self.init(data:)) { + // Create a "safe" copy of this image by flattening it to TIFF and then + // creating a new NSImage instance from it. + return safeCopy + } + + // This image appears to be safe to copy directly. (This call should never + // fail since we already know `self` is a direct instance of `NSImage`.) + return unsafeDowncast(self.copy() as AnyObject, to: Self.self) + } +} +#endif diff --git a/Sources/Overlays/_Testing_AppKit/ReexportTesting.swift b/Sources/Overlays/_Testing_AppKit/ReexportTesting.swift new file mode 100644 index 000000000..3716f1f01 --- /dev/null +++ b/Sources/Overlays/_Testing_AppKit/ReexportTesting.swift @@ -0,0 +1,12 @@ +// +// 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 +@_exported public import _Testing_CoreGraphics diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift index 14df843c6..08c94823c 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift @@ -24,6 +24,8 @@ private import ImageIO /// be attached to a test: /// /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) +/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) +/// (macOS) /// /// 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, diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift index 6bb6f3744..6c9a76dd5 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift @@ -36,6 +36,8 @@ extension Attachment { /// ``AttachableAsCGImage`` protocol and can be attached to a test: /// /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) + /// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) + /// (macOS) /// /// The testing library uses the image format specified by `contentType`. Pass /// `nil` to let the testing library decide which image format to use. If you @@ -80,6 +82,8 @@ extension Attachment { /// ``AttachableAsCGImage`` protocol and can be attached to a test: /// /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) + /// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) + /// (macOS) /// /// The testing library uses the image format specified by `contentType`. Pass /// `nil` to let the testing library decide which image format to use. If you diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift index 9b8ea6788..f61b17e7d 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift @@ -47,6 +47,8 @@ import UniformTypeIdentifiers /// to the ``AttachableAsCGImage`` protocol and can be attached to a test: /// /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) +/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) +/// (macOS) @_spi(Experimental) @available(_uttypesAPI, *) public struct _AttachableImageWrapper: Sendable where Image: AttachableAsCGImage { diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index efa1eccfd..4b16d98ea 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -10,6 +10,10 @@ @testable @_spi(ForToolsIntegrationOnly) import Testing private import _TestingInternals +#if canImport(AppKit) +import AppKit +@_spi(Experimental) import _Testing_AppKit +#endif #if canImport(Foundation) import Foundation import _Testing_Foundation @@ -577,6 +581,71 @@ extension AttachmentTests { } } #endif + +#if canImport(AppKit) + static var nsImage: NSImage { + get throws { + let cgImage = try cgImage.get() + let size = CGSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height)) + return NSImage(cgImage: cgImage, size: size) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImage() throws { + let image = try Self.nsImage + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImageWithCustomRep() throws { + let image = NSImage(size: NSSize(width: 32.0, height: 32.0), flipped: false) { rect in + NSColor.red.setFill() + rect.fill() + return true + } + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImageWithSubclassedNSImage() throws { + let image = MyImage(size: NSSize(width: 32.0, height: 32.0)) + image.addRepresentation(NSCustomImageRep(size: image.size, flipped: false) { rect in + NSColor.green.setFill() + rect.fill() + return true + }) + + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue === image) + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImageWithSubclassedRep() throws { + let image = NSImage(size: NSSize(width: 32.0, height: 32.0)) + image.addRepresentation(MyImageRep()) + + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + let firstRep = try #require(attachment.attachableValue.representations.first) + #expect(!(firstRep is MyImageRep)) + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } +#endif #endif } } @@ -666,3 +735,42 @@ final class MyCodableAndSecureCodingAttachable: NSObject, Codable, NSSecureCodin } } #endif + +#if canImport(AppKit) +private final class MyImage: NSImage { + override init(size: NSSize) { + super.init(size: size) + } + + required init(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) { + fatalError("Unimplemented") + } + + required init(coder: NSCoder) { + fatalError("Unimplemented") + } + + override func copy(with zone: NSZone?) -> Any { + // Intentionally make a copy as NSImage instead of MyImage to exercise the + // cast-failed code path in the overlay. + NSImage() + } +} + +private final class MyImageRep: NSImageRep { + override init() { + super.init() + size = NSSize(width: 32.0, height: 32.0) + } + + required init?(coder: NSCoder) { + fatalError("Unimplemented") + } + + override func draw() -> Bool { + NSColor.blue.setFill() + NSRect(origin: .zero, size: size).fill() + return true + } +} +#endif