Skip to content

Commit a8a3fcb

Browse files
authored
Ensure attachments created in exit tests are forwarded to the parent. (#1282)
This PR ensures that when an exit test records an attachment, it is forwarded to the parent process. It introduces a local/private dependency on `Data` in `EncodedAttachment` so that we can use its base64 encoding/decoding and its ability to map files from disk. If Foundation is not available, it falls back to encoding/decoding `[UInt8]` and reading files into memory with `fread()` (the old-fashioned way). Resolves rdar://149242118. ### 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 7f0110d commit a8a3fcb

File tree

3 files changed

+159
-4
lines changed

3 files changed

+159
-4
lines changed

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

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,144 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11+
#if canImport(Foundation)
12+
private import Foundation
13+
#endif
14+
1115
extension ABI {
1216
/// A type implementing the JSON encoding of ``Attachment`` for the ABI entry
1317
/// point and event stream output.
1418
///
1519
/// This type is not part of the public interface of the testing library. It
1620
/// assists in converting values to JSON; clients that consume this JSON are
1721
/// expected to write their own decoders.
18-
///
19-
/// - Warning: Attachments are not yet part of the JSON schema.
2022
struct EncodedAttachment<V>: Sendable where V: ABI.Version {
2123
/// The path where the attachment was written.
2224
var path: String?
2325

26+
/// The preferred name of the attachment.
27+
///
28+
/// - Warning: Attachments' preferred names are not yet part of the JSON
29+
/// schema.
30+
var _preferredName: String?
31+
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?
40+
41+
/// The source location where this attachment was created.
42+
///
43+
/// - Warning: Attachment source locations are not yet part of the JSON
44+
/// schema.
45+
var _sourceLocation: SourceLocation?
46+
2447
init(encoding attachment: borrowing Attachment<AnyAttachable>, in eventContext: borrowing Event.Context) {
2548
path = attachment.fileSystemPath
49+
50+
if V.versionNumber >= ABI.v6_3.versionNumber {
51+
_preferredName = attachment.preferredName
52+
53+
if path == nil {
54+
_bytes = try? attachment.withUnsafeBytes { bytes in
55+
return Bytes(rawValue: [UInt8](bytes))
56+
}
57+
}
58+
59+
_sourceLocation = attachment.sourceLocation
60+
}
61+
}
62+
63+
/// A structure representing the bytes of an attachment.
64+
struct Bytes: Sendable, RawRepresentable {
65+
var rawValue: [UInt8]
2666
}
2767
}
2868
}
2969

3070
// MARK: - Codable
3171

3272
extension ABI.EncodedAttachment: Codable {}
73+
74+
extension ABI.EncodedAttachment.Bytes: Codable {
75+
func encode(to encoder: any Encoder) throws {
76+
#if canImport(Foundation)
77+
// If possible, encode this structure as Base64 data.
78+
try rawValue.withUnsafeBytes { rawValue in
79+
let data = Data(bytesNoCopy: .init(mutating: rawValue.baseAddress!), count: rawValue.count, deallocator: .none)
80+
var container = encoder.singleValueContainer()
81+
try container.encode(data)
82+
}
83+
#else
84+
// Otherwise, it's an array of integers.
85+
var container = encoder.singleValueContainer()
86+
try container.encode(rawValue)
87+
#endif
88+
}
89+
90+
init(from decoder: any Decoder) throws {
91+
let container = try decoder.singleValueContainer()
92+
93+
#if canImport(Foundation)
94+
// If possible, decode a whole Foundation Data object.
95+
if let data = try? container.decode(Data.self) {
96+
self.init(rawValue: [UInt8](data))
97+
return
98+
}
99+
#endif
100+
101+
// Fall back to trying to decode an array of integers.
102+
let bytes = try container.decode([UInt8].self)
103+
self.init(rawValue: bytes)
104+
}
105+
}
106+
107+
// MARK: - Attachable
108+
109+
extension ABI.EncodedAttachment: Attachable {
110+
var estimatedAttachmentByteCount: Int? {
111+
_bytes?.rawValue.count
112+
}
113+
114+
/// An error type that is thrown when ``ABI/EncodedAttachment`` cannot satisfy
115+
/// a request for the underlying attachment's bytes.
116+
fileprivate struct BytesUnavailableError: Error {}
117+
118+
borrowing func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
119+
if let bytes = _bytes?.rawValue {
120+
return try bytes.withUnsafeBytes(body)
121+
}
122+
123+
#if !SWT_NO_FILE_IO
124+
guard let path else {
125+
throw BytesUnavailableError()
126+
}
127+
#if canImport(Foundation)
128+
// Leverage Foundation's file-mapping logic since we're using Data anyway.
129+
let url = URL(fileURLWithPath: path, isDirectory: false)
130+
let bytes = try Data(contentsOf: url, options: [.mappedIfSafe])
131+
#else
132+
let fileHandle = try FileHandle(forReadingAtPath: path)
133+
let bytes = try fileHandle.readToEnd()
134+
#endif
135+
return try bytes.withUnsafeBytes(body)
136+
#else
137+
// Cannot read the attachment from disk on this platform.
138+
throw BytesUnavailableError()
139+
#endif
140+
}
141+
142+
borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
143+
_preferredName ?? suggestedName
144+
}
145+
}
146+
147+
extension ABI.EncodedAttachment.BytesUnavailableError: CustomStringConvertible {
148+
var description: String {
149+
"The attachment's content could not be deserialized."
150+
}
151+
}

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -767,8 +767,12 @@ extension ExitTest {
767767
}
768768
}
769769
configuration.eventHandler = { event, eventContext in
770-
if case .issueRecorded = event.kind {
770+
switch event.kind {
771+
case .issueRecorded, .valueAttached:
771772
eventHandler(event, eventContext)
773+
default:
774+
// Don't forward other kinds of event.
775+
break
772776
}
773777
}
774778

@@ -1034,8 +1038,11 @@ extension ExitTest {
10341038
/// - Throws: Any error encountered attempting to decode or process the JSON.
10351039
private static func _processRecord(_ recordJSON: UnsafeRawBufferPointer, fromBackChannel backChannel: borrowing FileHandle) throws {
10361040
let record = try JSON.decode(ABI.Record<ABI.BackChannelVersion>.self, from: recordJSON)
1041+
guard case let .event(event) = record.kind else {
1042+
return
1043+
}
10371044

1038-
if case let .event(event) = record.kind, let issue = event.issue {
1045+
if let issue = event.issue {
10391046
// Translate the issue back into a "real" issue and record it
10401047
// in the parent process. This translation is, of course, lossy
10411048
// due to the process boundary, but we make a best effort.
@@ -1064,6 +1071,8 @@ extension ExitTest {
10641071
issueCopy.knownIssueContext = Issue.KnownIssueContext()
10651072
}
10661073
issueCopy.record()
1074+
} else if let attachment = event.attachment {
1075+
Attachment.record(attachment, sourceLocation: attachment._sourceLocation!)
10671076
}
10681077
}
10691078

Tests/TestingTests/ExitTestTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,33 @@ private import _TestingInternals
198198
}
199199
}
200200

201+
private static let attachmentPayload = [UInt8](0...255)
202+
203+
@Test("Exit test forwards attachments") func forwardsAttachments() async {
204+
await confirmation("Value attached") { valueAttached in
205+
var configuration = Configuration()
206+
configuration.eventHandler = { event, _ in
207+
guard case let .valueAttached(attachment) = event.kind else {
208+
return
209+
}
210+
#expect(throws: Never.self) {
211+
try attachment.withUnsafeBytes { bytes in
212+
#expect(Array(bytes) == Self.attachmentPayload)
213+
}
214+
}
215+
#expect(attachment.preferredName == "my attachment.bytes")
216+
valueAttached()
217+
}
218+
configuration.exitTestHandler = ExitTest.handlerForEntryPoint()
219+
220+
await Test {
221+
await #expect(processExitsWith: .success) {
222+
Attachment.record(Self.attachmentPayload, named: "my attachment.bytes")
223+
}
224+
}.run(configuration: configuration)
225+
}
226+
}
227+
201228
#if !os(Linux)
202229
@Test("Exit test reports > 8 bits of the exit code")
203230
func fullWidthExitCode() async {

0 commit comments

Comments
 (0)