Skip to content

Commit 3f4f1df

Browse files
authored
Install fallback event handler before running tests (#1523)
Swift Testing now participates in both directions of interoperability. Currently, this is opt-in via an environment variable at runtime. This only implements the "complete" interop mode -- follow-up changes will add interop configurability. ### Motivation: Allows Swift Testing to handle foreign test events (e.g. XCTAssert). ### Modifications: Install fallback event handler before running tests. ### 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 db99f39 commit 3f4f1df

File tree

7 files changed

+399
-64
lines changed

7 files changed

+399
-64
lines changed

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ add_library(Testing
3636
Attachments/Attachment.swift
3737
Events/Clock.swift
3838
Events/Event.swift
39-
Events/Event+FallbackHandler.swift
39+
Events/Event+FallbackEventHandler.swift
4040
Events/Recorder/Event.AdvancedConsoleOutputRecorder.swift
4141
Events/Recorder/Event.ConsoleOutputRecorder.swift
4242
Events/Recorder/Event.HumanReadableOutputRecorder.swift
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
private import _TestingInternals
12+
13+
extension Issue {
14+
/// Attempt to create an `Issue` from a foreign `EncodedIssue`.
15+
///
16+
/// Typically, another testing library transforms its own test issue into the
17+
/// `EncodedEvent` format and then passes it through Swift Testing's installed
18+
/// fallback event handler to be converted into an `Issue`.
19+
///
20+
/// The fidelity of this conversion is limited by the fields present in
21+
/// `EncodedIssue`, in addition to how well the foreign test issue is
22+
/// represented by the schema.
23+
///
24+
/// - Parameter event: The `EncodedIssue` wrapped in an `EncodedEvent`.
25+
/// - Returns: `nil` if this is not an issueRecorded kind of event, or if the
26+
/// event doesn't include an `EncodedIssue`.
27+
init?<V>(event: ABI.EncodedEvent<V>) where V: ABI.Version {
28+
switch event.kind {
29+
case .issueRecorded:
30+
guard let issue = event.issue else { return nil }
31+
let issueKind: Issue.Kind =
32+
if let error = issue._error {
33+
.errorCaught(error)
34+
} else {
35+
// The encoded Issue doesn't include enough information to determine
36+
// the exact kind of issue, so a expectation and unconditional failure
37+
// have the same representation.
38+
.unconditional
39+
}
40+
41+
let severity: Issue.Severity =
42+
switch issue.severity {
43+
case .warning: .warning
44+
case nil, .error: .error
45+
}
46+
47+
let comments = {
48+
let returnedComments = event.messages.map { $0.text }.map(Comment.init(rawValue:))
49+
return if returnedComments.isEmpty {
50+
[Comment("Unknown issue")]
51+
} else {
52+
returnedComments
53+
}
54+
}()
55+
56+
let sourceContext = SourceContext(
57+
backtrace: nil, // Requires backtrace information from the EncodedIssue
58+
sourceLocation: event._sourceLocation.flatMap(SourceLocation.init)
59+
)
60+
61+
self.init(
62+
kind: issueKind, severity: severity, comments: comments, sourceContext: sourceContext)
63+
default:
64+
// The fallback handler does not support this event type
65+
return nil
66+
}
67+
}
68+
}
69+
70+
extension Event {
71+
/// Attempt to handle an event encoded as JSON as if it had been generated in
72+
/// the current testing context.
73+
///
74+
/// If the event contains an issue, handle it, but also record a warning issue
75+
/// notifying the user that interop was performed.
76+
///
77+
/// - Parameters:
78+
/// - recordJSON: The JSON encoding of an event record.
79+
/// - version: The ABI version to use for decoding `recordJSON`.
80+
///
81+
/// - Throws: Any error that prevented handling the encoded record.
82+
///
83+
/// - Important: This function only handles a subset of event kinds.
84+
static func handle<V>(_ recordJSON: UnsafeRawBufferPointer, encodedWith version: V.Type) throws
85+
where V: ABI.Version {
86+
let record = try JSON.decode(ABI.Record<V>.self, from: recordJSON)
87+
guard
88+
case .event(let event) = record.kind,
89+
let issue = Issue(event: event)
90+
else {
91+
return
92+
}
93+
94+
// For the time being, assume that foreign test events originate from XCTest
95+
let warnForXCTestUsageIssue = {
96+
let sourceContext = SourceContext(
97+
backtrace: issue.sourceContext.backtrace,
98+
sourceLocation: event._sourceLocation.flatMap(SourceLocation.init)
99+
)
100+
return Issue(
101+
kind: .apiMisused, severity: .warning,
102+
comments: [
103+
"XCTest API was used in a Swift Testing test. Adopt Swift Testing primitives, such as #expect, instead."
104+
], sourceContext: sourceContext)
105+
}()
106+
107+
issue.record()
108+
warnForXCTestUsageIssue.record()
109+
}
110+
111+
#if !SWT_NO_INTEROP
112+
/// The fallback event handler that was installed in the current test process.
113+
private static let _activeFallbackEventHandler: SWTFallbackEventHandler? = {
114+
_swift_testing_getFallbackEventHandler()
115+
}()
116+
117+
/// The fallback event handler to install when Swift Testing is the active
118+
/// testing library.
119+
private static let _ourFallbackEventHandler: SWTFallbackEventHandler = {
120+
recordJSONSchemaVersionNumber, recordJSONBaseAddress, recordJSONByteCount, _ in
121+
let version = String(validatingCString: recordJSONSchemaVersionNumber)
122+
.flatMap(VersionNumber.init)
123+
.flatMap { ABI.version(forVersionNumber: $0) }
124+
if let version {
125+
let recordJSON = UnsafeRawBufferPointer(
126+
start: recordJSONBaseAddress, count: recordJSONByteCount)
127+
do {
128+
try Self.handle(recordJSON, encodedWith: version)
129+
} catch {
130+
// Surface otherwise "unhandleable" records instead of dropping them silently
131+
let errorContext: Comment = """
132+
Another test library reported a test event that Swift Testing could not decode. Inspect the payload to determine if this was a test assertion failure.
133+
134+
Error:
135+
\(error)
136+
137+
Raw payload:
138+
\(recordJSON)
139+
"""
140+
Issue.record(errorContext)
141+
}
142+
}
143+
}
144+
#endif
145+
146+
/// The implementation of ``installFallbackEventHandler()``.
147+
private static let _installFallbackEventHandler: Bool = {
148+
#if !SWT_NO_INTEROP
149+
if Environment.flag(named: "SWT_EXPERIMENTAL_INTEROP_ENABLED") == true {
150+
return _swift_testing_installFallbackEventHandler(Self._ourFallbackEventHandler)
151+
}
152+
#endif
153+
return false
154+
}()
155+
156+
/// Installs the Swift Testing's fallback event handler, indicating that it is
157+
/// the active testing library. You can only try installing the handler once,
158+
/// so extra attempts will return the status from the first attempt.
159+
///
160+
/// The handler receives events created by other testing libraries and tries
161+
/// to emulate behaviour in Swift Testing where possible. For example, an
162+
/// `XCTAssert` failure reported by the XCTest API can be recorded as an
163+
/// `Issue` in Swift Testing.
164+
///
165+
/// - Returns: Whether the installation succeeded. The installation typically
166+
/// fails because the _TestingInterop library was not available at runtime or
167+
/// another testing library has already installed a fallback event handler.
168+
static func installFallbackEventHandler() -> Bool {
169+
_installFallbackEventHandler
170+
}
171+
172+
/// Post this event to the currently-installed fallback event handler.
173+
///
174+
/// - Parameters:
175+
/// - context: The context associated with this event.
176+
///
177+
/// - Returns: Whether or not the fallback event handler was invoked. If the
178+
/// currently-installed handler belongs to the testing library, returns
179+
/// `false`.
180+
borrowing func postToFallbackEventHandler(in context: borrowing Context) -> Bool {
181+
#if !SWT_NO_INTEROP
182+
guard let fallbackEventHandler = Self._activeFallbackEventHandler else {
183+
return false
184+
}
185+
186+
let isOurInstalledHandler =
187+
castCFunction(fallbackEventHandler, to: UnsafeRawPointer.self)
188+
== castCFunction(Self._ourFallbackEventHandler, to: UnsafeRawPointer.self)
189+
guard !isOurInstalledHandler else {
190+
// The fallback event handler belongs to Swift Testing, so we don't want
191+
// to call it on our own behalf.
192+
return false
193+
}
194+
195+
// Encode the event as JSON and pass it to the handler.
196+
let encodeAndInvoke = ABI.CurrentVersion.eventHandler(encodeAsJSONLines: false) { recordJSON in
197+
fallbackEventHandler(
198+
String(describing: ABI.CurrentVersion.versionNumber),
199+
recordJSON.baseAddress!,
200+
recordJSON.count,
201+
nil
202+
)
203+
}
204+
encodeAndInvoke(self, context)
205+
return true
206+
#else
207+
return false
208+
#endif
209+
}
210+
}

Sources/Testing/Events/Event+FallbackHandler.swift

Lines changed: 0 additions & 49 deletions
This file was deleted.

Sources/Testing/Events/Event.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ extension Event {
368368
if configurations.isEmpty {
369369
// There are no registered event handlers. Use the fallback event
370370
// handler instead.
371-
_ = postToFallbackHandler(in: context)
371+
_ = postToFallbackEventHandler(in: context)
372372
} else {
373373
for configuration in configurations {
374374
_post(in: context, configuration: configuration)

Sources/Testing/Running/Runner.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ extension Runner {
452452
#if !SWT_NO_FILE_IO
453453
runner.configureAttachmentHandling()
454454
#endif
455+
_ = Event.installFallbackEventHandler()
455456

456457
// Track whether or not any issues were recorded across the entire run.
457458
let issueRecorded = Atomic(false)

Sources/_TestingInternals/include/Stubs.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,18 @@ typedef void (* SWTFallbackEventHandler)(const char *recordJSONSchemaVersionNumb
292292
size_t recordJSONByteCount,
293293
const void *_Nullable reserved);
294294

295+
/// Set the current fallback event handler if one has not already been set.
296+
///
297+
/// - Parameters:
298+
/// - handler: The handler function to set.
299+
///
300+
/// - Returns: Whether or not `handler` was installed.
301+
///
302+
/// The fallback event handler can only be installed once per process, typically
303+
/// by the first testing library to run. If this function has already been
304+
/// called and the handler set, it does not replace the previous handler.
305+
SWT_EXTERN bool _swift_testing_installFallbackEventHandler(SWTFallbackEventHandler handler);
306+
295307
/// Get the current fallback event handler.
296308
/// Shadows the function with the same name in _TestingInterop.
297309
///

0 commit comments

Comments
 (0)