diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index 2a3e137a3..02a3ccd72 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -835,10 +835,11 @@ public func __checkClosureCall( /// `#require()` macros. Do not call it directly. public func __checkClosureCall( throws errorType: E.Type, - performing body: () async throws -> some Any, + performing body: () async throws -> sending some Any, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation ) async -> Result where E: Error { if errorType == Never.self { @@ -848,6 +849,7 @@ public func __checkClosureCall( expression: expression, comments: comments(), isRequired: isRequired, + isolation: isolation, sourceLocation: sourceLocation ) } else { @@ -858,6 +860,7 @@ public func __checkClosureCall( expression: expression, comments: comments(), isRequired: isRequired, + isolation: isolation, sourceLocation: sourceLocation ) } @@ -911,10 +914,11 @@ public func __checkClosureCall( /// `#require()` macros. Do not call it directly. public func __checkClosureCall( throws _: Never.Type, - performing body: () async throws -> some Any, + performing body: () async throws -> sending some Any, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation ) async -> Result { var success = true @@ -973,10 +977,11 @@ public func __checkClosureCall( /// `#require()` macros. Do not call it directly. public func __checkClosureCall( throws error: E, - performing body: () async throws -> some Any, + performing body: () async throws -> sending some Any, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation ) async -> Result where E: Error & Equatable { await __checkClosureCall( @@ -986,6 +991,7 @@ public func __checkClosureCall( expression: expression, comments: comments(), isRequired: isRequired, + isolation: isolation, sourceLocation: sourceLocation ) } @@ -1047,12 +1053,13 @@ public func __checkClosureCall( /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. public func __checkClosureCall( - performing body: () async throws -> R, + performing body: () async throws -> sending R, throws errorMatcher: (any Error) async throws -> Bool, mismatchExplanation: ((any Error) -> String)? = nil, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation ) async -> Result { var errorMatches = false diff --git a/Sources/Testing/Issues/Confirmation.swift b/Sources/Testing/Issues/Confirmation.swift index 2ce3f1910..b842ce4f2 100644 --- a/Sources/Testing/Issues/Confirmation.swift +++ b/Sources/Testing/Issues/Confirmation.swift @@ -55,6 +55,7 @@ extension Confirmation { /// `body` is invoked. The default value of this argument is `1`, indicating /// that the event should occur exactly once. Pass `0` if the event should /// _never_ occur when `body` is invoked. +/// - isolation: The actor to which `body` is isolated, if any. /// - sourceLocation: The source location to which any recorded issues should /// be attributed. /// - body: The function to invoke. @@ -94,12 +95,14 @@ extension Confirmation { public func confirmation( _ comment: Comment? = nil, expectedCount: Int = 1, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation = #_sourceLocation, - _ body: (Confirmation) async throws -> R + _ body: (Confirmation) async throws -> sending R ) async rethrows -> R { try await confirmation( comment, expectedCount: expectedCount ... expectedCount, + isolation: isolation, sourceLocation: sourceLocation, body ) @@ -114,6 +117,7 @@ public func confirmation( /// function. /// - expectedCount: A range of integers indicating the number of times the /// expected event should occur when `body` is invoked. +/// - isolation: The actor to which `body` is isolated, if any. /// - sourceLocation: The source location to which any recorded issues should /// be attributed. /// - body: The function to invoke. @@ -156,13 +160,14 @@ public func confirmation( /// preconditions have been met, and records an issue if they have not. /// /// If an exact count is expected, use -/// ``confirmation(_:expectedCount:sourceLocation:_:)-7kfko`` instead. +/// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` instead. @_spi(Experimental) public func confirmation( _ comment: Comment? = nil, expectedCount: some Confirmation.ExpectedCount, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation = #_sourceLocation, - _ body: (Confirmation) async throws -> R + _ body: (Confirmation) async throws -> sending R ) async rethrows -> R { let confirmation = Confirmation() defer { @@ -182,7 +187,7 @@ public func confirmation( @_spi(Experimental) extension Confirmation { /// A protocol that describes a range expression that can be used with - /// ``confirmation(_:expectedCount:sourceLocation:_:)-41gmd``. + /// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-9rt6m``. /// /// This protocol represents any expression that describes a range of /// confirmation counts. For example, the expression `1 ..< 10` automatically diff --git a/Sources/Testing/Issues/Issue+Recording.swift b/Sources/Testing/Issues/Issue+Recording.swift index 32cc9f511..a45bbb822 100644 --- a/Sources/Testing/Issues/Issue+Recording.swift +++ b/Sources/Testing/Issues/Issue+Recording.swift @@ -196,6 +196,7 @@ extension Issue { /// - sourceLocation: The source location to attribute any caught error to. /// - configuration: The test configuration to use when recording an issue. /// The default value is ``Configuration/current``. + /// - isolation: The actor to which `body` is isolated, if any. /// - body: An asynchronous closure that might throw an error. /// /// - Returns: The issue representing the caught error, if any error was @@ -204,6 +205,7 @@ extension Issue { static func withErrorRecording( at sourceLocation: SourceLocation, configuration: Configuration? = nil, + isolation: isolated (any Actor)? = #isolation, _ body: () async throws -> Void ) async -> (any Error)? { // Ensure that we are capturing backtraces for errors before we start diff --git a/Sources/Testing/Issues/Issue.swift b/Sources/Testing/Issues/Issue.swift index fe69c9b60..91602ef7c 100644 --- a/Sources/Testing/Issues/Issue.swift +++ b/Sources/Testing/Issues/Issue.swift @@ -33,7 +33,7 @@ public struct Issue: Sendable { /// ``Confirmation/confirm(count:)`` should have been called. /// /// This issue can occur when calling - /// ``confirmation(_:expectedCount:sourceLocation:_:)`` when the + /// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` when the /// confirmation passed to these functions' `body` closures is confirmed too /// few or too many times. indirect case confirmationMiscounted(actual: Int, expected: Int) @@ -48,9 +48,9 @@ public struct Issue: Sendable { /// ``Confirmation/confirm(count:)`` should have been called. /// /// This issue can occur when calling - /// ``confirmation(_:expectedCount:sourceLocation:_:)-41gmd`` when the - /// confirmation passed to these functions' `body` closures is confirmed too - /// few or too many times. + /// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-9rt6m`` when + /// the confirmation passed to these functions' `body` closures is confirmed + /// too few or too many times. @_spi(Experimental) indirect case confirmationOutOfRange(actual: Int, expected: any Confirmation.ExpectedCount) diff --git a/Sources/Testing/Issues/KnownIssue.swift b/Sources/Testing/Issues/KnownIssue.swift index c9c03be56..70c9c3875 100644 --- a/Sources/Testing/Issues/KnownIssue.swift +++ b/Sources/Testing/Issues/KnownIssue.swift @@ -110,7 +110,7 @@ public typealias KnownIssueMatcher = @Sendable (_ issue: Issue) -> Bool /// Because all errors thrown by `body` are caught as known issues, this /// function is not throwing. If only some errors or issues are known to occur /// while others should continue to cause test failures, use -/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)-5vi5n`` +/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)`` /// instead. public func withKnownIssue( _ comment: Comment? = nil, @@ -161,7 +161,7 @@ public func withKnownIssue( /// /// It is not necessary to specify both `precondition` and `issueMatcher` if /// only one is relevant. If all errors and issues should be considered known -/// issues, use ``withKnownIssue(_:isIntermittent:sourceLocation:_:)-95r6o`` +/// issues, use ``withKnownIssue(_:isIntermittent:sourceLocation:_:)`` /// instead. /// /// - Note: `issueMatcher` may be invoked more than once for the same issue. @@ -200,6 +200,7 @@ public func withKnownIssue( /// - isIntermittent: Whether or not the known issue occurs intermittently. If /// this argument is `true` and the known issue does not occur, no secondary /// issue is recorded. +/// - isolation: The actor to which `body` is isolated, if any. /// - sourceLocation: The source location to which any recorded issues should /// be attributed. /// - body: The function to invoke. @@ -218,15 +219,16 @@ public func withKnownIssue( /// Because all errors thrown by `body` are caught as known issues, this /// function is not throwing. If only some errors or issues are known to occur /// while others should continue to cause test failures, use -/// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)-47y3z`` +/// ``withKnownIssue(_:isIntermittent:isolation:sourceLocation:_:when:matching:)`` /// instead. public func withKnownIssue( _ comment: Comment? = nil, isIntermittent: Bool = false, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation = #_sourceLocation, _ body: () async throws -> Void ) async { - try? await withKnownIssue(comment, isIntermittent: isIntermittent, sourceLocation: sourceLocation, body, matching: { _ in true }) + try? await withKnownIssue(comment, isIntermittent: isIntermittent, isolation: isolation, sourceLocation: sourceLocation, body, matching: { _ in true }) } /// Invoke a function that has a known issue that is expected to occur during @@ -237,6 +239,7 @@ public func withKnownIssue( /// - isIntermittent: Whether or not the known issue occurs intermittently. If /// this argument is `true` and the known issue does not occur, no secondary /// issue is recorded. +/// - isolation: The actor to which `body` is isolated, if any. /// - sourceLocation: The source location to which any recorded issues should /// be attributed. /// - body: The function to invoke. @@ -269,13 +272,14 @@ public func withKnownIssue( /// /// It is not necessary to specify both `precondition` and `issueMatcher` if /// only one is relevant. If all errors and issues should be considered known -/// issues, use ``withKnownIssue(_:isIntermittent:sourceLocation:_:)-3g6b7`` +/// issues, use ``withKnownIssue(_:isIntermittent:isolation:sourceLocation:_:when:matching:)`` /// instead. /// /// - Note: `issueMatcher` may be invoked more than once for the same issue. public func withKnownIssue( _ comment: Comment? = nil, isIntermittent: Bool = false, + isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation = #_sourceLocation, _ body: () async throws -> Void, when precondition: () async -> Bool = { true }, diff --git a/Sources/Testing/Testing.docc/Expectations.md b/Sources/Testing/Testing.docc/Expectations.md index de2f625e6..92185876a 100644 --- a/Sources/Testing/Testing.docc/Expectations.md +++ b/Sources/Testing/Testing.docc/Expectations.md @@ -77,7 +77,7 @@ the test when the code doesn't satisfy a requirement, use ### Confirming that asynchronous events occur - -- ``confirmation(_:expectedCount:sourceLocation:_:)`` +- ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` - ``Confirmation`` ### Retrieving information about checked expectations diff --git a/Sources/Testing/Testing.docc/MigratingFromXCTest.md b/Sources/Testing/Testing.docc/MigratingFromXCTest.md index 18cd202fc..b511e0c09 100644 --- a/Sources/Testing/Testing.docc/MigratingFromXCTest.md +++ b/Sources/Testing/Testing.docc/MigratingFromXCTest.md @@ -434,7 +434,7 @@ Some tests, especially those that test asynchronously-delivered events, cannot be readily converted to use Swift concurrency. The testing library offers functionality called _confirmations_ which can be used to implement these tests. Instances of ``Confirmation`` are created and used within the scope of the -function ``confirmation(_:expectedCount:sourceLocation:_:)``. +function ``confirmation(_:expectedCount:isolation:sourceLocation:_:)``. Confirmations function similarly to the expectations API of XCTest, however, they don't block or suspend the caller while waiting for a condition to be fulfilled. @@ -531,8 +531,8 @@ to tell XCTest and its infrastructure that the issue shouldn't cause the test to fail. The testing library has an equivalent function with synchronous and asynchronous variants: -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:)-95r6o`` -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:)-3g6b7`` +- ``withKnownIssue(_:isIntermittent:sourceLocation:_:)`` +- ``withKnownIssue(_:isIntermittent:isolation:sourceLocation:_:)`` This function can be used to annotate a section of a test as having a known issue: @@ -627,8 +627,8 @@ Additional options can be specified when calling `XCTExpectFailure()`: The testing library includes overloads of `withKnownIssue()` that take additional arguments with similar behavior: -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)-5vi5n`` -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)-47y3z`` +- ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)`` +- ``withKnownIssue(_:isIntermittent:isolation:sourceLocation:_:when:matching:)`` To conditionally enable known-issue matching or to match only certain kinds of issues: diff --git a/Sources/Testing/Testing.docc/known-issues.md b/Sources/Testing/Testing.docc/known-issues.md index 495e49d64..31906a5df 100644 --- a/Sources/Testing/Testing.docc/known-issues.md +++ b/Sources/Testing/Testing.docc/known-issues.md @@ -22,10 +22,10 @@ at runtime not to mark the test as failing when issues occur. ### Recording known issues in tests -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:)-95r6o`` -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:)-3g6b7`` -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)-5vi5n`` -- ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)-47y3z`` +- ``withKnownIssue(_:isIntermittent:sourceLocation:_:)`` +- ``withKnownIssue(_:isIntermittent:isolation:sourceLocation:_:)`` +- ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)`` +- ``withKnownIssue(_:isIntermittent:isolation:sourceLocation:_:when:matching:)`` - ``KnownIssueMatcher`` ### Describing a failure or warning diff --git a/Sources/Testing/Testing.docc/testing-asynchronous-code.md b/Sources/Testing/Testing.docc/testing-asynchronous-code.md index 2aa2f68af..548cf07b0 100644 --- a/Sources/Testing/Testing.docc/testing-asynchronous-code.md +++ b/Sources/Testing/Testing.docc/testing-asynchronous-code.md @@ -31,9 +31,9 @@ expected event happens. ### Confirm that an event happens -Call ``confirmation(_:expectedCount:sourceLocation:_:)`` in your asynchronous -test function to create a `Confirmation` for the expected event. In the trailing -closure parameter, call the code under test. Swift Testing passes a +Call ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` in your +asynchronous test function to create a `Confirmation` for the expected event. In +the trailing closure parameter, call the code under test. Swift Testing passes a `Confirmation` as the parameter to the closure, which you call as a function in the event handler for the code under test when the event you're testing for occurs: diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index 690489702..d7a276bcb 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -33,7 +33,9 @@ public import SwiftSyntaxMacros /// /// The `__check()` function that implements expansions of these macros must /// take any developer-supplied arguments _before_ the ones inserted during -/// macro expansion (starting with the `"expression"` argument.) +/// macro expansion (starting with the `"expression"` argument.) The `isolation` +/// argument (if present) and `sourceLocation` argument are placed at the end of +/// the generated function call's argument list. public protocol ConditionMacro: ExpressionMacro, Sendable { /// Whether or not the macro's expansion may throw an error. static var isThrowing: Bool { get } @@ -41,6 +43,10 @@ public protocol ConditionMacro: ExpressionMacro, Sendable { // MARK: - +/// The token used as the label of the argument passed to `#expect()` and +/// `#require()` and used for actor isolation. +private var _isolationLabel: TokenSyntax { .identifier("isolation") } + /// The token used as the label of the source location argument passed to /// `#expect()` and `#require()`. private var _sourceLocationLabel: TokenSyntax { .identifier("sourceLocation") } @@ -89,6 +95,9 @@ extension ConditionMacro { // never the first argument.) commentIndex = macroArguments.dropFirst().lastIndex { $0.label == nil } } + let isolationArgumentIndex = macroArguments.lazy + .compactMap(\.label) + .firstIndex { $0.tokenKind == _isolationLabel.tokenKind } let sourceLocationArgumentIndex = macroArguments.lazy .compactMap(\.label) .firstIndex { $0.tokenKind == _sourceLocationLabel.tokenKind } @@ -103,6 +112,7 @@ extension ConditionMacro { // arguments here. checkArguments += macroArguments.indices.lazy .filter { $0 != commentIndex } + .filter { $0 != isolationArgumentIndex } .filter { $0 != sourceLocationArgumentIndex } .map { macroArguments[$0] } @@ -124,6 +134,7 @@ extension ConditionMacro { // "sourceLocation" arguments here. checkArguments += macroArguments.dropFirst().indices.lazy .filter { $0 != commentIndex } + .filter { $0 != isolationArgumentIndex } .filter { $0 != sourceLocationArgumentIndex } .map { macroArguments[$0] } @@ -160,6 +171,10 @@ extension ConditionMacro { checkArguments.append(Argument(label: "isRequired", expression: BooleanLiteralExprSyntax(isThrowing))) + if let isolationArgumentIndex { + checkArguments.append(macroArguments[isolationArgumentIndex]) + } + if let sourceLocationArgumentIndex { checkArguments.append(macroArguments[sourceLocationArgumentIndex]) } else { diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index 4bdbd88bf..1da09a227 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -88,6 +88,8 @@ struct ConditionMacroTests { ##"Testing.__checkPropertyAccess(a.self, getting: { $0???.isB }, expression: .__fromPropertyAccess(.__fromSyntaxNode("a"), .__fromSyntaxNode("isB")), comments: [], isRequired: false, sourceLocation: Testing.SourceLocation.__here()).__expected()"##, ##"#expect(a?.b.isB)"##: ##"Testing.__checkPropertyAccess(a?.b.self, getting: { $0?.isB }, expression: .__fromPropertyAccess(.__fromSyntaxNode("a?.b"), .__fromSyntaxNode("isB")), comments: [], isRequired: false, sourceLocation: Testing.SourceLocation.__here()).__expected()"##, + ##"#expect(isolation: somewhere) {}"##: + ##"Testing.__checkClosureCall(performing: {}, expression: .__fromSyntaxNode("{}"), comments: [], isRequired: false, isolation: somewhere, sourceLocation: Testing.SourceLocation.__here()).__expected()"##, ] ) func expectMacro(input: String, expectedOutput: String) throws { @@ -164,6 +166,8 @@ struct ConditionMacroTests { ##"Testing.__checkPropertyAccess(a.self, getting: { $0???.isB }, expression: .__fromPropertyAccess(.__fromSyntaxNode("a"), .__fromSyntaxNode("isB")), comments: [], isRequired: true, sourceLocation: Testing.SourceLocation.__here()).__required()"##, ##"#require(a?.b.isB)"##: ##"Testing.__checkPropertyAccess(a?.b.self, getting: { $0?.isB }, expression: .__fromPropertyAccess(.__fromSyntaxNode("a?.b"), .__fromSyntaxNode("isB")), comments: [], isRequired: true, sourceLocation: Testing.SourceLocation.__here()).__required()"##, + ##"#require(isolation: somewhere) {}"##: + ##"Testing.__checkClosureCall(performing: {}, expression: .__fromSyntaxNode("{}"), comments: [], isRequired: true, isolation: somewhere, sourceLocation: Testing.SourceLocation.__here()).__required()"##, ] ) func requireMacro(input: String, expectedOutput: String) throws { diff --git a/Tests/TestingTests/ConfirmationTests.swift b/Tests/TestingTests/ConfirmationTests.swift index b545c0ab6..11d04b48c 100644 --- a/Tests/TestingTests/ConfirmationTests.swift +++ b/Tests/TestingTests/ConfirmationTests.swift @@ -62,6 +62,12 @@ struct ConfirmationTests { } } #endif + + @Test("Main actor isolation") + @MainActor + func mainActorIsolated() async { + await confirmation { $0() } + } } // MARK: - Fixtures diff --git a/Tests/TestingTests/KnownIssueTests.swift b/Tests/TestingTests/KnownIssueTests.swift index 95f6344a9..ac32f1a03 100644 --- a/Tests/TestingTests/KnownIssueTests.swift +++ b/Tests/TestingTests/KnownIssueTests.swift @@ -376,5 +376,12 @@ final class KnownIssueTests: XCTestCase { await fulfillment(of: [issueRecorded, knownIssueNotRecorded], timeout: 0.0) } + + @MainActor + func testMainActorIsolated() async { + await Test { + await withKnownIssue(isIntermittent: true) { () async in } + }.run(configuration: .init()) + } } #endif