diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index b4e865427..9c480912c 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -36,7 +36,7 @@ add_library(Testing ExitTests/ExitTest.Condition.swift ExitTests/ExitTest.Result.swift ExitTests/SpawnProcess.swift - ExitTests/StatusAtExit.swift + ExitTests/ExitStatus.swift ExitTests/WaitFor.swift Expectations/Expectation.swift Expectations/Expectation+Macro.swift diff --git a/Sources/Testing/ExitTests/StatusAtExit.swift b/Sources/Testing/ExitTests/ExitStatus.swift similarity index 63% rename from Sources/Testing/ExitTests/StatusAtExit.swift rename to Sources/Testing/ExitTests/ExitStatus.swift index ea5e287c7..afa47d8d6 100644 --- a/Sources/Testing/ExitTests/StatusAtExit.swift +++ b/Sources/Testing/ExitTests/ExitStatus.swift @@ -10,7 +10,7 @@ private import _TestingInternals -/// An enumeration describing possible status a process will yield on exit. +/// An enumeration describing possible status a process will report on exit. /// /// You can convert an instance of this type to an instance of /// ``ExitTest/Condition`` using ``ExitTest/Condition/init(_:)``. That value @@ -18,66 +18,86 @@ private import _TestingInternals /// expected to pass or fail by passing it to /// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or /// ``require(exitsWith:observing:_:sourceLocation:performing:)``. -@_spi(Experimental) +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } #if SWT_NO_PROCESS_SPAWNING @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif -public enum StatusAtExit: Sendable { - /// The process terminated with the given exit code. +public enum ExitStatus: Sendable { + /// The process exited with the given exit code. /// /// - Parameters: - /// - exitCode: The exit code yielded by the process. + /// - exitCode: The exit code reported by the process. /// - /// The C programming language defines two [standard exit codes](https://en.cppreference.com/w/c/program/EXIT_status), - /// `EXIT_SUCCESS` and `EXIT_FAILURE`. Platforms may additionally define their - /// own non-standard exit codes: + /// The C programming language defines two standard exit codes, `EXIT_SUCCESS` + /// and `EXIT_FAILURE`. Platforms may additionally define their own + /// non-standard exit codes: /// /// | Platform | Header | /// |-|-| /// | macOS | [``](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/_Exit.3.html), [``](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/sysexits.3.html) | - /// | Linux | [``](https://sourceware.org/glibc/manual/latest/html_node/Exit-Status.html), `` | + /// | Linux | [``](https://www.kernel.org/doc/man-pages/online/pages/man3/exit.3.html), [``](https://www.kernel.org/doc/man-pages/online/pages/man3/sysexits.h.3head.html) | /// | FreeBSD | [``](https://man.freebsd.org/cgi/man.cgi?exit(3)), [``](https://man.freebsd.org/cgi/man.cgi?sysexits(3)) | /// | OpenBSD | [``](https://man.openbsd.org/exit.3), [``](https://man.openbsd.org/sysexits.3) | /// | Windows | [``](https://learn.microsoft.com/en-us/cpp/c-runtime-library/exit-success-exit-failure) | /// + /// @Comment { + /// See https://en.cppreference.com/w/c/program/EXIT_status for more + /// information about exit codes defined by the C standard. + /// } + /// /// On macOS, FreeBSD, OpenBSD, and Windows, the full exit code reported by - /// the process is yielded to the parent process. Linux and other POSIX-like + /// the process is reported 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. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } case exitCode(_ exitCode: CInt) - /// The process terminated with the given signal. + /// The process exited with the given signal. /// /// - Parameters: - /// - signal: The signal that terminated the process. + /// - signal: The signal that caused the process to exit. /// - /// The C programming language defines a number of [standard signals](https://en.cppreference.com/w/c/program/SIG_types). - /// Platforms may additionally define their own non-standard signal codes: + /// The C programming language defines a number of standard signals. Platforms + /// may additionally define their own non-standard signal codes: /// /// | Platform | Header | /// |-|-| /// | 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) | + /// | Linux | [``](https://www.kernel.org/doc/man-pages/online/pages/man7/signal.7.html) | /// | FreeBSD | [``](https://man.freebsd.org/cgi/man.cgi?signal(3)) | /// | OpenBSD | [``](https://man.openbsd.org/signal.3) | /// | Windows | [``](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) | + /// + /// @Comment { + /// See https://en.cppreference.com/w/c/program/SIG_types for more + /// information about signals defined by the C standard. + /// } + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } case signal(_ signal: CInt) } // MARK: - Equatable -@_spi(Experimental) #if SWT_NO_PROCESS_SPAWNING @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif -extension StatusAtExit: Equatable {} +extension ExitStatus: Equatable {} // MARK: - CustomStringConvertible @_spi(Experimental) #if SWT_NO_PROCESS_SPAWNING @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif -extension StatusAtExit: CustomStringConvertible { +extension ExitStatus: CustomStringConvertible { public var description: String { switch self { case let .exitCode(exitCode): diff --git a/Sources/Testing/ExitTests/ExitTest.Condition.swift b/Sources/Testing/ExitTests/ExitTest.Condition.swift index d2c637d79..ac2f10e7d 100644 --- a/Sources/Testing/ExitTests/ExitTest.Condition.swift +++ b/Sources/Testing/ExitTests/ExitTest.Condition.swift @@ -10,7 +10,6 @@ private import _TestingInternals -@_spi(Experimental) #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif @@ -21,11 +20,27 @@ extension ExitTest { /// exit test is expected to pass or fail by passing them to /// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or /// ``require(exitsWith:observing:_:sourceLocation:performing:)``. + /// + /// ## Topics + /// + /// ### Successful exit conditions + /// + /// - ``success`` + /// + /// ### Failing exit conditions + /// + /// - ``failure`` + /// - ``exitCode(_:)`` + /// - ``signal(_:)`` + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public struct Condition: Sendable { /// An enumeration describing the possible conditions for an exit test. private enum _Kind: Sendable, Equatable { /// The exit test must exit with a particular exit status. - case statusAtExit(StatusAtExit) + case exitStatus(ExitStatus) /// The exit test must exit successfully. case success @@ -41,49 +56,71 @@ extension ExitTest { // MARK: - -@_spi(Experimental) #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension ExitTest.Condition { - /// A condition that matches when a process terminates successfully with exit - /// code `EXIT_SUCCESS`. + /// A condition that matches when a process exits normally. + /// + /// This condition matches the exit code `EXIT_SUCCESS`. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public static var success: Self { Self(_kind: .success) } - /// A condition that matches when a process terminates abnormally with any - /// exit code other than `EXIT_SUCCESS` or with any signal. + /// A condition that matches when a process exits abnormally + /// + /// This condition matches any exit code other than `EXIT_SUCCESS` or any + /// signal that causes the process to exit. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public static var failure: Self { Self(_kind: .failure) } - public init(_ statusAtExit: StatusAtExit) { - self.init(_kind: .statusAtExit(statusAtExit)) + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + public init(_ exitStatus: ExitStatus) { + self.init(_kind: .exitStatus(exitStatus)) } /// Creates a condition that matches when a process terminates with a given /// exit code. /// /// - Parameters: - /// - exitCode: The exit code yielded by the process. + /// - exitCode: The exit code reported by the process. /// - /// The C programming language defines two [standard exit codes](https://en.cppreference.com/w/c/program/EXIT_status), - /// `EXIT_SUCCESS` and `EXIT_FAILURE`. Platforms may additionally define their - /// own non-standard exit codes: + /// The C programming language defines two standard exit codes, `EXIT_SUCCESS` + /// and `EXIT_FAILURE`. Platforms may additionally define their own + /// non-standard exit codes: /// /// | Platform | Header | /// |-|-| /// | macOS | [``](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/_Exit.3.html), [``](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/sysexits.3.html) | - /// | Linux | [``](https://sourceware.org/glibc/manual/latest/html_node/Exit-Status.html), `` | + /// | Linux | [``](https://www.kernel.org/doc/man-pages/online/pages/man3/exit.3.html), [``](https://www.kernel.org/doc/man-pages/online/pages/man3/sysexits.h.3head.html) | /// | FreeBSD | [``](https://man.freebsd.org/cgi/man.cgi?exit(3)), [``](https://man.freebsd.org/cgi/man.cgi?sysexits(3)) | /// | OpenBSD | [``](https://man.openbsd.org/exit.3), [``](https://man.openbsd.org/sysexits.3) | /// | Windows | [``](https://learn.microsoft.com/en-us/cpp/c-runtime-library/exit-success-exit-failure) | /// + /// @Comment { + /// See https://en.cppreference.com/w/c/program/EXIT_status for more + /// information about exit codes defined by the C standard. + /// } + /// /// On macOS, FreeBSD, OpenBSD, and Windows, the full exit code reported by - /// the process is yielded to the parent process. Linux and other POSIX-like + /// the process is reported 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. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public static func exitCode(_ exitCode: CInt) -> Self { #if !SWT_NO_EXIT_TESTS Self(.exitCode(exitCode)) @@ -92,22 +129,30 @@ extension ExitTest.Condition { #endif } - /// Creates a condition that matches when a process terminates with a given - /// signal. + /// Creates a condition that matches when a process exits with a given signal. /// /// - Parameters: - /// - signal: The signal that terminated the process. + /// - signal: The signal that caused the process to exit. /// - /// The C programming language defines a number of [standard signals](https://en.cppreference.com/w/c/program/SIG_types). - /// Platforms may additionally define their own non-standard signal codes: + /// The C programming language defines a number of standard signals. Platforms + /// may additionally define their own non-standard signal codes: /// /// | Platform | Header | /// |-|-| /// | 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) | + /// | Linux | [``](https://www.kernel.org/doc/man-pages/online/pages/man7/signal.7.html) | /// | FreeBSD | [``](https://man.freebsd.org/cgi/man.cgi?signal(3)) | /// | OpenBSD | [``](https://man.openbsd.org/signal.3) | /// | Windows | [``](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) | + /// + /// @Comment { + /// See https://en.cppreference.com/w/c/program/SIG_types for more + /// information about signals defined by the C standard. + /// } + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public static func signal(_ signal: CInt) -> Self { #if !SWT_NO_EXIT_TESTS Self(.signal(signal)) @@ -131,8 +176,8 @@ extension ExitTest.Condition: CustomStringConvertible { ".failure" case .success: ".success" - case let .statusAtExit(statusAtExit): - String(describing: statusAtExit) + case let .exitStatus(exitStatus): + String(describing: exitStatus) } #else fatalError("Unsupported") @@ -149,19 +194,19 @@ extension ExitTest.Condition { /// Check whether or not an exit test condition matches a given exit status. /// /// - Parameters: - /// - statusAtExit: An exit status to compare against. + /// - exitStatus: An exit status to compare against. /// - /// - Returns: Whether or not `self` and `statusAtExit` represent the same - /// exit condition. + /// - Returns: Whether or not `self` and `exitStatus` represent the same exit + /// condition. /// /// Two exit test conditions can be compared; if either instance is equal to /// ``failure``, it will compare equal to any instance except ``success``. - func isApproximatelyEqual(to statusAtExit: StatusAtExit) -> Bool { + func isApproximatelyEqual(to exitStatus: ExitStatus) -> Bool { // Strictly speaking, the C standard treats 0 as a successful exit code and // potentially distinct from EXIT_SUCCESS. To my knowledge, no modern // operating system defines EXIT_SUCCESS to any value other than 0, so the // distinction is academic. - return switch (self._kind, statusAtExit) { + return switch (self._kind, exitStatus) { case let (.success, .exitCode(exitCode)): exitCode == EXIT_SUCCESS case let (.failure, .exitCode(exitCode)): @@ -170,7 +215,7 @@ extension ExitTest.Condition { // All terminating signals are considered failures. true default: - self._kind == .statusAtExit(statusAtExit) + self._kind == .exitStatus(exitStatus) } } } diff --git a/Sources/Testing/ExitTests/ExitTest.Result.swift b/Sources/Testing/ExitTests/ExitTest.Result.swift index beb2d56fc..046ba5207 100644 --- a/Sources/Testing/ExitTests/ExitTest.Result.swift +++ b/Sources/Testing/ExitTests/ExitTest.Result.swift @@ -8,7 +8,6 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@_spi(Experimental) #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif @@ -19,12 +18,17 @@ extension ExitTest { /// Both ``expect(exitsWith:observing:_:sourceLocation:performing:)`` and /// ``require(exitsWith:observing:_:sourceLocation:performing:)`` return /// instances of this type. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public struct Result: Sendable { - /// The exit condition the exit test exited with. + /// The exit status reported by the process hosting the exit test. /// - /// When the exit test passes, the value of this property is equal to the - /// exit status reported by the process that hosted the exit test. - public var statusAtExit: StatusAtExit + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + public var exitStatus: ExitStatus /// All bytes written to the standard output stream of the exit test before /// it exited. @@ -50,6 +54,10 @@ extension ExitTest { /// /// If you did not request standard output content when running an exit /// test, the value of this property is the empty array. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public var standardOutputContent: [UInt8] = [] /// All bytes written to the standard error stream of the exit test before @@ -76,11 +84,15 @@ extension ExitTest { /// /// If you did not request standard error content when running an exit test, /// the value of this property is the empty array. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public var standardErrorContent: [UInt8] = [] @_spi(ForToolsIntegrationOnly) - public init(statusAtExit: StatusAtExit) { - self.statusAtExit = statusAtExit + public init(exitStatus: ExitStatus) { + self.exitStatus = exitStatus } } } diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 14d10a04b..fd3c37ee0 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -26,10 +26,13 @@ private import _TestingInternals /// A type describing an exit test. /// /// Instances of this type describe exit tests you create using the -/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` +/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or /// ``require(exitsWith:observing:_:sourceLocation:performing:)`` macro. You /// don't usually need to interact directly with an instance of this type. -@_spi(Experimental) +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif @@ -95,7 +98,7 @@ public struct ExitTest: Sendable, ~Copyable { /// this property to determine what information you need to preserve from your /// child process. /// - /// The value of this property always includes ``ExitTest/Result/statusAtExit`` + /// The value of this property always includes ``ExitTest/Result/exitStatus`` /// even if the test author does not specify it. /// /// Within a child process running an exit test, the value of this property is @@ -104,8 +107,8 @@ public struct ExitTest: Sendable, ~Copyable { public var observedValues: [any PartialKeyPath & Sendable] { get { var result = _observedValues - if !result.contains(\.statusAtExit) { // O(n), but n <= 3 (no Set needed) - result.append(\.statusAtExit) + if !result.contains(\.exitStatus) { // O(n), but n <= 3 (no Set needed) + result.append(\.exitStatus) } return result } @@ -144,7 +147,6 @@ public struct ExitTest: Sendable, ~Copyable { #if !SWT_NO_EXIT_TESTS // MARK: - Current -@_spi(Experimental) extension ExitTest { /// Storage for ``current``. /// @@ -165,6 +167,10 @@ extension ExitTest { /// /// The value of this property is constant across all tasks in the current /// process. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public static var current: ExitTest? { _read { // NOTE: Even though this accessor is `_read` and has borrowing semantics, @@ -180,7 +186,7 @@ extension ExitTest { // MARK: - Invocation -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +@_spi(ForToolsIntegrationOnly) extension ExitTest { /// Disable crash reporting, crash logging, or core dumps for the current /// process. @@ -356,7 +362,7 @@ extension ExitTest { } } -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +@_spi(ForToolsIntegrationOnly) extension ExitTest { /// Find the exit test function at the given source location. /// @@ -396,7 +402,7 @@ extension ExitTest { /// - expectedExitCondition: The expected exit condition. /// - observedValues: An array of key paths representing results from within /// the exit test that should be observed and returned by this macro. The -/// ``ExitTest/Result/statusAtExit`` property is always returned. +/// ``ExitTest/Result/exitStatus`` property is always returned. /// - expression: The expression, corresponding to `condition`, that is being /// evaluated (if available at compile time.) /// - comments: An array of comments describing the expectation. This array @@ -438,8 +444,8 @@ func callExitTest( #if os(Windows) // For an explanation of this magic, see the corresponding logic in // ExitTest.callAsFunction(). - if case let .exitCode(exitCode) = result.statusAtExit, (exitCode & ~STATUS_CODE_MASK) == STATUS_SIGNAL_CAUGHT_BITS { - result.statusAtExit = .signal(exitCode & STATUS_CODE_MASK) + if case let .exitCode(exitCode) = result.exitStatus, (exitCode & ~STATUS_CODE_MASK) == STATUS_SIGNAL_CAUGHT_BITS { + result.exitStatus = .signal(exitCode & STATUS_CODE_MASK) } #endif } catch { @@ -464,22 +470,19 @@ func callExitTest( // For lack of a better way to handle an exit test failing in this way, // we record the system issue above, then let the expectation fail below by // reporting an exit condition that's the inverse of the expected one. - let statusAtExit: StatusAtExit = if expectedExitCondition.isApproximatelyEqual(to: .exitCode(EXIT_FAILURE)) { + let exitStatus: ExitStatus = if expectedExitCondition.isApproximatelyEqual(to: .exitCode(EXIT_FAILURE)) { .exitCode(EXIT_SUCCESS) } else { .exitCode(EXIT_FAILURE) } - result = ExitTest.Result(statusAtExit: statusAtExit) + result = ExitTest.Result(exitStatus: exitStatus) } - // How did the exit test actually exit? - let actualStatusAtExit = result.statusAtExit - // Plumb the exit test's result through the general expectation machinery. return __checkValue( - expectedExitCondition.isApproximatelyEqual(to: actualStatusAtExit), + expectedExitCondition.isApproximatelyEqual(to: result.exitStatus), expression: expression, - expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualStatusAtExit), + expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(result.exitStatus), mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition), comments: comments(), isRequired: isRequired, @@ -499,7 +502,7 @@ extension ABI { fileprivate typealias BackChannelVersion = v1 } -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +@_spi(ForToolsIntegrationOnly) extension ExitTest { /// A handler that is invoked when an exit test starts. /// @@ -615,28 +618,23 @@ extension ExitTest { static func findInEnvironmentForEntryPoint() -> Self? { // Find the ID of the exit test to run, if any, in the environment block. var id: ExitTest.ID? - if var idString = Environment.variable(named: "SWT_EXPERIMENTAL_EXIT_TEST_ID") { + if var idString = Environment.variable(named: "SWT_EXIT_TEST_ID") { // Clear the environment variable. It's an implementation detail and exit // test code shouldn't be dependent on it. Use ExitTest.current if needed! - Environment.setVariable(nil, named: "SWT_EXPERIMENTAL_EXIT_TEST_ID") + Environment.setVariable(nil, named: "SWT_EXIT_TEST_ID") id = try? idString.withUTF8 { idBuffer in try JSON.decode(ExitTest.ID.self, from: UnsafeRawBufferPointer(idBuffer)) } } - guard let id else { + guard let id, var result = find(identifiedBy: id) else { return nil } // If an exit test was found, inject back channel handling into its body. // External tools authors should set up their own back channel mechanisms // and ensure they're installed before calling ExitTest.callAsFunction(). - guard var result = find(identifiedBy: id) else { - return nil - } - - // We can't say guard let here because it counts as a consume. - guard let backChannel = _makeFileHandle(forEnvironmentVariableNamed: "SWT_EXPERIMENTAL_BACKCHANNEL", mode: "wb") else { + guard let backChannel = _makeFileHandle(forEnvironmentVariableNamed: "SWT_BACKCHANNEL", mode: "wb") else { return result } @@ -755,7 +753,7 @@ extension ExitTest { // Insert a specific variable that tells the child process which exit test // to run. try JSON.withEncoding(of: exitTest.id) { json in - childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_ID"] = String(decoding: json, as: UTF8.self) + childEnvironment["SWT_EXIT_TEST_ID"] = String(decoding: json, as: UTF8.self) } typealias ResultUpdater = @Sendable (inout ExitTest.Result) -> Void @@ -795,7 +793,7 @@ extension ExitTest { // captured values channel by setting a known environment variable to // the corresponding file descriptor (HANDLE on Windows) for each. if let backChannelEnvironmentVariable = _makeEnvironmentVariable(for: backChannelWriteEnd) { - childEnvironment["SWT_EXPERIMENTAL_BACKCHANNEL"] = backChannelEnvironmentVariable + childEnvironment["SWT_BACKCHANNEL"] = backChannelEnvironmentVariable } if let capturedValuesEnvironmentVariable = _makeEnvironmentVariable(for: capturedValuesReadEnd) { childEnvironment["SWT_EXPERIMENTAL_CAPTURED_VALUES"] = capturedValuesEnvironmentVariable @@ -831,8 +829,8 @@ extension ExitTest { // Await termination of the child process. taskGroup.addTask { - let statusAtExit = try await wait(for: processID) - return { $0.statusAtExit = statusAtExit } + let exitStatus = try await wait(for: processID) + return { $0.exitStatus = exitStatus } } // Read back the stdout and stderr streams. @@ -862,7 +860,7 @@ extension ExitTest { // Collate the various bits of the result. The exit condition used here // is just a placeholder and will be replaced by the result of one of // the tasks above. - var result = ExitTest.Result(statusAtExit: .exitCode(EXIT_FAILURE)) + var result = ExitTest.Result(exitStatus: .exitCode(EXIT_FAILURE)) for try await update in taskGroup { update?(&result) } diff --git a/Sources/Testing/ExitTests/WaitFor.swift b/Sources/Testing/ExitTests/WaitFor.swift index cc611158f..238ed835a 100644 --- a/Sources/Testing/ExitTests/WaitFor.swift +++ b/Sources/Testing/ExitTests/WaitFor.swift @@ -20,7 +20,7 @@ internal import _TestingInternals /// /// - Throws: If the exit status of the process with ID `pid` cannot be /// determined (i.e. it does not represent an exit condition.) -private func _blockAndWait(for pid: consuming pid_t) throws -> StatusAtExit { +private func _blockAndWait(for pid: consuming pid_t) throws -> ExitStatus { let pid = consume pid // Get the exit status of the process or throw an error (other than EINTR.) @@ -61,7 +61,7 @@ private func _blockAndWait(for pid: consuming pid_t) throws -> StatusAtExit { /// - Note: The open-source implementation of libdispatch available on Linux /// and other platforms does not support `DispatchSourceProcess`. Those /// platforms use an alternate implementation below. -func wait(for pid: consuming pid_t) async throws -> StatusAtExit { +func wait(for pid: consuming pid_t) async throws -> ExitStatus { let pid = consume pid let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit) @@ -80,7 +80,7 @@ func wait(for pid: consuming pid_t) async throws -> StatusAtExit { } #elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) /// A mapping of awaited child PIDs to their corresponding Swift continuations. -private let _childProcessContinuations = LockedWith]>() +private let _childProcessContinuations = LockedWith]>() /// A condition variable used to suspend the waiter thread created by /// `_createWaitThread()` when there are no child processes to await. @@ -202,7 +202,7 @@ private let _createWaitThread: Void = { /// /// On Apple platforms, the libdispatch-based implementation above is more /// efficient because it does not need to permanently reserve a thread. -func wait(for pid: consuming pid_t) async throws -> StatusAtExit { +func wait(for pid: consuming pid_t) async throws -> ExitStatus { let pid = consume pid // Ensure the waiter thread is running. @@ -239,7 +239,7 @@ func wait(for pid: consuming pid_t) async throws -> StatusAtExit { /// This implementation of `wait(for:)` calls `RegisterWaitForSingleObject()` to /// wait for `processHandle`, suspends the calling task until the waiter's /// callback is called, then calls `GetExitCodeProcess()`. -func wait(for processHandle: consuming HANDLE) async throws -> StatusAtExit { +func wait(for processHandle: consuming HANDLE) async throws -> ExitStatus { let processHandle = consume processHandle defer { _ = CloseHandle(processHandle) @@ -283,6 +283,6 @@ func wait(for processHandle: consuming HANDLE) async throws -> StatusAtExit { } #else #warning("Platform-specific implementation missing: cannot wait for child processes to exit") -func wait(for processID: consuming Never) async throws -> StatusAtExit {} +func wait(for processID: consuming Never) async throws -> ExitStatus {} #endif #endif diff --git a/Sources/Testing/Expectations/Expectation+Macro.swift b/Sources/Testing/Expectations/Expectation+Macro.swift index 5111fbddd..c93211201 100644 --- a/Sources/Testing/Expectations/Expectation+Macro.swift +++ b/Sources/Testing/Expectations/Expectation+Macro.swift @@ -490,7 +490,7 @@ public macro require( /// - expectedExitCondition: The expected exit condition. /// - observedValues: An array of key paths representing results from within /// the exit test that should be observed and returned by this macro. The -/// ``ExitTest/Result/statusAtExit`` property is always returned. +/// ``ExitTest/Result/exitStatus`` property is always returned. /// - comment: A comment describing the expectation. /// - sourceLocation: The source location to which recorded expectations and /// issues should be attributed. @@ -511,78 +511,9 @@ public macro require( /// } /// ``` /// -/// - Note: A call to this expectation macro is called an "exit test." -/// -/// ## How exit tests are run -/// -/// When an exit test is performed at runtime, the testing library starts a new -/// process with the same executable as the current process. The current task is -/// then suspended (as with `await`) and waits for the child process to -/// terminate. `expression` is not called in the parent process. -/// -/// Meanwhile, in the child process, `expression` is called directly. To ensure -/// 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. 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 `expectedExitCondition`. If they match, the exit -/// test has passed; otherwise, it has failed and an issue is recorded. -/// -/// ## Child process output -/// -/// By default, the child process is configured without a standard output or -/// standard error stream. If your test needs to review the content of either of -/// these streams, you can pass its key path in the `observedValues` argument: -/// -/// ```swift -/// let result = await #expect( -/// exitsWith: .failure, -/// observing: [\.standardOutputContent] -/// ) { -/// print("Goodbye, world!") -/// fatalError() -/// } -/// if let result { -/// #expect(result.standardOutputContent.contains(UInt8(ascii: "G"))) -/// } -/// ``` -/// -/// - Note: The content of the standard output and standard error streams may -/// contain any arbitrary sequence of bytes, including sequences that are not -/// valid UTF-8 and cannot be decoded by [`String.init(cString:)`](https://developer.apple.com/documentation/swift/string/init(cstring:)-6kr8s). -/// These streams are globally accessible within the child process, and any -/// code running in an exit test may write to it including the operating -/// system and any third-party dependencies you have declared in your package. -/// -/// The actual exit condition of the child process is always reported by the -/// testing library even if you do not specify it in `observedValues`. -/// -/// ## Runtime constraints -/// -/// Exit tests cannot capture any state originating in the parent process or -/// from the enclosing lexical context. For example, the following exit test -/// will fail to compile because it captures an argument to the enclosing -/// parameterized test: -/// -/// ```swift -/// @Test(arguments: 100 ..< 200) -/// func sellIceCreamCones(count: Int) async { -/// await #expect(exitsWith: .failure) { -/// precondition( -/// count < 10, // ERROR: A C function pointer cannot be formed from a -/// // closure that captures context -/// "Too many ice cream cones" -/// ) -/// } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) /// } -/// ``` -/// -/// An exit test cannot run within another exit test. -@_spi(Experimental) #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif @@ -602,7 +533,7 @@ public macro require( /// - expectedExitCondition: The expected exit condition. /// - observedValues: An array of key paths representing results from within /// the exit test that should be observed and returned by this macro. The -/// ``ExitTest/Result/statusAtExit`` property is always returned. +/// ``ExitTest/Result/exitStatus`` property is always returned. /// - comment: A comment describing the expectation. /// - sourceLocation: The source location to which recorded expectations and /// issues should be attributed. @@ -625,76 +556,9 @@ public macro require( /// } /// ``` /// -/// - Note: A call to this expectation macro is called an "exit test." -/// -/// ## How exit tests are run -/// -/// When an exit test is performed at runtime, the testing library starts a new -/// process with the same executable as the current process. The current task is -/// then suspended (as with `await`) and waits for the child process to -/// terminate. `expression` is not called in the parent process. -/// -/// Meanwhile, in the child process, `expression` is called directly. To ensure -/// 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. 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 `expectedExitCondition`. If they match, the exit -/// test has passed; otherwise, it has failed and an issue is recorded. -/// -/// ## Child process output -/// -/// By default, the child process is configured without a standard output or -/// standard error stream. If your test needs to review the content of either of -/// these streams, you can pass its key path in the `observedValues` argument: -/// -/// ```swift -/// let result = try await #require( -/// exitsWith: .failure, -/// observing: [\.standardOutputContent] -/// ) { -/// print("Goodbye, world!") -/// fatalError() -/// } -/// #expect(result.standardOutputContent.contains(UInt8(ascii: "G"))) -/// ``` -/// -/// - Note: The content of the standard output and standard error streams may -/// contain any arbitrary sequence of bytes, including sequences that are not -/// valid UTF-8 and cannot be decoded by [`String.init(cString:)`](https://developer.apple.com/documentation/swift/string/init(cstring:)-6kr8s). -/// These streams are globally accessible within the child process, and any -/// code running in an exit test may write to it including the operating -/// system and any third-party dependencies you have declared in your package. -/// -/// The actual exit condition of the child process is always reported by the -/// testing library even if you do not specify it in `observedValues`. -/// -/// ## Runtime constraints -/// -/// Exit tests cannot capture any state originating in the parent process or -/// from the enclosing lexical context. For example, the following exit test -/// will fail to compile because it captures an argument to the enclosing -/// parameterized test: -/// -/// ```swift -/// @Test(arguments: 100 ..< 200) -/// func sellIceCreamCones(count: Int) async throws { -/// try await #require(exitsWith: .failure) { -/// precondition( -/// count < 10, // ERROR: A C function pointer cannot be formed from a -/// // closure that captures context -/// "Too many ice cream cones" -/// ) -/// } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) /// } -/// ``` -/// -/// An exit test cannot run within another exit test. -@_spi(Experimental) #if SWT_NO_EXIT_TESTS @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index e8767d01f..5050c3c4a 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1144,7 +1144,6 @@ public func __checkClosureCall( /// /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. -@_spi(Experimental) public func __checkClosureCall( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), exitsWith expectedExitCondition: ExitTest.Condition, diff --git a/Sources/Testing/Running/Configuration.swift b/Sources/Testing/Running/Configuration.swift index b8c48aa79..bca788ec7 100644 --- a/Sources/Testing/Running/Configuration.swift +++ b/Sources/Testing/Running/Configuration.swift @@ -217,7 +217,6 @@ public struct Configuration: Sendable { /// When using the `swift test` command from Swift Package Manager, this /// property is pre-configured. Otherwise, the default value of this property /// records an issue indicating that it has not been configured. - @_spi(Experimental) public var exitTestHandler: ExitTest.Handler = { exitTest in throw SystemError(description: "Exit test support has not been implemented by the current testing infrastructure.") } diff --git a/Sources/Testing/Testing.docc/Expectations.md b/Sources/Testing/Testing.docc/Expectations.md index fd3b0070d..ddc02711f 100644 --- a/Sources/Testing/Testing.docc/Expectations.md +++ b/Sources/Testing/Testing.docc/Expectations.md @@ -72,6 +72,14 @@ the test when the code doesn't satisfy a requirement, use - ``require(throws:_:sourceLocation:performing:)-4djuw`` - ``require(_:sourceLocation:performing:throws:)`` +### Checking how processes exit + +- +- ``expect(exitsWith:observing:_:sourceLocation:performing:)`` +- ``require(exitsWith:observing:_:sourceLocation:performing:)`` +- ``ExitStatus`` +- ``ExitTest`` + ### Confirming that asynchronous events occur - diff --git a/Sources/Testing/Testing.docc/OrganizingTests.md b/Sources/Testing/Testing.docc/OrganizingTests.md index f2b577eb4..3464db4ae 100644 --- a/Sources/Testing/Testing.docc/OrganizingTests.md +++ b/Sources/Testing/Testing.docc/OrganizingTests.md @@ -124,7 +124,7 @@ struct MenuTests { The compiler emits an error when presented with a test suite that doesn't meet this requirement. -### Test suite types must always be available +#### Test suite types must always be available Although `@available` can be applied to a test function to limit its availability at runtime, a test suite type (and any types that contain it) must diff --git a/Sources/Testing/Testing.docc/exit-testing.md b/Sources/Testing/Testing.docc/exit-testing.md new file mode 100644 index 000000000..916f4cc8e --- /dev/null +++ b/Sources/Testing/Testing.docc/exit-testing.md @@ -0,0 +1,152 @@ +# Exit testing + + + +@Metadata { + @Available(Swift, introduced: 6.2) +} + +Use exit tests to test functionality that might cause a test process to exit. + +## Overview + +Your code might contain calls to [`precondition()`](https://developer.apple.com/documentation/swift/precondition(_:_:file:line:)), +[`fatalError()`](https://developer.apple.com/documentation/swift/fatalerror(_:file:line:)), +or other functions that can cause the current process to exit. For example: + +```swift +extension Customer { + func eat(_ food: consuming some Food) { + precondition(food.isDelicious, "Tasty food only!") + precondition(food.isNutritious, "Healthy food only!") + ... + } +} +``` + +In this function, if `food.isDelicious` or `food.isNutritious` is `false`, the +precondition fails and Swift forces the process to exit. You can write an exit +test to validate preconditions like the ones above and to make sure that your +functions correctly catch invalid inputs. + +- Note: Exit tests are available on macOS, Linux, FreeBSD, OpenBSD, and Windows. + +### Create an exit test + +To create an exit test, call either the ``expect(exitsWith:observing:_:sourceLocation:performing:)`` +or the ``require(exitsWith:observing:_:sourceLocation:performing:)`` macro: + +```swift +@Test func `Customer won't eat food unless it's delicious`() async { + let result = await #expect(exitsWith: .failure) { + var food = ... + food.isDelicious = false + Customer.current.eat(food) + } +} +``` + +The closure or function reference you pass to the macro is the _body_ of the +exit test. When an exit test is performed at runtime, the testing library starts +a new process with the same executable as the current process. The current task +is then suspended (as with `await`) and waits for the child process to exit. + +The parent process doesn't call the body of the exit test. Instead, the child +process treats the body of the exit test as its `main()` function and calls it +directly. + +- Note: Because the body acts as the `main()` function of a new process, it + can't capture any state originating in the parent process or from its lexical + context. For example, the following exit test will fail to compile because it + captures a variable declared outside the exit test itself: + + ```swift + @Test func `Customer won't eat food unless it's nutritious`() async { + let isNutritious = false + await #expect(exitsWith: .failure) { + var food = ... + food.isNutritious = isNutritious // ❌ ERROR: trying to capture state here + Customer.current.eat(food) + } + } + ``` + +If the body returns before the child process exits, the process exits as if +`main()` returned normally. If the body throws an error, Swift handles it as if +it were thrown from `main()` and forces the process to exit abnormally. + +### Specify an exit condition + +When you create an exit test, specify how you expect the child process exits by +passing an instance of ``ExitTest/Condition``: + +- If you expect the exit test's body to run to completion or exit normally (for + example, by calling [`exit(EXIT_SUCCESS)`](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/exit.3.html) + from the C standard library), pass ``ExitTest/Condition/success``. +- If you expect the body to cause the child process to exit abnormally, but the + exact status reported by the system is not important, pass + ``ExitTest/Condition/failure``. +- If you need to check for a specific exit code or signal, pass + ``ExitTest/Condition/exitCode(_:)`` or ``ExitTest/Condition/signal(_:)``. + +When the child process exits, the parent process resumes and compares the exit +status of the child process against the expected exit condition you passed. If +they match, the exit test passes; otherwise, it fails and the testing library +records an issue. + +### Gather output from the child process + +The ``expect(exitsWith:observing:_:sourceLocation:performing:)`` and +``require(exitsWith:observing:_:sourceLocation:performing:)`` macros return an +instance of ``ExitTest/Result`` that contains information about the state of the +child process. + +By default, the child process is configured without a standard output or +standard error stream. If your test needs to review the content of either of +these streams, pass the key path to the corresponding ``ExitTest/Result`` +property to the macro: + +```swift +extension Customer { + func eat(_ food: consuming some Food) { + print("Let's see if I want to eat \(food)...") + precondition(food.isDelicious, "Tasty food only!") + precondition(food.isNutritious, "Healthy food only!") + ... + } +} + +@Test func `Customer won't eat food unless it's delicious`() async { + let result = await #expect( + exitsWith: .failure, + observing: [\.standardOutputContent] + ) { + var food = ... + food.isDelicious = false + Customer.current.eat(food) + } + if let result { + #expect(result.standardOutputContent.contains(UInt8(ascii: "L"))) + } +} +``` + +- Note: The content of the standard output and standard error streams can + contain any arbitrary sequence of bytes, including sequences that aren't valid + UTF-8 and can't be decoded by [`String.init(cString:)`](https://developer.apple.com/documentation/swift/string/init(cstring:)-6kr8s). + These streams are globally accessible within the child process, and any code + running in an exit test may write to it including the operating system and any + third-party dependencies you declare in your package description or Xcode + project. + +The testing library always sets ``ExitTest/Result/exitStatus`` to the actual +exit status of the child process (as reported by the system) even if you do not +pass it. diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index 896784f22..524e50b3b 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -88,7 +88,7 @@ private import _TestingInternals // Mock an exit test where the process exits successfully. configuration.exitTestHandler = { _ in - return ExitTest.Result(statusAtExit: .exitCode(EXIT_SUCCESS)) + return ExitTest.Result(exitStatus: .exitCode(EXIT_SUCCESS)) } await Test { await #expect(exitsWith: .success) {} @@ -96,7 +96,7 @@ private import _TestingInternals // Mock an exit test where the process exits with a particular error code. configuration.exitTestHandler = { _ in - return ExitTest.Result(statusAtExit: .exitCode(123)) + return ExitTest.Result(exitStatus: .exitCode(123)) } await Test { await #expect(exitsWith: .failure) {} @@ -104,7 +104,7 @@ private import _TestingInternals // Mock an exit test where the process exits with a signal. configuration.exitTestHandler = { _ in - return ExitTest.Result(statusAtExit: .signal(SIGABRT)) + return ExitTest.Result(exitStatus: .signal(SIGABRT)) } await Test { await #expect(exitsWith: .signal(SIGABRT)) {} @@ -126,7 +126,7 @@ private import _TestingInternals // Mock exit tests that were expected to fail but passed. configuration.exitTestHandler = { _ in - return ExitTest.Result(statusAtExit: .exitCode(EXIT_SUCCESS)) + return ExitTest.Result(exitStatus: .exitCode(EXIT_SUCCESS)) } await Test { await #expect(exitsWith: .failure) {} @@ -140,7 +140,7 @@ private import _TestingInternals // Mock exit tests that unexpectedly signalled. configuration.exitTestHandler = { _ in - return ExitTest.Result(statusAtExit: .signal(SIGABRT)) + return ExitTest.Result(exitStatus: .signal(SIGABRT)) } await Test { await #expect(exitsWith: .exitCode(EXIT_SUCCESS)) {} @@ -245,21 +245,21 @@ private import _TestingInternals var result = await #expect(exitsWith: .success) { exit(EXIT_SUCCESS) } - #expect(result?.statusAtExit == .exitCode(EXIT_SUCCESS)) + #expect(result?.exitStatus == .exitCode(EXIT_SUCCESS)) result = await #expect(exitsWith: .exitCode(123)) { exit(123) } - #expect(result?.statusAtExit == .exitCode(123)) + #expect(result?.exitStatus == .exitCode(123)) // Test that basic passing exit tests produce the correct results (#require) result = try await #require(exitsWith: .success) { exit(EXIT_SUCCESS) } - #expect(result?.statusAtExit == .exitCode(EXIT_SUCCESS)) + #expect(result?.exitStatus == .exitCode(EXIT_SUCCESS)) result = try await #require(exitsWith: .exitCode(123)) { exit(123) } - #expect(result?.statusAtExit == .exitCode(123)) + #expect(result?.exitStatus == .exitCode(123)) } @Test("Result is nil on failure") @@ -278,7 +278,7 @@ private import _TestingInternals } } configuration.exitTestHandler = { _ in - ExitTest.Result(statusAtExit: .exitCode(123)) + ExitTest.Result(exitStatus: .exitCode(123)) } await Test { @@ -301,7 +301,7 @@ private import _TestingInternals } } configuration.exitTestHandler = { _ in - ExitTest.Result(statusAtExit: .exitCode(EXIT_FAILURE)) + ExitTest.Result(exitStatus: .exitCode(EXIT_FAILURE)) } await Test { @@ -348,7 +348,7 @@ private import _TestingInternals try FileHandle.stderr.write(String("STANDARD ERROR".reversed())) exit(EXIT_SUCCESS) } - #expect(result.statusAtExit == .exitCode(EXIT_SUCCESS)) + #expect(result.exitStatus == .exitCode(EXIT_SUCCESS)) #expect(result.standardOutputContent.contains("STANDARD OUTPUT".utf8)) #expect(result.standardErrorContent.isEmpty) @@ -357,7 +357,7 @@ private import _TestingInternals try FileHandle.stderr.write(String("STANDARD ERROR".reversed())) exit(EXIT_SUCCESS) } - #expect(result.statusAtExit == .exitCode(EXIT_SUCCESS)) + #expect(result.exitStatus == .exitCode(EXIT_SUCCESS)) #expect(result.standardOutputContent.isEmpty) #expect(result.standardErrorContent.contains("STANDARD ERROR".utf8.reversed())) }