From 6f87c4974394e7a7fa7b65188e6d8e9a250881d4 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 27 Mar 2025 15:07:24 -0400 Subject: [PATCH 01/12] Capturing values in exit tests This PR implements an experimental form of state capture in exit tests. If you specify a capture list on the test's body closure and explicitly write the type of each captured value, _and_ each value conforms to `Sendable`, `Codable`, and (implicitly) `Copyable`, we'll encode them and send them "over the wire" to the child process: ```swift let a = Int.random(in: 100 ..< 200) await #expect(exitsWith: .failure) { [a = a as Int] in assert(a > 500) } ``` This PR is incomplete. Among other details: - [] Need to properly transmit the data, not stuff it in an environment variable - [] Need to capture source location information correctly for error handling during value decoding - [] Need to implement diagnostics correctly We are ultimately constrained by the language here as we don't have real type information for the captured values, nor can we infer captures by inspecting the syntax of the exit test body (hence the need for an explicit capture list with types.) If we had something like `decltype()` we could apply during macro expansion, you wouldn't need to write `x = x as T` and could just write `x`. --- Package.swift | 8 + Package@swift-6.1.swift | 310 ++++++++++++++++++ .../Testing/ABI/ABI.Record+Streaming.swift | 35 +- Sources/Testing/CMakeLists.txt | 1 + .../ExitTests/ExitTest.CapturedValue.swift | 179 ++++++++++ Sources/Testing/ExitTests/ExitTest.swift | 306 +++++++++++++---- .../ExpectationChecking+Macro.swift | 28 ++ .../Support/Additions/ArrayAdditions.swift | 18 + Sources/Testing/Support/JSON.swift | 24 ++ Sources/TestingMacros/CMakeLists.txt | 1 + Sources/TestingMacros/ConditionMacro.swift | 68 +++- ...EditorPlaceholderExprSyntaxAdditions.swift | 13 + .../MacroExpansionContextAdditions.swift | 19 +- .../Support/ClosureCaptureListParsing.swift | 84 +++++ .../Support/DiagnosticMessage.swift | 101 +++++- .../ConditionMacroTests.swift | 22 ++ Tests/TestingTests/ExitTestTests.swift | 75 +++++ 17 files changed, 1165 insertions(+), 127 deletions(-) create mode 100644 Package@swift-6.1.swift create mode 100644 Sources/Testing/ExitTests/ExitTest.CapturedValue.swift create mode 100644 Sources/TestingMacros/Support/ClosureCaptureListParsing.swift diff --git a/Package.swift b/Package.swift index 8085d7bc8..8c3bc7e1a 100644 --- a/Package.swift +++ b/Package.swift @@ -221,6 +221,14 @@ extension Array where Element == PackageDescription.SwiftSetting { .define("SWT_NO_PIPES", .when(platforms: [.wasi])), ] + // Unconditionally enable 'ExperimentalExitTestValueCapture' when building + // for development. + if buildingForDevelopment { + result += [ + .define("ExperimentalExitTestValueCapture") + ] + } + return result } diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift new file mode 100644 index 000000000..8bc1161fc --- /dev/null +++ b/Package@swift-6.1.swift @@ -0,0 +1,310 @@ +// 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 +// 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 PackageDescription +import CompilerPluginSupport + +/// Information about the current state of the package's git repository. +let git = Context.gitInformation + +/// Whether or not this package is being built for development rather than +/// distribution as a package dependency. +let buildingForDevelopment = (git?.currentTag == nil) + +let package = Package( + name: "swift-testing", + + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + .macCatalyst(.v13), + .visionOS(.v1), + ], + + products: { + var result = [Product]() + +#if os(Windows) + result.append( + .library( + name: "Testing", + type: .dynamic, // needed so Windows exports ABI entry point symbols + targets: ["Testing"] + ) + ) +#else + result.append( + .library( + name: "Testing", + targets: ["Testing"] + ) + ) +#endif + + result.append( + .library( + name: "_TestDiscovery", + type: .static, + targets: ["_TestDiscovery"] + ) + ) + + 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: "601.0.0-latest"), + ], + + targets: [ + .target( + name: "Testing", + dependencies: [ + "_TestDiscovery", + "_TestingInternals", + "TestingMacros", + ], + exclude: ["CMakeLists.txt", "Testing.swiftcrossimport"], + cxxSettings: .packageSettings, + swiftSettings: .packageSettings + .enableLibraryEvolution(), + linkerSettings: [ + .linkedLibrary("execinfo", .when(platforms: [.custom("freebsd"), .openbsd])) + ] + ), + .testTarget( + name: "TestingTests", + dependencies: [ + "Testing", + "_Testing_CoreGraphics", + "_Testing_Foundation", + ], + swiftSettings: .packageSettings + ), + + .macro( + name: "TestingMacros", + dependencies: [ + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + exclude: ["CMakeLists.txt"], + swiftSettings: .packageSettings + { + var result = [PackageDescription.SwiftSetting]() + + // The only target which needs the ability to import this macro + // implementation target's module is its unit test target. Users of the + // macros this target implements use them via their declarations in the + // Testing module. This target's module is never distributed to users, + // but as an additional guard against accidental misuse, this specifies + // the unit test target as the only allowable client. + if buildingForDevelopment { + result.append(.unsafeFlags(["-Xfrontend", "-allowable-client", "-Xfrontend", "TestingMacrosTests"])) + } + + return result + }() + ), + + // "Support" targets: These targets are not meant to be used directly by + // test authors. + .target( + name: "_TestingInternals", + exclude: ["CMakeLists.txt"], + cxxSettings: .packageSettings + ), + .target( + name: "_TestDiscovery", + dependencies: ["_TestingInternals",], + exclude: ["CMakeLists.txt"], + cxxSettings: .packageSettings, + swiftSettings: .packageSettings + ), + + // Cross-import overlays (not supported by Swift Package Manager) + .target( + name: "_Testing_CoreGraphics", + dependencies: [ + "Testing", + ], + path: "Sources/Overlays/_Testing_CoreGraphics", + swiftSettings: .packageSettings + .enableLibraryEvolution() + ), + .target( + name: "_Testing_Foundation", + dependencies: [ + "Testing", + ], + path: "Sources/Overlays/_Testing_Foundation", + exclude: ["CMakeLists.txt"], + // The Foundation module only has Library Evolution enabled on Apple + // platforms, and since this target's module publicly imports Foundation, + // it can only enable Library Evolution itself on those platforms. + swiftSettings: .packageSettings + .enableLibraryEvolution(applePlatformsOnly: true) + ), + + // Utility targets: These are utilities intended for use when developing + // this package, not for distribution. + .executableTarget( + name: "SymbolShowcase", + dependencies: [ + "Testing", + ], + swiftSettings: .packageSettings + ), + ], + + cxxLanguageStandard: .cxx20 +) + +// BUG: swift-package-manager-#6367 +#if !os(Windows) && !os(FreeBSD) && !os(OpenBSD) +package.targets.append(contentsOf: [ + .testTarget( + name: "TestingMacrosTests", + dependencies: [ + "Testing", + "TestingMacros", + ], + swiftSettings: .packageSettings + ) +]) +#endif + +extension Array where Element == PackageDescription.SwiftSetting { + /// Settings intended to be applied to every Swift target in this package. + /// Analogous to project-level build settings in an Xcode project. + static var packageSettings: Self { + var result = availabilityMacroSettings + + if buildingForDevelopment { + result.append(.unsafeFlags(["-require-explicit-sendable"])) + } + + result += [ + .enableUpcomingFeature("ExistentialAny"), + + .enableExperimentalFeature("AccessLevelOnImport"), + .enableUpcomingFeature("InternalImportsByDefault"), + + .enableUpcomingFeature("MemberImportVisibility"), + + // This setting is enabled in the package, but not in the toolchain build + // (via CMake). Enabling it is dependent on acceptance of the @section + // proposal via Swift Evolution. + .enableExperimentalFeature("SymbolLinkageMarkers"), + + // When building as a package, the macro plugin always builds as an + // executable rather than a library. + .define("SWT_NO_LIBRARY_MACRO_PLUGINS"), + + .define("SWT_TARGET_OS_APPLE", .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS])), + + .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), + .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), + .define("SWT_NO_PIPES", .when(platforms: [.wasi])), + ] + + // Unconditionally enable 'ExperimentalExitTestValueCapture' when building + // for development. + if buildingForDevelopment { + result += [ + .define("ExperimentalExitTestValueCapture") + ] + } + + return result + } + + /// Settings which define commonly-used OS availability macros. + /// + /// These leverage a pseudo-experimental feature in the Swift compiler for + /// setting availability definitions, which was added in + /// [swift#65218](https://github.com/swiftlang/swift/pull/65218). + private static var availabilityMacroSettings: Self { + [ + .enableExperimentalFeature("AvailabilityMacro=_mangledTypeNameAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0"), + .enableExperimentalFeature("AvailabilityMacro=_uttypesAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0"), + .enableExperimentalFeature("AvailabilityMacro=_backtraceAsyncAPI:macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0"), + .enableExperimentalFeature("AvailabilityMacro=_clockAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0"), + .enableExperimentalFeature("AvailabilityMacro=_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"), + .enableExperimentalFeature("AvailabilityMacro=_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"), + .enableExperimentalFeature("AvailabilityMacro=_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"), + + .enableExperimentalFeature("AvailabilityMacro=_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0"), + ] + } + + /// Create a Swift setting which enables Library Evolution, optionally + /// constraining it to only Apple platforms. + /// + /// - Parameters: + /// - applePlatformsOnly: Whether to constrain this setting to only Apple + /// platforms. + static func enableLibraryEvolution(applePlatformsOnly: Bool = false) -> Self { + var result = [PackageDescription.SwiftSetting]() + + if buildingForDevelopment { + var condition: BuildSettingCondition? + if applePlatformsOnly { + condition = .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS]) + } + result.append(.unsafeFlags(["-enable-library-evolution"], condition)) + } + + return result + } +} + +extension Array where Element == PackageDescription.CXXSetting { + /// Settings intended to be applied to every C++ target in this package. + /// Analogous to project-level build settings in an Xcode project. + static var packageSettings: Self { + var result = Self() + + result += [ + .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), + .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), + .define("SWT_NO_PIPES", .when(platforms: [.wasi])), + ] + + // Capture the testing library's version as a C++ string constant. + if let git { + let testingLibraryVersion = if let tag = git.currentTag { + tag + } else if git.hasUncommittedChanges { + "\(git.currentCommit) (modified)" + } else { + git.currentCommit + } + result.append(.define("SWT_TESTING_LIBRARY_VERSION", to: #""\#(testingLibraryVersion)""#)) + } + + 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 7e07636d5..0dbe89450 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..e02bb6772 --- /dev/null +++ b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift @@ -0,0 +1,179 @@ +// +// 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 { +#if ExperimentalExitTestValueCapture + /// 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 + } + } + } +#else + /// A placeholder type for ``CapturedValue`` when value capturing is disabled. + typealias CapturedValue = Never +#endif +} + +// MARK: - Collection conveniences + +#if ExperimentalExitTestValueCapture +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)) + } +} +#endif + +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 { +#if ExperimentalExitTestValueCapture + // 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 +#else + fatalError("Unimplemented") +#endif + } + + 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 a38c7592e..454da7eb1 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -64,10 +64,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``. /// @@ -103,6 +106,41 @@ public struct ExitTest: Sendable, ~Copyable { _observedValues = newValue } } + +#if ExperimentalExitTestValueCapture + /// 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]() +#else + /// Placeholder for ``capturedValues`` when value capturing is disabled. + var capturedValues: EmptyCollection { + EmptyCollection() + } +#endif + + /// Make a copy of this instance. + /// + /// - Returns: A copy of this instance. + /// + /// This function is unsafe because if the caller is not careful, it could + /// invoke the same exit test twice. + fileprivate borrowing func unsafeCopy() -> Self { + var result = Self(id: id, body: body) + result._observedValues = _observedValues +#if ExperimentalExitTestValueCapture + result.capturedValues = capturedValues +#endif + return result + } } #if !SWT_NO_EXIT_TESTS @@ -122,7 +160,7 @@ extension ExitTest { let exitTest: ExitTest? init(exitTest: borrowing ExitTest) { - self.exitTest = ExitTest(id: exitTest.id, body: exitTest.body, _observedValues: exitTest._observedValues) + self.exitTest = exitTest.unsafeCopy() } } @@ -235,7 +273,7 @@ extension ExitTest { } do { - try await body() + try await body(&self) } catch { _errorInMain(error) } @@ -269,23 +307,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), - _ 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 { + // 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 } + + // 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) +#if ExperimentalExitTestValueCapture + exitTest.capturedValues = Array(repeat (each T).self) +#endif + outValue.initializeMemory(as: Self.self, to: exitTest) return true } } @@ -326,6 +380,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 @@ -343,8 +398,9 @@ extension ExitTest { /// This function contains the common implementation for all /// `await #expect(exitsWith:) { }` invocations regardless of calling /// convention. -func callExitTest( +func callExitTest( identifiedBy exitTestID: (UInt64, UInt64), + encodingCapturedValues capturedValues: (repeat each T), exitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable], expression: __Expression, @@ -352,15 +408,21 @@ func callExitTest( isRequired: Bool, isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation -) async -> Result { +) async -> Result where repeat each T: Codable & Sendable { 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: 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 +#if ExperimentalExitTestValueCapture + exitTest.capturedValues = Array(repeat each capturedValues) +#endif + + // Invoke the exit test handler and wait for the child process to terminate. result = try await configuration.exitTestHandler(exitTest) #if os(Windows) @@ -455,15 +517,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. /// - /// 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 { + /// - 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"`. + /// + /// - 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 } @@ -473,20 +543,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. @@ -521,7 +626,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 } @@ -532,9 +637,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 @@ -543,8 +648,13 @@ 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) { +#if ExperimentalExitTestValueCapture + try exitTest._decodeCapturedValuesForEntryPoint() +#endif + try await body(&exitTest) + } } return result } @@ -570,7 +680,7 @@ extension ExitTest { let parentArguments = CommandLine.arguments #if SWT_TARGET_OS_APPLE lazy var xctestTargetPath = Environment.variable(named: "XCTestBundlePath") - ?? parentArguments.dropFirst().last + ?? parentArguments.dropFirst().last // If the running executable appears to be the XCTest runner executable in // Xcode, figure out the path to the running XCTest bundle. If we can find // it, then we can re-run the host XCTestCase instance. @@ -614,7 +724,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 @@ -667,37 +777,64 @@ 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)) } - } +#if ExperimentalExitTestValueCapture + // 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) #else -#warning("Platform-specific implementation missing: back-channel pipe unavailable") + // A placeholder variable with this name (to make it easier to get a + // pointer to it when we call spawnExecutable().) + let capturedValuesReadEnd: Void #endif - if let backChannelEnvironmentVariable { + + // 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 ExperimentalExitTestValueCapture + if let capturedValuesEnvironmentVariable = _makeEnvironmentVariable(for: capturedValuesReadEnd) { + childEnvironment["SWT_EXPERIMENTAL_CAPTURED_VALUES"] = capturedValuesEnvironmentVariable + } +#endif // 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 + var additionalFileHandles = [backChannelWriteEnd] +#if ExperimentalExitTestValueCapture + additionalFileHandles.append(capturedValuesReadEnd) +#endif + return try spawnExecutable( + atPath: childProcessExecutablePath, + arguments: childArguments, + environment: childEnvironment, + standardOutput: stdoutWriteEnd, + standardError: stderrWriteEnd, + additionalFileHandles: additionalFileHandles + ) + } } +#if ExperimentalExitTestValueCapture + // 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() +#endif + // Await termination of the child process. taskGroup.addTask { let statusAtExit = try await wait(for: processID) @@ -738,6 +875,8 @@ extension ExitTest { return result } } + + return result } /// Read lines from the given back channel file handle and process them as @@ -785,9 +924,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 { @@ -803,5 +940,64 @@ extension ExitTest { issueCopy.record() } } + +#if ExperimentalExitTestValueCapture + /// 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 } #endif diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index 7254ad049..7c934e672 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1145,6 +1145,32 @@ public func __checkClosureCall( /// /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. +#if ExperimentalExitTestValueCapture +@_spi(Experimental) +public func __checkClosureCall( + identifiedBy exitTestID: (UInt64, UInt64), + encodingCapturedValues capturedValues: (repeat each T), + exitsWith expectedExitCondition: ExitTest.Condition, + observing observedValues: [any PartialKeyPath & Sendable], + performing body: @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: (repeat each capturedValues), + exitsWith: expectedExitCondition, + observing: observedValues, + expression: expression, + comments: comments(), + isRequired: isRequired, + sourceLocation: sourceLocation + ) +} +#else @_spi(Experimental) public func __checkClosureCall( identifiedBy exitTestID: (UInt64, UInt64), @@ -1159,6 +1185,7 @@ public func __checkClosureCall( ) async -> Result { await callExitTest( identifiedBy: exitTestID, + encodingCapturedValues: (), exitsWith: expectedExitCondition, observing: observedValues, expression: expression, @@ -1168,6 +1195,7 @@ public func __checkClosureCall( ) } #endif +#endif // MARK: - 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 72184f94b..55879736d 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 c82acd725..2d099291c 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -421,15 +421,15 @@ 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 conditionIndex = arguments.firstIndex { $0.label?.tokenKind == .identifier("exitsWith") } + guard let conditionIndex else { + fatalError("Could not find the condition 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) + at: arguments.index(after: conditionIndex) ) } let trailingClosureIndex = arguments.firstIndex { $0.label?.tokenKind == _trailingClosureLabel.tokenKind } @@ -437,15 +437,27 @@ extension ExitTestConditionMacro { 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 +#if ExperimentalExitTestValueCapture + // Find any captured values and extract them from the trailing closure. + var capturedValues = [CapturedValueInfo]() bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr - if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), - let captureClause = closureExpr.signature?.capture, - !captureClause.items.isEmpty { - context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) + 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 + do { + let bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr + if var closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), + let captureClause = closureExpr.signature?.capture, + !captureClause.items.isEmpty { + context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) + } + } +#endif // Generate a unique identifier for this exit test. let idExpr = _makeExitTestIDExpr(for: macro, in: context) @@ -455,10 +467,22 @@ 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 { +#if ExperimentalExitTestValueCapture + for capturedValue in capturedValues { + FunctionParameterSyntax( + firstName: .wildcardToken(trailingTrivia: .space), + secondName: capturedValue.name.trimmed, + colon: .colonToken(trailingTrivia: .space), + type: capturedValue.type.trimmed + ) + } +#endif + } decls.append( """ - @Sendable func \(bodyThunkName)() async throws -> Swift.Void { - return try await Testing.__requiringTry(Testing.__requiringAwait(\(bodyArgumentExpr.trimmed)))() + @Sendable func \(bodyThunkName)(\(bodyThunkParameterList)) async throws { + _ = try await Testing.__requiringTry(Testing.__requiringAwait(\(bodyArgumentExpr.trimmed)))() } """ ) @@ -522,12 +546,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 ExperimentalExitTestValueCapture + leadingArguments.append( + Argument( + label: "encodingCapturedValues", + expression: TupleExprSyntax { + for capturedValue in capturedValues { + LabeledExprSyntax(expression: capturedValue.expression.trimmed) + } + } + ), ) +#endif + 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. 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..55e72b2b6 --- /dev/null +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -0,0 +1,84 @@ +// +// 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 ExperimentalExitTestValueCapture +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 + } + + if let initializer = capture.initializer { + // Found an initializer clause. Extract the expression it captures. + self.expression = initializer.value + + // Find the 'as' clause so we can determine the type of the captured value. + guard let asExpr = (removeParentheses(from: expression) ?? expression).as(AsExprSyntax.self) else { + context.diagnose(.typeOfCaptureIsAmbiguous(capture, initializedWith: initializer)) + return + } + + self.type = if asExpr.questionOrExclamationMark?.tokenKind == .postfixQuestionMark { + // If the caller us using as?, make the type optional. + TypeSyntax(OptionalTypeSyntax(wrappedType: type.trimmed)) + } else { + asExpr.type + } + + } else if capture.name.tokenKind == .keyword(.self) { + // Capturing self is special-cased if we can find the type name in the + // enclosing scope. + var lexicalContext = context.lexicalContext[...] + lexicalContext = lexicalContext.drop { !$0.isProtocol((any DeclGroupSyntax).self) } + if let typeName = context.type(ofLexicalContext: lexicalContext) { + self.expression = "self" + self.type = typeName + } else { + context.diagnose(.typeOfCaptureIsAmbiguous(capture)) + } + + } else { + // Not enough contextual information to derive the type here. + context.diagnose(.typeOfCaptureIsAmbiguous(capture)) + } + } +} +#endif diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index dc9defe5d..9c09e32f2 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,82 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { // MARK: - Captured values extension DiagnosticMessage { +#if ExperimentalExitTestValueCapture + /// 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)) + ) + ] + ), + ] + ) + } +#else /// Create a diagnostic message stating that a capture clause cannot be used /// in an exit test. /// @@ -778,6 +838,12 @@ extension DiagnosticMessage { /// /// - Returns: A diagnostic message. static func captureClauseUnsupported(_ captureClause: ClosureCaptureClauseSyntax, in closure: ClosureExprSyntax, inExitTest exitTestMacro: some FreestandingMacroExpansionSyntax) -> Self { +#if SWIFT_PACKAGE + let message = "Capture clause in closure passed to \(_macroName(exitTestMacro)) requires that the 'ExperimentalExitTestValueCapture' trait be enabled for package 'swift-testing'" +#else + let message = "Cannot specify a capture clause in closure passed to \(_macroName(exitTestMacro))" +#endif + let changes: [FixIt.Change] if let signature = closure.signature, Array(signature.with(\.capture, nil).tokens(viewMode: .sourceAccurate)).count == 1 { @@ -801,7 +867,7 @@ extension DiagnosticMessage { return Self( syntax: Syntax(captureClause), - message: "Cannot specify a capture clause in closure passed to \(_macroName(exitTestMacro))", + message: message, severity: .error, fixIts: [ FixIt( @@ -811,4 +877,5 @@ extension DiagnosticMessage { ] ) } +#endif } diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index 07d84b0f8..56b93fa0b 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -383,6 +383,27 @@ 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 { + let (_, diagnostics) = try parse(input) + + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) + } + } +#else @Test( "Capture list on an exit test produces a diagnostic", arguments: [ @@ -399,6 +420,7 @@ struct ConditionMacroTests { #expect(diagnostic.message == expectedMessage) } } +#endif @Test("Macro expansion is performed within a test function") func macroExpansionInTestFunction() throws { diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index bc3425e0a..428144a67 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -380,6 +380,81 @@ 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] in + #expect(i == 123) + #expect(s == "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] in + #expect(self.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 From 1e8fa377941ef69d5939484cbe5051377bcaca44 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 7 Apr 2025 10:18:51 -0400 Subject: [PATCH 02/12] Support capturing self via assignment to another name --- .../Support/ClosureCaptureListParsing.swift | 47 +++++++++++-------- Tests/TestingTests/ExitTestTests.swift | 3 +- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift index 55e72b2b6..1a85f46c7 100644 --- a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -46,34 +46,41 @@ struct CapturedValueInfo { return } + // Potentially get the name of the type comprising the current lexical + // context (i.e. whatever `Self` is.) + let 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 = initializer.value + self.expression = removeParentheses(from: initializer.value) ?? initializer.value // Find the 'as' clause so we can determine the type of the captured value. - guard let asExpr = (removeParentheses(from: expression) ?? expression).as(AsExprSyntax.self) else { - context.diagnose(.typeOfCaptureIsAmbiguous(capture, initializedWith: initializer)) - return - } + 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: type.trimmed)) + } else { + asExpr.type + } - self.type = if asExpr.questionOrExclamationMark?.tokenKind == .postfixQuestionMark { - // If the caller us using as?, make the type optional. - TypeSyntax(OptionalTypeSyntax(wrappedType: type.trimmed)) + } 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 { - asExpr.type + context.diagnose(.typeOfCaptureIsAmbiguous(capture, initializedWith: initializer)) } - } else if capture.name.tokenKind == .keyword(.self) { - // Capturing self is special-cased if we can find the type name in the - // enclosing scope. - var lexicalContext = context.lexicalContext[...] - lexicalContext = lexicalContext.drop { !$0.isProtocol((any DeclGroupSyntax).self) } - if let typeName = context.type(ofLexicalContext: lexicalContext) { - self.expression = "self" - self.type = typeName - } else { - context.diagnose(.typeOfCaptureIsAmbiguous(capture)) - } + } 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. diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index 428144a67..deb19bc43 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -406,8 +406,9 @@ private import _TestingInternals @Test("self in capture list") func captureListWithSelf() async { - await #expect(exitsWith: .success) { [self] in + await #expect(exitsWith: .success) { [self, x = self] in #expect(self.property == 456) + #expect(x.property == 456) } } } From ff837170d219a11339133cb03e63fcebda690da9 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 7 Apr 2025 10:19:59 -0400 Subject: [PATCH 03/12] Fix typo --- Sources/TestingMacros/Support/ClosureCaptureListParsing.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift index 1a85f46c7..2316b5b4b 100644 --- a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -65,7 +65,6 @@ struct CapturedValueInfo { } else { asExpr.type } - } else if let selfExpr = self.expression.as(DeclReferenceExprSyntax.self), selfExpr.baseName.tokenKind == .keyword(.self), selfExpr.argumentNames == nil, From bdafeea35c42aa8d68f610a7d0e0dbf29765399c Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 8 Apr 2025 00:39:15 -0400 Subject: [PATCH 04/12] Fix incorrect merge from main --- Sources/Testing/Expectations/ExpectationChecking+Macro.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index f1ddae480..71c62753b 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1148,7 +1148,7 @@ public func __checkClosureCall( #if ExperimentalExitTestValueCapture @_spi(Experimental) public func __checkClosureCall( - identifiedBy exitTestID: (UInt64, UInt64), + identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), encodingCapturedValues capturedValues: (repeat each T), exitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable], From ee3109d09c8c58f256821cbea7362b9504e92b38 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 9 Apr 2025 11:59:54 -0400 Subject: [PATCH 05/12] Update Package.swift to use the 6.1 package manifest format, remove duplicate Package.swift file --- Package.swift | 114 ++++----------- Package@swift-6.1.swift | 310 ---------------------------------------- 2 files changed, 27 insertions(+), 397 deletions(-) delete mode 100644 Package@swift-6.1.swift diff --git a/Package.swift b/Package.swift index 57a42ee71..83994a6dd 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 @@ -20,49 +20,17 @@ let git = Context.gitInformation /// distribution as a package dependency. let buildingForDevelopment = (git?.currentTag == nil) -/// Whether or not this package is being built for Embedded Swift. -/// -/// This value is `true` if `SWT_EMBEDDED` is set in the environment to `true` -/// when `swift build` is invoked. This inference is experimental and is subject -/// to change in the future. -/// -/// - Bug: There is currently no way for us to tell if we are being asked to -/// build for an Embedded Swift target at the package manifest level. -/// ([swift-syntax-#8431](https://github.com/swiftlang/swift-package-manager/issues/8431)) -let buildingForEmbedded: Bool = { - guard let envvar = Context.environment["SWT_EMBEDDED"] else { - return false - } - return Bool(envvar) ?? ((Int(envvar) ?? 0) != 0) -}() - let package = Package( name: "swift-testing", - platforms: { - if !buildingForEmbedded { - [ - .macOS(.v10_15), - .iOS(.v13), - .watchOS(.v6), - .tvOS(.v13), - .macCatalyst(.v13), - .visionOS(.v1), - ] - } else { - // Open-source main-branch toolchains (currently required to build this - // package for Embedded Swift) have higher Apple platform deployment - // targets than we would otherwise require. - [ - .macOS(.v14), - .iOS(.v18), - .watchOS(.v10), - .tvOS(.v18), - .macCatalyst(.v18), - .visionOS(.v1), - ] - } - }(), + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + .macCatalyst(.v13), + .visionOS(.v1), + ], products: { var result = [Product]() @@ -95,6 +63,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: "601.0.0-latest"), ], @@ -217,31 +192,6 @@ package.targets.append(contentsOf: [ ]) #endif -extension BuildSettingCondition { - /// Creates a build setting condition that evaluates to `true` for Embedded - /// Swift. - /// - /// - Parameters: - /// - nonEmbeddedCondition: The value to return if the target is not - /// Embedded Swift. If `nil`, the build condition evaluates to `false`. - /// - /// - Returns: A build setting condition that evaluates to `true` for Embedded - /// Swift or is equal to `nonEmbeddedCondition` for non-Embedded Swift. - static func whenEmbedded(or nonEmbeddedCondition: @autoclosure () -> Self? = nil) -> Self? { - if !buildingForEmbedded { - if let nonEmbeddedCondition = nonEmbeddedCondition() { - nonEmbeddedCondition - } else { - // The caller did not supply a fallback. - .when(platforms: []) - } - } else { - // Enable unconditionally because the target is Embedded Swift. - nil - } - } -} - extension Array where Element == PackageDescription.SwiftSetting { /// Settings intended to be applied to every Swift target in this package. /// Analogous to project-level build settings in an Xcode project. @@ -252,10 +202,6 @@ extension Array where Element == PackageDescription.SwiftSetting { result.append(.unsafeFlags(["-require-explicit-sendable"])) } - if buildingForEmbedded { - result.append(.enableExperimentalFeature("Embedded")) - } - result += [ .enableUpcomingFeature("ExistentialAny"), @@ -275,14 +221,11 @@ extension Array where Element == PackageDescription.SwiftSetting { .define("SWT_TARGET_OS_APPLE", .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS])), - .define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), - .define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), - .define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android]))), - .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), - .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), - - .define("SWT_NO_LEGACY_TEST_DISCOVERY", .whenEmbedded()), - .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), + .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), + .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), + .define("SWT_NO_PIPES", .when(platforms: [.wasi])), ] // Unconditionally enable 'ExperimentalExitTestValueCapture' when building @@ -343,14 +286,11 @@ extension Array where Element == PackageDescription.CXXSetting { var result = Self() result += [ - .define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), - .define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), - .define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android]))), - .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), - .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), - - .define("SWT_NO_LEGACY_TEST_DISCOVERY", .whenEmbedded()), - .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), + .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), + .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), + .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), + .define("SWT_NO_PIPES", .when(platforms: [.wasi])), ] // Capture the testing library's version as a C++ string constant. diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift deleted file mode 100644 index 8bc1161fc..000000000 --- a/Package@swift-6.1.swift +++ /dev/null @@ -1,310 +0,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 -// 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 PackageDescription -import CompilerPluginSupport - -/// Information about the current state of the package's git repository. -let git = Context.gitInformation - -/// Whether or not this package is being built for development rather than -/// distribution as a package dependency. -let buildingForDevelopment = (git?.currentTag == nil) - -let package = Package( - name: "swift-testing", - - platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .watchOS(.v6), - .tvOS(.v13), - .macCatalyst(.v13), - .visionOS(.v1), - ], - - products: { - var result = [Product]() - -#if os(Windows) - result.append( - .library( - name: "Testing", - type: .dynamic, // needed so Windows exports ABI entry point symbols - targets: ["Testing"] - ) - ) -#else - result.append( - .library( - name: "Testing", - targets: ["Testing"] - ) - ) -#endif - - result.append( - .library( - name: "_TestDiscovery", - type: .static, - targets: ["_TestDiscovery"] - ) - ) - - 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: "601.0.0-latest"), - ], - - targets: [ - .target( - name: "Testing", - dependencies: [ - "_TestDiscovery", - "_TestingInternals", - "TestingMacros", - ], - exclude: ["CMakeLists.txt", "Testing.swiftcrossimport"], - cxxSettings: .packageSettings, - swiftSettings: .packageSettings + .enableLibraryEvolution(), - linkerSettings: [ - .linkedLibrary("execinfo", .when(platforms: [.custom("freebsd"), .openbsd])) - ] - ), - .testTarget( - name: "TestingTests", - dependencies: [ - "Testing", - "_Testing_CoreGraphics", - "_Testing_Foundation", - ], - swiftSettings: .packageSettings - ), - - .macro( - name: "TestingMacros", - dependencies: [ - .product(name: "SwiftDiagnostics", package: "swift-syntax"), - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), - .product(name: "SwiftParser", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - ], - exclude: ["CMakeLists.txt"], - swiftSettings: .packageSettings + { - var result = [PackageDescription.SwiftSetting]() - - // The only target which needs the ability to import this macro - // implementation target's module is its unit test target. Users of the - // macros this target implements use them via their declarations in the - // Testing module. This target's module is never distributed to users, - // but as an additional guard against accidental misuse, this specifies - // the unit test target as the only allowable client. - if buildingForDevelopment { - result.append(.unsafeFlags(["-Xfrontend", "-allowable-client", "-Xfrontend", "TestingMacrosTests"])) - } - - return result - }() - ), - - // "Support" targets: These targets are not meant to be used directly by - // test authors. - .target( - name: "_TestingInternals", - exclude: ["CMakeLists.txt"], - cxxSettings: .packageSettings - ), - .target( - name: "_TestDiscovery", - dependencies: ["_TestingInternals",], - exclude: ["CMakeLists.txt"], - cxxSettings: .packageSettings, - swiftSettings: .packageSettings - ), - - // Cross-import overlays (not supported by Swift Package Manager) - .target( - name: "_Testing_CoreGraphics", - dependencies: [ - "Testing", - ], - path: "Sources/Overlays/_Testing_CoreGraphics", - swiftSettings: .packageSettings + .enableLibraryEvolution() - ), - .target( - name: "_Testing_Foundation", - dependencies: [ - "Testing", - ], - path: "Sources/Overlays/_Testing_Foundation", - exclude: ["CMakeLists.txt"], - // The Foundation module only has Library Evolution enabled on Apple - // platforms, and since this target's module publicly imports Foundation, - // it can only enable Library Evolution itself on those platforms. - swiftSettings: .packageSettings + .enableLibraryEvolution(applePlatformsOnly: true) - ), - - // Utility targets: These are utilities intended for use when developing - // this package, not for distribution. - .executableTarget( - name: "SymbolShowcase", - dependencies: [ - "Testing", - ], - swiftSettings: .packageSettings - ), - ], - - cxxLanguageStandard: .cxx20 -) - -// BUG: swift-package-manager-#6367 -#if !os(Windows) && !os(FreeBSD) && !os(OpenBSD) -package.targets.append(contentsOf: [ - .testTarget( - name: "TestingMacrosTests", - dependencies: [ - "Testing", - "TestingMacros", - ], - swiftSettings: .packageSettings - ) -]) -#endif - -extension Array where Element == PackageDescription.SwiftSetting { - /// Settings intended to be applied to every Swift target in this package. - /// Analogous to project-level build settings in an Xcode project. - static var packageSettings: Self { - var result = availabilityMacroSettings - - if buildingForDevelopment { - result.append(.unsafeFlags(["-require-explicit-sendable"])) - } - - result += [ - .enableUpcomingFeature("ExistentialAny"), - - .enableExperimentalFeature("AccessLevelOnImport"), - .enableUpcomingFeature("InternalImportsByDefault"), - - .enableUpcomingFeature("MemberImportVisibility"), - - // This setting is enabled in the package, but not in the toolchain build - // (via CMake). Enabling it is dependent on acceptance of the @section - // proposal via Swift Evolution. - .enableExperimentalFeature("SymbolLinkageMarkers"), - - // When building as a package, the macro plugin always builds as an - // executable rather than a library. - .define("SWT_NO_LIBRARY_MACRO_PLUGINS"), - - .define("SWT_TARGET_OS_APPLE", .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS])), - - .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), - .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), - .define("SWT_NO_PIPES", .when(platforms: [.wasi])), - ] - - // Unconditionally enable 'ExperimentalExitTestValueCapture' when building - // for development. - if buildingForDevelopment { - result += [ - .define("ExperimentalExitTestValueCapture") - ] - } - - return result - } - - /// Settings which define commonly-used OS availability macros. - /// - /// These leverage a pseudo-experimental feature in the Swift compiler for - /// setting availability definitions, which was added in - /// [swift#65218](https://github.com/swiftlang/swift/pull/65218). - private static var availabilityMacroSettings: Self { - [ - .enableExperimentalFeature("AvailabilityMacro=_mangledTypeNameAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0"), - .enableExperimentalFeature("AvailabilityMacro=_uttypesAPI:macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0"), - .enableExperimentalFeature("AvailabilityMacro=_backtraceAsyncAPI:macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0"), - .enableExperimentalFeature("AvailabilityMacro=_clockAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0"), - .enableExperimentalFeature("AvailabilityMacro=_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"), - .enableExperimentalFeature("AvailabilityMacro=_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"), - .enableExperimentalFeature("AvailabilityMacro=_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"), - - .enableExperimentalFeature("AvailabilityMacro=_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0"), - ] - } - - /// Create a Swift setting which enables Library Evolution, optionally - /// constraining it to only Apple platforms. - /// - /// - Parameters: - /// - applePlatformsOnly: Whether to constrain this setting to only Apple - /// platforms. - static func enableLibraryEvolution(applePlatformsOnly: Bool = false) -> Self { - var result = [PackageDescription.SwiftSetting]() - - if buildingForDevelopment { - var condition: BuildSettingCondition? - if applePlatformsOnly { - condition = .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS]) - } - result.append(.unsafeFlags(["-enable-library-evolution"], condition)) - } - - return result - } -} - -extension Array where Element == PackageDescription.CXXSetting { - /// Settings intended to be applied to every C++ target in this package. - /// Analogous to project-level build settings in an Xcode project. - static var packageSettings: Self { - var result = Self() - - result += [ - .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), - .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), - .define("SWT_NO_PIPES", .when(platforms: [.wasi])), - ] - - // Capture the testing library's version as a C++ string constant. - if let git { - let testingLibraryVersion = if let tag = git.currentTag { - tag - } else if git.hasUncommittedChanges { - "\(git.currentCommit) (modified)" - } else { - git.currentCommit - } - result.append(.define("SWT_TESTING_LIBRARY_VERSION", to: #""\#(testingLibraryVersion)""#)) - } - - return result - } -} From 35a432fe57666b3661612dad6ce4b9b0bd1f5786 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 9 Apr 2025 12:02:39 -0400 Subject: [PATCH 06/12] Restore Embedded Swift changes in Package.swift --- Package.swift | 103 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/Package.swift b/Package.swift index 83994a6dd..e4df8d1a4 100644 --- a/Package.swift +++ b/Package.swift @@ -20,17 +20,49 @@ let git = Context.gitInformation /// distribution as a package dependency. let buildingForDevelopment = (git?.currentTag == nil) +/// Whether or not this package is being built for Embedded Swift. +/// +/// This value is `true` if `SWT_EMBEDDED` is set in the environment to `true` +/// when `swift build` is invoked. This inference is experimental and is subject +/// to change in the future. +/// +/// - Bug: There is currently no way for us to tell if we are being asked to +/// build for an Embedded Swift target at the package manifest level. +/// ([swift-syntax-#8431](https://github.com/swiftlang/swift-package-manager/issues/8431)) +let buildingForEmbedded: Bool = { + guard let envvar = Context.environment["SWT_EMBEDDED"] else { + return false + } + return Bool(envvar) ?? ((Int(envvar) ?? 0) != 0) +}() + let package = Package( name: "swift-testing", - platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .watchOS(.v6), - .tvOS(.v13), - .macCatalyst(.v13), - .visionOS(.v1), - ], + platforms: { + if !buildingForEmbedded { + [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + .macCatalyst(.v13), + .visionOS(.v1), + ] + } else { + // Open-source main-branch toolchains (currently required to build this + // package for Embedded Swift) have higher Apple platform deployment + // targets than we would otherwise require. + [ + .macOS(.v14), + .iOS(.v18), + .watchOS(.v10), + .tvOS(.v18), + .macCatalyst(.v18), + .visionOS(.v1), + ] + } + }(), products: { var result = [Product]() @@ -192,6 +224,31 @@ package.targets.append(contentsOf: [ ]) #endif +extension BuildSettingCondition { + /// Creates a build setting condition that evaluates to `true` for Embedded + /// Swift. + /// + /// - Parameters: + /// - nonEmbeddedCondition: The value to return if the target is not + /// Embedded Swift. If `nil`, the build condition evaluates to `false`. + /// + /// - Returns: A build setting condition that evaluates to `true` for Embedded + /// Swift or is equal to `nonEmbeddedCondition` for non-Embedded Swift. + static func whenEmbedded(or nonEmbeddedCondition: @autoclosure () -> Self? = nil) -> Self? { + if !buildingForEmbedded { + if let nonEmbeddedCondition = nonEmbeddedCondition() { + nonEmbeddedCondition + } else { + // The caller did not supply a fallback. + .when(platforms: []) + } + } else { + // Enable unconditionally because the target is Embedded Swift. + nil + } + } +} + extension Array where Element == PackageDescription.SwiftSetting { /// Settings intended to be applied to every Swift target in this package. /// Analogous to project-level build settings in an Xcode project. @@ -202,6 +259,10 @@ extension Array where Element == PackageDescription.SwiftSetting { result.append(.unsafeFlags(["-require-explicit-sendable"])) } + if buildingForEmbedded { + result.append(.enableExperimentalFeature("Embedded")) + } + result += [ .enableUpcomingFeature("ExistentialAny"), @@ -221,11 +282,14 @@ extension Array where Element == PackageDescription.SwiftSetting { .define("SWT_TARGET_OS_APPLE", .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS])), - .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), - .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), - .define("SWT_NO_PIPES", .when(platforms: [.wasi])), + .define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android]))), + .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), + .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), + + .define("SWT_NO_LEGACY_TEST_DISCOVERY", .whenEmbedded()), + .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), ] // Unconditionally enable 'ExperimentalExitTestValueCapture' when building @@ -286,11 +350,14 @@ extension Array where Element == PackageDescription.CXXSetting { var result = Self() result += [ - .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), - .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), - .define("SWT_NO_PIPES", .when(platforms: [.wasi])), + .define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android]))), + .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), + .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), + + .define("SWT_NO_LEGACY_TEST_DISCOVERY", .whenEmbedded()), + .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), ] // Capture the testing library's version as a C++ string constant. From e56fa7089f4c53f3cc772a0551bf7698c637ab83 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 14 Apr 2025 11:02:58 -0400 Subject: [PATCH 07/12] Fewer compile-time branches on ExperimentalExitTestValueCapture, just generally enable the feature --- .../ExitTests/ExitTest.CapturedValue.swift | 11 ---- Sources/Testing/ExitTests/ExitTest.swift | 35 +------------ .../ExpectationChecking+Macro.swift | 26 ---------- Sources/TestingMacros/ConditionMacro.swift | 52 +++++++++++-------- .../MacroExpansionContextAdditions.swift | 5 ++ .../Support/ClosureCaptureListParsing.swift | 2 - .../Support/DiagnosticMessage.swift | 12 +---- .../ConditionMacroTests.swift | 28 +++++----- 8 files changed, 56 insertions(+), 115 deletions(-) diff --git a/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift index e02bb6772..aeeb13818 100644 --- a/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift +++ b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift @@ -11,7 +11,6 @@ #if !SWT_NO_EXIT_TESTS @_spi(Experimental) @_spi(ForToolsIntegrationOnly) extension ExitTest { -#if ExperimentalExitTestValueCapture /// 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 @@ -108,15 +107,10 @@ extension ExitTest { } } } -#else - /// A placeholder type for ``CapturedValue`` when value capturing is disabled. - typealias CapturedValue = Never -#endif } // MARK: - Collection conveniences -#if ExperimentalExitTestValueCapture extension Array where Element == ExitTest.CapturedValue { init(_ wrappedValues: repeat each T) where repeat each T: Codable & Sendable { self.init() @@ -128,7 +122,6 @@ extension Array where Element == ExitTest.CapturedValue { repeat self.append(ExitTest.CapturedValue(typeOnly: (each typesOfWrappedValues).self)) } } -#endif extension Collection where Element == ExitTest.CapturedValue { /// Cast the elements in this collection to a tuple of their wrapped values. @@ -146,7 +139,6 @@ extension Collection where Element == ExitTest.CapturedValue { as type: U.Type, from capturedValues: inout SubSequence ) throws -> U { -#if ExperimentalExitTestValueCapture // 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. @@ -167,9 +159,6 @@ extension Collection where Element == ExitTest.CapturedValue { } return wrappedValue -#else - fatalError("Unimplemented") -#endif } var capturedValues = self[...] diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 32c7f9eae..3bdfd75f4 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -111,7 +111,6 @@ public struct ExitTest: Sendable, ~Copyable { } } -#if ExperimentalExitTestValueCapture /// The set of values captured in the parent process before the exit test is /// called. /// @@ -124,12 +123,6 @@ public struct ExitTest: Sendable, ~Copyable { /// child processes. @_spi(Experimental) @_spi(ForToolsIntegrationOnly) public var capturedValues = [CapturedValue]() -#else - /// Placeholder for ``capturedValues`` when value capturing is disabled. - var capturedValues: EmptyCollection { - EmptyCollection() - } -#endif /// Make a copy of this instance. /// @@ -140,9 +133,7 @@ public struct ExitTest: Sendable, ~Copyable { fileprivate borrowing func unsafeCopy() -> Self { var result = Self(id: id, body: body) result._observedValues = _observedValues -#if ExperimentalExitTestValueCapture result.capturedValues = capturedValues -#endif return result } } @@ -336,9 +327,7 @@ extension ExitTest: DiscoverableAsTestContent { // Construct and return the instance. var exitTest = Self(id: id, body: body) -#if ExperimentalExitTestValueCapture exitTest.capturedValues = Array(repeat (each T).self) -#endif outValue.initializeMemory(as: Self.self, to: exitTest) return true } @@ -418,9 +407,7 @@ func callExitTest( // Construct a temporary/local exit test to pass to the exit test handler. var exitTest = ExitTest(id: ExitTest.ID(exitTestID)) exitTest.observedValues = observedValues -#if ExperimentalExitTestValueCapture exitTest.capturedValues = Array(repeat each capturedValues) -#endif // Invoke the exit test handler and wait for the child process to terminate. result = try await configuration.exitTestHandler(exitTest) @@ -650,9 +637,7 @@ extension ExitTest { result.body = { [configuration, body = result.body] exitTest in try await Configuration.withCurrent(configuration) { -#if ExperimentalExitTestValueCapture try exitTest._decodeCapturedValuesForEntryPoint() -#endif try await body(&exitTest) } } @@ -777,17 +762,11 @@ extension ExitTest { var backChannelWriteEnd: FileHandle! try FileHandle.makePipe(readEnd: &backChannelReadEnd, writeEnd: &backChannelWriteEnd) -#if ExperimentalExitTestValueCapture // 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) -#else - // A placeholder variable with this name (to make it easier to get a - // pointer to it when we call spawnExecutable().) - let capturedValuesReadEnd: Void -#endif // Let the child process know how to find the back channel and // captured values channel by setting a known environment variable to @@ -795,31 +774,24 @@ extension ExitTest { if let backChannelEnvironmentVariable = _makeEnvironmentVariable(for: backChannelWriteEnd) { childEnvironment["SWT_EXPERIMENTAL_BACKCHANNEL"] = backChannelEnvironmentVariable } -#if ExperimentalExitTestValueCapture if let capturedValuesEnvironmentVariable = _makeEnvironmentVariable(for: capturedValuesReadEnd) { childEnvironment["SWT_EXPERIMENTAL_CAPTURED_VALUES"] = capturedValuesEnvironmentVariable } -#endif // Spawn the child process. let processID = try withUnsafePointer(to: backChannelWriteEnd) { backChannelWriteEnd in try withUnsafePointer(to: capturedValuesReadEnd) { capturedValuesReadEnd in - var additionalFileHandles = [backChannelWriteEnd] -#if ExperimentalExitTestValueCapture - additionalFileHandles.append(capturedValuesReadEnd) -#endif - return try spawnExecutable( + try spawnExecutable( atPath: childProcessExecutablePath, arguments: childArguments, environment: childEnvironment, standardOutput: stdoutWriteEnd, standardError: stderrWriteEnd, - additionalFileHandles: additionalFileHandles + additionalFileHandles: [backChannelWriteEnd, capturedValuesReadEnd] ) } } -#if ExperimentalExitTestValueCapture // 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 @@ -833,7 +805,6 @@ extension ExitTest { } capturedValuesReadEnd.close() capturedValuesWriteEnd.close() -#endif // Await termination of the child process. taskGroup.addTask { @@ -941,7 +912,6 @@ extension ExitTest { } } -#if ExperimentalExitTestValueCapture /// Decode this exit test's captured values and update its ``capturedValues`` /// property. /// @@ -998,6 +968,5 @@ extension ExitTest { } } } -#endif } #endif diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index 71c62753b..485261298 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1145,7 +1145,6 @@ public func __checkClosureCall( /// /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. -#if ExperimentalExitTestValueCapture @_spi(Experimental) public func __checkClosureCall( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), @@ -1170,31 +1169,6 @@ public func __checkClosureCall( sourceLocation: sourceLocation ) } -#else -@_spi(Experimental) -public func __checkClosureCall( - identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), - exitsWith expectedExitCondition: ExitTest.Condition, - observing observedValues: [any PartialKeyPath & Sendable], - performing body: @convention(thin) () -> Void, - expression: __Expression, - comments: @autoclosure () -> [Comment], - isRequired: Bool, - isolation: isolated (any Actor)? = #isolation, - sourceLocation: SourceLocation -) async -> Result { - await callExitTest( - identifiedBy: exitTestID, - encodingCapturedValues: (), - exitsWith: expectedExitCondition, - observing: observedValues, - expression: expression, - comments: comments(), - isRequired: isRequired, - sourceLocation: sourceLocation - ) -} -#endif #endif // MARK: - diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index e0d849538..6c233fc71 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -438,26 +438,27 @@ extension ExitTestConditionMacro { } var bodyArgumentExpr = arguments[trailingClosureIndex].expression -#if ExperimentalExitTestValueCapture + bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr + // Find any captured values and extract them from the trailing closure. var capturedValues = [CapturedValueInfo]() - bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr - 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 - do { - let bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr + 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 { + let bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr + if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), let captureClause = closureExpr.signature?.capture, !captureClause.items.isEmpty { context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) } } -#endif // Generate a unique identifier for this exit test. let idExpr = _makeExitTestIDExpr(for: macro, in: context) @@ -468,7 +469,6 @@ extension ExitTestConditionMacro { // that `Self` resolves to the type containing the exit test, not the enum. let bodyThunkName = context.makeUniqueName("") let bodyThunkParameterList = FunctionParameterListSyntax { -#if ExperimentalExitTestValueCapture for capturedValue in capturedValues { FunctionParameterSyntax( firstName: .wildcardToken(trailingTrivia: .space), @@ -477,7 +477,6 @@ extension ExitTestConditionMacro { type: capturedValue.type.trimmed ) } -#endif } decls.append( """ @@ -548,11 +547,8 @@ extension ExitTestConditionMacro { // Insert additional arguments at the beginning of the argument list. Note // that this will invalidate all indices into `arguments`! - var leadingArguments = [ + arguments = [ Argument(label: "identifiedBy", expression: idExpr), - ] -#if ExperimentalExitTestValueCapture - leadingArguments.append( Argument( label: "encodingCapturedValues", expression: TupleExprSyntax { @@ -561,9 +557,7 @@ extension ExitTestConditionMacro { } } ), - ) -#endif - arguments = leadingArguments + arguments + ] + arguments // Replace the exit test body (as an argument to the macro) with a stub // closure that hosts the type we created above. @@ -619,6 +613,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/MacroExpansionContextAdditions.swift b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift index d0f296892..d67112358 100644 --- a/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift @@ -13,6 +13,11 @@ import SwiftSyntaxBuilder import SwiftSyntaxMacros import SwiftDiagnostics +#if !SWT_NO_LIBRARY_MACRO_PLUGINS +private import Foundation +private import SwiftParser +#endif + extension MacroExpansionContext { /// Get the type of the given lexical context. /// diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift index 2316b5b4b..d05385525 100644 --- a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -8,7 +8,6 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -#if ExperimentalExitTestValueCapture import SwiftDiagnostics import SwiftParser import SwiftSyntax @@ -87,4 +86,3 @@ struct CapturedValueInfo { } } } -#endif diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index 9c09e32f2..36186ec4b 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -752,7 +752,6 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { // MARK: - Captured values extension DiagnosticMessage { -#if ExperimentalExitTestValueCapture /// Create a diagnostic message stating that a specifier keyword cannot be /// used with a given closure capture list item. /// @@ -827,7 +826,7 @@ extension DiagnosticMessage { ] ) } -#else + /// Create a diagnostic message stating that a capture clause cannot be used /// in an exit test. /// @@ -838,12 +837,6 @@ extension DiagnosticMessage { /// /// - Returns: A diagnostic message. static func captureClauseUnsupported(_ captureClause: ClosureCaptureClauseSyntax, in closure: ClosureExprSyntax, inExitTest exitTestMacro: some FreestandingMacroExpansionSyntax) -> Self { -#if SWIFT_PACKAGE - let message = "Capture clause in closure passed to \(_macroName(exitTestMacro)) requires that the 'ExperimentalExitTestValueCapture' trait be enabled for package 'swift-testing'" -#else - let message = "Cannot specify a capture clause in closure passed to \(_macroName(exitTestMacro))" -#endif - let changes: [FixIt.Change] if let signature = closure.signature, Array(signature.with(\.capture, nil).tokens(viewMode: .sourceAccurate)).count == 1 { @@ -867,7 +860,7 @@ extension DiagnosticMessage { return Self( syntax: Syntax(captureClause), - message: message, + message: "Cannot specify a capture clause in closure passed to \(_macroName(exitTestMacro))", severity: .error, fixIts: [ FixIt( @@ -877,5 +870,4 @@ extension DiagnosticMessage { ] ) } -#endif } diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index 56b93fa0b..cd1333941 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -395,15 +395,18 @@ struct ConditionMacroTests { ] ) func exitTestCaptureDiagnostics(input: String, expectedMessage: String) throws { - let (_, diagnostics) = try parse(input) + 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) + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) + } } } -#else +#endif + @Test( "Capture list on an exit test produces a diagnostic", arguments: [ @@ -412,15 +415,16 @@ 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) + } } } -#endif @Test("Macro expansion is performed within a test function") func macroExpansionInTestFunction() throws { From 0f385f5601787f2ab7ea5d59a76d8e26fb6f3744 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 14 Apr 2025 11:25:43 -0400 Subject: [PATCH 08/12] Lazily evaluate type of Self --- Sources/TestingMacros/Support/ClosureCaptureListParsing.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift index d05385525..f51c7ee7b 100644 --- a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -47,7 +47,7 @@ struct CapturedValueInfo { // Potentially get the name of the type comprising the current lexical // context (i.e. whatever `Self` is.) - let typeNameOfLexicalContext = { + lazy var typeNameOfLexicalContext = { let lexicalContext = context.lexicalContext.drop { !$0.isProtocol((any DeclGroupSyntax).self) } return context.type(ofLexicalContext: lexicalContext) }() From f3d78d7d958f00ee83b8928a978e3ee5f6cdc310 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 14 Apr 2025 18:38:11 -0400 Subject: [PATCH 09/12] Fix typo --- Sources/TestingMacros/Support/ClosureCaptureListParsing.swift | 2 +- Tests/TestingTests/ExitTestTests.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift index f51c7ee7b..41abe711c 100644 --- a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -60,7 +60,7 @@ struct CapturedValueInfo { 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: type.trimmed)) + TypeSyntax(OptionalTypeSyntax(wrappedType: asExpr.type.trimmed)) } else { asExpr.type } diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index deb19bc43..896784f22 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -386,9 +386,10 @@ private import _TestingInternals func captureList() async { let i = 123 let s = "abc" as Any - await #expect(exitsWith: .success) { [i = i as Int, s = s as! String] in + 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") } } From 9b55cdb897d8d43eda76c2c3b04fee6e287eb816 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 15 Apr 2025 09:59:28 -0400 Subject: [PATCH 10/12] Incorporate feedback --- Sources/Testing/ExitTests/ExitTest.swift | 2 +- .../Support/Additions/MacroExpansionContextAdditions.swift | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 3bdfd75f4..480ee40d6 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -665,7 +665,7 @@ extension ExitTest { let parentArguments = CommandLine.arguments #if SWT_TARGET_OS_APPLE lazy var xctestTargetPath = Environment.variable(named: "XCTestBundlePath") - ?? parentArguments.dropFirst().last + ?? parentArguments.dropFirst().last // If the running executable appears to be the XCTest runner executable in // Xcode, figure out the path to the running XCTest bundle. If we can find // it, then we can re-run the host XCTestCase instance. diff --git a/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift index d67112358..d0f296892 100644 --- a/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift @@ -13,11 +13,6 @@ import SwiftSyntaxBuilder import SwiftSyntaxMacros import SwiftDiagnostics -#if !SWT_NO_LIBRARY_MACRO_PLUGINS -private import Foundation -private import SwiftParser -#endif - extension MacroExpansionContext { /// Get the type of the given lexical context. /// From 04ca8a257aa0d0e3fb5c3dcb0a08ff5cca1a8c77 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 15 Apr 2025 10:12:15 -0400 Subject: [PATCH 11/12] Simplify macro expansion logic and don't emit encodingCapturedValues: argument if it's not used --- .../ExpectationChecking+Macro.swift | 41 ++++++++++++++--- Sources/TestingMacros/ConditionMacro.swift | 45 ++++++++----------- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index 485261298..c57379152 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1139,9 +1139,40 @@ 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. +@_spi(Experimental) +public func __checkClosureCall( + identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), + 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 { + 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. @@ -1150,8 +1181,8 @@ public func __checkClosureCall( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), encodingCapturedValues capturedValues: (repeat each T), 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, diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index 6c233fc71..d67f94754 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -421,17 +421,6 @@ extension ExitTestConditionMacro { _ = try Base.expansion(of: macro, in: context) var arguments = argumentList(of: macro, in: context) - let conditionIndex = arguments.firstIndex { $0.label?.tokenKind == .identifier("exitsWith") } - guard let conditionIndex else { - fatalError("Could not find the condition 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: conditionIndex) - ) - } 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") @@ -451,13 +440,10 @@ extension ExitTestConditionMacro { bodyArgumentExpr = ExprSyntax(closureExpr) } - } else { - let bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr - if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), - let captureClause = closureExpr.signature?.capture, - !captureClause.items.isEmpty { - context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) - } + } else if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), + let captureClause = closureExpr.signature?.capture, + !captureClause.items.isEmpty { + context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) } // Generate a unique identifier for this exit test. @@ -547,17 +533,22 @@ extension ExitTestConditionMacro { // Insert additional arguments at the beginning of the argument list. Note // that this will invalidate all indices into `arguments`! - arguments = [ + var leadingArguments = [ Argument(label: "identifiedBy", expression: idExpr), - Argument( - label: "encodingCapturedValues", - expression: TupleExprSyntax { - for capturedValue in capturedValues { - LabeledExprSyntax(expression: capturedValue.expression.trimmed) + ] + if !capturedValues.isEmpty { + leadingArguments.append( + Argument( + label: "encodingCapturedValues", + expression: TupleExprSyntax { + for capturedValue in capturedValues { + LabeledExprSyntax(expression: capturedValue.expression.trimmed) + } } - } - ), - ] + arguments + ) + ) + } + 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. From 8dbcb02f0f29a61128897365db325a2d44ea9c9d Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 15 Apr 2025 10:28:27 -0400 Subject: [PATCH 12/12] callExitTest doesn't need to be generic --- Sources/Testing/ExitTests/ExitTest.swift | 8 ++++---- .../Testing/Expectations/ExpectationChecking+Macro.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 480ee40d6..503e143c6 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -387,9 +387,9 @@ extension ExitTest { /// This function contains the common implementation for all /// `await #expect(exitsWith:) { }` invocations regardless of calling /// convention. -func callExitTest( +func callExitTest( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), - encodingCapturedValues capturedValues: (repeat each T), + encodingCapturedValues capturedValues: [ExitTest.CapturedValue], exitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable], expression: __Expression, @@ -397,7 +397,7 @@ func callExitTest( isRequired: Bool, isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation -) async -> Result where repeat each T: Codable & Sendable { +) async -> Result { guard let configuration = Configuration.current ?? Configuration.all.first else { preconditionFailure("A test must be running on the current task to use #expect(exitsWith:).") } @@ -407,7 +407,7 @@ func callExitTest( // 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 = Array(repeat each capturedValues) + exitTest.capturedValues = capturedValues // Invoke the exit test handler and wait for the child process to terminate. result = try await configuration.exitTestHandler(exitTest) diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index c57379152..e8767d01f 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1158,7 +1158,7 @@ public func __checkClosureCall( ) async -> Result { await callExitTest( identifiedBy: exitTestID, - encodingCapturedValues: (), + encodingCapturedValues: [], exitsWith: expectedExitCondition, observing: observedValues, expression: expression, @@ -1191,7 +1191,7 @@ public func __checkClosureCall( ) async -> Result where repeat each T: Codable & Sendable { await callExitTest( identifiedBy: exitTestID, - encodingCapturedValues: (repeat each capturedValues), + encodingCapturedValues: Array(repeat each capturedValues), exitsWith: expectedExitCondition, observing: observedValues, expression: expression,