diff --git a/Package.swift b/Package.swift index 4360aabd..799c3e97 100644 --- a/Package.swift +++ b/Package.swift @@ -95,13 +95,6 @@ let package = Package( return result }(), - traits: [ - .trait( - name: "ExperimentalExitTestValueCapture", - description: "Enable experimental support for capturing values in exit tests" - ), - ], - dependencies: [ .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest"), ], @@ -340,14 +333,6 @@ extension Array where Element == PackageDescription.SwiftSetting { .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), ] - // Unconditionally enable 'ExperimentalExitTestValueCapture' when building - // for development. - if buildingForDevelopment { - result += [ - .define("ExperimentalExitTestValueCapture") - ] - } - return result } diff --git a/Sources/Testing/Testing.docc/exit-testing.md b/Sources/Testing/Testing.docc/exit-testing.md index 6ae81b98..974fdd5f 100644 --- a/Sources/Testing/Testing.docc/exit-testing.md +++ b/Sources/Testing/Testing.docc/exit-testing.md @@ -67,21 +67,7 @@ The parent process doesn't call the body of the exit test. Instead, the child process treats the body of the exit test as its `main()` function and calls it directly. -- Note: Because the body acts as the `main()` function of a new process, it - can't capture any state originating in the parent process or from its lexical - context. For example, the following exit test will fail to compile because it - captures a variable declared outside the exit test itself: - - ```swift - @Test func `Customer won't eat food unless it's nutritious`() async { - let isNutritious = false - await #expect(processExitsWith: .failure) { - var food = ... - food.isNutritious = isNutritious // ❌ ERROR: trying to capture state here - Customer.current.eat(food) - } - } - ``` + If the body returns before the child process exits, the process exits as if `main()` returned normally. If the body throws an error, Swift handles it as if @@ -106,6 +92,56 @@ status of the child process against the expected exit condition you passed. If they match, the exit test passes; otherwise, it fails and the testing library records an issue. +### Capture state from the parent process + +To pass information from the parent process to the child process, you specify +the Swift values you want to pass in a [capture list](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/closures/#Capturing-Values) +on the exit test's body: + +```swift +@Test(arguments: Food.allJunkFood) +func `Customer won't eat food unless it's nutritious`(_ food: Food) async { + await #expect(processExitsWith: .failure) { [food] in + Customer.current.eat(food) + } +} +``` + +If a captured value is an argument to the current function or is `self`, its +type is inferred at compile time. Otherwise, explicitly specify the type of the +value using the `as` operator: + +```swift +@Test func `Customer won't eat food unless it's nutritious`() async { + var food = ... + food.isNutritious = false + await #expect(processExitsWith: .failure) { [self, food = food as Food] in + self.prepare(food) + Customer.current.eat(food) + } +} +``` + +Every value you capture in an exit test must conform to [`Sendable`](https://developer.apple.com/documentation/swift/sendable) +and [`Codable`](https://developer.apple.com/documentation/swift/codable). Each +value is encoded by the parent process using [`encode(to:)`](https://developer.apple.com/documentation/swift/encodable/encode(to:)) +and is decoded by the child process [`init(from:)`](https://developer.apple.com/documentation/swift/decodable/init(from:)) +before being passed to the exit test body. + +If a captured value's type does not conform to both `Sendable` and `Codable`, or +if the value is not explicitly specified in the exit test body's capture list, +the compiler emits an error: + +```swift +@Test func `Customer won't eat food unless it's nutritious`() async { + var food = ... + food.isNutritious = false + await #expect(processExitsWith: .failure) { + Customer.current.eat(food) // ❌ ERROR: implicitly capturing 'food' + } +} +``` + ### Gather output from the child process The ``expect(processExitsWith:observing:_:sourceLocation:performing:)`` and diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index 0ef0970e..41a99bd0 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -630,15 +630,6 @@ extension ExitTestConditionMacro { ) -> Bool { var diagnostics = [DiagnosticMessage]() - if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), - let captureClause = closureExpr.signature?.capture, - !captureClause.items.isEmpty { - // Disallow capture lists if the experimental feature is not enabled. - if !ExitTestExpectMacro.isValueCapturingEnabled { - diagnostics.append(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) - } - } - // Disallow exit tests in generic types and functions as they cannot be // correctly expanded due to the use of a nested type with static members. for lexicalContext in context.lexicalContext { @@ -664,22 +655,6 @@ 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(processExitsWith:)` macro. /// /// This type checks for nested invocations of `#expect()` and `#require()` and diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index b7103bcc..75289fd5 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -845,50 +845,6 @@ extension DiagnosticMessage { ) } - /// Create a diagnostic message stating that a capture clause cannot be used - /// in an exit test. - /// - /// - Parameters: - /// - captureClause: The invalid capture clause. - /// - closure: The closure containing `captureClause`. - /// - exitTestMacro: The containing exit test macro invocation. - /// - /// - Returns: A diagnostic message. - static func captureClauseUnsupported(_ captureClause: ClosureCaptureClauseSyntax, in closure: ClosureExprSyntax, inExitTest exitTestMacro: some FreestandingMacroExpansionSyntax) -> Self { - let changes: [FixIt.Change] - if let signature = closure.signature, - Array(signature.with(\.capture, nil).tokens(viewMode: .sourceAccurate)).count == 1 { - // The only remaining token in the signature is `in`, so remove the whole - // signature tree instead of just the capture clause. - changes = [ - .replaceTrailingTrivia(token: closure.leftBrace, newTrivia: ""), - .replace( - oldNode: Syntax(signature), - newNode: Syntax("" as ExprSyntax) - ) - ] - } else { - changes = [ - .replace( - oldNode: Syntax(captureClause), - newNode: Syntax("" as ExprSyntax) - ) - ] - } - - return Self( - syntax: Syntax(captureClause), - message: "Cannot specify a capture clause in closure passed to \(_macroName(exitTestMacro))", - severity: .error, - fixIts: [ - FixIt( - message: MacroExpansionFixItMessage("Remove '\(captureClause.trimmed)'"), - changes: changes - ), - ] - ) - } - /// Create a diagnostic message stating that an expression macro is not /// supported in a generic context. /// diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index 2c3cc38d..a451b6f2 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -452,7 +452,6 @@ struct ConditionMacroTests { } } -#if ExperimentalExitTestValueCapture @Test("#expect(processExitsWith:) produces a diagnostic for a bad capture", arguments: [ "#expectExitTest(processExitsWith: x) { [weak a] in }": @@ -470,34 +469,12 @@ struct ConditionMacroTests { ] ) func exitTestCaptureDiagnostics(input: String, expectedMessage: String) throws { - try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(true) { - let (_, diagnostics) = try parse(input) - - #expect(diagnostics.count > 0) - for diagnostic in diagnostics { - #expect(diagnostic.diagMessage.severity == .error) - #expect(diagnostic.message == expectedMessage) - } - } - } -#endif + let (_, diagnostics) = try parse(input) - @Test( - "Capture list on an exit test produces a diagnostic", - arguments: [ - "#expectExitTest(processExitsWith: x) { [a] in }": - "Cannot specify a capture clause in closure passed to '#expectExitTest(processExitsWith:_:)'" - ] - ) - func exitTestCaptureListProducesDiagnostic(input: String, expectedMessage: String) throws { - try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(false) { - let (_, diagnostics) = try parse(input) - - #expect(diagnostics.count > 0) - for diagnostic in diagnostics { - #expect(diagnostic.diagMessage.severity == .error) - #expect(diagnostic.message == expectedMessage) - } + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) } } diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index 89d78cdd..3b2b2c18 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -381,7 +381,6 @@ private import _TestingInternals } } -#if ExperimentalExitTestValueCapture @Test("Capture list") func captureList() async { let i = 123 @@ -559,7 +558,6 @@ private import _TestingInternals } } #endif -#endif } // MARK: - Fixtures