Skip to content

Commit a7b5435

Browse files
authored
Change the return types of test content accessors and add ExitTest.current. (#974)
This PR changes the signatures of the accessor functions for the test content metadata section so that we're not using underscored types. Instead, a `Test.Generator` type replaces `Test._Record` and the bare function type we were using for `Test` and `ExitTest` is promoted to (still experimental) API instead of hiding behind underscores. This change allows the types produced by accessor functions to always be nominal types, avoids the iffy substitution of `_Record` for `@Sendable () async -> Test` at runtime, and simplifies the internal `TestContent` protocol by eliminating the need for an associated type to stand in for the conforming type. This change leaves `ExitTest` as a nominal, but empty, type. I've added `ExitTest.current` to make it useful so that test code, especially libraries, can detect if it's running inside an exit test or not. I will separately update the exit test proposal PR to include this type and property. ### 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 f9e1701 commit a7b5435

File tree

8 files changed

+92
-103
lines changed

8 files changed

+92
-103
lines changed

Documentation/ABI/TestContent.md

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -143,26 +143,21 @@ filtering is performed.)
143143
The concrete Swift type of the value written to `outValue`, the type pointed to
144144
by `type`, and the value pointed to by `hint` depend on the kind of record:
145145

146-
- For test or suite declarations (kind `0x74657374`), the accessor produces an
147-
asynchronous Swift function[^notAccessorSignature] that returns an instance of
148-
`Testing.Test`:
146+
- For test or suite declarations (kind `0x74657374`), the accessor produces a
147+
structure of type `Testing.Test.Generator` that the testing library can use
148+
to generate the corresponding test[^notAccessorSignature].
149149

150-
```swift
151-
@Sendable () async -> Test
152-
```
153-
154-
[^notAccessorSignature]: This signature is not the signature of `accessor`,
155-
but of the Swift function reference it writes to `outValue`. This level of
156-
indirection is necessary because loading a test or suite declaration is an
157-
asynchronous operation, but C functions cannot be `async`.
150+
[^notAccessorSignature]: This level of indirection is necessary because
151+
loading a test or suite declaration is an asynchronous operation, but C
152+
functions cannot be `async`.
158153

159154
Test content records of this kind do not specify a type for `hint`. Always
160155
pass `nil`.
161156

162157
- For exit test declarations (kind `0x65786974`), the accessor produces a
163-
structure describing the exit test (of type `Testing.__ExitTest`.)
158+
structure describing the exit test (of type `Testing.ExitTest`.)
164159

165-
Test content records of this kind accept a `hint` of type `Testing.__ExitTest.ID`.
160+
Test content records of this kind accept a `hint` of type `Testing.ExitTest.ID`.
166161
They only produce a result if they represent an exit test declared with the
167162
same ID (or if `hint` is `nil`.)
168163

Sources/Testing/Discovery.swift

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,6 @@ protocol TestContent: ~Copyable {
7474
/// By default, this type equals `Never`, indicating that this type of test
7575
/// content does not support hinting during discovery.
7676
associatedtype TestContentAccessorHint: Sendable = Never
77-
78-
/// The type to pass (by address) as the accessor function's `type` argument.
79-
///
80-
/// The default value of this property is `Self.self`. A conforming type can
81-
/// override the default implementation to substitute another type (e.g. if
82-
/// the conforming type is not public but records are created during macro
83-
/// expansion and can only reference public types.)
84-
static var testContentAccessorTypeArgument: any ~Copyable.Type { get }
85-
}
86-
87-
extension TestContent where Self: ~Copyable {
88-
static var testContentAccessorTypeArgument: any ~Copyable.Type {
89-
self
90-
}
9177
}
9278

9379
// MARK: - Individual test content records
@@ -142,7 +128,7 @@ struct TestContentRecord<T>: Sendable where T: TestContent & ~Copyable {
142128
return nil
143129
}
144130

145-
return withUnsafePointer(to: T.testContentAccessorTypeArgument) { type in
131+
return withUnsafePointer(to: T.self) { type in
146132
withUnsafeTemporaryAllocation(of: T.self, capacity: 1) { buffer in
147133
let initialized = if let hint {
148134
withUnsafePointer(to: hint) { hint in

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 66 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -24,60 +24,39 @@ private import _TestingInternals
2424

2525
/// A type describing an exit test.
2626
///
27-
/// Instances of this type describe an exit test defined by the test author and
28-
/// discovered or called at runtime. Tools that implement custom exit test
29-
/// handling will encounter instances of this type in two contexts:
30-
///
31-
/// - When the current configuration's exit test handler, set with
32-
/// ``Configuration/exitTestHandler``, is called; and
33-
/// - When, in a child process, they need to look up the exit test to call.
34-
///
35-
/// If you are writing tests, you don't usually need to interact directly with
36-
/// an instance of this type. To create an exit test, use the
27+
/// Instances of this type describe exit tests you create using the
3728
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or
38-
/// ``require(exitsWith:_:sourceLocation:performing:)`` macro.
39-
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
40-
#if SWT_NO_EXIT_TESTS
41-
@available(*, unavailable, message: "Exit tests are not available on this platform.")
42-
#endif
43-
public typealias ExitTest = __ExitTest
44-
45-
/// A type describing an exit test.
46-
///
47-
/// - Warning: This type is used to implement the `#expect(exitsWith:)` macro.
48-
/// Do not use it directly. Tools can use the SPI ``ExitTest`` typealias if
49-
/// needed.
29+
/// ``require(exitsWith:_:sourceLocation:performing:)`` macro. You don't usually
30+
/// need to interact directly with an instance of this type.
5031
@_spi(Experimental)
5132
#if SWT_NO_EXIT_TESTS
5233
@available(*, unavailable, message: "Exit tests are not available on this platform.")
5334
#endif
54-
public struct __ExitTest: Sendable, ~Copyable {
55-
/// A type whose instances uniquely identify instances of `__ExitTest`.
35+
public struct ExitTest: Sendable, ~Copyable {
36+
/// A type whose instances uniquely identify instances of ``ExitTest``.
37+
@_spi(ForToolsIntegrationOnly)
5638
public struct ID: Sendable, Equatable, Codable {
5739
/// An underlying UUID (stored as two `UInt64` values to avoid relying on
5840
/// `UUID` from Foundation or any platform-specific interfaces.)
5941
private var _lo: UInt64
6042
private var _hi: UInt64
6143

62-
/// Initialize an instance of this type.
63-
///
64-
/// - Warning: This member is used to implement the `#expect(exitsWith:)`
65-
/// macro. Do not use it directly.
66-
public init(__uuid uuid: (UInt64, UInt64)) {
44+
init(_ uuid: (UInt64, UInt64)) {
6745
self._lo = uuid.0
6846
self._hi = uuid.1
6947
}
7048
}
7149

7250
/// A value that uniquely identifies this instance.
51+
@_spi(ForToolsIntegrationOnly)
7352
public var id: ID
7453

7554
/// The body closure of the exit test.
7655
///
7756
/// Do not invoke this closure directly. Instead, invoke ``callAsFunction()``
7857
/// to run the exit test. Running the exit test will always terminate the
7958
/// current process.
80-
fileprivate var body: @Sendable () async throws -> Void
59+
fileprivate var body: @Sendable () async throws -> Void = {}
8160

8261
/// Storage for ``observedValues``.
8362
///
@@ -113,21 +92,52 @@ public struct __ExitTest: Sendable, ~Copyable {
11392
_observedValues = newValue
11493
}
11594
}
95+
}
96+
97+
#if !SWT_NO_EXIT_TESTS
98+
// MARK: - Current
99+
100+
@_spi(Experimental)
101+
extension ExitTest {
102+
/// A container type to hold the current exit test.
103+
///
104+
/// This class is temporarily necessary until `ManagedBuffer` is updated to
105+
/// support storing move-only values. For more information, see [SE-NNNN](https://github.com/swiftlang/swift-evolution/pull/2657).
106+
private final class _CurrentContainer: Sendable {
107+
/// The exit test represented by this container.
108+
///
109+
/// The value of this property must be optional to avoid a copy when reading
110+
/// the value in ``ExitTest/current``.
111+
let exitTest: ExitTest?
112+
113+
init(exitTest: borrowing ExitTest) {
114+
self.exitTest = ExitTest(id: exitTest.id, body: exitTest.body, _observedValues: exitTest._observedValues)
115+
}
116+
}
117+
118+
/// Storage for ``current``.
119+
private static let _current = Locked<_CurrentContainer?>()
116120

117-
/// Initialize an exit test at runtime.
121+
/// The exit test that is running in the current process, if any.
118122
///
119-
/// - Warning: This initializer is used to implement the `#expect(exitsWith:)`
120-
/// macro. Do not use it directly.
121-
public init(
122-
__identifiedBy id: ID,
123-
body: @escaping @Sendable () async throws -> Void = {}
124-
) {
125-
self.id = id
126-
self.body = body
123+
/// If the current process was created to run an exit test, the value of this
124+
/// property describes that exit test. If this process is the parent process
125+
/// of an exit test, or if no exit test is currently running, the value of
126+
/// this property is `nil`.
127+
///
128+
/// The value of this property is constant across all tasks in the current
129+
/// process.
130+
public static var current: ExitTest? {
131+
_read {
132+
if let current = _current.rawValue {
133+
yield current.exitTest
134+
} else {
135+
yield nil
136+
}
137+
}
127138
}
128139
}
129140

130-
#if !SWT_NO_EXIT_TESTS
131141
// MARK: - Invocation
132142

133143
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
@@ -180,8 +190,7 @@ extension ExitTest {
180190
/// This function invokes the closure originally passed to
181191
/// `#expect(exitsWith:)` _in the current process_. That closure is expected
182192
/// to terminate the process; if it does not, the testing library will
183-
/// terminate the process in a way that causes the corresponding expectation
184-
/// to fail.
193+
/// terminate the process as if its `main()` function returned naturally.
185194
public consuming func callAsFunction() async -> Never {
186195
Self._disableCrashReporting()
187196

@@ -209,6 +218,11 @@ extension ExitTest {
209218
}
210219
#endif
211220

221+
// Set ExitTest.current before the test body runs.
222+
Self._current.withLock { current in
223+
current = _CurrentContainer(exitTest: self)
224+
}
225+
212226
do {
213227
try await body()
214228
} catch {
@@ -247,11 +261,15 @@ extension ExitTest {
247261
}
248262
}
249263

264+
#if !SWT_NO_LEGACY_TEST_DISCOVERY
250265
// Call the legacy lookup function that discovers tests embedded in types.
251266
return types(withNamesContaining: exitTestContainerTypeNameMagic).lazy
252267
.compactMap { $0 as? any __ExitTestContainer.Type }
253-
.first { $0.__id == id }
254-
.map { ExitTest(__identifiedBy: $0.__id, body: $0.__body) }
268+
.first { ID($0.__id) == id }
269+
.map { ExitTest(id: ID($0.__id), body: $0.__body) }
270+
#else
271+
return nil
272+
#endif
255273
}
256274
}
257275

@@ -280,7 +298,7 @@ extension ExitTest {
280298
/// `await #expect(exitsWith:) { }` invocations regardless of calling
281299
/// convention.
282300
func callExitTest(
283-
identifiedBy exitTestID: ExitTest.ID,
301+
identifiedBy exitTestID: (UInt64, UInt64),
284302
exitsWith expectedExitCondition: ExitCondition,
285303
observing observedValues: [any PartialKeyPath<ExitTestArtifacts> & Sendable],
286304
expression: __Expression,
@@ -295,7 +313,7 @@ func callExitTest(
295313

296314
var result: ExitTestArtifacts
297315
do {
298-
var exitTest = ExitTest(__identifiedBy: exitTestID)
316+
var exitTest = ExitTest(id: ExitTest.ID(exitTestID))
299317
exitTest.observedValues = observedValues
300318
result = try await configuration.exitTestHandler(exitTest)
301319

@@ -426,10 +444,10 @@ extension ExitTest {
426444
/// configurations is undefined.
427445
static func findInEnvironmentForEntryPoint() -> Self? {
428446
// Find the ID of the exit test to run, if any, in the environment block.
429-
var id: __ExitTest.ID?
447+
var id: ExitTest.ID?
430448
if var idString = Environment.variable(named: "SWT_EXPERIMENTAL_EXIT_TEST_ID") {
431449
id = try? idString.withUTF8 { idBuffer in
432-
try JSON.decode(__ExitTest.ID.self, from: UnsafeRawBufferPointer(idBuffer))
450+
try JSON.decode(ExitTest.ID.self, from: UnsafeRawBufferPointer(idBuffer))
433451
}
434452
}
435453
guard let id else {

Sources/Testing/Expectations/ExpectationChecking+Macro.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1147,7 +1147,7 @@ public func __checkClosureCall<R>(
11471147
/// `#require()` macros. Do not call it directly.
11481148
@_spi(Experimental)
11491149
public func __checkClosureCall(
1150-
identifiedBy exitTestID: __ExitTest.ID,
1150+
identifiedBy exitTestID: (UInt64, UInt64),
11511151
exitsWith expectedExitCondition: ExitCondition,
11521152
observing observedValues: [any PartialKeyPath<ExitTestArtifacts> & Sendable],
11531153
performing body: @convention(thin) () -> Void,

Sources/Testing/Test+Discovery+Legacy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ let testContainerTypeNameMagic = "__🟠$test_container__"
3333
@_spi(Experimental)
3434
public protocol __ExitTestContainer {
3535
/// The unique identifier of the exit test.
36-
static var __id: __ExitTest.ID { get }
36+
static var __id: (UInt64, UInt64) { get }
3737

3838
/// The body function of the exit test.
3939
static var __body: @Sendable () async throws -> Void { get }

Sources/Testing/Test+Discovery.swift

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,12 @@ extension Test {
1818
/// indirect `async` accessor function rather than directly producing
1919
/// instances of ``Test``, but functions are non-nominal types and cannot
2020
/// directly conform to protocols.
21-
///
22-
/// - Note: This helper type must have the exact in-memory layout of the
23-
/// `async` accessor function. Do not add any additional cases or associated
24-
/// values. The layout of this type is [guaranteed](https://github.com/swiftlang/swift/blob/main/docs/ABI/TypeLayout.rst#fragile-enum-layout)
25-
/// by the Swift ABI.
26-
/* @frozen */ private enum _Record: TestContent {
21+
fileprivate struct Generator: TestContent, RawRepresentable {
2722
static var testContentKind: UInt32 {
2823
0x74657374
2924
}
3025

31-
static var testContentAccessorTypeArgument: any ~Copyable.Type {
32-
Generator.self
33-
}
34-
35-
/// The type of the actual (asynchronous) generator function.
36-
typealias Generator = @Sendable () async -> Test
37-
38-
/// The actual (asynchronous) accessor function.
39-
case generator(Generator)
26+
var rawValue: @Sendable () async -> Test
4027
}
4128

4229
/// All available ``Test`` instances in the process, according to the runtime.
@@ -65,15 +52,10 @@ extension Test {
6552
// Walk all test content and gather generator functions, then call them in
6653
// a task group and collate their results.
6754
if useNewMode {
68-
let generators = _Record.allTestContentRecords().lazy.compactMap { record in
69-
if case let .generator(generator) = record.load() {
70-
return generator
71-
}
72-
return nil // currently unreachable, but not provably so
73-
}
55+
let generators = Generator.allTestContentRecords().lazy.compactMap { $0.load() }
7456
await withTaskGroup(of: Self.self) { taskGroup in
7557
for generator in generators {
76-
taskGroup.addTask(operation: generator)
58+
taskGroup.addTask { await generator.rawValue() }
7759
}
7860
result = await taskGroup.reduce(into: result) { $0.insert($1) }
7961
}

Sources/TestingMacros/ConditionMacro.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ extension ExitTestConditionMacro {
435435

436436
// TODO: use UUID() here if we can link to Foundation
437437
let exitTestID = (UInt64.random(in: 0 ... .max), UInt64.random(in: 0 ... .max))
438-
let exitTestIDExpr: ExprSyntax = "Testing.__ExitTest.ID(__uuid: (\(literal: exitTestID.0), \(literal: exitTestID.1)))"
438+
let exitTestIDExpr: ExprSyntax = "(\(literal: exitTestID.0), \(literal: exitTestID.1))"
439439

440440
var decls = [DeclSyntax]()
441441

@@ -444,7 +444,7 @@ extension ExitTestConditionMacro {
444444
let bodyThunkName = context.makeUniqueName("")
445445
decls.append(
446446
"""
447-
@Sendable func \(bodyThunkName)() async throws -> Void {
447+
@Sendable func \(bodyThunkName)() async throws -> Swift.Void {
448448
return try await Testing.__requiringTry(Testing.__requiringAwait(\(bodyArgumentExpr.trimmed)))()
449449
}
450450
"""
@@ -457,7 +457,7 @@ extension ExitTestConditionMacro {
457457
"""
458458
@available(*, deprecated, message: "This type is an implementation detail of the testing library. Do not use it directly.")
459459
enum \(enumName): Testing.__ExitTestContainer, Sendable {
460-
static var __id: Testing.__ExitTest.ID {
460+
static var __id: (Swift.UInt64, Swift.UInt64) {
461461
\(exitTestIDExpr)
462462
}
463463
static var __body: @Sendable () async throws -> Void {

Tests/TestingTests/ExitTestTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,14 @@ private import _TestingInternals
416416
fatalError()
417417
}
418418
}
419+
420+
@Test("ExitTest.current property")
421+
func currentProperty() async {
422+
#expect((ExitTest.current == nil) as Bool)
423+
await #expect(exitsWith: .success) {
424+
#expect((ExitTest.current != nil) as Bool)
425+
}
426+
}
419427
}
420428

421429
// MARK: - Fixtures

0 commit comments

Comments
 (0)