Skip to content

Commit deee757

Browse files
committed
Create a separate library containing a fallback event handler.
This PR adds an experimental helper library that contains a hook function called the "fallback event handler". When Swift Testing posts an event such as "issue recorded", but Swift Testing itself isn't the running testing library (i.e. XCTest is actually running), it calls the fallback event handler and passes the event (JSON-encoded) to it. Because the hook function is exported from a separate library, XCTest can (in theory, anyway) set the fallback event handler to a function that decodes the JSON-encoded event and translates it into the corresponding XCTest event. Swift Testing sets the fallback event handler itself when it starts running so that, conversely, if a testing library such as XCTest were to generate an event and determine it isn't running, it could post that event to the same event handler and have Swift Testing pick it up and translate it into a Swift Testing event. So that if you have code that calls `#expect()` from within XCTest, or if you have code that calls `XCTAssert()`` from within Swift Testing, it'll "just work™".
1 parent bf030fd commit deee757

File tree

12 files changed

+544
-35
lines changed

12 files changed

+544
-35
lines changed

Documentation/ABI/JSON.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ sufficient information to display the event in a human-readable format.
203203
}
204204
205205
<attachment> ::= {
206-
"path": <string>, ; the absolute path to the attachment on disk
206+
["path": <string>,] ; the absolute path to the attachment on disk if it has
207+
; been saved as a file
207208
}
208209
209210
<message> ::= {

Package.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ let package = Package(
105105
)
106106
)
107107

108+
result.append(
109+
.library(
110+
name: "_Testing_ExperimentalInfrastructure",
111+
type: .dynamic,
112+
targets: ["_Testing_ExperimentalInfrastructure"]
113+
)
114+
)
115+
108116
return result
109117
}(),
110118

@@ -118,6 +126,7 @@ let package = Package(
118126
dependencies: [
119127
"_TestDiscovery",
120128
"_TestingInternals",
129+
"_Testing_ExperimentalInfrastructure",
121130
"TestingMacros",
122131
],
123132
exclude: ["CMakeLists.txt", "Testing.swiftcrossimport"],
@@ -198,6 +207,13 @@ let package = Package(
198207
cxxSettings: .packageSettings,
199208
swiftSettings: .packageSettings + .enableLibraryEvolution()
200209
),
210+
.target(
211+
name: "_Testing_ExperimentalInfrastructure",
212+
dependencies: ["_TestingInternals",],
213+
exclude: ["CMakeLists.txt"],
214+
cxxSettings: .packageSettings,
215+
swiftSettings: .packageSettings + .enableLibraryEvolution()
216+
),
201217

202218
// Cross-import overlays (not supported by Swift Package Manager)
203219
.target(

Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,147 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11+
private import _TestingInternals
12+
13+
#if canImport(Foundation)
14+
private import Foundation
15+
#endif
16+
17+
/// The maximum size, in bytes, of an attachment that will be stored inline in
18+
/// an encoded attachment.
19+
private let _maximumInlineAttachmentByteCount: Int = {
20+
let pageSize: Int
21+
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
22+
pageSize = Int(clamping: sysconf(_SC_PAGESIZE))
23+
#elseif os(WASI)
24+
// sysconf(_SC_PAGESIZE) is a complex macro in wasi-libc.
25+
pageSize = Int(clamping: getpagesize())
26+
#elseif os(Windows)
27+
var systemInfo = SYSTEM_INFO()
28+
GetSystemInfo(&systemInfo)
29+
pageSize = Int(clamping: systemInfo.dwPageSize)
30+
#else
31+
#warning("Platform-specific implementation missing: page size unknown (assuming 4KB)")
32+
pageSize = 4 * 1024
33+
#endif
34+
35+
return pageSize // i.e. we'll store up to a page-sized attachment
36+
}()
37+
1138
extension ABI {
1239
/// A type implementing the JSON encoding of ``Attachment`` for the ABI entry
1340
/// point and event stream output.
1441
///
1542
/// This type is not part of the public interface of the testing library. It
1643
/// assists in converting values to JSON; clients that consume this JSON are
1744
/// expected to write their own decoders.
18-
///
19-
/// - Warning: Attachments are not yet part of the JSON schema.
2045
struct EncodedAttachment<V>: Sendable where V: ABI.Version {
2146
/// The path where the attachment was written.
2247
var path: String?
2348

49+
/// The preferred name of the attachment.
50+
///
51+
/// - Warning: Attachments' preferred names are not yet part of the JSON
52+
/// schema.
53+
var _preferredName: String?
54+
55+
/// The raw content of the attachment, if available.
56+
///
57+
/// If the value of this property is `nil`, the attachment can instead be
58+
/// read from ``path``.
59+
///
60+
/// - Warning: Inline attachment content is not yet part of the JSON schema.
61+
var _bytes: Bytes?
62+
2463
init(encoding attachment: borrowing Attachment<AnyAttachable>, in eventContext: borrowing Event.Context) {
2564
path = attachment.fileSystemPath
65+
_preferredName = attachment.preferredName
66+
67+
if let estimatedByteCount = attachment.attachableValue.estimatedAttachmentByteCount,
68+
estimatedByteCount <= _maximumInlineAttachmentByteCount {
69+
_bytes = try? attachment.withUnsafeBytes { bytes in
70+
if bytes.count > 0 && bytes.count < _maximumInlineAttachmentByteCount {
71+
return Bytes(rawValue: [UInt8](bytes))
72+
}
73+
return nil
74+
}
75+
}
76+
}
77+
78+
/// A structure representing the bytes of an attachment.
79+
struct Bytes: Sendable, RawRepresentable {
80+
var rawValue: [UInt8]
2681
}
2782
}
2883
}
2984

3085
// MARK: - Codable
3186

3287
extension ABI.EncodedAttachment: Codable {}
88+
89+
extension ABI.EncodedAttachment.Bytes: Codable {
90+
func encode(to encoder: any Encoder) throws {
91+
#if canImport(Foundation)
92+
// If possible, encode this structure as Base64 data.
93+
try rawValue.withUnsafeBytes { rawValue in
94+
let data = Data(bytesNoCopy: .init(mutating: rawValue.baseAddress!), count: rawValue.count, deallocator: .none)
95+
var container = encoder.singleValueContainer()
96+
try container.encode(data)
97+
}
98+
#else
99+
// Otherwise, it's an array of integers.
100+
var container = encoder.singleValueContainer()
101+
try container.encode(rawValue)
102+
#endif
103+
}
104+
105+
init(from decoder: any Decoder) throws {
106+
let container = try decoder.singleValueContainer()
107+
108+
#if canImport(Foundation)
109+
// If possible, decode a whole Foundation Data object.
110+
if let data = try? container.decode(Data.self) {
111+
self.init(rawValue: [UInt8](data))
112+
return
113+
}
114+
#endif
115+
116+
// Fall back to trying to decode an array of integers.
117+
let bytes = try container.decode([UInt8].self)
118+
self.init(rawValue: bytes)
119+
}
120+
}
121+
122+
// MARK: - Attachable
123+
124+
extension ABI.EncodedAttachment: Attachable {
125+
var estimatedAttachmentByteCount: Int? {
126+
_bytes?.rawValue.count
127+
}
128+
129+
fileprivate struct BytesUnavailableError: Error {}
130+
131+
borrowing func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
132+
if let bytes = _bytes?.rawValue {
133+
return try bytes.withUnsafeBytes(body)
134+
}
135+
136+
guard let path else {
137+
throw BytesUnavailableError()
138+
}
139+
let fileHandle = try FileHandle(forReadingAtPath: path)
140+
// TODO: map the attachment back into memory
141+
let bytes = try fileHandle.readToEnd()
142+
return try bytes.withUnsafeBytes(body)
143+
}
144+
145+
borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
146+
_preferredName ?? suggestedName
147+
}
148+
}
149+
150+
extension ABI.EncodedAttachment.BytesUnavailableError: CustomStringConvertible {
151+
var description: String {
152+
"The attachment's content could not be deserialized."
153+
}
154+
}

Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ extension ABI {
2727
/// The severity of this issue.
2828
///
2929
/// - Warning: Severity is not yet part of the JSON schema.
30-
var _severity: Severity
31-
30+
var _severity: Severity?
31+
3232
/// If the issue is a failing issue.
3333
///
3434
/// - Warning: Non-failing issues are not yet part of the JSON schema.
35-
var _isFailure: Bool
35+
var _isFailure: Bool?
3636

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

7373
extension ABI.EncodedIssue: Codable {}
7474
extension ABI.EncodedIssue.Severity: Codable {}
75+
76+
// MARK: - Converting back to an Issue
77+
78+
extension Issue {
79+
/// Attempt to reconstruct an instance of ``Issue`` from the given encoded
80+
/// event.
81+
///
82+
/// - Parameters:
83+
/// - event: The event that may contain an encoded issue.
84+
///
85+
/// If `event` does not represent an issue, this initializer returns `nil`.
86+
init?<V>(_ event: ABI.EncodedEvent<V>) {
87+
guard let issue = event.issue else {
88+
return nil
89+
}
90+
// Translate the issue back into a "real" issue and record it
91+
// in the parent process. This translation is, of course, lossy
92+
// due to the process boundary, but we make a best effort.
93+
let comments: [Comment] = event.messages.map(\.text).map(Comment.init(rawValue:))
94+
let issueKind: Issue.Kind = if let error = issue._error {
95+
.errorCaught(error)
96+
} else {
97+
// TODO: improve fidelity of issue kind reporting (especially those without associated values)
98+
.unconditional
99+
}
100+
let severity: Issue.Severity = switch issue._severity {
101+
case .warning:
102+
.warning
103+
case nil, .error:
104+
.error
105+
}
106+
let sourceContext = SourceContext(
107+
backtrace: nil, // `issue._backtrace` will have the wrong address space.
108+
sourceLocation: issue.sourceLocation
109+
)
110+
self.init(kind: issueKind, severity: severity, comments: comments, sourceContext: sourceContext)
111+
if issue.isKnown {
112+
// The known issue comment, if there was one, is already included in
113+
// the `comments` array above.
114+
knownIssueContext = Issue.KnownIssueContext()
115+
}
116+
}
117+
}

Sources/Testing/Events/Event.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11+
internal import _Testing_ExperimentalInfrastructure
12+
1113
/// An event that occurred during testing.
1214
@_spi(ForToolsIntegrationOnly)
1315
public struct Event: Sendable {
@@ -270,6 +272,60 @@ extension Event {
270272
}
271273
}
272274

275+
/// The implementation of ``fallbackEventHandler``.
276+
///
277+
/// - Parameters:
278+
/// - abi: The ABI version to use for decoding `recordJSON`.
279+
/// - recordJSON: The JSON encoding of an event record.
280+
///
281+
/// - Throws: Any error that prevented handling the encoded record.
282+
private static func _fallbackEventHandler<V>(_ abi: V.Type, _ recordJSON: UnsafeRawBufferPointer) throws where V: ABI.Version {
283+
let record = try JSON.decode(ABI.Record<ABI.CurrentVersion>.self, from: recordJSON)
284+
guard case let .event(event) = record.kind else {
285+
return
286+
}
287+
switch event.kind {
288+
case .issueRecorded:
289+
Issue(event)?.record()
290+
case .valueAttached:
291+
if let attachment = event.attachment {
292+
Attachment.record(attachment)
293+
}
294+
default:
295+
// Not handled here.
296+
break
297+
}
298+
}
299+
300+
/// The fallback event handler to set when Swift Testing is the active testing
301+
/// library.
302+
///
303+
/// ## See Also
304+
///
305+
/// - `swift_testing_getFallbackEventHandler()`
306+
/// - `swift_testing_setFallbackEventHandler()`
307+
static let fallbackEventHandler: FallbackEventHandler = { recordJSONSchemaVersionNumber, recordJSONBaseAddress, recordJSONByteCount, _ in
308+
let abi = String(validatingCString: recordJSONSchemaVersionNumber)
309+
.flatMap(VersionNumber.init)
310+
.flatMap(ABI.version(forVersionNumber:))
311+
if let abi {
312+
let recordJSON = UnsafeRawBufferPointer(start: recordJSONBaseAddress, count: recordJSONByteCount)
313+
try! Self._fallbackEventHandler(abi, recordJSON)
314+
}
315+
}
316+
317+
/// The implementation of ``installFallbackEventHandler()``.
318+
private static let _installFallbackHandler: Bool = {
319+
_Testing_ExperimentalInfrastructure.installFallbackEventHandler(Self.fallbackEventHandler)
320+
}()
321+
322+
/// Install the testing library's fallback event handler.
323+
///
324+
/// - Returns: Whether or not the handler was installed.
325+
static func installFallbackHandler() -> Bool {
326+
_installFallbackHandler
327+
}
328+
273329
/// Post this event to the currently-installed event handler.
274330
///
275331
/// - Parameters:
@@ -292,6 +348,19 @@ extension Event {
292348
if configuration.eventHandlingOptions.shouldHandleEvent(self) {
293349
configuration.handleEvent(self, in: context)
294350
}
351+
} else if let fallbackEventHandler = _Testing_ExperimentalInfrastructure.fallbackEventHandler(),
352+
castCFunction(fallbackEventHandler, to: UnsafeRawPointer.self) != castCFunction(Self.fallbackEventHandler, to: UnsafeRawPointer.self) {
353+
// Some library other than Swift Testing has set a fallback event handler.
354+
// Encode the event as JSON and call it.
355+
let fallbackEventHandler = ABI.CurrentVersion.eventHandler(encodeAsJSONLines: false) { recordJSON in
356+
fallbackEventHandler(
357+
String(describing: ABI.CurrentVersion.versionNumber),
358+
recordJSON.baseAddress!,
359+
recordJSON.count,
360+
nil
361+
)
362+
}
363+
fallbackEventHandler(self, context)
295364
} else {
296365
// The current task does NOT have an associated configuration. This event
297366
// will be lost! Post it to every registered event handler to avoid that.

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,35 +1034,8 @@ extension ExitTest {
10341034
/// - Throws: Any error encountered attempting to decode or process the JSON.
10351035
private static func _processRecord(_ recordJSON: UnsafeRawBufferPointer, fromBackChannel backChannel: borrowing FileHandle) throws {
10361036
let record = try JSON.decode(ABI.Record<ABI.BackChannelVersion>.self, from: recordJSON)
1037-
1038-
if case let .event(event) = record.kind, let issue = event.issue {
1039-
// Translate the issue back into a "real" issue and record it
1040-
// in the parent process. This translation is, of course, lossy
1041-
// due to the process boundary, but we make a best effort.
1042-
let comments: [Comment] = event.messages.map(\.text).map(Comment.init(rawValue:))
1043-
let issueKind: Issue.Kind = if let error = issue._error {
1044-
.errorCaught(error)
1045-
} else {
1046-
// TODO: improve fidelity of issue kind reporting (especially those without associated values)
1047-
.unconditional
1048-
}
1049-
let severity: Issue.Severity = switch issue._severity {
1050-
case .warning:
1051-
.warning
1052-
case .error:
1053-
.error
1054-
}
1055-
let sourceContext = SourceContext(
1056-
backtrace: nil, // `issue._backtrace` will have the wrong address space.
1057-
sourceLocation: issue.sourceLocation
1058-
)
1059-
var issueCopy = Issue(kind: issueKind, severity: severity, comments: comments, sourceContext: sourceContext)
1060-
if issue.isKnown {
1061-
// The known issue comment, if there was one, is already included in
1062-
// the `comments` array above.
1063-
issueCopy.knownIssueContext = Issue.KnownIssueContext()
1064-
}
1065-
issueCopy.record()
1037+
if case let .event(event) = record.kind, let issue = Issue(event) {
1038+
issue.record()
10661039
}
10671040
}
10681041

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ extension Runner.Plan {
208208
Backtrace.flushThrownErrorCache()
209209
}
210210

211+
// Ensure events generated by e.g. XCTAssert() are handled.
212+
_ = Event.installFallbackHandler()
213+
211214
// Convert the list of test into a graph of steps. The actions for these
212215
// steps will all be .run() *unless* an error was thrown while examining
213216
// them, in which case it will be .recordIssue().

0 commit comments

Comments
 (0)