From c16dd29a159a2acf99fe5c73efad58c8f31e7d0a Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 21 Aug 2025 12:10:07 -0400 Subject: [PATCH 1/2] Ensure attachments created in exit tests are forwarded to the parent. 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. --- .../ABI/Encoded/ABI.EncodedAttachment.swift | 118 +++++++++++++++++- Sources/Testing/ExitTests/ExitTest.swift | 13 +- Tests/TestingTests/ExitTestTests.swift | 27 ++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift index 7668f778a..8d30b6ada 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift @@ -8,6 +8,10 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +#if canImport(Foundation) +private import Foundation +#endif + extension ABI { /// A type implementing the JSON encoding of ``Attachment`` for the ABI entry /// point and event stream output. @@ -15,14 +19,50 @@ extension ABI { /// This type is not part of the public interface of the testing library. It /// assists in converting values to JSON; clients that consume this JSON are /// expected to write their own decoders. - /// - /// - Warning: Attachments are not yet part of the JSON schema. struct EncodedAttachment: Sendable where V: ABI.Version { /// The path where the attachment was written. var path: String? + /// The preferred name of the attachment. + /// + /// - Warning: Attachments' preferred names are not yet part of the JSON + /// schema. + var _preferredName: String? + + /// The raw content of the attachment, if available. + /// + /// The value of this property is set if the attachment was not first saved + /// to a file. It may also be `nil` if an error occurred while trying to get + /// the original attachment's serialized representation. + /// + /// - Warning: Inline attachment content is not yet part of the JSON schema. + var _bytes: Bytes? + + /// The source location where this attachment was created. + /// + /// - Warning: Attachment source locations are not yet part of the JSON + /// schema. + var _sourceLocation: SourceLocation? + init(encoding attachment: borrowing Attachment, in eventContext: borrowing Event.Context) { path = attachment.fileSystemPath + + if V.versionNumber >= ABI.v6_3.versionNumber { + _preferredName = attachment.preferredName + + if path == nil { + _bytes = try? attachment.withUnsafeBytes { bytes in + return Bytes(rawValue: [UInt8](bytes)) + } + } + + _sourceLocation = attachment.sourceLocation + } + } + + /// A structure representing the bytes of an attachment. + struct Bytes: Sendable, RawRepresentable { + var rawValue: [UInt8] } } } @@ -30,3 +70,77 @@ extension ABI { // MARK: - Codable extension ABI.EncodedAttachment: Codable {} + +extension ABI.EncodedAttachment.Bytes: Codable { + func encode(to encoder: any Encoder) throws { +#if canImport(Foundation) + // If possible, encode this structure as Base64 data. + try rawValue.withUnsafeBytes { rawValue in + let data = Data(bytesNoCopy: .init(mutating: rawValue.baseAddress!), count: rawValue.count, deallocator: .none) + var container = encoder.singleValueContainer() + try container.encode(data) + } +#else + // Otherwise, it's an array of integers. + var container = encoder.singleValueContainer() + try container.encode(rawValue) +#endif + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + +#if canImport(Foundation) + // If possible, decode a whole Foundation Data object. + if let data = try? container.decode(Data.self) { + self.init(rawValue: [UInt8](data)) + return + } +#endif + + // Fall back to trying to decode an array of integers. + let bytes = try container.decode([UInt8].self) + self.init(rawValue: bytes) + } +} + +// MARK: - Attachable + +extension ABI.EncodedAttachment: Attachable { + var estimatedAttachmentByteCount: Int? { + _bytes?.rawValue.count + } + + /// An error type that is thrown when ``ABI/EncodedAttachment`` cannot satisfy + /// a request for the underlying attachment's bytes. + fileprivate struct BytesUnavailableError: Error {} + + borrowing func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + if let bytes = _bytes?.rawValue { + return try bytes.withUnsafeBytes(body) + } + + guard let path else { + throw BytesUnavailableError() + } +#if canImport(Foundation) + // Leverage Foundation's file-mapping logic since we're using Data anyway. + let url = URL(fileURLWithPath: path, isDirectory: false) + let bytes = try Data(contentsOf: url, options: [.mappedIfSafe]) +#else + let fileHandle = try FileHandle(forReadingAtPath: path) + let bytes = try fileHandle.readToEnd() +#endif + return try bytes.withUnsafeBytes(body) + } + + borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { + _preferredName ?? suggestedName + } +} + +extension ABI.EncodedAttachment.BytesUnavailableError: CustomStringConvertible { + var description: String { + "The attachment's content could not be deserialized." + } +} diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index c1d6e9fe1..9f15c21bf 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -767,8 +767,12 @@ extension ExitTest { } } configuration.eventHandler = { event, eventContext in - if case .issueRecorded = event.kind { + switch event.kind { + case .issueRecorded, .valueAttached: eventHandler(event, eventContext) + default: + // Don't forward other kinds of event. + break } } @@ -1034,8 +1038,11 @@ extension ExitTest { /// - Throws: Any error encountered attempting to decode or process the JSON. private static func _processRecord(_ recordJSON: UnsafeRawBufferPointer, fromBackChannel backChannel: borrowing FileHandle) throws { let record = try JSON.decode(ABI.Record.self, from: recordJSON) + guard case let .event(event) = record.kind else { + return + } - if case let .event(event) = record.kind, let issue = event.issue { + if let issue = event.issue { // Translate the issue back into a "real" issue and record it // in the parent process. This translation is, of course, lossy // due to the process boundary, but we make a best effort. @@ -1063,6 +1070,8 @@ extension ExitTest { issueCopy.knownIssueContext = Issue.KnownIssueContext() } issueCopy.record() + } else if let attachment = event.attachment { + Attachment.record(attachment, sourceLocation: attachment._sourceLocation!) } } diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index d86489e13..e68e26c60 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -198,6 +198,33 @@ private import _TestingInternals } } + private static let attachmentPayload = [UInt8](0...255) + + @Test("Exit test forwards attachments") func forwardsAttachments() async { + await confirmation("Value attached") { valueAttached in + var configuration = Configuration() + configuration.eventHandler = { event, _ in + guard case let .valueAttached(attachment) = event.kind else { + return + } + #expect(throws: Never.self) { + try attachment.withUnsafeBytes { bytes in + #expect(Array(bytes) == Self.attachmentPayload) + } + } + #expect(attachment.preferredName == "my attachment.bytes") + valueAttached() + } + configuration.exitTestHandler = ExitTest.handlerForEntryPoint() + + await Test { + await #expect(processExitsWith: .success) { + Attachment.record(Self.attachmentPayload, named: "my attachment.bytes") + } + }.run(configuration: configuration) + } + } + #if !os(Linux) @Test("Exit test reports > 8 bits of the exit code") func fullWidthExitCode() async { From c2fde8ba5ef282479fd6586d27d0905a5b6e5cd9 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 21 Aug 2025 12:17:00 -0400 Subject: [PATCH 2/2] Add missing SWT_NO_FILE_IO check --- Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift index 8d30b6ada..9275da1ed 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift @@ -120,6 +120,7 @@ extension ABI.EncodedAttachment: Attachable { return try bytes.withUnsafeBytes(body) } +#if !SWT_NO_FILE_IO guard let path else { throw BytesUnavailableError() } @@ -132,6 +133,10 @@ extension ABI.EncodedAttachment: Attachable { let bytes = try fileHandle.readToEnd() #endif return try bytes.withUnsafeBytes(body) +#else + // Cannot read the attachment from disk on this platform. + throw BytesUnavailableError() +#endif } borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String {