Skip to content

Commit 4e4885d

Browse files
authored
[Experimental] Capturing values in exit tests (#1040)
This PR implements an experimental form of state capture in exit tests. If you specify a capture list on the test's body closure and explicitly write the type of each captured value, _and_ each value conforms to `Sendable`, `Codable`, and (implicitly) `Copyable`, we'll encode them and send them "over the wire" to the child process: ```swift let a = Int.random(in: 100 ..< 200) await #expect(exitsWith: .failure) { [a = a as Int] in assert(a > 500) } ``` This PR is incomplete. Among other details: - [x] Need to properly transmit the data, not stuff it in an environment variable - [x] Need to implement diagnostics correctly - [x] Need to figure out if `ExitTest.CapturedValue` and `__Expression.Value` have any synergy. _(They do, but it's beyond the scope of this initial/experimental PR.)_ We are ultimately constrained by the language here as we don't have real type information for the captured values, nor can we infer captures by inspecting the syntax of the exit test body (hence the need for an explicit capture list with types.) If we had something like `decltype()` we could apply during macro expansion, you wouldn't need to write `x = x as T` and could just write `x`. The macro would use `decltype()` to produce a thunk function of the form: ```swift @sendable func __compiler_generated_name__(aʹ: decltype(a), bʹ: decltype(b), cʹ: decltype(c)) async throws { let (a, b, c) = (aʹ, bʹ, cʹ) try await { /* ... */ }() } ``` ### 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 7097c2b commit 4e4885d

17 files changed

+833
-144
lines changed

Package.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
// swift-tools-version: 6.0
1+
// swift-tools-version: 6.1
22

33
//
44
// This source file is part of the Swift.org open source project
55
//
6-
// Copyright (c) 2023 Apple Inc. and the Swift project authors
6+
// Copyright (c) 2023–2025 Apple Inc. and the Swift project authors
77
// Licensed under Apache License v2.0 with Runtime Library Exception
88
//
99
// See https://swift.org/LICENSE.txt for license information
@@ -95,6 +95,13 @@ let package = Package(
9595
return result
9696
}(),
9797

98+
traits: [
99+
.trait(
100+
name: "ExperimentalExitTestValueCapture",
101+
description: "Enable experimental support for capturing values in exit tests"
102+
),
103+
],
104+
98105
dependencies: [
99106
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest"),
100107
],
@@ -285,6 +292,14 @@ extension Array where Element == PackageDescription.SwiftSetting {
285292
.define("SWT_NO_LIBDISPATCH", .whenEmbedded()),
286293
]
287294

295+
// Unconditionally enable 'ExperimentalExitTestValueCapture' when building
296+
// for development.
297+
if buildingForDevelopment {
298+
result += [
299+
.define("ExperimentalExitTestValueCapture")
300+
]
301+
}
302+
288303
return result
289304
}
290305

Sources/Testing/ABI/ABI.Record+Streaming.swift

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,47 +12,14 @@
1212
private import Foundation
1313

1414
extension ABI.Version {
15-
/// Post-process encoded JSON and write it to a file.
16-
///
17-
/// - Parameters:
18-
/// - json: The JSON to write.
19-
/// - file: The file to write to.
20-
///
21-
/// - Throws: Whatever is thrown when writing to `file`.
22-
private static func _asJSONLine(_ json: UnsafeRawBufferPointer, _ eventHandler: (_ recordJSON: UnsafeRawBufferPointer) throws -> Void) rethrows {
23-
// We don't actually expect the JSON encoder to produce output containing
24-
// newline characters, so in debug builds we'll log a diagnostic message.
25-
if _slowPath(json.contains(where: \.isASCIINewline)) {
26-
#if DEBUG && !SWT_NO_FILE_IO
27-
let message = Event.ConsoleOutputRecorder.warning(
28-
"JSON encoder produced one or more newline characters while encoding an event to JSON. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new",
29-
options: .for(.stderr)
30-
)
31-
#if SWT_TARGET_OS_APPLE
32-
try? FileHandle.stderr.write(message)
33-
#else
34-
print(message)
35-
#endif
36-
#endif
37-
38-
// Remove the newline characters to conform to JSON lines specification.
39-
var json = Array(json)
40-
json.removeAll(where: \.isASCIINewline)
41-
try json.withUnsafeBytes(eventHandler)
42-
} else {
43-
// No newlines found, no need to copy the buffer.
44-
try eventHandler(json)
45-
}
46-
}
47-
4815
static func eventHandler(
4916
encodeAsJSONLines: Bool,
5017
forwardingTo eventHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void
5118
) -> Event.Handler {
5219
// Encode as JSON Lines if requested.
5320
var eventHandlerCopy = eventHandler
5421
if encodeAsJSONLines {
55-
eventHandlerCopy = { @Sendable in _asJSONLine($0, eventHandler) }
22+
eventHandlerCopy = { @Sendable in JSON.asJSONLine($0, eventHandler) }
5623
}
5724

5825
let humanReadableOutputRecorder = Event.HumanReadableOutputRecorder()

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ add_library(Testing
3232
Events/Recorder/Event.Symbol.swift
3333
Events/TimeValue.swift
3434
ExitTests/ExitTest.swift
35+
ExitTests/ExitTest.CapturedValue.swift
3536
ExitTests/ExitTest.Condition.swift
3637
ExitTests/ExitTest.Result.swift
3738
ExitTests/SpawnProcess.swift
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2023–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+
#if !SWT_NO_EXIT_TESTS
12+
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
13+
extension ExitTest {
14+
/// A type representing a value captured by an exit test's body.
15+
///
16+
/// An instance of this type may represent the actual value that was captured
17+
/// when the exit test was invoked. In the child process created by the
18+
/// current exit test handler, instances will initially only have the type of
19+
/// the value, but not the value itself.
20+
///
21+
/// Instances of this type are created automatically by the testing library
22+
/// for all elements in an exit test body's capture list and are stored in the
23+
/// exit test's ``capturedValues`` property. For example, given the following
24+
/// exit test:
25+
///
26+
/// ```swift
27+
/// await #expect(exitsWith: .failure) { [a = a as T, b = b as U, c = c as V] in
28+
/// ...
29+
/// }
30+
/// ```
31+
///
32+
/// There are three captured values in its ``capturedValues`` property. These
33+
/// values are captured at the time the exit test is called, as they would be
34+
/// if the closure were called locally.
35+
///
36+
/// The current exit test handler is responsible for encoding and decoding
37+
/// instances of this type. When the handler is called, it is passed an
38+
/// instance of ``ExitTest``. The handler encodes the values in that
39+
/// instance's ``capturedValues`` property, then passes the encoded forms of
40+
/// those values to the child process. The encoding format and message-passing
41+
/// interface are implementation details of the exit test handler.
42+
///
43+
/// When the child process calls ``ExitTest/find(identifiedBy:)``, it receives
44+
/// an instance of ``ExitTest`` whose ``capturedValues`` property contains
45+
/// type information but no values. The child process decodes the values it
46+
/// encoded in the parent process and then updates the ``wrappedValue``
47+
/// property of each element in the array before calling the exit test's body.
48+
public struct CapturedValue: Sendable {
49+
/// An enumeration of the different states a captured value can have.
50+
private enum _Kind: Sendable {
51+
/// The runtime value of the captured value is known.
52+
case wrappedValue(any Codable & Sendable)
53+
54+
/// Only the type of the captured value is known.
55+
case typeOnly(any (Codable & Sendable).Type)
56+
}
57+
58+
/// The current state of this instance.
59+
private var _kind: _Kind
60+
61+
init(wrappedValue: some Codable & Sendable) {
62+
_kind = .wrappedValue(wrappedValue)
63+
}
64+
65+
init(typeOnly type: (some Codable & Sendable).Type) {
66+
_kind = .typeOnly(type)
67+
}
68+
69+
/// The underlying value captured by this instance at runtime.
70+
///
71+
/// In a child process created by the current exit test handler, the value
72+
/// of this property is `nil` until the entry point sets it.
73+
public var wrappedValue: (any Codable & Sendable)? {
74+
get {
75+
if case let .wrappedValue(wrappedValue) = _kind {
76+
return wrappedValue
77+
}
78+
return nil
79+
}
80+
81+
set {
82+
let type = typeOfWrappedValue
83+
84+
func validate<T, U>(_ newValue: T, is expectedType: U.Type) {
85+
assert(newValue is U, "Attempted to set a captured value to an instance of '\(String(describingForTest: T.self))', but an instance of '\(String(describingForTest: U.self))' was expected.")
86+
}
87+
validate(newValue, is: type)
88+
89+
if let newValue {
90+
_kind = .wrappedValue(newValue)
91+
} else {
92+
_kind = .typeOnly(type)
93+
}
94+
}
95+
}
96+
97+
/// The type of the underlying value captured by this instance.
98+
///
99+
/// This type is known at compile time and is always available, even before
100+
/// this instance's ``wrappedValue`` property is set.
101+
public var typeOfWrappedValue: any (Codable & Sendable).Type {
102+
switch _kind {
103+
case let .wrappedValue(wrappedValue):
104+
type(of: wrappedValue)
105+
case let .typeOnly(type):
106+
type
107+
}
108+
}
109+
}
110+
}
111+
112+
// MARK: - Collection conveniences
113+
114+
extension Array where Element == ExitTest.CapturedValue {
115+
init<each T>(_ wrappedValues: repeat each T) where repeat each T: Codable & Sendable {
116+
self.init()
117+
repeat self.append(ExitTest.CapturedValue(wrappedValue: each wrappedValues))
118+
}
119+
120+
init<each T>(_ typesOfWrappedValues: repeat (each T).Type) where repeat each T: Codable & Sendable {
121+
self.init()
122+
repeat self.append(ExitTest.CapturedValue(typeOnly: (each typesOfWrappedValues).self))
123+
}
124+
}
125+
126+
extension Collection where Element == ExitTest.CapturedValue {
127+
/// Cast the elements in this collection to a tuple of their wrapped values.
128+
///
129+
/// - Returns: A tuple containing the wrapped values of the elements in this
130+
/// collection.
131+
///
132+
/// - Throws: If an expected value could not be found or was not of the
133+
/// type the caller expected.
134+
///
135+
/// This function assumes that the entry point function has already set the
136+
/// ``wrappedValue`` property of each element in this collection.
137+
func takeCapturedValues<each T>() throws -> (repeat each T) {
138+
func nextValue<U>(
139+
as type: U.Type,
140+
from capturedValues: inout SubSequence
141+
) throws -> U {
142+
// Get the next captured value in the collection. If we run out of values
143+
// before running out of parameter pack elements, then something in the
144+
// exit test handler or entry point is likely broken.
145+
guard let wrappedValue = capturedValues.first?.wrappedValue else {
146+
let actualCount = self.count
147+
let expectedCount = parameterPackCount(repeat (each T).self)
148+
fatalError("Found fewer captured values (\(actualCount)) than expected (\(expectedCount)) when passing them to the current exit test.")
149+
}
150+
151+
// Next loop, get the next element. (We're mutating a subsequence, not
152+
// self, so this is generally an O(1) operation.)
153+
capturedValues = capturedValues.dropFirst()
154+
155+
// Make sure the value is of the correct type. If it's not, that's also
156+
// probably a problem with the exit test handler or entry point.
157+
guard let wrappedValue = wrappedValue as? U else {
158+
fatalError("Expected captured value at index \(capturedValues.startIndex) with type '\(String(describingForTest: U.self))', but found an instance of '\(String(describingForTest: Swift.type(of: wrappedValue)))' instead.")
159+
}
160+
161+
return wrappedValue
162+
}
163+
164+
var capturedValues = self[...]
165+
return (repeat try nextValue(as: (each T).self, from: &capturedValues))
166+
}
167+
}
168+
#endif

0 commit comments

Comments
 (0)