@@ -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.
200238func 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 }
0 commit comments