Skip to content

Commit a46df3f

Browse files
authored
Fixes to exit tests. (#615)
This PR supersedes #603, #613, and #614. Exit tests remain an experimental feature. ## Clarify that 8-bit exit codes aren't a problem on macOS and Windows. (#603) The documentation for the experimental exit tests feature currently says that on POSIX-like systems, only the low 8 bits of a process' exit code are preserved. This would be true if we used `wait()`, `wait4()`, etc. and `WEXITSTATUS()`, but we use `waitid()` instead which is [supposed to](https://pubs.opengroup.org/onlinepubs/9699919799/functions/exit.html) preserve the full exit code. It does so on Darwin, but not on Linux; Windows doesn't use `waitid()` but does report the full exit code. Now, we're not currently building for any other POSIX-like systems that support processes (WASI/Wasm doesn't count here), so I've left in some weasel words and added a canary unit test. It will let us know if/when we add a platform that where `waitid()` doesn't preserve all the bits of the exit code, and we can amend the documentation in that case. ## Implement an equality operator for ExitCondition. (#613) This PR implements `==` and `===` for `ExitCondition`, part of the experimental exit tests feature. These operators are necessary in order to allow for exit tests to support more complex matching by trailing closure (e.g. to support inspecting `stdout`.) Because `.failure` is a fuzzy case, `==` fuzzy-matches while `===` exactly matches. `Hashable` conformance is unavailable. Example usage: ```swift let lhs: ExitCondition = .failure let rhs: ExitCondition = .signal(SIGTERM) print(lhs == rhs) // prints "true" print(lhs === rhs) // prints "false" ``` ## Allow throwing an error from an exit test's body. (#614) This PR amends the signatures of the exit test macros (`#expect(exitsWith:) {}` and `try #require(exitsWith:) {}`) to allow bodies to throw errors. If they do, they are treated as uncaught errors and the child process terminates abnormally (in the same way it does if an error is thrown from the main function of a Swift program.) ### 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 d01c406 commit a46df3f

File tree

7 files changed

+224
-34
lines changed

7 files changed

+224
-34
lines changed

Sources/Testing/ExitTests/ExitCondition.swift

Lines changed: 134 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ public enum ExitCondition: Sendable {
4444
/// | Linux | [`<stdlib.h>`](https://sourceware.org/glibc/manual/latest/html_node/Exit-Status.html), `<sysexits.h>` |
4545
/// | Windows | [`<stdlib.h>`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/exit-success-exit-failure) |
4646
///
47-
/// On POSIX-like systems including macOS and Linux, only the low unsigned 8
48-
/// bits (0&ndash;255) of the exit code are reliably preserved and reported to
49-
/// a parent process.
47+
/// On macOS and Windows, the full exit code reported by the process is
48+
/// yielded to the parent process. Linux and other POSIX-like systems may only
49+
/// reliably report the low unsigned 8 bits (0&ndash;255) of the exit code.
5050
case exitCode(_ exitCode: CInt)
5151

5252
/// The process terminated with the given signal.
@@ -62,43 +62,159 @@ public enum ExitCondition: Sendable {
6262
/// | macOS | [`<signal.h>`](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/signal.3.html) |
6363
/// | Linux | [`<signal.h>`](https://sourceware.org/glibc/manual/latest/html_node/Standard-Signals.html) |
6464
/// | Windows | [`<signal.h>`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) |
65+
///
66+
/// On Windows, by default, the C runtime will terminate a process with exit
67+
/// code `-3` if a raised signal is not handled, exactly as if `exit(-3)` were
68+
/// called. As a result, this case is unavailable on that platform. Developers
69+
/// should use ``failure`` instead when testing signal handling on Windows.
6570
#if os(Windows)
6671
@available(*, unavailable, message: "On Windows, use .failure instead.")
6772
#endif
6873
case signal(_ signal: CInt)
6974
}
7075

71-
// MARK: -
76+
// MARK: - Equatable
7277

7378
#if SWT_NO_EXIT_TESTS
7479
@available(*, unavailable, message: "Exit tests are not available on this platform.")
7580
#endif
7681
extension ExitCondition {
77-
/// Check whether this instance matches another.
82+
/// Check whether or not two values of this type are equal.
7883
///
7984
/// - Parameters:
80-
/// - other: The other instance to compare against.
85+
/// - lhs: One value to compare.
86+
/// - rhs: Another value to compare.
8187
///
82-
/// - Returns: Whether or not this instance is equal to, or at least covers,
83-
/// the other instance.
84-
func matches(_ other: ExitCondition) -> Bool {
85-
return switch (self, other) {
86-
case (.failure, .failure):
87-
true
88+
/// - Returns: Whether or not `lhs` and `rhs` are equal.
89+
///
90+
/// Two instances of this type can be compared; if either instance is equal to
91+
/// ``failure``, it will compare equal to any instance except ``success``. To
92+
/// check if two instances are exactly equal, use the ``===(_:_:)`` operator:
93+
///
94+
/// ```swift
95+
/// let lhs: ExitCondition = .failure
96+
/// let rhs: ExitCondition = .signal(SIGINT)
97+
/// print(lhs == rhs) // prints "true"
98+
/// print(lhs === rhs) // prints "false"
99+
/// ```
100+
///
101+
/// This special behavior means that the ``==(_:_:)`` operator is not
102+
/// transitive, and does not satisfy the requirements of
103+
/// [`Equatable`](https://developer.apple.com/documentation/swift/equatable)
104+
/// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable).
105+
///
106+
/// For any values `a` and `b`, `a == b` implies that `a != b` is `false`.
107+
public static func ==(lhs: Self, rhs: Self) -> Bool {
108+
return switch (lhs, rhs) {
88109
case let (.failure, .exitCode(exitCode)), let (.exitCode(exitCode), .failure):
89110
exitCode != EXIT_SUCCESS
111+
#if !os(Windows)
112+
case (.failure, .signal), (.signal, .failure):
113+
// All terminating signals are considered failures.
114+
true
115+
#endif
116+
default:
117+
lhs === rhs
118+
}
119+
}
120+
121+
/// Check whether or not two values of this type are _not_ equal.
122+
///
123+
/// - Parameters:
124+
/// - lhs: One value to compare.
125+
/// - rhs: Another value to compare.
126+
///
127+
/// - Returns: Whether or not `lhs` and `rhs` are _not_ equal.
128+
///
129+
/// Two instances of this type can be compared; if either instance is equal to
130+
/// ``failure``, it will compare equal to any instance except ``success``. To
131+
/// check if two instances are not exactly equal, use the ``!==(_:_:)``
132+
/// operator:
133+
///
134+
/// ```swift
135+
/// let lhs: ExitCondition = .failure
136+
/// let rhs: ExitCondition = .signal(SIGINT)
137+
/// print(lhs != rhs) // prints "false"
138+
/// print(lhs !== rhs) // prints "true"
139+
/// ```
140+
///
141+
/// This special behavior means that the ``!=(_:_:)`` operator is not
142+
/// transitive, and does not satisfy the requirements of
143+
/// [`Equatable`](https://developer.apple.com/documentation/swift/equatable)
144+
/// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable).
145+
///
146+
/// For any values `a` and `b`, `a == b` implies that `a != b` is `false`.
147+
public static func !=(lhs: Self, rhs: Self) -> Bool {
148+
!(lhs == rhs)
149+
}
150+
151+
/// Check whether or not two values of this type are identical.
152+
///
153+
/// - Parameters:
154+
/// - lhs: One value to compare.
155+
/// - rhs: Another value to compare.
156+
///
157+
/// - Returns: Whether or not `lhs` and `rhs` are identical.
158+
///
159+
/// Two instances of this type can be compared; if either instance is equal to
160+
/// ``failure``, it will compare equal to any instance except ``success``. To
161+
/// check if two instances are exactly equal, use the ``===(_:_:)`` operator:
162+
///
163+
/// ```swift
164+
/// let lhs: ExitCondition = .failure
165+
/// let rhs: ExitCondition = .signal(SIGINT)
166+
/// print(lhs == rhs) // prints "true"
167+
/// print(lhs === rhs) // prints "false"
168+
/// ```
169+
///
170+
/// This special behavior means that the ``==(_:_:)`` operator is not
171+
/// transitive, and does not satisfy the requirements of
172+
/// [`Equatable`](https://developer.apple.com/documentation/swift/equatable)
173+
/// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable).
174+
///
175+
/// For any values `a` and `b`, `a === b` implies that `a !== b` is `false`.
176+
public static func ===(lhs: Self, rhs: Self) -> Bool {
177+
return switch (lhs, rhs) {
178+
case (.failure, .failure):
179+
true
90180
case let (.exitCode(lhs), .exitCode(rhs)):
91181
lhs == rhs
92182
#if !os(Windows)
93183
case let (.signal(lhs), .signal(rhs)):
94184
lhs == rhs
95-
case (.signal, .failure), (.failure, .signal):
96-
// All terminating signals are considered failures.
97-
true
98-
case (.signal, .exitCode), (.exitCode, .signal):
99-
// Signals do not match exit codes.
100-
false
101185
#endif
186+
default:
187+
false
102188
}
103189
}
190+
191+
/// Check whether or not two values of this type are _not_ identical.
192+
///
193+
/// - Parameters:
194+
/// - lhs: One value to compare.
195+
/// - rhs: Another value to compare.
196+
///
197+
/// - Returns: Whether or not `lhs` and `rhs` are _not_ identical.
198+
///
199+
/// Two instances of this type can be compared; if either instance is equal to
200+
/// ``failure``, it will compare equal to any instance except ``success``. To
201+
/// check if two instances are not exactly equal, use the ``!==(_:_:)``
202+
/// operator:
203+
///
204+
/// ```swift
205+
/// let lhs: ExitCondition = .failure
206+
/// let rhs: ExitCondition = .signal(SIGINT)
207+
/// print(lhs != rhs) // prints "false"
208+
/// print(lhs !== rhs) // prints "true"
209+
/// ```
210+
///
211+
/// This special behavior means that the ``!=(_:_:)`` operator is not
212+
/// transitive, and does not satisfy the requirements of
213+
/// [`Equatable`](https://developer.apple.com/documentation/swift/equatable)
214+
/// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable).
215+
///
216+
/// For any values `a` and `b`, `a === b` implies that `a !== b` is `false`.
217+
public static func !==(lhs: Self, rhs: Self) -> Bool {
218+
!(lhs === rhs)
219+
}
104220
}

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public struct ExitTest: Sendable {
2121
public var expectedExitCondition: ExitCondition
2222

2323
/// The body closure of the exit test.
24-
fileprivate var body: @Sendable () async -> Void
24+
fileprivate var body: @Sendable () async throws -> Void
2525

2626
/// The source location of the exit test.
2727
///
@@ -37,12 +37,16 @@ public struct ExitTest: Sendable {
3737
/// terminate the process in a way that causes the corresponding expectation
3838
/// to fail.
3939
public func callAsFunction() async -> Never {
40-
await body()
40+
do {
41+
try await body()
42+
} catch {
43+
_errorInMain(error)
44+
}
4145

4246
// Run some glue code that terminates the process with an exit condition
4347
// that does not match the expected one. If the exit test's body doesn't
4448
// terminate, we'll manually call exit() and cause the test to fail.
45-
let expectingFailure = expectedExitCondition.matches(.failure)
49+
let expectingFailure = expectedExitCondition == .failure
4650
exit(expectingFailure ? EXIT_SUCCESS : EXIT_FAILURE)
4751
}
4852
}
@@ -63,7 +67,7 @@ public protocol __ExitTestContainer {
6367
static var __sourceLocation: SourceLocation { get }
6468

6569
/// The body function of the exit test.
66-
static var __body: @Sendable () async -> Void { get }
70+
static var __body: @Sendable () async throws -> Void { get }
6771
}
6872

6973
extension ExitTest {
@@ -118,7 +122,7 @@ extension ExitTest {
118122
/// convention.
119123
func callExitTest(
120124
exitsWith expectedExitCondition: ExitCondition,
121-
performing body: @escaping @Sendable () async -> Void,
125+
performing body: @escaping @Sendable () async throws -> Void,
122126
expression: __Expression,
123127
comments: @autoclosure () -> [Comment],
124128
isRequired: Bool,
@@ -150,7 +154,7 @@ func callExitTest(
150154
}
151155

152156
return __checkValue(
153-
expectedExitCondition.matches(actualExitCondition),
157+
expectedExitCondition == actualExitCondition,
154158
expression: expression,
155159
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualExitCondition),
156160
mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition),

Sources/Testing/Expectations/Expectation+Macro.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,9 @@ public macro require(
440440
/// a clean environment for execution, it is not called within the context of
441441
/// the original test. If `expression` does not terminate the child process, the
442442
/// process is terminated automatically as if the main function of the child
443-
/// process were allowed to return naturally.
443+
/// process were allowed to return naturally. If an error is thrown from
444+
/// `expression`, it is handed as if the error were thrown from `main()` and the
445+
/// process is terminated.
444446
///
445447
/// Once the child process terminates, the parent process resumes and compares
446448
/// its exit status against `exitCondition`. If they match, the exit test has
@@ -488,8 +490,8 @@ public macro require(
488490
/// issues should be attributed.
489491
/// - expression: The expression to be evaluated.
490492
///
491-
/// - Throws: An instance of ``ExpectationFailedError`` if `condition` evaluates
492-
/// to `false`.
493+
/// - Throws: An instance of ``ExpectationFailedError`` if the exit condition of
494+
/// the child process does not equal `expectedExitCondition`.
493495
///
494496
/// Use this overload of `#require()` when an expression will cause the current
495497
/// process to terminate and the nature of that termination will determine if
@@ -515,7 +517,9 @@ public macro require(
515517
/// a clean environment for execution, it is not called within the context of
516518
/// the original test. If `expression` does not terminate the child process, the
517519
/// process is terminated automatically as if the main function of the child
518-
/// process were allowed to return naturally.
520+
/// process were allowed to return naturally. If an error is thrown from
521+
/// `expression`, it is handed as if the error were thrown from `main()` and the
522+
/// process is terminated.
519523
///
520524
/// Once the child process terminates, the parent process resumes and compares
521525
/// its exit status against `exitCondition`. If they match, the exit test has
@@ -550,5 +554,5 @@ public macro require(
550554
exitsWith expectedExitCondition: ExitCondition,
551555
_ comment: @autoclosure () -> Comment? = nil,
552556
sourceLocation: SourceLocation = #_sourceLocation,
553-
performing expression: @convention(thin) () async -> Void
557+
performing expression: @convention(thin) () async throws -> Void
554558
) = #externalMacro(module: "TestingMacros", type: "ExitTestRequireMacro")

Sources/Testing/Expectations/ExpectationChecking+Macro.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,15 +1103,15 @@ public func __checkClosureCall<R>(
11031103
@_spi(Experimental)
11041104
public func __checkClosureCall(
11051105
exitsWith expectedExitCondition: ExitCondition,
1106-
performing body: @convention(thin) () async -> Void,
1106+
performing body: @convention(thin) () async throws -> Void,
11071107
expression: __Expression,
11081108
comments: @autoclosure () -> [Comment],
11091109
isRequired: Bool,
11101110
sourceLocation: SourceLocation
11111111
) async -> Result<Void, any Error> {
11121112
await callExitTest(
11131113
exitsWith: expectedExitCondition,
1114-
performing: { await body() },
1114+
performing: { try await body() },
11151115
expression: expression,
11161116
comments: comments(),
11171117
isRequired: isRequired,

Sources/TestingMacros/ConditionMacro.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,15 +362,23 @@ extension ExitTestConditionMacro {
362362
static var __sourceLocation: Testing.SourceLocation {
363363
\(createSourceLocationExpr(of: macro, context: context))
364364
}
365-
static var __body: @Sendable () async -> Void {
365+
static var __body: @Sendable () async throws -> Void {
366366
\(bodyArgumentExpr.trimmed)
367367
}
368368
static var __expectedExitCondition: Testing.ExitCondition {
369369
\(arguments[expectedExitConditionIndex].expression.trimmed)
370370
}
371371
}
372372
"""
373-
arguments[trailingClosureIndex].expression = "{ \(enumDecl) }"
373+
374+
// Explicitly include a closure signature to work around a compiler bug
375+
// type-checking thin throwing functions after macro expansion.
376+
// SEE: rdar://133979438
377+
arguments[trailingClosureIndex].expression = """
378+
{ () async throws in
379+
\(enumDecl)
380+
}
381+
"""
374382

375383
// Replace the exit test body (as an argument to the macro) with a stub
376384
// closure that hosts the type we created above.

Tests/TestingTests/ExitTestTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ private import _TestingInternals
3232
await Task.yield()
3333
exit(123)
3434
}
35+
await #expect(exitsWith: .failure) {
36+
throw MyError()
37+
}
3538
#if !os(Windows)
3639
await #expect(exitsWith: .signal(SIGKILL)) {
3740
_ = kill(getpid(), SIGKILL)
@@ -197,6 +200,51 @@ private import _TestingInternals
197200
}.run(configuration: configuration)
198201
}
199202
}
203+
204+
#if !os(Linux)
205+
@Test("Exit test reports > 8 bits of the exit code")
206+
func fullWidthExitCode() async {
207+
// On macOS and Linux, we use waitid() which per POSIX should report the
208+
// full exit code, not just the low 8 bits. This behaviour is not
209+
// well-documented and while Darwin correctly reports the full value, Linux
210+
// does not (at least as of this writing) and other POSIX-like systems may
211+
// also have issues. This test serves as a canary when adding new platforms
212+
// that we need to document the difference.
213+
//
214+
// Windows does not have the 8-bit exit code restriction and always reports
215+
// the full CInt value back to the testing library.
216+
await #expect(exitsWith: .exitCode(512)) {
217+
exit(512)
218+
}
219+
}
220+
#endif
221+
222+
@Test("Exit condition matching operators (==, !=, ===, !==)")
223+
func exitConditionMatching() {
224+
#expect(ExitCondition.success == .success)
225+
#expect(ExitCondition.success === .success)
226+
#expect(ExitCondition.success == .exitCode(EXIT_SUCCESS))
227+
#expect(ExitCondition.success === .exitCode(EXIT_SUCCESS))
228+
#expect(ExitCondition.success != .exitCode(EXIT_FAILURE))
229+
#expect(ExitCondition.success !== .exitCode(EXIT_FAILURE))
230+
231+
#expect(ExitCondition.failure == .failure)
232+
#expect(ExitCondition.failure === .failure)
233+
234+
#expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) != .exitCode(EXIT_FAILURE))
235+
#expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) !== .exitCode(EXIT_FAILURE))
236+
237+
#if !os(Windows)
238+
#expect(ExitCondition.success != .exitCode(EXIT_FAILURE))
239+
#expect(ExitCondition.success !== .exitCode(EXIT_FAILURE))
240+
#expect(ExitCondition.success != .signal(SIGINT))
241+
#expect(ExitCondition.success !== .signal(SIGINT))
242+
#expect(ExitCondition.signal(SIGINT) == .signal(SIGINT))
243+
#expect(ExitCondition.signal(SIGINT) === .signal(SIGINT))
244+
#expect(ExitCondition.signal(SIGTERM) != .signal(SIGINT))
245+
#expect(ExitCondition.signal(SIGTERM) !== .signal(SIGINT))
246+
#endif
247+
}
200248
}
201249

202250
// MARK: - Fixtures

0 commit comments

Comments
 (0)