Skip to content

Commit b3ffbc2

Browse files
authored
Expose ABI.EncodedAttachment as tools SPI. (#1590)
Follow-up to #1587. Exposes `ABI.EncodedAttachment` as SPI. A value of this type is associated with the `valueAttached` event kind. The type already conforms to `Attachable` so it can be directly plumbed back into Swift Testing, and XCTest internally knows how to convert an `Attachable` value to an `XCTAttachment`. ### 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.
1 parent 22ffa1f commit b3ffbc2

10 files changed

Lines changed: 324 additions & 86 deletions

File tree

Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift

Lines changed: 206 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -16,122 +16,193 @@ extension ABI {
1616
/// A type implementing the JSON encoding of ``Attachment`` for the ABI entry
1717
/// point and event stream output.
1818
///
19-
/// This type is not part of the public interface of the testing library. It
20-
/// assists in converting values to JSON; clients that consume this JSON are
21-
/// expected to write their own decoders.
22-
struct EncodedAttachment<V>: Sendable where V: ABI.Version {
23-
/// The path where the attachment was written.
24-
var path: String?
19+
/// You can use this type and its conformance to [`Codable`](https://developer.apple.com/documentation/swift/codable),
20+
/// when integrating the testing library with development tools. It is not
21+
/// part of the testing library's public interface.
22+
public struct EncodedAttachment<V>: Sendable where V: ABI.Version {
23+
/// The different kinds of encoded attachment.
24+
fileprivate enum Kind: Sendable {
25+
/// The attachment has already been saved to disk and we have its local
26+
/// file system path.
27+
case savedAtPath(String)
28+
29+
/// The attachment is stored in memory and we have its serialized form.
30+
case inMemory([UInt8])
31+
32+
/// The attachment has not been saved nor serialized yet and we still have
33+
/// it as an attachable value.
34+
case abstract(Attachment<AnyAttachable>)
35+
36+
/// An error occurred when the attachment was encoded that prevented it
37+
/// from being properly serialized.
38+
case error(ABI.EncodedError<V>)
39+
}
40+
41+
/// The kind of encoded attachment.
42+
fileprivate var kind: Kind
2543

2644
/// The preferred name of the attachment.
2745
///
2846
/// - Warning: Attachments' preferred names are not yet part of the JSON
2947
/// schema.
3048
var _preferredName: String?
49+
}
50+
}
3151

32-
/// The raw content of the attachment, if available.
33-
///
34-
/// The value of this property is set if the attachment was not first saved
35-
/// to a file. It may also be `nil` if an error occurred while trying to get
36-
/// the original attachment's serialized representation.
37-
///
38-
/// - Warning: Inline attachment content is not yet part of the JSON schema.
39-
var _bytes: Bytes?
52+
// MARK: - Codable
4053

41-
init(encoding attachment: borrowing Attachment<AnyAttachable>, in eventContext: borrowing Event.Context) {
42-
path = attachment.fileSystemPath
54+
extension ABI.EncodedAttachment: Codable {
55+
private enum CodingKeys: String, CodingKey {
56+
case path
57+
case preferredName = "_preferredName"
58+
case bytes = "_bytes"
59+
case error = "_error"
60+
}
4361

44-
if V.includesExperimentalFields {
45-
_preferredName = attachment.preferredName
62+
public func encode(to encoder: any Encoder) throws {
63+
var container = encoder.container(keyedBy: CodingKeys.self)
4664

47-
if path == nil {
48-
_bytes = try? attachment.withUnsafeBytes { bytes in
49-
return Bytes(rawValue: [UInt8](bytes))
65+
func encodeBytes(_ bytes: UnsafeRawBufferPointer) throws {
66+
#if canImport(Foundation)
67+
// If possible, encode this structure as Base64 data.
68+
try bytes.withUnsafeBytes { bytes in
69+
let data = Data(bytesNoCopy: .init(mutating: bytes.baseAddress!), count: bytes.count, deallocator: .none)
70+
try container.encode(data.base64EncodedString(), forKey: .bytes)
71+
}
72+
#else
73+
// Otherwise, it's an array of integers.
74+
try container.encode(bytes, forKey: .bytes)
75+
#endif
76+
}
77+
78+
switch kind {
79+
case let .savedAtPath(path):
80+
try container.encode(path, forKey: .path)
81+
case let .abstract(attachment):
82+
if V.includesExperimentalFields {
83+
var errorWhileEncoding: (any Error)?
84+
do {
85+
try attachment.withUnsafeBytes { bytes in
86+
do {
87+
try encodeBytes(bytes)
88+
} catch {
89+
// An error occurred during encoding rather than coming from the
90+
// attachment itself. Preserve it and throw it before returning.
91+
errorWhileEncoding = error
92+
}
5093
}
94+
} catch {
95+
// An error occurred while serializing the attachment. Encode it
96+
// separately for recovery on the calling side.
97+
let error = ABI.EncodedError<V>(encoding: error)
98+
try container.encode(error, forKey: .error)
5199
}
100+
if let errorWhileEncoding {
101+
throw errorWhileEncoding
102+
}
103+
}
104+
case let .inMemory(bytes):
105+
if V.includesExperimentalFields {
106+
try bytes.withUnsafeBytes(encodeBytes)
107+
}
108+
case let .error(error):
109+
if V.includesExperimentalFields {
110+
try container.encode(error, forKey: .error)
52111
}
53112
}
54-
55-
/// A structure representing the bytes of an attachment.
56-
struct Bytes: Sendable, RawRepresentable {
57-
var rawValue: [UInt8]
113+
if V.includesExperimentalFields {
114+
try container.encodeIfPresent(_preferredName, forKey: .preferredName)
58115
}
59116
}
60-
}
61117

62-
// MARK: - Codable
118+
public init(from decoder: any Decoder) throws {
119+
let container = try decoder.container(keyedBy: CodingKeys.self)
63120

64-
extension ABI.EncodedAttachment: Codable {}
121+
kind = try {
122+
if let path = try container.decodeIfPresent(String.self, forKey: .path) {
123+
return .savedAtPath(path)
124+
}
65125

66-
extension ABI.EncodedAttachment.Bytes: Codable {
67-
func encode(to encoder: any Encoder) throws {
126+
if V.includesExperimentalFields {
68127
#if canImport(Foundation)
69-
// If possible, encode this structure as Base64 data.
70-
try rawValue.withUnsafeBytes { rawValue in
71-
let data = Data(bytesNoCopy: .init(mutating: rawValue.baseAddress!), count: rawValue.count, deallocator: .none)
72-
var container = encoder.singleValueContainer()
73-
try container.encode(data)
74-
}
75-
#else
76-
// Otherwise, it's an array of integers.
77-
var container = encoder.singleValueContainer()
78-
try container.encode(rawValue)
128+
// If possible, decode a whole Foundation Data object.
129+
if let data = try? container.decodeIfPresent(Data.self, forKey: .bytes) {
130+
return .inMemory([UInt8](data))
131+
}
79132
#endif
80-
}
81133

82-
init(from decoder: any Decoder) throws {
83-
let container = try decoder.singleValueContainer()
134+
// Fall back to trying to decode an array of integers.
135+
if let bytes = try container.decodeIfPresent([UInt8].self, forKey: .bytes) {
136+
return .inMemory(bytes)
137+
}
84138

85-
#if canImport(Foundation)
86-
// If possible, decode a whole Foundation Data object.
87-
if let data = try? container.decode(Data.self) {
88-
self.init(rawValue: [UInt8](data))
89-
return
90-
}
91-
#endif
139+
// Finally, look for an error caught during encoding.
140+
if let error = try container.decodeIfPresent(ABI.EncodedError<V>.self, forKey: .error) {
141+
return .error(error)
142+
}
143+
}
144+
145+
// Couldn't find anything to decode.
146+
throw DecodingError.valueNotFound(
147+
String.self,
148+
DecodingError.Context(
149+
codingPath: decoder.codingPath + [CodingKeys.path],
150+
debugDescription: "Encoded attachment did not include any persistent representation."
151+
)
152+
)
153+
}()
92154

93-
// Fall back to trying to decode an array of integers.
94-
let bytes = try container.decode([UInt8].self)
95-
self.init(rawValue: bytes)
155+
if V.includesExperimentalFields {
156+
_preferredName = try container.decodeIfPresent(String.self, forKey: .preferredName)
157+
}
96158
}
97159
}
98160

99161
// MARK: - Attachable
100162

101163
extension ABI.EncodedAttachment: Attachable {
102-
var estimatedAttachmentByteCount: Int? {
103-
_bytes?.rawValue.count
164+
public var estimatedAttachmentByteCount: Int? {
165+
switch kind {
166+
case .savedAtPath, .error:
167+
return nil
168+
case let .inMemory(bytes):
169+
return bytes.count
170+
case let .abstract(attachment):
171+
return attachment.attachableValue.estimatedAttachmentByteCount
172+
}
104173
}
105174

106175
/// An error type that is thrown when ``ABI/EncodedAttachment`` cannot satisfy
107176
/// a request for the underlying attachment's bytes.
108177
fileprivate struct BytesUnavailableError: Error {}
109178

110-
borrowing func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
111-
if let bytes = _bytes?.rawValue {
112-
return try bytes.withUnsafeBytes(body)
113-
}
114-
179+
public borrowing func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
180+
switch kind {
181+
case let .savedAtPath(path):
115182
#if !SWT_NO_FILE_IO
116-
guard let path else {
117-
throw BytesUnavailableError()
118-
}
119183
#if canImport(Foundation)
120-
// Leverage Foundation's file-mapping logic since we're using Data anyway.
121-
let url = URL(fileURLWithPath: path, isDirectory: false)
122-
let bytes = try Data(contentsOf: url, options: [.mappedIfSafe])
184+
// Leverage Foundation's file-mapping logic since we're using Data anyway.
185+
let url = URL(fileURLWithPath: path, isDirectory: false)
186+
let bytes = try Data(contentsOf: url, options: [.mappedIfSafe])
123187
#else
124-
let fileHandle = try FileHandle(forReadingAtPath: path)
125-
let bytes = try fileHandle.readToEnd()
188+
let fileHandle = try FileHandle(forReadingAtPath: path)
189+
let bytes = try fileHandle.readToEnd()
126190
#endif
127-
return try bytes.withUnsafeBytes(body)
191+
return try bytes.withUnsafeBytes(body)
128192
#else
129-
// Cannot read the attachment from disk on this platform.
130-
throw BytesUnavailableError()
193+
// Cannot read the attachment from disk on this platform.
194+
throw BytesUnavailableError()
131195
#endif
196+
case let .inMemory(bytes):
197+
return try bytes.withUnsafeBytes(body)
198+
case let .abstract(attachment):
199+
return try attachment.withUnsafeBytes(body)
200+
case let .error(error):
201+
throw error
202+
}
132203
}
133204

134-
borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
205+
public borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
135206
_preferredName ?? suggestedName
136207
}
137208
}
@@ -141,3 +212,66 @@ extension ABI.EncodedAttachment.BytesUnavailableError: CustomStringConvertible {
141212
"The attachment's content could not be deserialized."
142213
}
143214
}
215+
216+
// MARK: - Conversion to/from library types
217+
218+
extension ABI.EncodedAttachment {
219+
/// Initialize an instance of this type from the given value.
220+
///
221+
/// - Parameters:
222+
/// - attachment: The attachment to initialize this instance from.
223+
public init(encoding attachment: borrowing Attachment<AnyAttachable>) {
224+
if let path = attachment.fileSystemPath {
225+
kind = .savedAtPath(path)
226+
} else {
227+
kind = .abstract(copy attachment)
228+
}
229+
230+
if V.includesExperimentalFields {
231+
_preferredName = attachment.preferredName
232+
}
233+
}
234+
235+
/// Initialize an instance of this type from the given value.
236+
///
237+
/// - Parameters:
238+
/// - attachment: The attachment to initialize this instance from.
239+
public init(encoding attachment: borrowing Attachment<some Attachable & Sendable & ~Copyable>) {
240+
let attachmentCopy = Attachment<AnyAttachable>(copy attachment)
241+
self.init(encoding: attachmentCopy)
242+
}
243+
}
244+
245+
@_spi(ForToolsIntegrationOnly)
246+
extension Attachment where AttachableValue == AnyAttachable {
247+
/// Initialize an instance of this type from the given value.
248+
///
249+
/// - Parameters:
250+
/// - event: The encoded event to initialize this instance from.
251+
///
252+
/// If `event` does not represent an attached value, the initializer returns
253+
/// `nil`.
254+
public init?<V>(decoding event: ABI.EncodedEvent<V>) {
255+
guard let attachment = event.attachment else {
256+
return nil
257+
}
258+
self.init(decoding: attachment)
259+
if let sourceLocation = event._sourceLocation.flatMap(SourceLocation.init(decoding:)) {
260+
self.sourceLocation = sourceLocation
261+
}
262+
}
263+
264+
/// Initialize an instance of this type from the given value.
265+
///
266+
/// - Parameters:
267+
/// - attachment: The encoded attachment to initialize this instance from.
268+
public init?<V>(decoding attachment: ABI.EncodedAttachment<V>) {
269+
switch attachment.kind {
270+
case let .abstract(attachment):
271+
self = attachment // No need to nest it further.
272+
default:
273+
let attachmentCopy = Attachment<ABI.EncodedAttachment<V>>(attachment, sourceLocation: .unknown)
274+
self.init(attachmentCopy)
275+
}
276+
}
277+
}

Sources/Testing/ABI/Encoded/ABI.EncodedError.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ extension ABI {
2929

3030
// TODO: userInfo (partial) encoding
3131

32-
init(encoding error: some Error, in eventContext: borrowing Event.Context) {
32+
init(encoding error: some Error) {
3333
description = String(describingForTest: error)
3434
domain = error._domain
3535
code = error._code

Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ extension ABI {
5252
///
5353
/// The value of this property is `nil` unless the value of the
5454
/// ``kind-swift.property`` property is ``Kind-swift.enum/valueAttached``.
55+
///
56+
/// To get an instance of ``Attachment`` from an instance of
57+
/// ``ABI/EncodedEvent`` of kind ``Kind-swift.enum/valueAttached``, pass the
58+
/// encoded event to ``Attachment/init(decoding:)-(ABI.EncodedEvent<V>)``.
5559
var attachment: EncodedAttachment<V>?
5660

5761
/// Human-readable messages associated with this event that can be presented
@@ -122,7 +126,7 @@ extension ABI {
122126
issue = EncodedIssue(encoding: recordedIssue, in: eventContext)
123127
case let .valueAttached(attachment):
124128
kind = .valueAttached
125-
self.attachment = EncodedAttachment(encoding: attachment, in: eventContext)
129+
self.attachment = EncodedAttachment(encoding: attachment)
126130
case .testCaseEnded:
127131
if eventContext.test?.isParameterized == false {
128132
return nil

Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ extension ABI {
8585
_backtrace = EncodedBacktrace(encoding: backtrace, in: eventContext)
8686
}
8787
if let error = issue.error {
88-
_error = EncodedError(encoding: error, in: eventContext)
88+
_error = EncodedError(encoding: error)
8989
}
9090
if case let .expectationFailed(expectation) = issue.kind {
9191
_expectation = EncodedExpectation(encoding: expectation, in: eventContext)

Sources/Testing/ABI/Encoded/ABI.EncodedSourceLocation.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ extension ABI.EncodedSourceLocation: Codable {
6666

6767
// MARK: - Conversion to/from library types
6868

69-
@_spi(ForToolsIntegrationOnly)
7069
extension ABI.EncodedSourceLocation {
7170
/// Initialize an instance of this type from the given value.
7271
///

0 commit comments

Comments
 (0)