From ed88c55abed9c85062033743a40a3aca23f3f769 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Mon, 18 Mar 2024 18:22:47 -0700 Subject: [PATCH] Update Require DSL to be (mostly) Sendable. (#1130) * Update Require DSL to largely be Sendable * Fix now-broken tests --- Sources/Nimble/DSL+Require.swift | 20 ++-- .../Nimble/Matchers/PostNotification.swift | 2 +- Sources/Nimble/Polling+Require.swift | 10 +- Sources/Nimble/Requirement.swift | 6 +- .../NimbleTests/AsyncAwaitTest+Require.swift | 102 +++++++++--------- Tests/NimbleTests/PollingTest.swift | 2 +- 6 files changed, 75 insertions(+), 67 deletions(-) diff --git a/Sources/Nimble/DSL+Require.swift b/Sources/Nimble/DSL+Require.swift index d21c39f1d..c04bbfcb2 100644 --- a/Sources/Nimble/DSL+Require.swift +++ b/Sources/Nimble/DSL+Require.swift @@ -123,7 +123,7 @@ public func requires(file: FileString = #file, line: UInt = #line, customError: /// `require` will return the result of the expression if the matcher passes, and throw an error if not. /// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown. @discardableResult -public func require(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @escaping () async throws -> T?) -> AsyncRequirement { +public func require(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @escaping @Sendable () async throws -> T?) -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression, @@ -137,7 +137,7 @@ public func require(file: FileString = #file, line: UInt = #line, customError /// `require` will return the result of the expression if the matcher passes, and throw an error if not. /// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown. @discardableResult -public func require(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: () -> (() async throws -> T)) -> AsyncRequirement { +public func require(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: () -> (@Sendable () async throws -> T)) -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), @@ -151,7 +151,7 @@ public func require(file: FileString = #file, line: UInt = #line, customError /// `require` will return the result of the expression if the matcher passes, and throw an error if not. /// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown. @discardableResult -public func require(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: () -> (() async throws -> T?)) -> AsyncRequirement { +public func require(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: () -> (@Sendable () async throws -> T?)) -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), @@ -167,7 +167,7 @@ public func require(file: FileString = #file, line: UInt = #line, customError /// /// This is provided to avoid confusion between `require -> SyncRequirement` and `require -> AsyncRequirement`. @discardableResult -public func requirea(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure @escaping () async throws -> T?) async -> AsyncRequirement { +public func requirea(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure @escaping @Sendable () async throws -> T?) async -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression, @@ -183,7 +183,7 @@ public func requirea(file: FileString = #file, line: UInt = #line, customErro /// /// This is provided to avoid confusion between `require -> SyncRequirement` and `require -> AsyncRequirement` @discardableResult -public func requirea(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure () -> (() async throws -> T)) async -> AsyncRequirement { +public func requirea(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () async throws -> T)) async -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), @@ -199,7 +199,7 @@ public func requirea(file: FileString = #file, line: UInt = #line, customErro /// /// This is provided to avoid confusion between `require -> SyncRequirement` and `require -> AsyncRequirement` @discardableResult -public func requirea(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure () -> (() async throws -> T?)) async -> AsyncRequirement { +public func requirea(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () async throws -> T?)) async -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), @@ -256,7 +256,7 @@ public func unwraps(file: FileString = #file, line: UInt = #line, customError /// `unwrap` will return the result of the expression if it is non-nil, and throw an error if the value is nil. /// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown. @discardableResult -public func unwrap(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @escaping () async throws -> T?) async throws -> T { +public func unwrap(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @escaping @Sendable () async throws -> T?) async throws -> T { try await requirea(file: file, line: line, customError: customError, try await expression()).toNot(beNil()) } @@ -266,7 +266,7 @@ public func unwrap(file: FileString = #file, line: UInt = #line, customError: /// `unwrap` will return the result of the expression if it is non-nil, and throw an error if the value is nil. /// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown. @discardableResult -public func unwrap(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: () -> (() async throws -> T?)) async throws -> T { +public func unwrap(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: () -> (@Sendable () async throws -> T?)) async throws -> T { try await requirea(file: file, line: line, customError: customError, expression()).toNot(beNil()) } @@ -276,7 +276,7 @@ public func unwrap(file: FileString = #file, line: UInt = #line, customError: /// `unwrapa` will return the result of the expression if it is non-nil, and throw an error if the value is nil. /// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown. @discardableResult -public func unwrapa(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure @escaping () async throws -> T?) async throws -> T { +public func unwrapa(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure @escaping @Sendable () async throws -> T?) async throws -> T { try await requirea(file: file, line: line, customError: customError, try await expression()).toNot(beNil()) } @@ -286,6 +286,6 @@ public func unwrapa(file: FileString = #file, line: UInt = #line, customError /// `unwrapa` will return the result of the expression if it is non-nil, and throw an error if the value is nil. /// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown. @discardableResult -public func unwrapa(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure () -> (() async throws -> T?)) async throws -> T { +public func unwrapa(file: FileString = #file, line: UInt = #line, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () async throws -> T?)) async throws -> T { try await requirea(file: file, line: line, customError: customError, expression()).toNot(beNil()) } diff --git a/Sources/Nimble/Matchers/PostNotification.swift b/Sources/Nimble/Matchers/PostNotification.swift index 6601b68a7..fd00d1a22 100644 --- a/Sources/Nimble/Matchers/PostNotification.swift +++ b/Sources/Nimble/Matchers/PostNotification.swift @@ -80,7 +80,7 @@ private func _postNotifications( let message = ExpectationMessage .expectedTo("post notifications - but was called off the main thread.") .appended(details: "postNotifications and postDistributedNotifications attempted to run their predicate off the main thread. This is a bug in Nimble.") - return PredicateResult(status: .fail, message: message) + return MatcherResult(status: .fail, message: message) } let collectorNotificationsExpression = Expression( diff --git a/Sources/Nimble/Polling+Require.swift b/Sources/Nimble/Polling+Require.swift index 7f9c9268d..09ee815d4 100644 --- a/Sources/Nimble/Polling+Require.swift +++ b/Sources/Nimble/Polling+Require.swift @@ -189,7 +189,9 @@ extension SyncRequirement { public func alwaysTo(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) throws -> Value { return try toAlways(matcher, until: until, pollInterval: pollInterval, description: description) } +} +extension SyncRequirement where Value: Sendable { // MARK: - Async Polling with Synchronous Matchers /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. @@ -734,28 +736,28 @@ public func pollUnwraps(file: FileString = #file, line: UInt = #line, _ expre /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `requirea(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: @escaping () async throws -> T?) async throws -> T { +public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: @escaping @Sendable () async throws -> T?) async throws -> T { try await requirea(file: file, line: line, try await expression()).toEventuallyNot(beNil()) } /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `requirea(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: () -> (() async throws -> T?)) async throws -> T { +public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: () -> (@Sendable () async throws -> T?)) async throws -> T { try await requirea(file: file, line: line, expression()).toEventuallyNot(beNil()) } /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `requirea(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrapa(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () async throws -> T?) async throws -> T { +public func pollUnwrapa(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping @Sendable () async throws -> T?) async throws -> T { try await requirea(file: file, line: line, try await expression()).toEventuallyNot(beNil()) } /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `requirea(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrapa(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() async throws -> T?)) async throws -> T { +public func pollUnwrapa(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (@Sendable () async throws -> T?)) async throws -> T { try await requirea(file: file, line: line, expression()).toEventuallyNot(beNil()) } diff --git a/Sources/Nimble/Requirement.swift b/Sources/Nimble/Requirement.swift index 91c8487da..d03cdcc33 100644 --- a/Sources/Nimble/Requirement.swift +++ b/Sources/Nimble/Requirement.swift @@ -1,6 +1,6 @@ import Foundation -public struct RequireError: Error, CustomNSError { +public struct RequireError: Error, CustomNSError, Sendable { let message: String let location: SourceLocation @@ -115,7 +115,9 @@ public struct SyncRequirement { public func notTo(_ matcher: Matcher, description: String? = nil) throws -> Value { try toNot(matcher, description: description) } +} +extension SyncRequirement where Value: Sendable { // MARK: - AsyncMatchers /// Tests the actual value using a matcher to match. @discardableResult @@ -140,7 +142,7 @@ public struct SyncRequirement { } } -public struct AsyncRequirement { +public struct AsyncRequirement: Sendable { public let expression: AsyncExpression /// A custom error to throw. diff --git a/Tests/NimbleTests/AsyncAwaitTest+Require.swift b/Tests/NimbleTests/AsyncAwaitTest+Require.swift index 7925cc36d..5550ceb5e 100644 --- a/Tests/NimbleTests/AsyncAwaitTest+Require.swift +++ b/Tests/NimbleTests/AsyncAwaitTest+Require.swift @@ -8,7 +8,7 @@ import NimbleSharedTestHelpers final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_body_length func testToPositiveMatches() async throws { - func someAsyncFunction() async throws -> Int { + @Sendable func someAsyncFunction() async throws -> Int { try await Task.sleep(nanoseconds: 1_000_000) // 1 millisecond return 1 } @@ -16,10 +16,10 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b try await require { try await someAsyncFunction() }.to(equal(1)) } - class Error: Swift.Error {} - let errorToThrow = Error() + struct Error: Swift.Error, Sendable {} + static let errorToThrow = Error() - private func doThrowError() throws -> Int { + private static func doThrowError() throws -> Int { throw errorToThrow } @@ -40,16 +40,16 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b await failsWithErrorMessage("expected to eventually equal <1>, got <0>") { try await require { value }.toEventually(equal(1)) } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await require { try self.doThrowError() }.toEventually(equal(1)) + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await require { try Self.doThrowError() }.toEventually(equal(1)) } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await require { try self.doThrowError() }.toEventuallyNot(equal(0)) + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await require { try Self.doThrowError() }.toEventuallyNot(equal(0)) } } func testPollUnwrapPositiveCase() async { - func someAsyncFunction() async throws -> Int { + @Sendable func someAsyncFunction() async throws -> Int { try await Task.sleep(nanoseconds: 1_000_000) // 1 millisecond return 1 } @@ -62,11 +62,11 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b await failsWithErrorMessage("expected to eventually not be nil, got nil") { try await pollUnwrap { nil as Int? } } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await pollUnwrap { try self.doThrowError() as Int? } + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await pollUnwrap { try Self.doThrowError() as Int? } } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await pollUnwrap { try self.doThrowError() as Int? } + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await pollUnwrap { try Self.doThrowError() as Int? } } } @@ -90,18 +90,20 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b } func testToEventuallyWaitingOnMainTask() async throws { - class EncapsulatedValue { - static var executed = false + class EncapsulatedValue: @unchecked Sendable { + var executed = false - static func execute() { + func execute() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - Self.executed = true + self.executed = true } } } - EncapsulatedValue.execute() - try await require(EncapsulatedValue.executed).toEventually(beTrue()) + let obj = EncapsulatedValue() + + obj.execute() + try await require(obj.executed).toEventually(beTrue()) } @MainActor @@ -117,7 +119,7 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b // is otherwise correctly executing on the main thread. // Double-y so if your CI automatically reads backtraces (like what the main thread checker will output) as test crashes, // and fails your build. - struct MySubject: CustomDebugStringConvertible, Equatable { + struct MySubject: CustomDebugStringConvertible, Equatable, Sendable { var debugDescription: String { expect(Thread.isMainThread).to(beTrue()) return "Test" @@ -141,7 +143,7 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b func testToEventuallyWithAsyncExpectationDoesNotNecessarilyExecutesExpressionOnMainActor() async throws { // This prevents a "Class property 'isMainThread' is unavailable from asynchronous contexts; Work intended for the main actor should be marked with @MainActor; this is an error in Swift 6" warning. // However, the functionality actually works as you'd expect it to, you're just expected to tag things to use the main actor. - func isMainThread() -> Bool { Thread.isMainThread } + @Sendable func isMainThread() -> Bool { Thread.isMainThread } try await requirea(isMainThread()).toEventually(beFalse()) try await requirea(isMainThread()).toEventuallyNot(beTrue()) @@ -149,41 +151,43 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b try await requirea(isMainThread()).toNever(beTrue(), until: .seconds(1)) } - @MainActor - func testToEventuallyWithAsyncExpectationDoesExecuteExpressionOnMainActorWhenTestRunsOnMainActor() async throws { - // This prevents a "Class property 'isMainThread' is unavailable from asynchronous contexts; Work intended for the main actor should be marked with @MainActor; this is an error in Swift 6" warning. - // However, the functionality actually works as you'd expect it to, you're just expected to tag things to use the main actor. - func isMainThread() -> Bool { Thread.isMainThread } - - try await requirea(isMainThread()).toEventually(beTrue()) - try await requirea(isMainThread()).toEventuallyNot(beFalse()) - try await requirea(isMainThread()).toAlways(beTrue(), until: .seconds(1)) - try await requirea(isMainThread()).toNever(beFalse(), until: .seconds(1)) - } - func testToEventuallyWithCustomDefaultTimeout() async throws { PollingDefaults.timeout = .seconds(2) defer { PollingDefaults.timeout = .seconds(1) } - var value = 0 + final class Box: @unchecked Sendable { + private let lock = NSRecursiveLock() + + private var _value = 0 + var value: Int { + lock.lock() + defer { + lock.unlock() + } + return _value + } - let sleepThenSetValueTo: (Int) -> Void = { newValue in - Thread.sleep(forTimeInterval: 1.1) - value = newValue + func sleepThenSetValueTo(_ newValue: Int) { + Thread.sleep(forTimeInterval: 1.1) + lock.lock() + _value = newValue + lock.unlock() + } } + let box = Box() let task = Task { - sleepThenSetValueTo(1) + box.sleepThenSetValueTo(1) } - try await require { value }.toEventually(equal(1)) + try await require { box.value }.toEventually(equal(1)) let secondTask = Task { - sleepThenSetValueTo(0) + box.sleepThenSetValueTo(0) } - try await require { value }.toEventuallyNot(equal(1)) + try await require { box.value }.toEventuallyNot(equal(1)) _ = await task.value _ = await secondTask.result @@ -237,11 +241,11 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b deferToMainQueue { value = 1 } try await require { value }.neverTo(equal(1)) } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await require { try self.doThrowError() }.toNever(equal(0)) + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await require { try Self.doThrowError() }.toNever(equal(0)) } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await require { try self.doThrowError() }.neverTo(equal(0)) + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await require { try Self.doThrowError() }.neverTo(equal(0)) } await failsWithErrorMessage("expected to never equal <1>, got <1>") { try await require(1).toNever(equal(1)) @@ -273,11 +277,11 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b deferToMainQueue { value = 0 } try await require { value }.alwaysTo(equal(1)) } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await require { try self.doThrowError() }.toAlways(equal(0)) + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await require { try Self.doThrowError() }.toAlways(equal(0)) } - await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { - try await require { try self.doThrowError() }.alwaysTo(equal(0)) + await failsWithErrorMessage("unexpected error thrown: <\(Self.errorToThrow)>") { + try await require { try Self.doThrowError() }.alwaysTo(equal(0)) } await failsWithErrorMessage("expected to always equal <0>, got (use beNil() to match nils)") { try await require(nil).toAlways(equal(0)) diff --git a/Tests/NimbleTests/PollingTest.swift b/Tests/NimbleTests/PollingTest.swift index cb0de8d9b..cf6583338 100644 --- a/Tests/NimbleTests/PollingTest.swift +++ b/Tests/NimbleTests/PollingTest.swift @@ -13,7 +13,7 @@ import NimbleSharedTestHelpers // swiftlint:disable:next type_body_length final class PollingTest: XCTestCase { - class Error: Swift.Error {} + struct Error: Swift.Error, Sendable {} let errorToThrow = Error() private func doThrowError() throws -> Int {