Skip to content
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
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
1 change: 1 addition & 0 deletions Sources/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ endif()
include(AvailabilityDefinitions)
include(CompilerSettings)
add_subdirectory(_TestDiscovery)
add_subdirectory(_TestingInfrastructure)
add_subdirectory(_TestingInternals)
add_subdirectory(Overlays)
add_subdirectory(Testing)
9 changes: 9 additions & 0 deletions Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ extension ABI {
/// - Warning: Errors are not yet part of the JSON schema.
var _error: EncodedError<V>?

/// The comment associated with the call to `withKnownIssue()` that
/// generated this issue.
///
/// - Warning: This field is not yet part of the JSON schema.
var _knownIssueComment: String?

init(encoding issue: borrowing Issue, in eventContext: borrowing Event.Context) {
// >= v0
isKnown = issue.isKnown
Expand All @@ -80,6 +86,9 @@ extension ABI {
if let error = issue.error {
_error = EncodedError(encoding: error, in: eventContext)
}
if let knownIssueContext = issue.knownIssueContext {
_knownIssueComment = knownIssueContext.comment?.rawValue
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ add_library(Testing
Attachments/Attachment.swift
Events/Clock.swift
Events/Event.swift
Events/Event+FallbackHandler.swift
Events/Recorder/Event.AdvancedConsoleOutputRecorder.swift
Events/Recorder/Event.ConsoleOutputRecorder.swift
Events/Recorder/Event.HumanReadableOutputRecorder.swift
Expand Down
132 changes: 132 additions & 0 deletions Sources/Testing/Events/Event+FallbackHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if canImport(_TestingInfrastructure)
private import _TestingInfrastructure
#endif

extension Event {
/// Attempt to handle an event encoded as JSON as if it had been generated in
/// the current testing context.
///
/// - Parameters:
/// - recordJSON: The JSON encoding of an event record.
/// - abi: The ABI version to use for decoding `recordJSON`.
///
/// - Throws: Any error that prevented handling the encoded record.
///
/// - Important: This function only handles a subset of event kinds.
static func handle<V>(_ recordJSON: UnsafeRawBufferPointer, encodedWith abi: V.Type) throws where V: ABI.Version {
let record = try JSON.decode(ABI.Record<V>.self, from: recordJSON)
guard case let .event(event) = record.kind else {
return
}

lazy var comments: [Comment] = event._comments?.map(Comment.init(rawValue:)) ?? []
lazy var sourceContext = SourceContext(
backtrace: nil, // A backtrace from the child process will have the wrong address space.
sourceLocation: event._sourceLocation
)
lazy var skipInfo = SkipInfo(comment: comments.first, sourceContext: sourceContext)
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 ABI
// and/or process boundary, but we make a best effort.
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
}
var issueCopy = Issue(kind: issueKind, severity: severity, comments: comments, sourceContext: sourceContext)
if issue.isKnown {
issueCopy.knownIssueContext = Issue.KnownIssueContext()
issueCopy.knownIssueContext?.comment = issue._knownIssueComment.map(Comment.init(rawValue:))
}
issueCopy.record()
} else if let attachment = event.attachment {
Attachment.record(attachment, sourceLocation: event._sourceLocation!)
} else if case .testCancelled = event.kind {
_ = try? Test.cancel(with: skipInfo)
} else if case .testCaseCancelled = event.kind {
_ = try? Test.Case.cancel(with: skipInfo)
}
}

#if canImport(_TestingInfrastructure)
/// The fallback event handler to set when Swift Testing is the active testing
/// library.
private 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.handle(recordJSON, encodedWith: abi)
}
}
#endif

/// The implementation of ``installFallbackEventHandler()``.
private static let _installFallbackHandler: Bool = {
#if canImport(_TestingInfrastructure)
_swift_testing_installFallbackEventHandler(Self._fallbackEventHandler)
#else
false
#endif
}()

/// 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 fallback event handler.
///
/// - Parameters:
/// - context: The context associated with this event.
///
/// - Returns: Whether or not the fallback event handler was invoked. If the
/// currently-installed handler belongs to the testing library, returns
/// `false`.
borrowing func postToFallbackHandler(in context: borrowing Context) -> Bool {
#if canImport(_TestingInfrastructure)
guard let fallbackEventHandler = _swift_testing_getFallbackEventHandler() else {
// No fallback event handler is installed.
return false
}
if castCFunction(fallbackEventHandler, to: UnsafeRawPointer.self) == castCFunction(Self._fallbackEventHandler, to: UnsafeRawPointer.self) {
// The fallback event handler belongs to Swift Testing, so we don't want
// to call it on our own behalf.
return false
}

// Encode the event as JSON and pass it to the handler.
let encodeAndInvoke = ABI.CurrentVersion.eventHandler(encodeAsJSONLines: false) { recordJSON in
fallbackEventHandler(
String(describing: ABI.CurrentVersion.versionNumber),
recordJSON.baseAddress!,
recordJSON.count,
nil
)
}
encodeAndInvoke(self, context)
return true
#else
return false
#endif
}
}
2 changes: 2 additions & 0 deletions Sources/Testing/Events/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ extension Event {
if configuration.eventHandlingOptions.shouldHandleEvent(self) {
configuration.handleEvent(self, in: context)
}
} else if postToFallbackHandler(in: context) {
// The fallback event handler handled this event.
} 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
43 changes: 1 addition & 42 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1031,48 +1031,7 @@ 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
}

lazy var comments: [Comment] = event._comments?.map(Comment.init(rawValue:)) ?? []
lazy var sourceContext = SourceContext(
backtrace: nil, // A backtrace from the child process will have the wrong address space.
sourceLocation: event._sourceLocation
)
lazy var skipInfo = SkipInfo(comment: comments.first, sourceContext: sourceContext)
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.
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, nil:
// Prior to 6.3, all Issues are errors
.error
}
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()
} else if let attachment = event.attachment {
Attachment.record(attachment, sourceLocation: event._sourceLocation!)
} else if case .testCancelled = event.kind {
_ = try? Test.cancel(with: skipInfo)
} else if case .testCaseCancelled = event.kind {
_ = try? Test.Case.cancel(with: skipInfo)
}
try Event.handle(recordJSON, encodedWith: ABI.BackChannelVersion.self)
}

/// Decode this exit test's captured values and update its ``capturedValues``
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 @@ -294,6 +294,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
3 changes: 3 additions & 0 deletions Sources/Testing/Running/Runner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,9 @@ extension Runner {
var runner = runner
runner.configureEventHandlerRuntimeState()

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

// Track whether or not any issues were recorded across the entire run.
let issueRecorded = Locked(rawValue: false)
runner.configuration.eventHandler = { [eventHandler = runner.configuration.eventHandler] event, context in
Expand Down
24 changes: 24 additions & 0 deletions Sources/_TestingInfrastructure/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# This source file is part of the Swift.org open source project
#
# Copyright (c) 2025 Apple Inc. and the Swift project authors
# Licensed under Apache License v2.0 with Runtime Library Exception
#
# See http://swift.org/LICENSE.txt for license information
# See http://swift.org/CONTRIBUTORS.txt for Swift project authors

add_library(_TestingInfrastructure
FallbackEventHandler.swift)

target_link_libraries(_TestingInfrastructure PRIVATE
_TestingInternals)
if(NOT BUILD_SHARED_LIBS)
# When building a static library, tell clients to autolink the internal
# libraries.
target_compile_options(Testing PRIVATE
"SHELL:-Xfrontend -public-autolink-library -Xfrontend _TestingInternals")
endif()
target_compile_options(_TestingInfrastructure PRIVATE
-enable-library-evolution
-emit-module-interface -emit-module-interface-path $<TARGET_PROPERTY:_TestingInfrastructure,Swift_MODULE_DIRECTORY>/_TestingInfrastructure.swiftinterface)

_swift_testing_install_target(_TestingInfrastructure)
104 changes: 104 additions & 0 deletions Sources/_TestingInfrastructure/FallbackEventHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK
private import _TestingInternals
#else
private import Synchronization
#endif

#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK
/// The installed event handler.
private nonisolated(unsafe) let _fallbackEventHandler = {
let result = ManagedBuffer<FallbackEventHandler?, os_unfair_lock>.create(
minimumCapacity: 1,
makingHeaderWith: { _ in nil }
)
result.withUnsafeMutablePointerToHeader { $0.initialize(to: nil) }
return result
}()
#else
/// The installed event handler.
private nonisolated(unsafe) let _fallbackEventHandler = Atomic<UnsafeRawPointer?>(nil)
#endif

/// A type describing a fallback event handler to invoke when testing API is
/// used while the testing library is not running.
///
/// - Parameters:
/// - recordJSONSchemaVersionNumber: The JSON schema version used to encode
/// the event record.
/// - recordJSONBaseAddress: A pointer to the first byte of the encoded event.
/// - recordJSONByteCount: The size of the encoded event in bytes.
/// - reserved: Reserved for future use.
@usableFromInline
package typealias FallbackEventHandler = @Sendable @convention(c) (
_ recordJSONSchemaVersionNumber: UnsafePointer<CChar>,
_ recordJSONBaseAddress: UnsafeRawPointer,
_ recordJSONByteCount: Int,
_ reserved: UnsafeRawPointer?
) -> Void

/// Get the current fallback event handler.
///
/// - Returns: The currently-set handler function, if any.
///
/// - Important: This operation is thread-safe, but is not atomic with respect
/// to calls to ``setFallbackEventHandler(_:)``. If you need to atomically
/// exchange the previous value with a new value, call
/// ``setFallbackEventHandler(_:)`` and store its returned value.
@_cdecl("_swift_testing_getFallbackEventHandler")
@usableFromInline
package func _swift_testing_getFallbackEventHandler() -> FallbackEventHandler? {
#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK
return _fallbackEventHandler.withUnsafeMutablePointers { fallbackEventHandler, lock in
os_unfair_lock_lock(lock)
defer {
os_unfair_lock_unlock(lock)
}
return fallbackEventHandler.pointee
}
#else
return _fallbackEventHandler.load(ordering: .sequentiallyConsistent).flatMap { fallbackEventHandler in
unsafeBitCast(fallbackEventHandler, to: FallbackEventHandler?.self)
}
#endif
}

/// Set the current fallback event handler if one has not already been set.
///
/// - Parameters:
/// - handler: The handler function to set.
///
/// - Returns: Whether or not `handler` was installed.
///
/// The fallback event handler can only be installed once per process, typically
/// by the first testing library to run. If this function has already been
/// called and the handler set, it does not replace the previous handler.
@_cdecl("_swift_testing_installFallbackEventHandler")
@usableFromInline
package func _swift_testing_installFallbackEventHandler(_ handler: FallbackEventHandler) -> CBool {
#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK
return _fallbackEventHandler.withUnsafeMutablePointers { fallbackEventHandler, lock in
os_unfair_lock_lock(lock)
defer {
os_unfair_lock_unlock(lock)
}
guard fallbackEventHandler.pointee == nil else {
return false
}
fallbackEventHandler.pointee = handler
return true
}
#else
let handler = unsafeBitCast(handler, to: UnsafeRawPointer.self)
return _fallbackEventHandler.compareExchange(expected: nil, desired: handler, ordering: .sequentiallyConsistent).exchanged
#endif
}
Loading
Loading