diff --git a/Package.swift b/Package.swift index f515f16a9..80e6ce797 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 // // This source file is part of the Swift.org open source project // -// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Copyright (c) 2023–2025 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -95,6 +95,13 @@ let package = Package( return result }(), + traits: [ + .trait( + name: "ExperimentalExitTestValueCapture", + description: "Enable experimental support for capturing values in exit tests" + ), + ], + dependencies: [ .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest"), ], @@ -285,6 +292,14 @@ extension Array where Element == PackageDescription.SwiftSetting { .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), ] + // Unconditionally enable 'ExperimentalExitTestValueCapture' when building + // for development. + if buildingForDevelopment { + result += [ + .define("ExperimentalExitTestValueCapture") + ] + } + return result } diff --git a/Sources/Testing/ABI/ABI.Record+Streaming.swift b/Sources/Testing/ABI/ABI.Record+Streaming.swift index 7b86cb438..1aa1362ec 100644 --- a/Sources/Testing/ABI/ABI.Record+Streaming.swift +++ b/Sources/Testing/ABI/ABI.Record+Streaming.swift @@ -12,39 +12,6 @@ private import Foundation extension ABI.Version { - /// Post-process encoded JSON and write it to a file. - /// - /// - Parameters: - /// - json: The JSON to write. - /// - file: The file to write to. - /// - /// - Throws: Whatever is thrown when writing to `file`. - private static func _asJSONLine(_ json: UnsafeRawBufferPointer, _ eventHandler: (_ recordJSON: UnsafeRawBufferPointer) throws -> Void) rethrows { - // We don't actually expect the JSON encoder to produce output containing - // newline characters, so in debug builds we'll log a diagnostic message. - if _slowPath(json.contains(where: \.isASCIINewline)) { -#if DEBUG && !SWT_NO_FILE_IO - let message = Event.ConsoleOutputRecorder.warning( - "JSON encoder produced one or more newline characters while encoding an event to JSON. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new", - options: .for(.stderr) - ) -#if SWT_TARGET_OS_APPLE - try? FileHandle.stderr.write(message) -#else - print(message) -#endif -#endif - - // Remove the newline characters to conform to JSON lines specification. - var json = Array(json) - json.removeAll(where: \.isASCIINewline) - try json.withUnsafeBytes(eventHandler) - } else { - // No newlines found, no need to copy the buffer. - try eventHandler(json) - } - } - static func eventHandler( encodeAsJSONLines: Bool, forwardingTo eventHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void @@ -52,7 +19,7 @@ extension ABI.Version { // Encode as JSON Lines if requested. var eventHandlerCopy = eventHandler if encodeAsJSONLines { - eventHandlerCopy = { @Sendable in _asJSONLine($0, eventHandler) } + eventHandlerCopy = { @Sendable in JSON.asJSONLine($0, eventHandler) } } let humanReadableOutputRecorder = Event.HumanReadableOutputRecorder() diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 81a0c550a..b4e865427 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -32,6 +32,7 @@ add_library(Testing Events/Recorder/Event.Symbol.swift Events/TimeValue.swift ExitTests/ExitTest.swift + ExitTests/ExitTest.CapturedValue.swift ExitTests/ExitTest.Condition.swift ExitTests/ExitTest.Result.swift ExitTests/SpawnProcess.swift diff --git a/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift new file mode 100644 index 000000000..aeeb13818 --- /dev/null +++ b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift @@ -0,0 +1,168 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023–2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if !SWT_NO_EXIT_TESTS +@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +extension ExitTest { + /// A type representing a value captured by an exit test's body. + /// + /// An instance of this type may represent the actual value that was captured + /// when the exit test was invoked. In the child process created by the + /// current exit test handler, instances will initially only have the type of + /// the value, but not the value itself. + /// + /// Instances of this type are created automatically by the testing library + /// for all elements in an exit test body's capture list and are stored in the + /// exit test's ``capturedValues`` property. For example, given the following + /// exit test: + /// + /// ```swift + /// await #expect(exitsWith: .failure) { [a = a as T, b = b as U, c = c as V] in + /// ... + /// } + /// ``` + /// + /// There are three captured values in its ``capturedValues`` property. These + /// values are captured at the time the exit test is called, as they would be + /// if the closure were called locally. + /// + /// The current exit test handler is responsible for encoding and decoding + /// instances of this type. When the handler is called, it is passed an + /// instance of ``ExitTest``. The handler encodes the values in that + /// instance's ``capturedValues`` property, then passes the encoded forms of + /// those values to the child process. The encoding format and message-passing + /// interface are implementation details of the exit test handler. + /// + /// When the child process calls ``ExitTest/find(identifiedBy:)``, it receives + /// an instance of ``ExitTest`` whose ``capturedValues`` property contains + /// type information but no values. The child process decodes the values it + /// encoded in the parent process and then updates the ``wrappedValue`` + /// property of each element in the array before calling the exit test's body. + public struct CapturedValue: Sendable { + /// An enumeration of the different states a captured value can have. + private enum _Kind: Sendable { + /// The runtime value of the captured value is known. + case wrappedValue(any Codable & Sendable) + + /// Only the type of the captured value is known. + case typeOnly(any (Codable & Sendable).Type) + } + + /// The current state of this instance. + private var _kind: _Kind + + init(wrappedValue: some Codable & Sendable) { + _kind = .wrappedValue(wrappedValue) + } + + init(typeOnly type: (some Codable & Sendable).Type) { + _kind = .typeOnly(type) + } + + /// The underlying value captured by this instance at runtime. + /// + /// In a child process created by the current exit test handler, the value + /// of this property is `nil` until the entry point sets it. + public var wrappedValue: (any Codable & Sendable)? { + get { + if case let .wrappedValue(wrappedValue) = _kind { + return wrappedValue + } + return nil + } + + set { + let type = typeOfWrappedValue + + func validate(_ newValue: T, is expectedType: U.Type) { + assert(newValue is U, "Attempted to set a captured value to an instance of '\(String(describingForTest: T.self))', but an instance of '\(String(describingForTest: U.self))' was expected.") + } + validate(newValue, is: type) + + if let newValue { + _kind = .wrappedValue(newValue) + } else { + _kind = .typeOnly(type) + } + } + } + + /// The type of the underlying value captured by this instance. + /// + /// This type is known at compile time and is always available, even before + /// this instance's ``wrappedValue`` property is set. + public var typeOfWrappedValue: any (Codable & Sendable).Type { + switch _kind { + case let .wrappedValue(wrappedValue): + type(of: wrappedValue) + case let .typeOnly(type): + type + } + } + } +} + +// MARK: - Collection conveniences + +extension Array where Element == ExitTest.CapturedValue { + init(_ wrappedValues: repeat each T) where repeat each T: Codable & Sendable { + self.init() + repeat self.append(ExitTest.CapturedValue(wrappedValue: each wrappedValues)) + } + + init(_ typesOfWrappedValues: repeat (each T).Type) where repeat each T: Codable & Sendable { + self.init() + repeat self.append(ExitTest.CapturedValue(typeOnly: (each typesOfWrappedValues).self)) + } +} + +extension Collection where Element == ExitTest.CapturedValue { + /// Cast the elements in this collection to a tuple of their wrapped values. + /// + /// - Returns: A tuple containing the wrapped values of the elements in this + /// collection. + /// + /// - Throws: If an expected value could not be found or was not of the + /// type the caller expected. + /// + /// This function assumes that the entry point function has already set the + /// ``wrappedValue`` property of each element in this collection. + func takeCapturedValues() throws -> (repeat each T) { + func nextValue( + as type: U.Type, + from capturedValues: inout SubSequence + ) throws -> U { + // Get the next captured value in the collection. If we run out of values + // before running out of parameter pack elements, then something in the + // exit test handler or entry point is likely broken. + guard let wrappedValue = capturedValues.first?.wrappedValue else { + let actualCount = self.count + let expectedCount = parameterPackCount(repeat (each T).self) + fatalError("Found fewer captured values (\(actualCount)) than expected (\(expectedCount)) when passing them to the current exit test.") + } + + // Next loop, get the next element. (We're mutating a subsequence, not + // self, so this is generally an O(1) operation.) + capturedValues = capturedValues.dropFirst() + + // Make sure the value is of the correct type. If it's not, that's also + // probably a problem with the exit test handler or entry point. + guard let wrappedValue = wrappedValue as? U else { + fatalError("Expected captured value at index \(capturedValues.startIndex) with type '\(String(describingForTest: U.self))', but found an instance of '\(String(describingForTest: Swift.type(of: wrappedValue)))' instead.") + } + + return wrappedValue + } + + var capturedValues = self[...] + return (repeat try nextValue(as: (each T).self, from: &capturedValues)) + } +} +#endif diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 93393b69b..503e143c6 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -68,10 +68,13 @@ public struct ExitTest: Sendable, ~Copyable { /// The body closure of the exit test. /// + /// - Parameters: + /// - exitTest: The exit test to which this body closure belongs. + /// /// Do not invoke this closure directly. Instead, invoke ``callAsFunction()`` /// to run the exit test. Running the exit test will always terminate the /// current process. - fileprivate var body: @Sendable () async throws -> Void = {} + fileprivate var body: @Sendable (_ exitTest: inout Self) async throws -> Void = { _ in } /// Storage for ``observedValues``. /// @@ -108,6 +111,19 @@ public struct ExitTest: Sendable, ~Copyable { } } + /// The set of values captured in the parent process before the exit test is + /// called. + /// + /// This property is automatically set by the testing library when using the + /// built-in exit test handler and entry point functions. Do not modify the + /// value of this property unless you are implementing a custom exit test + /// handler or entry point function. + /// + /// The order of values in this array must be the same between the parent and + /// child processes. + @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + public var capturedValues = [CapturedValue]() + /// Make a copy of this instance. /// /// - Returns: A copy of this instance. @@ -117,6 +133,7 @@ public struct ExitTest: Sendable, ~Copyable { fileprivate borrowing func unsafeCopy() -> Self { var result = Self(id: id, body: body) result._observedValues = _observedValues + result.capturedValues = capturedValues return result } } @@ -245,7 +262,7 @@ extension ExitTest { } do { - try await body() + try await body(&self) } catch { _errorInMain(error) } @@ -279,25 +296,39 @@ extension ExitTest: DiscoverableAsTestContent { /// /// - Warning: This function is used to implement the `#expect(exitsWith:)` /// macro. Do not use it directly. - public static func __store( + public static func __store( _ id: (UInt64, UInt64, UInt64, UInt64), - _ body: @escaping @Sendable () async throws -> Void, + _ body: @escaping @Sendable (repeat each T) async throws -> Void, into outValue: UnsafeMutableRawPointer, asTypeAt typeAddress: UnsafeRawPointer, withHintAt hintAddress: UnsafeRawPointer? = nil - ) -> CBool { + ) -> CBool where repeat each T: Codable & Sendable { #if !hasFeature(Embedded) + // Check that the type matches. let callerExpectedType = TypeInfo(describing: typeAddress.load(as: Any.Type.self)) let selfType = TypeInfo(describing: Self.self) guard callerExpectedType == selfType else { return false } #endif + + // Check that the ID matches if provided. let id = ID(id) if let hintedID = hintAddress?.load(as: ID.self), hintedID != id { return false } - outValue.initializeMemory(as: Self.self, to: Self(id: id, body: body)) + + // Wrap the body function in a thunk that decodes any captured state and + // passes it along. + let body: @Sendable (inout Self) async throws -> Void = { exitTest in + let values: (repeat each T) = try exitTest.capturedValues.takeCapturedValues() + try await body(repeat each values) + } + + // Construct and return the instance. + var exitTest = Self(id: id, body: body) + exitTest.capturedValues = Array(repeat (each T).self) + outValue.initializeMemory(as: Self.self, to: exitTest) return true } } @@ -338,6 +369,7 @@ extension ExitTest { /// /// - Parameters: /// - exitTestID: The unique identifier of the exit test. +/// - capturedValues: Any values captured by the exit test. /// - 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 @@ -357,6 +389,7 @@ extension ExitTest { /// convention. func callExitTest( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), + encodingCapturedValues capturedValues: [ExitTest.CapturedValue], exitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable], expression: __Expression, @@ -371,8 +404,12 @@ func callExitTest( var result: ExitTest.Result do { + // Construct a temporary/local exit test to pass to the exit test handler. var exitTest = ExitTest(id: ExitTest.ID(exitTestID)) exitTest.observedValues = observedValues + exitTest.capturedValues = capturedValues + + // Invoke the exit test handler and wait for the child process to terminate. result = try await configuration.exitTestHandler(exitTest) #if os(Windows) @@ -467,15 +504,23 @@ extension ExitTest { /// are available or the child environment is otherwise terminated. The parent /// environment is then responsible for interpreting those results and /// recording any issues that occur. - public typealias Handler = @Sendable (_ exitTest: borrowing ExitTest) async throws -> ExitTest.Result + public typealias Handler = @Sendable (_ exitTest: borrowing Self) async throws -> ExitTest.Result - /// The back channel file handle set up by the parent process. + /// Make a file handle from the string contained in the given environment + /// variable. + /// + /// - Parameters: + /// - name: The name of the environment variable to read. The value of this + /// environment variable should represent the file handle. The exact value + /// is platform-specific but is generally the file descriptor as a string. + /// - mode: The mode to open the file with, such as `"wb"`. /// - /// The value of this property is a file handle open for writing to which - /// events should be written, or `nil` if the file handle could not be - /// resolved. - private static let _backChannelForEntryPoint: FileHandle? = { - guard let backChannelEnvironmentVariable = Environment.variable(named: "SWT_EXPERIMENTAL_BACKCHANNEL") else { + /// - Returns: A new file handle, or `nil` if one could not be created. + /// + /// The effect of calling this function more than once for the same + /// environment variable is undefined. + private static func _makeFileHandle(forEnvironmentVariableNamed name: String, mode: String) -> FileHandle? { + guard let environmentVariable = Environment.variable(named: name) else { return nil } @@ -485,20 +530,55 @@ extension ExitTest { var fd: CInt? #if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) - fd = CInt(backChannelEnvironmentVariable) + fd = CInt(environmentVariable) #elseif os(Windows) - if let handle = UInt(backChannelEnvironmentVariable).flatMap(HANDLE.init(bitPattern:)) { - fd = _open_osfhandle(Int(bitPattern: handle), _O_WRONLY | _O_BINARY) + if let handle = UInt(environmentVariable).flatMap(HANDLE.init(bitPattern:)) { + var flags: CInt = switch (mode.contains("r"), mode.contains("w")) { + case (true, true): + _O_RDWR + case (true, false): + _O_RDONLY + case (false, true): + _O_WRONLY + case (false, false): + 0 + } + flags |= _O_BINARY + fd = _open_osfhandle(Int(bitPattern: handle), flags) } #else -#warning("Platform-specific implementation missing: back-channel pipe unavailable") +#warning("Platform-specific implementation missing: additional file descriptors unavailable") #endif guard let fd, fd >= 0 else { return nil } - return try? FileHandle(unsafePOSIXFileDescriptor: fd, mode: "wb") - }() + return try? FileHandle(unsafePOSIXFileDescriptor: fd, mode: mode) + } + + /// Make a string suitable for use as the value of an environment variable + /// that describes the given file handle. + /// + /// - Parameters: + /// - fileHandle: The file handle to represent. + /// + /// - Returns: A string representation of `fileHandle` that can be converted + /// back to a (new) file handle with `_makeFileHandle()`, or `nil` if the + /// file handle could not be converted to a string. + private static func _makeEnvironmentVariable(for fileHandle: borrowing FileHandle) -> String? { +#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) + return fileHandle.withUnsafePOSIXFileDescriptor { fd in + fd.map(String.init(describing:)) + } +#elseif os(Windows) + return fileHandle.withUnsafeWindowsHANDLE { handle in + handle.flatMap { String(describing: UInt(bitPattern: $0)) } + } +#else +#warning("Platform-specific implementation missing: additional file descriptors unavailable") + return nil +#endif + } /// Find the exit test function specified in the environment of the current /// process, if any. @@ -533,7 +613,7 @@ extension ExitTest { } // We can't say guard let here because it counts as a consume. - guard _backChannelForEntryPoint != nil else { + guard let backChannel = _makeFileHandle(forEnvironmentVariableNamed: "SWT_EXPERIMENTAL_BACKCHANNEL", mode: "wb") else { return result } @@ -544,9 +624,9 @@ extension ExitTest { // Only forward issue-recorded events. (If we start handling other kinds of // events in the future, we can forward them too.) let eventHandler = ABI.BackChannelVersion.eventHandler(encodeAsJSONLines: true) { json in - _ = try? _backChannelForEntryPoint?.withLock { - try _backChannelForEntryPoint?.write(json) - try _backChannelForEntryPoint?.write("\n") + _ = try? backChannel.withLock { + try backChannel.write(json) + try backChannel.write("\n") } } configuration.eventHandler = { event, eventContext in @@ -555,8 +635,11 @@ extension ExitTest { } } - result.body = { [configuration, body = result.body] in - try await Configuration.withCurrent(configuration, perform: body) + result.body = { [configuration, body = result.body] exitTest in + try await Configuration.withCurrent(configuration) { + try exitTest._decodeCapturedValuesForEntryPoint() + try await body(&exitTest) + } } return result } @@ -626,7 +709,7 @@ extension ExitTest { return result }() - return { exitTest in + @Sendable func result(_ exitTest: borrowing ExitTest) async throws -> ExitTest.Result { let childProcessExecutablePath = try childProcessExecutablePath.get() // Inherit the environment from the parent process and make any necessary @@ -679,37 +762,50 @@ extension ExitTest { 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) || os(OpenBSD) - backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafePOSIXFileDescriptor { fd in - fd.map(String.init(describing:)) - } -#elseif os(Windows) - backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafeWindowsHANDLE { handle in - handle.flatMap { String(describing: UInt(bitPattern: $0)) } - } -#else -#warning("Platform-specific implementation missing: back-channel pipe unavailable") -#endif - if let backChannelEnvironmentVariable { + // Create another pipe to send captured values (and possibly other state + // in the future) to the child process. + var capturedValuesReadEnd: FileHandle! + var capturedValuesWriteEnd: FileHandle! + try FileHandle.makePipe(readEnd: &capturedValuesReadEnd, writeEnd: &capturedValuesWriteEnd) + + // Let the child process know how to find the back channel and + // 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 } + if let capturedValuesEnvironmentVariable = _makeEnvironmentVariable(for: capturedValuesReadEnd) { + childEnvironment["SWT_EXPERIMENTAL_CAPTURED_VALUES"] = capturedValuesEnvironmentVariable + } // Spawn the child process. let processID = try withUnsafePointer(to: backChannelWriteEnd) { backChannelWriteEnd in - try spawnExecutable( - atPath: childProcessExecutablePath, - arguments: childArguments, - environment: childEnvironment, - standardOutput: stdoutWriteEnd, - standardError: stderrWriteEnd, - additionalFileHandles: [backChannelWriteEnd] - ) + try withUnsafePointer(to: capturedValuesReadEnd) { capturedValuesReadEnd in + try spawnExecutable( + atPath: childProcessExecutablePath, + arguments: childArguments, + environment: childEnvironment, + standardOutput: stdoutWriteEnd, + standardError: stderrWriteEnd, + additionalFileHandles: [backChannelWriteEnd, capturedValuesReadEnd] + ) + } } + // Write the captured values blob over the back channel to the child + // process. (If we end up needing to write additional data, we can + // define a full schema for this stream. Fortunately, both endpoints are + // implemented in the same copy of the testing library, so we don't have + // to worry about backwards-compatibility.) + try capturedValuesWriteEnd.withLock { + try exitTest._withEncodedCapturedValuesForEntryPoint { capturedValuesJSON in + try capturedValuesWriteEnd.write(capturedValuesJSON) + try capturedValuesWriteEnd.write("\n") + } + } + capturedValuesReadEnd.close() + capturedValuesWriteEnd.close() + // Await termination of the child process. taskGroup.addTask { let statusAtExit = try await wait(for: processID) @@ -750,6 +846,8 @@ extension ExitTest { return result } } + + return result } /// Read lines from the given back channel file handle and process them as @@ -797,9 +895,7 @@ extension ExitTest { // Translate the issue back into a "real" issue and record it // in the parent process. This translation is, of course, lossy // due to the process boundary, but we make a best effort. - let comments: [Comment] = event.messages.compactMap { message in - message.symbol == .details ? Comment(rawValue: message.text) : nil - } + let comments: [Comment] = event.messages.map(\.text).map(Comment.init(rawValue:)) let issueKind: Issue.Kind = if let error = issue._error { .errorCaught(error) } else { @@ -815,5 +911,62 @@ extension ExitTest { issueCopy.record() } } + + /// Decode this exit test's captured values and update its ``capturedValues`` + /// property. + /// + /// - Throws: If a captured value could not be decoded. + /// + /// This function should only be used when the process was started via the + /// `__swiftPMEntryPoint()` function. The effect of using it under other + /// configurations is undefined. + private mutating func _decodeCapturedValuesForEntryPoint() throws { + // Read the content of the captured values stream provided by the parent + // process above. + guard let fileHandle = Self._makeFileHandle(forEnvironmentVariableNamed: "SWT_EXPERIMENTAL_CAPTURED_VALUES", mode: "rb") else { + return + } + let capturedValuesJSON = try fileHandle.readToEnd() + let capturedValuesJSONLines = capturedValuesJSON.split(whereSeparator: \.isASCIINewline) + assert(capturedValues.count == capturedValuesJSONLines.count, "Expected to decode \(capturedValues.count) captured value(s) for the current exit test, but received \(capturedValuesJSONLines.count). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") + + // Walk the list of captured values' types, map them to their JSON blobs, + // and decode them. + capturedValues = try zip(capturedValues, capturedValuesJSONLines).map { capturedValue, capturedValueJSON in + var capturedValue = capturedValue + + func open(_ type: T.Type) throws -> T where T: Codable & Sendable { + return try capturedValueJSON.withUnsafeBytes { capturedValueJSON in + try JSON.decode(type, from: capturedValueJSON) + } + } + capturedValue.wrappedValue = try open(capturedValue.typeOfWrappedValue) + + return capturedValue + } + } + + /// Encode this exit test's captured values in a format suitable for passing + /// to the child process. + /// + /// - Parameters: + /// - body: A function to call. This function is called once per captured + /// value in the exit test. + /// + /// - Throws: Whatever is thrown by `body` or while encoding. + /// + /// This function produces a byte buffer representing each value in this exit + /// test's ``capturedValues`` property and passes each buffer to `body`. + /// + /// This function should only be used when the process was started via the + /// `__swiftPMEntryPoint()` function. The effect of using it under other + /// configurations is undefined. + private borrowing func _withEncodedCapturedValuesForEntryPoint(_ body: (UnsafeRawBufferPointer) throws -> Void) throws -> Void { + for capturedValue in capturedValues { + try JSON.withEncoding(of: capturedValue.wrappedValue!) { capturedValueJSON in + try JSON.asJSONLine(capturedValueJSON, body) + } + } + } } #endif diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index aa999395a..e8767d01f 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1139,9 +1139,8 @@ public func __checkClosureCall( /// Check that an expression always exits (terminates the current process) with /// a given status. /// -/// This overload is used for `await #expect(exitsWith:) { }` invocations. Note -/// that the `body` argument is thin here because it cannot meaningfully capture -/// state from the enclosing context. +/// This overload is used for `await #expect(exitsWith:) { }` invocations that +/// do not capture any state. /// /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. @@ -1149,8 +1148,8 @@ public func __checkClosureCall( public func __checkClosureCall( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), exitsWith expectedExitCondition: ExitTest.Condition, - observing observedValues: [any PartialKeyPath & Sendable], - performing body: @convention(thin) () -> Void, + observing observedValues: [any PartialKeyPath & Sendable] = [], + performing _: @convention(thin) () -> Void, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, @@ -1159,6 +1158,40 @@ public func __checkClosureCall( ) async -> Result { await callExitTest( identifiedBy: exitTestID, + encodingCapturedValues: [], + exitsWith: expectedExitCondition, + observing: observedValues, + expression: expression, + comments: comments(), + isRequired: isRequired, + sourceLocation: sourceLocation + ) +} + +/// Check that an expression always exits (terminates the current process) with +/// a given status. +/// +/// This overload is used for `await #expect(exitsWith:) { }` invocations that +/// capture some values with an explicit capture list. +/// +/// - 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), + encodingCapturedValues capturedValues: (repeat each T), + exitsWith expectedExitCondition: ExitTest.Condition, + observing observedValues: [any PartialKeyPath & Sendable] = [], + performing _: @convention(thin) () -> Void, + expression: __Expression, + comments: @autoclosure () -> [Comment], + isRequired: Bool, + isolation: isolated (any Actor)? = #isolation, + sourceLocation: SourceLocation +) async -> Result where repeat each T: Codable & Sendable { + await callExitTest( + identifiedBy: exitTestID, + encodingCapturedValues: Array(repeat each capturedValues), exitsWith: expectedExitCondition, observing: observedValues, expression: expression, diff --git a/Sources/Testing/Support/Additions/ArrayAdditions.swift b/Sources/Testing/Support/Additions/ArrayAdditions.swift index 462a330fd..eee74037d 100644 --- a/Sources/Testing/Support/Additions/ArrayAdditions.swift +++ b/Sources/Testing/Support/Additions/ArrayAdditions.swift @@ -21,3 +21,21 @@ extension Array { self = optionalValue.map { [$0] } ?? [] } } + +/// Get the number of elements in a parameter pack. +/// +/// - Parameters: +/// - pack: The parameter pack. +/// +/// - Returns: The number of elements in `pack`. +/// +/// - Complexity: O(_n_) where _n_ is the number of elements in `pack`. The +/// compiler may be able to optimize this operation when the types of `pack` +/// are statically known. +func parameterPackCount(_ pack: repeat each T) -> Int { + var result = 0 + for _ in repeat each pack { + result += 1 + } + return result +} diff --git a/Sources/Testing/Support/JSON.swift b/Sources/Testing/Support/JSON.swift index 76c7b7f07..3d656687f 100644 --- a/Sources/Testing/Support/JSON.swift +++ b/Sources/Testing/Support/JSON.swift @@ -50,6 +50,30 @@ enum JSON { #endif } + /// Post-process encoded JSON and write it to a file. + /// + /// - Parameters: + /// - json: The JSON to write. + /// - body: A function to call. A copy of `json` is passed to it with any + /// newlines removed. + /// + /// - Returns: Whatever is returned by `body`. + /// + /// - Throws: Whatever is thrown by `body`. + static func asJSONLine(_ json: UnsafeRawBufferPointer, _ body: (UnsafeRawBufferPointer) throws -> R) rethrows -> R { + if _slowPath(json.contains(where: \.isASCIINewline)) { + // Remove the newline characters to conform to JSON lines specification. + // This is not actually expected to happen in practice with Foundation's + // JSON encoder. + var json = Array(json) + json.removeAll(where: \.isASCIINewline) + return try json.withUnsafeBytes(body) + } else { + // No newlines found, no need to copy the buffer. + return try body(json) + } + } + /// Decode a value from JSON data. /// /// - Parameters: diff --git a/Sources/TestingMacros/CMakeLists.txt b/Sources/TestingMacros/CMakeLists.txt index e535f13cf..c9a579eaf 100644 --- a/Sources/TestingMacros/CMakeLists.txt +++ b/Sources/TestingMacros/CMakeLists.txt @@ -97,6 +97,7 @@ target_sources(TestingMacros PRIVATE Support/Argument.swift Support/AttributeDiscovery.swift Support/AvailabilityGuards.swift + Support/ClosureCaptureListParsing.swift Support/CommentParsing.swift Support/ConditionArgumentParsing.swift Support/DiagnosticMessage.swift diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index 8ae26bf82..326522858 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -420,29 +420,28 @@ extension ExitTestConditionMacro { _ = try Base.expansion(of: macro, in: context) var arguments = argumentList(of: macro, in: context) - let requirementIndex = arguments.firstIndex { $0.label?.tokenKind == .identifier("exitsWith") } - guard let requirementIndex else { - fatalError("Could not find the requirement for this exit test. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") - } - let observationListIndex = arguments.firstIndex { $0.label?.tokenKind == .identifier("observing") } - if observationListIndex == nil { - arguments.insert( - Argument(label: "observing", expression: ArrayExprSyntax(expressions: [])), - at: arguments.index(after: requirementIndex) - ) - } let trailingClosureIndex = arguments.firstIndex { $0.label?.tokenKind == _trailingClosureLabel.tokenKind } guard let trailingClosureIndex else { fatalError("Could not find the body argument to this exit test. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") } - // Extract the body argument and, if it's a closure with a capture list, - // emit an appropriate diagnostic. var bodyArgumentExpr = arguments[trailingClosureIndex].expression bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr - if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), - let captureClause = closureExpr.signature?.capture, - !captureClause.items.isEmpty { + + // Find any captured values and extract them from the trailing closure. + var capturedValues = [CapturedValueInfo]() + if ExitTestExpectMacro.isValueCapturingEnabled { + // The source file imports @_spi(Experimental), so allow value capturing. + if var closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), + let captureList = closureExpr.signature?.capture?.items { + closureExpr.signature?.capture = ClosureCaptureClauseSyntax(items: [], trailingTrivia: .space) + capturedValues = captureList.map { CapturedValueInfo($0, in: context) } + bodyArgumentExpr = ExprSyntax(closureExpr) + } + + } else if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), + let captureClause = closureExpr.signature?.capture, + !captureClause.items.isEmpty { context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) } @@ -454,10 +453,20 @@ extension ExitTestConditionMacro { // Implement the body of the exit test outside the enum we're declaring so // that `Self` resolves to the type containing the exit test, not the enum. let bodyThunkName = context.makeUniqueName("") + let bodyThunkParameterList = FunctionParameterListSyntax { + for capturedValue in capturedValues { + FunctionParameterSyntax( + firstName: .wildcardToken(trailingTrivia: .space), + secondName: capturedValue.name.trimmed, + colon: .colonToken(trailingTrivia: .space), + type: capturedValue.type.trimmed + ) + } + } decls.append( """ - @Sendable func \(bodyThunkName)() async throws -> Swift.Void { - return \(applyEffectfulKeywords([.try, .await, .unsafe], to: bodyArgumentExpr))() + @Sendable func \(bodyThunkName)(\(bodyThunkParameterList)) async throws { + _ = \(applyEffectfulKeywords([.try, .await, .unsafe], to: bodyArgumentExpr))() } """ ) @@ -521,12 +530,24 @@ extension ExitTestConditionMacro { } ) - // Insert the exit test's ID as the first argument. Note that this will - // invalidate all indices into `arguments`! - arguments.insert( + // Insert additional arguments at the beginning of the argument list. Note + // that this will invalidate all indices into `arguments`! + var leadingArguments = [ Argument(label: "identifiedBy", expression: idExpr), - at: arguments.startIndex - ) + ] + if !capturedValues.isEmpty { + leadingArguments.append( + Argument( + label: "encodingCapturedValues", + expression: TupleExprSyntax { + for capturedValue in capturedValues { + LabeledExprSyntax(expression: capturedValue.expression.trimmed) + } + } + ) + ) + } + arguments = leadingArguments + arguments // Replace the exit test body (as an argument to the macro) with a stub // closure that hosts the type we created above. @@ -582,6 +603,22 @@ extension ExitTestConditionMacro { } } +extension ExitTestExpectMacro { + /// Whether or not experimental value capturing via explicit capture lists is + /// enabled. + /// + /// This member is declared on ``ExitTestExpectMacro`` but also applies to + /// ``ExitTestRequireMacro``. + @TaskLocal + static var isValueCapturingEnabled: Bool = { +#if ExperimentalExitTestValueCapture + return true +#else + return false +#endif + }() +} + /// A type describing the expansion of the `#expect(exitsWith:)` macro. /// /// This type checks for nested invocations of `#expect()` and `#require()` and diff --git a/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift index a8b5063cc..9a0d31ab3 100644 --- a/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift @@ -52,3 +52,16 @@ extension EditorPlaceholderExprSyntax { self.init(type, type: type) } } + +extension TypeSyntax { + /// Construct a type syntax node containing a placeholder string. + /// + /// - Parameters: + /// - placeholder: The placeholder string, not including surrounding angle + /// brackets or pound characters. + /// + /// - Returns: A new `TypeSyntax` instance representing a placeholder. + static func placeholder(_ placeholder: String) -> Self { + return Self(IdentifierTypeSyntax(name: .identifier("<#\(placeholder)#" + ">"))) + } +} diff --git a/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift index 322a84f3a..d0f296892 100644 --- a/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift @@ -14,17 +14,18 @@ import SwiftSyntaxMacros import SwiftDiagnostics extension MacroExpansionContext { - /// Get the type of the lexical context enclosing the given node. + /// Get the type of the given lexical context. /// /// - Parameters: - /// - node: The node whose lexical context should be examined. + /// - lexicalContext: The lexical context. /// - /// - Returns: The type of the lexical context enclosing `node`, or `nil` if - /// the lexical context cannot be represented as a type. + /// - Returns: The type represented by `lexicalContext`, or `nil` if one could + /// not be derived (for example, because the lexical context inclues a + /// function, closure, or some other non-type scope.) /// /// If the lexical context includes functions, closures, or some other /// non-type scope, the value of this property is `nil`. - var typeOfLexicalContext: TypeSyntax? { + func type(ofLexicalContext lexicalContext: some RandomAccessCollection) -> TypeSyntax? { var typeNames = [String]() for lexicalContext in lexicalContext.reversed() { guard let decl = lexicalContext.asProtocol((any DeclGroupSyntax).self) else { @@ -38,6 +39,14 @@ extension MacroExpansionContext { return "\(raw: typeNames.joined(separator: "."))" } + + /// The type of the lexical context enclosing the given node. + /// + /// If the lexical context includes functions, closures, or some other + /// non-type scope, the value of this property is `nil`. + var typeOfLexicalContext: TypeSyntax? { + type(ofLexicalContext: lexicalContext) + } } // MARK: - diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift new file mode 100644 index 000000000..41abe711c --- /dev/null +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -0,0 +1,88 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023–2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +import SwiftDiagnostics +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// A type representing a value extracted from a closure's capture list. +struct CapturedValueInfo { + /// The original instance of `ClosureCaptureSyntax` used to create this value. + var capture: ClosureCaptureSyntax + + /// The name of the captured value. + var name: TokenSyntax { + let text = capture.name.textWithoutBackticks + if text.isValidSwiftIdentifier(for: .variableName) { + return capture.name + } + return .identifier("`\(text)`") + } + + /// The expression to assign to the captured value. + var expression: ExprSyntax + + /// The type of the captured value. + var type: TypeSyntax + + init(_ capture: ClosureCaptureSyntax, in context: some MacroExpansionContext) { + self.capture = capture + self.expression = "()" + self.type = "Swift.Void" + + // We don't support capture specifiers at this time. + if let specifier = capture.specifier { + context.diagnose(.specifierUnsupported(specifier, on: capture)) + return + } + + // Potentially get the name of the type comprising the current lexical + // context (i.e. whatever `Self` is.) + lazy var typeNameOfLexicalContext = { + let lexicalContext = context.lexicalContext.drop { !$0.isProtocol((any DeclGroupSyntax).self) } + return context.type(ofLexicalContext: lexicalContext) + }() + + if let initializer = capture.initializer { + // Found an initializer clause. Extract the expression it captures. + self.expression = removeParentheses(from: initializer.value) ?? initializer.value + + // Find the 'as' clause so we can determine the type of the captured value. + if let asExpr = self.expression.as(AsExprSyntax.self) { + self.type = if asExpr.questionOrExclamationMark?.tokenKind == .postfixQuestionMark { + // If the caller is using as?, make the type optional. + TypeSyntax(OptionalTypeSyntax(wrappedType: asExpr.type.trimmed)) + } else { + asExpr.type + } + } else if let selfExpr = self.expression.as(DeclReferenceExprSyntax.self), + selfExpr.baseName.tokenKind == .keyword(.self), + selfExpr.argumentNames == nil, + let typeNameOfLexicalContext { + // Copying self. + self.type = typeNameOfLexicalContext + } else { + context.diagnose(.typeOfCaptureIsAmbiguous(capture, initializedWith: initializer)) + } + + } else if capture.name.tokenKind == .keyword(.self), + let typeNameOfLexicalContext { + // Capturing self. + self.expression = "self" + self.type = typeNameOfLexicalContext + + } else { + // Not enough contextual information to derive the type here. + context.diagnose(.typeOfCaptureIsAmbiguous(capture)) + } + } +} diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index dc9defe5d..36186ec4b 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -739,22 +739,6 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } - /// Create a diagnostic message stating that a condition macro nested inside - /// an exit test will not record any diagnostics. - /// - /// - Parameters: - /// - checkMacro: The inner condition macro invocation. - /// - exitTestMacro: The containing exit test macro invocation. - /// - /// - Returns: A diagnostic message. - static func checkUnsupported(_ checkMacro: some FreestandingMacroExpansionSyntax, inExitTest exitTestMacro: some FreestandingMacroExpansionSyntax) -> Self { - Self( - syntax: Syntax(checkMacro), - message: "Expression \(_macroName(checkMacro)) will not record an issue on failure inside exit test \(_macroName(exitTestMacro))", - severity: .error - ) - } - var syntax: Syntax // MARK: - DiagnosticMessage @@ -768,6 +752,81 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { // MARK: - Captured values extension DiagnosticMessage { + /// Create a diagnostic message stating that a specifier keyword cannot be + /// used with a given closure capture list item. + /// + /// - Parameters: + /// - specifier: The invalid specifier. + /// - capture: The closure capture list item. + /// + /// - Returns: A diagnostic message. + static func specifierUnsupported(_ specifier: ClosureCaptureSpecifierSyntax, on capture: ClosureCaptureSyntax) -> Self { + Self( + syntax: Syntax(specifier), + message: "Specifier '\(specifier.trimmed)' cannot be used with captured value '\(capture.name.textWithoutBackticks)'", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Remove '\(specifier.trimmed)'"), + changes: [ + .replace( + oldNode: Syntax(capture), + newNode: Syntax(capture.with(\.specifier, nil)) + ) + ] + ), + ] + ) + } + + /// Create a diagnostic message stating that a closure capture list item's + /// type is ambiguous and must be made explicit. + /// + /// - Parameters: + /// - capture: The closure capture list item. + /// - initializerClause: The existing initializer clause, if any. + /// + /// - Returns: A diagnostic message. + static func typeOfCaptureIsAmbiguous(_ capture: ClosureCaptureSyntax, initializedWith initializerClause: InitializerClauseSyntax? = nil) -> Self { + let castValueExpr: some ExprSyntaxProtocol = if let initializerClause { + ExprSyntax(initializerClause.value.trimmed) + } else { + ExprSyntax(DeclReferenceExprSyntax(baseName: capture.name.trimmed)) + } + let initializerValueExpr = ExprSyntax( + AsExprSyntax( + expression: castValueExpr, + asKeyword: .keyword(.as, leadingTrivia: .space, trailingTrivia: .space), + type: TypeSyntax.placeholder("T") + ) + ) + let placeholderInitializerClause = if let initializerClause { + initializerClause.with(\.value, initializerValueExpr) + } else { + InitializerClauseSyntax( + equal: .equalToken(leadingTrivia: .space, trailingTrivia: .space), + value: initializerValueExpr + ) + } + + return Self( + syntax: Syntax(capture), + message: "Type of captured value '\(capture.name.textWithoutBackticks)' is ambiguous", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Add '= \(castValueExpr) as T'"), + changes: [ + .replace( + oldNode: Syntax(capture), + newNode: Syntax(capture.with(\.initializer, placeholderInitializerClause)) + ) + ] + ), + ] + ) + } + /// Create a diagnostic message stating that a capture clause cannot be used /// in an exit test. /// diff --git a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift index b8f6d125d..f67ca40ee 100644 --- a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift +++ b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift @@ -107,7 +107,7 @@ private func _makeCallToEffectfulThunk(_ thunkName: TokenSyntax, passing expr: s /// adds the keywords in `effectfulKeywords` to `expr`. func applyEffectfulKeywords(_ effectfulKeywords: Set, to expr: some ExprSyntaxProtocol) -> ExprSyntax { let originalExpr = expr - var expr = ExprSyntax(expr) + var expr = ExprSyntax(expr.trimmed) let needAwait = effectfulKeywords.contains(.await) && !expr.is(AwaitExprSyntax.self) let needTry = effectfulKeywords.contains(.try) && !expr.is(TryExprSyntax.self) diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index 07d84b0f8..cd1333941 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -383,6 +383,30 @@ struct ConditionMacroTests { #expect(diagnostic.message.contains("is redundant")) } +#if ExperimentalExitTestValueCapture + @Test("#expect(exitsWith:) produces a diagnostic for a bad capture", + arguments: [ + "#expectExitTest(exitsWith: x) { [weak a] in }": + "Specifier 'weak' cannot be used with captured value 'a'", + "#expectExitTest(exitsWith: x) { [a] in }": + "Type of captured value 'a' is ambiguous", + "#expectExitTest(exitsWith: x) { [a = b] in }": + "Type of captured value 'a' is ambiguous", + ] + ) + func exitTestCaptureDiagnostics(input: String, expectedMessage: String) throws { + try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(true) { + let (_, diagnostics) = try parse(input) + + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) + } + } + } +#endif + @Test( "Capture list on an exit test produces a diagnostic", arguments: [ @@ -391,12 +415,14 @@ struct ConditionMacroTests { ] ) func exitTestCaptureListProducesDiagnostic(input: String, expectedMessage: String) throws { - let (_, diagnostics) = try parse(input) + try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(false) { + let (_, diagnostics) = try parse(input) - #expect(diagnostics.count > 0) - for diagnostic in diagnostics { - #expect(diagnostic.diagMessage.severity == .error) - #expect(diagnostic.message == expectedMessage) + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) + } } } diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index bc3425e0a..896784f22 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -380,6 +380,83 @@ private import _TestingInternals #expect((ExitTest.current != nil) as Bool) } } + +#if ExperimentalExitTestValueCapture + @Test("Capture list") + func captureList() async { + let i = 123 + let s = "abc" as Any + await #expect(exitsWith: .success) { [i = i as Int, s = s as! String, t = (s as Any) as? String?] in + #expect(i == 123) + #expect(s == "abc") + #expect(t == "abc") + } + } + + @Test("Capture list (very long encoded form)") + func longCaptureList() async { + let count = 1 * 1024 * 1024 + let buffer = Array(repeatElement(0 as UInt8, count: count)) + await #expect(exitsWith: .success) { [count = count as Int, buffer = buffer as [UInt8]] in + #expect(buffer.count == count) + } + } + + struct CapturableSuite: Codable { + var property = 456 + + @Test("self in capture list") + func captureListWithSelf() async { + await #expect(exitsWith: .success) { [self, x = self] in + #expect(self.property == 456) + #expect(x.property == 456) + } + } + } + + class CapturableBaseClass: @unchecked Sendable, Codable { + init() {} + + required init(from decoder: any Decoder) throws {} + func encode(to encoder: any Encoder) throws {} + } + + final class CapturableDerivedClass: CapturableBaseClass, @unchecked Sendable { + let x: Int + + init(x: Int) { + self.x = x + super.init() + } + + required init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.x = try container.decode(Int.self) + super.init() + } + + override func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(x) + } + } + + @Test("Capturing an instance of a subclass") + func captureSubclass() async { + let instance = CapturableDerivedClass(x: 123) + await #expect(exitsWith: .success) { [instance = instance as CapturableBaseClass] in + #expect((instance as AnyObject) is CapturableBaseClass) + // However, because the static type of `instance` is not Derived, we won't + // be able to cast it to Derived. + #expect(!((instance as AnyObject) is CapturableDerivedClass)) + } + await #expect(exitsWith: .success) { [instance = instance as CapturableDerivedClass] in + #expect((instance as AnyObject) is CapturableBaseClass) + #expect((instance as AnyObject) is CapturableDerivedClass) + #expect(instance.x == 123) + } + } +#endif } // MARK: - Fixtures