From e48d355c320c1850c5a5b09a49cd8e01543a39f2 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 7 Aug 2024 12:05:57 -0400 Subject: [PATCH 1/4] Fixes to exit tests. 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.) --- Sources/Testing/ExitTests/ExitCondition.swift | 72 +++++++++++++------ Sources/Testing/ExitTests/ExitTest.swift | 16 +++-- .../Expectations/Expectation+Macro.swift | 14 ++-- .../ExpectationChecking+Macro.swift | 4 +- Sources/TestingMacros/ConditionMacro.swift | 12 +++- Tests/TestingTests/ExitTestTests.swift | 39 ++++++++++ .../Support/FileHandleTests.swift | 12 +++- 7 files changed, 131 insertions(+), 38 deletions(-) diff --git a/Sources/Testing/ExitTests/ExitCondition.swift b/Sources/Testing/ExitTests/ExitCondition.swift index e3074740d..f21e289af 100644 --- a/Sources/Testing/ExitTests/ExitCondition.swift +++ b/Sources/Testing/ExitTests/ExitCondition.swift @@ -17,6 +17,17 @@ private import _TestingInternals /// ``expect(exitsWith:_:sourceLocation:performing:)`` or /// ``require(exitsWith:_:sourceLocation:performing:)`` to configure which exit /// statuses should be considered successful. +/// +/// Two instances of this type can be compared; if either instance is equal to +/// ``failure``, it will compare equal to any instance except ``success``. To +/// check if two instances are exactly equal, use the `===` operator: +/// +/// ```swift +/// let lhs: ExitCondition = .failure +/// let rhs: ExitCondition = .signal(SIGINT) +/// print(lhs == rhs) // prints "true" +/// print(lhs === rhs) // prints "false" +/// ``` @_spi(Experimental) #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") @@ -44,9 +55,9 @@ public enum ExitCondition: Sendable { /// | Linux | [``](https://sourceware.org/glibc/manual/latest/html_node/Exit-Status.html), `` | /// | Windows | [``](https://learn.microsoft.com/en-us/cpp/c-runtime-library/exit-success-exit-failure) | /// - /// On POSIX-like systems including macOS and Linux, only the low unsigned 8 - /// bits (0–255) of the exit code are reliably preserved and reported to - /// a parent process. + /// On macOS and Windows, the full exit code reported by the process is + /// yielded to the parent process. Linux and other POSIX-like systems may only + /// reliably report the low unsigned 8 bits (0–255) of the exit code. case exitCode(_ exitCode: CInt) /// The process terminated with the given signal. @@ -62,43 +73,60 @@ public enum ExitCondition: Sendable { /// | macOS | [``](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/signal.3.html) | /// | Linux | [``](https://sourceware.org/glibc/manual/latest/html_node/Standard-Signals.html) | /// | Windows | [``](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) | + /// + /// On Windows, by default, the C runtime will terminate a process with exit + /// code `-3` if a raised signal is not handled, exactly as if `exit(-3)` were + /// called. As a result, this case is unavailable on that platform. Developers + /// should use ``failure`` instead when testing signal handling on Windows. #if os(Windows) @available(*, unavailable, message: "On Windows, use .failure instead.") #endif case signal(_ signal: CInt) } -// MARK: - +// MARK: - Equatable, Hashable #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif -extension ExitCondition { - /// Check whether this instance matches another. - /// - /// - Parameters: - /// - other: The other instance to compare against. - /// - /// - Returns: Whether or not this instance is equal to, or at least covers, - /// the other instance. - func matches(_ other: ExitCondition) -> Bool { - return switch (self, other) { - case (.failure, .failure): - true +extension ExitCondition: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + return switch (lhs, rhs) { case let (.failure, .exitCode(exitCode)), let (.exitCode(exitCode), .failure): exitCode != EXIT_SUCCESS +#if !os(Windows) + case (.failure, .signal), (.signal, .failure): + // All terminating signals are considered failures. + true +#endif + default: + lhs === rhs + } + } + + public static func ===(lhs: Self, rhs: Self) -> Bool { + return switch (lhs, rhs) { + case (.failure, .failure): + true case let (.exitCode(lhs), .exitCode(rhs)): lhs == rhs #if !os(Windows) case let (.signal(lhs), .signal(rhs)): lhs == rhs - case (.signal, .failure), (.failure, .signal): - // All terminating signals are considered failures. - true - case (.signal, .exitCode), (.exitCode, .signal): - // Signals do not match exit codes. - false #endif + default: + false } } + + public static func !==(lhs: Self, rhs: Self) -> Bool { + !(lhs === rhs) + } +} + +@available(*, unavailable, message: "ExitCondition does not conform to Hashable.") +extension ExitCondition: Hashable { + public func hash(into hasher: inout Hasher) { + fatalError("Unsupported") + } } diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 798e95d0a..c99e0945c 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -21,7 +21,7 @@ public struct ExitTest: Sendable { public var expectedExitCondition: ExitCondition /// The body closure of the exit test. - fileprivate var body: @Sendable () async -> Void + fileprivate var body: @Sendable () async throws -> Void /// The source location of the exit test. /// @@ -37,12 +37,16 @@ public struct ExitTest: Sendable { /// terminate the process in a way that causes the corresponding expectation /// to fail. public func callAsFunction() async -> Never { - await body() + do { + try await body() + } catch { + _errorInMain(error) + } // Run some glue code that terminates the process with an exit condition // that does not match the expected one. If the exit test's body doesn't // terminate, we'll manually call exit() and cause the test to fail. - let expectingFailure = expectedExitCondition.matches(.failure) + let expectingFailure = expectedExitCondition == .failure exit(expectingFailure ? EXIT_SUCCESS : EXIT_FAILURE) } } @@ -63,7 +67,7 @@ public protocol __ExitTestContainer { static var __sourceLocation: SourceLocation { get } /// The body function of the exit test. - static var __body: @Sendable () async -> Void { get } + static var __body: @Sendable () async throws -> Void { get } } extension ExitTest { @@ -118,7 +122,7 @@ extension ExitTest { /// convention. func callExitTest( exitsWith expectedExitCondition: ExitCondition, - performing body: @escaping @Sendable () async -> Void, + performing body: @escaping @Sendable () async throws -> Void, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, @@ -150,7 +154,7 @@ func callExitTest( } return __checkValue( - expectedExitCondition.matches(actualExitCondition), + expectedExitCondition == actualExitCondition, expression: expression, expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualExitCondition), mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition), diff --git a/Sources/Testing/Expectations/Expectation+Macro.swift b/Sources/Testing/Expectations/Expectation+Macro.swift index dd4b8875d..c3cf787b0 100644 --- a/Sources/Testing/Expectations/Expectation+Macro.swift +++ b/Sources/Testing/Expectations/Expectation+Macro.swift @@ -440,7 +440,9 @@ public macro require( /// a clean environment for execution, it is not called within the context of /// the original test. If `expression` does not terminate the child process, the /// process is terminated automatically as if the main function of the child -/// process were allowed to return naturally. +/// process were allowed to return naturally. If an error is thrown from +/// `expression`, it is handed as if the error were thrown from `main()` and the +/// process is terminated. /// /// Once the child process terminates, the parent process resumes and compares /// its exit status against `exitCondition`. If they match, the exit test has @@ -488,8 +490,8 @@ public macro require( /// issues should be attributed. /// - expression: The expression to be evaluated. /// -/// - Throws: An instance of ``ExpectationFailedError`` if `condition` evaluates -/// to `false`. +/// - Throws: An instance of ``ExpectationFailedError`` if the exit condition of +/// the child process does not equal `expectedExitCondition`. /// /// Use this overload of `#require()` when an expression will cause the current /// process to terminate and the nature of that termination will determine if @@ -515,7 +517,9 @@ public macro require( /// a clean environment for execution, it is not called within the context of /// the original test. If `expression` does not terminate the child process, the /// process is terminated automatically as if the main function of the child -/// process were allowed to return naturally. +/// process were allowed to return naturally. If an error is thrown from +/// `expression`, it is handed as if the error were thrown from `main()` and the +/// process is terminated. /// /// Once the child process terminates, the parent process resumes and compares /// its exit status against `exitCondition`. If they match, the exit test has @@ -550,5 +554,5 @@ public macro require( exitsWith expectedExitCondition: ExitCondition, _ comment: @autoclosure () -> Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation, - performing expression: @convention(thin) () async -> Void + performing expression: @convention(thin) () async throws -> Void ) = #externalMacro(module: "TestingMacros", type: "ExitTestRequireMacro") diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index 2a3e137a3..edc6fac04 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1103,7 +1103,7 @@ public func __checkClosureCall( @_spi(Experimental) public func __checkClosureCall( exitsWith expectedExitCondition: ExitCondition, - performing body: @convention(thin) () async -> Void, + performing body: @convention(thin) () async throws -> Void, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, @@ -1111,7 +1111,7 @@ public func __checkClosureCall( ) async -> Result { await callExitTest( exitsWith: expectedExitCondition, - performing: { await body() }, + performing: { try await body() }, expression: expression, comments: comments(), isRequired: isRequired, diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index 690489702..2ee461017 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -362,7 +362,7 @@ extension ExitTestConditionMacro { static var __sourceLocation: Testing.SourceLocation { \(createSourceLocationExpr(of: macro, context: context)) } - static var __body: @Sendable () async -> Void { + static var __body: @Sendable () async throws -> Void { \(bodyArgumentExpr.trimmed) } static var __expectedExitCondition: Testing.ExitCondition { @@ -370,7 +370,15 @@ extension ExitTestConditionMacro { } } """ - arguments[trailingClosureIndex].expression = "{ \(enumDecl) }" + + // Explicitly include a closure signature to work around a compiler bug + // type-checking thin throwing functions after macro expansion. + // SEE: rdar://133979438 + arguments[trailingClosureIndex].expression = """ + { () async throws in + \(enumDecl) + } + """ // Replace the exit test body (as an argument to the macro) with a stub // closure that hosts the type we created above. diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index 55c22a9bb..111a5163e 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -32,6 +32,9 @@ private import _TestingInternals await Task.yield() exit(123) } + await #expect(exitsWith: .failure) { + throw MyError() + } #if !os(Windows) await #expect(exitsWith: .signal(SIGKILL)) { _ = kill(getpid(), SIGKILL) @@ -197,6 +200,42 @@ private import _TestingInternals }.run(configuration: configuration) } } + +#if !os(Linux) + @Test("Exit test reports > 8 bits of the exit code") + func fullWidthExitCode() async { + // On macOS and Linux, we use waitid() which per POSIX should report the + // full exit code, not just the low 8 bits. This behaviour is not + // well-documented and while Darwin correctly reports the full value, Linux + // does not (at least as of this writing) and other POSIX-like systems may + // also have issues. This test serves as a canary when adding new platforms + // that we need to document the difference. + // + // Windows does not have the 8-bit exit code restriction and always reports + // the full CInt value back to the testing library. + await #expect(exitsWith: .exitCode(512)) { + exit(512) + } + } +#endif + + @Test("Exit condition exact matching (===)") + func exitConditionMatching() { + #expect(ExitCondition.success === .success) + #expect(ExitCondition.success === .exitCode(EXIT_SUCCESS)) + #expect(ExitCondition.success !== .exitCode(EXIT_FAILURE)) + + #expect(ExitCondition.failure === .failure) + + #expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) !== .exitCode(EXIT_FAILURE)) + +#if !os(Windows) + #expect(ExitCondition.success !== .exitCode(EXIT_FAILURE)) + #expect(ExitCondition.success !== .signal(SIGINT)) + #expect(ExitCondition.signal(SIGINT) === .signal(SIGINT)) + #expect(ExitCondition.signal(SIGTERM) !== .signal(SIGINT)) +#endif + } } // MARK: - Fixtures diff --git a/Tests/TestingTests/Support/FileHandleTests.swift b/Tests/TestingTests/Support/FileHandleTests.swift index a8d6e8285..89a3d246f 100644 --- a/Tests/TestingTests/Support/FileHandleTests.swift +++ b/Tests/TestingTests/Support/FileHandleTests.swift @@ -8,7 +8,7 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@testable import Testing +@testable @_spi(Experimental) import Testing private import _TestingInternals #if !SWT_NO_FILE_IO @@ -63,6 +63,16 @@ struct FileHandleTests { } } +#if !SWT_NO_EXIT_TESTS + @Test("Writing requires contiguous storage") + func writeIsContiguous() async { + await #expect(exitsWith: .failure) { + let fileHandle = try FileHandle.null(mode: "wb") + try fileHandle.write([1, 2, 3, 4, 5].lazy.filter { $0 == 1 }) + } + } +#endif + @Test("Can read from a file") func canRead() throws { let bytes: [UInt8] = (0 ..< 8192).map { _ in From 6faa184e2ed76a8b0249d5df4205650468193956 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 15 Aug 2024 19:37:07 -0400 Subject: [PATCH 2/4] Add markup for === and !== --- Sources/Testing/ExitTests/ExitCondition.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Sources/Testing/ExitTests/ExitCondition.swift b/Sources/Testing/ExitTests/ExitCondition.swift index f21e289af..f65b7fe75 100644 --- a/Sources/Testing/ExitTests/ExitCondition.swift +++ b/Sources/Testing/ExitTests/ExitCondition.swift @@ -104,6 +104,20 @@ extension ExitCondition: Equatable { } } + /// Check whether or not two values of this type are identical. + /// + /// - Parameters: + /// - lhs: One value to compare. + /// - rhs: Another value to compare. + /// + /// - Returns: Whether or not `lhs` and `rhs` are identical. + /// + /// This operator differs from [`==(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/==(_:_:)-3axv1) + /// in that ``failure`` will only compare equal to itself using this operator, + /// but will compare equal to any value except ``success`` when using + /// [`==(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/==(_:_:)-3axv1). + /// + /// For any values `a` and `b`, `a === b` implies that `a !== b` is false. public static func ===(lhs: Self, rhs: Self) -> Bool { return switch (lhs, rhs) { case (.failure, .failure): @@ -119,6 +133,20 @@ extension ExitCondition: Equatable { } } + /// Check whether or not two values of this type are _not_ identical. + /// + /// - Parameters: + /// - lhs: One value to compare. + /// - rhs: Another value to compare. + /// + /// - Returns: Whether or not `lhs` and `rhs` are _not_ identical. + /// + /// This operator differs from [`!=(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/!=(_:_:)) + /// in that ``failure`` will only compare equal to itself using this operator, + /// but will compare equal to any value except ``success`` when using + /// [`!=(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/!=(_:_:)). + /// + /// For any values `a` and `b`, `a === b` implies that `a !== b` is false. public static func !==(lhs: Self, rhs: Self) -> Bool { !(lhs === rhs) } From 50b0b2534a5bb2f52cab261523e9c413347a58e5 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Fri, 16 Aug 2024 15:38:21 -0400 Subject: [PATCH 3/4] Can't suppress Hashable conformance, so just note why we didn't add it --- Sources/Testing/ExitTests/ExitCondition.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/Testing/ExitTests/ExitCondition.swift b/Sources/Testing/ExitTests/ExitCondition.swift index f65b7fe75..58cadddd0 100644 --- a/Sources/Testing/ExitTests/ExitCondition.swift +++ b/Sources/Testing/ExitTests/ExitCondition.swift @@ -84,7 +84,7 @@ public enum ExitCondition: Sendable { case signal(_ signal: CInt) } -// MARK: - Equatable, Hashable +// MARK: - Equatable #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") @@ -152,9 +152,9 @@ extension ExitCondition: Equatable { } } -@available(*, unavailable, message: "ExitCondition does not conform to Hashable.") -extension ExitCondition: Hashable { - public func hash(into hasher: inout Hasher) { - fatalError("Unsupported") - } -} +// MARK: - Hashable + +// Because .failure is fuzzy-matched, the hash of an exit condition cannot +// distinguish failure cases without violating Hashable's contract. Hence, the +// only thing we can hash is whether or not it's a failure. That's a terrible +// hash function, so we have intentionally omitted Hashable conformance. From 7f67fb62e97a276926be14584c5be8951a6af22e Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 19 Aug 2024 10:11:39 -0400 Subject: [PATCH 4/4] Cannot conform to Equatable either --- Sources/Testing/ExitTests/ExitCondition.swift | 118 +++++++++++++----- Tests/TestingTests/ExitTestTests.swift | 11 +- 2 files changed, 99 insertions(+), 30 deletions(-) diff --git a/Sources/Testing/ExitTests/ExitCondition.swift b/Sources/Testing/ExitTests/ExitCondition.swift index 58cadddd0..205f7d515 100644 --- a/Sources/Testing/ExitTests/ExitCondition.swift +++ b/Sources/Testing/ExitTests/ExitCondition.swift @@ -17,17 +17,6 @@ private import _TestingInternals /// ``expect(exitsWith:_:sourceLocation:performing:)`` or /// ``require(exitsWith:_:sourceLocation:performing:)`` to configure which exit /// statuses should be considered successful. -/// -/// Two instances of this type can be compared; if either instance is equal to -/// ``failure``, it will compare equal to any instance except ``success``. To -/// check if two instances are exactly equal, use the `===` operator: -/// -/// ```swift -/// let lhs: ExitCondition = .failure -/// let rhs: ExitCondition = .signal(SIGINT) -/// print(lhs == rhs) // prints "true" -/// print(lhs === rhs) // prints "false" -/// ``` @_spi(Experimental) #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") @@ -89,7 +78,32 @@ public enum ExitCondition: Sendable { #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif -extension ExitCondition: Equatable { +extension ExitCondition { + /// Check whether or not two values of this type are equal. + /// + /// - Parameters: + /// - lhs: One value to compare. + /// - rhs: Another value to compare. + /// + /// - Returns: Whether or not `lhs` and `rhs` are equal. + /// + /// Two instances of this type can be compared; if either instance is equal to + /// ``failure``, it will compare equal to any instance except ``success``. To + /// check if two instances are exactly equal, use the ``===(_:_:)`` operator: + /// + /// ```swift + /// let lhs: ExitCondition = .failure + /// let rhs: ExitCondition = .signal(SIGINT) + /// print(lhs == rhs) // prints "true" + /// print(lhs === rhs) // prints "false" + /// ``` + /// + /// This special behavior means that the ``==(_:_:)`` operator is not + /// transitive, and does not satisfy the requirements of + /// [`Equatable`](https://developer.apple.com/documentation/swift/equatable) + /// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable). + /// + /// For any values `a` and `b`, `a == b` implies that `a != b` is `false`. public static func ==(lhs: Self, rhs: Self) -> Bool { return switch (lhs, rhs) { case let (.failure, .exitCode(exitCode)), let (.exitCode(exitCode), .failure): @@ -104,6 +118,36 @@ extension ExitCondition: Equatable { } } + /// Check whether or not two values of this type are _not_ equal. + /// + /// - Parameters: + /// - lhs: One value to compare. + /// - rhs: Another value to compare. + /// + /// - Returns: Whether or not `lhs` and `rhs` are _not_ equal. + /// + /// Two instances of this type can be compared; if either instance is equal to + /// ``failure``, it will compare equal to any instance except ``success``. To + /// check if two instances are not exactly equal, use the ``!==(_:_:)`` + /// operator: + /// + /// ```swift + /// let lhs: ExitCondition = .failure + /// let rhs: ExitCondition = .signal(SIGINT) + /// print(lhs != rhs) // prints "false" + /// print(lhs !== rhs) // prints "true" + /// ``` + /// + /// This special behavior means that the ``!=(_:_:)`` operator is not + /// transitive, and does not satisfy the requirements of + /// [`Equatable`](https://developer.apple.com/documentation/swift/equatable) + /// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable). + /// + /// For any values `a` and `b`, `a == b` implies that `a != b` is `false`. + public static func !=(lhs: Self, rhs: Self) -> Bool { + !(lhs == rhs) + } + /// Check whether or not two values of this type are identical. /// /// - Parameters: @@ -112,12 +156,23 @@ extension ExitCondition: Equatable { /// /// - Returns: Whether or not `lhs` and `rhs` are identical. /// - /// This operator differs from [`==(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/==(_:_:)-3axv1) - /// in that ``failure`` will only compare equal to itself using this operator, - /// but will compare equal to any value except ``success`` when using - /// [`==(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/==(_:_:)-3axv1). + /// Two instances of this type can be compared; if either instance is equal to + /// ``failure``, it will compare equal to any instance except ``success``. To + /// check if two instances are exactly equal, use the ``===(_:_:)`` operator: + /// + /// ```swift + /// let lhs: ExitCondition = .failure + /// let rhs: ExitCondition = .signal(SIGINT) + /// print(lhs == rhs) // prints "true" + /// print(lhs === rhs) // prints "false" + /// ``` /// - /// For any values `a` and `b`, `a === b` implies that `a !== b` is false. + /// This special behavior means that the ``==(_:_:)`` operator is not + /// transitive, and does not satisfy the requirements of + /// [`Equatable`](https://developer.apple.com/documentation/swift/equatable) + /// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable). + /// + /// For any values `a` and `b`, `a === b` implies that `a !== b` is `false`. public static func ===(lhs: Self, rhs: Self) -> Bool { return switch (lhs, rhs) { case (.failure, .failure): @@ -141,20 +196,25 @@ extension ExitCondition: Equatable { /// /// - Returns: Whether or not `lhs` and `rhs` are _not_ identical. /// - /// This operator differs from [`!=(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/!=(_:_:)) - /// in that ``failure`` will only compare equal to itself using this operator, - /// but will compare equal to any value except ``success`` when using - /// [`!=(lhs:rhs:)`](https://developer.apple.com/documentation/swift/equatable/!=(_:_:)). + /// Two instances of this type can be compared; if either instance is equal to + /// ``failure``, it will compare equal to any instance except ``success``. To + /// check if two instances are not exactly equal, use the ``!==(_:_:)`` + /// operator: + /// + /// ```swift + /// let lhs: ExitCondition = .failure + /// let rhs: ExitCondition = .signal(SIGINT) + /// print(lhs != rhs) // prints "false" + /// print(lhs !== rhs) // prints "true" + /// ``` /// - /// For any values `a` and `b`, `a === b` implies that `a !== b` is false. + /// This special behavior means that the ``!=(_:_:)`` operator is not + /// transitive, and does not satisfy the requirements of + /// [`Equatable`](https://developer.apple.com/documentation/swift/equatable) + /// or [`Hashable`](https://developer.apple.com/documentation/swift/hashable). + /// + /// For any values `a` and `b`, `a === b` implies that `a !== b` is `false`. public static func !==(lhs: Self, rhs: Self) -> Bool { !(lhs === rhs) } } - -// MARK: - Hashable - -// Because .failure is fuzzy-matched, the hash of an exit condition cannot -// distinguish failure cases without violating Hashable's contract. Hence, the -// only thing we can hash is whether or not it's a failure. That's a terrible -// hash function, so we have intentionally omitted Hashable conformance. diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index 111a5163e..fad1da180 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -219,20 +219,29 @@ private import _TestingInternals } #endif - @Test("Exit condition exact matching (===)") + @Test("Exit condition matching operators (==, !=, ===, !==)") func exitConditionMatching() { + #expect(ExitCondition.success == .success) #expect(ExitCondition.success === .success) + #expect(ExitCondition.success == .exitCode(EXIT_SUCCESS)) #expect(ExitCondition.success === .exitCode(EXIT_SUCCESS)) + #expect(ExitCondition.success != .exitCode(EXIT_FAILURE)) #expect(ExitCondition.success !== .exitCode(EXIT_FAILURE)) + #expect(ExitCondition.failure == .failure) #expect(ExitCondition.failure === .failure) + #expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) != .exitCode(EXIT_FAILURE)) #expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) !== .exitCode(EXIT_FAILURE)) #if !os(Windows) + #expect(ExitCondition.success != .exitCode(EXIT_FAILURE)) #expect(ExitCondition.success !== .exitCode(EXIT_FAILURE)) + #expect(ExitCondition.success != .signal(SIGINT)) #expect(ExitCondition.success !== .signal(SIGINT)) + #expect(ExitCondition.signal(SIGINT) == .signal(SIGINT)) #expect(ExitCondition.signal(SIGINT) === .signal(SIGINT)) + #expect(ExitCondition.signal(SIGTERM) != .signal(SIGINT)) #expect(ExitCondition.signal(SIGTERM) !== .signal(SIGINT)) #endif }