diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index 90edd5607..dd5ec8095 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -139,6 +139,7 @@ 895644DF2C1B71DE0006EC12 /* SwiftTestingSupportTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895644DE2C1B71DE0006EC12 /* SwiftTestingSupportTest.swift */; }; 896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; }; 8969624A2A5FAD5F00A7929D /* AsyncAllPassTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */; }; + 897F84F42BA922B500BF354B /* NSLocking+Nimble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897F84F32BA922B500BF354B /* NSLocking+Nimble.swift */; }; 898F28B025D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */; }; 899441EF2902EE4B00C1FAF9 /* AsyncAwaitTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */; }; 899441F82902EF2500C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; @@ -149,6 +150,7 @@ 89D8AC852B3211C600410644 /* CwlCatchException in Frameworks */ = {isa = PBXBuildFile; productRef = 89D8AC842B3211C600410644 /* CwlCatchException */; }; 89D8AC872B3211EA00410644 /* CwlPosixPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, watchos, ); productRef = 89D8AC862B3211EA00410644 /* CwlPosixPreconditionTesting */; }; 89D8AC892B3211EA00410644 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; platformFilters = (driverkit, ios, maccatalyst, macos, xros, ); productRef = 89D8AC882B3211EA00410644 /* CwlPreconditionTesting */; }; + 89E5E1682BC78724002D54ED /* LockedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E5E1672BC78724002D54ED /* LockedContainer.swift */; }; 89EEF5A52A03293100988224 /* AsyncMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncMatcher.swift */; }; 89EEF5B72A032C3200988224 /* AsyncPredicateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */; }; 89EEF5C02A06211C00988224 /* AsyncHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */; }; @@ -330,6 +332,7 @@ 895644DE2C1B71DE0006EC12 /* SwiftTestingSupportTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTestingSupportTest.swift; sourceTree = ""; }; 896962402A5FABD000A7929D /* AsyncAllPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPass.swift; sourceTree = ""; }; 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPassTest.swift; sourceTree = ""; }; + 897F84F32BA922B500BF354B /* NSLocking+Nimble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLocking+Nimble.swift"; sourceTree = ""; }; 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlwaysFailMatcher.swift; sourceTree = ""; }; 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAwaitTest.swift; sourceTree = ""; }; 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DSL+AsyncAwait.swift"; sourceTree = ""; }; @@ -337,6 +340,7 @@ 89B8C6102C6478F2001F12D3 /* NegationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegationTest.swift; sourceTree = ""; }; 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTimerSequenceTest.swift; sourceTree = ""; }; 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPromiseTest.swift; sourceTree = ""; }; + 89E5E1672BC78724002D54ED /* LockedContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedContainer.swift; sourceTree = ""; }; 89EEF5A42A03293100988224 /* AsyncMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncMatcher.swift; sourceTree = ""; }; 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPredicateTest.swift; sourceTree = ""; }; 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHelpers.swift; sourceTree = ""; }; @@ -624,12 +628,14 @@ isa = PBXGroup; children = ( 1FD8CD261968AB07008ED995 /* PollAwait.swift */, + 897F84F32BA922B500BF354B /* NSLocking+Nimble.swift */, 89F5E08B290B8D22001F9377 /* AsyncAwait.swift */, 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */, 1FD8CD271968AB07008ED995 /* SourceLocation.swift */, 1FD8CD281968AB07008ED995 /* Stringers.swift */, AE4BA9AC1C88DDB500B73906 /* Errors.swift */, 0477153423B740AD00402D4E /* NimbleTimeInterval.swift */, + 89E5E1672BC78724002D54ED /* LockedContainer.swift */, ); path = Utils; sourceTree = ""; @@ -864,6 +870,7 @@ 1F1871D91CA89EF100A34BF2 /* NMBExpectation.swift in Sources */, DA9E8C831A414BB9002633C2 /* DSL+Wait.swift in Sources */, DDB1BC7A1A92235600F743C3 /* AllPass.swift in Sources */, + 89E5E1682BC78724002D54ED /* LockedContainer.swift in Sources */, 1FD8CD3F1968AB07008ED995 /* BeAKindOf.swift in Sources */, 1FD8CD2F1968AB07008ED995 /* AssertionRecorder.swift in Sources */, 7B13BA061DD360AA00C9098C /* ContainElementSatisfying.swift in Sources */, @@ -892,6 +899,7 @@ 1FD8CD571968AB07008ED995 /* Contain.swift in Sources */, 7A0A26231E7F52360092A34E /* ToSucceed.swift in Sources */, 89F5E0862908E655001F9377 /* Polling+AsyncAwait.swift in Sources */, + 897F84F42BA922B500BF354B /* NSLocking+Nimble.swift in Sources */, 899441F82902EF2500C1FAF9 /* DSL+AsyncAwait.swift in Sources */, 1FD8CD491968AB07008ED995 /* BeGreaterThanOrEqualTo.swift in Sources */, 1FE661571E6574E30035F243 /* ExpectationMessage.swift in Sources */, diff --git a/Sources/Nimble/Adapters/AdapterProtocols.swift b/Sources/Nimble/Adapters/AdapterProtocols.swift index d7734879e..5aba01790 100644 --- a/Sources/Nimble/Adapters/AdapterProtocols.swift +++ b/Sources/Nimble/Adapters/AdapterProtocols.swift @@ -1,5 +1,5 @@ /// Protocol for the assertion handler that Nimble uses for all expectations. -public protocol AssertionHandler { +public protocol AssertionHandler: Sendable { func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) } @@ -10,11 +10,21 @@ public protocol AssertionHandler { /// before using any matchers, otherwise Nimble will abort the program. /// /// @see AssertionHandler -public var NimbleAssertionHandler: AssertionHandler = { () -> AssertionHandler in +public var NimbleAssertionHandler: AssertionHandler { + // swiftlint:disable:previous identifier_name + get { + _NimbleAssertionHandler.value + } + set { + _NimbleAssertionHandler.set(newValue) + } +} + +private let _NimbleAssertionHandler = LockedContainer { // swiftlint:disable:previous identifier_name if isSwiftTestingAvailable() || isXCTestAvailable() { return NimbleTestingHandler() } return NimbleTestingUnavailableHandler() -}() +} diff --git a/Sources/Nimble/Adapters/AssertionDispatcher.swift b/Sources/Nimble/Adapters/AssertionDispatcher.swift index 94a9030eb..cf0b8ac77 100644 --- a/Sources/Nimble/Adapters/AssertionDispatcher.swift +++ b/Sources/Nimble/Adapters/AssertionDispatcher.swift @@ -4,7 +4,7 @@ /// @warning Does not fully dispatch if one of the handlers raises an exception. /// This is possible with XCTest-based assertion handlers. /// -public class AssertionDispatcher: AssertionHandler { +public final class AssertionDispatcher: AssertionHandler { let handlers: [AssertionHandler] public init(handlers: [AssertionHandler]) { diff --git a/Sources/Nimble/Adapters/AssertionRecorder+Async.swift b/Sources/Nimble/Adapters/AssertionRecorder+Async.swift index 286be5f2d..441c54dae 100644 --- a/Sources/Nimble/Adapters/AssertionRecorder+Async.swift +++ b/Sources/Nimble/Adapters/AssertionRecorder+Async.swift @@ -8,10 +8,7 @@ /// /// @see AssertionHandler public func withAssertionHandler(_ tempAssertionHandler: AssertionHandler, - fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, + location: SourceLocation = SourceLocation(), closure: () async throws -> Void) async { let environment = NimbleEnvironment.activeInstance let oldRecorder = environment.assertionHandler @@ -27,7 +24,6 @@ public func withAssertionHandler(_ tempAssertionHandler: AssertionHandler, } catch { let failureMessage = FailureMessage() failureMessage.stringValue = "unexpected error thrown: <\(error)>" - let location = SourceLocation(fileID: fileID, filePath: file, line: line, column: column) tempAssertionHandler.assert(false, message: failureMessage, location: location) } } diff --git a/Sources/Nimble/Adapters/AssertionRecorder.swift b/Sources/Nimble/Adapters/AssertionRecorder.swift index 0ee397219..e49988032 100644 --- a/Sources/Nimble/Adapters/AssertionRecorder.swift +++ b/Sources/Nimble/Adapters/AssertionRecorder.swift @@ -3,7 +3,7 @@ /// /// @see AssertionRecorder /// @see AssertionHandler -public struct AssertionRecord: CustomStringConvertible { +public struct AssertionRecord: CustomStringConvertible, Sendable { /// Whether the assertion succeeded or failed public let success: Bool /// The failure message the assertion would display on failure. @@ -20,9 +20,17 @@ public struct AssertionRecord: CustomStringConvertible { /// This is useful for testing failure messages for matchers. /// /// @see AssertionHandler -public class AssertionRecorder: AssertionHandler { +public final class AssertionRecorder: AssertionHandler { /// All the assertions that were captured by this recorder - public var assertions = [AssertionRecord]() + public var assertions: [AssertionRecord] { + get { + _assertion.value + } + set { + _assertion.set(newValue) + } + } + private let _assertion = LockedContainer([AssertionRecord]()) public init() {} @@ -63,10 +71,7 @@ extension NMBExceptionCapture { /// /// @see AssertionHandler public func withAssertionHandler(_ tempAssertionHandler: AssertionHandler, - fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, + location: SourceLocation = SourceLocation(), closure: () throws -> Void) { let environment = NimbleEnvironment.activeInstance let oldRecorder = environment.assertionHandler @@ -84,11 +89,6 @@ public func withAssertionHandler(_ tempAssertionHandler: AssertionHandler, } catch { let failureMessage = FailureMessage() failureMessage.stringValue = "unexpected error thrown: <\(error)>" - let location = SourceLocation( - fileID: fileID, - filePath: file, - line: line, column: column - ) tempAssertionHandler.assert(false, message: failureMessage, location: location) } } diff --git a/Sources/Nimble/Adapters/NMBExpectation.swift b/Sources/Nimble/Adapters/NMBExpectation.swift index f8d2d692f..52548a6fc 100644 --- a/Sources/Nimble/Adapters/NMBExpectation.swift +++ b/Sources/Nimble/Adapters/NMBExpectation.swift @@ -13,27 +13,42 @@ private func from(objcMatcher: NMBMatcher) -> Matcher { } // Equivalent to Expectation, but for Nimble's Objective-C interface -public class NMBExpectation: NSObject { - internal let _actualBlock: () -> NSObject? - internal var _negative: Bool +public final class NMBExpectation: NSObject, Sendable { + internal let _actualBlock: @Sendable () -> NSObject? + internal let _negative: Bool internal let _file: FileString internal let _line: UInt - internal var _timeout: NimbleTimeInterval = .seconds(1) + internal let _timeout: NimbleTimeInterval - @objc public init(actualBlock: @escaping () -> NSObject?, negative: Bool, file: FileString, line: UInt) { + @objc public init(actualBlock: @escaping @Sendable () -> NSObject?, negative: Bool, file: FileString, line: UInt) { self._actualBlock = actualBlock self._negative = negative self._file = file self._line = line + self._timeout = .seconds(1) + } + + private init(actualBlock: @escaping @Sendable () -> NSObject?, negative: Bool, file: FileString, line: UInt, timeout: NimbleTimeInterval) { + self._actualBlock = actualBlock + self._negative = negative + self._file = file + self._line = line + self._timeout = timeout } private var expectValue: SyncExpectation { - return expect(file: _file, line: _line, self._actualBlock() as NSObject?) + return expect(location: SourceLocation(fileID: "unknown/\(_file)", filePath: _file, line: _line, column: 0), self._actualBlock() as NSObject?) } @objc public var withTimeout: (TimeInterval) -> NMBExpectation { - return { timeout in self._timeout = timeout.nimbleInterval - return self + return { timeout in + NMBExpectation( + actualBlock: self._actualBlock, + negative: self._negative, + file: self._file, + line: self._line, + timeout: timeout.nimbleInterval + ) } } diff --git a/Sources/Nimble/Adapters/NimbleSwiftTestingHandler.swift b/Sources/Nimble/Adapters/NimbleSwiftTestingHandler.swift index bf86bb6d5..ca7511f03 100644 --- a/Sources/Nimble/Adapters/NimbleSwiftTestingHandler.swift +++ b/Sources/Nimble/Adapters/NimbleSwiftTestingHandler.swift @@ -7,7 +7,7 @@ import Foundation @_implementationOnly import Testing #endif -public class NimbleSwiftTestingHandler: AssertionHandler { +public struct NimbleSwiftTestingHandler: AssertionHandler { public func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) { if !assertion { recordTestingFailure("\(message.stringValue)\n", location: location) diff --git a/Sources/Nimble/Adapters/NimbleXCTestHandler.swift b/Sources/Nimble/Adapters/NimbleXCTestHandler.swift index 8db21f44e..56a932a9a 100644 --- a/Sources/Nimble/Adapters/NimbleXCTestHandler.swift +++ b/Sources/Nimble/Adapters/NimbleXCTestHandler.swift @@ -2,7 +2,7 @@ import Foundation import XCTest /// Default handler for Nimble. This assertion handler passes on to Swift Testing or XCTest. -public class NimbleTestingHandler: AssertionHandler { +public struct NimbleTestingHandler: AssertionHandler { public func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) { if isRunningSwiftTest() { NimbleSwiftTestingHandler().assert(assertion, message: message, location: location) @@ -13,7 +13,7 @@ public class NimbleTestingHandler: AssertionHandler { } /// This assertion handler passes failures along to XCTest. -public class NimbleXCTestHandler: AssertionHandler { +public struct NimbleXCTestHandler: AssertionHandler { public func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) { if !assertion { recordFailure("\(message.stringValue)\n", location: location) @@ -23,7 +23,7 @@ public class NimbleXCTestHandler: AssertionHandler { /// Alternative handler for Nimble. This assertion handler passes failures along /// to XCTest by attempting to reduce the failure message size. -public class NimbleShortXCTestHandler: AssertionHandler { +public struct NimbleShortXCTestHandler: AssertionHandler { public func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) { if !assertion { let msg: String @@ -39,7 +39,7 @@ public class NimbleShortXCTestHandler: AssertionHandler { /// Fallback handler in case XCTest/Swift Testing is unavailable. This assertion handler will abort /// the program if it is invoked. -class NimbleTestingUnavailableHandler: AssertionHandler { +struct NimbleTestingUnavailableHandler: AssertionHandler { func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) { fatalError("XCTest and Swift Testing are not available and no custom assertion handler was configured. Aborting.") } @@ -47,24 +47,37 @@ class NimbleTestingUnavailableHandler: AssertionHandler { #if canImport(Darwin) /// Helper class providing access to the currently executing XCTestCase instance, if any -@objc final public class CurrentTestCaseTracker: NSObject, XCTestObservation { +@objc final public class CurrentTestCaseTracker: NSObject, XCTestObservation, @unchecked Sendable { @objc public static let sharedInstance = CurrentTestCaseTracker() - private(set) var currentTestCase: XCTestCase? + private let lock = NSRecursiveLock() + + private var _currentTestCase: XCTestCase? + var currentTestCase: XCTestCase? { + lock.lock() + defer { lock.unlock() } + return _currentTestCase + } private var stashed_swift_reportFatalErrorsToDebugger: Bool = false @objc public func testCaseWillStart(_ testCase: XCTestCase) { + lock.lock() + defer { lock.unlock() } + #if (os(macOS) || os(iOS) || os(visionOS)) && !SWIFT_PACKAGE stashed_swift_reportFatalErrorsToDebugger = _swift_reportFatalErrorsToDebugger _swift_reportFatalErrorsToDebugger = false #endif - currentTestCase = testCase + _currentTestCase = testCase } @objc public func testCaseDidFinish(_ testCase: XCTestCase) { - currentTestCase = nil + lock.lock() + defer { lock.unlock() } + + _currentTestCase = nil #if (os(macOS) || os(iOS) || os(visionOS)) && !SWIFT_PACKAGE _swift_reportFatalErrorsToDebugger = stashed_swift_reportFatalErrorsToDebugger diff --git a/Sources/Nimble/AsyncExpression.swift b/Sources/Nimble/AsyncExpression.swift index b669d5a0b..22c366cf2 100644 --- a/Sources/Nimble/AsyncExpression.swift +++ b/Sources/Nimble/AsyncExpression.swift @@ -1,15 +1,78 @@ -// Memoizes the given closure, only calling the passed -// closure once; even if repeat calls to the returned closure -private func memoizedClosure(_ closure: @escaping () async throws -> T) -> (Bool) async throws -> T { - var cache: T? - return { withoutCaching in - if withoutCaching || cache == nil { - cache = try await closure() +import Foundation + +/// Memoizes the given closure, only calling the passed closure once; even if repeat calls to the returned closure +private final class MemoizedClosure: Sendable { + enum State { + case notStarted + case inProgress + case finished(Result) + } + + private let lock = NSRecursiveLock() + nonisolated(unsafe) private var _state = State.notStarted + nonisolated(unsafe) private var _continuations = [CheckedContinuation]() + nonisolated(unsafe) private var _task: Task? + + let closure: @Sendable () async throws -> T + + init(_ closure: @escaping @Sendable () async throws -> T) { + self.closure = closure + } + + deinit { + _task?.cancel() + } + + @Sendable func callAsFunction(_ withoutCaching: Bool) async throws -> T { + if withoutCaching { + try await closure() + } else { + try await withCheckedThrowingContinuation { continuation in + lock.withLock { + switch _state { + case .notStarted: + _state = .inProgress + _task = Task { [weak self] in + guard let self else { return } + do { + let value = try await self.closure() + self.handle(.success(value)) + } catch { + self.handle(.failure(error)) + } + } + _continuations.append(continuation) + case .inProgress: + _continuations.append(continuation) + case .finished(let result): + continuation.resume(with: result) + } + } + } + } + } + + private func handle(_ result: Result) { + lock.withLock { + _state = .finished(result) + for continuation in _continuations { + continuation.resume(with: result) + } + _continuations = [] + _task = nil } - return cache! } } +// Memoizes the given closure, only calling the passed +// closure once; even if repeat calls to the returned closure +private func memoizedClosure( + _ closure: @escaping @Sendable () async throws -> T +) -> @Sendable (Bool) async throws -> T { + let memoized = MemoizedClosure(closure) + return memoized.callAsFunction(_:) +} + /// Expression represents the closure of the value inside expect(...). /// Expressions are memoized by default. This makes them safe to call /// evaluate() multiple times without causing a re-evaluation of the underlying @@ -21,8 +84,8 @@ private func memoizedClosure(_ closure: @escaping () async throws -> T) -> (B /// /// This provides a common consumable API for matchers to utilize to allow /// Nimble to change internals to how the captured closure is managed. -public struct AsyncExpression { - internal let _expression: (Bool) async throws -> Value? +public actor AsyncExpression { + internal let _expression: @Sendable (Bool) async throws -> Value? internal let _withoutCaching: Bool public let location: SourceLocation public let isClosure: Bool @@ -38,7 +101,7 @@ public struct AsyncExpression { /// requires an explicit closure. This gives Nimble /// flexibility if @autoclosure behavior changes between /// Swift versions. Nimble internals always sets this true. - public init(expression: @escaping () async throws -> Value?, location: SourceLocation, isClosure: Bool = true) { + public init(expression: @escaping @Sendable () async throws -> Value?, location: SourceLocation, isClosure: Bool = true) { self._expression = memoizedClosure(expression) self.location = location self._withoutCaching = false @@ -59,7 +122,7 @@ public struct AsyncExpression { /// requires an explicit closure. This gives Nimble /// flexibility if @autoclosure behavior changes between /// Swift versions. Nimble internals always sets this true. - public init(memoizedExpression: @escaping (Bool) async throws -> Value?, location: SourceLocation, withoutCaching: Bool, isClosure: Bool = true) { + public init(memoizedExpression: @escaping @Sendable (Bool) async throws -> Value?, location: SourceLocation, withoutCaching: Bool, isClosure: Bool = true) { self._expression = memoizedExpression self.location = location self._withoutCaching = withoutCaching @@ -90,7 +153,7 @@ public struct AsyncExpression { /// /// - Parameter block: The block that can cast the current Expression value to a /// new type. - public func cast(_ block: @escaping (Value?) throws -> U?) -> AsyncExpression { + public func cast(_ block: @escaping @Sendable (Value?) throws -> U?) -> AsyncExpression { AsyncExpression( expression: ({ try await block(self.evaluate()) }), location: self.location, @@ -98,7 +161,7 @@ public struct AsyncExpression { ) } - public func cast(_ block: @escaping (Value?) async throws -> U?) -> AsyncExpression { + public func cast(_ block: @escaping @Sendable (Value?) async throws -> U?) -> AsyncExpression { AsyncExpression( expression: ({ try await block(self.evaluate()) }), location: self.location, diff --git a/Sources/Nimble/DSL+AsyncAwait.swift b/Sources/Nimble/DSL+AsyncAwait.swift index 57b4213df..8a704ce66 100644 --- a/Sources/Nimble/DSL+AsyncAwait.swift +++ b/Sources/Nimble/DSL+AsyncAwait.swift @@ -3,78 +3,78 @@ import Dispatch #endif /// Make an ``AsyncExpectation`` on a given actual value. The value given is lazily evaluated. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @escaping () async throws -> T?) -> AsyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @escaping @Sendable () async throws -> T?) -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make an ``AsyncExpectation`` on a given actual value. The closure is lazily invoked. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: () -> (() async throws -> T)) -> AsyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @Sendable () -> (@Sendable () async throws -> T)) -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make an ``AsyncExpectation`` on a given actual value. The closure is lazily invoked. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: () -> (() async throws -> T?)) -> AsyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @Sendable () -> (@Sendable () async throws -> T?)) -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make an ``AsyncExpectation`` on a given actual value. The closure is lazily invoked. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: () -> (() async throws -> Void)) -> AsyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @Sendable () -> (@Sendable () async throws -> Void)) -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make an ``AsyncExpectation`` on a given actual value. The value given is lazily evaluated. /// This is provided to avoid confusion between `expect -> SyncExpectation` and `expect -> AsyncExpectation`. -public func expecta(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure @escaping () async throws -> T?) async -> AsyncExpectation { +public func expecta(location: SourceLocation = SourceLocation(), _ expression: @autoclosure @escaping @Sendable () async throws -> T?) async -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make an ``AsyncExpectation`` on a given actual value. The closure is lazily invoked. /// This is provided to avoid confusion between `expect -> SyncExpectation` and `expect -> AsyncExpectation` -public func expecta(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() async throws -> T)) async -> AsyncExpectation { +public func expecta(location: SourceLocation = SourceLocation(), _ expression: @autoclosure @Sendable () -> (@Sendable () async throws -> T)) async -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make an ``AsyncExpectation`` on a given actual value. The closure is lazily invoked. /// This is provided to avoid confusion between `expect -> SyncExpectation` and `expect -> AsyncExpectation` -public func expecta(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() async throws -> T?)) async -> AsyncExpectation { +public func expecta(location: SourceLocation = SourceLocation(), _ expression: @autoclosure @Sendable () -> (@Sendable () async throws -> T?)) async -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make an ``AsyncExpectation`` on a given actual value. The closure is lazily invoked. /// This is provided to avoid confusion between `expect -> SyncExpectation` and `expect -> AsyncExpectation` -public func expecta(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() async throws -> Void)) async -> AsyncExpectation { +public func expecta(location: SourceLocation = SourceLocation(), _ expression: @autoclosure @Sendable () -> (@Sendable () async throws -> Void)) async -> AsyncExpectation { return AsyncExpectation( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } @@ -89,15 +89,12 @@ public func expecta(fileID: String = #fileID, file: FileString = #filePath, line /// Unlike the synchronous version of this call, this does not support catching Objective-C exceptions. public func waitUntil( timeout: NimbleTimeInterval = PollingDefaults.timeout, - fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, - action: @escaping (@escaping @Sendable () -> Void) async -> Void + location: SourceLocation = SourceLocation(), + action: @escaping @Sendable (@escaping @Sendable () -> Void) async -> Void ) async { await throwableUntil( timeout: timeout, - sourceLocation: SourceLocation(fileID: fileID, filePath: file, line: line, column: column) + sourceLocation: location ) { done in await action(done) } @@ -112,15 +109,12 @@ public func waitUntil( /// Unlike the synchronous version of this call, this does not support catching Objective-C exceptions. public func waitUntil( timeout: NimbleTimeInterval = PollingDefaults.timeout, - fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, - action: @escaping (@escaping @Sendable () -> Void) -> Void + location: SourceLocation = SourceLocation(), + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void ) async { await throwableUntil( timeout: timeout, - sourceLocation: SourceLocation(fileID: fileID, filePath: file, line: line, column: column) + sourceLocation: location ) { done in action(done) } @@ -134,12 +128,12 @@ private enum ErrorResult { private func throwableUntil( timeout: NimbleTimeInterval, sourceLocation: SourceLocation, - action: @escaping (@escaping @Sendable () -> Void) async throws -> Void) async { + action: @escaping @Sendable (@escaping @Sendable () -> Void) async throws -> Void) async { let leeway = timeout.divided let result = await performBlock( timeoutInterval: timeout, leeway: leeway, - sourceLocation: sourceLocation) { @MainActor (done: @escaping (ErrorResult) -> Void) async throws -> Void in + sourceLocation: sourceLocation) { @MainActor (done: @escaping @Sendable (ErrorResult) -> Void) async throws -> Void in do { try await action { done(.none) @@ -154,34 +148,22 @@ private func throwableUntil( case .blockedRunLoop: fail( blockedRunLoopErrorMessageFor("-waitUntil()", leeway: leeway), - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column + location: sourceLocation ) case .timedOut: fail( "Waited more than \(timeout.description)", - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column + location: sourceLocation ) case let .errorThrown(error): fail( "Unexpected error thrown: \(error)", - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column + location: sourceLocation ) case .completed(.error(let error)): fail( "Unexpected error thrown: \(error)", - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column + location: sourceLocation ) case .completed(.none): // success break diff --git a/Sources/Nimble/DSL+Require.swift b/Sources/Nimble/DSL+Require.swift index bd73f72f6..211735bd7 100644 --- a/Sources/Nimble/DSL+Require.swift +++ b/Sources/Nimble/DSL+Require.swift @@ -3,11 +3,11 @@ /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure @escaping () throws -> T?) -> SyncRequirement { +public func require(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> T?) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -17,11 +17,11 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, l /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() throws -> T)) -> SyncRequirement { +public func require(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T)) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -31,11 +31,11 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, l /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() throws -> T?)) -> SyncRequirement { +public func require(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -45,11 +45,11 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, l /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() throws -> Void)) -> SyncRequirement { +public func require(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> Void)) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -61,11 +61,11 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, line /// /// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``. @discardableResult -public func requires(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure @escaping () throws -> T?) -> SyncRequirement { +public func requires(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> T?) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -77,11 +77,11 @@ public func requires(fileID: String = #fileID, file: FileString = #filePath, /// /// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``. @discardableResult -public func requires(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() throws -> T)) -> SyncRequirement { +public func requires(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T)) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -93,11 +93,11 @@ public func requires(fileID: String = #fileID, file: FileString = #filePath, /// /// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``. @discardableResult -public func requires(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() throws -> T?)) -> SyncRequirement { +public func requires(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -109,11 +109,11 @@ public func requires(fileID: String = #fileID, file: FileString = #filePath, /// /// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``. @discardableResult -public func requires(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() throws -> Void)) -> SyncRequirement { +public func requires(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> Void)) -> SyncRequirement { return SyncRequirement( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -123,11 +123,11 @@ public func requires(fileID: String = #fileID, file: FileString = #filePath, lin /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @escaping () async throws -> T?) -> AsyncRequirement { +public func require(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @escaping @Sendable () async throws -> T?) -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -137,11 +137,11 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, l /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: () -> (() async throws -> T)) -> AsyncRequirement { +public func require(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: () -> (@Sendable () async throws -> T)) -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -151,11 +151,11 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, l /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: () -> (() async throws -> T?)) -> AsyncRequirement { +public func require(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: () -> (@Sendable () async throws -> T?)) -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -167,11 +167,11 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, l /// /// This is provided to avoid confusion between `require -> SyncRequirement` and `require -> AsyncRequirement`. @discardableResult -public func requirea(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure @escaping () async throws -> T?) async -> AsyncRequirement { +public func requirea(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure @escaping @Sendable () async throws -> T?) async -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -183,11 +183,11 @@ public func requirea(fileID: String = #fileID, file: FileString = #filePath, /// /// This is provided to avoid confusion between `require -> SyncRequirement` and `require -> AsyncRequirement` @discardableResult -public func requirea(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() async throws -> T)) async -> AsyncRequirement { +public func requirea(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () async throws -> T)) async -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -199,11 +199,11 @@ public func requirea(fileID: String = #fileID, file: FileString = #filePath, /// /// This is provided to avoid confusion between `require -> SyncRequirement` and `require -> AsyncRequirement` @discardableResult -public func requirea(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (() async throws -> T?)) async -> AsyncRequirement { +public func requirea(location: SourceLocation = SourceLocation(), customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () async throws -> T?)) async -> AsyncRequirement { return AsyncRequirement( expression: AsyncExpression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true), customError: customError) } @@ -216,8 +216,8 @@ public func requirea(fileID: String = #fileID, file: FileString = #filePath, /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping () throws -> T?) throws -> T { - try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description) +public func unwrap(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> T?) throws -> T { + try requires(location: location, customError: customError, expression()).toNot(beNil(), description: description) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. @@ -226,8 +226,8 @@ public func unwrap(fileID: String = #fileID, file: FileString = #filePath, li /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T { - try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description) +public func unwrap(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) throws -> T { + try requires(location: location, customError: customError, expression()).toNot(beNil(), description: description) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. @@ -236,8 +236,8 @@ public func unwrap(fileID: String = #fileID, file: FileString = #filePath, li /// `unwraps` 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 unwraps(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping () throws -> T?) throws -> T { - try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description) +public func unwraps(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> T?) throws -> T { + try requires(location: location, customError: customError, expression()).toNot(beNil(), description: description) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. @@ -246,8 +246,8 @@ public func unwraps(fileID: String = #fileID, file: FileString = #filePath, l /// `unwraps` 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 unwraps(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T { - try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description) +public func unwraps(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) throws -> T { + try requires(location: location, customError: customError, expression()).toNot(beNil(), description: description) } /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. @@ -256,8 +256,8 @@ public func unwraps(fileID: String = #fileID, file: FileString = #filePath, l /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @escaping () async throws -> T?) async throws -> T { - try await requirea(fileID: fileID, file: file, line: line, column: column, customError: customError, try await expression()).toNot(beNil(), description: description) +public func unwrap(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: @escaping () async throws -> T?) async throws -> T { + try await requirea(location: location, customError: customError, try await expression()).toNot(beNil(), description: description) } /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. @@ -266,8 +266,8 @@ public func unwrap(fileID: String = #fileID, file: FileString = #filePath, li /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: () -> (() async throws -> T?)) async throws -> T { - try await requirea(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description) +public func unwrap(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: () -> (@Sendable () async throws -> T?)) async throws -> T { + try await requirea(location: location, customError: customError, expression()).toNot(beNil(), description: description) } /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. @@ -276,8 +276,8 @@ public func unwrap(fileID: String = #fileID, file: FileString = #filePath, li /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping () async throws -> T?) async throws -> T { - try await requirea(fileID: fileID, file: file, line: line, column: column, customError: customError, try await expression()).toNot(beNil(), description: description) +public func unwrapa(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping () async throws -> T?) async throws -> T { + try await requirea(location: location, customError: customError, try await expression()).toNot(beNil(), description: description) } /// Makes sure that the async expression evaluates to a non-nil value, otherwise throw an error. @@ -286,16 +286,15 @@ public func unwrapa(fileID: String = #fileID, file: FileString = #filePath, l /// `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(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (() async throws -> T?)) async throws -> T { - try await requirea(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description) +public func unwrapa(location: SourceLocation = SourceLocation(), customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () async throws -> T?)) async throws -> T { + try await requirea(location: location, customError: customError, expression()).toNot(beNil(), description: description) } /// Always fails the test and throw an error to prevent further test execution. /// /// - Parameter message: A custom message to use in place of the default one. /// - Parameter customError: A custom error to throw in place of a ``RequireError``. -public func requireFail(_ message: String? = nil, customError: Error? = nil, fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column) throws { - let location = SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) +public func requireFail(_ message: String? = nil, customError: Error? = nil, location: SourceLocation = SourceLocation()) throws { let handler = NimbleEnvironment.activeInstance.assertionHandler let msg = message ?? "requireFail() always fails" diff --git a/Sources/Nimble/DSL+Wait.swift b/Sources/Nimble/DSL+Wait.swift index a91e919a2..bef8e77a4 100644 --- a/Sources/Nimble/DSL+Wait.swift +++ b/Sources/Nimble/DSL+Wait.swift @@ -19,24 +19,18 @@ public class NMBWait: NSObject { @objc public class func until( timeout: TimeInterval, - fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, - action: @escaping (@escaping () -> Void) -> Void) { + location: SourceLocation = SourceLocation(), + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) { // Convert TimeInterval to NimbleTimeInterval - until(timeout: timeout.nimbleInterval, file: file, line: line, action: action) + until(timeout: timeout.nimbleInterval, location: location, action: action) } #endif public class func until( timeout: NimbleTimeInterval, - fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, - action: @escaping (@escaping () -> Void) -> Void) { - return throwableUntil(timeout: timeout, file: file, line: line) { done in + location: SourceLocation = SourceLocation(), + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) { + return throwableUntil(timeout: timeout, location: location) { done in action(done) } } @@ -44,14 +38,11 @@ public class NMBWait: NSObject { // Using a throwable closure makes this method not objc compatible. public class func throwableUntil( timeout: NimbleTimeInterval, - fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, - action: @escaping (@escaping () -> Void) throws -> Void) { + location: SourceLocation = SourceLocation(), + action: @escaping @Sendable (@escaping @Sendable () -> Void) throws -> Void) { let awaiter = NimbleEnvironment.activeInstance.awaiter let leeway = timeout.divided - let result = awaiter.performBlock(file: file, line: line) { (done: @escaping (ErrorResult) -> Void) throws -> Void in + let result = awaiter.performBlock(location: location) { (done: @escaping @Sendable (ErrorResult) -> Void) throws -> Void in DispatchQueue.main.async { let capture = NMBExceptionCapture( handler: ({ exception in @@ -69,34 +60,36 @@ public class NMBWait: NSObject { } } } - }.timeout(timeout, forcefullyAbortTimeout: leeway).wait( - "waitUntil(...)", - sourceLocation: SourceLocation(fileID: fileID, filePath: file, line: line, column: column) - ) + } + .timeout(timeout, forcefullyAbortTimeout: leeway) + .wait( + "waitUntil(...)", + sourceLocation: location + ) switch result { case .incomplete: internalError("Reached .incomplete state for waitUntil(...).") case .blockedRunLoop: fail(blockedRunLoopErrorMessageFor("-waitUntil()", leeway: leeway), - fileID: fileID, file: file, line: line, column: column) + location: location) case .timedOut: fail("Waited more than \(timeout.description)", - fileID: fileID, file: file, line: line, column: column) + location: location) case let .raisedException(exception): fail("Unexpected exception raised: \(exception)", - fileID: fileID, file: file, line: line, column: column + location: location ) case let .errorThrown(error): fail("Unexpected error thrown: \(error)", - fileID: fileID, file: file, line: line, column: column + location: location ) case .completed(.exception(let exception)): fail("Unexpected exception raised: \(exception)", - fileID: fileID, file: file, line: line, column: column + location: location ) case .completed(.error(let error)): fail("Unexpected error thrown: \(error)", - fileID: fileID, file: file, line: line, column: column + location: location ) case .completed(.none): // success break @@ -104,23 +97,17 @@ public class NMBWait: NSObject { } #if canImport(Darwin) - @objc(untilFileID:file:line:column:action:) + @objc(untilLocation:action:) public class func until( - _ fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, - action: @escaping (@escaping () -> Void) -> Void) { - until(timeout: .seconds(1), fileID: fileID, file: file, line: line, column: column, action: action) + _ location: SourceLocation = SourceLocation(), + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) { + until(timeout: .seconds(1), location: location, action: action) } #else public class func until( - _ fileID: String = #fileID, - file: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, + _ location: SourceLocation = SourceLocation(), action: @escaping (@escaping () -> Void) -> Void) { - until(timeout: .seconds(1), fileID: fileID, file: file, line: line, column: column, action: action) + until(timeout: .seconds(1), location: location, action: action) } #endif } @@ -138,8 +125,12 @@ internal func blockedRunLoopErrorMessageFor(_ fnName: String, leeway: NimbleTime /// This function manages the main run loop (`NSRunLoop.mainRunLoop()`) while this function /// is executing. Any attempts to touch the run loop may cause non-deterministic behavior. @available(*, noasync, message: "the sync variant of `waitUntil` does not work in async contexts. Use the async variant as a drop-in replacement") -public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, action: @escaping (@escaping () -> Void) -> Void) { - NMBWait.until(timeout: timeout, fileID: fileID, file: file, line: line, column: column, action: action) +public func waitUntil( + timeout: NimbleTimeInterval = PollingDefaults.timeout, + location: SourceLocation = SourceLocation(), + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void +) { + NMBWait.until(timeout: timeout, location: location, action: action) } #endif // #if !os(WASI) diff --git a/Sources/Nimble/DSL.swift b/Sources/Nimble/DSL.swift index 334a4ddb1..ff20967a0 100644 --- a/Sources/Nimble/DSL.swift +++ b/Sources/Nimble/DSL.swift @@ -1,93 +1,91 @@ /// Make a ``SyncExpectation`` on a given actual value. The value given is lazily evaluated. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure @escaping () throws -> T?) -> SyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @autoclosure @escaping @Sendable () throws -> T?) -> SyncExpectation { return SyncExpectation( expression: Expression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make a ``SyncExpectation`` on a given actual value. The closure is lazily invoked. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() throws -> T)) -> SyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @autoclosure () -> (@Sendable () throws -> T)) -> SyncExpectation { return SyncExpectation( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make a ``SyncExpectation`` on a given actual value. The closure is lazily invoked. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() throws -> T?)) -> SyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @autoclosure () -> (@Sendable () throws -> T?)) -> SyncExpectation { return SyncExpectation( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make a ``SyncExpectation`` on a given actual value. The closure is lazily invoked. -public func expect(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() throws -> Void)) -> SyncExpectation { +public func expect(location: SourceLocation = SourceLocation(), _ expression: @autoclosure () -> (@Sendable () throws -> Void)) -> SyncExpectation { return SyncExpectation( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make a ``SyncExpectation`` on a given actual value. The value given is lazily evaluated. /// This is provided as an alternative to `expect` which avoids overloading with `expect -> AsyncExpectation`. -public func expects(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure @escaping () throws -> T?) -> SyncExpectation { +public func expects(location: SourceLocation = SourceLocation(), _ expression: @autoclosure @escaping @Sendable () throws -> T?) -> SyncExpectation { return SyncExpectation( expression: Expression( expression: expression, - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make a ``SyncExpectation`` on a given actual value. The closure is lazily invoked. /// This is provided as an alternative to `expect` which avoids overloading with `expect -> AsyncExpectation`. -public func expects(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() throws -> T)) -> SyncExpectation { +public func expects(location: SourceLocation = SourceLocation(), _ expression: @autoclosure () -> (@Sendable () throws -> T)) -> SyncExpectation { return SyncExpectation( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make a ``SyncExpectation`` on a given actual value. The closure is lazily invoked. /// This is provided as an alternative to `expect` which avoids overloading with `expect -> AsyncExpectation`. -public func expects(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() throws -> T?)) -> SyncExpectation { +public func expects(location: SourceLocation = SourceLocation(), _ expression: @autoclosure () -> (@Sendable () throws -> T?)) -> SyncExpectation { return SyncExpectation( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Make a ``SyncExpectation`` on a given actual value. The closure is lazily invoked. /// This is provided as an alternative to `expect` which avoids overloading with `expect -> AsyncExpectation`. -public func expects(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, _ expression: @autoclosure () -> (() throws -> Void)) -> SyncExpectation { +public func expects(location: SourceLocation = SourceLocation(), _ expression: @autoclosure () -> (@Sendable () throws -> Void)) -> SyncExpectation { + // It would seem like `sending` isn't necessary for the `expression` argument + // because the closure returns void. However, this gets rid of a type + // conversion warning/error. return SyncExpectation( expression: Expression( expression: expression(), - location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + location: location, isClosure: true)) } /// Always fails the test with a message and a specified location. -public func fail(_ message: String, location: SourceLocation) { +public func fail(_ message: String, location: SourceLocation = SourceLocation()) { let handler = NimbleEnvironment.activeInstance.assertionHandler handler.assert(false, message: FailureMessage(stringValue: message), location: location) } -/// Always fails the test with a message. -public func fail(_ message: String, fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column) { - fail(message, location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column)) -} - /// Always fails the test. -public func fail(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column) { - fail("fail() always fails", location: SourceLocation(fileID: fileID, filePath: file, line: line, column: column)) +public func fail(location: SourceLocation = SourceLocation()) { + fail("fail() always fails", location: location) } /// Like Swift's precondition(), but raises NSExceptions instead of sigaborts diff --git a/Sources/Nimble/Expectation.swift b/Sources/Nimble/Expectation.swift index 732e0c573..e001a2a22 100644 --- a/Sources/Nimble/Expectation.swift +++ b/Sources/Nimble/Expectation.swift @@ -50,7 +50,7 @@ internal func execute(_ expression: AsyncExpression, _ style: ExpectationS } } -public enum ExpectationStatus: Equatable { +public enum ExpectationStatus: Equatable, Sendable { /// No matchers have been performed. case pending @@ -150,7 +150,7 @@ extension Expectation { } } -public struct SyncExpectation: Expectation { +public struct SyncExpectation: Expectation, Sendable { public let expression: Expression /// The status of the test after matchers have been evaluated. @@ -214,8 +214,10 @@ public struct SyncExpectation: Expectation { public func notTo(_ matcher: Matcher, description: String? = nil) -> Self { toNot(matcher, description: description) } +} - // MARK: - AsyncMatchers +extension SyncExpectation where Value: Sendable { + // MARK: - AsyncPredicates /// Tests the actual value using a matcher to match. @discardableResult public func to(_ matcher: AsyncMatcher, description: String? = nil) async -> Self { @@ -243,7 +245,7 @@ public struct SyncExpectation: Expectation { // - NMBExpectation for Objective-C interface } -public struct AsyncExpectation: Expectation { +public struct AsyncExpectation: Expectation, Sendable { public let expression: AsyncExpression /// The status of the test after matchers have been evaluated. diff --git a/Sources/Nimble/ExpectationMessage.swift b/Sources/Nimble/ExpectationMessage.swift index 4efda7c01..c3072eef0 100644 --- a/Sources/Nimble/ExpectationMessage.swift +++ b/Sources/Nimble/ExpectationMessage.swift @@ -1,4 +1,4 @@ -public indirect enum ExpectationMessage { +public indirect enum ExpectationMessage: Sendable { // --- Primary Expectations --- /// includes actual value in output ("expected to , got ") case expectedActualValueTo(/* message: */ String) @@ -174,37 +174,10 @@ public indirect enum ExpectationMessage { } } -extension FailureMessage { - internal func toExpectationMessage() -> ExpectationMessage { - let defaultMessage = FailureMessage() - if expected != defaultMessage.expected || _stringValueOverride != nil { - return .fail(stringValue) - } - - var message: ExpectationMessage = .fail(userDescription ?? "") - if actualValue != "" && actualValue != nil { - message = .expectedCustomValueTo(postfixMessage, actual: actualValue ?? "") - } else if postfixMessage != defaultMessage.postfixMessage { - if actualValue == nil { - message = .expectedTo(postfixMessage) - } else { - message = .expectedActualValueTo(postfixMessage) - } - } - if postfixActual != defaultMessage.postfixActual { - message = .appends(message, postfixActual) - } - if let extended = extendedMessage { - message = .details(message, extended) - } - return message - } -} - #if canImport(Darwin) import class Foundation.NSObject -public class NMBExpectationMessage: NSObject { +public final class NMBExpectationMessage: NSObject, Sendable { private let msg: ExpectationMessage internal init(swift msg: ExpectationMessage) { diff --git a/Sources/Nimble/Expression.swift b/Sources/Nimble/Expression.swift index 1bab44fc6..8b91d4390 100644 --- a/Sources/Nimble/Expression.swift +++ b/Sources/Nimble/Expression.swift @@ -1,15 +1,30 @@ -// Memoizes the given closure, only calling the passed -// closure once; even if repeat calls to the returned closure -private func memoizedClosure(_ closure: @escaping () throws -> T) -> (Bool) throws -> T { - var cache: T? - return { withoutCaching in - if withoutCaching || cache == nil { - cache = try closure() +import Foundation + +private final class MemoizedValue: Sendable { + private let lock = NSRecursiveLock() + nonisolated(unsafe) private var cache: T? = nil + private let closure: @Sendable () throws -> T + + init(_ closure: @escaping @Sendable () throws -> T) { + self.closure = closure + } + + @Sendable func evaluate(withoutCaching: Bool) throws -> T { + try lock.withLock { + if withoutCaching || cache == nil { + cache = try closure() + } + return cache! } - return cache! } } +// Memoizes the given closure, only calling the passed +// closure once; even if repeat calls to the returned closure +private func memoizedClosure(_ closure: @escaping @Sendable () throws -> T) -> @Sendable (Bool) throws -> T { + MemoizedValue(closure).evaluate(withoutCaching:) +} + /// Expression represents the closure of the value inside expect(...). /// Expressions are memoized by default. This makes them safe to call /// evaluate() multiple times without causing a re-evaluation of the underlying @@ -21,8 +36,8 @@ private func memoizedClosure(_ closure: @escaping () throws -> T) -> (Bool) t /// /// This provides a common consumable API for matchers to utilize to allow /// Nimble to change internals to how the captured closure is managed. -public struct Expression { - internal let _expression: (Bool) throws -> Value? +public struct Expression: Sendable { + internal let _expression: @Sendable (Bool) throws -> Value? internal let _withoutCaching: Bool public let location: SourceLocation public let isClosure: Bool @@ -38,7 +53,7 @@ public struct Expression { /// requires an explicit closure. This gives Nimble /// flexibility if @autoclosure behavior changes between /// Swift versions. Nimble internals always sets this true. - public init(expression: @escaping () throws -> Value?, location: SourceLocation, isClosure: Bool = true) { + public init(expression: @escaping @Sendable () throws -> Value?, location: SourceLocation, isClosure: Bool = true) { self._expression = memoizedClosure(expression) self.location = location self._withoutCaching = false @@ -59,7 +74,7 @@ public struct Expression { /// requires an explicit closure. This gives Nimble /// flexibility if @autoclosure behavior changes between /// Swift versions. Nimble internals always sets this true. - public init(memoizedExpression: @escaping (Bool) throws -> Value?, location: SourceLocation, withoutCaching: Bool, isClosure: Bool = true) { + public init(memoizedExpression: @escaping @Sendable (Bool) throws -> Value?, location: SourceLocation, withoutCaching: Bool, isClosure: Bool = true) { self._expression = memoizedExpression self.location = location self._withoutCaching = withoutCaching @@ -74,7 +89,7 @@ public struct Expression { /// /// - Parameter block: The block that can cast the current Expression value to a /// new type. - public func cast(_ block: @escaping (Value?) throws -> U?) -> Expression { + public func cast(_ block: @escaping @Sendable (Value?) throws -> U?) -> Expression { Expression( expression: ({ try block(self.evaluate()) }), location: self.location, @@ -103,7 +118,9 @@ public struct Expression { isClosure: isClosure ) } +} +extension Expression where Value: Sendable { public func toAsyncExpression() -> AsyncExpression { AsyncExpression( memoizedExpression: { @MainActor memoize in try _expression(memoize) }, diff --git a/Sources/Nimble/FailureMessage.swift b/Sources/Nimble/FailureMessage.swift index 8b60b9c2e..12561b1e4 100644 --- a/Sources/Nimble/FailureMessage.swift +++ b/Sources/Nimble/FailureMessage.swift @@ -3,19 +3,81 @@ import Foundation /// Encapsulates the failure message that matchers can report to the end user. /// /// This is shared state between Nimble and matchers that mutate this value. -public class FailureMessage: NSObject { - public var expected: String = "expected" - public var actualValue: String? = "" // empty string -> use default; nil -> exclude - public var to: String = "to" - public var postfixMessage: String = "match" - public var postfixActual: String = "" +public final class FailureMessage: NSObject, @unchecked Sendable { + private let lock = NSRecursiveLock() + + private var _expected: String = "expected" + private var _actualValue: String? = "" // empty string -> use default; nil -> exclude + private var _to: String = "to" + private var _postfixMessage: String = "match" + private var _postfixActual: String = "" /// An optional message that will be appended as a new line and provides additional details /// about the failure. This message will only be visible in the issue navigator / in logs but /// not directly in the source editor since only a single line is presented there. - public var extendedMessage: String? - public var userDescription: String? + private var _extendedMessage: String? + private var _userDescription: String? - public var stringValue: String { + public var expected: String { + get { + return lock.sync { return _expected } + } + set { + lock.sync { _expected = newValue } + } + } + public var actualValue: String? { + get { + return lock.sync { return _actualValue } + } + set { + lock.sync { _actualValue = newValue } + } + } // empty string -> use default; nil -> exclude + public var to: String { + get { + return lock.sync { return _to } + } + set { + lock.sync { _to = newValue } + } + } + public var postfixMessage: String { + get { + return lock.sync { return _postfixMessage } + } + set { + lock.sync { _postfixMessage = newValue } + } + } + public var postfixActual: String { + get { + return lock.sync { return _postfixActual } + } + set { + lock.sync { _postfixActual = newValue } + } + } + /// An optional message that will be appended as a new line and provides additional details + /// about the failure. This message will only be visible in the issue navigator / in logs but + /// not directly in the source editor since only a single line is presented there. + public var extendedMessage: String? { + get { + return lock.sync { return _extendedMessage } + } + set { + lock.sync { _extendedMessage = newValue } + } + } + public var userDescription: String? { + get { + return lock.sync { return _userDescription } + } + set { + lock.sync { _userDescription = newValue } + } + } + + private var _stringValue: String { get { if let value = _stringValueOverride { return value @@ -27,20 +89,33 @@ public class FailureMessage: NSObject { _stringValueOverride = newValue } } + public var stringValue: String { + get { + return lock.sync { return _stringValue } + } + set { + lock.sync { _stringValue = newValue } + } + } - internal var _stringValueOverride: String? - internal var hasOverriddenStringValue: Bool { + private var _stringValueOverride: String? + private var _hasOverriddenStringValue: Bool { return _stringValueOverride != nil } + internal var hasOverriddenStringValue: Bool { + return lock.sync { return _hasOverriddenStringValue } + } + public override init() { + super.init() } public init(stringValue: String) { _stringValueOverride = stringValue } - internal func stripNewlines(_ str: String) -> String { + private func stripNewlines(_ str: String) -> String { let whitespaces = CharacterSet.whitespacesAndNewlines return str .components(separatedBy: "\n") @@ -48,45 +123,78 @@ public class FailureMessage: NSObject { .joined(separator: "") } - internal func computeStringValue() -> String { - var value = "\(expected) \(to) \(postfixMessage)" - if let actualValue = actualValue { - value = "\(expected) \(to) \(postfixMessage), got \(actualValue)\(postfixActual)" - } - value = stripNewlines(value) + private func computeStringValue() -> String { + return lock.sync { + var value = "\(_expected) \(_to) \(_postfixMessage)" + if let actualValue = _actualValue { + value = "\(_expected) \(_to) \(_postfixMessage), got \(actualValue)\(_postfixActual)" + } + value = stripNewlines(value) - if let extendedMessage = extendedMessage { - value += "\n\(extendedMessage)" - } + if let extendedMessage = _extendedMessage { + value += "\n\(extendedMessage)" + } - if let userDescription = userDescription { - return "\(userDescription)\n\(value)" - } + if let userDescription = _userDescription { + return "\(userDescription)\n\(value)" + } - return value + return value + } } internal func appendMessage(_ msg: String) { - if hasOverriddenStringValue { - stringValue += "\(msg)" - } else if actualValue != nil { - postfixActual += msg - } else { - postfixMessage += msg + lock.sync { + if _hasOverriddenStringValue { + _stringValue += "\(msg)" + } else if _actualValue != nil { + _postfixActual += msg + } else { + _postfixMessage += msg + } } } internal func appendDetails(_ msg: String) { - if hasOverriddenStringValue { - if let desc = userDescription { - stringValue = "\(desc)\n\(stringValue)" + lock.sync { + if _hasOverriddenStringValue { + if let desc = _userDescription { + _stringValue = "\(desc)\n\(_stringValue)" + } + _stringValue += "\n\(msg)" + } else { + if let desc = _userDescription { + _userDescription = desc + } + _extendedMessage = msg + } + } + } + + internal func toExpectationMessage() -> ExpectationMessage { + lock.sync { + let defaultMessage = FailureMessage() + if _expected != defaultMessage._expected || _hasOverriddenStringValue { + return .fail(_stringValue) + } + + var message: ExpectationMessage = .fail(_userDescription ?? "") + if _actualValue != "" && _actualValue != nil { + message = .expectedCustomValueTo(_postfixMessage, actual: _actualValue ?? "") + } else if _postfixMessage != defaultMessage._postfixMessage { + if _actualValue == nil { + message = .expectedTo(_postfixMessage) + } else { + message = .expectedActualValueTo(_postfixMessage) + } + } + if _postfixActual != defaultMessage._postfixActual { + message = .appends(message, _postfixActual) } - stringValue += "\n\(msg)" - } else { - if let desc = userDescription { - userDescription = desc + if let extended = _extendedMessage { + message = .details(message, extended) } - extendedMessage = msg + return message } } } diff --git a/Sources/Nimble/Matchers/AllPass.swift b/Sources/Nimble/Matchers/AllPass.swift index 133c21ec4..489f2e4bb 100644 --- a/Sources/Nimble/Matchers/AllPass.swift +++ b/Sources/Nimble/Matchers/AllPass.swift @@ -1,5 +1,5 @@ public func allPass( - _ passFunc: @escaping (S.Element) throws -> Bool + _ passFunc: @escaping @Sendable (S.Element) throws -> Bool ) -> Matcher { let matcher = Matcher.define("pass a condition") { actualExpression, message in guard let actual = try actualExpression.evaluate() else { @@ -12,7 +12,7 @@ public func allPass( public func allPass( _ passName: String, - _ passFunc: @escaping (S.Element) throws -> Bool + _ passFunc: @escaping @Sendable (S.Element) throws -> Bool ) -> Matcher { let matcher = Matcher.define(passName) { actualExpression, message in guard let actual = try actualExpression.evaluate() else { @@ -100,7 +100,9 @@ extension NMBMatcher { ) } - let expr = Expression(expression: ({ nsObjects }), location: location) + let immutableCollection = nsObjects + + let expr = Expression(expression: ({ immutableCollection }), location: location) let pred: Matcher<[NSObject]> = createMatcher(Matcher { expr in return matcher.satisfies(({ try expr.evaluate() }), location: expr.location).toSwift() }) diff --git a/Sources/Nimble/Matchers/AsyncAllPass.swift b/Sources/Nimble/Matchers/AsyncAllPass.swift index ec04f9ebe..d12c38b20 100644 --- a/Sources/Nimble/Matchers/AsyncAllPass.swift +++ b/Sources/Nimble/Matchers/AsyncAllPass.swift @@ -1,6 +1,6 @@ public func allPass( - _ passFunc: @escaping (S.Element) async throws -> Bool -) -> AsyncMatcher { + _ passFunc: @escaping @Sendable (S.Element) async throws -> Bool +) -> AsyncMatcher where S.Element: Sendable { let matcher = AsyncMatcher.define("pass a condition") { actualExpression, message in guard let actual = try await actualExpression.evaluate() else { return MatcherResult(status: .fail, message: message) @@ -12,8 +12,8 @@ public func allPass( public func allPass( _ passName: String, - _ passFunc: @escaping (S.Element) async throws -> Bool -) -> AsyncMatcher { + _ passFunc: @escaping @Sendable (S.Element) async throws -> Bool +) -> AsyncMatcher where S.Element: Sendable { let matcher = AsyncMatcher.define(passName) { actualExpression, message in guard let actual = try await actualExpression.evaluate() else { return MatcherResult(status: .fail, message: message) diff --git a/Sources/Nimble/Matchers/AsyncMatcher.swift b/Sources/Nimble/Matchers/AsyncMatcher.swift index 96b118a99..78db294d3 100644 --- a/Sources/Nimble/Matchers/AsyncMatcher.swift +++ b/Sources/Nimble/Matchers/AsyncMatcher.swift @@ -1,5 +1,5 @@ -public protocol AsyncableMatcher { - associatedtype Value +public protocol AsyncableMatcher: Sendable { + associatedtype Value: Sendable func satisfies(_ expression: AsyncExpression) async throws -> MatcherResult } @@ -27,10 +27,10 @@ extension Matcher: AsyncableMatcher { /// These can also be used with either `Expectation`s or `AsyncExpectation`s. /// But these can only be used from async contexts, and are unavailable in Objective-C. /// You can, however, call regular Matchers from an AsyncMatcher, if you wish to compose one like that. -public struct AsyncMatcher: AsyncableMatcher { - fileprivate var matcher: (AsyncExpression) async throws -> MatcherResult +public struct AsyncMatcher: AsyncableMatcher, Sendable { + fileprivate var matcher: @Sendable (AsyncExpression) async throws -> MatcherResult - public init(_ matcher: @escaping (AsyncExpression) async throws -> MatcherResult) { + public init(_ matcher: @escaping @Sendable (AsyncExpression) async throws -> MatcherResult) { self.matcher = matcher } @@ -49,7 +49,7 @@ public typealias AsyncPredicate = AsyncMatcher /// Provides convenience helpers to defining matchers extension AsyncMatcher { /// Like Matcher() constructor, but automatically guard against nil (actual) values - public static func define(matcher: @escaping (AsyncExpression) async throws -> MatcherResult) -> AsyncMatcher { + public static func define(matcher: @escaping @Sendable (AsyncExpression) async throws -> MatcherResult) -> AsyncMatcher { return AsyncMatcher { actual in return try await matcher(actual) }.requireNonNil @@ -57,7 +57,7 @@ extension AsyncMatcher { /// Defines a matcher with a default message that can be returned in the closure /// Also ensures the matcher's actual value cannot pass with `nil` given. - public static func define(_ message: String = "match", matcher: @escaping (AsyncExpression, ExpectationMessage) async throws -> MatcherResult) -> AsyncMatcher { + public static func define(_ message: String = "match", matcher: @escaping @Sendable (AsyncExpression, ExpectationMessage) async throws -> MatcherResult) -> AsyncMatcher { return AsyncMatcher { actual in return try await matcher(actual, .expectedActualValueTo(message)) }.requireNonNil @@ -65,7 +65,7 @@ extension AsyncMatcher { /// Defines a matcher with a default message that can be returned in the closure /// Unlike `define`, this allows nil values to succeed if the given closure chooses to. - public static func defineNilable(_ message: String = "match", matcher: @escaping (AsyncExpression, ExpectationMessage) async throws -> MatcherResult) -> AsyncMatcher { + public static func defineNilable(_ message: String = "match", matcher: @escaping @Sendable (AsyncExpression, ExpectationMessage) async throws -> MatcherResult) -> AsyncMatcher { return AsyncMatcher { actual in return try await matcher(actual, .expectedActualValueTo(message)) } @@ -75,7 +75,7 @@ extension AsyncMatcher { /// error message. /// /// Also ensures the matcher's actual value cannot pass with `nil` given. - public static func simple(_ message: String = "match", matcher: @escaping (AsyncExpression) async throws -> MatcherStatus) -> AsyncMatcher { + public static func simple(_ message: String = "match", matcher: @escaping @Sendable (AsyncExpression) async throws -> MatcherStatus) -> AsyncMatcher { return AsyncMatcher { actual in return MatcherResult(status: try await matcher(actual), message: .expectedActualValueTo(message)) }.requireNonNil @@ -85,7 +85,7 @@ extension AsyncMatcher { /// error message. /// /// Unlike `simple`, this allows nil values to succeed if the given closure chooses to. - public static func simpleNilable(_ message: String = "match", matcher: @escaping (AsyncExpression) async throws -> MatcherStatus) -> AsyncMatcher { + public static func simpleNilable(_ message: String = "match", matcher: @escaping @Sendable (AsyncExpression) async throws -> MatcherStatus) -> AsyncMatcher { return AsyncMatcher { actual in return MatcherResult(status: try await matcher(actual), message: .expectedActualValueTo(message)) } @@ -94,7 +94,7 @@ extension AsyncMatcher { extension AsyncMatcher { // Someday, make this public? Needs documentation - internal func after(f: @escaping (AsyncExpression, MatcherResult) async throws -> MatcherResult) -> AsyncMatcher { + internal func after(f: @escaping @Sendable (AsyncExpression, MatcherResult) async throws -> MatcherResult) -> AsyncMatcher { // swiftlint:disable:previous identifier_name return AsyncMatcher { actual -> MatcherResult in let result = try await self.satisfies(actual) diff --git a/Sources/Nimble/Matchers/BeCloseTo.swift b/Sources/Nimble/Matchers/BeCloseTo.swift index 36dc0e00f..0031d8f3e 100644 --- a/Sources/Nimble/Matchers/BeCloseTo.swift +++ b/Sources/Nimble/Matchers/BeCloseTo.swift @@ -67,7 +67,7 @@ private func beCloseTo( } #if canImport(Darwin) -public class NMBObjCBeCloseToMatcher: NMBMatcher { +public final class NMBObjCBeCloseToMatcher: NMBMatcher, @unchecked Sendable { private let _expected: NSNumber fileprivate init(expected: NSNumber, within: CDouble) { diff --git a/Sources/Nimble/Matchers/BeEmpty.swift b/Sources/Nimble/Matchers/BeEmpty.swift index 571797ca1..cdec3e606 100644 --- a/Sources/Nimble/Matchers/BeEmpty.swift +++ b/Sources/Nimble/Matchers/BeEmpty.swift @@ -85,10 +85,11 @@ extension NMBMatcher { let actualValue = try actualExpression.evaluate() if let value = actualValue as? NMBCollection { - let expr = Expression(expression: ({ value }), location: location) + let expr = Expression(expression: { value }, location: location) return try beEmpty().satisfies(expr).toObjectiveC() } else if let value = actualValue as? NSString { - let expr = Expression(expression: ({ value }), location: location) + let stringValue = String(value) + let expr = Expression(expression: { stringValue }, location: location) return try beEmpty().satisfies(expr).toObjectiveC() } else if let actualValue = actualValue { let badTypeErrorMsg = "be empty (only works for NSArrays, NSSets, NSIndexSets, NSDictionaries, NSHashTables, and NSStrings)" diff --git a/Sources/Nimble/Matchers/BeLogical.swift b/Sources/Nimble/Matchers/BeLogical.swift index ea04915e1..d24444d7e 100644 --- a/Sources/Nimble/Matchers/BeLogical.swift +++ b/Sources/Nimble/Matchers/BeLogical.swift @@ -1,72 +1,72 @@ import Foundation -extension Int8: Swift.ExpressibleByBooleanLiteral { +extension Int8: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).int8Value } } -extension UInt8: Swift.ExpressibleByBooleanLiteral { +extension UInt8: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).uint8Value } } -extension Int16: Swift.ExpressibleByBooleanLiteral { +extension Int16: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).int16Value } } -extension UInt16: Swift.ExpressibleByBooleanLiteral { +extension UInt16: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).uint16Value } } -extension Int32: Swift.ExpressibleByBooleanLiteral { +extension Int32: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).int32Value } } -extension UInt32: Swift.ExpressibleByBooleanLiteral { +extension UInt32: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).uint32Value } } -extension Int64: Swift.ExpressibleByBooleanLiteral { +extension Int64: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).int64Value } } -extension UInt64: ExpressibleByBooleanLiteral { +extension UInt64: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).uint64Value } } -extension Float: ExpressibleByBooleanLiteral { +extension Float: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).floatValue } } -extension Double: ExpressibleByBooleanLiteral { +extension Double: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).doubleValue } } -extension Int: ExpressibleByBooleanLiteral { +extension Int: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).intValue } } -extension UInt: ExpressibleByBooleanLiteral { +extension UInt: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).uintValue } diff --git a/Sources/Nimble/Matchers/BeResult.swift b/Sources/Nimble/Matchers/BeResult.swift index 6c4457661..88f4473b3 100644 --- a/Sources/Nimble/Matchers/BeResult.swift +++ b/Sources/Nimble/Matchers/BeResult.swift @@ -5,7 +5,7 @@ import Foundation /// You can pass a closure to do any arbitrary custom matching to the value inside result. /// The closure only gets called when the result is success. public func beSuccess( - test: ((Success) -> Void)? = nil + test: (@Sendable (Success) -> Void)? = nil ) -> Matcher> { return Matcher.define { expression in var rawMessage = "be " @@ -92,7 +92,7 @@ public func beSuccess( /// You can pass a closure to do any arbitrary custom matching to the error inside result. /// The closure only gets called when the result is failure. public func beFailure( - test: ((Failure) -> Void)? = nil + test: (@Sendable (Failure) -> Void)? = nil ) -> Matcher> { return Matcher.define { expression in var rawMessage = "be " diff --git a/Sources/Nimble/Matchers/Contain.swift b/Sources/Nimble/Matchers/Contain.swift index 555d4d1ff..93e5364f9 100644 --- a/Sources/Nimble/Matchers/Contain.swift +++ b/Sources/Nimble/Matchers/Contain.swift @@ -77,10 +77,14 @@ public func contain(_ substrings: NSString...) -> Matcher { } public func contain(_ substrings: [NSString]) -> Matcher { + let stringSubstrings = substrings.map { String($0) } return Matcher.simple("contain <\(arrayAsString(substrings))>") { actualExpression in guard let actual = try actualExpression.evaluate() else { return .fail } + let actualString = String(actual) - let matches = substrings.allSatisfy { actual.range(of: $0.description).length != 0 } + let matches = stringSubstrings.allSatisfy { string in + actualString.contains(string) + } return MatcherStatus(bool: matches) } } @@ -115,7 +119,8 @@ extension NMBMatcher { let expectedOptionals: [Any?] = expected.map({ $0 as Any? }) return try contain(expectedOptionals).satisfies(expr).toObjectiveC() } else if let value = actualValue as? NSString { - let expr = Expression(expression: ({ value as String }), location: location) + let stringValue = String(value) + let expr = Expression(expression: ({ stringValue }), location: location) // swiftlint:disable:next force_cast return try contain(expected as! [String]).satisfies(expr).toObjectiveC() } diff --git a/Sources/Nimble/Matchers/Equal+Tuple.swift b/Sources/Nimble/Matchers/Equal+Tuple.swift index 17b2f2e5f..9abe3411b 100644 --- a/Sources/Nimble/Matchers/Equal+Tuple.swift +++ b/Sources/Nimble/Matchers/Equal+Tuple.swift @@ -7,7 +7,7 @@ public func equal( _ expectedValue: (T1, T2)? ) -> Matcher<(T1, T2)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -45,7 +45,7 @@ public func != ( public func equal( _ expectedValue: (T1, T2, T3)? ) -> Matcher<(T1, T2, T3)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -85,7 +85,7 @@ public func != ( public func equal( _ expectedValue: (T1, T2, T3, T4)? ) -> Matcher<(T1, T2, T3, T4)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -124,7 +124,7 @@ public func != ( public func equal( _ expectedValue: (T1, T2, T3, T4, T5)? ) -> Matcher<(T1, T2, T3, T4, T5)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -164,7 +164,7 @@ public func != ( _ expectedValue: (T1, T2, T3, T4, T5, T6)? ) -> Matcher<(T1, T2, T3, T4, T5, T6)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( diff --git a/Sources/Nimble/Matchers/Equal+TupleArray.swift b/Sources/Nimble/Matchers/Equal+TupleArray.swift index eff6168d0..3139c1121 100644 --- a/Sources/Nimble/Matchers/Equal+TupleArray.swift +++ b/Sources/Nimble/Matchers/Equal+TupleArray.swift @@ -7,7 +7,7 @@ public func equal( _ expectedValue: [(T1, T2)]? ) -> Matcher<[(T1, T2)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -45,7 +45,7 @@ public func != ( public func equal( _ expectedValue: [(T1, T2, T3)]? ) -> Matcher<[(T1, T2, T3)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -83,7 +83,7 @@ public func != ( public func equal( _ expectedValue: [(T1, T2, T3, T4)]? ) -> Matcher<[(T1, T2, T3, T4)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -121,7 +121,7 @@ public func != ( public func equal( _ expectedValue: [(T1, T2, T3, T4, T5)]? ) -> Matcher<[(T1, T2, T3, T4, T5)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -159,7 +159,7 @@ public func != ( _ expectedValue: [(T1, T2, T3, T4, T5, T6)]? ) -> Matcher<[(T1, T2, T3, T4, T5, T6)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -196,7 +196,7 @@ public func != ( _ expectedValue: [(Tuple)]?, - by areTuplesEquivalent: @escaping (Tuple, Tuple) -> Bool + by areTuplesEquivalent: @escaping @Sendable (Tuple, Tuple) -> Bool ) -> Matcher<[Tuple]> { equal(expectedValue) { $0.elementsEqual($1, by: areTuplesEquivalent) diff --git a/Sources/Nimble/Matchers/Equal.swift b/Sources/Nimble/Matchers/Equal.swift index 4ec21e37d..4d4c00b02 100644 --- a/Sources/Nimble/Matchers/Equal.swift +++ b/Sources/Nimble/Matchers/Equal.swift @@ -1,6 +1,6 @@ internal func equal( _ expectedValue: T?, - by areEquivalent: @escaping (T, T) -> Bool + by areEquivalent: @escaping @Sendable (T, T) -> Bool ) -> Matcher { Matcher.define("equal <\(stringify(expectedValue))>") { actualExpression, msg in let actualValue = try actualExpression.evaluate() @@ -44,7 +44,7 @@ public func equal(_ expectedValue: [T?]) -> Matcher<[T?]> { /// /// @see beCloseTo if you want to match imprecise types (eg - floats, doubles). public func equal(_ expectedValue: T?) -> Matcher { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } /// A Nimble matcher that succeeds when the actual set is equal to the expected set. diff --git a/Sources/Nimble/Matchers/Map.swift b/Sources/Nimble/Matchers/Map.swift index 5029f98be..2bb02998e 100644 --- a/Sources/Nimble/Matchers/Map.swift +++ b/Sources/Nimble/Matchers/Map.swift @@ -3,7 +3,7 @@ /// For example, you might only care that a particular property on a method equals some other value. /// So, you could write `expect(myObject).to(map(\.someIntValue, equal(3))`. /// This is also useful in conjunction with ``satisfyAllOf`` to do a partial equality of an object. -public func map(_ transform: @escaping (T) throws -> U, _ matcher: Matcher) -> Matcher { +public func map(_ transform: @escaping @Sendable (T) throws -> U, _ matcher: Matcher) -> Matcher { Matcher { (received: Expression) in try matcher.satisfies(received.cast { value in guard let value else { return nil } @@ -17,7 +17,7 @@ public func map(_ transform: @escaping (T) throws -> U, _ matcher: Matcher /// For example, you might only care that a particular property on a method equals some other value. /// So, you could write `expect(myObject).to(map(\.someIntValue, equal(3))`. /// This is also useful in conjunction with ``satisfyAllOf`` to do a partial equality of an object. -public func map(_ transform: @escaping (T) async throws -> U, _ matcher: some AsyncableMatcher) -> AsyncMatcher { +public func map(_ transform: @escaping @Sendable (T) async throws -> U, _ matcher: some AsyncableMatcher) -> AsyncMatcher { AsyncMatcher { (received: AsyncExpression) in try await matcher.satisfies(received.cast { value in guard let value else { return nil } diff --git a/Sources/Nimble/Matchers/Match.swift b/Sources/Nimble/Matchers/Match.swift index b634ad31c..1ed3f3c90 100644 --- a/Sources/Nimble/Matchers/Match.swift +++ b/Sources/Nimble/Matchers/Match.swift @@ -14,9 +14,10 @@ import class Foundation.NSString extension NMBMatcher { @objc public class func matchMatcher(_ expected: NSString) -> NMBMatcher { + let expected = String(expected) return NMBMatcher { actualExpression in let actual = actualExpression.cast { $0 as? String } - return try match(expected.description).satisfies(actual).toObjectiveC() + return try match(expected).satisfies(actual).toObjectiveC() } } } diff --git a/Sources/Nimble/Matchers/Matcher.swift b/Sources/Nimble/Matchers/Matcher.swift index 375419e4c..ffb5798b5 100644 --- a/Sources/Nimble/Matchers/Matcher.swift +++ b/Sources/Nimble/Matchers/Matcher.swift @@ -18,11 +18,11 @@ /// In the 2023 Apple Platform releases (macOS 14, iOS 17, watchOS 10, tvOS 17, visionOS 1), Apple /// renamed `NSMatcher` to `Matcher`. In response, we decided to rename `Matcher` to /// `Matcher`. -public struct Matcher { - fileprivate var matcher: (Expression) throws -> MatcherResult +public struct Matcher: Sendable { + fileprivate let matcher: @Sendable (Expression) throws -> MatcherResult /// Constructs a matcher that knows how take a given value - public init(_ matcher: @escaping (Expression) throws -> MatcherResult) { + public init(_ matcher: @escaping @Sendable (Expression) throws -> MatcherResult) { self.matcher = matcher } @@ -42,7 +42,7 @@ public typealias Predicate = Matcher /// Provides convenience helpers to defining matchers extension Matcher { /// Like Matcher() constructor, but automatically guard against nil (actual) values - public static func define(matcher: @escaping (Expression) throws -> MatcherResult) -> Matcher { + public static func define(matcher: @escaping @Sendable (Expression) throws -> MatcherResult) -> Matcher { return Matcher { actual in return try matcher(actual) }.requireNonNil @@ -50,7 +50,7 @@ extension Matcher { /// Defines a matcher with a default message that can be returned in the closure /// Also ensures the matcher's actual value cannot pass with `nil` given. - public static func define(_ message: String = "match", matcher: @escaping (Expression, ExpectationMessage) throws -> MatcherResult) -> Matcher { + public static func define(_ message: String = "match", matcher: @escaping @Sendable (Expression, ExpectationMessage) throws -> MatcherResult) -> Matcher { return Matcher { actual in return try matcher(actual, .expectedActualValueTo(message)) }.requireNonNil @@ -58,7 +58,7 @@ extension Matcher { /// Defines a matcher with a default message that can be returned in the closure /// Unlike `define`, this allows nil values to succeed if the given closure chooses to. - public static func defineNilable(_ message: String = "match", matcher: @escaping (Expression, ExpectationMessage) throws -> MatcherResult) -> Matcher { + public static func defineNilable(_ message: String = "match", matcher: @escaping @Sendable (Expression, ExpectationMessage) throws -> MatcherResult) -> Matcher { return Matcher { actual in return try matcher(actual, .expectedActualValueTo(message)) } @@ -70,7 +70,7 @@ extension Matcher { /// error message. /// /// Also ensures the matcher's actual value cannot pass with `nil` given. - public static func simple(_ message: String = "match", matcher: @escaping (Expression) throws -> MatcherStatus) -> Matcher { + public static func simple(_ message: String = "match", matcher: @escaping @Sendable (Expression) throws -> MatcherStatus) -> Matcher { return Matcher { actual in return MatcherResult(status: try matcher(actual), message: .expectedActualValueTo(message)) }.requireNonNil @@ -80,7 +80,7 @@ extension Matcher { /// error message. /// /// Unlike `simple`, this allows nil values to succeed if the given closure chooses to. - public static func simpleNilable(_ message: String = "match", matcher: @escaping (Expression) throws -> MatcherStatus) -> Matcher { + public static func simpleNilable(_ message: String = "match", matcher: @escaping @Sendable (Expression) throws -> MatcherStatus) -> Matcher { return Matcher { actual in return MatcherResult(status: try matcher(actual), message: .expectedActualValueTo(message)) } @@ -88,13 +88,13 @@ extension Matcher { } /// The Expectation style intended for comparison to a MatcherStatus. -public enum ExpectationStyle { +public enum ExpectationStyle: Sendable { case toMatch, toNotMatch } /// The value that a Matcher returns to describe if the given (actual) value matches the /// matcher. -public struct MatcherResult { +public struct MatcherResult: Sendable { /// Status indicates if the matcher matches, does not match, or fails. public var status: MatcherStatus /// The error message that can be displayed if it does not match @@ -123,7 +123,7 @@ public struct MatcherResult { public typealias PredicateResult = MatcherResult /// MatcherStatus is a trinary that indicates if a Matcher matches a given value or not -public enum MatcherStatus { +public enum MatcherStatus: Sendable { /// Matches indicates if the matcher / matcher passes with the given value /// /// For example, `equals(1)` returns `.matches` for `expect(1).to(equal(1))`. @@ -181,7 +181,7 @@ public typealias PredicateStatus = MatcherStatus extension Matcher { // Someday, make this public? Needs documentation - internal func after(f: @escaping (Expression, MatcherResult) throws -> MatcherResult) -> Matcher { + internal func after(f: @escaping @Sendable (Expression, MatcherResult) throws -> MatcherResult) -> Matcher { // swiftlint:disable:previous identifier_name return Matcher { actual -> MatcherResult in let result = try self.satisfies(actual) @@ -207,13 +207,13 @@ extension Matcher { #if canImport(Darwin) import class Foundation.NSObject -public typealias MatcherBlock = (_ actualExpression: Expression) throws -> NMBMatcherResult +public typealias MatcherBlock = @Sendable (_ actualExpression: Expression) throws -> NMBMatcherResult /// Provides an easy upgrade path for custom Matchers to be renamed to Matchers @available(*, deprecated, renamed: "MatcherBlock") public typealias PredicateBlock = MatcherBlock -public class NMBMatcher: NSObject { +public class NMBMatcher: NSObject, @unchecked Sendable { private let matcher: MatcherBlock public init(matcher: @escaping MatcherBlock) { @@ -225,7 +225,7 @@ public class NMBMatcher: NSObject { self.init(matcher: predicate) } - func satisfies(_ expression: @escaping () throws -> NSObject?, location: SourceLocation) -> NMBMatcherResult { + func satisfies(_ expression: @escaping @Sendable () throws -> NSObject?, location: SourceLocation) -> NMBMatcherResult { let expr = Expression(expression: expression, location: location) do { return try self.matcher(expr) @@ -269,7 +269,7 @@ extension MatcherResult { } } -final public class NMBMatcherStatus: NSObject { +final public class NMBMatcherStatus: NSObject, Sendable { private let status: Int private init(status: Int) { self.status = status diff --git a/Sources/Nimble/Matchers/PostNotification.swift b/Sources/Nimble/Matchers/PostNotification.swift index 5144cc13f..4bbeeff75 100644 --- a/Sources/Nimble/Matchers/PostNotification.swift +++ b/Sources/Nimble/Matchers/PostNotification.swift @@ -3,12 +3,13 @@ #if canImport(Foundation) import Foundation -internal class NotificationCollector { - private(set) var observedNotifications: [Notification] - private(set) var observedNotificationDescriptions: [String] +final class NotificationCollector: Sendable { + nonisolated(unsafe) private(set) var observedNotifications: [Notification] + nonisolated(unsafe) private(set) var observedNotificationDescriptions: [String] private let notificationCenter: NotificationCenter private let names: Set - private var tokens: [NSObjectProtocol] + nonisolated(unsafe) private var tokens: [NSObjectProtocol] + private let lock = NSRecursiveLock() required init(notificationCenter: NotificationCenter, names: Set = []) { self.notificationCenter = notificationCenter @@ -22,11 +23,21 @@ internal class NotificationCollector { func addObserver(forName name: Notification.Name?) -> NSObjectProtocol { return notificationCenter.addObserver(forName: name, object: nil, queue: nil) { [weak self] notification in // linux-swift gets confused by .append(n) - self?.observedNotifications.append(notification) - self?.observedNotificationDescriptions.append(stringify(notification)) + guard let self else { return } + + self.lock.lock() + defer { + self.lock.unlock() + } + self.observedNotifications.append(notification) + self.observedNotificationDescriptions.append(stringify(notification)) } } + lock.lock() + defer { + lock.unlock() + } if names.isEmpty { tokens.append(addObserver(forName: nil)) } else { @@ -44,11 +55,27 @@ internal class NotificationCollector { } #if !os(Windows) -private let mainThread = pthread_self() +nonisolated(unsafe) private let mainThread = pthread_self() #else private let mainThread = Thread.mainThread #endif +private final class OnlyOnceChecker: Sendable { + nonisolated(unsafe) var hasRun = false + let lock = NSRecursiveLock() + + func runOnlyOnce(_ closure: @Sendable () throws -> Void) rethrows { + lock.lock() + defer { + lock.unlock() + } + if !hasRun { + hasRun = true + try closure() + } + } +} + private func _postNotifications( _ matcher: Matcher<[Notification]>, from center: NotificationCenter, @@ -57,9 +84,16 @@ private func _postNotifications( _ = mainThread // Force lazy-loading of this value let collector = NotificationCollector(notificationCenter: center, names: names) collector.startObserving() - var once: Bool = false + let once = OnlyOnceChecker() return Matcher { actualExpression in + guard Thread.isMainThread else { + 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 MatcherResult(status: .fail, message: message) + } + let collectorNotificationsExpression = Expression( memoizedExpression: { _ in return collector.observedNotifications @@ -69,8 +103,7 @@ private func _postNotifications( ) assert(Thread.isMainThread, "Only expecting closure to be evaluated on main thread.") - if !once { - once = true + try once.runOnlyOnce { _ = try actualExpression.evaluate() } diff --git a/Sources/Nimble/Matchers/RaisesException.swift b/Sources/Nimble/Matchers/RaisesException.swift index 2bb94094d..d1d9b4644 100644 --- a/Sources/Nimble/Matchers/RaisesException.swift +++ b/Sources/Nimble/Matchers/RaisesException.swift @@ -141,7 +141,7 @@ internal func exceptionMatchesNonNilFieldsOrClosure( return matches } -public class NMBObjCRaiseExceptionMatcher: NMBMatcher { +public class NMBObjCRaiseExceptionMatcher: NMBMatcher, @unchecked Sendable { private let _name: String? private let _reason: String? private let _userInfo: NSDictionary? diff --git a/Sources/Nimble/Matchers/SatisfyAllOf.swift b/Sources/Nimble/Matchers/SatisfyAllOf.swift index 30f9045ac..a9d55e354 100644 --- a/Sources/Nimble/Matchers/SatisfyAllOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAllOf.swift @@ -57,7 +57,7 @@ public func satisfyAllOf(_ matchers: any AsyncableMatcher...) -> AsyncMatc @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAllOf(_ matchers: [any AsyncableMatcher]) -> AsyncMatcher { return AsyncMatcher.define { actualExpression in - let cachedExpression = actualExpression.withCaching() + let cachedExpression = await actualExpression.withCaching() var postfixMessages = [String]() var status: MatcherStatus = .matches for matcher in matchers { diff --git a/Sources/Nimble/Matchers/SatisfyAnyOf.swift b/Sources/Nimble/Matchers/SatisfyAnyOf.swift index 56ffdd10d..adb3ebbcb 100644 --- a/Sources/Nimble/Matchers/SatisfyAnyOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAnyOf.swift @@ -57,7 +57,7 @@ public func satisfyAnyOf(_ matchers: any AsyncableMatcher...) -> AsyncMatc @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAnyOf(_ matchers: [any AsyncableMatcher]) -> AsyncMatcher { return AsyncMatcher.define { actualExpression in - let cachedExpression = actualExpression.withCaching() + let cachedExpression = await actualExpression.withCaching() var postfixMessages = [String]() var status: MatcherStatus = .doesNotMatch for matcher in matchers { diff --git a/Sources/Nimble/Matchers/ThrowError.swift b/Sources/Nimble/Matchers/ThrowError.swift index 32c2f6c1d..818ea4eb4 100644 --- a/Sources/Nimble/Matchers/ThrowError.swift +++ b/Sources/Nimble/Matchers/ThrowError.swift @@ -43,7 +43,7 @@ public func throwError() -> Matcher { /// /// nil arguments indicates that the matcher should not attempt to match against /// that parameter. -public func throwError(_ error: T, closure: ((Error) -> Void)? = nil) -> Matcher { +public func throwError(_ error: T, closure: (@Sendable (Error) -> Void)? = nil) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { @@ -89,7 +89,7 @@ public func throwError(_ error: T, closure: ((Error) -> Void)? = /// /// nil arguments indicates that the matcher should not attempt to match against /// that parameter. -public func throwError(_ error: T, closure: ((T) -> Void)? = nil) -> Matcher { +public func throwError(_ error: T, closure: (@Sendable (T) -> Void)? = nil) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { @@ -137,7 +137,7 @@ public func throwError(_ error: T, closure: ((T) -> V /// that parameter. public func throwError( errorType: T.Type, - closure: ((T) -> Void)? = nil + closure: (@Sendable (T) -> Void)? = nil ) -> Matcher { return Matcher { actualExpression in var actualError: Error? @@ -197,7 +197,7 @@ public func throwError( /// values of the existential type `Error` in the closure. /// /// The closure only gets called when an error was thrown. -public func throwError(closure: @escaping ((Error) -> Void)) -> Matcher { +public func throwError(closure: @escaping (@Sendable (Error) -> Void)) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { @@ -232,7 +232,7 @@ public func throwError(closure: @escaping ((Error) -> Void)) -> Matcher(closure: @escaping ((T) -> Void)) -> Matcher { +public func throwError(closure: @escaping (@Sendable (T) -> Void)) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { diff --git a/Sources/Nimble/Polling+AsyncAwait.swift b/Sources/Nimble/Polling+AsyncAwait.swift index 2238fb425..030b7a11a 100644 --- a/Sources/Nimble/Polling+AsyncAwait.swift +++ b/Sources/Nimble/Polling+AsyncAwait.swift @@ -4,7 +4,13 @@ import Dispatch @MainActor -internal func execute(_ expression: AsyncExpression, style: ExpectationStyle, to: String, description: String?, matcherExecutor: () async throws -> MatcherResult) async -> (Bool, FailureMessage) { +internal func execute( + _ expression: AsyncExpression, + style: ExpectationStyle, + to: String, + description: String?, + matcherExecutor: @Sendable () async throws -> MatcherResult +) async -> (Bool, FailureMessage) { let msg = FailureMessage() msg.userDescription = description msg.to = to @@ -21,7 +27,7 @@ internal func execute(_ expression: AsyncExpression, style: ExpectationSty } } -internal actor Poller { +internal actor Poller { private var lastMatcherResult: MatcherResult? init() {} @@ -33,14 +39,14 @@ internal actor Poller { timeout: NimbleTimeInterval, poll: NimbleTimeInterval, fnName: String, - matcherRunner: @escaping () async throws -> MatcherResult) async -> MatcherResult { + matcherRunner: @escaping @Sendable () async throws -> MatcherResult) async -> MatcherResult { let fnName = "expect(...).\(fnName)(...)" - let result = await pollBlock( + let result = await asyncPollBlock( pollInterval: poll, timeoutInterval: timeout, sourceLocation: expression.location, fnName: fnName) { - if self.updateMatcherResult(result: try await matcherRunner()) + if await self.updateMatcherResult(result: try await matcherRunner()) .toBoolean(expectation: style) { if matchStyle.isContinous { return .incomplete @@ -71,7 +77,7 @@ internal func poll( timeout: NimbleTimeInterval, poll: NimbleTimeInterval, fnName: String, - matcherRunner: @escaping () async throws -> MatcherResult + matcherRunner: @escaping @Sendable () async throws -> MatcherResult ) async -> MatcherResult { let poller = Poller() return await poller.poll( @@ -85,12 +91,17 @@ internal func poll( ) } -extension SyncExpectation { +extension SyncExpectation where Value: Sendable { // MARK: - With Synchronous Matchers /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. @discardableResult - public func toEventually(_ matcher: Matcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toEventually( + _ matcher: Matcher, + timeout: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -116,7 +127,12 @@ extension SyncExpectation { /// Tests the actual value using a matcher to not match by checking /// continuously at each pollInterval until the timeout is reached. @discardableResult - public func toEventuallyNot(_ matcher: Matcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toEventuallyNot( + _ matcher: Matcher, + timeout: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -144,14 +160,24 @@ extension SyncExpectation { /// /// Alias of toEventuallyNot() @discardableResult - public func toNotEventually(_ matcher: Matcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toNotEventually( + _ matcher: Matcher, + timeout: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { return await toEventuallyNot(matcher, timeout: timeout, pollInterval: pollInterval, description: description) } /// Tests the actual value using a matcher to never match by checking /// continuously at each pollInterval until the timeout is reached. @discardableResult - public func toNever(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toNever( + _ matcher: Matcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -178,14 +204,24 @@ extension SyncExpectation { /// /// Alias of toNever() @discardableResult - public func neverTo(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func neverTo( + _ matcher: Matcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { return await toNever(matcher, until: until, pollInterval: pollInterval, description: description) } /// Tests the actual value using a matcher to always match by checking /// continusouly at each pollInterval until the timeout is reached @discardableResult - public func toAlways(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toAlways( + _ matcher: Matcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -212,15 +248,27 @@ extension SyncExpectation { /// /// Alias of toAlways() @discardableResult - public func alwaysTo(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func alwaysTo( + _ matcher: Matcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { return await toAlways(matcher, until: until, pollInterval: pollInterval, description: description) } +} +extension SyncExpectation where Value: Sendable { // MARK: - With AsyncMatchers /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. @discardableResult - public func toEventually(_ matcher: AsyncMatcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toEventually( + _ matcher: AsyncMatcher, + timeout: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -246,7 +294,12 @@ extension SyncExpectation { /// Tests the actual value using a matcher to not match by checking /// continuously at each pollInterval until the timeout is reached. @discardableResult - public func toEventuallyNot(_ matcher: AsyncMatcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toEventuallyNot( + _ matcher: AsyncMatcher, + timeout: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -274,14 +327,24 @@ extension SyncExpectation { /// /// Alias of toEventuallyNot() @discardableResult - public func toNotEventually(_ matcher: AsyncMatcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toNotEventually( + _ matcher: AsyncMatcher, + timeout: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { return await toEventuallyNot(matcher, timeout: timeout, pollInterval: pollInterval, description: description) } /// Tests the actual value using a matcher to never match by checking /// continuously at each pollInterval until the timeout is reached. @discardableResult - public func toNever(_ matcher: AsyncMatcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toNever( + _ matcher: AsyncMatcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -308,14 +371,24 @@ extension SyncExpectation { /// /// Alias of toNever() @discardableResult - public func neverTo(_ matcher: AsyncMatcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func neverTo( + _ matcher: AsyncMatcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { return await toNever(matcher, until: until, pollInterval: pollInterval, description: description) } /// Tests the actual value using a matcher to always match by checking /// continusouly at each pollInterval until the timeout is reached @discardableResult - public func toAlways(_ matcher: AsyncMatcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func toAlways( + _ matcher: AsyncMatcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) let asyncExpression = expression.toAsyncExpression() @@ -342,7 +415,12 @@ extension SyncExpectation { /// /// Alias of toAlways() @discardableResult - public func alwaysTo(_ matcher: AsyncMatcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + public func alwaysTo( + _ matcher: AsyncMatcher, + until: NimbleTimeInterval = PollingDefaults.timeout, + pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, + description: String? = nil + ) async -> Self { return await toAlways(matcher, until: until, pollInterval: pollInterval, description: description) } } diff --git a/Sources/Nimble/Polling+Require.swift b/Sources/Nimble/Polling+Require.swift index 673ce70b9..1977c59b9 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. @@ -706,57 +708,57 @@ extension AsyncRequirement { /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () throws -> T?) throws -> T { - try require(file: file, line: line, expression()).toEventuallyNot(beNil()) +public func pollUnwrap(location: SourceLocation = SourceLocation(), _ expression: @autoclosure @escaping @Sendable () throws -> T?) throws -> T { + try require(location: location, expression()).toEventuallyNot(beNil()) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrap(file: FileString = #file, line: UInt = #line, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T { - try require(file: file, line: line, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) +public func pollUnwrap(location: SourceLocation = SourceLocation(), timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) throws -> T { + try require(location: location, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwraps(file: FileString = #file, line: UInt = #line, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure @escaping () throws -> T?) throws -> T { - try require(file: file, line: line, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) +public func pollUnwraps(location: SourceLocation = SourceLocation(), timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> T?) throws -> T { + try require(location: location, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwraps(file: FileString = #file, line: UInt = #line, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T { - try require(file: file, line: line, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) +public func pollUnwraps(location: SourceLocation = SourceLocation(), timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) throws -> T { + try require(location: location, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) } /// 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, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @escaping () async throws -> T?) async throws -> T { - try await requirea(file: file, line: line, try await expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) +public func pollUnwrap(location: SourceLocation = SourceLocation(), timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @escaping @Sendable () async throws -> T?) async throws -> T { + try await requirea(location: location, try await expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) } /// 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, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: () -> (() async throws -> T?)) async throws -> T { - try await requirea(file: file, line: line, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) +public func pollUnwrap(location: SourceLocation = SourceLocation(), timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: () -> (@Sendable () async throws -> T?)) async throws -> T { + try await requirea(location: location, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) } /// 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, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure @escaping () async throws -> T?) async throws -> T { - try await requirea(file: file, line: line, try await expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) +public func pollUnwrapa(location: SourceLocation = SourceLocation(), timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure @escaping @Sendable () async throws -> T?) async throws -> T { + try await requirea(location: location, try await expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) } /// 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, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure () -> (() async throws -> T?)) async throws -> T { - try await requirea(file: file, line: line, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) +public func pollUnwrapa(location: SourceLocation = SourceLocation(), timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () async throws -> T?)) async throws -> T { + try await requirea(location: location, expression()).toEventuallyNot(beNil(), timeout: timeout, pollInterval: pollInterval, description: description) } #endif // #if !os(WASI) diff --git a/Sources/Nimble/Polling.swift b/Sources/Nimble/Polling.swift index c74facb61..f54b4caf1 100644 --- a/Sources/Nimble/Polling.swift +++ b/Sources/Nimble/Polling.swift @@ -37,8 +37,8 @@ public struct AsyncDefaults { public struct PollingDefaults: @unchecked Sendable { private static let lock = NSRecursiveLock() - private static var _timeout: NimbleTimeInterval = .seconds(1) - private static var _pollInterval: NimbleTimeInterval = .milliseconds(10) + nonisolated(unsafe) private static var _timeout: NimbleTimeInterval = .seconds(1) + nonisolated(unsafe) private static var _pollInterval: NimbleTimeInterval = .milliseconds(10) public static var timeout: NimbleTimeInterval { get { diff --git a/Sources/Nimble/Requirement.swift b/Sources/Nimble/Requirement.swift index 91c8487da..9e573750e 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 @@ -57,7 +57,7 @@ internal func executeRequire(_ expression: AsyncExpression, _ style: Expec msg.userDescription = description msg.to = to do { - let cachedExpression = expression.withCaching() + let cachedExpression = await expression.withCaching() let result = try await matcher.satisfies(cachedExpression) let value = try await cachedExpression.evaluate() result.message.update(failureMessage: msg) @@ -71,7 +71,7 @@ internal func executeRequire(_ expression: AsyncExpression, _ style: Expec } } -public struct SyncRequirement { +public struct SyncRequirement: Sendable { public let expression: Expression /// A custom error to throw. @@ -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/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 141662259..3225bcc0c 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -12,7 +12,7 @@ private let pollLeeway = NimbleTimeInterval.milliseconds(1) // Like PollResult, except it doesn't support objective-c exceptions. // Which is tolerable because Swift Concurrency doesn't support recording objective-c exceptions. -internal enum AsyncPollResult { +internal enum AsyncPollResult: Sendable { /// Incomplete indicates None (aka - this value hasn't been fulfilled yet) case incomplete /// TimedOut indicates the result reached its defined timeout limit before returning @@ -57,10 +57,10 @@ internal enum AsyncPollResult { // Inspired by swift-async-algorithm's AsyncChannel, but massively simplified // especially given Nimble's usecase. // AsyncChannel: https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/Channels/AsyncChannel.swift -internal actor AsyncPromise { +internal actor AsyncPromise { private let storage = Storage() - private final class Storage { + private final class Storage: @unchecked Sendable { private var continuations: [UnsafeContinuation] = [] private var value: T? // Yes, this is not the fastest lock, but it's platform independent, @@ -131,7 +131,7 @@ internal actor AsyncPromise { /// checked. /// /// In addition, stopping the run loop is used to halt code executed on the main run loop. -private func timeout(timeoutQueue: DispatchQueue, timeoutInterval: NimbleTimeInterval, forcefullyAbortTimeout: NimbleTimeInterval) async -> AsyncPollResult { +private func timeout(timeoutQueue: DispatchQueue, timeoutInterval: NimbleTimeInterval, forcefullyAbortTimeout: NimbleTimeInterval) async -> AsyncPollResult { do { try await Task.sleep(nanoseconds: timeoutInterval.nanoseconds) } catch {} @@ -165,7 +165,7 @@ private func timeout(timeoutQueue: DispatchQueue, timeoutInterval: NimbleTime return await promise.value } -private func poll(_ pollInterval: NimbleTimeInterval, expression: @escaping () async throws -> PollStatus) async -> AsyncPollResult { +private func poll(_ pollInterval: NimbleTimeInterval, expression: @escaping @Sendable () async throws -> PollStatus) async -> AsyncPollResult { for try await _ in AsyncTimerSequence(interval: pollInterval) { do { if case .finished(let result) = try await expression() { @@ -200,7 +200,7 @@ private func runPoller( awaiter: Awaiter, fnName: String, sourceLocation: SourceLocation, - expression: @escaping () async throws -> PollStatus + expression: @escaping @Sendable () async throws -> PollStatus ) async -> AsyncPollResult { awaiter.waitLock.acquireWaitingLock( fnName, @@ -259,7 +259,7 @@ private func runAwaitTrigger( timeoutInterval: NimbleTimeInterval, leeway: NimbleTimeInterval, sourceLocation: SourceLocation, - _ closure: @escaping (@escaping (T) -> Void) async throws -> Void + _ closure: @escaping @Sendable (@escaping @Sendable (T) -> Void) async throws -> Void ) async -> AsyncPollResult { let timeoutQueue = awaiter.timeoutQueue let completionCount = Box(value: 0) @@ -286,10 +286,7 @@ private func runAwaitTrigger( } else { fail( "waitUntil(..) expects its completion closure to be only called once", - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column + location: sourceLocation ) } } @@ -315,7 +312,7 @@ internal func performBlock( timeoutInterval: NimbleTimeInterval, leeway: NimbleTimeInterval, sourceLocation: SourceLocation, - _ closure: @escaping (@escaping (T) -> Void) async throws -> Void + _ closure: @escaping @Sendable (@escaping @Sendable (T) -> Void) async throws -> Void ) async -> AsyncPollResult { await runAwaitTrigger( awaiter: NimbleEnvironment.activeInstance.awaiter, @@ -325,12 +322,12 @@ internal func performBlock( closure) } -internal func pollBlock( +internal func asyncPollBlock( pollInterval: NimbleTimeInterval, timeoutInterval: NimbleTimeInterval, sourceLocation: SourceLocation, fnName: String, - expression: @escaping () async throws -> PollStatus) async -> AsyncPollResult { + expression: @escaping @Sendable () async throws -> PollStatus) async -> AsyncPollResult { await runPoller( timeoutInterval: timeoutInterval, pollInterval: pollInterval, diff --git a/Sources/Nimble/Utils/LockedContainer.swift b/Sources/Nimble/Utils/LockedContainer.swift new file mode 100644 index 000000000..fcc8455ba --- /dev/null +++ b/Sources/Nimble/Utils/LockedContainer.swift @@ -0,0 +1,32 @@ +import Foundation + +final class LockedContainer: @unchecked Sendable { + private let lock = NSRecursiveLock() + private var _value: T + + var value: T { + lock.lock() + defer { lock.unlock() } + return _value + } + + init(_ value: T) { + _value = value + } + + init(_ closure: () -> T) { + _value = closure() + } + + func operate(_ closure: (T) -> T) { + lock.lock() + defer { lock.unlock() } + _value = closure(_value) + } + + func set(_ newValue: T) { + lock.lock() + defer { lock.unlock() } + _value = newValue + } +} diff --git a/Sources/Nimble/Utils/NSLocking+Nimble.swift b/Sources/Nimble/Utils/NSLocking+Nimble.swift new file mode 100644 index 000000000..67bd89e97 --- /dev/null +++ b/Sources/Nimble/Utils/NSLocking+Nimble.swift @@ -0,0 +1,11 @@ +import Foundation + +extension NSLocking { + internal func sync(_ closure: () throws -> T) rethrows -> T { + lock() + defer { + unlock() + } + return try closure() + } +} diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index 1bc1311ba..9003e93f1 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -64,7 +64,7 @@ internal final class AssertionWaitLock: WaitLock, @unchecked Sendable { } } -internal enum PollResult { +internal enum PollResult: Sendable { /// Incomplete indicates None (aka - this value hasn't been fulfilled yet) case incomplete /// TimedOut indicates the result reached its defined timeout limit before returning @@ -104,9 +104,9 @@ internal enum PollStatus { /// Holds the resulting value from an asynchronous expectation. /// This class is thread-safe at receiving a "response" to this promise. -internal final class AwaitPromise { - private(set) internal var asyncResult: PollResult = .incomplete - private var signal: DispatchSemaphore +internal final class AwaitPromise: Sendable { + nonisolated(unsafe) private(set) internal var asyncResult: PollResult = .incomplete + private let signal: DispatchSemaphore init() { signal = DispatchSemaphore(value: 1) @@ -142,7 +142,7 @@ internal struct PollAwaitTrigger { /// /// This factory stores all the state for an async expectation so that Await doesn't /// doesn't have to manage it. -internal class AwaitPromiseBuilder { +internal class AwaitPromiseBuilder { let awaiter: Awaiter let waitLock: WaitLock let trigger: PollAwaitTrigger @@ -313,36 +313,38 @@ internal class Awaiter { return DispatchSource.makeTimerSource(flags: .strict, queue: queue) } - func performBlock( - file: FileString, - line: UInt, - _ closure: @escaping (@escaping (T) -> Void) throws -> Void + func performBlock( + location: SourceLocation, + _ closure: @escaping (@escaping @Sendable (T) -> Void) throws -> Void ) -> AwaitPromiseBuilder { let promise = AwaitPromise() let timeoutSource = createTimerSource(timeoutQueue) - var completionCount = 0 + nonisolated(unsafe) var completionCount = 0 + let lock = NSRecursiveLock() let trigger = PollAwaitTrigger(timeoutSource: timeoutSource, actionSource: nil) { try closure { result in - completionCount += 1 - if completionCount < 2 { - func completeBlock() { - if promise.resolveResult(.completed(result)) { - #if canImport(CoreFoundation) - CFRunLoopStop(CFRunLoopGetMain()) - #else - RunLoop.main._stop() - #endif + lock.withLock { + completionCount += 1 + if completionCount < 2 { + @Sendable func completeBlock() { + if promise.resolveResult(.completed(result)) { +#if canImport(CoreFoundation) + CFRunLoopStop(CFRunLoopGetMain()) +#else + RunLoop.main._stop() +#endif + } } - } - if Thread.isMainThread { - completeBlock() + if Thread.isMainThread { + completeBlock() + } else { + DispatchQueue.main.async { completeBlock() } + } } else { - DispatchQueue.main.async { completeBlock() } + fail("waitUntil(..) expects its completion closure to be only called once", + location: location) } - } else { - fail("waitUntil(..) expects its completion closure to be only called once", - file: file, line: line) } } } @@ -401,7 +403,7 @@ internal func pollBlock( pollInterval: NimbleTimeInterval, timeoutInterval: NimbleTimeInterval, sourceLocation: SourceLocation, - fnName: String = #function, + fnName: String, expression: @escaping () throws -> PollStatus) -> PollResult { let awaiter = NimbleEnvironment.activeInstance.awaiter let result = awaiter.poll(pollInterval) { () throws -> Bool? in diff --git a/Sources/Nimble/Utils/SourceLocation.swift b/Sources/Nimble/Utils/SourceLocation.swift index 557e11219..24c087232 100644 --- a/Sources/Nimble/Utils/SourceLocation.swift +++ b/Sources/Nimble/Utils/SourceLocation.swift @@ -11,6 +11,9 @@ public typealias FileString = StaticString public typealias FileString = String #endif +#if canImport(Darwin) +@objc +#endif public final class SourceLocation: NSObject, Sendable { public let fileID: String @available(*, deprecated, renamed: "filePath") @@ -19,14 +22,10 @@ public final class SourceLocation: NSObject, Sendable { public let line: UInt public let column: UInt - override init() { - fileID = "Unknown/File" - filePath = "Unknown File" - line = 0 - column = 0 - } - - init(fileID: String, filePath: FileString, line: UInt, column: UInt) { +#if canImport(Darwin) +@objc +#endif + public init(fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column) { self.fileID = fileID self.filePath = filePath self.line = line diff --git a/Sources/NimbleObjectiveC/DSL.m b/Sources/NimbleObjectiveC/DSL.m index 1aad14e36..68e69852f 100644 --- a/Sources/NimbleObjectiveC/DSL.m +++ b/Sources/NimbleObjectiveC/DSL.m @@ -155,22 +155,22 @@ NIMBLE_EXPORT void NMB_failWithMessage(NSString *msg, NSString *file, NSUInteger NIMBLE_EXPORT NMBWaitUntilTimeoutBlock NMB_waitUntilTimeoutBuilder(NSString *file, NSUInteger line) { return ^(NSTimeInterval timeout, void (^ _Nonnull action)(void (^ _Nonnull)(void))) { [NMBWait untilTimeout:timeout - fileID:[NSString stringWithFormat:@"Unknown/%@", file] - file:file - line:line - column:0 + location: [[SourceLocation alloc] initWithFileID:[NSString stringWithFormat:@"Unknown/%@", file] + filePath:file + line:line + column:0] action:action]; }; } NIMBLE_EXPORT NMBWaitUntilBlock NMB_waitUntilBuilder(NSString *file, NSUInteger line) { - return ^(void (^ _Nonnull action)(void (^ _Nonnull)(void))) { - [NMBWait untilFileID:[NSString stringWithFormat:@"Unknown/%@", file] - file:file - line:line - column:0 - action:action]; - }; + return ^(void (^ _Nonnull action)(void (^ _Nonnull)(void))) { + [NMBWait untilLocation:[[SourceLocation alloc] initWithFileID:[NSString stringWithFormat:@"Unknown/%@", file] + filePath:file + line:line + column:0] + action:action]; + }; } NS_ASSUME_NONNULL_END diff --git a/Sources/NimbleSharedTestHelpers/utils.swift b/Sources/NimbleSharedTestHelpers/utils.swift index 1d94b91ac..987678c75 100644 --- a/Sources/NimbleSharedTestHelpers/utils.swift +++ b/Sources/NimbleSharedTestHelpers/utils.swift @@ -10,17 +10,13 @@ import XCTest public func failsWithErrorMessage( _ messages: [String], - fileID: String = #fileID, - filePath: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, + location: SourceLocation = SourceLocation(), preferOriginalSourceLocation: Bool = false, closure: () throws -> Void ) { - var location = SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) - + var location = location let recorder = AssertionRecorder() - withAssertionHandler(recorder, fileID: fileID, file: filePath, line: line, column: column, closure: closure) + withAssertionHandler(recorder, location: location, closure: closure) for msg in messages { var lastFailure: AssertionRecord? @@ -64,14 +60,11 @@ public func failsWithErrorMessage( // Verifies that the error message matches the given regex. public func failsWithErrorRegex( _ regex: String, - fileID: String = #fileID, - filePath: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, + location: SourceLocation = SourceLocation(), closure: () throws -> Void ) { let recorder = AssertionRecorder() - withAssertionHandler(recorder, fileID: fileID, file: filePath, line: line, column: column, closure: closure) + withAssertionHandler(recorder, location: location, closure: closure) for assertion in recorder.assertions where assertion.message.stringValue.range(of: regex, options: .regularExpression) != nil && !assertion.success { return @@ -85,48 +78,46 @@ public func failsWithErrorRegex( Assertions Received: \(recorder.assertions) """ - NimbleAssertionHandler.assert(false, - message: FailureMessage(stringValue: message), - location: SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column)) + NimbleAssertionHandler.assert( + false, + message: FailureMessage(stringValue: message), + location: location + ) } public func failsWithErrorMessage( _ message: String, - fileID: String = #fileID, - filePath: FileString = #filePath, - line: UInt = #line, - column: UInt = #column, + location: SourceLocation = SourceLocation(), preferOriginalSourceLocation: Bool = false, closure: () throws -> Void ) { failsWithErrorMessage( [message], - fileID: fileID, - filePath: filePath, - line: line, - column: column, + location: location, preferOriginalSourceLocation: preferOriginalSourceLocation, closure: closure ) } -public func failsWithErrorMessageForNil(_ message: String, fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column, preferOriginalSourceLocation: Bool = false, closure: () throws -> Void) { +public func failsWithErrorMessageForNil( + _ message: String, + location: SourceLocation = SourceLocation(), + preferOriginalSourceLocation: Bool = false, + closure: () throws -> Void +) { failsWithErrorMessage( "\(message) (use beNil() to match nils)", - fileID: fileID, - filePath: filePath, - line: line, - column: column, + location: location, preferOriginalSourceLocation: preferOriginalSourceLocation, closure: closure ) } -public func failsWithErrorMessage(_ messages: [String], fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column, preferOriginalSourceLocation: Bool = false, closure: () async throws -> Void) async { - var sourceLocation = SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) +public func failsWithErrorMessage(_ messages: [String], location: SourceLocation = SourceLocation(), preferOriginalSourceLocation: Bool = false, closure: () async throws -> Void) async { + var sourceLocation = location let recorder = AssertionRecorder() - await withAssertionHandler(recorder, fileID: fileID, file: filePath, line: line, column: column, closure: closure) + await withAssertionHandler(recorder, location: location, closure: closure) for msg in messages { var lastFailure: AssertionRecord? @@ -167,25 +158,29 @@ public func failsWithErrorMessage(_ messages: [String], fileID: String = #fileID } } -public func failsWithErrorMessage(_ message: String, fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column, preferOriginalSourceLocation: Bool = false, closure: () async throws -> Void) async { +public func failsWithErrorMessage( + _ message: String, + location: SourceLocation = SourceLocation(), + preferOriginalSourceLocation: Bool = false, + closure: () async throws -> Void +) async { await failsWithErrorMessage( [message], - fileID: fileID, - filePath: filePath, - line: line, - column: column, + location: location, preferOriginalSourceLocation: preferOriginalSourceLocation, closure: closure ) } -public func failsWithErrorMessageForNil(_ message: String, fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column, preferOriginalSourceLocation: Bool = false, closure: () async throws -> Void) async { +public func failsWithErrorMessageForNil( + _ message: String, + location: SourceLocation = SourceLocation(), + preferOriginalSourceLocation: Bool = false, + closure: () async throws -> Void +) async { await failsWithErrorMessage( "\(message) (use beNil() to match nils)", - fileID: fileID, - filePath: filePath, - line: line, - column: column, + location: location, preferOriginalSourceLocation: preferOriginalSourceLocation, closure: closure ) @@ -201,16 +196,20 @@ public func suppressErrors(closure: () -> T) -> T { return output! } -public func producesStatus(_ status: ExpectationStatus, fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column, closure: () -> SyncExpectation) { +public func producesStatus( + _ status: ExpectationStatus, + location: SourceLocation = SourceLocation(), + closure: () -> SyncExpectation +) { let expectation = suppressErrors(closure: closure) - expect(fileID: fileID, file: filePath, line: line, column: column, expectation.status).to(equal(status)) + expect(location: location, expectation.status).to(equal(status)) } -public func producesStatus(_ status: ExpectationStatus, fileID: String = #fileID, filePath: FileString = #filePath, line: UInt = #line, column: UInt = #column, closure: () -> AsyncExpectation) { +public func producesStatus(_ status: ExpectationStatus, location: SourceLocation = SourceLocation(), closure: () -> AsyncExpectation) { let expectation = suppressErrors(closure: closure) - expect(fileID: fileID, file: filePath, line: line, column: column, expectation.status).to(equal(status)) + expect(location: location, expectation.status).to(equal(status)) } #if !os(WASI) @@ -227,10 +226,12 @@ public class NimbleHelper: NSObject { @objc public class func expectFailureMessage(_ message: NSString, block: () -> Void, file: FileString, line: UInt) { failsWithErrorMessage( String(describing: message), - fileID: "Unknown/\(file)", - filePath: file, - line: line, - column: 0, + location: SourceLocation( + fileID: "Unknown/\(file)", + filePath: file, + line: line, + column: 0 + ), preferOriginalSourceLocation: true, closure: block ) @@ -239,10 +240,12 @@ public class NimbleHelper: NSObject { @objc public class func expectFailureMessages(_ messages: [NSString], block: () -> Void, file: FileString, line: UInt) { failsWithErrorMessage( messages.map({String(describing: $0)}), - fileID: "Unknown/\(file)", - filePath: file, - line: line, - column: 0, + location: SourceLocation( + fileID: "Unknown/\(file)", + filePath: file, + line: line, + column: 0 + ), preferOriginalSourceLocation: true, closure: block ) @@ -251,10 +254,12 @@ public class NimbleHelper: NSObject { @objc public class func expectFailureMessageForNil(_ message: NSString, block: () -> Void, file: FileString, line: UInt) { failsWithErrorMessageForNil( String(describing: message), - fileID: "Unknown/\(file)", - filePath: file, - line: line, - column: 0, + location: SourceLocation( + fileID: "Unknown/\(file)", + filePath: file, + line: line, + column: 0 + ), preferOriginalSourceLocation: true, closure: block ) @@ -263,10 +268,12 @@ public class NimbleHelper: NSObject { @objc public class func expectFailureMessageRegex(_ regex: NSString, block: () -> Void, file: FileString, line: UInt) { failsWithErrorRegex( String(describing: regex), - fileID: "Unknown/\(file)", - filePath: file, - line: line, - column: 0, + location: SourceLocation( + fileID: "Unknown/\(file)", + filePath: file, + line: line, + column: 0 + ), closure: block ) } diff --git a/Tests/NimbleTests/AsyncAwaitTest+Require.swift b/Tests/NimbleTests/AsyncAwaitTest+Require.swift index f6dfd712d..f681557ae 100644 --- a/Tests/NimbleTests/AsyncAwaitTest+Require.swift +++ b/Tests/NimbleTests/AsyncAwaitTest+Require.swift @@ -1,14 +1,14 @@ #if !os(WASI) import XCTest -import Nimble +@testable import Nimble #if SWIFT_PACKAGE import NimbleSharedTestHelpers #endif 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,20 +16,20 @@ 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 } func testToEventuallyPositiveMatches() async throws { - var value = 0 - deferToMainQueue { value = 1 } - try await require { value }.toEventually(equal(1)) + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } + try await require { value.value }.toEventually(equal(1)) - deferToMainQueue { value = 0 } - try await require { value }.toEventuallyNot(equal(1)) + deferToMainQueue { value.set(0) } + try await require { value.value }.toEventuallyNot(equal(1)) } func testToEventuallyNegativeMatches() async { @@ -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 ") { 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,99 +151,101 @@ 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 } final class ClassUnderTest { - var deinitCalled: (() -> Void)? - var count = 0 - deinit { deinitCalled?() } + let deinitCalled = LockedContainer<(() -> Void)?>(nil) + let count = LockedContainer(0) + deinit { deinitCalled.value?() } } func testSubjectUnderTestIsReleasedFromMemory() async throws { - var subject: ClassUnderTest? = ClassUnderTest() + let subject = LockedContainer(ClassUnderTest()) - if let sub = subject { - try await require(sub.count).toEventually(equal(0), timeout: .milliseconds(100)) - try await require(sub.count).toEventuallyNot(equal(1), timeout: .milliseconds(100)) + if let sub = subject.value { + try await require(sub.count.value).toEventually(equal(0), timeout: .milliseconds(100)) + try await require(sub.count.value).toEventuallyNot(equal(1), timeout: .milliseconds(100)) } await waitUntil(timeout: .milliseconds(500)) { done in - subject?.deinitCalled = { + subject.value?.deinitCalled.set({ done() - } + }) - deferToMainQueue { subject = nil } + deferToMainQueue { subject.set(nil) } } } func testToNeverPositiveMatches() async throws { - var value = 0 - deferToMainQueue { value = 1 } - try await require { value }.toNever(beGreaterThan(1)) + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } + try await require { value.value }.toNever(beGreaterThan(1)) - deferToMainQueue { value = 0 } - try await require { value }.neverTo(beGreaterThan(1)) + deferToMainQueue { value.set(0) } + try await require { value.value }.neverTo(beGreaterThan(1)) } func testToNeverNegativeMatches() async { - var value = 0 + let value = LockedContainer(0) await failsWithErrorMessage("expected to never equal <0>, got <0>") { - try await require { value }.toNever(equal(0)) + try await require { value.value }.toNever(equal(0)) } await failsWithErrorMessage("expected to never equal <0>, got <0>") { - try await require { value }.neverTo(equal(0)) + try await require { value.value }.neverTo(equal(0)) } await failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - try await require { value }.toNever(equal(1)) + deferToMainQueue { value.set(1) } + try await require { value.value }.toNever(equal(1)) } await failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - try await require { value }.neverTo(equal(1)) + deferToMainQueue { value.set(1) } + try await require { value.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)) @@ -249,35 +253,35 @@ final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_b } func testToAlwaysPositiveMatches() async throws { - var value = 1 - deferToMainQueue { value = 2 } - try await require { value }.toAlways(beGreaterThan(0)) + let value = LockedContainer(1) + deferToMainQueue { value.set(2) } + try await require { value.value }.toAlways(beGreaterThan(0)) - deferToMainQueue { value = 2 } - try await require { value }.alwaysTo(beGreaterThan(1)) + deferToMainQueue { value.set(2) } + try await require { value.value }.alwaysTo(beGreaterThan(1)) } func testToAlwaysNegativeMatches() async { - var value = 1 + let value = LockedContainer(1) await failsWithErrorMessage("expected to always equal <0>, got <1>") { - try await require { value }.toAlways(equal(0)) + try await require { value.value }.toAlways(equal(0)) } await failsWithErrorMessage("expected to always equal <0>, got <1>") { - try await require { value }.alwaysTo(equal(0)) + try await require { value.value }.alwaysTo(equal(0)) } await failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - try await require { value }.toAlways(equal(1)) + deferToMainQueue { value.set(0) } + try await require { value.value }.toAlways(equal(1)) } await failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - try await require { value }.alwaysTo(equal(1)) + deferToMainQueue { value.set(0) } + try await require { value.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/AsyncAwaitTest.swift b/Tests/NimbleTests/AsyncAwaitTest.swift index 7af280623..44d2cd8ea 100644 --- a/Tests/NimbleTests/AsyncAwaitTest.swift +++ b/Tests/NimbleTests/AsyncAwaitTest.swift @@ -1,14 +1,14 @@ #if !os(WASI) import XCTest -import Nimble +@testable import Nimble #if SWIFT_PACKAGE import NimbleSharedTestHelpers #endif final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_length func testToPositiveMatches() async { - func someAsyncFunction() async throws -> Int { + @Sendable func someAsyncFunction() async throws -> Int { try await Task.sleep(nanoseconds: 1_000_000) // 1 millisecond return 1 } @@ -24,12 +24,12 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len } func testToEventuallyPositiveMatches() async { - var value = 0 - deferToMainQueue { value = 1 } - await expect { value }.toEventually(equal(1)) + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } + await expect { value.value }.toEventually(equal(1)) - deferToMainQueue { value = 0 } - await expect { value }.toEventuallyNot(equal(1)) + deferToMainQueue { value.set(0) } + await expect { value.value }.toEventuallyNot(equal(1)) } func testToEventuallyNegativeMatches() async { @@ -119,7 +119,7 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len func testToEventuallyWithAsyncExpectationDoesNotNecessarilyExecutesExpressionOnMainActor() async { // 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 } await expecta(isMainThread()).toEventually(beFalse()) await expecta(isMainThread()).toEventuallyNot(beTrue()) @@ -131,7 +131,7 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len func testToEventuallyWithAsyncExpectationDoesExecuteExpressionOnMainActorWhenTestRunsOnMainActor() async { // 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 } await expecta(isMainThread()).toEventually(beTrue()) await expecta(isMainThread()).toEventuallyNot(beFalse()) @@ -145,23 +145,23 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len PollingDefaults.timeout = .seconds(1) } - var value = 0 + let value = LockedContainer(0) let sleepThenSetValueTo: (Int) -> Void = { newValue in Thread.sleep(forTimeInterval: 1.1) - value = newValue + value.set(newValue) } let task = Task { sleepThenSetValueTo(1) } - await expect { value }.toEventually(equal(1)) + await expect { value.value }.toEventually(equal(1)) let secondTask = Task { sleepThenSetValueTo(0) } - await expect { value }.toEventuallyNot(equal(1)) + await expect { value.value }.toEventuallyNot(equal(1)) _ = await task.value _ = await secondTask.result @@ -284,7 +284,7 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len for index in 0..<100 { if failed { break } - await waitUntil(line: UInt(index)) { done in + await waitUntil(location: SourceLocation(column: UInt(index))) { done in DispatchQueue(label: "Nimble.waitUntilTest.\(index)").async { done() } @@ -295,52 +295,52 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len } final class ClassUnderTest { - var deinitCalled: (() -> Void)? - var count = 0 - deinit { deinitCalled?() } + let deinitCalled = LockedContainer<(() -> Void)?>(nil) + let count = LockedContainer(0) + deinit { deinitCalled.value?() } } func testSubjectUnderTestIsReleasedFromMemory() async { - var subject: ClassUnderTest? = ClassUnderTest() + let subject = LockedContainer(ClassUnderTest()) - if let sub = subject { - await expect(sub.count).toEventually(equal(0), timeout: .milliseconds(100)) - await expect(sub.count).toEventuallyNot(equal(1), timeout: .milliseconds(100)) + if let sub = subject.value { + await expect(sub.count.value).toEventually(equal(0), timeout: .milliseconds(100)) + await expect(sub.count.value).toEventuallyNot(equal(1), timeout: .milliseconds(100)) } await waitUntil(timeout: .milliseconds(500)) { done in - subject?.deinitCalled = { + subject.value?.deinitCalled.set({ done() - } + }) - deferToMainQueue { subject = nil } + deferToMainQueue { subject.set(nil) } } } func testToNeverPositiveMatches() async { - var value = 0 - deferToMainQueue { value = 1 } - await expect { value }.toNever(beGreaterThan(1)) + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } + await expect { value.value }.toNever(beGreaterThan(1)) - deferToMainQueue { value = 0 } - await expect { value }.neverTo(beGreaterThan(1)) + deferToMainQueue { value.set(0) } + await expect { value.value }.neverTo(beGreaterThan(1)) } func testToNeverNegativeMatches() async { - var value = 0 + let value = LockedContainer(0) await failsWithErrorMessage("expected to never equal <0>, got <0>") { - await expect { value }.toNever(equal(0)) + await expect { value.value }.toNever(equal(0)) } await failsWithErrorMessage("expected to never equal <0>, got <0>") { - await expect { value }.neverTo(equal(0)) + await expect { value.value }.neverTo(equal(0)) } await failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - await expect { value }.toNever(equal(1)) + deferToMainQueue { value.set(1) } + await expect { value.value }.toNever(equal(1)) } await failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - await expect { value }.neverTo(equal(1)) + deferToMainQueue { value.set(1) } + await expect { value.value }.neverTo(equal(1)) } await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { await expect { try self.doThrowError() }.toNever(equal(0)) @@ -351,29 +351,29 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len } func testToAlwaysPositiveMatches() async { - var value = 1 - deferToMainQueue { value = 2 } - await expect { value }.toAlways(beGreaterThan(0)) + let value = LockedContainer(1) + deferToMainQueue { value.set(2) } + await expect { value.value }.toAlways(beGreaterThan(0)) - deferToMainQueue { value = 2 } - await expect { value }.alwaysTo(beGreaterThan(1)) + deferToMainQueue { value.set(2) } + await expect { value.value }.alwaysTo(beGreaterThan(1)) } func testToAlwaysNegativeMatches() async { - var value = 1 + let value = LockedContainer(1) await failsWithErrorMessage("expected to always equal <0>, got <1>") { - await expect { value }.toAlways(equal(0)) + await expect { value.value }.toAlways(equal(0)) } await failsWithErrorMessage("expected to always equal <0>, got <1>") { - await expect { value }.alwaysTo(equal(0)) + await expect { value.value }.alwaysTo(equal(0)) } await failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - await expect { value }.toAlways(equal(1)) + deferToMainQueue { value.set(0) } + await expect { value.value }.toAlways(equal(1)) } await failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - await expect { value }.alwaysTo(equal(1)) + deferToMainQueue { value.set(0) } + await expect { value.value }.alwaysTo(equal(1)) } await failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { await expect { try self.doThrowError() }.toAlways(equal(0)) diff --git a/Tests/NimbleTests/Matchers/BeAKindOfTest.swift b/Tests/NimbleTests/Matchers/BeAKindOfTest.swift index f7c2b0dd6..5cafece65 100644 --- a/Tests/NimbleTests/Matchers/BeAKindOfTest.swift +++ b/Tests/NimbleTests/Matchers/BeAKindOfTest.swift @@ -5,9 +5,9 @@ import Nimble import NimbleSharedTestHelpers #endif -private class TestNull: NSNull {} +private final class TestNull: NSNull, @unchecked Sendable {} private protocol TestProtocol {} -private class TestClassConformingToProtocol: TestProtocol {} +private final class TestClassConformingToProtocol: TestProtocol {} private struct TestStructConformingToProtocol: TestProtocol {} final class BeAKindOfSwiftTest: XCTestCase { diff --git a/Tests/NimbleTests/Matchers/BeIdenticalToTest.swift b/Tests/NimbleTests/Matchers/BeIdenticalToTest.swift index 8fcf363eb..be04bc782 100644 --- a/Tests/NimbleTests/Matchers/BeIdenticalToTest.swift +++ b/Tests/NimbleTests/Matchers/BeIdenticalToTest.swift @@ -26,7 +26,7 @@ final class BeIdenticalToTest: XCTestCase { } func testBeIdenticalToNegativeMessage() { - let value1 = NSArray() + let value1 = 1 as NSNumber let value2 = value1 let message = "expected to not be identical to \(identityAsString(value2)), got \(identityAsString(value1))" failsWithErrorMessage(message) { @@ -46,7 +46,7 @@ final class BeIdenticalToTest: XCTestCase { expect(1 as NSNumber).toNot(be("turtles" as NSString)) expect([1 as NSNumber] as NSArray).toNot(be([1 as NSNumber] as NSArray)) - let value1 = NSArray() + let value1 = 1 as NSNumber let value2 = value1 let message = "expected to not be identical to \(identityAsString(value1)), got \(identityAsString(value2))" failsWithErrorMessage(message) { diff --git a/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift b/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift index 38a3eac10..cce362555 100644 --- a/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift +++ b/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift @@ -8,7 +8,7 @@ import NimbleSharedTestHelpers final class ContainElementSatisfyingTest: XCTestCase { // MARK: - Matcher variant func testContainElementSatisfying() { - var orderIndifferentArray = [1, 2, 3] + let orderIndifferentArray = [1, 2, 3] expect(orderIndifferentArray).to(containElementSatisfying({ number in return number == 1 })) @@ -18,8 +18,10 @@ final class ContainElementSatisfyingTest: XCTestCase { expect(orderIndifferentArray).to(containElementSatisfying({ number in return number == 3 })) + } - orderIndifferentArray = [3, 1, 2] + func testContainElementSatisfying2() { + let orderIndifferentArray = [3, 1, 2] expect(orderIndifferentArray).to(containElementSatisfying({ number in return number == 1 })) @@ -76,7 +78,7 @@ final class ContainElementSatisfyingTest: XCTestCase { // MARK: - AsyncMatcher variant func testAsyncContainElementSatisfying() async { - var orderIndifferentArray = [1, 2, 3] + let orderIndifferentArray = [1, 2, 3] await expect(orderIndifferentArray).to(containElementSatisfying({ number in await asyncEqualityCheck(number, 1) })) @@ -86,8 +88,10 @@ final class ContainElementSatisfyingTest: XCTestCase { await expect(orderIndifferentArray).to(containElementSatisfying({ number in await asyncEqualityCheck(number, 3) })) + } - orderIndifferentArray = [3, 1, 2] + func testAsyncContainElementSatisfying2() async { + let orderIndifferentArray = [3, 1, 2] await expect(orderIndifferentArray).to(containElementSatisfying({ number in await asyncEqualityCheck(number, 1) })) diff --git a/Tests/NimbleTests/Matchers/ContainTest.swift b/Tests/NimbleTests/Matchers/ContainTest.swift index 2991405a0..3810a52fe 100644 --- a/Tests/NimbleTests/Matchers/ContainTest.swift +++ b/Tests/NimbleTests/Matchers/ContainTest.swift @@ -89,11 +89,10 @@ final class ContainTest: XCTestCase { } func testContainNSStringSubstring() { - let str = "foo" as NSString - expect(str).to(contain("o" as NSString)) - expect(str).to(contain("oo" as NSString)) - expect(str).toNot(contain("z" as NSString)) - expect(str).toNot(contain("zz" as NSString)) + expect("foo" as NSString).to(contain("o" as NSString)) + expect("foo" as NSString).to(contain("oo" as NSString)) + expect("foo" as NSString).toNot(contain("z" as NSString)) + expect("foo" as NSString).toNot(contain("zz" as NSString)) } func testVariadicArguments() { diff --git a/Tests/NimbleTests/Matchers/EqualTest.swift b/Tests/NimbleTests/Matchers/EqualTest.swift index 07646f5f1..e4beae641 100644 --- a/Tests/NimbleTests/Matchers/EqualTest.swift +++ b/Tests/NimbleTests/Matchers/EqualTest.swift @@ -338,13 +338,24 @@ final class EqualTest: XCTestCase { // swiftlint:disable:this type_body_length expect(originalArray) != expectedArray.reversed() expect(originalArray) != [] - let originalArrayAsync = { () async in originalArray } - await expect(originalArrayAsync).toEventually(equal(expectedArray)) - await expect(originalArrayAsync).toEventuallyNot(equal(expectedArray.reversed())) - await expect(originalArrayAsync).toEventuallyNot(equal([])) - await expect(originalArrayAsync) == expectedArray - await expect(originalArrayAsync) != expectedArray.reversed() - await expect(originalArrayAsync) != [] + await expect { @Sendable () async in + originalArray + }.toEventually(equal(expectedArray)) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal(expectedArray.reversed())) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal([])) + await expect { @Sendable () async in + originalArray + } == expectedArray + await expect { @Sendable () async in + originalArray + } != expectedArray.reversed() + await expect { @Sendable () async in + originalArray + } != [] } func testTuple3Array() async { @@ -371,13 +382,24 @@ final class EqualTest: XCTestCase { // swiftlint:disable:this type_body_length expect(originalArray) != expectedArray.reversed() expect(originalArray) != [] - let originalArrayAsync = { () async in originalArray } - await expect(originalArrayAsync).toEventually(equal(expectedArray)) - await expect(originalArrayAsync).toEventuallyNot(equal(expectedArray.reversed())) - await expect(originalArrayAsync).toEventuallyNot(equal([])) - await expect(originalArrayAsync) == expectedArray - await expect(originalArrayAsync) != expectedArray.reversed() - await expect(originalArrayAsync) != [] + await expect { @Sendable () async in + originalArray + }.toEventually(equal(expectedArray)) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal(expectedArray.reversed())) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal([])) + await expect { @Sendable () async in + originalArray + } == expectedArray + await expect { @Sendable () async in + originalArray + } != expectedArray.reversed() + await expect { @Sendable () async in + originalArray + } != [] } func testTuple4Array() async { @@ -404,13 +426,24 @@ final class EqualTest: XCTestCase { // swiftlint:disable:this type_body_length expect(originalArray) != expectedArray.reversed() expect(originalArray) != [] - let originalArrayAsync = { () async in originalArray } - await expect(originalArrayAsync).toEventually(equal(expectedArray)) - await expect(originalArrayAsync).toEventuallyNot(equal(expectedArray.reversed())) - await expect(originalArrayAsync).toEventuallyNot(equal([])) - await expect(originalArrayAsync) == expectedArray - await expect(originalArrayAsync) != expectedArray.reversed() - await expect(originalArrayAsync) != [] + await expect { @Sendable () async in + originalArray + }.toEventually(equal(expectedArray)) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal(expectedArray.reversed())) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal([])) + await expect { @Sendable () async in + originalArray + } == expectedArray + await expect { @Sendable () async in + originalArray + } != expectedArray.reversed() + await expect { @Sendable () async in + originalArray + } != [] } func testTuple5Array() async { @@ -437,13 +470,24 @@ final class EqualTest: XCTestCase { // swiftlint:disable:this type_body_length expect(originalArray) != expectedArray.reversed() expect(originalArray) != [] - let originalArrayAsync = { () async in originalArray } - await expect(originalArrayAsync).toEventually(equal(expectedArray)) - await expect(originalArrayAsync).toEventuallyNot(equal(expectedArray.reversed())) - await expect(originalArrayAsync).toEventuallyNot(equal([])) - await expect(originalArrayAsync) == expectedArray - await expect(originalArrayAsync) != expectedArray.reversed() - await expect(originalArrayAsync) != [] + await expect { @Sendable () async in + originalArray + }.toEventually(equal(expectedArray)) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal(expectedArray.reversed())) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal([])) + await expect { @Sendable () async in + originalArray + } == expectedArray + await expect { @Sendable () async in + originalArray + } != expectedArray.reversed() + await expect { @Sendable () async in + originalArray + } != [] } func testTuple6Array() async { @@ -470,13 +514,24 @@ final class EqualTest: XCTestCase { // swiftlint:disable:this type_body_length expect(originalArray) != expectedArray.reversed() expect(originalArray) != [] - let originalArrayAsync = { () async in originalArray } - await expect(originalArrayAsync).toEventually(equal(expectedArray)) - await expect(originalArrayAsync).toEventuallyNot(equal(expectedArray.reversed())) - await expect(originalArrayAsync).toEventuallyNot(equal([])) - await expect(originalArrayAsync) == expectedArray - await expect(originalArrayAsync) != expectedArray.reversed() - await expect(originalArrayAsync) != [] + await expect { @Sendable () async in + originalArray + }.toEventually(equal(expectedArray)) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal(expectedArray.reversed())) + await expect { @Sendable () async in + originalArray + }.toEventuallyNot(equal([])) + await expect { @Sendable () async in + originalArray + } == expectedArray + await expect { @Sendable () async in + originalArray + } != expectedArray.reversed() + await expect { @Sendable () async in + originalArray + } != [] } // swiftlint:enable large_tuple diff --git a/Tests/NimbleTests/Matchers/MapTest.swift b/Tests/NimbleTests/Matchers/MapTest.swift index e7413deaa..e35d1b64e 100644 --- a/Tests/NimbleTests/Matchers/MapTest.swift +++ b/Tests/NimbleTests/Matchers/MapTest.swift @@ -57,7 +57,7 @@ final class MapTest: XCTestCase { } func testMapWithAsyncFunction() async { - func someOperation(_ value: Int) async -> String { + @Sendable func someOperation(_ value: Int) async -> String { "\(value)" } await expect(1).to(map(someOperation, equal("1"))) @@ -77,8 +77,8 @@ final class MapTest: XCTestCase { let box = Box(int: 3, string: "world") expect(box).to(satisfyAllOf( - map(\.int, equal(3)), - map(\.string, equal("world")) + map( { $0.int }, equal(3)), + map( { $0.string }, equal("world")) )) } diff --git a/Tests/NimbleTests/Matchers/PostNotificationTest.swift b/Tests/NimbleTests/Matchers/PostNotificationTest.swift index c92e15caa..cde1b9c31 100644 --- a/Tests/NimbleTests/Matchers/PostNotificationTest.swift +++ b/Tests/NimbleTests/Matchers/PostNotificationTest.swift @@ -19,7 +19,7 @@ final class PostNotificationTest: XCTestCase { func testPassesWhenExpectedNotificationIsPosted() { let testNotification = Notification(name: Notification.Name("Foo"), object: nil) expect { - self.notificationCenter.post(testNotification) + self.notificationCenter.post(Notification(name: Notification.Name("Foo"), object: nil)) }.to(postNotifications(equal([testNotification]), from: notificationCenter)) } @@ -29,8 +29,8 @@ final class PostNotificationTest: XCTestCase { let n1 = Notification(name: Notification.Name("Foo"), object: foo) let n2 = Notification(name: Notification.Name("Bar"), object: bar) expect { - self.notificationCenter.post(n1) - self.notificationCenter.post(n2) + self.notificationCenter.post(Notification(name: Notification.Name("Foo"), object: foo)) + self.notificationCenter.post(Notification(name: Notification.Name("Bar"), object: bar)) }.to(postNotifications(equal([n1, n2]), from: notificationCenter)) } @@ -48,17 +48,18 @@ final class PostNotificationTest: XCTestCase { let n2 = Notification(name: Notification.Name(n1.name.rawValue + "a"), object: nil) failsWithErrorMessage("expected to equal <[\(n1)]>, got <[\(n2)]>") { expect { - self.notificationCenter.post(n2) + self.notificationCenter.post(Notification(name: Notification.Name("Fooa"), object: nil)) }.to(postNotifications(equal([n1]), from: self.notificationCenter)) } } func testFailsWhenNotificationWithWrongObjectIsPosted() { let n1 = Notification(name: Notification.Name("Foo"), object: nil) - let n2 = Notification(name: n1.name, object: NSObject()) + let object = NSObject() + let n2 = Notification(name: n1.name, object: object) failsWithErrorMessage("expected to equal <[\(n1)]>, got <[\(n2)]>") { expect { - self.notificationCenter.post(n2) + self.notificationCenter.post(Notification(name: Notification.Name("Foo"), object: object)) }.to(postNotifications(equal([n1]), from: self.notificationCenter)) } } @@ -67,7 +68,7 @@ final class PostNotificationTest: XCTestCase { let testNotification = Notification(name: Notification.Name("Foo"), object: nil) expect { deferToMainQueue { - self.notificationCenter.post(testNotification) + self.notificationCenter.post(Notification(name: Notification.Name("Foo"), object: nil)) } }.toEventually(postNotifications(equal([testNotification]), from: notificationCenter)) } @@ -76,16 +77,15 @@ final class PostNotificationTest: XCTestCase { let n1 = Notification(name: Notification.Name("Foo"), object: nil) failsWithErrorMessage("expected to not equal <[\(n1)]>, got <[\(n1)]>") { expect { - self.notificationCenter.post(n1) + self.notificationCenter.post(Notification(name: Notification.Name("Foo"), object: nil)) }.toNot(postNotifications(equal([n1]), from: self.notificationCenter)) } } func testPassesWhenNotificationIsNotPosted() { let n1 = Notification(name: Notification.Name("Foo"), object: nil) - let n2 = Notification(name: Notification.Name(n1.name.rawValue + "a"), object: nil) expect { - self.notificationCenter.post(n2) + self.notificationCenter.post(Notification(name: Notification.Name("Fooa"), object: nil)) }.toNever(postNotifications(equal([n1]), from: self.notificationCenter)) } @@ -99,7 +99,7 @@ final class PostNotificationTest: XCTestCase { self.notificationCenter.post(n2) }, ], waitUntilFinished: true) - self.notificationCenter.post(n1) + self.notificationCenter.post(Notification(name: Notification.Name("Foo"), object: nil)) }.to(postNotifications(contain([n1]), from: notificationCenter)) } @@ -109,7 +109,7 @@ final class PostNotificationTest: XCTestCase { OperationQueue().addOperations([ BlockOperation { let backgroundThreadObject = BackgroundThreadObject() - let n2 = Notification(name: Notification.Name(n1.name.rawValue + "a"), object: backgroundThreadObject) + let n2 = Notification(name: Notification.Name("Fooa"), object: backgroundThreadObject) self.notificationCenter.post(n2) }, ], waitUntilFinished: true) @@ -122,8 +122,8 @@ final class PostNotificationTest: XCTestCase { let n1 = Notification(name: Notification.Name("Foo"), object: "1") let n2 = Notification(name: Notification.Name("Bar"), object: "2") expect { - center.post(n1) - center.post(n2) + center.post(Notification(name: Notification.Name("Foo"), object: "1")) + center.post(Notification(name: Notification.Name("Bar"), object: "2")) }.toEventually(postDistributedNotifications(equal([n1, n2]), from: center, names: [n1.name, n2.name])) } #endif diff --git a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift index e2563581a..429695864 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift @@ -1,5 +1,5 @@ import XCTest -import Nimble +@testable import Nimble import Foundation #if SWIFT_PACKAGE import NimbleSharedTestHelpers @@ -57,10 +57,12 @@ final class SatisfyAllOfTest: XCTestCase { func testSatisfyAllOfCachesExpressionBeforePassingToMatchers() { // This is not a great example of assertion writing - functions being asserted on in Expressions should not have side effects. // But we should still handle those cases anyway. - var value: Int = 0 - func testFunction() -> Int { - value += 1 - return value + let value = LockedContainer(0) + @Sendable func testFunction() -> Int { + value.operate { + $0 + 1 + } + return value.value } expect(testFunction()).toEventually(satisfyAllOf(equal(1), equal(1))) diff --git a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift index f02c35206..d8afc25e4 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift @@ -1,5 +1,5 @@ import XCTest -import Nimble +@testable import Nimble import Foundation #if SWIFT_PACKAGE import NimbleSharedTestHelpers @@ -55,10 +55,10 @@ final class SatisfyAnyOfTest: XCTestCase { func testSatisfyAllOfCachesExpressionBeforePassingToMatchers() { // This is not a great example of assertion writing - functions being asserted on in Expressions should not have side effects. // But we should still handle those cases anyway. - var value: Int = 0 - func testFunction() -> Int { - value += 1 - return value + let value = LockedContainer(0) + @Sendable func testFunction() -> Int { + value.operate { $0 + 1 } + return value.value } // This demonstrates caching because the first time this is evaluated, the function should return 1, which doesn't pass the `equal(0)`. diff --git a/Tests/NimbleTests/Matchers/ThrowAssertionTest.swift b/Tests/NimbleTests/Matchers/ThrowAssertionTest.swift index 8d547d128..8bffc575e 100644 --- a/Tests/NimbleTests/Matchers/ThrowAssertionTest.swift +++ b/Tests/NimbleTests/Matchers/ThrowAssertionTest.swift @@ -1,6 +1,6 @@ import Foundation import XCTest -import Nimble +@testable import Nimble #if SWIFT_PACKAGE import NimbleSharedTestHelpers #endif @@ -22,27 +22,27 @@ final class ThrowAssertionTest: XCTestCase { func testPostAssertionCodeNotRun() { #if (arch(x86_64) || arch(arm64)) && !os(Windows) - var reachedPoint1 = false - var reachedPoint2 = false + let reachedPoint1 = LockedContainer(false) + let reachedPoint2 = LockedContainer(false) expect { - reachedPoint1 = true + reachedPoint1.set(true) precondition(false, "condition message") - reachedPoint2 = true + reachedPoint2.set(true) }.to(throwAssertion()) - expect(reachedPoint1) == true - expect(reachedPoint2) == false + expect(reachedPoint1.value) == true + expect(reachedPoint2.value) == false #endif } func testNegativeMatch() { #if (arch(x86_64) || arch(arm64)) && !os(Windows) - var reachedPoint1 = false + let reachedPoint1 = LockedContainer(false) - expect { reachedPoint1 = true }.toNot(throwAssertion()) + expect { reachedPoint1.set(true) }.toNot(throwAssertion()) - expect(reachedPoint1) == true + expect(reachedPoint1.value) == true #endif } diff --git a/Tests/NimbleTests/Matchers/ThrowErrorTest.swift b/Tests/NimbleTests/Matchers/ThrowErrorTest.swift index f2523d86d..bbdbb4d4f 100644 --- a/Tests/NimbleTests/Matchers/ThrowErrorTest.swift +++ b/Tests/NimbleTests/Matchers/ThrowErrorTest.swift @@ -128,7 +128,7 @@ final class ThrowErrorTest: XCTestCase { func testNegativeMatchesWithClosure() { let moduleName = "NimbleTests" let innerFailureMessage = "expected to equal , got <\(moduleName).NimbleError>" - let closure = { (error: Error) -> Void in + let closure = { @Sendable (error: Error) -> Void in expect(error._domain).to(equal("foo")) } diff --git a/Tests/NimbleTests/PollingTest+Require.swift b/Tests/NimbleTests/PollingTest+Require.swift index 7bdc48464..ac3edcfc7 100644 --- a/Tests/NimbleTests/PollingTest+Require.swift +++ b/Tests/NimbleTests/PollingTest+Require.swift @@ -6,7 +6,7 @@ import CoreFoundation #endif import Foundation import XCTest -import Nimble +@testable import Nimble #if SWIFT_PACKAGE import NimbleSharedTestHelpers #endif @@ -21,15 +21,15 @@ final class PollingRequireTest: XCTestCase { } func testToEventuallyPositiveMatches() { - var value = 0 - deferToMainQueue { value = 1 } + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } expect { - try require { value }.toEventually(equal(1)) + try require { value.value }.toEventually(equal(1)) }.to(equal(1)) - deferToMainQueue { value = 0 } + deferToMainQueue { value.set(0) } expect { - try require { value }.toEventuallyNot(equal(1)) + try require { value.value }.toEventuallyNot(equal(1)) }.to(equal(0)) } @@ -50,12 +50,10 @@ final class PollingRequireTest: XCTestCase { } func testPollUnwrapPositiveCase() { - var value: Int? = nil - deferToMainQueue { - value = 1 - } + let value = LockedContainer(nil) + deferToMainQueue { value.set(1) } expect { - try pollUnwrap(value) + try pollUnwrap(value.value) }.to(equal(1)) } @@ -83,22 +81,22 @@ final class PollingRequireTest: XCTestCase { PollingDefaults.timeout = .seconds(1) } - var value = 0 + let value = LockedContainer(0) let sleepThenSetValueTo: (Int) -> Void = { newValue in Thread.sleep(forTimeInterval: 1.1) - value = newValue + value.set(newValue) } var asyncOperation: () -> Void = { sleepThenSetValueTo(1) } DispatchQueue.global().async(execute: asyncOperation) - try require { value }.toEventually(equal(1)) + try require { value.value }.toEventually(equal(1)) asyncOperation = { sleepThenSetValueTo(0) } DispatchQueue.global().async(execute: asyncOperation) - try require { value }.toEventuallyNot(equal(1)) + try require { value.value }.toEventuallyNot(equal(1)) } func testToEventuallyAllowsInBackgroundThread() { @@ -120,53 +118,53 @@ final class PollingRequireTest: XCTestCase { #endif } - final class ClassUnderTest { - var deinitCalled: (() -> Void)? - var count = 0 - deinit { deinitCalled?() } + final class ClassUnderTest: Sendable { + let deinitCalled = LockedContainer<(@Sendable () -> Void)?>(nil) + let count = LockedContainer(0) + deinit { deinitCalled.value?() } } func testSubjectUnderTestIsReleasedFromMemory() throws { - var subject: ClassUnderTest? = ClassUnderTest() + let subject = LockedContainer(ClassUnderTest()) - if let sub = subject { - try require(sub.count).toEventually(equal(0), timeout: .milliseconds(100)) - try require(sub.count).toEventuallyNot(equal(1), timeout: .milliseconds(100)) + if let sub = subject.value { + try require(sub.count.value).toEventually(equal(0), timeout: .milliseconds(100)) + try require(sub.count.value).toEventuallyNot(equal(1), timeout: .milliseconds(100)) } waitUntil(timeout: .milliseconds(500)) { done in - subject?.deinitCalled = { + subject.value?.deinitCalled.set({ done() - } + }) - deferToMainQueue { subject = nil } + deferToMainQueue { subject.set(nil) } } } func testToNeverPositiveMatches() throws { - var value = 0 - deferToMainQueue { value = 1 } - try require { value }.toNever(beGreaterThan(1)) + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } + try require { value.value }.toNever(beGreaterThan(1)) - deferToMainQueue { value = 0 } - try require { value }.neverTo(beGreaterThan(1)) + deferToMainQueue { value.set(0) } + try require { value.value }.neverTo(beGreaterThan(1)) } func testToNeverNegativeMatches() { - var value = 0 + let value = LockedContainer(0) failsWithErrorMessage("expected to never equal <0>, got <0>") { - try require { value }.toNever(equal(0)) + try require { value.value }.toNever(equal(0)) } failsWithErrorMessage("expected to never equal <0>, got <0>") { - try require { value }.neverTo(equal(0)) + try require { value.value }.neverTo(equal(0)) } failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - try require { value }.toNever(equal(1)) + deferToMainQueue { value.set(1) } + try require { value.value }.toNever(equal(1)) } failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - try require { value }.neverTo(equal(1)) + deferToMainQueue { value.set(1) } + try require { value.value }.neverTo(equal(1)) } failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { try require { try self.doThrowError() }.toNever(equal(0)) @@ -180,29 +178,29 @@ final class PollingRequireTest: XCTestCase { } func testToAlwaysPositiveMatches() throws { - var value = 1 - deferToMainQueue { value = 2 } - try require { value }.toAlways(beGreaterThan(0)) + let value = LockedContainer(1) + deferToMainQueue { value.set(2) } + try require { value.value }.toAlways(beGreaterThan(0)) - deferToMainQueue { value = 2 } - try require { value }.alwaysTo(beGreaterThan(1)) + deferToMainQueue { value.set(2) } + try require { value.value }.alwaysTo(beGreaterThan(1)) } func testToAlwaysNegativeMatches() { - var value = 1 + let value = LockedContainer(1) failsWithErrorMessage("expected to always equal <0>, got <1>") { - try require { value }.toAlways(equal(0)) + try require { value.value }.toAlways(equal(0)) } failsWithErrorMessage("expected to always equal <0>, got <1>") { - try require { value }.alwaysTo(equal(0)) + try require { value.value }.alwaysTo(equal(0)) } failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - try require { value }.toAlways(equal(1)) + deferToMainQueue { value.set(0) } + try require { value.value }.toAlways(equal(1)) } failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - try require { value }.alwaysTo(equal(1)) + deferToMainQueue { value.set(0) } + try require { value.value }.alwaysTo(equal(1)) } failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { try require { try self.doThrowError() }.toAlways(equal(0)) diff --git a/Tests/NimbleTests/PollingTest.swift b/Tests/NimbleTests/PollingTest.swift index a9d4d72ec..4f0b315f6 100644 --- a/Tests/NimbleTests/PollingTest.swift +++ b/Tests/NimbleTests/PollingTest.swift @@ -6,14 +6,14 @@ import CoreFoundation #endif import Foundation import XCTest -import Nimble +@testable import Nimble #if SWIFT_PACKAGE import NimbleSharedTestHelpers #endif // 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 { @@ -21,12 +21,12 @@ final class PollingTest: XCTestCase { } func testToEventuallyPositiveMatches() { - var value = 0 - deferToMainQueue { value = 1 } - expect { value }.toEventually(equal(1)) + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } + expect { value.value }.toEventually(equal(1)) - deferToMainQueue { value = 0 } - expect { value }.toEventuallyNot(equal(1)) + deferToMainQueue { value.set(0) } + expect { value.value }.toEventuallyNot(equal(1)) } func testToEventuallyNegativeMatches() { @@ -55,22 +55,22 @@ final class PollingTest: XCTestCase { PollingDefaults.timeout = .seconds(1) } - var value = 0 + let value = LockedContainer(0) let sleepThenSetValueTo: (Int) -> Void = { newValue in Thread.sleep(forTimeInterval: 1.1) - value = newValue + value.set(newValue) } var asyncOperation: () -> Void = { sleepThenSetValueTo(1) } DispatchQueue.global().async(execute: asyncOperation) - expect { value }.toEventually(equal(1)) + expect { value.value }.toEventually(equal(1)) asyncOperation = { sleepThenSetValueTo(0) } DispatchQueue.global().async(execute: asyncOperation) - expect { value }.toEventuallyNot(equal(1)) + expect { value.value }.toEventuallyNot(equal(1)) } func testWaitUntilWithCustomDefaultsTimeout() { @@ -102,13 +102,13 @@ final class PollingTest: XCTestCase { } func testWaitUntilTimesOutWhenExceedingItsTime() { - var waiting = true + let waiting = LockedContainer(true) failsWithErrorMessage("Waited more than 0.01 seconds") { waitUntil(timeout: .milliseconds(10)) { done in - let asyncOperation: () -> Void = { + let asyncOperation: @Sendable () -> Void = { Thread.sleep(forTimeInterval: 0.1) done() - waiting = false + waiting.set(false) } DispatchQueue.global().async(execute: asyncOperation) } @@ -117,7 +117,7 @@ final class PollingTest: XCTestCase { // "clear" runloop to ensure this test doesn't poison other tests repeat { RunLoop.main.run(until: Date().addingTimeInterval(0.2)) - } while(waiting) + } while(waiting.value) } func testWaitUntilNegativeMatches() { @@ -202,7 +202,7 @@ final class PollingTest: XCTestCase { for index in 0..<100 { if failed { break } - waitUntil(line: UInt(index)) { done in + waitUntil(location: SourceLocation(column: UInt(index))) { done in DispatchQueue(label: "Nimble.waitUntilTest.\(index)").async { done() } @@ -241,53 +241,53 @@ final class PollingTest: XCTestCase { #endif } - final class ClassUnderTest { - var deinitCalled: (() -> Void)? - var count = 0 - deinit { deinitCalled?() } + final class ClassUnderTest: Sendable { + let deinitCalled = LockedContainer<(@Sendable () -> Void)?>(nil) + let count = 0 + deinit { deinitCalled.value?() } } func testSubjectUnderTestIsReleasedFromMemory() { - var subject: ClassUnderTest? = ClassUnderTest() + let subject = LockedContainer(ClassUnderTest()) - if let sub = subject { + if let sub = subject.value { expect(sub.count).toEventually(equal(0), timeout: .milliseconds(100)) expect(sub.count).toEventuallyNot(equal(1), timeout: .milliseconds(100)) } waitUntil(timeout: .milliseconds(500)) { done in - subject?.deinitCalled = { + subject.value?.deinitCalled.set({ done() - } + }) - deferToMainQueue { subject = nil } + deferToMainQueue { subject.set(nil) } } } func testToNeverPositiveMatches() { - var value = 0 - deferToMainQueue { value = 1 } - expect { value }.toNever(beGreaterThan(1)) + let value = LockedContainer(0) + deferToMainQueue { value.set(1) } + expect { value.value }.toNever(beGreaterThan(1)) - deferToMainQueue { value = 0 } - expect { value }.neverTo(beGreaterThan(1)) + deferToMainQueue { value.set(0) } + expect { value.value }.neverTo(beGreaterThan(1)) } func testToNeverNegativeMatches() { - var value = 0 + let value = LockedContainer(0) failsWithErrorMessage("expected to never equal <0>, got <0>") { - expect { value }.toNever(equal(0)) + expect { value.value }.toNever(equal(0)) } failsWithErrorMessage("expected to never equal <0>, got <0>") { - expect { value }.neverTo(equal(0)) + expect { value.value }.neverTo(equal(0)) } failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - expect { value }.toNever(equal(1)) + deferToMainQueue { value.set(1) } + expect { value.value }.toNever(equal(1)) } failsWithErrorMessage("expected to never equal <1>, got <1>") { - deferToMainQueue { value = 1 } - expect { value }.neverTo(equal(1)) + deferToMainQueue { value.set(1) } + expect { value.value }.neverTo(equal(1)) } failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { expect { try self.doThrowError() }.toNever(equal(0)) @@ -301,29 +301,29 @@ final class PollingTest: XCTestCase { } func testToAlwaysPositiveMatches() { - var value = 1 - deferToMainQueue { value = 2 } - expect { value }.toAlways(beGreaterThan(0)) + let value = LockedContainer(1) + deferToMainQueue { value.set(2) } + expect { value.value }.toAlways(beGreaterThan(0)) - deferToMainQueue { value = 2 } - expect { value }.alwaysTo(beGreaterThan(1)) + deferToMainQueue { value.set(2) } + expect { value.value }.alwaysTo(beGreaterThan(1)) } func testToAlwaysNegativeMatches() { - var value = 1 + let value = LockedContainer(1) failsWithErrorMessage("expected to always equal <0>, got <1>") { - expect { value }.toAlways(equal(0)) + expect { value.value }.toAlways(equal(0)) } failsWithErrorMessage("expected to always equal <0>, got <1>") { - expect { value }.alwaysTo(equal(0)) + expect { value.value }.alwaysTo(equal(0)) } failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - expect { value }.toAlways(equal(1)) + deferToMainQueue { value.set(0) } + expect { value.value }.toAlways(equal(1)) } failsWithErrorMessage("expected to always equal <1>, got <0>") { - deferToMainQueue { value = 0 } - expect { value }.alwaysTo(equal(1)) + deferToMainQueue { value.set(0) } + expect { value.value }.alwaysTo(equal(1)) } failsWithErrorMessage("unexpected error thrown: <\(errorToThrow)>") { expect { try self.doThrowError() }.toAlways(equal(0)) diff --git a/Tests/NimbleTests/SynchronousTest.swift b/Tests/NimbleTests/SynchronousTest.swift index 98171f961..ef3dd2e0f 100644 --- a/Tests/NimbleTests/SynchronousTest.swift +++ b/Tests/NimbleTests/SynchronousTest.swift @@ -37,29 +37,33 @@ final class SynchronousTest: XCTestCase { } func testToProvidesActualValueExpression() { - var value: Int? - expect(1).to(Matcher.simple { expr in value = try expr.evaluate(); return .matches }) - expect(value).to(equal(1)) + let recorder = Recorder() + expect(1).to(Matcher.simple { expr in recorder.record(try expr.evaluate()); return .matches }) + expect(recorder.records).to(equal([1])) } func testToProvidesAMemoizedActualValueExpression() { - var callCount = 0 - expect { callCount += 1 }.to(Matcher.simple { expr in + let recorder = Recorder() + expect { + recorder.record(()) + }.to(Matcher.simple { expr in _ = try expr.evaluate() _ = try expr.evaluate() return .matches }) - expect(callCount).to(equal(1)) + expect(recorder.records).to(haveCount(1)) } func testToProvidesAMemoizedActualValueExpressionIsEvaluatedAtMatcherControl() { - var callCount = 0 - expect { callCount += 1 }.to(Matcher.simple { expr in - expect(callCount).to(equal(0)) + let recorder = Recorder() + expect { + recorder.record(()) + }.to(Matcher.simple { expr in + expect(recorder.records).to(beEmpty()) _ = try expr.evaluate() return .matches }) - expect(callCount).to(equal(1)) + expect(recorder.records).to(haveCount(1)) } func testToMatchAgainstLazyProperties() { @@ -76,29 +80,29 @@ final class SynchronousTest: XCTestCase { } func testToNotProvidesActualValueExpression() { - var value: Int? - expect(1).toNot(Matcher.simple { expr in value = try expr.evaluate(); return .doesNotMatch }) - expect(value).to(equal(1)) + let recorder = Recorder() + expect(1).toNot(Matcher.simple { expr in recorder.record(try expr.evaluate()); return .doesNotMatch }) + expect(recorder.records).to(equal([1])) } func testToNotProvidesAMemoizedActualValueExpression() { - var callCount = 0 - expect { callCount += 1 }.toNot(Matcher.simple { expr in + let recorder = Recorder() + expect { recorder.record(()) }.toNot(Matcher.simple { expr in _ = try expr.evaluate() _ = try expr.evaluate() return .doesNotMatch }) - expect(callCount).to(equal(1)) + expect(recorder.records).to(haveCount(1)) } func testToNotProvidesAMemoizedActualValueExpressionIsEvaluatedAtMatcherControl() { - var callCount = 0 - expect { callCount += 1 }.toNot(Matcher.simple { expr in - expect(callCount).to(equal(0)) + let recorder = Recorder() + expect { recorder.record(()) }.toNot(Matcher.simple { expr in + expect(recorder.records).to(beEmpty()) _ = try expr.evaluate() return .doesNotMatch }) - expect(callCount).to(equal(1)) + expect(recorder.records).to(haveCount(1)) } func testToNegativeMatches() { @@ -129,3 +133,24 @@ final class SynchronousTest: XCTestCase { } } } + +private final class Recorder: @unchecked Sendable { + private var _records: [T] = [] + private let lock = NSRecursiveLock() + + var records: [T] { + get { + lock.lock() + defer { + lock.unlock() + } + return _records + } + } + + func record(_ value: T) { + lock.lock() + self._records.append(value) + lock.unlock() + } +}