Skip to content

Commit e1388f3

Browse files
authored
Make the result of an exit test optional with #expect. (#779)
This PR changes the semantics of `#expect(exitsWith:)` such that it only returns a value on success, and returns `nil` on failure. This is consistent with general Swift semantics. This means that if you have an exit test that fails to exit with the expected exit condition, you won't get a value back, but that's intentional now because your test will have already failed. This makes it easier to reason about properties we add to the result structure in the future: they'll always be valid if you have an instance of the structure because you can't get a half-baked instance back by mistake. This change made me realize that our custom comparison operators on `ExitCondition` do not compare against `nil` correctly, so I've moved them to `Optional<ExitCondition>`. (I know @stmontgomery is interested in revisiting whether they should be operators at all, but let's leave that discussion for a separate PR or thread.) I also renamed the structure `ExitTestArtifacts` for two reasons: 1. To get it out from under `ExitTest` so that that type does not need to be API, and 2. To give it an even uglier name so we are forced to debate what to call it when the feature comes up for review. ### 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 88bdff3 commit e1388f3

File tree

9 files changed

+126
-168
lines changed

9 files changed

+126
-168
lines changed

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ add_library(Testing
2929
Events/TimeValue.swift
3030
ExitTests/ExitCondition.swift
3131
ExitTests/ExitTest.swift
32-
ExitTests/ExitTest.Result.swift
32+
ExitTests/ExitTestArtifacts.swift
3333
ExitTests/SpawnProcess.swift
3434
ExitTests/WaitFor.swift
3535
Expectations/Expectation.swift

Sources/Testing/ExitTests/ExitCondition.swift

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,26 @@
1010

1111
private import _TestingInternals
1212

13-
/// An enumeration describing possible conditions under which an exit test will
14-
/// succeed or fail.
13+
/// An enumeration describing possible conditions under which a process will
14+
/// exit.
1515
///
16-
/// Values of this type can be passed to
16+
/// Values of this type are used to describe the conditions under which an exit
17+
/// test is expected to pass or fail by passing them to
1718
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or
18-
/// ``require(exitsWith:_:sourceLocation:performing:)`` to configure which exit
19-
/// statuses should be considered successful.
19+
/// ``require(exitsWith:_:sourceLocation:performing:)``.
2020
@_spi(Experimental)
2121
#if SWT_NO_PROCESS_SPAWNING
2222
@available(*, unavailable, message: "Exit tests are not available on this platform.")
23-
#elseif SWT_NO_EXIT_TESTS
24-
@_spi(ForToolsIntegrationOnly)
2523
#endif
2624
public enum ExitCondition: Sendable {
2725
/// The process terminated successfully with status `EXIT_SUCCESS`.
28-
public static var success: Self { .exitCode(EXIT_SUCCESS) }
26+
public static var success: Self {
27+
// Strictly speaking, the C standard treats 0 as a successful exit code and
28+
// potentially distinct from EXIT_SUCCESS. To my knowledge, no modern
29+
// operating system defines EXIT_SUCCESS to any value other than 0, so the
30+
// distinction is academic.
31+
.exitCode(EXIT_SUCCESS)
32+
}
2933

3034
/// The process terminated abnormally with any status other than
3135
/// `EXIT_SUCCESS` or with any signal.
@@ -72,23 +76,23 @@ public enum ExitCondition: Sendable {
7276

7377
// MARK: - Equatable
7478

79+
@_spi(Experimental)
7580
#if SWT_NO_PROCESS_SPAWNING
7681
@available(*, unavailable, message: "Exit tests are not available on this platform.")
77-
#elseif SWT_NO_EXIT_TESTS
78-
@_spi(ForToolsIntegrationOnly)
7982
#endif
80-
extension ExitCondition {
81-
/// Check whether or not two values of this type are equal.
83+
extension Optional<ExitCondition> {
84+
/// Check whether or not two exit conditions are equal.
8285
///
8386
/// - Parameters:
8487
/// - lhs: One value to compare.
8588
/// - rhs: Another value to compare.
8689
///
8790
/// - Returns: Whether or not `lhs` and `rhs` are equal.
8891
///
89-
/// Two instances of this type can be compared; if either instance is equal to
90-
/// ``failure``, it will compare equal to any instance except ``success``. To
91-
/// check if two instances are exactly equal, use the ``===(_:_:)`` operator:
92+
/// Two exit conditions can be compared; if either instance is equal to
93+
/// ``ExitCondition/failure``, it will compare equal to any instance except
94+
/// ``ExitCondition/success``. To check if two instances are _exactly_ equal,
95+
/// use the ``===(_:_:)`` operator:
9296
///
9397
/// ```swift
9498
/// let lhs: ExitCondition = .failure
@@ -119,18 +123,18 @@ extension ExitCondition {
119123
#endif
120124
}
121125

122-
/// Check whether or not two values of this type are _not_ equal.
126+
/// Check whether or not two exit conditions are _not_ equal.
123127
///
124128
/// - Parameters:
125129
/// - lhs: One value to compare.
126130
/// - rhs: Another value to compare.
127131
///
128132
/// - Returns: Whether or not `lhs` and `rhs` are _not_ equal.
129133
///
130-
/// Two instances of this type can be compared; if either instance is equal to
131-
/// ``failure``, it will compare equal to any instance except ``success``. To
132-
/// check if two instances are not exactly equal, use the ``!==(_:_:)``
133-
/// operator:
134+
/// Two exit conditions can be compared; if either instance is equal to
135+
/// ``ExitCondition/failure``, it will compare equal to any instance except
136+
/// ``ExitCondition/success``. To check if two instances are not _exactly_
137+
/// equal, use the ``!==(_:_:)`` operator:
134138
///
135139
/// ```swift
136140
/// let lhs: ExitCondition = .failure
@@ -153,17 +157,18 @@ extension ExitCondition {
153157
#endif
154158
}
155159

156-
/// Check whether or not two values of this type are identical.
160+
/// Check whether or not two exit conditions are identical.
157161
///
158162
/// - Parameters:
159163
/// - lhs: One value to compare.
160164
/// - rhs: Another value to compare.
161165
///
162166
/// - Returns: Whether or not `lhs` and `rhs` are identical.
163167
///
164-
/// Two instances of this type can be compared; if either instance is equal to
165-
/// ``failure``, it will compare equal to any instance except ``success``. To
166-
/// check if two instances are exactly equal, use the ``===(_:_:)`` operator:
168+
/// Two exit conditions can be compared; if either instance is equal to
169+
/// ``ExitCondition/failure``, it will compare equal to any instance except
170+
/// ``ExitCondition/success``. To check if two instances are _exactly_ equal,
171+
/// use the ``===(_:_:)`` operator:
167172
///
168173
/// ```swift
169174
/// let lhs: ExitCondition = .failure
@@ -180,6 +185,8 @@ extension ExitCondition {
180185
/// For any values `a` and `b`, `a === b` implies that `a !== b` is `false`.
181186
public static func ===(lhs: Self, rhs: Self) -> Bool {
182187
return switch (lhs, rhs) {
188+
case (.none, .none):
189+
true
183190
case (.failure, .failure):
184191
true
185192
case let (.exitCode(lhs), .exitCode(rhs)):
@@ -191,18 +198,18 @@ extension ExitCondition {
191198
}
192199
}
193200

194-
/// Check whether or not two values of this type are _not_ identical.
201+
/// Check whether or not two exit conditions are _not_ identical.
195202
///
196203
/// - Parameters:
197204
/// - lhs: One value to compare.
198205
/// - rhs: Another value to compare.
199206
///
200207
/// - Returns: Whether or not `lhs` and `rhs` are _not_ identical.
201208
///
202-
/// Two instances of this type can be compared; if either instance is equal to
203-
/// ``failure``, it will compare equal to any instance except ``success``. To
204-
/// check if two instances are not exactly equal, use the ``!==(_:_:)``
205-
/// operator:
209+
/// Two exit conditions can be compared; if either instance is equal to
210+
/// ``ExitCondition/failure``, it will compare equal to any instance except
211+
/// ``ExitCondition/success``. To check if two instances are not _exactly_
212+
/// equal, use the ``!==(_:_:)`` operator:
206213
///
207214
/// ```swift
208215
/// let lhs: ExitCondition = .failure

Sources/Testing/ExitTests/ExitTest.Result.swift

Lines changed: 0 additions & 86 deletions
This file was deleted.

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,12 @@ private import _TestingInternals
2323
///
2424
/// Instances of this type describe an exit test defined by the test author and
2525
/// discovered or called at runtime.
26-
@_spi(Experimental)
26+
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
2727
#if SWT_NO_EXIT_TESTS
2828
@available(*, unavailable, message: "Exit tests are not available on this platform.")
2929
#endif
3030
public struct ExitTest: Sendable, ~Copyable {
3131
/// The expected exit condition of the exit test.
32-
@_spi(ForToolsIntegrationOnly)
3332
public var expectedExitCondition: ExitCondition
3433

3534
/// The body closure of the exit test.
@@ -39,7 +38,6 @@ public struct ExitTest: Sendable, ~Copyable {
3938
///
4039
/// The source location is unique to each exit test and is consistent between
4140
/// processes, so it can be used to uniquely identify an exit test at runtime.
42-
@_spi(ForToolsIntegrationOnly)
4341
public var sourceLocation: SourceLocation
4442
}
4543

@@ -97,7 +95,6 @@ extension ExitTest {
9795
/// to terminate the process; if it does not, the testing library will
9896
/// terminate the process in a way that causes the corresponding expectation
9997
/// to fail.
100-
@_spi(ForToolsIntegrationOnly)
10198
public consuming func callAsFunction() async -> Never {
10299
Self._disableCrashReporting()
103100

@@ -162,7 +159,6 @@ extension ExitTest {
162159
///
163160
/// - Returns: The specified exit test function, or `nil` if no such exit test
164161
/// could be found.
165-
@_spi(ForToolsIntegrationOnly)
166162
public static func find(at sourceLocation: SourceLocation) -> Self? {
167163
var result: Self?
168164

@@ -208,12 +204,12 @@ func callExitTest(
208204
isRequired: Bool,
209205
isolation: isolated (any Actor)? = #isolation,
210206
sourceLocation: SourceLocation
211-
) async -> ExitTest.Result {
207+
) async -> Result<ExitTestArtifacts, any Error> {
212208
guard let configuration = Configuration.current ?? Configuration.all.first else {
213209
preconditionFailure("A test must be running on the current task to use #expect(exitsWith:).")
214210
}
215211

216-
var result: ExitTest.Result
212+
var result: ExitTestArtifacts
217213
do {
218214
let exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
219215
result = try await configuration.exitTestHandler(exitTest)
@@ -247,29 +243,22 @@ func callExitTest(
247243
// For lack of a better way to handle an exit test failing in this way,
248244
// we record the system issue above, then let the expectation fail below by
249245
// reporting an exit condition that's the inverse of the expected one.
250-
result = ExitTest.Result(exitCondition: expectedExitCondition == .failure ? .success : .failure)
246+
result = ExitTestArtifacts(exitCondition: expectedExitCondition == .failure ? .success : .failure)
251247
}
252248

253249
// How did the exit test actually exit?
254250
let actualExitCondition = result.exitCondition
255251

256-
// Plumb the resulting exit condition through the general expectation
257-
// machinery. If the expectation failed, capture the ExpectationFailedError
258-
// instance so that calls to #require(exitsWith:) throw it correctly.
259-
let checkResult = __checkValue(
252+
// Plumb the exit test's result through the general expectation machinery.
253+
return __checkValue(
260254
expectedExitCondition == actualExitCondition,
261255
expression: expression,
262256
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualExitCondition),
263257
mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition),
264258
comments: comments(),
265259
isRequired: isRequired,
266260
sourceLocation: sourceLocation
267-
)
268-
if case let .failure(error) = checkResult {
269-
result.caughtError = error
270-
}
271-
272-
return result
261+
).map { result }
273262
}
274263

275264
// MARK: - SwiftPM/tools integration
@@ -297,8 +286,7 @@ extension ExitTest {
297286
/// are available or the child environment is otherwise terminated. The parent
298287
/// environment is then responsible for interpreting those results and
299288
/// recording any issues that occur.
300-
@_spi(ForToolsIntegrationOnly)
301-
public typealias Handler = @Sendable (_ exitTest: borrowing ExitTest) async throws -> ExitTest.Result
289+
public typealias Handler = @Sendable (_ exitTest: borrowing ExitTest) async throws -> ExitTestArtifacts
302290

303291
/// The back channel file handle set up by the parent process.
304292
///
@@ -477,7 +465,7 @@ extension ExitTest {
477465
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = String(decoding: json, as: UTF8.self)
478466
}
479467

480-
return try await withThrowingTaskGroup(of: ExitTest.Result?.self) { taskGroup in
468+
return try await withThrowingTaskGroup(of: ExitTestArtifacts?.self) { taskGroup in
481469
// Create a "back channel" pipe to handle events from the child process.
482470
let backChannel = try FileHandle.Pipe()
483471

@@ -513,7 +501,7 @@ extension ExitTest {
513501
// Await termination of the child process.
514502
taskGroup.addTask {
515503
let exitCondition = try await wait(for: processID)
516-
return ExitTest.Result(exitCondition: exitCondition)
504+
return ExitTestArtifacts(exitCondition: exitCondition)
517505
}
518506

519507
// Read back all data written to the back channel by the child process
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 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+
/// A type representing the result of an exit test after it has exited and
12+
/// returned control to the calling test function.
13+
///
14+
/// Both ``expect(exitsWith:_:sourceLocation:performing:)`` and
15+
/// ``require(exitsWith:_:sourceLocation:performing:)`` return instances of
16+
/// this type.
17+
///
18+
/// - Warning: The name of this type is still unstable and subject to change.
19+
@_spi(Experimental)
20+
#if SWT_NO_EXIT_TESTS
21+
@available(*, unavailable, message: "Exit tests are not available on this platform.")
22+
#endif
23+
public struct ExitTestArtifacts: Sendable {
24+
/// The exit condition the exit test exited with.
25+
///
26+
/// When the exit test passes, the value of this property is equal to the
27+
/// value of the `expectedExitCondition` argument passed to
28+
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or to
29+
/// ``require(exitsWith:_:sourceLocation:performing:)``. You can compare two
30+
/// instances of ``ExitCondition`` with ``/Swift/Optional/==(_:_:)``.
31+
public var exitCondition: ExitCondition
32+
33+
@_spi(ForToolsIntegrationOnly)
34+
public init(exitCondition: ExitCondition) {
35+
self.exitCondition = exitCondition
36+
}
37+
}

0 commit comments

Comments
 (0)