Skip to content

Commit e6abba8

Browse files
authored
Add support for collecting stdout and stderr in an exit test. (#773)
This PR adds `stdout` and `stderr` capture to exit tests. Gathering these streams can be very expensive, so they are _opt-in_. There is a new argument to `#expect(exitsWith:)`, `observedValues`, to which you can pass one or more key paths on `ExitTest.Result` to indicate which bits you want: ```swift let result = try await #require(exitsWith: .failure, observing: [\.standardOutputContent]) { print("Goodbye, world!") fatalError() } #expect(result.standardOutputContent.contains(UInt8(ascii: "G"))) ``` Some changes to pipes were needed to satisfy the compiler's concerns about `Pipe` being a move-only type that contains other move-only types—now it's just a function that returns two `FileHandle` instances. This is simpler anyway. It is possible some callers will want `stdout` and `stderr` to be set, but to something other than a pipe (such as a file on disk or a PTY.) While these are interesting cases, they are beyond the scope of this PR. ### 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 e1388f3 commit e6abba8

11 files changed

+454
-157
lines changed

Sources/Testing/ExitTests/ExitCondition.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ private import _TestingInternals
1515
///
1616
/// Values of this type are used to describe the conditions under which an exit
1717
/// test is expected to pass or fail by passing them to
18-
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or
19-
/// ``require(exitsWith:_:sourceLocation:performing:)``.
18+
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
19+
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``.
2020
@_spi(Experimental)
2121
#if SWT_NO_PROCESS_SPAWNING
2222
@available(*, unavailable, message: "Exit tests are not available on this platform.")

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 109 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,41 @@ public struct ExitTest: Sendable, ~Copyable {
3434
/// The body closure of the exit test.
3535
fileprivate var body: @Sendable () async throws -> Void = {}
3636

37+
/// Storage for ``observedValues``.
38+
///
39+
/// Key paths are not sendable because the properties they refer to may or may
40+
/// not be, so this property needs to be `nonisolated(unsafe)`. It is safe to
41+
/// use it in this fashion because `ExitTestArtifacts` is sendable.
42+
fileprivate nonisolated(unsafe) var _observedValues = [PartialKeyPath<ExitTestArtifacts>]()
43+
44+
/// Key paths representing results from within this exit test that should be
45+
/// observed and returned to the caller.
46+
///
47+
/// The testing library sets this property to match what was passed by the
48+
/// developer to the `#expect(exitsWith:)` or `#require(exitsWith:)` macro.
49+
/// If you are implementing an exit test handler, you can check the value of
50+
/// this property to determine what information you need to preserve from your
51+
/// child process.
52+
///
53+
/// The value of this property always includes ``Result/exitCondition`` even
54+
/// if the test author does not specify it.
55+
///
56+
/// Within a child process running an exit test, the value of this property is
57+
/// otherwise unspecified.
58+
@_spi(ForToolsIntegrationOnly)
59+
public var observedValues: [PartialKeyPath<ExitTestArtifacts>] {
60+
get {
61+
var result = _observedValues
62+
if !result.contains(\.exitCondition) { // O(n), but n <= 3 (no Set needed)
63+
result.append(\.exitCondition)
64+
}
65+
return result
66+
}
67+
set {
68+
_observedValues = newValue
69+
}
70+
}
71+
3772
/// The source location of the exit test.
3873
///
3974
/// The source location is unique to each exit test and is consistent between
@@ -184,6 +219,9 @@ extension ExitTest {
184219
///
185220
/// - Parameters:
186221
/// - expectedExitCondition: The expected exit condition.
222+
/// - observedValues: An array of key paths representing results from within
223+
/// the exit test that should be observed and returned by this macro. The
224+
/// ``ExitTestArtifacts/exitCondition`` property is always returned.
187225
/// - expression: The expression, corresponding to `condition`, that is being
188226
/// evaluated (if available at compile time.)
189227
/// - comments: An array of comments describing the expectation. This array
@@ -199,19 +237,21 @@ extension ExitTest {
199237
/// convention.
200238
func callExitTest(
201239
exitsWith expectedExitCondition: ExitCondition,
240+
observing observedValues: [PartialKeyPath<ExitTestArtifacts>],
202241
expression: __Expression,
203242
comments: @autoclosure () -> [Comment],
204243
isRequired: Bool,
205244
isolation: isolated (any Actor)? = #isolation,
206245
sourceLocation: SourceLocation
207-
) async -> Result<ExitTestArtifacts, any Error> {
246+
) async -> Result<ExitTestArtifacts?, any Error> {
208247
guard let configuration = Configuration.current ?? Configuration.all.first else {
209248
preconditionFailure("A test must be running on the current task to use #expect(exitsWith:).")
210249
}
211250

212251
var result: ExitTestArtifacts
213252
do {
214-
let exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
253+
var exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
254+
exitTest.observedValues = observedValues
215255
result = try await configuration.exitTestHandler(exitTest)
216256

217257
#if os(Windows)
@@ -276,11 +316,15 @@ extension ExitTest {
276316
/// the exit test.
277317
///
278318
/// This handler is invoked when an exit test (i.e. a call to either
279-
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or
280-
/// ``require(exitsWith:_:sourceLocation:performing:)``) is started. The
281-
/// handler is responsible for initializing a new child environment (typically
282-
/// a child process) and running the exit test identified by `sourceLocation`
283-
/// there. The exit test's body can be found using ``ExitTest/find(at:)``.
319+
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
320+
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``) is started.
321+
/// The handler is responsible for initializing a new child environment
322+
/// (typically a child process) and running the exit test identified by
323+
/// `sourceLocation` there.
324+
///
325+
/// In the child environment, you can find the exit test again by calling
326+
/// ``ExitTest/find(at:)`` and can run it by calling
327+
/// ``ExitTest/callAsFunction()``.
284328
///
285329
/// The parent environment should suspend until the results of the exit test
286330
/// are available or the child environment is otherwise terminated. The parent
@@ -465,20 +509,43 @@ extension ExitTest {
465509
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = String(decoding: json, as: UTF8.self)
466510
}
467511

468-
return try await withThrowingTaskGroup(of: ExitTestArtifacts?.self) { taskGroup in
512+
typealias ResultUpdater = @Sendable (inout ExitTestArtifacts) -> Void
513+
return try await withThrowingTaskGroup(of: ResultUpdater?.self) { taskGroup in
514+
// Set up stdout and stderr streams. By POSIX convention, stdin/stdout
515+
// are line-buffered by default and stderr is unbuffered by default.
516+
// SEE: https://en.cppreference.com/w/cpp/io/c/std_streams
517+
var stdoutReadEnd: FileHandle?
518+
var stdoutWriteEnd: FileHandle?
519+
if exitTest._observedValues.contains(\.standardOutputContent) {
520+
try FileHandle.makePipe(readEnd: &stdoutReadEnd, writeEnd: &stdoutWriteEnd)
521+
stdoutWriteEnd?.withUnsafeCFILEHandle { stdout in
522+
_ = setvbuf(stdout, nil, _IOLBF, Int(BUFSIZ))
523+
}
524+
}
525+
var stderrReadEnd: FileHandle?
526+
var stderrWriteEnd: FileHandle?
527+
if exitTest._observedValues.contains(\.standardErrorContent) {
528+
try FileHandle.makePipe(readEnd: &stderrReadEnd, writeEnd: &stderrWriteEnd)
529+
stderrWriteEnd?.withUnsafeCFILEHandle { stderr in
530+
_ = setvbuf(stderr, nil, _IONBF, Int(BUFSIZ))
531+
}
532+
}
533+
469534
// Create a "back channel" pipe to handle events from the child process.
470-
let backChannel = try FileHandle.Pipe()
535+
var backChannelReadEnd: FileHandle!
536+
var backChannelWriteEnd: FileHandle!
537+
try FileHandle.makePipe(readEnd: &backChannelReadEnd, writeEnd: &backChannelWriteEnd)
471538

472539
// Let the child process know how to find the back channel by setting a
473540
// known environment variable to the corresponding file descriptor
474541
// (HANDLE on Windows.)
475542
var backChannelEnvironmentVariable: String?
476543
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD)
477-
backChannelEnvironmentVariable = backChannel.writeEnd.withUnsafePOSIXFileDescriptor { fd in
544+
backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafePOSIXFileDescriptor { fd in
478545
fd.map(String.init(describing:))
479546
}
480547
#elseif os(Windows)
481-
backChannelEnvironmentVariable = backChannel.writeEnd.withUnsafeWindowsHANDLE { handle in
548+
backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafeWindowsHANDLE { handle in
482549
handle.flatMap { String(describing: UInt(bitPattern: $0)) }
483550
}
484551
#else
@@ -489,32 +556,55 @@ extension ExitTest {
489556
}
490557

491558
// Spawn the child process.
492-
let processID = try withUnsafePointer(to: backChannel.writeEnd) { writeEnd in
559+
let processID = try withUnsafePointer(to: backChannelWriteEnd) { backChannelWriteEnd in
493560
try spawnExecutable(
494561
atPath: childProcessExecutablePath,
495562
arguments: childArguments,
496563
environment: childEnvironment,
497-
additionalFileHandles: .init(start: writeEnd, count: 1)
564+
standardOutput: stdoutWriteEnd,
565+
standardError: stderrWriteEnd,
566+
additionalFileHandles: [backChannelWriteEnd]
498567
)
499568
}
500569

501570
// Await termination of the child process.
502571
taskGroup.addTask {
503572
let exitCondition = try await wait(for: processID)
504-
return ExitTestArtifacts(exitCondition: exitCondition)
573+
return { $0.exitCondition = exitCondition }
574+
}
575+
576+
// Read back the stdout and stderr streams.
577+
if let stdoutReadEnd {
578+
stdoutWriteEnd?.close()
579+
taskGroup.addTask {
580+
let standardOutputContent = try stdoutReadEnd.readToEnd()
581+
return { $0.standardOutputContent = standardOutputContent }
582+
}
583+
}
584+
if let stderrReadEnd {
585+
stderrWriteEnd?.close()
586+
taskGroup.addTask {
587+
let standardErrorContent = try stderrReadEnd.readToEnd()
588+
return { $0.standardErrorContent = standardErrorContent }
589+
}
505590
}
506591

507592
// Read back all data written to the back channel by the child process
508593
// and process it as a (minimal) event stream.
509-
let readEnd = backChannel.closeWriteEnd()
594+
backChannelWriteEnd.close()
510595
taskGroup.addTask {
511-
Self._processRecords(fromBackChannel: readEnd)
596+
Self._processRecords(fromBackChannel: backChannelReadEnd)
512597
return nil
513598
}
514599

515-
// This is a roundabout way of saying "and return the exit condition
516-
// yielded by wait(for:)".
517-
return try await taskGroup.compactMap { $0 }.first { _ in true }!
600+
// Collate the various bits of the result. The exit condition .failure
601+
// here is just a placeholder and will be replaced by the result of one
602+
// of the tasks above.
603+
var result = ExitTestArtifacts(exitCondition: .failure)
604+
for try await update in taskGroup {
605+
update?(&result)
606+
}
607+
return result
518608
}
519609
}
520610
}

Sources/Testing/ExitTests/ExitTestArtifacts.swift

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
/// A type representing the result of an exit test after it has exited and
1212
/// returned control to the calling test function.
1313
///
14-
/// Both ``expect(exitsWith:_:sourceLocation:performing:)`` and
15-
/// ``require(exitsWith:_:sourceLocation:performing:)`` return instances of
16-
/// this type.
14+
/// Both ``expect(exitsWith:observing:_:sourceLocation:performing:)`` and
15+
/// ``require(exitsWith:observing:_:sourceLocation:performing:)`` return
16+
/// instances of this type.
1717
///
1818
/// - Warning: The name of this type is still unstable and subject to change.
1919
@_spi(Experimental)
@@ -25,11 +25,64 @@ public struct ExitTestArtifacts: Sendable {
2525
///
2626
/// When the exit test passes, the value of this property is equal to the
2727
/// 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/==(_:_:)``.
28+
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or to
29+
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``. You can
30+
/// compare two instances of ``ExitCondition`` with
31+
/// ``/Swift/Optional/==(_:_:)``.
3132
public var exitCondition: ExitCondition
3233

34+
/// All bytes written to the standard output stream of the exit test before
35+
/// it exited.
36+
///
37+
/// The value of this property may contain any arbitrary sequence of bytes,
38+
/// including sequences that are not valid UTF-8 and cannot be decoded by
39+
/// [`String.init(cString:)`](https://developer.apple.com/documentation/swift/string/init(cstring:)-6kr8s).
40+
/// Consider using [`String.init(validatingCString:)`](https://developer.apple.com/documentation/swift/string/init(validatingcstring:)-992vo)
41+
/// instead.
42+
///
43+
/// When checking the value of this property, keep in mind that the standard
44+
/// output stream is globally accessible, and any code running in an exit
45+
/// test may write to it including including the operating system and any
46+
/// third-party dependencies you have declared in your package. Rather than
47+
/// comparing the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)),
48+
/// use [`contains(_:)`](https://developer.apple.com/documentation/swift/collection/contains(_:))
49+
/// to check if expected output is present.
50+
///
51+
/// To enable gathering output from the standard output stream during an
52+
/// exit test, pass `\.standardOutputContent` in the `observedValues`
53+
/// argument of ``expect(exitsWith:observing:_:sourceLocation:performing:)``
54+
/// or ``require(exitsWith:observing:_:sourceLocation:performing:)``.
55+
///
56+
/// If you did not request standard output content when running an exit test,
57+
/// the value of this property is the empty array.
58+
public var standardOutputContent = [UInt8]()
59+
60+
/// All bytes written to the standard error stream of the exit test before
61+
/// it exited.
62+
///
63+
/// The value of this property may contain any arbitrary sequence of bytes,
64+
/// including sequences that are not valid UTF-8 and cannot be decoded by
65+
/// [`String.init(cString:)`](https://developer.apple.com/documentation/swift/string/init(cstring:)-6kr8s).
66+
/// Consider using [`String.init(validatingCString:)`](https://developer.apple.com/documentation/swift/string/init(validatingcstring:)-992vo)
67+
/// instead.
68+
///
69+
/// When checking the value of this property, keep in mind that the standard
70+
/// output stream is globally accessible, and any code running in an exit
71+
/// test may write to it including including the operating system and any
72+
/// third-party dependencies you have declared in your package. Rather than
73+
/// comparing the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)),
74+
/// use [`contains(_:)`](https://developer.apple.com/documentation/swift/collection/contains(_:))
75+
/// to check if expected output is present.
76+
///
77+
/// To enable gathering output from the standard error stream during an exit
78+
/// test, pass `\.standardErrorContent` in the `observedValues` argument of
79+
/// ``expect(exitsWith:observing:_:sourceLocation:performing:)`` or
80+
/// ``require(exitsWith:observing:_:sourceLocation:performing:)``.
81+
///
82+
/// If you did not request standard error content when running an exit test,
83+
/// the value of this property is the empty array.
84+
public var standardErrorContent = [UInt8]()
85+
3386
@_spi(ForToolsIntegrationOnly)
3487
public init(exitCondition: ExitCondition) {
3588
self.exitCondition = exitCondition

0 commit comments

Comments
 (0)