Skip to content

Ensure attachments created in exit tests are forwarded to the parent. #1282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,144 @@
// 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.
///
/// 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<V>: 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<AnyAttachable>, 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]
}
}
}

// 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<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
if let bytes = _bytes?.rawValue {
return try bytes.withUnsafeBytes(body)
}

#if !SWT_NO_FILE_IO
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)
#else
// Cannot read the attachment from disk on this platform.
throw BytesUnavailableError()
#endif
}

borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
_preferredName ?? suggestedName
}
}

extension ABI.EncodedAttachment.BytesUnavailableError: CustomStringConvertible {
var description: String {
"The attachment's content could not be deserialized."
}
}
13 changes: 11 additions & 2 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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<ABI.BackChannelVersion>.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.
Expand Down Expand Up @@ -1063,6 +1070,8 @@ extension ExitTest {
issueCopy.knownIssueContext = Issue.KnownIssueContext()
}
issueCopy.record()
} else if let attachment = event.attachment {
Attachment.record(attachment, sourceLocation: attachment._sourceLocation!)
}
}

Expand Down
27 changes: 27 additions & 0 deletions Tests/TestingTests/ExitTestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down