Skip to content
4 changes: 2 additions & 2 deletions Sources/Testing/ExitTests/ExitCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ private import _TestingInternals
///
/// Values of this type are used to describe the conditions under which an exit
/// test is expected to pass or fail by passing them to
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or
/// ``require(exitsWith:_:sourceLocation:performing:)``.
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``.
@_spi(Experimental)
#if SWT_NO_PROCESS_SPAWNING
@available(*, unavailable, message: "Exit tests are not available on this platform.")
Expand Down
128 changes: 109 additions & 19 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,41 @@ public struct ExitTest: Sendable, ~Copyable {
/// The body closure of the exit test.
fileprivate var body: @Sendable () async throws -> Void = {}

/// Storage for ``observedValues``.
///
/// Key paths are not sendable because the properties they refer to may or may
/// not be, so this property needs to be `nonisolated(unsafe)`. It is safe to
/// use it in this fashion because `ExitTestArtifacts` is sendable.
fileprivate nonisolated(unsafe) var _observedValues = [PartialKeyPath<ExitTestArtifacts>]()

/// Key paths representing results from within this exit test that should be
/// observed and returned to the caller.
///
/// The testing library sets this property to match what was passed by the
/// developer to the `#expect(exitsWith:)` or `#require(exitsWith:)` macro.
/// If you are implementing an exit test handler, you can check the value of
/// this property to determine what information you need to preserve from your
/// child process.
///
/// The value of this property always includes ``Result/exitCondition`` even
/// if the test author does not specify it.
///
/// Within a child process running an exit test, the value of this property is
/// otherwise unspecified.
@_spi(ForToolsIntegrationOnly)
public var observedValues: [PartialKeyPath<ExitTestArtifacts>] {
get {
var result = _observedValues
if !result.contains(\.exitCondition) { // O(n), but n <= 3 (no Set needed)
result.append(\.exitCondition)
}
return result
}
set {
_observedValues = newValue
}
}

/// The source location of the exit test.
///
/// The source location is unique to each exit test and is consistent between
Expand Down Expand Up @@ -184,6 +219,9 @@ extension ExitTest {
///
/// - Parameters:
/// - 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
/// ``ExitTestArtifacts/exitCondition`` 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
Expand All @@ -199,19 +237,21 @@ extension ExitTest {
/// convention.
func callExitTest(
exitsWith expectedExitCondition: ExitCondition,
observing observedValues: [PartialKeyPath<ExitTestArtifacts>],
expression: __Expression,
comments: @autoclosure () -> [Comment],
isRequired: Bool,
isolation: isolated (any Actor)? = #isolation,
sourceLocation: SourceLocation
) async -> Result<ExitTestArtifacts, any Error> {
) async -> Result<ExitTestArtifacts?, any Error> {
guard let configuration = Configuration.current ?? Configuration.all.first else {
preconditionFailure("A test must be running on the current task to use #expect(exitsWith:).")
}

var result: ExitTestArtifacts
do {
let exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
var exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
exitTest.observedValues = observedValues
result = try await configuration.exitTestHandler(exitTest)

#if os(Windows)
Expand Down Expand Up @@ -276,11 +316,15 @@ extension ExitTest {
/// the exit test.
///
/// This handler is invoked when an exit test (i.e. a call to either
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or
/// ``require(exitsWith:_:sourceLocation:performing:)``) is started. The
/// handler is responsible for initializing a new child environment (typically
/// a child process) and running the exit test identified by `sourceLocation`
/// there. The exit test's body can be found using ``ExitTest/find(at:)``.
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``) is started.
/// The handler is responsible for initializing a new child environment
/// (typically a child process) and running the exit test identified by
/// `sourceLocation` there.
///
/// In the child environment, you can find the exit test again by calling
/// ``ExitTest/find(at:)`` and can run it by calling
/// ``ExitTest/callAsFunction()``.
///
/// The parent environment should suspend until the results of the exit test
/// are available or the child environment is otherwise terminated. The parent
Expand Down Expand Up @@ -465,20 +509,43 @@ extension ExitTest {
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = String(decoding: json, as: UTF8.self)
}

return try await withThrowingTaskGroup(of: ExitTestArtifacts?.self) { taskGroup in
typealias ResultUpdater = @Sendable (inout ExitTestArtifacts) -> Void
return try await withThrowingTaskGroup(of: ResultUpdater?.self) { taskGroup in
// Set up stdout and stderr streams. By POSIX convention, stdin/stdout
// are line-buffered by default and stderr is unbuffered by default.
// SEE: https://en.cppreference.com/w/cpp/io/c/std_streams
var stdoutReadEnd: FileHandle?
var stdoutWriteEnd: FileHandle?
if exitTest._observedValues.contains(\.standardOutputContent) {
try FileHandle.makePipe(readEnd: &stdoutReadEnd, writeEnd: &stdoutWriteEnd)
stdoutWriteEnd?.withUnsafeCFILEHandle { stdout in
_ = setvbuf(stdout, nil, _IOLBF, Int(BUFSIZ))
}
}
var stderrReadEnd: FileHandle?
var stderrWriteEnd: FileHandle?
if exitTest._observedValues.contains(\.standardErrorContent) {
try FileHandle.makePipe(readEnd: &stderrReadEnd, writeEnd: &stderrWriteEnd)
stderrWriteEnd?.withUnsafeCFILEHandle { stderr in
_ = setvbuf(stderr, nil, _IONBF, Int(BUFSIZ))
}
}

// Create a "back channel" pipe to handle events from the child process.
let backChannel = try FileHandle.Pipe()
var backChannelReadEnd: FileHandle!
var backChannelWriteEnd: FileHandle!
try FileHandle.makePipe(readEnd: &backChannelReadEnd, writeEnd: &backChannelWriteEnd)

// Let the child process know how to find the back channel by setting a
// known environment variable to the corresponding file descriptor
// (HANDLE on Windows.)
var backChannelEnvironmentVariable: String?
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD)
backChannelEnvironmentVariable = backChannel.writeEnd.withUnsafePOSIXFileDescriptor { fd in
backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafePOSIXFileDescriptor { fd in
fd.map(String.init(describing:))
}
#elseif os(Windows)
backChannelEnvironmentVariable = backChannel.writeEnd.withUnsafeWindowsHANDLE { handle in
backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafeWindowsHANDLE { handle in
handle.flatMap { String(describing: UInt(bitPattern: $0)) }
}
#else
Expand All @@ -489,32 +556,55 @@ extension ExitTest {
}

// Spawn the child process.
let processID = try withUnsafePointer(to: backChannel.writeEnd) { writeEnd in
let processID = try withUnsafePointer(to: backChannelWriteEnd) { backChannelWriteEnd in
try spawnExecutable(
atPath: childProcessExecutablePath,
arguments: childArguments,
environment: childEnvironment,
additionalFileHandles: .init(start: writeEnd, count: 1)
standardOutput: stdoutWriteEnd,
standardError: stderrWriteEnd,
additionalFileHandles: [backChannelWriteEnd]
)
}

// Await termination of the child process.
taskGroup.addTask {
let exitCondition = try await wait(for: processID)
return ExitTestArtifacts(exitCondition: exitCondition)
return { $0.exitCondition = exitCondition }
}

// Read back the stdout and stderr streams.
if let stdoutReadEnd {
stdoutWriteEnd?.close()
taskGroup.addTask {
let standardOutputContent = try stdoutReadEnd.readToEnd()
return { $0.standardOutputContent = standardOutputContent }
}
}
if let stderrReadEnd {
stderrWriteEnd?.close()
taskGroup.addTask {
let standardErrorContent = try stderrReadEnd.readToEnd()
return { $0.standardErrorContent = standardErrorContent }
}
}

// Read back all data written to the back channel by the child process
// and process it as a (minimal) event stream.
let readEnd = backChannel.closeWriteEnd()
backChannelWriteEnd.close()
taskGroup.addTask {
Self._processRecords(fromBackChannel: readEnd)
Self._processRecords(fromBackChannel: backChannelReadEnd)
return nil
}

// This is a roundabout way of saying "and return the exit condition
// yielded by wait(for:)".
return try await taskGroup.compactMap { $0 }.first { _ in true }!
// Collate the various bits of the result. The exit condition .failure
// here is just a placeholder and will be replaced by the result of one
// of the tasks above.
var result = ExitTestArtifacts(exitCondition: .failure)
for try await update in taskGroup {
update?(&result)
}
return result
}
}
}
Expand Down
65 changes: 59 additions & 6 deletions Sources/Testing/ExitTests/ExitTestArtifacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
/// A type representing the result of an exit test after it has exited and
/// returned control to the calling test function.
///
/// Both ``expect(exitsWith:_:sourceLocation:performing:)`` and
/// ``require(exitsWith:_:sourceLocation:performing:)`` return instances of
/// this type.
/// Both ``expect(exitsWith:observing:_:sourceLocation:performing:)`` and
/// ``require(exitsWith:observing:_:sourceLocation:performing:)`` return
/// instances of this type.
///
/// - Warning: The name of this type is still unstable and subject to change.
@_spi(Experimental)
Expand All @@ -25,11 +25,64 @@ public struct ExitTestArtifacts: Sendable {
///
/// When the exit test passes, the value of this property is equal to the
/// value of the `expectedExitCondition` argument passed to
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or to
/// ``require(exitsWith:_:sourceLocation:performing:)``. You can compare two
/// instances of ``ExitCondition`` with ``/Swift/Optional/==(_:_:)``.
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or to
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``. You can
/// compare two instances of ``ExitCondition`` with
/// ``/Swift/Optional/==(_:_:)``.
public var exitCondition: ExitCondition

/// All bytes written to the standard output stream of the exit test before
/// it exited.
///
/// The value of this property 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).
/// Consider using [`String.init(validatingCString:)`](https://developer.apple.com/documentation/swift/string/init(validatingcstring:)-992vo)
/// instead.
///
/// When checking the value of this property, keep in mind that the standard
/// output stream is globally accessible, and any code running in an exit
/// test may write to it including including the operating system and any
/// third-party dependencies you have declared in your package. Rather than
/// comparing the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)),
/// use [`contains(_:)`](https://developer.apple.com/documentation/swift/collection/contains(_:))
/// to check if expected output is present.
///
/// To enable gathering output from the standard output stream during an
/// exit test, pass `\.standardOutputContent` in the `observedValues`
/// argument of ``expect(exitsWith:observing:_:sourceLocation:performing:)``
/// or ``require(exitsWith:observing:_:sourceLocation:performing:)``.
///
/// If you did not request standard output content when running an exit test,
/// the value of this property is the empty array.
public var standardOutputContent = [UInt8]()

/// All bytes written to the standard error stream of the exit test before
/// it exited.
///
/// The value of this property 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).
/// Consider using [`String.init(validatingCString:)`](https://developer.apple.com/documentation/swift/string/init(validatingcstring:)-992vo)
/// instead.
///
/// When checking the value of this property, keep in mind that the standard
/// output stream is globally accessible, and any code running in an exit
/// test may write to it including including the operating system and any
/// third-party dependencies you have declared in your package. Rather than
/// comparing the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)),
/// use [`contains(_:)`](https://developer.apple.com/documentation/swift/collection/contains(_:))
/// to check if expected output is present.
///
/// To enable gathering output from the standard error stream during an exit
/// test, pass `\.standardErrorContent` in the `observedValues` argument of
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``.
///
/// If you did not request standard error content when running an exit test,
/// the value of this property is the empty array.
public var standardErrorContent = [UInt8]()

@_spi(ForToolsIntegrationOnly)
public init(exitCondition: ExitCondition) {
self.exitCondition = exitCondition
Expand Down
Loading