Skip to content

Commit 3fdb5aa

Browse files
committed
[SWT-NNNN] Exit tests
One of the first enhancement requests we received for swift-testing was the ability to test for precondition failures and other critical failures that terminate the current process when they occur. This feature is also frequently requested for XCTest. With swift-testing, we have the opportunity to build such a feature in an ergonomic way. Read the full proposal [here](https://github.com/apple/swift-testing/blob/jgrynspan/exit-tests-proposal/Documentation/Proposals/NNNN-exit-tests.md).
1 parent 1eba9c0 commit 3fdb5aa

File tree

10 files changed

+890
-24
lines changed

10 files changed

+890
-24
lines changed

Documentation/Proposals/NNNN-exit-tests.md

Lines changed: 787 additions & 0 deletions
Large diffs are not rendered by default.

Sources/Testing/ExitTests/ExitTest.Condition.swift

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
private import _TestingInternals
1212

13-
@_spi(Experimental)
1413
#if SWT_NO_EXIT_TESTS
1514
@available(*, unavailable, message: "Exit tests are not available on this platform.")
1615
#endif
@@ -21,6 +20,22 @@ extension ExitTest {
2120
/// exit test is expected to pass or fail by passing them to
2221
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
2322
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``.
23+
///
24+
/// ## Topics
25+
///
26+
/// ### Successful exit conditions
27+
///
28+
/// - ``success``
29+
///
30+
/// ### Failing exit conditions
31+
///
32+
/// - ``failure``
33+
/// - ``exitCode(_:)``
34+
/// - ``signal(_:)``
35+
///
36+
/// @Metadata {
37+
/// @Available(Swift, introduced: 6.2)
38+
/// }
2439
public struct Condition: Sendable {
2540
/// An enumeration describing the possible conditions for an exit test.
2641
private enum _Kind: Sendable, Equatable {
@@ -38,13 +53,20 @@ extension ExitTest {
3853

3954
// MARK: -
4055

41-
@_spi(Experimental)
4256
#if SWT_NO_EXIT_TESTS
4357
@available(*, unavailable, message: "Exit tests are not available on this platform.")
4458
#endif
4559
extension ExitTest.Condition {
4660
/// A condition that matches when a process terminates successfully with exit
4761
/// code `EXIT_SUCCESS`.
62+
///
63+
/// The C programming language defines two [standard exit codes](https://en.cppreference.com/w/c/program/EXIT_status),
64+
/// `EXIT_SUCCESS` and `EXIT_FAILURE` as well as `0` (as a synonym for
65+
/// `EXIT_SUCCESS`.)
66+
///
67+
/// @Metadata {
68+
/// @Available(Swift, introduced: 6.2)
69+
/// }
4870
public static var success: Self {
4971
// Strictly speaking, the C standard treats 0 as a successful exit code and
5072
// potentially distinct from EXIT_SUCCESS. To my knowledge, no modern
@@ -59,10 +81,17 @@ extension ExitTest.Condition {
5981

6082
/// A condition that matches when a process terminates abnormally with any
6183
/// exit code other than `EXIT_SUCCESS` or with any signal.
84+
///
85+
/// @Metadata {
86+
/// @Available(Swift, introduced: 6.2)
87+
/// }
6288
public static var failure: Self {
6389
Self(_kind: .failure)
6490
}
6591

92+
/// @Metadata {
93+
/// @Available(Swift, introduced: 6.2)
94+
/// }
6695
public init(_ statusAtExit: StatusAtExit) {
6796
self.init(_kind: .statusAtExit(statusAtExit))
6897
}
@@ -89,6 +118,10 @@ extension ExitTest.Condition {
89118
/// the process is yielded to the parent process. Linux and other POSIX-like
90119
/// systems may only reliably report the low unsigned 8 bits (0–255) of
91120
/// the exit code.
121+
///
122+
/// @Metadata {
123+
/// @Available(Swift, introduced: 6.2)
124+
/// }
92125
public static func exitCode(_ exitCode: CInt) -> Self {
93126
#if !SWT_NO_EXIT_TESTS
94127
Self(.exitCode(exitCode))
@@ -113,6 +146,10 @@ extension ExitTest.Condition {
113146
/// | FreeBSD | [`<signal.h>`](https://man.freebsd.org/cgi/man.cgi?signal(3)) |
114147
/// | OpenBSD | [`<signal.h>`](https://man.openbsd.org/signal.3) |
115148
/// | Windows | [`<signal.h>`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) |
149+
///
150+
/// @Metadata {
151+
/// @Available(Swift, introduced: 6.2)
152+
/// }
116153
public static func signal(_ signal: CInt) -> Self {
117154
#if !SWT_NO_EXIT_TESTS
118155
Self(.signal(signal))

Sources/Testing/ExitTests/ExitTest.Result.swift

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

11-
@_spi(Experimental)
1211
#if SWT_NO_EXIT_TESTS
1312
@available(*, unavailable, message: "Exit tests are not available on this platform.")
1413
#endif
@@ -19,11 +18,19 @@ extension ExitTest {
1918
/// Both ``expect(exitsWith:observing:_:sourceLocation:performing:)`` and
2019
/// ``require(exitsWith:observing:_:sourceLocation:performing:)`` return
2120
/// instances of this type.
21+
///
22+
/// @Metadata {
23+
/// @Available(Swift, introduced: 6.2)
24+
/// }
2225
public struct Result: Sendable {
23-
/// The exit condition the exit test exited with.
26+
/// The status of the process hosting the exit test at the time it exits.
2427
///
2528
/// When the exit test passes, the value of this property is equal to the
2629
/// exit status reported by the process that hosted the exit test.
30+
///
31+
/// @Metadata {
32+
/// @Available(Swift, introduced: 6.2)
33+
/// }
2734
public var statusAtExit: StatusAtExit
2835

2936
/// All bytes written to the standard output stream of the exit test before
@@ -50,6 +57,10 @@ extension ExitTest {
5057
///
5158
/// If you did not request standard output content when running an exit
5259
/// test, the value of this property is the empty array.
60+
///
61+
/// @Metadata {
62+
/// @Available(Swift, introduced: 6.2)
63+
/// }
5364
public var standardOutputContent: [UInt8] = []
5465

5566
/// All bytes written to the standard error stream of the exit test before
@@ -76,6 +87,10 @@ extension ExitTest {
7687
///
7788
/// If you did not request standard error content when running an exit test,
7889
/// the value of this property is the empty array.
90+
///
91+
/// @Metadata {
92+
/// @Available(Swift, introduced: 6.2)
93+
/// }
7994
public var standardErrorContent: [UInt8] = []
8095

8196
@_spi(ForToolsIntegrationOnly)

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ private import _TestingInternals
2626
/// A type describing an exit test.
2727
///
2828
/// Instances of this type describe exit tests you create using the
29-
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)``
29+
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
3030
/// ``require(exitsWith:observing:_:sourceLocation:performing:)`` macro. You
3131
/// don't usually need to interact directly with an instance of this type.
32-
@_spi(Experimental)
32+
///
33+
/// @Metadata {
34+
/// @Available(Swift, introduced: 6.2)
35+
/// }
3336
#if SWT_NO_EXIT_TESTS
3437
@available(*, unavailable, message: "Exit tests are not available on this platform.")
3538
#endif
@@ -108,7 +111,6 @@ public struct ExitTest: Sendable, ~Copyable {
108111
#if !SWT_NO_EXIT_TESTS
109112
// MARK: - Current
110113

111-
@_spi(Experimental)
112114
extension ExitTest {
113115
/// A container type to hold the current exit test.
114116
///
@@ -138,6 +140,10 @@ extension ExitTest {
138140
///
139141
/// The value of this property is constant across all tasks in the current
140142
/// process.
143+
///
144+
/// @Metadata {
145+
/// @Available(Swift, introduced: 6.2)
146+
/// }
141147
public static var current: ExitTest? {
142148
_read {
143149
if let current = _current.rawValue {
@@ -151,7 +157,7 @@ extension ExitTest {
151157

152158
// MARK: - Invocation
153159

154-
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
160+
@_spi(ForToolsIntegrationOnly)
155161
extension ExitTest {
156162
/// Disable crash reporting, crash logging, or core dumps for the current
157163
/// process.
@@ -290,7 +296,7 @@ extension ExitTest: DiscoverableAsTestContent {
290296
}
291297
}
292298

293-
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
299+
@_spi(ForToolsIntegrationOnly)
294300
extension ExitTest {
295301
/// Find the exit test function at the given source location.
296302
///
@@ -427,7 +433,7 @@ extension ABI {
427433
fileprivate typealias BackChannelVersion = v1
428434
}
429435

430-
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
436+
@_spi(ForToolsIntegrationOnly)
431437
extension ExitTest {
432438
/// A handler that is invoked when an exit test starts.
433439
///
@@ -463,13 +469,13 @@ extension ExitTest {
463469
/// events should be written, or `nil` if the file handle could not be
464470
/// resolved.
465471
private static let _backChannelForEntryPoint: FileHandle? = {
466-
guard let backChannelEnvironmentVariable = Environment.variable(named: "SWT_EXPERIMENTAL_BACKCHANNEL") else {
472+
guard let backChannelEnvironmentVariable = Environment.variable(named: "SWT_BACKCHANNEL") else {
467473
return nil
468474
}
469475

470476
// Erase the environment variable so that it cannot accidentally be opened
471477
// twice (nor, in theory, affect the code of the exit test.)
472-
Environment.setVariable(nil, named: "SWT_EXPERIMENTAL_BACKCHANNEL")
478+
Environment.setVariable(nil, named: "SWT_BACKCHANNEL")
473479

474480
var fd: CInt?
475481
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD)
@@ -500,10 +506,10 @@ extension ExitTest {
500506
static func findInEnvironmentForEntryPoint() -> Self? {
501507
// Find the ID of the exit test to run, if any, in the environment block.
502508
var id: ExitTest.ID?
503-
if var idString = Environment.variable(named: "SWT_EXPERIMENTAL_EXIT_TEST_ID") {
509+
if var idString = Environment.variable(named: "SWT_EXIT_TEST_ID") {
504510
// Clear the environment variable. It's an implementation detail and exit
505511
// test code shouldn't be dependent on it. Use ExitTest.current if needed!
506-
Environment.setVariable(nil, named: "SWT_EXPERIMENTAL_EXIT_TEST_ID")
512+
Environment.setVariable(nil, named: "SWT_EXIT_TEST_ID")
507513

508514
id = try? idString.withUTF8 { idBuffer in
509515
try JSON.decode(ExitTest.ID.self, from: UnsafeRawBufferPointer(idBuffer))
@@ -637,7 +643,7 @@ extension ExitTest {
637643
// Insert a specific variable that tells the child process which exit test
638644
// to run.
639645
try JSON.withEncoding(of: exitTest.id) { json in
640-
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_ID"] = String(decoding: json, as: UTF8.self)
646+
childEnvironment["SWT_EXIT_TEST_ID"] = String(decoding: json, as: UTF8.self)
641647
}
642648

643649
typealias ResultUpdater = @Sendable (inout ExitTest.Result) -> Void
@@ -683,7 +689,7 @@ extension ExitTest {
683689
#warning("Platform-specific implementation missing: back-channel pipe unavailable")
684690
#endif
685691
if let backChannelEnvironmentVariable {
686-
childEnvironment["SWT_EXPERIMENTAL_BACKCHANNEL"] = backChannelEnvironmentVariable
692+
childEnvironment["SWT_BACKCHANNEL"] = backChannelEnvironmentVariable
687693
}
688694

689695
// Spawn the child process.

Sources/Testing/ExitTests/StatusAtExit.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ private import _TestingInternals
1818
/// expected to pass or fail by passing it to
1919
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
2020
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``.
21-
@_spi(Experimental)
21+
///
22+
/// @Metadata {
23+
/// @Available(Swift, introduced: 6.2)
24+
/// }
2225
#if SWT_NO_PROCESS_SPAWNING
2326
@available(*, unavailable, message: "Exit tests are not available on this platform.")
2427
#endif
@@ -44,6 +47,10 @@ public enum StatusAtExit: Sendable {
4447
/// the process is yielded to the parent process. Linux and other POSIX-like
4548
/// systems may only reliably report the low unsigned 8 bits (0&ndash;255) of
4649
/// the exit code.
50+
///
51+
/// @Metadata {
52+
/// @Available(Swift, introduced: 6.2)
53+
/// }
4754
case exitCode(_ exitCode: CInt)
4855

4956
/// The process terminated with the given signal.
@@ -61,12 +68,15 @@ public enum StatusAtExit: Sendable {
6168
/// | FreeBSD | [`<signal.h>`](https://man.freebsd.org/cgi/man.cgi?signal(3)) |
6269
/// | OpenBSD | [`<signal.h>`](https://man.openbsd.org/signal.3) |
6370
/// | Windows | [`<signal.h>`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) |
71+
///
72+
/// @Metadata {
73+
/// @Available(Swift, introduced: 6.2)
74+
/// }
6475
case signal(_ signal: CInt)
6576
}
6677

6778
// MARK: - Equatable
6879

69-
@_spi(Experimental)
7080
#if SWT_NO_PROCESS_SPAWNING
7181
@available(*, unavailable, message: "Exit tests are not available on this platform.")
7282
#endif

Sources/Testing/Expectations/Expectation+Macro.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,10 @@ public macro require<R>(
582582
/// ```
583583
///
584584
/// An exit test cannot run within another exit test.
585-
@_spi(Experimental)
585+
///
586+
/// @Metadata {
587+
/// @Available(Swift, introduced: 6.2)
588+
/// }
586589
#if SWT_NO_EXIT_TESTS
587590
@available(*, unavailable, message: "Exit tests are not available on this platform.")
588591
#endif
@@ -694,7 +697,10 @@ public macro require<R>(
694697
/// ```
695698
///
696699
/// An exit test cannot run within another exit test.
697-
@_spi(Experimental)
700+
///
701+
/// @Metadata {
702+
/// @Available(Swift, introduced: 6.2)
703+
/// }
698704
#if SWT_NO_EXIT_TESTS
699705
@available(*, unavailable, message: "Exit tests are not available on this platform.")
700706
#endif

Sources/Testing/Expectations/ExpectationChecking+Macro.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,6 @@ public func __checkClosureCall<R>(
11451145
///
11461146
/// - Warning: This function is used to implement the `#expect()` and
11471147
/// `#require()` macros. Do not call it directly.
1148-
@_spi(Experimental)
11491148
public func __checkClosureCall(
11501149
identifiedBy exitTestID: (UInt64, UInt64),
11511150
exitsWith expectedExitCondition: ExitTest.Condition,

Sources/Testing/Running/Configuration.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,7 @@ public struct Configuration: Sendable {
217217
/// When using the `swift test` command from Swift Package Manager, this
218218
/// property is pre-configured. Otherwise, the default value of this property
219219
/// records an issue indicating that it has not been configured.
220-
@_spi(Experimental)
221-
public var exitTestHandler: ExitTest.Handler = { exitTest in
220+
public var exitTestHandler: ExitTest.Handler = { _ in
222221
throw SystemError(description: "Exit test support has not been implemented by the current testing infrastructure.")
223222
}
224223
#endif

Sources/Testing/Testing.docc/Expectations.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ the test when the code doesn't satisfy a requirement, use
7272
- ``require(throws:_:sourceLocation:performing:)-4djuw``
7373
- ``require(_:sourceLocation:performing:throws:)``
7474

75+
### Checking how processes exit
76+
77+
- ``expect(exitsWith:observing:_:sourceLocation:performing:)``
78+
- ``require(exitsWith:observing:_:sourceLocation:performing:)``
79+
- ``ExitTest``
80+
- ``StatusAtExit``
81+
7582
### Confirming that asynchronous events occur
7683

7784
- <doc:testing-asynchronous-code>

Tests/TestingTests/ExitTestTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11-
@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing
11+
@testable @_spi(ForToolsIntegrationOnly) import Testing
1212
private import _TestingInternals
1313

1414
#if !SWT_NO_EXIT_TESTS

0 commit comments

Comments
 (0)