Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion Documentation/ABI/JSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ sufficient information to display the event in a human-readable format.
}

<attachment> ::= {
"path": <string>, ; the absolute path to the attachment on disk
["path": <string>,] ; the absolute path to the attachment on disk if it has
Copy link
Contributor Author

@grynspan grynspan Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value is not set if an attachment wasn't saved to disk already, so it should be optional in the schema.

; been saved as a file
}

<message> ::= {
Expand Down
16 changes: 16 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ let package = Package(
)
)

result.append(
.library(
name: "_Testing_ExperimentalInfrastructure",
type: .dynamic,
targets: ["_Testing_ExperimentalInfrastructure"]
)
)

return result
}(),

Expand All @@ -118,6 +126,7 @@ let package = Package(
dependencies: [
"_TestDiscovery",
"_TestingInternals",
"_Testing_ExperimentalInfrastructure",
"TestingMacros",
],
exclude: ["CMakeLists.txt", "Testing.swiftcrossimport"],
Expand Down Expand Up @@ -198,6 +207,13 @@ let package = Package(
cxxSettings: .packageSettings,
swiftSettings: .packageSettings + .enableLibraryEvolution()
),
.target(
name: "_Testing_ExperimentalInfrastructure",
dependencies: ["_TestingInternals",],
exclude: ["CMakeLists.txt"],
cxxSettings: .packageSettings,
swiftSettings: .packageSettings + .enableLibraryEvolution()
),

// Cross-import overlays (not supported by Swift Package Manager)
.target(
Expand Down
126 changes: 124 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,147 @@
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

private import _TestingInternals

#if canImport(Foundation)
private import Foundation
#endif

/// The maximum size, in bytes, of an attachment that will be stored inline in
/// an encoded attachment.
private let _maximumInlineAttachmentByteCount: Int = {
let pageSize: Int
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
pageSize = Int(clamping: sysconf(_SC_PAGESIZE))
#elseif os(WASI)
// sysconf(_SC_PAGESIZE) is a complex macro in wasi-libc.
pageSize = Int(clamping: getpagesize())
#elseif os(Windows)
var systemInfo = SYSTEM_INFO()
GetSystemInfo(&systemInfo)
pageSize = Int(clamping: systemInfo.dwPageSize)
#else
#warning("Platform-specific implementation missing: page size unknown (assuming 4KB)")
pageSize = 4 * 1024
#endif

return pageSize // i.e. we'll store up to a page-sized attachment
}()

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.
///
/// If the value of this property is `nil`, the attachment can instead be
/// read from ``path``.
///
/// - Warning: Inline attachment content is not yet part of the JSON schema.
var _bytes: Bytes?

init(encoding attachment: borrowing Attachment<AnyAttachable>, in eventContext: borrowing Event.Context) {
path = attachment.fileSystemPath
_preferredName = attachment.preferredName

if let estimatedByteCount = attachment.attachableValue.estimatedAttachmentByteCount,
estimatedByteCount <= _maximumInlineAttachmentByteCount {
_bytes = try? attachment.withUnsafeBytes { bytes in
if bytes.count > 0 && bytes.count < _maximumInlineAttachmentByteCount {
return Bytes(rawValue: [UInt8](bytes))
}
return nil
}
}
}

/// 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
}

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)
}

guard let path else {
throw BytesUnavailableError()
}
let fileHandle = try FileHandle(forReadingAtPath: path)
// TODO: map the attachment back into memory
let bytes = try fileHandle.readToEnd()
return try bytes.withUnsafeBytes(body)
}

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."
}
}
49 changes: 46 additions & 3 deletions Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ extension ABI {
/// The severity of this issue.
///
/// - Warning: Severity is not yet part of the JSON schema.
var _severity: Severity
var _severity: Severity?

/// If the issue is a failing issue.
///
/// - Warning: Non-failing issues are not yet part of the JSON schema.
var _isFailure: Bool
var _isFailure: Bool?

/// Whether or not this issue is known to occur.
var isKnown: Bool
Expand Down Expand Up @@ -72,3 +72,46 @@ extension ABI {

extension ABI.EncodedIssue: Codable {}
extension ABI.EncodedIssue.Severity: Codable {}

// MARK: - Converting back to an Issue

extension Issue {
/// Attempt to reconstruct an instance of ``Issue`` from the given encoded
/// event.
///
/// - Parameters:
/// - event: The event that may contain an encoded issue.
///
/// If `event` does not represent an issue, this initializer returns `nil`.
init?<V>(_ event: ABI.EncodedEvent<V>) {
guard let issue = event.issue else {
return nil
}
// 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.
let comments: [Comment] = event.messages.map(\.text).map(Comment.init(rawValue:))
let issueKind: Issue.Kind = if let error = issue._error {
.errorCaught(error)
} else {
// TODO: improve fidelity of issue kind reporting (especially those without associated values)
.unconditional
}
let severity: Issue.Severity = switch issue._severity {
case .warning:
.warning
case nil, .error:
.error
}
let sourceContext = SourceContext(
backtrace: nil, // `issue._backtrace` will have the wrong address space.
sourceLocation: issue.sourceLocation
)
self.init(kind: issueKind, severity: severity, comments: comments, sourceContext: sourceContext)
if issue.isKnown {
// The known issue comment, if there was one, is already included in
// the `comments` array above.
knownIssueContext = Issue.KnownIssueContext()
}
}
}
69 changes: 69 additions & 0 deletions Sources/Testing/Events/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

internal import _Testing_ExperimentalInfrastructure

/// An event that occurred during testing.
@_spi(ForToolsIntegrationOnly)
public struct Event: Sendable {
Expand Down Expand Up @@ -270,6 +272,60 @@ extension Event {
}
}

/// The implementation of ``fallbackEventHandler``.
///
/// - Parameters:
/// - abi: The ABI version to use for decoding `recordJSON`.
/// - recordJSON: The JSON encoding of an event record.
///
/// - Throws: Any error that prevented handling the encoded record.
private static func _fallbackEventHandler<V>(_ abi: V.Type, _ recordJSON: UnsafeRawBufferPointer) throws where V: ABI.Version {
let record = try JSON.decode(ABI.Record<ABI.CurrentVersion>.self, from: recordJSON)
guard case let .event(event) = record.kind else {
return
}
switch event.kind {
case .issueRecorded:
Issue(event)?.record()
case .valueAttached:
if let attachment = event.attachment {
Attachment.record(attachment)
}
default:
// Not handled here.
break
}
}

/// The fallback event handler to set when Swift Testing is the active testing
/// library.
///
/// ## See Also
///
/// - `swift_testing_getFallbackEventHandler()`
/// - `swift_testing_setFallbackEventHandler()`
static let fallbackEventHandler: FallbackEventHandler = { recordJSONSchemaVersionNumber, recordJSONBaseAddress, recordJSONByteCount, _ in
let abi = String(validatingCString: recordJSONSchemaVersionNumber)
.flatMap(VersionNumber.init)
.flatMap(ABI.version(forVersionNumber:))
if let abi {
let recordJSON = UnsafeRawBufferPointer(start: recordJSONBaseAddress, count: recordJSONByteCount)
try! Self._fallbackEventHandler(abi, recordJSON)
}
}

/// The implementation of ``installFallbackEventHandler()``.
private static let _installFallbackHandler: Bool = {
_Testing_ExperimentalInfrastructure.installFallbackEventHandler(Self.fallbackEventHandler)
}()

/// Install the testing library's fallback event handler.
///
/// - Returns: Whether or not the handler was installed.
static func installFallbackHandler() -> Bool {
_installFallbackHandler
}

/// Post this event to the currently-installed event handler.
///
/// - Parameters:
Expand All @@ -292,6 +348,19 @@ extension Event {
if configuration.eventHandlingOptions.shouldHandleEvent(self) {
configuration.handleEvent(self, in: context)
}
} else if let fallbackEventHandler = _Testing_ExperimentalInfrastructure.fallbackEventHandler(),
castCFunction(fallbackEventHandler, to: UnsafeRawPointer.self) != castCFunction(Self.fallbackEventHandler, to: UnsafeRawPointer.self) {
// Some library other than Swift Testing has set a fallback event handler.
// Encode the event as JSON and call it.
let fallbackEventHandler = ABI.CurrentVersion.eventHandler(encodeAsJSONLines: false) { recordJSON in
fallbackEventHandler(
String(describing: ABI.CurrentVersion.versionNumber),
recordJSON.baseAddress!,
recordJSON.count,
nil
)
}
fallbackEventHandler(self, context)
} else {
// The current task does NOT have an associated configuration. This event
// will be lost! Post it to every registered event handler to avoid that.
Expand Down
31 changes: 2 additions & 29 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1034,35 +1034,8 @@ 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)

if case let .event(event) = record.kind, 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.
let comments: [Comment] = event.messages.map(\.text).map(Comment.init(rawValue:))
let issueKind: Issue.Kind = if let error = issue._error {
.errorCaught(error)
} else {
// TODO: improve fidelity of issue kind reporting (especially those without associated values)
.unconditional
}
let severity: Issue.Severity = switch issue._severity {
case .warning:
.warning
case .error:
.error
}
let sourceContext = SourceContext(
backtrace: nil, // `issue._backtrace` will have the wrong address space.
sourceLocation: issue.sourceLocation
)
var issueCopy = Issue(kind: issueKind, severity: severity, comments: comments, sourceContext: sourceContext)
if issue.isKnown {
// The known issue comment, if there was one, is already included in
// the `comments` array above.
issueCopy.knownIssueContext = Issue.KnownIssueContext()
}
issueCopy.record()
if case let .event(event) = record.kind, let issue = Issue(event) {
issue.record()
}
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/Testing/Running/Runner.Plan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ extension Runner.Plan {
Backtrace.flushThrownErrorCache()
}

// Ensure events generated by e.g. XCTAssert() are handled.
_ = Event.installFallbackHandler()

// Convert the list of test into a graph of steps. The actions for these
// steps will all be .run() *unless* an error was thrown while examining
// them, in which case it will be .recordIssue().
Expand Down
Loading