Skip to content

Commit ee1288d

Browse files
CoreTransferable attachments (#1519)
Implements support for attachments based on the `Transferable` protocol, per [ST-0023](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0023-attachments-transferable.md). ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. --------- Co-authored-by: Stuart Montgomery <[email protected]>
1 parent 8a4f3ad commit ee1288d

10 files changed

Lines changed: 321 additions & 1 deletion

File tree

Package.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ let package = Package(
154154
"_Testing_AppKit",
155155
"_Testing_CoreGraphics",
156156
"_Testing_CoreImage",
157+
"_Testing_CoreTransferable",
157158
"_Testing_Foundation",
158159
"_Testing_UIKit",
159160
"_Testing_WinSDK",
@@ -256,6 +257,15 @@ let package = Package(
256257
exclude: ["CMakeLists.txt"],
257258
swiftSettings: .packageSettings() + .enableLibraryEvolution() + .moduleABIName("_Testing_CoreImage")
258259
),
260+
.target(
261+
name: "_Testing_CoreTransferable",
262+
dependencies: [
263+
"Testing",
264+
],
265+
path: "Sources/Overlays/_Testing_CoreTransferable",
266+
exclude: ["CMakeLists.txt"],
267+
swiftSettings: .packageSettings() + .enableLibraryEvolution() + .moduleABIName("_Testing_CoreTransferable")
268+
),
259269
.target(
260270
name: "_Testing_Foundation",
261271
dependencies: [
@@ -399,6 +409,7 @@ extension Array where Element == PackageDescription.SwiftSetting {
399409
.enableExperimentalFeature("AvailabilityMacro=_uttypesAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0"),
400410
.enableExperimentalFeature("AvailabilityMacro=_clockAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0"),
401411
.enableExperimentalFeature("AvailabilityMacro=_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"),
412+
.enableExperimentalFeature("AvailabilityMacro=_transferableAPI:macOS 15.2, iOS 18.2, watchOS 11.2, tvOS 18.2, visionOS 2.2"),
402413
.enableExperimentalFeature("AvailabilityMacro=_castingWithNonCopyableGenerics:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"),
403414

404415
.enableExperimentalFeature("AvailabilityMacro=_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0"),

Sources/Overlays/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
add_subdirectory(_Testing_AppKit)
1010
add_subdirectory(_Testing_CoreGraphics)
1111
add_subdirectory(_Testing_CoreImage)
12+
add_subdirectory(_Testing_CoreTransferable)
1213
add_subdirectory(_Testing_Foundation)
1314
add_subdirectory(_Testing_UIKit)
1415
add_subdirectory(_Testing_WinSDK)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2026 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if canImport(CoreTransferable)
12+
public import Testing
13+
public import CoreTransferable
14+
15+
public import UniformTypeIdentifiers
16+
17+
/// @Metadata {
18+
/// @Available(Swift, introduced: 6.4)
19+
/// }
20+
@available(_transferableAPI, *)
21+
extension Attachment {
22+
/// Initialize an instance of this type that encloses the given transferable
23+
/// value.
24+
///
25+
/// - Parameters:
26+
/// - transferableValue: The value that will be attached to the output of
27+
/// the test run.
28+
/// - contentType: The content type with which to export `transferableValue`.
29+
/// If this argument is `nil`, the testing library calls [`exportedContentTypes(_:)`](https://developer.apple.com/documentation/coretransferable/transferable/exportedcontenttypes(_:))
30+
/// on `transferableValue` and uses the first type the function returns
31+
/// that conforms to [`UTType.data`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/data).
32+
/// - preferredName: The preferred name of the attachment to use when saving
33+
/// it. If `nil`, the testing library attempts to generate a reasonable
34+
/// filename for the attached value.
35+
/// - sourceLocation: The source location of the call to this initializer.
36+
/// This value is used when recording issues associated with the
37+
/// attachment.
38+
///
39+
/// - Throws: Any error that occurs while exporting `transferableValue`.
40+
///
41+
/// Use this initializer to create an instance of ``Attachment`` from a value
42+
/// that conforms to the [`Transferable`](https://developer.apple.com/documentation/coretransferable/transferable)
43+
/// protocol.
44+
///
45+
/// ```swift
46+
/// let menu = FoodTruck.menu
47+
/// let attachment = try await Attachment(exporting: menu, as: .pdf)
48+
/// Attachment.record(attachment)
49+
/// ```
50+
///
51+
/// When you call this initializer and pass it a transferable value, it
52+
/// calls [`exported(as:)`](https://developer.apple.com/documentation/coretransferable/transferable/exported(as:))
53+
/// on that value. This operation may take some time, so this initializer
54+
/// suspends the calling task until it is complete.
55+
///
56+
/// @Metadata {
57+
/// @Available(Swift, introduced: 6.4)
58+
/// }
59+
public init<T>(
60+
exporting transferableValue: T,
61+
as contentType: UTType? = nil,
62+
named preferredName: String? = nil,
63+
sourceLocation: SourceLocation = #_sourceLocation
64+
) async throws where T: Transferable, AttachableValue == _AttachableTransferableWrapper<T> {
65+
let transferableWrapper = try await _AttachableTransferableWrapper(exporting: transferableValue, as: contentType)
66+
self.init(transferableWrapper, named: preferredName, sourceLocation: sourceLocation)
67+
}
68+
}
69+
70+
// MARK: -
71+
72+
/// A type describing errors that can occur when attaching a transferable value.
73+
enum TransferableAttachmentError: Error {
74+
/// The developer did not pass a content type and the value did not list any
75+
/// that conform to `UTType.data`.
76+
case suitableContentTypeNotFound
77+
}
78+
79+
extension TransferableAttachmentError: CustomStringConvertible {
80+
var description: String {
81+
switch self {
82+
case .suitableContentTypeNotFound:
83+
"The value does not list any exported content types that conform to 'UTType.data'."
84+
}
85+
}
86+
}
87+
#endif
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2026 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if canImport(CoreTransferable)
12+
public import Testing
13+
public import CoreTransferable
14+
15+
private import Foundation
16+
import UniformTypeIdentifiers
17+
18+
/// A wrapper type representing transferable values that can be attached
19+
/// indirectly.
20+
///
21+
/// You do not need to use this type directly. Instead, initialize an instance
22+
/// of ``Attachment`` using an instance of a type conforming to the [`Transferable`](https://developer.apple.com/documentation/coretransferable/transferable)
23+
/// protocol.
24+
///
25+
/// @Metadata {
26+
/// @Available(Swift, introduced: 6.4)
27+
/// }
28+
@available(_transferableAPI, *)
29+
public struct _AttachableTransferableWrapper<T>: Sendable where T: Transferable {
30+
/// The transferable value.
31+
private var _transferableValue: T
32+
33+
/// The content type used to export the transferable value.
34+
private var _contentType: UTType
35+
36+
/// The exported form of the transferable value.
37+
private var _bytes: Data
38+
39+
init(exporting transferableValue: T, as contentType: UTType?) async throws {
40+
let contentType = contentType ?? transferableValue.exportedContentTypes()
41+
.first { $0.conforms(to: .data) }
42+
guard let contentType else {
43+
throw TransferableAttachmentError.suitableContentTypeNotFound
44+
}
45+
46+
_transferableValue = transferableValue
47+
_contentType = contentType
48+
_bytes = try await transferableValue.exported(as: contentType)
49+
}
50+
}
51+
52+
// MARK: -
53+
54+
/// @Metadata {
55+
/// @Available(Swift, introduced: 6.4)
56+
/// }
57+
@available(_transferableAPI, *)
58+
extension _AttachableTransferableWrapper: AttachableWrapper {
59+
/// @Metadata {
60+
/// @Available(Swift, introduced: 6.4)
61+
/// }
62+
public var wrappedValue: T {
63+
_transferableValue
64+
}
65+
66+
/// @Metadata {
67+
/// @Available(Swift, introduced: 6.4)
68+
/// }
69+
public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
70+
try _bytes.withUnsafeBytes(body)
71+
}
72+
73+
/// @Metadata {
74+
/// @Available(Swift, introduced: 6.4)
75+
/// }
76+
public borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
77+
let baseName = _transferableValue.suggestedFilename ?? suggestedName
78+
return (baseName as NSString).appendingPathExtension(for: _contentType)
79+
}
80+
}
81+
#endif
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# This source file is part of the Swift.org open source project
2+
#
3+
# Copyright (c) 2026 Apple Inc. and the Swift project authors
4+
# Licensed under Apache License v2.0 with Runtime Library Exception
5+
#
6+
# See https://swift.org/LICENSE.txt for license information
7+
# See https://swift.org/CONTRIBUTORS.txt for Swift project authors
8+
9+
if (CMAKE_SYSTEM_NAME STREQUAL "Darwin")
10+
include(ModuleABIName)
11+
add_library(_Testing_CoreTransferable
12+
Attachments/_AttachableTransferableWrapper.swift
13+
Attachments/Attachment+Transferable.swift
14+
ReexportTesting.swift)
15+
16+
target_link_libraries(_Testing_CoreTransferable PUBLIC
17+
Testing)
18+
19+
target_compile_options(_Testing_CoreTransferable PRIVATE
20+
-enable-library-evolution
21+
-emit-module-interface -emit-module-interface-path $<TARGET_PROPERTY:_Testing_CoreTransferable,Swift_MODULE_DIRECTORY>/_Testing_CoreTransferable.swiftinterface)
22+
23+
_swift_testing_install_target(_Testing_CoreTransferable)
24+
endif()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2026 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
@_exported public import Testing

Sources/Testing/Testing.docc/Attachments.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,44 @@ extension SalesReport: Encodable, Attachable {}
7676
your test target imports the [Foundation](https://developer.apple.com/documentation/foundation)
7777
module.
7878

79+
### Attach transferable values
80+
81+
If you have a value you want to save as an attachment that conforms to
82+
[`Transferable`](https://developer.apple.com/documentation/CoreTransferable/Transferable),
83+
you can create an instance of ``Attachment`` from it when you import the
84+
[Core Transferable](https://developer.apple.com/documentation/coretransferable)
85+
module.
86+
87+
```swift
88+
import Testing
89+
import CoreTransferable
90+
91+
struct SalesReport { ... }
92+
extension SalesReport: Encodable, Attachable {}
93+
94+
@Test func `sales report adds up`() async throws {
95+
let salesReport = await generateSalesReport()
96+
try salesReport.validate()
97+
let attachment = try await Attachment(exporting: salesReport)
98+
Attachment.record(attachment)
99+
}
100+
```
101+
102+
- Important: The testing library provides this ``Attachment`` initializer only
103+
if your test target imports the [Core Transferable](https://developer.apple.com/documentation/coretransferable)
104+
module.
105+
106+
When you create an attachment from a transferable value, the testing library
107+
calls the value's [`exported(as:)`](https://developer.apple.com/documentation/coretransferable/transferable/exported(as:))
108+
function. By default, the testing library calls the value's [`exportedContentTypes(_:)`](https://developer.apple.com/documentation/coretransferable/transferable/exportedcontenttypes(_:))
109+
function and uses the first returned content type that conforms to [`UTType.data`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/data).
110+
If you want to use a different format for the attachment, you can pass another
111+
content type supported by the attachable value when you create the attachment.
112+
113+
```swift
114+
let attachment = try await Attachment(exporting: salesReport, as: .pdf)
115+
```
116+
79117
### Attach files and directories
80118

81119
If you have a file you want to save as an attachment, you can attach it using
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version: 1
2+
modules:
3+
- name: _Testing_CoreTransferable

Tests/TestingTests/AttachmentTests.swift

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import CoreGraphics
2626
import CoreImage
2727
import _Testing_CoreImage
2828
#endif
29+
#if canImport(CoreTransferable) && canImport(_Testing_CoreTransferable)
30+
import CoreTransferable
31+
@_spi(Experimental) import _Testing_CoreTransferable
32+
#endif
2933
#if canImport(UIKit) && canImport(_Testing_UIKit)
3034
import UIKit
3135
import _Testing_UIKit
@@ -258,7 +262,7 @@ struct AttachmentTests {
258262

259263
#expect((attachment.attachableValue as Any) is AnyAttachable.Wrapped)
260264
#expect(attachment.sourceLocation.fileID == #fileID)
261-
valueAttached()
265+
valueAttached()
262266
}
263267

264268
await Test {
@@ -486,6 +490,45 @@ struct AttachmentTests {
486490
}
487491
}
488492
#endif
493+
494+
#if canImport(CoreTransferable) && canImport(_Testing_CoreTransferable)
495+
@available(_transferableAPI, *)
496+
@Test("Attach Transferable-conformant value")
497+
func transferable() async throws {
498+
let value = MyTransferable()
499+
let attachment = try await Attachment(exporting: value, as: .plainText)
500+
#expect(value == attachment.attachableValue)
501+
try attachment.withUnsafeBytes { bytes in
502+
#expect(Array(bytes) == Array(MyTransferable.stringValue.utf8))
503+
}
504+
}
505+
506+
@available(_transferableAPI, *)
507+
@Test("Attach Transferable-conformant value with a nonsensical type")
508+
func transferableWithNonsensicalType() async throws {
509+
let value = MyTransferable()
510+
await #expect(throws: (any Error).self) {
511+
_ = try await Attachment(exporting: value, as: .gif)
512+
}
513+
}
514+
515+
@available(_transferableAPI, *)
516+
@Test("Preferred name of Transferable-conformant value")
517+
func transferablePreferredName() async throws {
518+
let value = MyTransferable()
519+
let attachment = try await Attachment(exporting: value)
520+
#expect(attachment.preferredName == "untitled.txt")
521+
}
522+
523+
@available(_transferableAPI, *)
524+
@Test("Attach Transferable-conformant value with no available type")
525+
func transferableWithNoAvailableType() async throws {
526+
let value = MyBadTransferable()
527+
await #expect(throws: (any Error).self) {
528+
_ = try await Attachment(exporting: value)
529+
}
530+
}
531+
#endif
489532
}
490533

491534
extension AttachmentTests {
@@ -1083,6 +1126,26 @@ struct MySendableAttachableWithDefaultByteCount: Attachable, Sendable {
10831126
}
10841127
}
10851128

1129+
#if canImport(CoreTransferable) && canImport(_Testing_CoreTransferable)
1130+
struct MyTransferable: Transferable, Equatable {
1131+
static let stringValue = "This isn't even my exported form!"
1132+
1133+
static var transferRepresentation: some TransferRepresentation {
1134+
DataRepresentation(exportedContentType: .plainText) { instance in
1135+
Data(Self.stringValue.utf8)
1136+
}
1137+
}
1138+
}
1139+
1140+
struct MyBadTransferable: Transferable, Equatable {
1141+
static var transferRepresentation: some TransferRepresentation {
1142+
DataRepresentation(exportedContentType: .directory) { _ in
1143+
throw MyError()
1144+
}
1145+
}
1146+
}
1147+
#endif
1148+
10861149
#if canImport(Foundation) && canImport(_Testing_Foundation)
10871150
struct MyCodableAttachable: Codable, Attachable, Sendable {
10881151
var string: String

cmake/modules/shared/AvailabilityDefinitions.cmake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ add_compile_options(
1313
"SHELL:$<$<COMPILE_LANGUAGE:Swift>:-Xfrontend -define-availability -Xfrontend \"_uttypesAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0\">"
1414
"SHELL:$<$<COMPILE_LANGUAGE:Swift>:-Xfrontend -define-availability -Xfrontend \"_clockAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0\">"
1515
"SHELL:$<$<COMPILE_LANGUAGE:Swift>:-Xfrontend -define-availability -Xfrontend \"_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0\">"
16+
"SHELL:$<$<COMPILE_LANGUAGE:Swift>:-Xfrontend -define-availability -Xfrontend \"_transferableAPI:macOS 15.2, iOS 18.2, watchOS 11.2, tvOS 18.2, visionOS 2.2\">"
1617
"SHELL:$<$<COMPILE_LANGUAGE:Swift>:-Xfrontend -define-availability -Xfrontend \"_castingWithNonCopyableGenerics:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0\">"
1718
"SHELL:$<$<COMPILE_LANGUAGE:Swift>:-Xfrontend -define-availability -Xfrontend \"_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0\">")

0 commit comments

Comments
 (0)